diff --git a/backend/src/__tests__/staff.test.ts b/backend/src/__tests__/staff.test.ts index e4ae681..394409f 100644 --- a/backend/src/__tests__/staff.test.ts +++ b/backend/src/__tests__/staff.test.ts @@ -530,12 +530,21 @@ describe('/admin/floor payload (#15)', () => { const res = await testRequest('/admin/floor', { headers: await adminHeaders(), env: adminEnv(db) }); expect(res.status).toBe(200); - const body = (await res.json()) as { areas: Array<{ id: string }>; tables: Array<{ id: string; active: boolean; sessionId: string | null; oldestSubmittedAt: number | null }> }; + const body = (await res.json()) as { areas: Array<{ id: string }>; tables: Array<{ id: string; active: boolean; sessionId: string | null; oldestSubmittedAt: number | null; provisionalTotal: number }> }; expect(body.areas).toEqual([{ id: 'area-1', name: 'Sala', sortOrder: 0 }]); const row = body.tables.find((t) => t.id === 'table-1')!; expect(row.sessionId).toBe(sessionId); expect(row.oldestSubmittedAt).toEqual(expect.any(Number)); expect(row.active).toBe(true); + expect(row.provisionalTotal).toBe(750); + + const checkRes = await testRequest(`/admin/sessions/${sessionId}/check`, { method: 'POST', headers: await adminHeaders(), env: adminEnv(db) }); + expect(checkRes.status).toBe(201); + const checkedRes = await testRequest('/admin/floor', { headers: await adminHeaders(), env: adminEnv(db) }); + const checkedBody = (await checkedRes.json()) as { tables: Array<{ id: string; checkStatus: string | null; checkTotal: number | null }> }; + const checkedRow = checkedBody.tables.find((t) => t.id === 'table-1')!; + expect(checkedRow.checkStatus).toBe('open'); + expect(checkedRow.checkTotal).toBe(750); }); it('includes inactive tables (admin) while /staff/floor excludes them', async () => { diff --git a/backend/src/lib/floor.ts b/backend/src/lib/floor.ts index dbb7fec..e984f50 100644 --- a/backend/src/lib/floor.ts +++ b/backend/src/lib/floor.ts @@ -34,17 +34,51 @@ export async function buildFloorState(db: DbInstance, includeInactive: boolean) const sessionIds = openSessions.map((s) => s.id); const orderRows = sessionIds.length > 0 ? await db - .select({ tableSessionId: schema.orders.tableSessionId, status: schema.orders.status, createdAt: schema.orders.createdAt }) + .select({ id: schema.orders.id, tableSessionId: schema.orders.tableSessionId, status: schema.orders.status, createdAt: schema.orders.createdAt }) .from(schema.orders) .where(inArray(schema.orders.tableSessionId, sessionIds)) : []; - const counts = new Map(); + const orderIds = orderRows.map((o) => o.id); + const itemRows = includeInactive && orderIds.length > 0 + ? await db + .select({ orderId: schema.orderItems.orderId, price: schema.orderItems.price, quantity: schema.orderItems.quantity }) + .from(schema.orderItems) + .where(inArray(schema.orderItems.orderId, orderIds)) + : []; + const orderTotal = new Map(); + for (const item of itemRows) orderTotal.set(item.orderId, (orderTotal.get(item.orderId) ?? 0) + item.price * item.quantity); + + const checks = includeInactive && sessionIds.length > 0 + ? await db + .select() + .from(schema.checks) + .where(inArray(schema.checks.tableSessionId, sessionIds)) + : []; + const checkBySession = new Map(); + for (const check of checks) { + const prev = checkBySession.get(check.tableSessionId); + if (!prev || check.createdAt > prev.createdAt) checkBySession.set(check.tableSessionId, check); + } + + const checkTotal = (check: typeof checks[number]): number => { + const lines = check.lines ?? []; + const adjustments = check.adjustments ?? []; + const subtotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0); + const discount = check.discount + ? check.discount.type === 'percent' ? Math.round(subtotal * check.discount.value / 100) : check.discount.value + : 0; + const adjusted = subtotal - discount + adjustments.reduce((sum, a) => sum + a.amount, 0); + return Math.max(0, adjusted); + }; + + const counts = new Map(); for (const o of orderRows) { if (!o.tableSessionId) continue; - const c = counts.get(o.tableSessionId) ?? { orderCount: 0, readyCount: 0, oldestSubmittedAt: null }; + const c = counts.get(o.tableSessionId) ?? { orderCount: 0, readyCount: 0, oldestSubmittedAt: null, provisionalTotal: 0 }; // "Open orders" = anything not served/rejected; "ready" = ready to serve. if (o.status === 'submitted' || o.status === 'ready') c.orderCount++; + if (o.status !== 'rejected') c.provisionalTotal += orderTotal.get(o.id) ?? 0; if (o.status === 'ready') c.readyCount++; if (o.status === 'submitted' && (c.oldestSubmittedAt === null || o.createdAt < c.oldestSubmittedAt)) { c.oldestSubmittedAt = o.createdAt; @@ -69,6 +103,11 @@ export async function buildFloorState(db: DbInstance, includeInactive: boolean) x: t.x, y: t.y, shape: t.shape, + ...(includeInactive ? { + provisionalTotal: count?.provisionalTotal ?? 0, + checkStatus: session ? checkBySession.get(session.id)?.status ?? null : null, + checkTotal: session && checkBySession.has(session.id) ? checkTotal(checkBySession.get(session.id)!) : null, + } : {}), oldestSubmittedAt: count?.oldestSubmittedAt ?? null, }; }), diff --git a/packages/schemas/src/staff.ts b/packages/schemas/src/staff.ts index 2e74049..7130c52 100644 --- a/packages/schemas/src/staff.ts +++ b/packages/schemas/src/staff.ts @@ -111,6 +111,9 @@ export interface FloorTable { /** Admin floor state: a FloorTable that also carries the active flag (inactive tables show dimmed). */ export interface AdminFloorTable extends FloorTable { active: boolean; + provisionalTotal: number; + checkStatus: 'open' | 'settled' | 'voided' | null; + checkTotal: number | null; } export interface StaffOrderItem { diff --git a/web/e2e/admin/admin-checks-real.spec.ts b/web/e2e/admin/admin-checks-real.spec.ts index b9ae4b3..abfa347 100644 --- a/web/e2e/admin/admin-checks-real.spec.ts +++ b/web/e2e/admin/admin-checks-real.spec.ts @@ -42,6 +42,7 @@ test.describe.serial("Checks (conto) — real backend", () => { await page.goto("/admin/tables/"); const tile = page.getByTestId(`table-${SEEDED_TABLE.id}`); await expect(tile).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId(`table-total-${SEEDED_TABLE.id}`)).toHaveText(/15,00/); await tile.click(); await expect(page).toHaveURL(new RegExp(`/admin/tables/detail/?\\?tableId=${SEEDED_TABLE.id}`)); @@ -55,6 +56,12 @@ test.describe.serial("Checks (conto) — real backend", () => { await expect(page.getByTestId("check-card")).toBeVisible(); await expect(page.getByTestId("add-items-blocked")).toContainText(/paga o annulla|settle or void/i); await expect(page.getByTestId("add-order")).toHaveCount(0); + + await page.goto("/admin/tables/"); + await expect(page.getByTestId(`table-check-${SEEDED_TABLE.id}`)).toHaveText(/conto|check/i); + await expect(page.getByTestId(`table-total-${SEEDED_TABLE.id}`)).toHaveText(/15,00/); + await page.getByTestId(`table-${SEEDED_TABLE.id}`).click(); + await expect(page).toHaveURL(new RegExp(`/admin/tables/detail/?\\?tableId=${SEEDED_TABLE.id}`)); await expect(page.getByTestId("check-total")).toHaveText(/15,00/); // Apply a 10% discount; total updates to 13,50. @@ -72,7 +79,7 @@ test.describe.serial("Checks (conto) — real backend", () => { await settleRes; await expect(page.getByTestId("table-free")).toBeVisible(); - await expect(page.getByText("Pagato").first()).toBeVisible(); + await expect(page.getByText(/Pagato|Paid/).first()).toBeVisible(); }); test("admin adds searchable items from the table page", async ({ page, request }) => { await seedSessionWithOrder(request); diff --git a/web/messages/de.json b/web/messages/de.json index bf621f7..ede941f 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -661,7 +661,8 @@ "tableDetail.searchItems": "Alle verfügbaren Artikel suchen", "tableDetail.noAvailableItems": "Keine verfügbaren Artikel gefunden.", "tableDetail.submitItems": "Artikel hinzufügen", - "tableDetail.checkOpenAddItemsBlocked": "Begleiche oder storniere die offene Rechnung, bevor du weitere Artikel hinzufügst." + "tableDetail.checkOpenAddItemsBlocked": "Begleiche oder storniere die offene Rechnung, bevor du weitere Artikel hinzufügst.", + "tables.checkOpen": "Rechnung" }, "outOfStock": "AUSVERKAUFT", "staff": { diff --git a/web/messages/en.json b/web/messages/en.json index 7fa0e53..c6d58f3 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -661,7 +661,8 @@ "tableDetail.searchItems": "Search all available items", "tableDetail.noAvailableItems": "No available items found.", "tableDetail.submitItems": "Add items", - "tableDetail.checkOpenAddItemsBlocked": "Settle or void the open check before adding more items." + "tableDetail.checkOpenAddItemsBlocked": "Settle or void the open check before adding more items.", + "tables.checkOpen": "Check" }, "outOfStock": "OUT OF STOCK", "staff": { diff --git a/web/messages/es.json b/web/messages/es.json index dcf9832..c103021 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -661,7 +661,8 @@ "tableDetail.searchItems": "Buscar artículos disponibles", "tableDetail.noAvailableItems": "No se encontraron artículos disponibles.", "tableDetail.submitItems": "Añadir artículos", - "tableDetail.checkOpenAddItemsBlocked": "Cobra o anula la cuenta abierta antes de añadir más artículos." + "tableDetail.checkOpenAddItemsBlocked": "Cobra o anula la cuenta abierta antes de añadir más artículos.", + "tables.checkOpen": "Cuenta" }, "outOfStock": "AGOTADO", "staff": { diff --git a/web/messages/fr.json b/web/messages/fr.json index f9f2e99..de63095 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -661,7 +661,8 @@ "tableDetail.searchItems": "Rechercher les articles disponibles", "tableDetail.noAvailableItems": "Aucun article disponible trouvé.", "tableDetail.submitItems": "Ajouter des articles", - "tableDetail.checkOpenAddItemsBlocked": "Encaissez ou annulez l’addition ouverte avant d’ajouter d’autres articles." + "tableDetail.checkOpenAddItemsBlocked": "Encaissez ou annulez l’addition ouverte avant d’ajouter d’autres articles.", + "tables.checkOpen": "Note" }, "outOfStock": "ÉPUISÉ", "staff": { diff --git a/web/messages/hu.json b/web/messages/hu.json index 9630fe5..2c08084 100644 --- a/web/messages/hu.json +++ b/web/messages/hu.json @@ -661,7 +661,8 @@ "tableDetail.searchItems": "Elérhető tételek keresése", "tableDetail.noAvailableItems": "Nem található elérhető tétel.", "tableDetail.submitItems": "Tételek hozzáadása", - "tableDetail.checkOpenAddItemsBlocked": "Rendezze vagy érvénytelenítse a nyitott számlát, mielőtt új tételeket ad hozzá." + "tableDetail.checkOpenAddItemsBlocked": "Rendezze vagy érvénytelenítse a nyitott számlát, mielőtt új tételeket ad hozzá.", + "tables.checkOpen": "Számla" }, "outOfStock": "OUT OF STOCK", "staff": { diff --git a/web/messages/it.json b/web/messages/it.json index b755a65..237a6a0 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -661,7 +661,8 @@ "tableDetail.searchItems": "Cerca tutti gli articoli disponibili", "tableDetail.noAvailableItems": "Nessun articolo disponibile trovato.", "tableDetail.submitItems": "Aggiungi articoli", - "tableDetail.checkOpenAddItemsBlocked": "Paga o annulla il conto aperto prima di aggiungere altri articoli." + "tableDetail.checkOpenAddItemsBlocked": "Paga o annulla il conto aperto prima di aggiungere altri articoli.", + "tables.checkOpen": "Conto" }, "outOfStock": "ESAURITO", "staff": { diff --git a/web/messages/nl.json b/web/messages/nl.json index aebd3b5..ce9cb9e 100644 --- a/web/messages/nl.json +++ b/web/messages/nl.json @@ -661,7 +661,8 @@ "tableDetail.searchItems": "Zoek beschikbare items", "tableDetail.noAvailableItems": "Geen beschikbare items gevonden.", "tableDetail.submitItems": "Items toevoegen", - "tableDetail.checkOpenAddItemsBlocked": "Reken de open bon af of annuleer deze voordat je meer items toevoegt." + "tableDetail.checkOpenAddItemsBlocked": "Reken de open bon af of annuleer deze voordat je meer items toevoegt.", + "tables.checkOpen": "Rekening" }, "outOfStock": "UITVERKOCHT", "staff": { diff --git a/web/messages/pt.json b/web/messages/pt.json index 381a1ad..e031cf4 100644 --- a/web/messages/pt.json +++ b/web/messages/pt.json @@ -661,7 +661,8 @@ "tableDetail.searchItems": "Pesquisar itens disponíveis", "tableDetail.noAvailableItems": "Nenhum item disponível encontrado.", "tableDetail.submitItems": "Adicionar itens", - "tableDetail.checkOpenAddItemsBlocked": "Liquide ou anule a conta aberta antes de adicionar mais itens." + "tableDetail.checkOpenAddItemsBlocked": "Liquide ou anule a conta aberta antes de adicionar mais itens.", + "tables.checkOpen": "Conta" }, "outOfStock": "ESGOTADO", "staff": { diff --git a/web/messages/ru.json b/web/messages/ru.json index d0924dc..5952e82 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -661,7 +661,8 @@ "tableDetail.searchItems": "Поиск доступных позиций", "tableDetail.noAvailableItems": "Доступные позиции не найдены.", "tableDetail.submitItems": "Добавить позиции", - "tableDetail.checkOpenAddItemsBlocked": "Оплатите или аннулируйте открытый счёт перед добавлением новых позиций." + "tableDetail.checkOpenAddItemsBlocked": "Оплатите или аннулируйте открытый счёт перед добавлением новых позиций.", + "tables.checkOpen": "Счет" }, "outOfStock": "НЕТ В НАЛИЧИИ", "staff": { diff --git a/web/messages/vec.json b/web/messages/vec.json index 2474d0a..50d2892 100644 --- a/web/messages/vec.json +++ b/web/messages/vec.json @@ -661,7 +661,8 @@ "tableDetail.searchItems": "Serca tute łe voci disponìbiłi", "tableDetail.noAvailableItems": "Nisuna vose disponìbiłe catada.", "tableDetail.submitItems": "Zonta voci", - "tableDetail.checkOpenAddItemsBlocked": "Paga o anuła el conto verto prima de zontar altre voci." + "tableDetail.checkOpenAddItemsBlocked": "Paga o anuła el conto verto prima de zontar altre voci.", + "tables.checkOpen": "Conto" }, "outOfStock": "FINIO", "staff": { diff --git a/web/src/components/admin/pages/TablesPage.test.tsx b/web/src/components/admin/pages/TablesPage.test.tsx index 1084658..1870ca0 100644 --- a/web/src/components/admin/pages/TablesPage.test.tsx +++ b/web/src/components/admin/pages/TablesPage.test.tsx @@ -32,8 +32,8 @@ const areas = [ const base = { openedAt: null, orderCount: 0, readyCount: 0 }; const tables = [ // t1: green (open, no submitted); t2 lives in another area. - { id: "t1", name: "1", active: true, areaId: "a1", x: 50, y: 50, shape: "rect" as const, sessionId: "s1", oldestSubmittedAt: null, ...base }, - { id: "t2", name: "2", active: true, areaId: "a2", x: 50, y: 50, shape: "circle" as const, sessionId: null, oldestSubmittedAt: null, ...base }, + { id: "t1", name: "1", active: true, areaId: "a1", x: 50, y: 50, shape: "rect" as const, sessionId: "s1", oldestSubmittedAt: null, provisionalTotal: 1500, checkStatus: null, checkTotal: null, ...base }, + { id: "t2", name: "2", active: true, areaId: "a2", x: 50, y: 50, shape: "circle" as const, sessionId: null, oldestSubmittedAt: null, provisionalTotal: 0, checkStatus: null, checkTotal: null, ...base }, ]; beforeEach(() => { @@ -55,6 +55,7 @@ describe("TablesPage", () => { expect(tile).toBeInTheDocument(); expect(tile.className).toContain("bg-green-100"); expect(screen.queryByTestId("table-t2")).toBeNull(); + expect(screen.getByTestId("table-total-t1")).toHaveTextContent("15,00"); }); it("switches the visible tables when another area tab is clicked", async () => { @@ -72,6 +73,14 @@ describe("TablesPage", () => { expect(routerPush).toHaveBeenCalledWith("/admin/tables/detail?tableId=t1"); }); + it("shows an open-check badge and check total on admin floor tiles", async () => { + apiMocks.fetchAdminFloor.mockResolvedValue({ areas, tables: [{ ...tables[0], checkStatus: "open", checkTotal: 1350 }] }); + render(); + await screen.findByTestId("area-tab-a1"); + expect(screen.getByTestId("table-total-t1")).toHaveTextContent("13,50"); + expect(screen.getByTestId("table-check-t1")).toHaveTextContent("tables.checkOpen"); + }); + it("hides the edit UI by default and reveals it via the toggle", async () => { render(); await screen.findByTestId("area-tab-a1"); diff --git a/web/src/components/admin/pages/TablesPage.tsx b/web/src/components/admin/pages/TablesPage.tsx index ee6e26a..5ec6f5f 100644 --- a/web/src/components/admin/pages/TablesPage.tsx +++ b/web/src/components/admin/pages/TablesPage.tsx @@ -12,6 +12,7 @@ import { FloorCanvas, type FloorTile } from "@/components/floor/FloorCanvas"; import type { TableShape } from "@menu/schemas"; const POLL_MS = 10_000; +const money = (cents: number) => (cents / 100).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); /** * Admin tables (#15): canvas-first, colour-coded like the staff floor. Default mode @@ -101,6 +102,7 @@ export default function TablesPage() { setTables((prev) => [...prev, { id: res.id, name: trimmed, active: true, areaId: activeArea, x: 25, y: 25, shape: tableShape, sessionId: null, openedAt: null, orderCount: 0, readyCount: 0, oldestSubmittedAt: null, + provisionalTotal: 0, checkStatus: null, checkTotal: null, }]); setTableName(""); } catch (err) { @@ -167,6 +169,8 @@ export default function TablesPage() { const tiles: FloorTile[] = areaTables.map((tb) => ({ id: tb.id, name: tb.name, x: tb.x, y: tb.y, shape: tb.shape, active: tb.active, sessionId: tb.sessionId, oldestSubmittedAt: tb.oldestSubmittedAt, + totalLabel: tb.checkTotal != null ? money(tb.checkTotal) : tb.provisionalTotal > 0 ? `≈${money(tb.provisionalTotal)}` : null, + checkLabel: tb.checkStatus === "open" ? t("tables.checkOpen") : null, })); const selected = areaTables.find((tb) => tb.id === selectedId) ?? null; diff --git a/web/src/components/floor/FloorCanvas.tsx b/web/src/components/floor/FloorCanvas.tsx index 6d6438e..d56b6fa 100644 --- a/web/src/components/floor/FloorCanvas.tsx +++ b/web/src/components/floor/FloorCanvas.tsx @@ -19,6 +19,8 @@ export type FloorTile = { active?: boolean; // admin: dim inactive tables sessionId?: string | null; // staff: open session state oldestSubmittedAt?: number | null; // staff: oldest submitted order timestamp + totalLabel?: string | null; + checkLabel?: string | null; }; type Visual = { color: "gray" | "green" | "amber" | "red"; minutes: number | null }; @@ -142,6 +144,8 @@ export function FloorCanvas({ tiles, editable, now = Date.now(), busyId, pannabl {visual?.minutes != null && ( {visual.minutes}m )} + {tile.totalLabel && {tile.totalLabel}} + {tile.checkLabel && {tile.checkLabel}} ); })}