import { defineStore } from 'pinia' import { ref, computed } from 'vue' import axios from 'axios' import api from '@/api' import { sampleStation, SAMPLE_REASONS } from '@/data/sample' import { isGrafanaEmbed, grafanaWindow } from '@/embed' import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station' // Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling(). // // Connection loss on this LIVE terminal: never fabricate carts. A failed fetch keeps // the last known REAL payload (stale > fake — an operator must never book against a // demo cart) and flips `disconnected`, so the UI shows a reconnect spinner and polling // retries faster. Bundled sample data renders ONLY under `npm run dev` (no gateway) — // gated behind `import.meta.env.DEV`, so `vite build` strips it from production. const CACHE_KEY = 'temper_rejects_payload' export const useTemperRejectsStore = defineStore('temperRejects', () => { const payload = ref(null) const reasons = ref([]) const window = ref('7T') // Grafana embed: when hosted in a Grafana iframe (?embed=grafana), the native window // picker is hidden and the listed-carts window is driven by Grafana's own time range // (from/to epoch ms) instead of the preset. Read once from the URL — a Grafana time // change reloads the iframe, which re-runs this store setup. const embedded = ref(isGrafanaEmbed()) const grafanaWin = embedded.value ? grafanaWindow() : null const checkedPresses = ref([]) // press filter (default = all) const loading = ref(false) const error = ref(null) const usingSample = ref(false) const disconnected = ref(false) const lastUpdated = ref(null) let timer: ReturnType | null = null let pollMs = 20000 const RETRY_MS = 5000 // faster cadence while disconnected, to recover quickly let filterInitialized = false const hasData = computed(() => payload.value !== null) const allPresses = computed(() => payload.value?.presses ?? []) // entries per press (over ALL entries) — for the sidebar hit-count badges. const pressCounts = computed>(() => { const m: Record = {} for (const p of allPresses.value) m[p] = 0 for (const e of payload.value?.entries ?? []) m[e.press] = (m[e.press] ?? 0) + 1 return m }) const filteredEntries = computed(() => { const checked = new Set(checkedPresses.value) return (payload.value?.entries ?? []).filter((e) => checked.has(e.press)) }) function saveCache() { try { sessionStorage.setItem(CACHE_KEY, JSON.stringify({ payload: payload.value, window: window.value, checkedPresses: checkedPresses.value })) } catch { /* best-effort */ } } function loadCache() { try { const raw = sessionStorage.getItem(CACHE_KEY) if (!raw) return const blob = JSON.parse(raw) as { payload: StationPayload | null; window: WindowCode; checkedPresses: string[] } payload.value = blob.payload ?? null if (blob.window) window.value = blob.window if (blob.checkedPresses?.length) { checkedPresses.value = blob.checkedPresses filterInitialized = true } } catch { /* corrupt cache — ignore */ } } async function hydrate() { if (!hasData.value) loading.value = true await Promise.all([refresh(), fetchReasons()]) } async function refresh() { error.value = null try { // Grafana embed → exact from→to window; standalone → the preset window. const params = grafanaWin ? { from: grafanaWin.from, to: grafanaWin.to } : { window: window.value } const res = await api.get('/overview', { params }) if (!res.data || !Array.isArray(res.data.entries)) { throw new Error('Unexpected /overview response (no backend?)') } payload.value = res.data usingSample.value = res.data.sample === true disconnected.value = false } catch (e) { // Connection lost. Keep the last known REAL data (stale > fabricated): never // replace live carts with demo carts on a live terminal, or an operator could // book against a fabricated entry. Flag `disconnected` so the UI shows a // reconnect spinner (cold start) or a header indicator (stale data) and retries. disconnected.value = true error.value = e instanceof Error ? e.message : 'Overview API unreachable' // Sample data is for `npm run dev` only (no gateway) — `vite build` strips this. if (import.meta.env.DEV && (!payload.value || usingSample.value)) { payload.value = sampleStation(window.value) usingSample.value = true } } finally { // Default the filter to ALL presses on first load (Alle). "Keine" stays a // valid explicit state afterwards, so only auto-fill once. if (!filterInitialized && payload.value) { checkedPresses.value = [...payload.value.presses] filterInitialized = true } loading.value = false lastUpdated.value = Date.now() saveCache() } } async function fetchReasons() { try { const res = await api.get<{ reasons: Reason[] }>('/reasons') if (Array.isArray(res.data?.reasons) && res.data.reasons.length) { reasons.value = res.data.reasons return } throw new Error('no reasons') } catch { // Keep any reasons already loaded; only seed the bundled sample reasons under // `npm run dev` — production must never offer fabricated reason codes. if (import.meta.env.DEV && !reasons.value.length) reasons.value = SAMPLE_REASONS } } async function setWindow(code: WindowCode) { window.value = code await refresh() } // ── press filter ──────────────────────────────────────────────────────────── function togglePress(press: string) { const i = checkedPresses.value.indexOf(press) if (i >= 0) checkedPresses.value.splice(i, 1) else checkedPresses.value.push(press) saveCache() } function selectAllPresses() { checkedPresses.value = [...allPresses.value] saveCache() } function selectNoPresses() { checkedPresses.value = [] saveCache() } /** Book one reject. Returns the gateway's own {success} verdict. NEVER fakes success: * on this live, login-free terminal an unconfirmed write must not read as booked. */ async function submitReject(req: RejectRequest): Promise { try { const res = await api.post('/reject', req) if (res.data && typeof res.data === 'object' && 'success' in res.data) { if (res.data.success) await refresh() return res.data } // Reachable but unexpected shape (e.g. a dev SPA fallback serving index.html) → // treat as NOT booked. return { success: false, error: 'bad_response' } } catch (e) { // The gateway returns HTTP 200 + {success:false} for refusals/write failures, so a // throw here is a genuine transport failure (backend unreachable / timeout) or a // non-2xx carrying a body. Prefer the server's own {success:false}; otherwise report // a transport failure. Either way, do NOT report success for an unconfirmed write. const data = axios.isAxiosError(e) ? (e.response?.data as RejectResult | undefined) : undefined if (data && typeof data === 'object' && 'success' in data) return data return { success: false, error: 'unreachable' } } } // Self-scheduling poll: next tick is the normal cadence when connected, a faster // retry while disconnected so the terminal recovers as soon as the gateway is back. function scheduleNext() { timer = setTimeout(async () => { await refresh() scheduleNext() }, disconnected.value ? RETRY_MS : pollMs) } function startPolling(ms = 20000) { if (timer) return pollMs = ms scheduleNext() } function stopPolling() { if (timer) { clearTimeout(timer) timer = null } } loadCache() return { payload, reasons, window, embedded, checkedPresses, loading, error, usingSample, disconnected, lastUpdated, hasData, allPresses, pressCounts, filteredEntries, hydrate, refresh, fetchReasons, setWindow, togglePress, selectAllPresses, selectNoPresses, submitReject, startPolling, stopPolling, } })