diff --git a/app/api/display-config/route.ts b/app/api/display-config/route.ts index 29b22d5..8697cac 100644 --- a/app/api/display-config/route.ts +++ b/app/api/display-config/route.ts @@ -25,6 +25,12 @@ export async function PATCH(request: Request) { if (typeof body.fullscreenAlertEnabled === "boolean") { patch.fullscreenAlertEnabled = body.fullscreenAlertEnabled; } + if (typeof body.autoScrollPagesEnabled === "boolean") { + patch.autoScrollPagesEnabled = body.autoScrollPagesEnabled; + } + if (typeof body.displayZoom === "number" && body.displayZoom >= 50 && body.displayZoom <= 200) { + patch.displayZoom = Math.round(body.displayZoom); + } const updated = updateConfig(patch); return Response.json(updated); diff --git a/app/api/events/display/route.ts b/app/api/events/display/route.ts index 6655fd8..ca7d24d 100644 --- a/app/api/events/display/route.ts +++ b/app/api/events/display/route.ts @@ -12,13 +12,33 @@ export async function GET(request: Request) { const backendUrl = process.env.API_URL || "http://localhost:3000"; - const response = await fetch(`${backendUrl}/events/display`, { - signal: request.signal, - headers: { - "Accept": "text/event-stream", - "Cookie": token ? `mysagra_token=${token}` : "", - } - }); + const lastEventId = request.headers.get("Last-Event-ID"); + + let response: Response; + try { + response = await fetch(`${backendUrl}/events/display`, { + signal: request.signal, + headers: { + "Accept": "text/event-stream", + "Cookie": token ? `mysagra_token=${token}` : "", + ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}), + } + }); + } catch { + // Return valid SSE stream that closes immediately — EventSource stays CONNECTING and retries + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("retry: 3000\n\n")); + controller.close(); + } + }); + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + } + }); + } if (!response.ok) { return new Response("Error connecting to event stream", { status: response.status }); diff --git a/app/display/page.tsx b/app/display/page.tsx index b3a6010..71bd5cb 100644 --- a/app/display/page.tsx +++ b/app/display/page.tsx @@ -1,11 +1,12 @@ "use client"; import { Header } from "@/components/display/header"; -import { useEffect, useState, useRef, useCallback } from "react"; +import { useEffect, useState, useRef, useCallback, useMemo } from "react"; import { getWorkdayBounds, sortByDate } from "@/utils/utils"; import { type DisplayMode, DISPLAY_MODE_KEY } from "@/components/settings/DisplayModeSettingsCard"; import { EVENT_NAME_KEY } from "@/components/settings/GeneralSettingsCard"; import { NUMBER_DISPLAY_KEY, TICKET_NUMBER_MAX_KEY } from "@/components/settings/NumberDisplaySettingsCard"; +import { DISPLAY_ZOOM_KEY } from "@/components/settings/DisplayZoomSettingsCard"; import type { NumberDisplay } from "@/lib/display-config-store"; import { useTranslation } from "react-i18next"; @@ -119,9 +120,11 @@ interface DisplaySectionProps { immediateRemoval?: boolean; getOrderLabel: (order: ReadyOrder) => string; bare?: boolean; + autoScrollEnabled?: boolean; + displayZoom?: number; } -function DisplaySection({ orders, cols, rows, title, headerClass, cardBgClass, sectionId, immediateRemoval = false, getOrderLabel, bare = false }: DisplaySectionProps) { +function DisplaySection({ orders, cols, rows, title, headerClass, cardBgClass, sectionId, immediateRemoval = false, getOrderLabel, bare = false, autoScrollEnabled = true, displayZoom = 100 }: DisplaySectionProps) { const staticCardsPerPage = cols * rows; const [effectiveCardsPerPage, setEffectiveCardsPerPage] = useState(staticCardsPerPage); const cardsPerPage = effectiveCardsPerPage; @@ -141,8 +144,8 @@ function DisplaySection({ orders, cols, rows, title, headerClass, cardBgClass, s const { width, height } = el.getBoundingClientRect(); if (width < 10 || height < 10) return; const gap = 12; // gap-3 = 12px - const minColW = 100; // minmax(100px, 1fr) - const minRowH = 70; // minmax(70px, 1fr) + const minColW = 100 * displayZoom / 100; // minmax(100px, 1fr) scaled by zoom + const minRowH = 70 * displayZoom / 100; // minmax(70px, 1fr) scaled by zoom const c = Math.max(1, Math.floor((width + gap) / (minColW + gap))); const r = Math.max(1, Math.floor((height + gap) / (minRowH + gap))); setEffectiveCardsPerPage(c * r); @@ -151,7 +154,7 @@ function DisplaySection({ orders, cols, rows, title, headerClass, cardBgClass, s const ro = new ResizeObserver(compute); ro.observe(el); return () => ro.disconnect(); - }, []); + }, [displayZoom]); const realOrderCount = displayedOrders.filter((o): o is ReadyOrder => o !== null).length; const totalPages = Math.max(1, Math.ceil(realOrderCount / cardsPerPage)); @@ -177,6 +180,7 @@ function DisplaySection({ orders, cols, rows, title, headerClass, cardBgClass, s }, [orders, cardsPerPage, immediateRemoval]); useEffect(() => { + if (!autoScrollEnabled) { setCurrentPage(0); return; } if (totalPages <= 1) { setCurrentPage(0); return; } const timer = setTimeout(() => { setCurrentPage(prev => { @@ -187,7 +191,7 @@ function DisplaySection({ orders, cols, rows, title, headerClass, cardBgClass, s }, PAGE_INTERVAL); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentPage, totalPages]); + }, [currentPage, totalPages, autoScrollEnabled]); const pageOrders = displayedOrders.slice(currentPage * cardsPerPage, (currentPage + 1) * cardsPerPage); @@ -195,7 +199,7 @@ function DisplaySection({ orders, cols, rows, title, headerClass, cardBgClass, s <>

{title}

- {totalPages > 1 && ( + {autoScrollEnabled && totalPages > 1 && (
{currentPage + 1} / @@ -203,13 +207,15 @@ function DisplaySection({ orders, cols, rows, title, headerClass, cardBgClass, s
)}
-
- {totalPages > 1 && ( -
- )} -
+ {autoScrollEnabled && ( +
+ {totalPages > 1 && ( +
+ )} +
+ )}
-
+
{pageOrders.map((order, idx) => order ? ( ) : ( @@ -243,6 +249,8 @@ interface SplitDisplaySectionProps { topRows: number; bottomRows: number; getOrderLabel: (order: ReadyOrder) => string; + autoScrollEnabled?: boolean; + displayZoom?: number; } function SplitDisplaySection({ @@ -252,6 +260,8 @@ function SplitDisplaySection({ topHeaderClass, bottomHeaderClass, topCardBgClass, bottomCardBgClass, sectionId, cols, topRows, bottomRows, getOrderLabel, + autoScrollEnabled = true, + displayZoom = 100, }: SplitDisplaySectionProps) { return (
@@ -272,6 +282,8 @@ function SplitDisplaySection({ immediateRemoval bare getOrderLabel={getOrderLabel} + autoScrollEnabled={autoScrollEnabled} + displayZoom={displayZoom} />
{/* Bottom 2/3: ready */} @@ -286,6 +298,8 @@ function SplitDisplaySection({ sectionId={`${sectionId}-bottom`} bare getOrderLabel={getOrderLabel} + autoScrollEnabled={true} + displayZoom={displayZoom} />
@@ -302,11 +316,9 @@ export default function Display() { const [numberDisplay, setNumberDisplay] = useState("displayCode"); const [ticketNumberMax, setTicketNumberMax] = useState(100); const [stationsEnabled, setStationsEnabled] = useState(false); + const [autoScrollPagesEnabled, setAutoScrollPagesEnabled] = useState(true); const [stations, setStations] = useState([]); - - // Per-station order maps (stations mode) - const [stationConfirmed, setStationConfirmed] = useState>({}); - const [stationCompleted, setStationCompleted] = useState>({}); + const [displayZoom, setDisplayZoom] = useState(100); const TITLE_MAP: Record = { ready: t("display.ordersReady"), @@ -314,9 +326,50 @@ export default function Display() { hybrid: t("display.orders"), }; - // Normal mode order lists - const [readyOrders, setReadyOrders] = useState([]); - const [prepOrders, setPrepOrders] = useState([]); + // Single source of truth: all orders keyed by id + const [ordersMap, setOrdersMap] = useState>(new Map()); + + // Mirror ref for SSE alert handlers (read-only access without stale closure) + const ordersMapRef = useRef>(new Map()); + useEffect(() => { ordersMapRef.current = ordersMap; }, [ordersMap]); + + // --- Derived lists: normal mode (pre-sorted) --- + const readyOrders = useMemo(() => + Array.from(ordersMap.values()) + .filter(o => (o.orderStationStates ?? []).length > 0 && o.status === 'COMPLETED') + .sort((a, b) => a.ticketNumber - b.ticketNumber), + [ordersMap] + ); + + const prepOrders = useMemo(() => + Array.from(ordersMap.values()) + .filter(o => (o.orderStationStates ?? []).length > 0 && (o.status === 'CONFIRMED' || o.status === 'PARTIAL')) + .sort((a, b) => a.ticketNumber - b.ticketNumber), + [ordersMap] + ); + + // --- Derived maps: station mode --- + const stationConfirmed = useMemo(() => { + const map: Record = {}; + for (const s of stations) map[s.id] = []; + for (const o of ordersMap.values()) + for (const state of o.orderStationStates ?? []) + if (state.status === 'CONFIRMED' && state.stationId in map && + !map[state.stationId].find(x => x.id === o.id)) + map[state.stationId].push(o); + return map; + }, [ordersMap, stations]); + + const stationCompleted = useMemo(() => { + const map: Record = {}; + for (const s of stations) map[s.id] = []; + for (const o of ordersMap.values()) + for (const state of o.orderStationStates ?? []) + if (state.status === 'COMPLETED' && state.stationId in map && + !map[state.stationId].find(x => x.id === o.id)) + map[state.stationId].push(o); + return map; + }, [ordersMap, stations]); // Single-mode pagination const [currentPage, setCurrentPage] = useState(0); @@ -328,13 +381,8 @@ export default function Display() { const [eventName, setEventName] = useState(""); const stationsEnabledRef = useRef(false); - - // Refs to current station maps so SSE closures can look up orders by id - const stationConfirmedRef = useRef>({}); - const stationCompletedRef = useRef>({}); - const pickedUpOrdersRef = useRef>({}); - useEffect(() => { stationConfirmedRef.current = stationConfirmed; }, [stationConfirmed]); - useEffect(() => { stationCompletedRef.current = stationCompleted; }, [stationCompleted]); + const autoScrollPagesEnabledRef = useRef(true); + useEffect(() => { autoScrollPagesEnabledRef.current = autoScrollPagesEnabled; }, [autoScrollPagesEnabled]); // Full-screen overlay const [fullscreenAlertEnabled, setFullscreenAlertEnabled] = useState(true); @@ -360,6 +408,35 @@ export default function Display() { const totalPages = Math.max(1, Math.ceil(displayedOrders.length / CARDS_PER_PAGE)); const pageOrders = displayedOrders.slice(currentPage * CARDS_PER_PAGE, (currentPage + 1) * CARDS_PER_PAGE); + // ------------------------------------------------------------------ + // Fetch initial orders + // ------------------------------------------------------------------ + const fetchOrders = useCallback(async () => { + try { + const { dateFrom, dateTo } = getWorkdayBounds(); + const dateParams = `&dateFrom=${encodeURIComponent(dateFrom)}&dateTo=${encodeURIComponent(dateTo)}`; + const res = await fetch(`/api/orders?limit=100${dateParams}&include=ordersStationsStates`); + if (!res.ok) return; + const json = await res.json(); + const orders: Order[] = json.data || json.orders || (Array.isArray(json) ? json : []); + if (!Array.isArray(orders)) return; + + const toRO = (o: Order): ReadyOrder => ({ + id: o.id, + ticketNumber: o.ticketNumber, + displayCode: o.displayCode, + status: o.status, + ordersStations: o.ordersStations, + orderStationStates: o.orderStationStates, + }); + + const filtered = orders.filter(o => (o.orderStationStates ?? []).length > 0); + setOrdersMap(new Map(filtered.map(o => [o.id, toRO(o)]))); + } catch (err) { + console.error("Failed to fetch orders:", err); + } + }, []); + // ------------------------------------------------------------------ // Fetch display config // ------------------------------------------------------------------ @@ -386,6 +463,8 @@ export default function Display() { if (cfg.numberDisplay && ["displayCode", "ticketNumber"].includes(cfg.numberDisplay)) setNumberDisplay(cfg.numberDisplay as NumberDisplay); if (typeof cfg.ticketNumberMax === "number" && cfg.ticketNumberMax >= 0) setTicketNumberMax(cfg.ticketNumberMax); if (typeof cfg.fullscreenAlertEnabled === "boolean") { fullscreenAlertEnabledRef.current = cfg.fullscreenAlertEnabled; setFullscreenAlertEnabled(cfg.fullscreenAlertEnabled); } + if (typeof cfg.autoScrollPagesEnabled === "boolean") { autoScrollPagesEnabledRef.current = cfg.autoScrollPagesEnabled; setAutoScrollPagesEnabled(cfg.autoScrollPagesEnabled); } + if (typeof cfg.displayZoom === "number" && cfg.displayZoom >= 50 && cfg.displayZoom <= 200) { setDisplayZoom(cfg.displayZoom); localStorage.setItem(DISPLAY_ZOOM_KEY, String(cfg.displayZoom)); } if (cfg.stationsEnabled) { stationsEnabledRef.current = true; setStationsEnabled(true); @@ -394,10 +473,6 @@ export default function Display() { .then(data => { if (Array.isArray(data)) { setStations(data); - const empty: Record = {}; - for (const s of data) empty[s.id] = []; - setStationConfirmed({ ...empty }); - setStationCompleted({ ...empty }); fetchOrders(); } }) @@ -415,6 +490,8 @@ export default function Display() { if (storedND && ["displayCode", "ticketNumber"].includes(storedND)) setNumberDisplay(storedND); const storedMax = localStorage.getItem(TICKET_NUMBER_MAX_KEY); if (storedMax) { const n = parseInt(storedMax, 10); if (!isNaN(n) && n >= 1) setTicketNumberMax(n); } + const storedZoom = localStorage.getItem(DISPLAY_ZOOM_KEY); + if (storedZoom) { const z = parseInt(storedZoom, 10); if (!isNaN(z) && z >= 50 && z <= 200) setDisplayZoom(z); } fetchOrders(); }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -434,31 +511,23 @@ export default function Display() { if (cfg.numberDisplay && ["displayCode", "ticketNumber"].includes(cfg.numberDisplay)) setNumberDisplay(cfg.numberDisplay as NumberDisplay); if (typeof cfg.ticketNumberMax === "number" && cfg.ticketNumberMax >= 0) setTicketNumberMax(cfg.ticketNumberMax); if (typeof cfg.fullscreenAlertEnabled === "boolean") { fullscreenAlertEnabledRef.current = cfg.fullscreenAlertEnabled; setFullscreenAlertEnabled(cfg.fullscreenAlertEnabled); } + if (typeof cfg.autoScrollPagesEnabled === "boolean") { autoScrollPagesEnabledRef.current = cfg.autoScrollPagesEnabled; setAutoScrollPagesEnabled(cfg.autoScrollPagesEnabled); } + if (typeof cfg.displayZoom === "number" && cfg.displayZoom >= 50 && cfg.displayZoom <= 200) { setDisplayZoom(cfg.displayZoom); localStorage.setItem(DISPLAY_ZOOM_KEY, String(cfg.displayZoom)); } if (typeof cfg.stationsEnabled === "boolean") { stationsEnabledRef.current = cfg.stationsEnabled; setStationsEnabled(cfg.stationsEnabled); if (cfg.stationsEnabled) { - setReadyOrders([]); - setPrepOrders([]); fetch("/api/stations") .then(r => r.ok ? r.json() : null) .then(data => { if (Array.isArray(data)) { setStations(data); - setStationConfirmed({}); - setStationCompleted({}); - pickedUpOrdersRef.current = {}; fetchOrders(); } }) .catch(console.error); } else { setStations([]); - setStationConfirmed({}); - setStationCompleted({}); - pickedUpOrdersRef.current = {}; - setReadyOrders([]); - setPrepOrders([]); fetchOrders(); } } @@ -468,174 +537,80 @@ export default function Display() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // ------------------------------------------------------------------ - // Fetch initial orders - // ------------------------------------------------------------------ - const fetchOrders = useCallback(async () => { - try { - const { dateFrom, dateTo } = getWorkdayBounds(); - const dateParams = `&dateFrom=${encodeURIComponent(dateFrom)}&dateTo=${encodeURIComponent(dateTo)}`; - const mode = displayModeRef.current; - - // Always fetch with station states included - const res = await fetch(`/api/orders?limit=100${dateParams}&include=ordersStationsStates`); - if (!res.ok) return; - const json = await res.json(); - const orders: Order[] = json.data || json.orders || (Array.isArray(json) ? json : []); - if (!Array.isArray(orders)) return; - - if (stationsEnabledRef.current) { - pickedUpOrdersRef.current = {}; - const confirmedMap: Record = {}; - const completedMap: Record = {}; - for (const o of orders) { - const ro: ReadyOrder = { id: o.id, ticketNumber: o.ticketNumber, displayCode: o.displayCode, status: o.status, ordersStations: o.ordersStations, orderStationStates: o.orderStationStates }; - for (const state of o.orderStationStates ?? []) { - if (state.status === 'CONFIRMED') { - if (!(state.stationId in confirmedMap)) confirmedMap[state.stationId] = []; - if (!confirmedMap[state.stationId].find(x => x.id === o.id)) confirmedMap[state.stationId].push(ro); - } else if (state.status === 'COMPLETED') { - if (!(state.stationId in completedMap)) completedMap[state.stationId] = []; - if (!completedMap[state.stationId].find(x => x.id === o.id)) completedMap[state.stationId].push(ro); - } else if (state.status === 'PICKED_UP') { - // Track in ref so SSE COMPLETED (undo pickup) can find the order - pickedUpOrdersRef.current[String(o.id)] = ro; - } - } - } - setStationConfirmed(confirmedMap); - setStationCompleted(completedMap); - return; - } - - // Normal mode: skip orders with no station assignments - const sorted = orders.filter(o => (o.orderStationStates ?? []).length > 0).sort(sortByDate); - const toRO = (o: Order): ReadyOrder => ({ id: o.id, ticketNumber: o.ticketNumber, displayCode: o.displayCode, status: o.status }); - if (mode === "ready" || mode === "hybrid") setReadyOrders(sorted.filter(o => o.status === 'COMPLETED').map(toRO)); - if (mode === "preparing" || mode === "hybrid") setPrepOrders(sorted.filter(o => o.status === 'CONFIRMED' || o.status === 'PARTIAL').map(toRO)); - } catch (err) { - console.error("Failed to fetch orders:", err); - } - }, []); - // ------------------------------------------------------------------ // SSE — order updates // ------------------------------------------------------------------ useEffect(() => { - fetchOrders(); const es = new EventSource("/api/events/display"); + let isFirstOpen = true; - const removeFromAllStationMaps = (orderId: string) => { - delete pickedUpOrdersRef.current[orderId]; - setStationConfirmed(prev => { - const next = { ...prev }; - for (const k of Object.keys(next)) next[k] = next[k].filter(o => String(o.id) !== orderId); - return next; - }); - setStationCompleted(prev => { - const next = { ...prev }; - for (const k of Object.keys(next)) next[k] = next[k].filter(o => String(o.id) !== orderId); - return next; - }); - }; + // Refetch full state on every reconnect to resync after any missed events + es.addEventListener('open', () => { + if (!isFirstOpen) fetchOrders(); + isFirstOpen = false; + }); es.addEventListener("confirmed-order", (event: MessageEvent) => { - const data = JSON.parse(event.data) as ReadyOrder; - const mode = displayModeRef.current; - - // Ignore orders not assigned to any station (universal check) - if ((data.ordersStations ?? []).length === 0) return; - - if (stationsEnabledRef.current) { - setStationConfirmed(prev => { - const next = { ...prev }; - for (const stId of data.ordersStations!) { - const existing = next[stId] ?? []; - if (!existing.find(o => String(o.id) === String(data.id))) next[stId] = [...existing, data]; - } - return next; - }); - if (mode === "preparing" || mode === "hybrid") { - if (fullscreenAlertEnabledRef.current) setFsQueue(q => q.find(o => String(o.id) === String(data.id)) ? q : [...q, data]); - } - return; - } - - if (mode === "preparing" || mode === "hybrid") { - setPrepOrders(prev => prev.find(o => String(o.id) === String(data.id)) ? prev : [...prev, data]); - } - if (mode === "preparing") { - if (fullscreenAlertEnabledRef.current) setFsQueue(q => q.find(o => String(o.id) === String(data.id)) ? q : [...q, data]); + try { + const raw = JSON.parse(event.data) as ReadyOrder; + if ((raw.ordersStations ?? []).length === 0) return; + // Synthesize station states if missing (new confirmed order = all stations CONFIRMED) + const orderStationStates = (raw.orderStationStates ?? []).length > 0 + ? raw.orderStationStates! + : (raw.ordersStations ?? []).map(stId => ({ stationId: stId, status: 'CONFIRMED' })); + setOrdersMap(prev => new Map(prev).set(String(raw.id), { ...raw, orderStationStates })); + } catch (err) { + console.error("Error parsing confirmed-order event:", err); } }); es.addEventListener("order-status-update", (event: MessageEvent) => { - const raw = JSON.parse(event.data); - const sid = String(raw.id); - const mode = displayModeRef.current; - - if (stationsEnabledRef.current) { - // Find order object in refs before clearing state - let order: ReadyOrder | undefined; - for (const stId of Object.keys(stationConfirmedRef.current)) { - order = stationConfirmedRef.current[stId].find(o => String(o.id) === sid); - if (order) break; - } - if (!order) { - for (const stId of Object.keys(stationCompletedRef.current)) { - order = stationCompletedRef.current[stId].find(o => String(o.id) === sid); - if (order) break; - } - } + try { + const raw = JSON.parse(event.data); + const sid = String(raw.id); + const mode = displayModeRef.current; - removeFromAllStationMaps(sid); - - if (raw.status === "CONFIRMED" && order) { - const stIds = Object.keys(stationCompletedRef.current).filter(k => - stationCompletedRef.current[k].some(o => String(o.id) === sid) || - stationConfirmedRef.current[k]?.some(o => String(o.id) === sid) - ); - setStationConfirmed(prev => { - const next = { ...prev }; - for (const stId of stIds) { - if (stId in next && !next[stId].find(o => String(o.id) === sid)) next[stId] = [...next[stId], order!]; + setOrdersMap(prev => { + const existing = prev.get(sid); + if (!existing) return prev; + + let orderStationStates = existing.orderStationStates; + // In station mode, propagate order-level status to station states. + // Handles header undo ops that PATCH order-level (no stationId). + if (stationsEnabledRef.current) { + if (raw.status === 'COMPLETED') { + orderStationStates = (orderStationStates ?? []).map(s => + s.status === 'PICKED_UP' ? { ...s, status: 'COMPLETED' } : s + ); + } else if (raw.status === 'CONFIRMED') { + orderStationStates = (orderStationStates ?? []).map(s => + s.status === 'COMPLETED' ? { ...s, status: 'CONFIRMED' } : s + ); } - return next; - }); - } else if (raw.status === "COMPLETED" && order) { - const stIds = Object.keys(stationConfirmedRef.current).filter(k => - stationConfirmedRef.current[k].some(o => String(o.id) === sid) - ); - setStationCompleted(prev => { - const next = { ...prev }; - for (const stId of stIds) { - if (stId in next && !next[stId].find(o => String(o.id) === sid)) next[stId] = [...next[stId], order!]; - } - return next; - }); - if (mode === "ready" || mode === "hybrid") { - if (order && fullscreenAlertEnabledRef.current) setFsQueue(q => q.find(o => String(o.id) === sid) ? q : [...q, order!]); } - } - // PICKED_UP: removeFromAllStationMaps already handled above - return; - } - const data = raw as ReadyOrder; - if (mode === "ready" || mode === "hybrid") { - if (data.status === "COMPLETED") { - setReadyOrders(prev => prev.find(o => String(o.id) === sid) ? prev : [...prev, data]); - if (fullscreenAlertEnabledRef.current) setFsQueue(q => q.find(o => String(o.id) === sid) ? q : [...q, data]); - } else { - setReadyOrders(prev => prev.filter(o => String(o.id) !== sid)); - } - } - if (mode === "preparing" || mode === "hybrid") { - if (data.status === "CONFIRMED" || data.status === "PARTIAL") { - setPrepOrders(prev => prev.find(o => String(o.id) === sid) ? prev : [...prev, data]); - } else { - setPrepOrders(prev => prev.filter(o => String(o.id) !== sid)); + return new Map(prev).set(sid, { + ...existing, + status: raw.status, + displayCode: raw.displayCode, + ticketNumber: raw.ticketNumber, + orderStationStates, + }); + }); + + // Alert: order reached COMPLETED in normal mode + if (!stationsEnabledRef.current && raw.status === 'COMPLETED' && + (mode === 'ready' || mode === 'hybrid') && fullscreenAlertEnabledRef.current) { + const alertOrder: ReadyOrder = { + id: sid, + status: raw.status, + displayCode: raw.displayCode, + ticketNumber: raw.ticketNumber, + }; + setFsQueue(q => q.find(o => o.id === sid) ? q : [...q, alertOrder]); } + } catch (err) { + console.error("Error parsing order-status-update event:", err); } }); @@ -646,27 +621,21 @@ export default function Display() { const sid = String(orderId); const mode = displayModeRef.current; - if (status === "COMPLETED") { - const order = stationConfirmedRef.current[stationId]?.find(o => String(o.id) === sid) - ?? pickedUpOrdersRef.current[sid]; - setStationConfirmed(prev => ({ ...prev, [stationId]: (prev[stationId] ?? []).filter(o => String(o.id) !== sid) })); - if (order) { - // Don't delete from pickedUpOrdersRef — other stations may still need it - setStationCompleted(prev => prev[stationId]?.find(o => String(o.id) === sid) ? prev : { ...prev, [stationId]: [...(prev[stationId] ?? []), order] }); - if ((mode === "ready" || mode === "hybrid") && fullscreenAlertEnabledRef.current) setFsQueue(q => q.find(o => String(o.id) === sid) ? q : [...q, { ...order, _alertStationId: stationId }]); - } - } else if (status === "CONFIRMED") { - const order = stationCompletedRef.current[stationId]?.find(o => String(o.id) === sid) - ?? stationConfirmedRef.current[stationId]?.find(o => String(o.id) === sid) - ?? pickedUpOrdersRef.current[sid]; - setStationCompleted(prev => ({ ...prev, [stationId]: (prev[stationId] ?? []).filter(o => String(o.id) !== sid) })); - if (order) setStationConfirmed(prev => prev[stationId]?.find(o => String(o.id) === sid) ? prev : { ...prev, [stationId]: [...(prev[stationId] ?? []), order] }); - } else if (status === "PICKED_UP") { - const order = stationCompletedRef.current[stationId]?.find(o => String(o.id) === sid) - ?? stationConfirmedRef.current[stationId]?.find(o => String(o.id) === sid); - if (order) pickedUpOrdersRef.current[sid] = order; - setStationConfirmed(prev => ({ ...prev, [stationId]: (prev[stationId] ?? []).filter(o => String(o.id) !== sid) })); - setStationCompleted(prev => ({ ...prev, [stationId]: (prev[stationId] ?? []).filter(o => String(o.id) !== sid) })); + setOrdersMap(prev => { + const order = prev.get(sid); + if (!order) return prev; + const existing = order.orderStationStates ?? []; + const hasState = existing.some(s => s.stationId === stationId); + const updatedStates = hasState + ? existing.map(s => s.stationId === stationId ? { ...s, status } : s) + : [...existing, { stationId, status }]; + return new Map(prev).set(sid, { ...order, orderStationStates: updatedStates }); + }); + + // Alert: station completed — read from ref snapshot (outside setOrdersMap) + if (status === 'COMPLETED' && (mode === 'ready' || mode === 'hybrid') && fullscreenAlertEnabledRef.current) { + const order = ordersMapRef.current.get(sid); + if (order) setFsQueue(q => q.find(o => o.id === sid) ? q : [...q, { ...order, _alertStationId: stationId }]); } } catch (err) { console.error("Error parsing order-station-status-update event:", err); @@ -674,14 +643,13 @@ export default function Display() { }); es.addEventListener("order-cancelled", (event: MessageEvent) => { - const data = JSON.parse(event.data) as ReadyOrder; - const sid = String(data.id); - if (stationsEnabledRef.current) { - removeFromAllStationMaps(sid); - return; + try { + const data = JSON.parse(event.data); + const sid = String(data.id); + setOrdersMap(prev => { const next = new Map(prev); next.delete(sid); return next; }); + } catch (err) { + console.error("Error parsing order-cancelled event:", err); } - setReadyOrders(prev => prev.filter(o => String(o.id) !== sid)); - setPrepOrders(prev => prev.filter(o => String(o.id) !== sid)); }); return () => es.close(); @@ -725,18 +693,20 @@ export default function Display() { // ------------------------------------------------------------------ useEffect(() => { if (displayMode === "hybrid" || stationsEnabled) return; - setDisplayedOrders(activeOrders); + const ordered = [...activeOrders].sort((a, b) => a.ticketNumber - b.ticketNumber); + setDisplayedOrders(ordered); setCurrentPage(0); }, [displayMode]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (displayMode === "hybrid" || stationsEnabled) return; + const orderedActive = [...activeOrders].sort((a, b) => a.ticketNumber - b.ticketNumber); setDisplayedOrders(prev => { const prevItems = prev.filter((o): o is ReadyOrder => o !== null); - if (prevItems.length <= CARDS_PER_PAGE) return activeOrders; - const currentIds = new Set(activeOrders.map(o => o.id)); + if (prevItems.length <= CARDS_PER_PAGE) return orderedActive; + const currentIds = new Set(orderedActive.map(o => o.id)); const prevIds = new Set(prevItems.map(o => o.id)); - const newOrders = activeOrders.filter(o => !prevIds.has(o.id)); + const newOrders = orderedActive.filter(o => !prevIds.has(o.id)); const base = prev.map(o => (o === null || currentIds.has(o.id)) ? o : null); if (newOrders.length === 0 && base.every((o, i) => o === prev[i])) return prev; return [...base, ...newOrders]; @@ -746,6 +716,7 @@ export default function Display() { useEffect(() => { if (displayMode === "hybrid" || stationsEnabled) return; + if (!autoScrollPagesEnabled) { setCurrentPage(0); return; } if (totalPages <= 1) { setCurrentPage(0); return; } const timer = setTimeout(() => { setCurrentPage(prev => { @@ -756,7 +727,7 @@ export default function Display() { }, PAGE_INTERVAL); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentPage, totalPages, displayMode]); + }, [currentPage, totalPages, displayMode, autoScrollPagesEnabled]); // ------------------------------------------------------------------ // Derived: effective mode when stations active @@ -785,65 +756,81 @@ export default function Display() { {/* STATIONS — HYBRID (≤3 stations): one card per station split top=prep/bottom=ready */} {effectiveHybrid ? (
- {stations.map((station, idx) => ( -
- -
- ))} + {stations.map((station, idx) => { + const sortedTop = [...(stationConfirmed[station.id] ?? [])].sort((a, b) => a.ticketNumber - b.ticketNumber); + const sortedBottom = [...(stationCompleted[station.id] ?? [])].sort((a, b) => a.ticketNumber - b.ticketNumber); + return ( +
+ +
+ ); + })}
/* STATIONS — PREPARING (or hybrid degraded with >3 stations) */ ) : effectivePreparing ? (
- {stations.map((station, idx) => ( -
- -
- ))} + {stations.map((station, idx) => { + const sorted = [...(stationConfirmed[station.id] ?? [])].sort((a, b) => a.ticketNumber - b.ticketNumber); + return ( +
+ +
+ ); + })}
/* STATIONS — READY */ ) : effectiveReady ? (
- {stations.map((station, idx) => ( -
- -
- ))} + {stations.map((station, idx) => { + const sorted = [...(stationCompleted[station.id] ?? [])].sort((a, b) => a.ticketNumber - b.ticketNumber); + return ( +
+ +
+ ); + })}
/* NORMAL — HYBRID */ @@ -860,6 +847,8 @@ export default function Display() { sectionId="prep" immediateRemoval getOrderLabel={getOrderLabel} + autoScrollEnabled={autoScrollPagesEnabled} + displayZoom={displayZoom} />
@@ -872,6 +861,8 @@ export default function Display() { cardBgClass="bg-green-100" sectionId="ready" getOrderLabel={getOrderLabel} + autoScrollEnabled={true} + displayZoom={displayZoom} />
@@ -879,7 +870,7 @@ export default function Display() { /* NORMAL — SINGLE MODE */ ) : (
-
+
{pageOrders.map((order, idx) => order ? ( ) : ( diff --git a/app/manager/page.tsx b/app/manager/page.tsx index 1ac2438..e1bfccd 100644 --- a/app/manager/page.tsx +++ b/app/manager/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { Header } from "@/components/manager/header"; import OrdersGrid from "@/components/manager/orders-grid"; import { PickedUpOrdersSheet } from "@/components/manager/picked-up-orders-sheet"; @@ -31,30 +31,80 @@ const toOrder = (o: Order): Order => ({ export default function Manager() { const { t } = useTranslation(); - // --- Normal mode state --- - const [confirmedOrders, setConfirmedOrders] = useState([]); - const [readyOrders, setReadyOrders] = useState([]); - const [pickedUpOrders, setPickedUpOrders] = useState([]); + // Single source of truth: all orders keyed by id + const [ordersMap, setOrdersMap] = useState>(new Map()); - // --- Stations mode state --- + // Station config const [stations, setStations] = useState([]); const [stationsEnabled, setStationsEnabled] = useState(false); const stationsEnabledRef = useRef(false); - const [stationConfirmed, setStationConfirmed] = useState>({}); - const [stationCompleted, setStationCompleted] = useState>({}); - const [stationPickedUp, setStationPickedUp] = useState>({}); - - // Keeps a ref to stations list so SSE closures can access it - const stationsRef = useRef([]); - useEffect(() => { stationsRef.current = stations; }, [stations]); - - // Refs to current station maps so SSE closures can look up orders by id - const stationConfirmedRef = useRef>({}); - const stationCompletedRef = useRef>({}); - const pickedUpOrdersRef = useRef([]); - useEffect(() => { stationConfirmedRef.current = stationConfirmed; }, [stationConfirmed]); - useEffect(() => { stationCompletedRef.current = stationCompleted; }, [stationCompleted]); - useEffect(() => { pickedUpOrdersRef.current = pickedUpOrders; }, [pickedUpOrders]); + + // --- Derived lists: normal mode --- + const confirmedOrders = useMemo(() => + Array.from(ordersMap.values()).filter(o => + (o.orderStationStates ?? []).length > 0 && + (o.status === 'CONFIRMED' || o.status === 'PARTIAL') + ), + [ordersMap] + ); + + const readyOrders = useMemo(() => + Array.from(ordersMap.values()).filter(o => + (o.orderStationStates ?? []).length > 0 && o.status === 'COMPLETED' + ), + [ordersMap] + ); + + const pickedUpOrders = useMemo(() => + Array.from(ordersMap.values()).filter(o => + (o.orderStationStates ?? []).length > 0 && o.status === 'PICKED_UP' + ), + [ordersMap] + ); + + // --- Derived maps: station mode --- + const stationConfirmed = useMemo(() => { + const map: Record = {}; + for (const s of stations) map[s.id] = []; + for (const o of ordersMap.values()) + for (const state of o.orderStationStates ?? []) + if (state.status === 'CONFIRMED' && state.stationId in map && + !map[state.stationId].find(x => x.id === o.id)) + map[state.stationId].push(o); + return map; + }, [ordersMap, stations]); + + const stationCompleted = useMemo(() => { + const map: Record = {}; + for (const s of stations) map[s.id] = []; + for (const o of ordersMap.values()) + for (const state of o.orderStationStates ?? []) + if (state.status === 'COMPLETED' && state.stationId in map && + !map[state.stationId].find(x => x.id === o.id)) + map[state.stationId].push(o); + return map; + }, [ordersMap, stations]); + + const stationPickedUp = useMemo(() => { + const map: Record = {}; + for (const s of stations) map[s.id] = []; + for (const o of ordersMap.values()) + for (const state of o.orderStationStates ?? []) + if (state.status === 'PICKED_UP' && state.stationId in map && + !map[state.stationId].find(x => x.id === o.id)) + map[state.stationId].push(o); + return map; + }, [ordersMap, stations]); + + // Flat deduped picked-up list for the header in station mode + const stationModePickedUpOrders = useMemo(() => { + const seen = new Set(); + const result: Order[] = []; + for (const list of Object.values(stationPickedUp)) + for (const o of list) + if (!seen.has(o.id)) { seen.add(o.id); result.push(o); } + return result; + }, [stationPickedUp]); const fetchAllPages = async (baseParams: string): Promise => { let page = 1; @@ -76,60 +126,8 @@ export default function Manager() { try { const { dateFrom, dateTo } = getWorkdayBounds(); const dateParams = `&dateFrom=${encodeURIComponent(dateFrom)}&dateTo=${encodeURIComponent(dateTo)}`; - - // Always fetch with station states included const orders = await fetchAllPages(`${dateParams}&include=ordersStationsStates`); - - if (stationsEnabledRef.current) { - const stList = stationsRef.current; - const confirmedMap: Record = {}; - const completedMap: Record = {}; - const pickedUpMap: Record = {}; - for (const s of stList) { confirmedMap[s.id] = []; completedMap[s.id] = []; pickedUpMap[s.id] = []; } - - for (const o of orders) { - // Distribute by station-level status (independent of order-level status) - for (const state of o.orderStationStates ?? []) { - const stId = state.stationId; - if (state.status === 'CONFIRMED' && stId in confirmedMap && !confirmedMap[stId].find(x => x.id === o.id)) { - confirmedMap[stId].push(o); - } else if (state.status === 'COMPLETED' && stId in completedMap && !completedMap[stId].find(x => x.id === o.id)) { - completedMap[stId].push(o); - } else if (state.status === 'PICKED_UP' && stId in pickedUpMap && !pickedUpMap[stId].find(x => x.id === o.id)) { - pickedUpMap[stId].push(o); - } - } - } - - // Flatten pickedUpMap → flat pickedUpOrders for header (deduped) - const pickedUpSeen = new Set(); - const pickedUp: Order[] = []; - for (const list of Object.values(pickedUpMap)) { - for (const o of list) { - if (!pickedUpSeen.has(o.id)) { pickedUpSeen.add(o.id); pickedUp.push(o); } - } - } - - setStationConfirmed(confirmedMap); - setStationCompleted(completedMap); - setStationPickedUp(pickedUpMap); - setPickedUpOrders(pickedUp); - return; - } - - // Normal mode: skip orders with no station assignments - const confirmed: Order[] = []; - const ready: Order[] = []; - const pickedUp: Order[] = []; - for (const o of orders) { - if ((o.orderStationStates ?? []).length === 0) continue; - if (o.status === 'CONFIRMED' || o.status === 'PARTIAL') confirmed.push(o); - else if (o.status === 'COMPLETED') ready.push(o); - else if (o.status === 'PICKED_UP') pickedUp.push(o); - } - setConfirmedOrders(confirmed); - setReadyOrders(ready); - setPickedUpOrders(pickedUp); + setOrdersMap(new Map(orders.map(o => [o.id, o]))); } catch (error) { console.error("Failed to fetch orders:", error); } @@ -150,12 +148,7 @@ export default function Manager() { .then(res => res && res.ok ? res.json() : null) .then(data => { if (Array.isArray(data)) { - stationsRef.current = data; setStations(data); - const empty: Record = {}; - for (const s of data) empty[s.id] = []; - setStationConfirmed({ ...empty }); - setStationCompleted({ ...empty }); fetchOrders(); } }) @@ -167,41 +160,23 @@ export default function Manager() { fetchOrders(); const eventSource = new EventSource('/api/events/display'); + let isFirstOpen = true; - const removeFromAllStations = (orderId: string) => { - setStationConfirmed(prev => { - const next = { ...prev }; - for (const k of Object.keys(next)) next[k] = next[k].filter(o => o.id !== orderId); - return next; - }); - setStationCompleted(prev => { - const next = { ...prev }; - for (const k of Object.keys(next)) next[k] = next[k].filter(o => o.id !== orderId); - return next; - }); - }; + // Refetch full state on every reconnect to resync after any missed events + eventSource.addEventListener('open', () => { + if (!isFirstOpen) fetchOrders(); + isFirstOpen = false; + }); const handleConfirmedOrder = (event: MessageEvent) => { try { - const newOrder = toOrder(JSON.parse(event.data)); - // Ignore orders not assigned to any station - if ((newOrder.ordersStations ?? []).length === 0) return; - if (stationsEnabledRef.current) { - setStationConfirmed(prev => { - const next = { ...prev }; - for (const stId of newOrder.ordersStations ?? []) { - if (stId in next && !next[stId].find(o => o.id === newOrder.id)) { - next[stId] = [...next[stId], newOrder]; - } - } - return next; - }); - return; - } - setConfirmedOrders(prev => { - if (prev.find(o => String(o.id) === String(newOrder.id))) return prev; - return [...prev, newOrder]; - }); + const raw = toOrder(JSON.parse(event.data)); + if ((raw.ordersStations ?? []).length === 0) return; + // Synthesize station states from ordersStations if not included in payload + const orderStationStates = (raw.orderStationStates ?? []).length > 0 + ? raw.orderStationStates! + : (raw.ordersStations ?? []).map(stId => ({ stationId: stId, status: 'CONFIRMED' })); + setOrdersMap(prev => new Map(prev).set(raw.id, { ...raw, orderStationStates })); } catch (err) { console.error("Error parsing confirmed-order event:", err); } @@ -212,104 +187,52 @@ export default function Manager() { eventSource.addEventListener('order-status-update', (event: MessageEvent) => { try { - const raw = JSON.parse(event.data); - const { id, status } = raw; - const sid = String(id); - - if (stationsEnabledRef.current) { - // Find order in current maps before clearing (refs still point to current state) - let order: Order | undefined; - for (const stId of Object.keys(stationConfirmedRef.current)) { - order = stationConfirmedRef.current[stId].find(o => String(o.id) === sid); - if (order) break; - } - if (!order) { - for (const stId of Object.keys(stationCompletedRef.current)) { - order = stationCompletedRef.current[stId].find(o => String(o.id) === sid); - if (order) break; + const { id, status, displayCode, ticketNumber } = JSON.parse(event.data); + setOrdersMap(prev => { + const existing = prev.get(String(id)); + if (!existing) return prev; + + let orderStationStates = existing.orderStationStates; + // In station mode, propagate order-level status to station states. + // This handles header undo ops that PATCH order-level (no stationId). + if (stationsEnabledRef.current) { + if (status === 'COMPLETED') { + orderStationStates = (orderStationStates ?? []).map(s => + s.status === 'PICKED_UP' ? { ...s, status: 'COMPLETED' } : s + ); + } else if (status === 'CONFIRMED') { + orderStationStates = (orderStationStates ?? []).map(s => + s.status === 'COMPLETED' ? { ...s, status: 'CONFIRMED' } : s + ); } } - if (!order) { - order = pickedUpOrdersRef.current.find(o => String(o.id) === sid); - } - - removeFromAllStations(sid); - - if (status === 'CONFIRMED' && order) { - // Undo: put back in confirmed for every station the order belongs to - const stIds = Object.keys(stationCompletedRef.current).filter(k => - stationCompletedRef.current[k].some(o => String(o.id) === sid) || - stationConfirmedRef.current[k]?.some(o => String(o.id) === sid) - ); - setStationConfirmed(prev => { - const next = { ...prev }; - for (const stId of stIds) { - if (stId in next && !next[stId].find(o => String(o.id) === sid)) next[stId] = [...next[stId], order!]; - } - return next; - }); - } else if (status === 'COMPLETED' && order) { - const stIdsFromConfirmed = Object.keys(stationConfirmedRef.current).filter(k => - stationConfirmedRef.current[k].some(o => String(o.id) === sid) - ); - // fallback: use order.ordersStations when coming from PICKED_UP - const stIds = stIdsFromConfirmed.length > 0 - ? stIdsFromConfirmed - : (order.ordersStations ?? []).filter(k => k in stationCompletedRef.current); - setPickedUpOrders(prev => prev.filter(o => String(o.id) !== sid)); - setStationCompleted(prev => { - const next = { ...prev }; - for (const stId of stIds) { - if (stId in next && !next[stId].find(o => String(o.id) === sid)) next[stId] = [...next[stId], order!]; - } - return next; - }); - } else if (status === 'PICKED_UP' && order) { - setPickedUpOrders(prev => prev.find(o => String(o.id) === sid) ? prev : [...prev, order!]); - } - return; - } - const updated = toOrder(raw); - setConfirmedOrders(prev => prev.filter(o => String(o.id) !== sid)); - setReadyOrders(prev => prev.filter(o => String(o.id) !== sid)); - setPickedUpOrders(prev => prev.filter(o => String(o.id) !== sid)); - if (updated.status === 'CONFIRMED' || updated.status === 'PARTIAL') setConfirmedOrders(prev => [...prev, updated]); - if (updated.status === 'COMPLETED') setReadyOrders(prev => [...prev, updated]); - if (updated.status === 'PICKED_UP') setPickedUpOrders(prev => [...prev, updated]); + return new Map(prev).set(String(id), { + ...existing, + status, + displayCode, + ticketNumber, + orderStationStates, + }); + }); } catch (err) { console.error("Error parsing order-status-update event:", err); } }); eventSource.addEventListener('order-station-status-update', (event: MessageEvent) => { - if (!stationsEnabledRef.current) return; try { const { orderId, stationId, status } = JSON.parse(event.data); - const sid = String(orderId); - - if (status === 'COMPLETED') { - const order = stationConfirmedRef.current[stationId]?.find(o => String(o.id) === sid) - ?? pickedUpOrdersRef.current.find(o => String(o.id) === sid); - setStationConfirmed(prev => ({ ...prev, [stationId]: (prev[stationId] ?? []).filter(o => String(o.id) !== sid) })); - setStationPickedUp(prev => ({ ...prev, [stationId]: (prev[stationId] ?? []).filter(o => String(o.id) !== sid) })); - setPickedUpOrders(prev => prev.filter(o => String(o.id) !== sid)); - if (order) setStationCompleted(prev => prev[stationId]?.find(o => String(o.id) === sid) ? prev : { ...prev, [stationId]: [...(prev[stationId] ?? []), order] }); - } else if (status === 'CONFIRMED') { - const order = stationCompletedRef.current[stationId]?.find(o => String(o.id) === sid) - ?? stationConfirmedRef.current[stationId]?.find(o => String(o.id) === sid); - setStationCompleted(prev => ({ ...prev, [stationId]: (prev[stationId] ?? []).filter(o => String(o.id) !== sid) })); - if (order) setStationConfirmed(prev => prev[stationId]?.find(o => String(o.id) === sid) ? prev : { ...prev, [stationId]: [...(prev[stationId] ?? []), order] }); - } else if (status === 'PICKED_UP') { - const order = stationCompletedRef.current[stationId]?.find(o => String(o.id) === sid) - ?? stationConfirmedRef.current[stationId]?.find(o => String(o.id) === sid); - setStationConfirmed(prev => ({ ...prev, [stationId]: (prev[stationId] ?? []).filter(o => String(o.id) !== sid) })); - setStationCompleted(prev => ({ ...prev, [stationId]: (prev[stationId] ?? []).filter(o => String(o.id) !== sid) })); - if (order) { - setStationPickedUp(prev => prev[stationId]?.find(o => String(o.id) === sid) ? prev : { ...prev, [stationId]: [...(prev[stationId] ?? []), order] }); - setPickedUpOrders(prev => prev.find(o => String(o.id) === sid) ? prev : [...prev, order]); - } - } + setOrdersMap(prev => { + const order = prev.get(String(orderId)); + if (!order) return prev; + const existing = order.orderStationStates ?? []; + const hasState = existing.some(s => s.stationId === stationId); + const updatedStates = hasState + ? existing.map(s => s.stationId === stationId ? { ...s, status } : s) + : [...existing, { stationId, status }]; + return new Map(prev).set(order.id, { ...order, orderStationStates: updatedStates }); + }); } catch (err) { console.error("Error parsing order-station-status-update event:", err); } @@ -319,14 +242,7 @@ export default function Manager() { try { const cancelled = JSON.parse(event.data); const sid = String(cancelled.id); - if (stationsEnabledRef.current) { - removeFromAllStations(sid); - setPickedUpOrders(prev => prev.filter(o => String(o.id) !== sid)); - } else { - setConfirmedOrders(prev => prev.filter(o => String(o.id) !== sid)); - setReadyOrders(prev => prev.filter(o => String(o.id) !== sid)); - setPickedUpOrders(prev => prev.filter(o => String(o.id) !== sid)); - } + setOrdersMap(prev => { const next = new Map(prev); next.delete(sid); return next; }); toast.warning(t("manager.orderCancelled", { code: cancelled.displayCode })); } catch (err) { console.error("Error parsing order-cancelled event:", err); @@ -354,61 +270,85 @@ export default function Manager() { // --- Station mode handlers --- const handleStationMarkDone = (order: Order, stationId: string) => { - setStationConfirmed(prev => ({ ...prev, [stationId]: prev[stationId]?.filter(o => o.id !== order.id) ?? [] })); - setStationCompleted(prev => ({ ...prev, [stationId]: [...(prev[stationId] ?? []), order] })); + setOrdersMap(prev => { + const current = prev.get(order.id) ?? order; + const updatedStates = (current.orderStationStates ?? []).map(s => + s.stationId === stationId ? { ...s, status: 'COMPLETED' } : s + ); + return new Map(prev).set(order.id, { ...current, orderStationStates: updatedStates }); + }); updateOrderStatus(order.id, 'COMPLETED', stationId); }; const handleStationMarkUndo = (order: Order, stationId: string) => { - setStationCompleted(prev => ({ ...prev, [stationId]: prev[stationId]?.filter(o => o.id !== order.id) ?? [] })); - setStationConfirmed(prev => ({ ...prev, [stationId]: [...(prev[stationId] ?? []), order] })); + setOrdersMap(prev => { + const current = prev.get(order.id) ?? order; + const updatedStates = (current.orderStationStates ?? []).map(s => + s.stationId === stationId ? { ...s, status: 'CONFIRMED' } : s + ); + return new Map(prev).set(order.id, { ...current, orderStationStates: updatedStates }); + }); updateOrderStatus(order.id, 'CONFIRMED', stationId); }; const handleStationMarkPickup = (order: Order, stationId: string) => { - setStationCompleted(prev => ({ ...prev, [stationId]: prev[stationId]?.filter(o => o.id !== order.id) ?? [] })); - setStationPickedUp(prev => ({ - ...prev, - [stationId]: prev[stationId]?.find(o => o.id === order.id) ? prev[stationId] : [...(prev[stationId] ?? []), order], - })); - setPickedUpOrders(prev => prev.find(o => o.id === order.id) ? prev : [...prev, order]); + setOrdersMap(prev => { + const current = prev.get(order.id) ?? order; + const updatedStates = (current.orderStationStates ?? []).map(s => + s.stationId === stationId ? { ...s, status: 'PICKED_UP' } : s + ); + return new Map(prev).set(order.id, { ...current, orderStationStates: updatedStates }); + }); updateOrderStatus(order.id, 'PICKED_UP', stationId); }; const handlePickupToCompleteStation = (order: Order, stationId?: string) => { - setPickedUpOrders(prev => prev.filter(o => o.id !== order.id)); - if (stationId) { - setStationPickedUp(prev => ({ ...prev, [stationId]: prev[stationId]?.filter(o => o.id !== order.id) ?? [] })); - setStationCompleted(prev => ({ - ...prev, - [stationId]: prev[stationId]?.find(o => o.id === order.id) ? prev[stationId] : [...(prev[stationId] ?? []), order], - })); - } + setOrdersMap(prev => { + const current = prev.get(order.id) ?? order; + // With stationId: move that specific station PICKED_UP → COMPLETED + // Without stationId (header): move all PICKED_UP stations → COMPLETED + const updatedStates = stationId + ? (current.orderStationStates ?? []).map(s => + s.stationId === stationId ? { ...s, status: 'COMPLETED' } : s + ) + : (current.orderStationStates ?? []).map(s => + s.status === 'PICKED_UP' ? { ...s, status: 'COMPLETED' } : s + ); + return new Map(prev).set(order.id, { ...current, orderStationStates: updatedStates }); + }); updateOrderStatus(order.id, 'COMPLETED', stationId); }; // --- Normal mode handlers --- const handleConfirmToComplete = (order: Order) => { - setConfirmedOrders(prev => prev.filter(o => o.id !== order.id)); - setReadyOrders(prev => [...prev, order]); + setOrdersMap(prev => { + const current = prev.get(order.id) ?? order; + return new Map(prev).set(order.id, { ...current, status: 'COMPLETED' }); + }); updateOrderStatus(order.id, 'COMPLETED'); }; const handleCompleteToConfirm = (order: Order) => { - setReadyOrders(prev => prev.filter(o => o.id !== order.id)); - setConfirmedOrders(prev => [...prev, order]); + setOrdersMap(prev => { + const current = prev.get(order.id) ?? order; + return new Map(prev).set(order.id, { ...current, status: 'CONFIRMED' }); + }); updateOrderStatus(order.id, 'CONFIRMED'); }; const handleCompleteToPickup = (order: Order) => { - setReadyOrders(prev => prev.filter(o => o.id !== order.id)); - setPickedUpOrders(prev => [...prev, order]); + setOrdersMap(prev => { + const current = prev.get(order.id) ?? order; + return new Map(prev).set(order.id, { ...current, status: 'PICKED_UP' }); + }); updateOrderStatus(order.id, 'PICKED_UP'); }; const handlePickupToComplete = (order: Order) => { - setPickedUpOrders(prev => prev.filter(o => o.id !== order.id)); - setReadyOrders(prev => [...prev, order]); + setOrdersMap(prev => { + const current = prev.get(order.id) ?? order; + return new Map(prev).set(order.id, { ...current, status: 'COMPLETED' }); + }); updateOrderStatus(order.id, 'COMPLETED'); }; @@ -416,7 +356,7 @@ export default function Manager() { if (stationsEnabled && stations.length > 0) { return (
-
+
{stations.map(station => ( diff --git a/components/manager/StationCard.tsx b/components/manager/StationCard.tsx index a556ddd..c7c9e14 100644 --- a/components/manager/StationCard.tsx +++ b/components/manager/StationCard.tsx @@ -48,8 +48,8 @@ export function StationCard({
- {confirmedOrders.map(order => ( -
+ {[...confirmedOrders].sort((a, b) => a.ticketNumber - b.ticketNumber).map(order => ( +
))} @@ -70,8 +70,8 @@ export function StationCard({
- {completedOrders.map(order => ( -
+ {[...completedOrders].sort((a, b) => a.ticketNumber - b.ticketNumber).map(order => ( +
))} diff --git a/components/manager/orders-grid.tsx b/components/manager/orders-grid.tsx index bafbb38..1de23a8 100644 --- a/components/manager/orders-grid.tsx +++ b/components/manager/orders-grid.tsx @@ -18,6 +18,8 @@ interface OrdersGridProps { } export default function OrdersGrid({ className, orders, title, status, stationId, onPrev, onNext, children }: OrdersGridProps) { + const sortedOrders = [...orders].sort((a, b) => a.ticketNumber - b.ticketNumber); + return (
@@ -34,8 +36,8 @@ export default function OrdersGrid({ className, orders, title, status, stationId }
{ - orders.map((order) => ( -
+ sortedOrders.map((order) => ( +
)) @@ -54,14 +56,38 @@ interface OrderCardProps { } export function OrderCard({ order, status, onPrev, onNext }: OrderCardProps) { - const [numberDisplay, setNumberDisplay] = useState("displayCode"); + const [numberDisplay, setNumberDisplay] = useState(() => { + const stored = localStorage.getItem(NUMBER_DISPLAY_KEY) as NumberDisplay | null; + return (stored && ["displayCode", "ticketNumber"].includes(stored)) ? stored : "displayCode"; + }); const [ticketNumberMax, setTicketNumberMax] = useState(0); useEffect(() => { - const nd = localStorage.getItem(NUMBER_DISPLAY_KEY) as NumberDisplay | null; - if (nd && ["displayCode", "ticketNumber"].includes(nd)) setNumberDisplay(nd); - const mx = localStorage.getItem(TICKET_NUMBER_MAX_KEY); - if (mx !== null) { const n = parseInt(mx, 10); if (!isNaN(n) && n >= 0) setTicketNumberMax(n); } + fetch("/api/display-config") + .then(res => res.ok ? res.json() : null) + .then(cfg => { + if (!cfg) { + const nd = localStorage.getItem(NUMBER_DISPLAY_KEY) as NumberDisplay | null; + if (nd && ["displayCode", "ticketNumber"].includes(nd)) setNumberDisplay(nd); + const mx = localStorage.getItem(TICKET_NUMBER_MAX_KEY); + if (mx !== null) { const n = parseInt(mx, 10); if (!isNaN(n) && n >= 0) setTicketNumberMax(n); } + return; + } + if (cfg.numberDisplay && ["displayCode", "ticketNumber"].includes(cfg.numberDisplay)) { + setNumberDisplay(cfg.numberDisplay as NumberDisplay); + localStorage.setItem(NUMBER_DISPLAY_KEY, cfg.numberDisplay); + } + if (typeof cfg.ticketNumberMax === "number" && cfg.ticketNumberMax >= 0) { + setTicketNumberMax(cfg.ticketNumberMax); + localStorage.setItem(TICKET_NUMBER_MAX_KEY, String(cfg.ticketNumberMax)); + } + }) + .catch(() => { + const nd = localStorage.getItem(NUMBER_DISPLAY_KEY) as NumberDisplay | null; + if (nd && ["displayCode", "ticketNumber"].includes(nd)) setNumberDisplay(nd); + const mx = localStorage.getItem(TICKET_NUMBER_MAX_KEY); + if (mx !== null) { const n = parseInt(mx, 10); if (!isNaN(n) && n >= 0) setTicketNumberMax(n); } + }); }, []); function addNext() { @@ -77,7 +103,7 @@ export function OrderCard({ order, status, onPrev, onNext }: OrderCardProps) { } const orderTitle = (() => { - if (numberDisplay !== "ticketNumber") return order.displayCode; + if (numberDisplay == "displayCode") return order.displayCode; // 0 = no max, show raw ticketNumber if (!ticketNumberMax) return String(order.ticketNumber); return String(order.ticketNumber % ticketNumberMax); @@ -89,7 +115,7 @@ export function OrderCard({ order, status, onPrev, onNext }: OrderCardProps) { variant="outline" size="lg" onClick={addNext} - className="select-none h-16 px-4 text-3xl font-bold font-mono whitespace-nowrap hover:bg-primary hover:text-primary-foreground transition-colors" + className="w-full select-none h-16 px-4 text-3xl font-bold font-mono whitespace-nowrap hover:bg-primary transition-colors" disabled={!onNext} > {orderTitle} @@ -99,7 +125,7 @@ export function OrderCard({ order, status, onPrev, onNext }: OrderCardProps) { if (status === 'COMPLETED') { return ( -
+
)} -
-
- - +
+ {/* Zoom Section */} +
+ + {isLoading ? ( + + ) : ( +
+
+ + setDisplayZoom(parseInt(e.target.value, 10))} + disabled={isLoading} + className="flex-1 h-2 bg-blue-200 rounded-lg appearance-none cursor-pointer dark:bg-blue-800" + style={{ + background: `linear-gradient(to right, rgb(96, 165, 250) 0%, rgb(96, 165, 250) ${((displayZoom - 50) / 150) * 100}%, rgb(229, 231, 235) ${((displayZoom - 50) / 150) * 100}%, rgb(229, 231, 235) 100%)` + }} + /> + + { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val >= 50 && val <= 200) { + setDisplayZoom(val); + } + }} + disabled={isLoading} + className="w-14 h-8 rounded px-2 text-sm font-bold text-center text-blue-700 dark:text-blue-400 bg-white dark:bg-blue-900/50 border border-blue-200 dark:border-blue-800 tabular-nums" + /> + % +
+
+ + + +
+
+ )}
-
- - + + {/* Flags Grid 2x2 */} +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
diff --git a/components/settings/DisplayZoomSettingsCard.tsx b/components/settings/DisplayZoomSettingsCard.tsx new file mode 100644 index 0000000..33a3ecb --- /dev/null +++ b/components/settings/DisplayZoomSettingsCard.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Zap, ZoomIn, ZoomOut } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; + +export const DISPLAY_ZOOM_KEY = "display-zoom"; + +export function DisplayZoomSettingsCard() { + const { t } = useTranslation(); + const [zoom, setZoom] = useState(100); + const [savedZoom, setSavedZoom] = useState(100); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + fetch("/api/display-config") + .then((res) => res.ok ? res.json() : null) + .then((cfg) => { + if (typeof cfg?.displayZoom === "number" && cfg.displayZoom >= 50 && cfg.displayZoom <= 200) { + setZoom(cfg.displayZoom); + setSavedZoom(cfg.displayZoom); + localStorage.setItem(DISPLAY_ZOOM_KEY, String(cfg.displayZoom)); + } else { + const stored = localStorage.getItem(DISPLAY_ZOOM_KEY); + if (stored) { + const parsed = parseInt(stored, 10); + if (!isNaN(parsed) && parsed >= 50 && parsed <= 200) { + setZoom(parsed); + setSavedZoom(parsed); + } + } + } + }) + .catch(() => { + const stored = localStorage.getItem(DISPLAY_ZOOM_KEY); + if (stored) { + const parsed = parseInt(stored, 10); + if (!isNaN(parsed) && parsed >= 50 && parsed <= 200) { + setZoom(parsed); + setSavedZoom(parsed); + } + } + }) + .finally(() => setIsLoading(false)); + }, []); + + const handleSave = async () => { + setIsSaving(true); + try { + await fetch("/api/display-config", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayZoom: zoom }), + }); + setSavedZoom(zoom); + localStorage.setItem(DISPLAY_ZOOM_KEY, String(zoom)); + toast.success(t("settings.zoomSaved")); + } catch { + toast.error(t("settings.saveError")); + } finally { + setIsSaving(false); + } + }; + + const hasChanges = zoom !== savedZoom; + + return ( + + +
+ + {t("settings.displayZoom")} +
+ + {t("settings.displayZoomDesc")} + +
+ + {isLoading ? ( + + ) : ( +
+
+ +
+ setZoom(parseInt(e.target.value, 10))} + disabled={isLoading} + className="w-full h-2 bg-blue-200 rounded-lg appearance-none cursor-pointer dark:bg-blue-800" + style={{ + background: `linear-gradient(to right, rgb(96, 165, 250) 0%, rgb(96, 165, 250) ${((zoom - 50) / 150) * 100}%, rgb(229, 231, 235) ${((zoom - 50) / 150) * 100}%, rgb(229, 231, 235) 100%)` + }} + /> +
+ + + {zoom}% + +
+
+ + + +
+
+ )} + +
+ +
+
+
+ ); +} diff --git a/lib/display-config-store.ts b/lib/display-config-store.ts index cbb192d..4ca5d6d 100644 --- a/lib/display-config-store.ts +++ b/lib/display-config-store.ts @@ -15,6 +15,8 @@ export interface DisplayConfig { ticketNumberMax: number; stationsEnabled: boolean; fullscreenAlertEnabled: boolean; + autoScrollPagesEnabled: boolean; + displayZoom: number; } type Subscriber = (text: string) => void; @@ -30,6 +32,8 @@ const DEFAULT_CONFIG: DisplayConfig = { ticketNumberMax: 100, stationsEnabled: false, fullscreenAlertEnabled: true, + autoScrollPagesEnabled: true, + displayZoom: 100, }; // In-memory cache — populated lazily on first read/write @@ -83,7 +87,8 @@ export function updateConfig(patch: Partial): DisplayConfig { if (patch.displayMode !== undefined || patch.eventName !== undefined || patch.numberDisplay !== undefined || patch.ticketNumberMax !== undefined || - patch.stationsEnabled !== undefined || patch.fullscreenAlertEnabled !== undefined) { + patch.stationsEnabled !== undefined || patch.fullscreenAlertEnabled !== undefined || + patch.autoScrollPagesEnabled !== undefined || patch.displayZoom !== undefined) { configSubscribers.forEach((fn) => { try { fn({ ...cache }); } catch { /* subscriber gone */ } }); @@ -109,6 +114,8 @@ export function subscribe(fn: Subscriber): () => void { } export function subscribeConfig(fn: ConfigSubscriber): () => void { + loadFromFile(); + fn({ ...cache }); configSubscribers.add(fn); return () => configSubscribers.delete(fn); } diff --git a/lib/i18n/locales/en.ts b/lib/i18n/locales/en.ts index ffd5e7a..dfcd275 100644 --- a/lib/i18n/locales/en.ts +++ b/lib/i18n/locales/en.ts @@ -63,6 +63,7 @@ export const en = { displayModeHybridDesc: "¾ of the page for preparing orders and ¼ for ready orders", display: "Display", displayDesc: "Edit the display page", + displayOptions: "Display options", operativeMode: "Operative mode", operativeModeDesc: "Select what to show on the public display page", saving: "Saving...", @@ -94,6 +95,11 @@ export const en = { stationsSaved: "Stations setting saved", fullscreenAlertEnabled: "Full-screen alert", fullscreenAlertEnabledDesc: "Show a full-screen overlay when an order is ready", + autoScrollPagesEnabled: "Auto-scroll pages", + autoScrollPagesEnabledDesc: "Automatically scroll through order pages every 10 seconds", + displayZoom: "Display zoom", + displayZoomDesc: "Zoom in or out to see more or fewer orders", + zoomSaved: "Display zoom saved", allOrders: "All orders" }, session: { diff --git a/lib/i18n/locales/it.ts b/lib/i18n/locales/it.ts index a2ccc12..785c1c5 100644 --- a/lib/i18n/locales/it.ts +++ b/lib/i18n/locales/it.ts @@ -63,6 +63,7 @@ export const it = { displayModeHybridDesc: "¾ della pagina per gli ordini in preparazione e ¼ per gli ordini pronti", display: "Display", displayDesc: "Modifica la pagina del display", + displayOptions: "Opzioni display", operativeMode: "Modalità operativa", operativeModeDesc: "Seleziona cosa mostrare nella pagina display pubblica", saving: "Salvataggio...", @@ -94,6 +95,11 @@ export const it = { stationsSaved: "Impostazione postazioni salvata", fullscreenAlertEnabled: "Alert a tutto schermo", fullscreenAlertEnabledDesc: "Mostra un overlay a tutto schermo quando un ordine è pronto", + autoScrollPagesEnabled: "Scorrimento automatico pagine", + autoScrollPagesEnabledDesc: "Scorre automaticamente le pagine di ordini ogni 10 secondi", + displayZoom: "Zoom del display", + displayZoomDesc: "Zoom in o out per vedere più o meno ordini", + zoomSaved: "Zoom del display salvato", allOrders: "Tutti gli ordini" }, session: {