Skip to content

Commit 870610b

Browse files
authored
Admin table service, floorplan, and checks (#15) (#28)
* dev: expose dev env via TryCloudflare tunnel with same-origin API proxy - next.config.ts: dev-only rewrites /api -> :8787 and /chat -> :8788, mirroring production's Pages Function proxy, so one tunnel to :3000 covers app + API + chat. Allow *.trycloudflare.com dev origins. - dev.sh: start next dev with NEXT_PUBLIC_API_URL=/api and NEXT_PUBLIC_CHAT_WORKER_URL=/chat (process env wins over .env.local), and spawn a cloudflared quick-tunnel tab that prints the public URL. * feat(schemas): staff links, tables, table-session types; optional tableSessionId on submit (#15) * feat(backend): waiter mode - staff links, tables, table sessions, staff-gated intents (#15) - migration 0014: staff_links, tables, table_sessions, orders.table_session_id - one-use link exchange (public) -> session token; revocable staff session middleware - staff floor/session/serve endpoints; move order-intents review+consume from admin to staff gating - shared createOrder accepts optional tableSessionId (validated: open session only); no new submit path - /orders authenticates staff session when tableSessionId present, bypassing diner submitMode gate - admin staff-links + tables CRUD; kitchen board GET /orders now returns tableName * feat(web): waiter mode UI - /staff floor, table detail, staff gate, /order-review migration (#15) - /staff floor view + table detail (open/close session, mark served, add order) - StaffGate consumes ?token, stores session, cleans URL; ask-personnel screen otherwise - move /admin/order-review to top-level /order-review, staff-gated; diner QR points there - staff-context selection submits with tableSessionId + staff header, returns to table - admin staff-links (link/QR once, list, revoke) + tables CRUD pages + nav - kitchen board shows table name; real translations for all 10 locales - e2e: waiter mode flows; updated handoff spec for /order-review + staff session * test(e2e): pin NEXT_PUBLIC_API_URL to direct backend in playwright config The admin/catalog specs assert direct localhost:8787 URLs (as in CI); the same-origin /api value the cloudflared dev tunnel writes to .env.local was leaking into local e2e runs and failing 4 specs. * test(e2e): real-backend smoke for staff-links, tables, /staff mounting (#15) The waiter-mode e2e suite mocks every API call at the browser level, so a backend that 500s on /admin/staff-links (as the stale local dev server did) passed the suite. These specs follow the admin-api.spec.ts pattern: they run against the seeded DEMO_MODE backend and catch route-mounting and schema regressions the mocked specs can't see. * Demock menu selection e2e * Demock older admin e2e specs * Demock order and staff e2e * test(e2e): remove Playwright app-state seams * test(e2e): remove backend mocks from Playwright suite Playwright e2e now runs only against the real DEMO_MODE backend. Mocked chat-worker specs moved out of web/e2e; app-state seams and page.route fulfils were removed from order, staff, admin, and menu specs. E2E_MODE is an explicit test-only Worker binding used by CI/local e2e to bypass rate limiting without allowing browser spoofed Cloudflare headers. * feat(orders): waiter table pick at QR review, board elapsed time, UI polish (#15) - /order-review: waiter selects the target table before submitting; picking a table without an open session opens one, order lands on it and the kitchen board shows the table name - kitchen board: show elapsed time since last status change per order - selection: replace native confirm() with inline clear confirmation - item detail: rounded sheet corners so the add-to-selection footer no longer overflows the popup * feat(demo): seed Cucina/Bar departments, entry routing, and 10 tables (#15, #11) Departments Cucina/Bar with food->Cucina, drink glasses->Bar assignments via order_destinations/entry_destinations. 10 tables split Sala 1..6 / Terrazza 1..4 (area encoded in the flat name). Demo reset wipes tables/sessions then restores the seed. * feat(staff): consume intent with optional lines override (#15) The waiter's edited selection is created instead of the frozen snapshot; lines validated with SubmitOrderLineSchema and run through createOrder's stale-item path (409 stale_items lists ids, claim released so the intent stays reusable). The intent row itself stays immutable. * feat(review): waiter edits order at review + post-submit navigation (#15) Order review page now lets the waiter change line quantities, remove lines (including stale ones that blocked submit), and add items from a searchable flat catalog picker. Edited lines submit as a consume override. Success screen offers Go to floor and, when bound to a table, Go to table. Real translations for the 8 new keys in all 10 locales. * feat(selection): show waiter QR in a modal instead of replacing the view (#15) The QR handoff now opens as a headlessui Dialog overlay (same pattern as MenuItemDetail); the selection stays mounted behind it and dismissing returns without losing cart state. * test(e2e): ride the demo seed for tables/departments; cover review edit flow (#15) Flow specs use seeded Sala 1 and Cucina/Bar assignments instead of creating their own via API. New handoff test edits the intent (bump qty, add item, bind table) and asserts the override on the kitchen board plus post-submit nav. * feat(selection): open "my selection" as a modal over the menu; reuse QR intent (#15) Task 1: extract the selection UI into a shared SelectionContent component used by both the /selection page (chrome + back link) and a new bottom-sheet Dialog on the menu. The menu pill is now a button that opens the modal (no navigation, menu stays mounted behind); staff add-order still deep-links to /selection. Task 2: cache the created order intent (url + expiresAt + line fingerprint); reopening the QR with an unchanged cart and >2min left reuses it instead of POSTing again, so a diner toying with the button no longer burns the 10/min per-IP limit. Added a 429-specific selection.qrRateLimit string in all 10 locales. Tests: extended SelectionPageClient unit tests (intent reuse, 429 error); added a MenuPageClient modal-open test; updated e2e specs for the pill-as-button and a no-navigation assertion. web+backend+chat unit suites green, lint clean, i18n-completeness green, full Playwright suite green (119). * feat(staff): order lifecycle changelog on table detail; fix euro/cents mixup at review (#15) - order_events table (0015): one row per status event with actor (diner/staff/admin), written at createOrder, admin status patch, staff serve - GET /staff/sessions/:id returns each order's events; TableDetail renders the changelog (time, status, actor) - fix: /order-review showed 0.07 EUR for a 7.50 item - the public catalog serves euros while order lines are cents; normalize at the seam (the only crossing point) with a regression test using real euro-shaped catalog data - QR consume without a table now logs actor 'staff' (explicit actor arg), demo reset clears order_events * fix(staff): require table when consuming waiter QR intent (#15) Waiter consume now requires tableSessionId at the shared schema/route boundary, so 'no table' is not a valid backend path. The review UI disables submit until a table is selected and uses a disabled placeholder instead of a no-table option. Tests cover missing table 400 without claiming the intent, invalid lines with a valid table, and the real e2e helper opens a seeded table before API consume. * feat(demo): seed reusable waiter links for stable QR access (#15) Demo reset now seeds Marco/Giulia waiter links with fixed tokens and fixed session tokens. In DEMO_MODE only, consumed demo tokens can be exchanged again, so /staff?token=demo-staff-link-marco is safe to print as a stable QR while normal staff links stay one-use. Tests cover seeded rows and demo-only reuse. * fix(demo): keep seeded waiter QR one-use (#15) Demo reset still seeds stable waiter tokens for printable QR URLs, but consuming them now follows the normal staff-link rule: first device wins, second scan is consumed. Removed the DEMO_MODE reuse branch and stopped seeding a pre-consumed session token. * feat(staff): show waiter names and clearer item-done state (#15) Order lifecycle events now store actorName so kitchen/table views can show the waiter who submitted/served an order. Waiter table submits and QR consumes pass the staff session name through the shared createOrder path. Department checkboxes now visibly mark done rows and all-order cards show destination done state. * feat(#15): floor-plan model — areas + table positions (backend) Introduce a floor-plan layer on top of the flat table list: - New `areas` table (name, sort_order); `tables` gains area_id (FK, no cascade — an area can't be dropped while tables reference it), x/y on a 1000x700 virtual canvas, and shape ('rect'|'circle'). Migration 0017. - Admin areas CRUD (GET/POST/PUT/DELETE); area delete returns 409 {error:'has_tables'} when tables still reference it. Table create now requires areaId (400 invalid_area if unknown) + shape; PATCH /admin/tables/:id/position saves drag-end coordinates. - /staff/floor returns {areas, tables}; each table carries area_id/x/y/shape and oldestSubmittedAt (createdAt of the oldest still-'submitted' order in the open session, else null) to drive tile colour/aging on the client. - Order/table names are composed 'Area · Name' on the kitchen board and session detail via a left join. - Demo seed reworked into two areas (Sala rect, Terrazza circle) with baked positions and short numeric table names. - Schemas: Area, TableShape, Create/UpdateArea, UpdateTablePosition (x:0..1000,y:0..700); CreateTableBody requires areaId+shape. * feat(#15): floor-plan editor + staff view (web) Build the client on top of the backend floor-plan model: - Shared FloorCanvas (web/src/components/floor): absolutely-positioned tiles rendered by percentage into an aspect-ratio 1000/700 container so it scales for free. Read-only for staff; pointer-drag (setPointerCapture, snap-25, no dnd lib) with optimistic PATCH auto-save for admin. Staff tile colours: gray (no session), green (open, no submitted), amber (<15min), red (>=15min), with an elapsed-minutes label. - Admin /admin/tables: area management (add/rename/delete with a friendly has_tables message), area tabs, add-table form (name + shape), and the editor canvas + per-area table list. Area is fixed after creation. - Staff /staff: replaces the tile grid with area tabs + read-only canvas; tap opens/continues the session. Keeps table-<id> testids; adds a 30s tick for the minutes label. - api.ts: fetchAreas/createArea/updateArea/deleteArea/updateTablePosition; createTable now takes areaId+shape; /staff/floor typed as {areas,tables}. OrderReviewPage composes 'Area · Name' in the table select. - i18n: new admin.tables.* keys in all 10 locales. - Tests: FloorCanvas colour/minutes unit test, TablesPage area-tab/create/ 409 test; e2e updated for renamed tables + composed labels, plus a floor tab/tap spec and a real-backend position-PATCH spec. * Rework admin tables page to canvas-first colour-coded floor (#15) Owner feedback: the admin tables page should be canvas-first and colour-coded like the staff floor; the separate table list was redundant. Backend: - Extract shared floor-state builder into lib/floor.ts (buildFloorState with includeInactive). Staff /floor uses it (active tables only, same response shape). New admin GET /admin/floor returns the same shape including inactive tables with an `active` flag. Web: - TablesPage: remove the table list. Poll /admin/floor (10s), render tiles with the same colour coding as staff (inactive dimmed gray). Tap a tile (pointer travel < 5px) opens an inline action panel: rename, activate/deactivate, delete (inline confirm). Drag still moves + autosaves position. - FloorCanvas: tap-vs-drag threshold in editable mode; colour tiles in both views via the shared tileVisual/COLOR_CLASS. - api: fetchAdminFloor(); AdminFloorTable schema type. - i18n: canvasHint + status strings (free/open/waiting) across all 10 locales; dropped the now-unused dragHint. Tests: - backend: /admin/floor returns state and includes inactive tables while /staff/floor excludes them (and carries no active flag). - web unit: tap-opens-panel rename/delete; canvas renders colour-coded tiles. - e2e: expect /admin/floor call, tiles visible, tap opens panel, rename round-trips (demo reset after). * fix(backend): drop blanket admin/staff rate limits, scope the rest A single venue NAT IP shared one per-IP counter across all mounts, so staff polling exhausted the budget and 429ed the admin UI. /admin/* is behind Cloudflare Access and /staff/* requires a session token (consume has its own limiter), so the blanket limits added nothing. Remaining public limiters (/catalog/view, /orders) now key on ip+scope so they cannot starve each other. * feat(checks): backend conto lifecycle + admin table detail (#15) - checks table (0018): open/settled/voided, frozen line snapshot, discount + adjustments JSON - computeCheckTotals shared helper (schemas): subtotal/total, percent rounding, clamp >=0 - admin-checks routes: GET /admin/tables/:id detail, POST /sessions/:id/check, PATCH /checks/:id, settle (closes session), void, POST /sessions/:id/close (manual) - orders createOrder guard: 409 check_open while an open check freezes the session - remove staff close-session route (waiters no longer close tables) * feat(checks): admin table page + conto UI, view/edit toggle (#15) - new /admin/tables/[tableId] page: session orders, check card (discount/adjustments/settle/void/print), history with reprint - TablesPage: default tap navigates to the table page; "Edit layout" toggle gates drag + add/area/panel actions - api.ts: fetchAdminTableDetail, createCheck, updateCheck, settleCheck, voidCheck, adminCloseSession; drop closeTableSession - staff TableDetail: remove close-session button (admin owns checks) - surface 409 check_open on diner/waiter submit paths - print CSS, i18n across 10 locales, unit + e2e coverage * feat(demo): showcase seed - live table + settled-check history (#15) Demo reset now seeds (outside E2E_MODE only, since e2e asserts a clean floor): Sala 2 with an open 25-min session and two active Marco Demo orders spanning Cucina+Bar, and 4 closed sessions with settled checks per remaining table spread over the past 8 days (waiters alternating, one in four with a 10% discount). Deterministic ids, daily numbers computed per UTC day so live submits continue from the seeded count. * feat(admin): add table orders and full demo history (#15) Sala 2 now keeps the live showcase session and still gets the same settled-check history as every other table. Admin table detail can open a free table and append orders to the current session through admin-only endpoints backed by the shared createOrder path, so Cucina/Bar routing and changelog semantics stay consistent. Orders remain blocked while a conto is open. * Fix static export routes and stabilize e2e * Run CI e2e serially
1 parent 0fd54ca commit 870610b

102 files changed

Lines changed: 7652 additions & 2474 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ jobs:
7676
API_VERSION = "v1"
7777
SERVICE_NAME = "menu-backend"
7878
DEMO_MODE = "true"
79+
E2E_MODE = "true"
7980
8081
[[d1_databases]]
8182
binding = "DB"
@@ -97,11 +98,10 @@ jobs:
9798
curl -fsS -X POST http://localhost:8787/admin/demo/reset
9899
curl -fsS http://localhost:8787/catalog | node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const c=JSON.parse(s); const entries=c.categories.flatMap(x=>x.entries); if (!entries.length) throw new Error('seed produced no entries'); console.log(`seeded ${entries.length} entries`)})"
99100
- name: Run Playwright E2E (full non-live suite)
100-
# The chat specs mock /session and /chat at the browser level, so no chat
101-
# worker process is needed — NEXT_PUBLIC_CHAT_WORKER_URL only has to be set
102-
# for the chat FAB to mount. chat-live and auth-setup are excluded via the
103-
# playwright config testIgnore.
104-
run: npm --workspace web run test:e2e -- --project=chromium
101+
# Mocked chat worker specs live outside web/e2e. The non-live e2e suite
102+
# runs against the real DEMO_MODE backend only; chat-live covers the real
103+
# chat worker path separately.
104+
run: npm --workspace web run test:e2e -- --project=chromium --workers=1
105105
env:
106106
NEXT_PUBLIC_API_URL: http://localhost:8787
107107
NEXT_PUBLIC_CHAT_WORKER_URL: http://localhost:8788

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ cd web && npm run dev
9393

9494
Open the frontend dev server's root path for the diner menu.
9595

96-
`/admin` should be protected by Cloudflare Access. In local dev, either point `NEXT_PUBLIC_API_URL` at a deployed backend or use the Playwright admin bypass described in [docs/self-hosting.md](docs/self-hosting.md#8-run-locally). The first migration seeds a `settings` row named "My Restaurant"; change it from `/admin?s=settings`.
96+
`/admin` should be protected by Cloudflare Access. In local dev, point `NEXT_PUBLIC_API_URL` at a backend that can authenticate you, or run the backend with `DEMO_MODE=true` for demo-only access. The first migration seeds a `settings` row named "My Restaurant"; change it from `/admin?s=settings`.
9797

9898
## Common commands
9999

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
CREATE TABLE `staff_links` (
2+
`id` text PRIMARY KEY NOT NULL,
3+
`name` text NOT NULL,
4+
`token` text NOT NULL,
5+
`session_token` text,
6+
`consumed_at` integer,
7+
`revoked_at` integer,
8+
`last_seen_at` integer,
9+
`created_at` integer NOT NULL,
10+
`updated_at` integer NOT NULL
11+
);
12+
--> statement-breakpoint
13+
CREATE UNIQUE INDEX `staff_links_token_idx` ON `staff_links` (`token`);
14+
--> statement-breakpoint
15+
CREATE UNIQUE INDEX `staff_links_session_token_idx` ON `staff_links` (`session_token`);
16+
--> statement-breakpoint
17+
CREATE TABLE `tables` (
18+
`id` text PRIMARY KEY NOT NULL,
19+
`name` text NOT NULL,
20+
`active` integer NOT NULL DEFAULT 1,
21+
`sort_order` integer NOT NULL DEFAULT 0,
22+
`created_at` integer NOT NULL,
23+
`updated_at` integer NOT NULL
24+
);
25+
--> statement-breakpoint
26+
CREATE INDEX `tables_sort_idx` ON `tables` (`sort_order`);
27+
--> statement-breakpoint
28+
CREATE TABLE `table_sessions` (
29+
`id` text PRIMARY KEY NOT NULL,
30+
`table_id` text NOT NULL REFERENCES `tables`(`id`) ON DELETE CASCADE,
31+
`opened_at` integer NOT NULL,
32+
`closed_at` integer
33+
);
34+
--> statement-breakpoint
35+
CREATE INDEX `table_sessions_table_idx` ON `table_sessions` (`table_id`);
36+
--> statement-breakpoint
37+
ALTER TABLE `orders` ADD `table_session_id` text REFERENCES `table_sessions`(`id`) ON DELETE SET NULL;
38+
--> statement-breakpoint
39+
CREATE INDEX `orders_table_session_idx` ON `orders` (`table_session_id`);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- Order status changelog (#15 follow-up): one row per lifecycle event.
2+
CREATE TABLE `order_events` (
3+
`id` text PRIMARY KEY NOT NULL,
4+
`order_id` text NOT NULL,
5+
`status` text NOT NULL,
6+
`actor` text,
7+
`created_at` integer NOT NULL,
8+
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE cascade
9+
);
10+
--> statement-breakpoint
11+
CREATE INDEX `order_events_order_idx` ON `order_events` (`order_id`);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE `order_events` ADD COLUMN `actor_name` text;

backend/drizzle/0017_floorplan.sql

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
CREATE TABLE `areas` (
2+
`id` text PRIMARY KEY NOT NULL,
3+
`name` text NOT NULL,
4+
`sort_order` integer NOT NULL DEFAULT 0,
5+
`created_at` integer NOT NULL,
6+
`updated_at` integer NOT NULL
7+
);
8+
--> statement-breakpoint
9+
CREATE INDEX `areas_sort_idx` ON `areas` (`sort_order`);
10+
--> statement-breakpoint
11+
ALTER TABLE `tables` ADD `area_id` text REFERENCES `areas`(`id`);
12+
--> statement-breakpoint
13+
ALTER TABLE `tables` ADD `x` integer NOT NULL DEFAULT 25;
14+
--> statement-breakpoint
15+
ALTER TABLE `tables` ADD `y` integer NOT NULL DEFAULT 25;
16+
--> statement-breakpoint
17+
ALTER TABLE `tables` ADD `shape` text NOT NULL DEFAULT 'rect';

backend/drizzle/0018_checks.sql

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
CREATE TABLE `checks` (
2+
`id` text PRIMARY KEY NOT NULL,
3+
`table_session_id` text NOT NULL,
4+
`status` text NOT NULL DEFAULT 'open',
5+
`lines` text NOT NULL,
6+
`discount` text,
7+
`adjustments` text NOT NULL DEFAULT '[]',
8+
`created_at` integer NOT NULL,
9+
`settled_at` integer,
10+
`voided_at` integer,
11+
FOREIGN KEY (`table_session_id`) REFERENCES `table_sessions`(`id`) ON UPDATE no action ON DELETE cascade
12+
);
13+
--> statement-breakpoint
14+
CREATE INDEX `checks_table_session_idx` ON `checks` (`table_session_id`);

0 commit comments

Comments
 (0)