Saltar al contenido principal

FlowPOS Restaurant POS — Frontend PWA Design Document

Purpose: Frontend companion to feature-restaurant-pos-v1-complete.md. Covers all restaurant screens, WebSocket client design, component structure, service layer, and Speckit commands for the frontend-pwa. Follows existing PWA conventions: components/forms/, services/, hooks/, contexts/, React Hook Form + Zod, TanStack Query 5, Socket.IO (already in stack), Shadcn/ui, Tailwind CSS, i18next.


Table of Contents

  1. Screen Scope
  2. WebSocket Client Design
  3. Folder Structure
  4. Screen Specifications
    • 4.1 restaurantDining — Floor Plan
    • 4.2 restaurantOrders — Order Taking
    • 4.3 restaurantKds — Kitchen Display System
    • 4.4 restaurantMenus — Menu Management
    • 4.5 restaurantKitchenStations — Station & Device Config
    • 4.6 restaurantReports — Reports
    • 4.7 restaurantDashboard — Summary Dashboard
    • 4.8 restaurantReservations — Reservations
    • 4.9 restaurantWaitlist — Waitlist
    • 4.10 restaurantPriceRules — Price Rules
  5. Service Layer
  6. TanStack Query Hooks
  7. Shared Restaurant Components
  8. i18n Namespaces
  9. Deferred to V2
  10. Implementation Ordering
  11. Speckit Commands

1. Screen Scope

V1 — In Scope

Form routeScreenComplexityNotes
restaurantDiningFloor planHighReal-time table status via Socket.IO
restaurantOrdersOrder takingHighModifier dialog, split bill, 86 state
restaurantKdsKitchen DisplayHighKiosk mode, real-time, aging timers
restaurantMenusMenu managementMediumMenu/item/modifier/recipe CRUD
restaurantKitchenStationsStation & device configMediumPrinter config, pairing code UI
restaurantReportsReportsMediumP-Mix, depletion, shift, turnaround
restaurantDashboardSummary dashboardLowCards pulling existing data
restaurantReservationsReservationsLowList + create/edit form
restaurantWaitlistWaitlistLowList + join/seat actions
restaurantPriceRulesPrice rulesLowTime-based discount CRUD

V2 — Deferred

Form routeReason
restaurantExpoExpediter view — depends on KDS being stable first
restaurantKitchenLoadBatch summary — nice-to-have, not operational blocker
restaurantPackingScannerBarcode scanning workflow — separate device consideration
restaurantExternalPlatformMappingsUber Eats / Rappi — backend integration not in v1 scope

2. WebSocket Client Design

Socket.IO is already in the PWA stack. The restaurant module adds two hooks on top of the existing Socket.IO client — one for KDS devices (device token auth), one for POS terminals and manager screens (Firebase token auth).

2.1 useRestaurantSocket — POS Terminal / Manager Screens

Used by restaurantDining, restaurantOrders, and restaurantDashboard. Connects to the location-wide room (rst:{biz}:{loc}:all) using the Firebase ID token already in AuthContext.

// hooks/restaurant/useRestaurantSocket.ts

export function useRestaurantSocket() {
const { firebaseUser } = useAuth();
const { businessId, locationId } = useLocation();
const queryClient = useQueryClient();
const socketRef = useRef<ReturnType<typeof io> | null>(null);

useEffect(() => {
if (!firebaseUser || !businessId || !locationId) return;

// Async token fetch, but cleanup is synchronous via ref
firebaseUser.getIdToken().then(token => {
// Guard against unmount race — if cleanup already ran, ref is null; don't connect
if (socketRef.current) return;

socketRef.current = io('/kds', {
auth: { token },
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
reconnectionAttempts: Infinity,
});

socketRef.current.on('product:86', () => {
queryClient.invalidateQueries({
queryKey: ['restaurant', 'menu-items', businessId, locationId],
});
});

socketRef.current.on('printer:offline', (payload) => {
toast.warning(t('restaurant.printer.offline', { station: payload.stationName }));
});

socketRef.current.on('order:item:ready', ({ orderId }) => {
queryClient.invalidateQueries({ queryKey: ['restaurant', 'orders', orderId] });
});
});

// Cleanup runs synchronously — no async gap, no leaked connection
return () => {
socketRef.current?.disconnect();
socketRef.current = null;
};
}, [firebaseUser, businessId, locationId]);
}

This hook must be called exactly once, at the RestaurantLayout level — not inside individual screens. See §3 for the RestaurantLayout definition. This keeps a single persistent connection alive while a manager navigates between dining, orders, and dashboard. Calling it inside a form component would disconnect and reconnect on every navigation, causing missed table status updates during the gap.


2.2 useKdsSocket — KDS Device Screens

Used exclusively by restaurantKds. Authenticates with a deviceToken stored in localStorage (the KDS screen is a dedicated device, not a user session — device token is the persistent identity). Requests pending tickets on connect/reconnect.

// hooks/restaurant/useKdsSocket.ts

const DEVICE_TOKEN_KEY = 'flowpos_kds_device_token';

export type KitchenTicket = {
id: string;
orderItemId: string; // needed for modification replacement and ready matching
orderNumber: number;
orderType: 'dine_in' | 'quick_service';
tableAlias: string | null;
seatNo: number | null;
itemName: string;
quantity: number;
modifiers: { groupName: string; optionName: string; type: 'add' | 'remove' | 'default' }[];
notes: string | null;
courseNumber: number | null;
isModification: boolean;
modifiedAt: string | null;
firedAt: string; // ISO timestamp — used for aging timer
status: 'pending' | 'ready' | 'bumped' | 'voided';
};

export function useKdsSocket() {
const [tickets, setTickets] = useState<KitchenTicket[]>([]);
const [connectionState, setConnectionState] =
useState<'connecting' | 'connected' | 'reconnecting' | 'error'>('connecting');

useEffect(() => {
const deviceToken = localStorage.getItem(DEVICE_TOKEN_KEY);
if (!deviceToken) {
setConnectionState('error');
return;
}

const socket = io('/kds', {
auth: { token: deviceToken },
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 60000, // max 1 minute between retries
reconnectionAttempts: Infinity,
});

socket.on('connect', () => setConnectionState('connected'));
socket.on('reconnecting', () => setConnectionState('reconnecting'));
socket.on('connect_error', () => setConnectionState('error'));

socket.on('pending_tickets', (pending: KitchenTicket[]) => {
setTickets(pending);
});

socket.on('order:ticket', (ticket: KitchenTicket) => {
setTickets(prev => {
if (ticket.isModification) {
// The backend voids the old kitchen_ticket and creates a new one with a new id.
// Match on orderItemId (same item) + status='pending', NOT on ticket.id
// (which is the NEW row's id — it won't match the old row).
return [
...prev.filter(t =>
!(t.orderItemId === ticket.orderItemId && t.status === 'pending')
),
ticket,
];
}
return [...prev, ticket];
});
});

// Backend event payload: { orderId, orderItemId, stationId }
// There is no 'ticketId' field — match on orderItemId
socket.on('order:item:ready', ({ orderItemId }: { orderItemId: string }) => {
setTickets(prev => prev.map(t =>
t.orderItemId === orderItemId ? { ...t, status: 'ready' } : t
));
});

return () => socket.disconnect();
}, []);

const bumpTicket = useCallback(async (ticketId: string) => {
await restaurantTicketService.bump(ticketId);
setTickets(prev => prev.filter(t => t.id !== ticketId));
}, []);

const recallTicket = useCallback(async (ticketId: string) => {
const recalled = await restaurantTicketService.recall(ticketId);
setTickets(prev => [...prev, recalled]);
}, []);

return { tickets, connectionState, bumpTicket, recallTicket };
}

Device token storage: localStorage is the correct choice here. The KDS screen is a dedicated physical device running in kiosk mode — the token is its persistent identity, equivalent to a device certificate. It is not a user session token.

Reconnection behaviour: reconnectionAttempts: Infinity ensures the KDS screen recovers automatically after WiFi outages without requiring staff intervention.


2.3 Ticket Registration Flow (First Launch)

When localStorage has no deviceToken, the KDS screen shows a pairing screen instead of the ticket grid:

// components/forms/restaurant/kds/KdsPairingScreen.tsx

export function KdsPairingScreen({ onPaired }: { onPaired: () => void }) {
const [code, setCode] = useState('');
const { mutate, isPending, isError } = useMutation({
mutationFn: (pairingCode: string) =>
restaurantDeviceService.register(pairingCode),
onSuccess: ({ deviceToken }) => {
localStorage.setItem(DEVICE_TOKEN_KEY, deviceToken);
onPaired();
},
});

return (
<div className="flex flex-col items-center justify-center h-screen gap-8 bg-background">
<h1 className="text-3xl font-bold">{t('restaurant.kds.pairing.title')}</h1>
<p className="text-muted-foreground">{t('restaurant.kds.pairing.instruction')}</p>
<Input
className="text-center text-4xl tracking-widest w-48 h-16"
maxLength={6}
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
/>
{isError && <p className="text-destructive">{t('restaurant.kds.pairing.invalid')}</p>}
<Button size="lg" disabled={code.length !== 6 || isPending}
onClick={() => mutate(code)}>
{t('restaurant.kds.pairing.connect')}
</Button>
</div>
);
}

3. Folder Structure

Following the existing components/forms/ convention. A RestaurantLayout wrapper is introduced as the persistent shell for all restaurant screens — it owns the useRestaurantSocket connection so it survives navigation between forms.

src/
├── layouts/
│ └── RestaurantLayout.tsx ← NEW: mounts useRestaurantSocket once;
│ wraps all restaurant form routes in the router

├── components/
│ └── forms/
│ └── restaurant/
│ ├── dashboard/
│ │ └── RestaurantDashboardForm.tsx
│ ├── dining/
│ │ ├── RestaurantDiningForm.tsx ← root, renders floor plan
│ │ ├── FloorPlanGrid.tsx
│ │ ├── TableCard.tsx
│ │ ├── TableStatusBadge.tsx
│ │ └── OpenOrderSheet.tsx ← slide-up when table tapped
│ ├── orders/
│ │ ├── RestaurantOrdersForm.tsx ← root
│ │ ├── MenuGrid.tsx
│ │ ├── MenuItemCard.tsx
│ │ ├── ModifierDialog.tsx ← critical: required group gating
│ │ ├── OrderTicket.tsx ← active order sidebar
│ │ ├── OrderItemRow.tsx
│ │ ├── SplitBillSheet.tsx
│ │ ├── CourseControls.tsx
│ │ └── QuickServiceQueue.tsx
│ ├── kds/
│ │ ├── RestaurantKdsForm.tsx ← root; kiosk mode wrapper
│ │ ├── KdsPairingScreen.tsx
│ │ ├── KdsConnectionBanner.tsx
│ │ ├── KdsTicketGrid.tsx
│ │ ├── KdsTicketCard.tsx ← aging timer, bump, MODIFIED flag
│ │ ├── KdsTicketModifierList.tsx
│ │ └── KdsStationSelector.tsx ← header toggle (All / Station view)
│ ├── menus/
│ │ ├── RestaurantMenusForm.tsx
│ │ ├── MenuList.tsx
│ │ ├── MenuItemForm.tsx
│ │ ├── ModifierGroupForm.tsx
│ │ ├── ModifierOptionForm.tsx
│ │ └── RecipeForm.tsx
│ ├── kitchen-stations/
│ │ ├── RestaurantKitchenStationsForm.tsx
│ │ ├── StationCard.tsx
│ │ ├── StationOutputConfigForm.tsx
│ │ ├── PrinterConfigForm.tsx
│ │ ├── DeviceList.tsx
│ │ ├── PairingCodeDialog.tsx ← shows 6-digit code + countdown
│ │ └── PrinterHealthBadge.tsx
│ ├── reports/
│ │ ├── RestaurantReportsForm.tsx
│ │ ├── ProductMixReport.tsx
│ │ ├── IngredientDepletionReport.tsx
│ │ ├── ShiftSummaryReport.tsx
│ │ └── TableTurnaroundReport.tsx
│ ├── reservations/
│ │ ├── RestaurantReservationsForm.tsx
│ │ └── ReservationForm.tsx
│ ├── waitlist/
│ │ ├── RestaurantWaitlistForm.tsx
│ │ └── WaitlistEntryForm.tsx
│ └── price-rules/
│ ├── RestaurantPriceRulesForm.tsx
│ └── PriceRuleForm.tsx

├── hooks/
│ ├── useLongPress.ts ← general utility
│ ├── useCountdown.ts ← general utility
│ └── restaurant/
│ ├── useRestaurantSocket.ts ← Option A: called inside RestaurantLayout
│ ├── useConditionalRestaurantSocket.ts ← Option B: called inside MainPage
│ ├── useKdsSocket.ts
│ ├── useRestaurantTables.ts
│ ├── useRestaurantOrder.ts
│ ├── useRestaurantMenu.ts
│ ├── useRestaurantModifiers.ts
│ ├── useRestaurantRecipe.ts
│ ├── useKitchenStations.ts
│ ├── useKitchenTickets.ts
│ ├── useKdsDevices.ts
│ ├── useRestaurantShift.ts
│ ├── useRestaurantReports.ts
│ ├── useReservations.ts
│ ├── useWaitlist.ts
│ └── usePriceRules.ts

├── services/
│ └── restaurant/
│ ├── restaurant-table.service.ts
│ ├── restaurant-order.service.ts
│ ├── restaurant-menu.service.ts
│ ├── restaurant-modifier.service.ts
│ ├── restaurant-recipe.service.ts
│ ├── restaurant-kitchen-station.service.ts
│ ├── restaurant-ticket.service.ts
│ ├── restaurant-device.service.ts
│ ├── restaurant-shift.service.ts
│ ├── restaurant-platform-mapping.service.ts
│ └── restaurant-reports.service.ts

└── schemas/
└── restaurant/
├── menu-item.schema.ts
├── modifier-group.schema.ts
├── order.schema.ts
├── shift.schema.ts
├── price-rule.schema.ts
└── reservation.schema.ts

RestaurantLayout — Integration Strategy

useRestaurantSocket must be mounted once for the duration of a restaurant session, not inside individual form components (which would disconnect on every navigation). Two strategies depending on how the PWA router is configured:

Option A — Dedicated nested route (if restaurant forms have distinct URL paths):

// layouts/RestaurantLayout.tsx
export function RestaurantLayout() {
useRestaurantSocket();
return <Outlet />;
}

// Router config — wrap all restaurant form routes except restaurantKds:
{
path: '/forms/restaurant*',
element: <RestaurantLayout />,
children: [
{ path: 'restaurantDining', element: <RestaurantDiningForm /> },
{ path: 'restaurantOrders', element: <RestaurantOrdersForm /> },
// ... all other restaurant routes except restaurantKds
],
}

Option B — MainPage conditional mount (if restaurant forms use ?form= query params inside MainPage):

The existing PWA renders all forms inside MainPage via location.state?.selectedForm or ?form= query params. In this case there is no nested route boundary to attach RestaurantLayout to. Instead, add a conditional mount inside MainPage itself:

// Inside MainPage (or its nearest stable ancestor that persists across form changes)
const activeForm = useActiveForm(); // reads ?form= or location.state
const isRestaurantForm = activeForm?.startsWith('restaurant') &&
activeForm !== 'restaurantKds';

// Mount socket for the duration of any restaurant form session
useConditionalRestaurantSocket(isRestaurantForm);
// hooks/restaurant/useConditionalRestaurantSocket.ts
export function useConditionalRestaurantSocket(enabled: boolean) {
const { firebaseUser } = useAuth();
const { businessId, locationId } = useLocation();
const queryClient = useQueryClient();
const socketRef = useRef<ReturnType<typeof io> | null>(null);

useEffect(() => {
if (!enabled || !firebaseUser || !businessId || !locationId) {
socketRef.current?.disconnect();
socketRef.current = null;
return;
}

firebaseUser.getIdToken().then(token => {
if (socketRef.current) return; // already connected
socketRef.current = io('/kds', {
auth: { token },
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
reconnectionAttempts: Infinity,
});
socketRef.current.on('product:86', () => {
queryClient.invalidateQueries({
queryKey: ['restaurant', 'menu-items', businessId, locationId],
});
});
socketRef.current.on('printer:offline', (payload) => {
toast.warning(t('restaurant.printer.offline', { station: payload.stationName }));
});
socketRef.current.on('order:item:ready', ({ orderId }) => {
queryClient.invalidateQueries({ queryKey: ['restaurant', 'orders', orderId] });
});
});

return () => {
socketRef.current?.disconnect();
socketRef.current = null;
};
}, [enabled, firebaseUser, businessId, locationId]);
}

Which option to use: Inspect how the PWA routes restaurant forms. If they use ?form=restaurantDining inside a single MainPage component, use Option B. If they have distinct URL segments (/forms/restaurantDining), use Option A. The behaviour is identical — the difference is only where the hook is mounted.

restaurantKds is excluded from both options because it uses useKdsSocket (device token auth) and its own fullscreen kiosk wrapper.

4.1 restaurantDining — Floor Plan

Purpose: Visual table map for the shift. Shows real-time table status. Entry point for opening/managing orders for dine-in service.

Layout: Full-height grid of TableCard components, each positioned via CSS grid or absolute positioning using dining_table.positionX/Y. Header shows location name, shift status, and active table count.

TableCard states:

StatusBorderBackgroundLabel
IDLEgraywhiteTable name
OCCUPIEDblueblue-50Table + server initials + elapsed time
BILL_PRINTEDamberamber-50Table + "Cuenta" badge
CLOSEDgreen fadingwhiteBriefly shows before returning to IDLE

Interactions:

  • Tap idle table → OpenOrderSheet slides up → select service type → creates order
  • Tap occupied table → OpenOrderSheet with current order summary + "Manage" CTA
  • Long-press table → quick actions: merge, transfer, assign server

Real-time updates: useRestaurantSocket listens for order:status_change and calls queryClient.invalidateQueries(['restaurant', 'tables', locationId]). Table cards re-render without full page refresh.

86 indicator: If a product on an active table's order is 86'd mid-service, a subtle warning badge appears on that table card.

// components/forms/restaurant/dining/TableCard.tsx
// Props: table: RestaurantTable, onClick: () => void
// Renders status-coded card with elapsed timer using useElapsedTime(table.occupiedAt)

4.2 restaurantOrders — Order Taking

Purpose: The main POS screen for adding items to an order. Used for both dine-in (opened from floor plan) and quick service (new order button).

Layout: Two-column: left = menu grid (categories + items), right = order ticket sidebar. On mobile: bottom sheet for order ticket.

Menu grid:

  • Categories as horizontal tab strip or vertical filter list
  • MenuItemCard shows: item name, price, thumbnail, 86'd overlay when is_86d = true
  • Active price rules shown as strikethrough + discounted price
  • Items unavailable due to time window shown as dimmed, not tappable

Modifier dialog (ModifierDialog): Opened when tapping any item that has modifier groups. This is the most critical UI piece in the whole module.

┌─────────────────────────────────────────┐
│ Hamburguesa Especial │
│ Q 65.00 │
├─────────────────────────────────────────┤
│ Término de la carne * │ ← required group — asterisk
│ ○ Rojo ○ Tres cuartos ● Bien cocido │
├─────────────────────────────────────────┤
│ Extras (opcional) │ ← optional group
│ ☐ Extra queso +Q5 ☑ Sin cebolla Q0 │
├─────────────────────────────────────────┤
│ Notas especiales │
│ [ ] │
├─────────────────────────────────────────┤
│ [ Cancelar ] [ Agregar Q70.00 ]│
│ ← disabled until required groups satisfied
└─────────────────────────────────────────┘

Rules:

  • "Add" button disabled until all isRequired groups have minSelections met
  • maxSelections = 1 → radio button group
  • maxSelections > 1 → checkbox group with counter (2 / 3 seleccionados)
  • Each option shows priceAdjustment if non-zero (+Q5, -Q3, free)
  • Running total updates live as options are selected
  • V1 renders one level of modifier groups only. Nested modifiers (e.g. "Choose Side" → "Salad" → "Choose Dressing") are deferred to v2. The backend schema supports nesting via modifier.ingredient_product_id but the dialog must not attempt to render recursive groups in v1.

Order ticket sidebar:

  • Lists order_item rows grouped by course
  • Each row shows: name, modifiers summary, price, void button (manager-gated)
  • Hold/Fire toggle per item (course pacing)
  • Running subtotal + tax + total
  • Actions: "Send to Kitchen" (fires all non-held items), "Print Bill", "Split Bill"

Split bill sheet (SplitBillSheet): Three modes selectable via segmented control:

  • By item: tap-to-assign — tap an item in the unassigned column, then tap which check it belongs to. No drag-and-drop: drag-and-drop is unreliable on touchscreens in a kitchen environment and would require adding @dnd-kit/core (a significant new dependency). Tap-to-assign matches how most restaurant POS systems implement this on touch hardware.
  • Equal: number spinner splits total evenly; subtotal per check updates live
  • By seat: assigns items to seats already defined in order_guest; shows seat alias labels from order_guest.guest_alias

86 state: 86'd items show a red AGOTADO badge overlay on the card. Tapping them shows a toast: "Este producto no está disponible en este momento." They cannot be added to an order while product.is_86d = true.


4.3 restaurantKds — Kitchen Display System

Purpose: Dedicated screen for kitchen staff. Displays incoming ticket cards in real time. Designed for touch use at distance in a hot, loud environment.

Kiosk mode: This screen is meant to run fullscreen on a dedicated tablet with no navigation chrome. Hide the main TreeMenu/nav and render full-viewport when ?kiosk=true is present in the URL. The presence of deviceToken in localStorage must not trigger kiosk mode — a manager who opens the full PWA on a previously paired tablet would find the navigation silently suppressed, making the app appear broken. Separate the concerns:

  • ?kiosk=true → hide nav, fullscreen layout (URL param, set once when deploying the tablet bookmark)
  • deviceToken in localStorage → determines which auth path useKdsSocket uses (device token vs. pairing screen); has no effect on navigation visibility

Connection states:

// KdsConnectionBanner.tsx — always visible at top of screen
// 'connected' → no banner (clean)
// 'reconnecting' → amber banner: "Reconectando..."
// 'error' → red banner: "Sin conexión — las comandas pueden perderse"

Ticket grid:

┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐
│ #42 · T-04 · P2 │ │ #43 · Llevar │ │ #41 · T-07 · P1 │
│ ──────────────── │ │ ──────────────── │ │ ──────────────── │
│ HAMBURGUESA ESP. │ │ LIMONADA ×2 │ │ POLLO A PLANCHA │
│ • Tres cuartos │ │ │ │ ──────────────── │
│ • SIN CEBOLLA │ │ │ │ MODIFICADO ⚠ │
│ ──────────────── │ │ │ │ • CON LIMÓN │
│ Sin gluten │ │ │ │ │
│ ──────────────── │ │ ──────────────── │ │ ──────────────── │
│ 04:23 · BUMP ✓ │ │ 01:05 · BUMP ✓ │ │ 11:47 · BUMP ✓ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
green border green border red pulsing border

KdsTicketCard design rules:

  • Font size must be readable at arm's length — minimum text-lg for item name, text-2xl font-bold for very short names
  • Border colour derived from useTicketAge(ticket.firedAt, now) where now is passed as a prop from KdsTicketGrid (see aging timer section below):
    • urgency === 'normal'border-green-500
    • urgency === 'warning'border-yellow-500
    • urgency === 'critical'border-red-500 animate-pulse
  • Modifier colours:
    • Removal modifiers ("SIN CEBOLLA", "SIN GLUTEN") → text-red-600 font-semibold uppercase
    • Addition modifiers ("EXTRA QUESO") → text-green-700 font-semibold uppercase
    • Default modifiers → text-foreground
  • isModification: true → amber MODIFICADO ⚠ badge in top-right corner of card
  • BUMP button is large, full-width, high-contrast — easy to tap with wet/gloved hands
  • Long-press BUMP → confirm dialog (prevents accidental bumps)
  • RECALL (undo last bump) → floating button in bottom-right corner of grid

useLongPress hookonLongPress is not a native React event. Without an explicit implementation, Speckit will guess incorrectly (typically onContextMenu, which does not fire on touch). Use pointer events with a setTimeout ref, cancelling on pointer up or leave to handle scrolling correctly:

// hooks/useLongPress.ts  (general utility — not restaurant-specific)
export function useLongPress(onLongPress: () => void, ms = 600) {
const timerRef = useRef<ReturnType<typeof setTimeout>>();
return {
onPointerDown: () => {
timerRef.current = setTimeout(onLongPress, ms);
},
onPointerUp: () => clearTimeout(timerRef.current),
onPointerLeave: () => clearTimeout(timerRef.current),
onPointerCancel: () => clearTimeout(timerRef.current),
};
}

Usage on the BUMP button:

const longPress = useLongPress(() => setShowBumpConfirm(true));
<Button {...longPress} className="w-full h-16 text-xl">
{t('restaurant.kds.ticket.bump')}
</Button>

useCountdown hook — referenced by PairingCodeDialog. General utility, not restaurant-specific:

// hooks/useCountdown.ts
export function useCountdown(initialSeconds: number) {
const [remaining, setRemaining] = useState(initialSeconds);

useEffect(() => {
setRemaining(initialSeconds); // reset when initialSeconds changes
if (initialSeconds <= 0) return;
const id = setInterval(() =>
setRemaining(s => (s <= 1 ? 0 : s - 1)), 1000);
return () => clearInterval(id);
}, [initialSeconds]);

const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
return {
remaining,
display: `${minutes}:${seconds.toString().padStart(2, '0')}`,
expired: remaining === 0,
};
}

Usage in PairingCodeDialog:

const { display, expired } = useCountdown(data.expiresInSeconds);
// expired === true → show "Generar nuevo" button enabled

Aging timer — single global clock (important for tablet performance):

A setInterval inside each ticket card (one per ticket, 1s cadence) causes N simultaneous re-renders every second. On a mid-range Android tablet with 20 active tickets this produces visible stuttering. Use a single clock at the grid level instead — one interval provides a now timestamp, and each card derives its elapsed time from now - firedAt as a simple calculation on each render:

// hooks/restaurant/useKdsClock.ts — one interval for the entire grid
export function useKdsClock() {
const [now, setNow] = useState(Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
return now;
}

// hooks/restaurant/useTicketAge.ts — pure calculation, no timer
export function useTicketAge(firedAt: string, now: number) {
const elapsedMs = now - new Date(firedAt).getTime();
const minutes = Math.floor(elapsedMs / 60000);
const seconds = Math.floor((elapsedMs % 60000) / 1000);
const urgency: 'normal' | 'warning' | 'critical' =
minutes >= 10 ? 'critical' : minutes >= 5 ? 'warning' : 'normal';
return { display: `${minutes}:${seconds.toString().padStart(2, '0')}`, urgency };
}

KdsTicketGrid calls useKdsClock() once and passes now as a prop to each KdsTicketCard. One re-render per second for the whole grid, not N re-renders.

KdsTicketCard must be wrapped in React.memo with a custom comparator, or the global clock still causes full re-renders of every card every second — React re-renders any component whose props change, even if the output is visually identical. The comparator limits re-renders to only when a card's urgency level transitions or its ticket data changes:

// components/forms/restaurant/kds/KdsTicketCard.tsx

function getUrgency(now: number, firedAt: string): 'normal' | 'warning' | 'critical' {
const minutes = Math.floor((now - new Date(firedAt).getTime()) / 60000);
return minutes >= 10 ? 'critical' : minutes >= 5 ? 'warning' : 'normal';
}

export const KdsTicketCard = React.memo(
({ ticket, now, onBump, onRecall }: KdsTicketCardProps) => {
const { display, urgency } = useTicketAge(ticket.firedAt, now);
// ... render
},
(prev, next) => {
// Re-render if ticket data changed (modification, status update, etc.)
if (prev.ticket !== next.ticket) return false;
// Re-render only when urgency level changes — not on every second tick
const prevUrgency = getUrgency(prev.now, prev.ticket.firedAt);
const nextUrgency = getUrgency(next.now, next.ticket.firedAt);
return prevUrgency === nextUrgency;
},
);

KdsTicketGrid must be wrapped in an ErrorBoundary — a runtime error in any ticket card (malformed ticket_data_jsonb, unexpected null, etc.) will crash the entire KDS screen to a blank white page. Kitchen tablets run unattended; a crashed screen goes unnoticed until orders back up. Use react-error-boundary (MIT, no other dependencies — add to package.json). It renders a recoverable fallback instead of a blank page:

// components/forms/restaurant/kds/KdsTicketGrid.tsx
import { ErrorBoundary } from 'react-error-boundary';

export function KdsTicketGrid({ ... }) {
const now = useKdsClock();
return (
<ErrorBoundary
fallbackRender={() => (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-destructive text-xl font-bold">
{t('restaurant.kds.error.title')}
</p>
<Button onClick={() => window.location.reload()}>
{t('restaurant.kds.error.reload')}
</Button>
</div>
)}
>
<div className="grid grid-cols-3 gap-4 p-4">
{tickets.map(ticket => (
<KdsTicketCard key={ticket.id} ticket={ticket} now={now}
onBump={onBump} onRecall={onRecall} />
))}
</div>
</ErrorBoundary>
);
}

Add i18n keys: restaurant.kds.error.title ("Error en pantalla KDS") and restaurant.kds.error.reload ("Recargar").

Station selector: Header toggle allows switching between "Todos" (all tickets for this station) and filtering by course number. The KDS device is bound to a single station — it cannot see other stations' tickets.


4.4 restaurantMenus — Menu Management

Purpose: Admin screen for configuring menus, categories, items, modifier groups, and recipes. Not a real-time screen — standard CRUD.

Sub-sections (tab navigation):

  1. Menus — list of named menus; create/edit with available_from/to time pickers; location assignment via multi-select
  2. Menu Items — filterable list by menu/category; create/edit with:
    • Basic fields: name, description, price, category, image upload
    • Availability: time window, is_active, is_86d toggle (prominent — "Marcar agotado")
    • Combo toggle: if enabled, show combo_items_jsonb builder (product multi-select + qty)
  3. Modifier Groups — scoped to selected menu item; form per group with options list
    • Inline option editing (name, price adjustment, ingredient link for BOM)
    • Drag-to-reorder (sort_order)
    • Archive button (not Delete) for groups with order history — shows warning dialog
  4. Recipes (BOM) — scoped to selected menu item; ingredient rows with quantity + UOM
    • Ingredient autocomplete searches product where inventory_type = raw_material
    • is_optional checkbox per ingredient line

Important UX for modifier groups: When a user clicks "Eliminar" on a modifier group that has order history, show a confirmation dialog explaining the group will be archived (hidden from future orders) rather than permanently deleted. This prevents confusion from the ON DELETE RESTRICT operational constraint.


4.5 restaurantKitchenStations — Station & Device Config

Purpose: Manager screen for configuring kitchen stations and registering KDS devices. Accessed infrequently but critical for initial setup.

Station list: Cards per station showing: name, output type badge (KDS / Printer / Both), printer status indicator, device count.

StationOutputConfigForm:

Output type:  [○ Printer]  [○ KDS]  [● Both]

Printer URL: [ http://192.168.1.50:9100 ]
Paper width: [● 80mm] [○ 58mm]
Copies: [ 1 ▲▼ ]
Open cash drawer: [ ○ ]

Fallback station: [ Expo Station ▼ ]
↳ Warning shown if selected station creates a circular reference

[ Save ] [ Test Print ]

PairingCodeDialog:

Opened via "Vincular dispositivo KDS" button on a station card. Calls POST /restaurant/stations/:id/pairing-code and displays:

┌─────────────────────────────────────┐
│ Código de vinculación │
│ │
│ 3 8 5 2 1 7 │ ← large monospaced digits
│ │
│ Ingresa este código en la pantalla │
│ de cocina para conectarla. │
│ │
│ Expira en 9:47 │ ← countdown from response.expiresInSeconds
│ │
│ [ Cancelar ] [ Generar nuevo ] │
└─────────────────────────────────────┘

The countdown uses useCountdown(data.expiresInSeconds)not a hardcoded 600. The countdown must start from the value returned by the API response. If the request takes 3 seconds, starting from a hardcoded 600 would display an incorrect expiry time and confuse staff when the code is rejected as expired. The dialog receives expiresInSeconds as a prop from the mutation result:

const { mutate, data } = useMutation({
mutationFn: () => restaurantDeviceService.generatePairingCode(stationId),
});
// After success, render:
<PairingCodeDialog
code={data.code}
expiresInSeconds={data.expiresInSeconds} // from API, not hardcoded
onClose={...}
/>

When countdown hits zero, the "Generate new" button enables immediately (no artificial 2-second delay — the code is already expired; there is no reason to block regeneration).

DeviceList: Shows registered devices per station with: device name, last seen timestamp, active badge. "Desvincular" button calls DELETE /restaurant/devices/:id with a confirmation dialog explaining that the device will need to be re-paired.

PrinterHealthBadge: A coloured dot per station:

  • Green → online
  • Amber → unknown (not yet checked)
  • Red → offline (shows fallback station name if configured)

Refreshes every 30 seconds via TanStack Query staleTime: 30_000.


4.6 restaurantReports — Reports

Purpose: Manager screen with tabbed reports. Standard data display — no real-time.

Tabs:

  1. Product Mix (P-Mix) — table: item name, units sold, revenue, cost, margin %; colour-coded Stars (high margin + high volume) vs. Dogs (low both); date range picker
  2. Ingredient Depletion — table: ingredient, theoretical usage, actual ledger usage, variance; highlights items with variance > 10%
  3. Shift Summary — table of shifts: staff name, opened/closed times, opening float, declared cash, variance; colour-code variance (red if > Q10 over/under)
  4. Table Turnaround — average time from fire to ready per station; histogram by hour of day; date range filter

All reports use TanStack Query with staleTime: 5 * 60 * 1000 (5 minutes). No polling.


4.7 restaurantDashboard — Summary Dashboard

Purpose: At-a-glance overview of the current shift. Composable from existing data.

Metric cards:

  • Active tables (occupied / total)
  • Open orders count
  • Revenue today (from sale records via existing sales service)
  • Average ticket value
  • Pending KDS tickets across all stations

Standard TanStack Query fetches. No WebSocket needed. Auto-refreshes every 60 seconds via refetchInterval: 60_000.


4.8 restaurantReservations — Reservations

Purpose: List and manage today's reservations. Standard CRUD with status workflow.

List view: Chronological table of reservations with: time, party size, guest name, table assignment, status badge (pending → confirmed → seated → no_show / cancelled).

Status transitions via action buttons:

  • Pending → Confirmed: "Confirmar"
  • Confirmed → Seated: "Sentar" (also opens OpenOrderSheet for the assigned table)
  • Any → Cancelled / No-show: "Cancelar" / "No se presentó"

Create/edit form: ReservationForm with React Hook Form + Zod. Fields: reserved_at (date + time picker), party_size, customer search (existing customer autocomplete), table assignment (optional), notes.


4.9 restaurantWaitlist — Waitlist

Purpose: Queue of walk-in guests waiting for a table.

List view: Queue ordered by created_at. Each row: position, party size, wait time elapsed, notify phone, status badge.

Actions: "Notificar" (marks as notified), "Sentar" (marks as seated, optionally opens table assignment), "Cancelar".

Add to waitlist form: WaitlistEntryForm — party_size, notify_phone (optional), estimated_wait_minutes (set by manager, shown to guest).


4.10 restaurantPriceRules — Price Rules

Purpose: Configure happy hour and time-based pricing rules.

List view: Cards per rule showing: name, schedule summary ("Lunes–Viernes 17:00–19:00"), discount summary ("20% descuento"), affected products/categories count, active toggle.

PriceRuleForm:

  • Name, is_active
  • Day of week: multi-select checkbox grid (L M M J V S D)
  • Start/end time: time pickers
  • Discount type: percentage or fixed toggle
  • Discount value: numeric input
  • Scope: radio — "All items" / "By category" / "By product"
    • Category multi-select or product multi-select depending on scope
  • Location scope: optional location picker (null = all locations)

5. Service Layer

Following the existing services/ convention — typed functions, no business logic:

// services/restaurant/restaurant-order.service.ts
export const restaurantOrderService = {
listActive: (businessId: string, locationId: string) =>
api.get<RestaurantOrder[]>(`/restaurant/orders`, { params: { locationId, status: 'open' } }),

get: (orderId: string) =>
api.get<RestaurantOrder>(`/restaurant/orders/${orderId}`),

open: (data: OpenOrderDto) =>
api.post<RestaurantOrder>(`/restaurant/orders`, data),

addItems: (orderId: string, data: AddItemsDto) =>
api.post<RestaurantOrder>(`/restaurant/orders/${orderId}/items`, data),

voidItem: (orderId: string, itemId: string, data: VoidItemDto) =>
api.delete(`/restaurant/orders/${orderId}/items/${itemId}`, { data }),

fire: (orderId: string) =>
api.post(`/restaurant/orders/${orderId}/fire`),

printBill: (orderId: string) =>
api.post(`/restaurant/orders/${orderId}/print-bill`),

settle: (orderId: string, data: SettleOrderDto) =>
api.post(`/restaurant/orders/${orderId}/settle`, data),

split: (orderId: string, data: SplitOrderDto) =>
api.post(`/restaurant/orders/${orderId}/split`, data),

payCheck: (orderId: string, checkId: string, data: PayCheckDto) =>
api.post(`/restaurant/orders/${orderId}/checks/${checkId}/pay`, data),
};

// services/restaurant/restaurant-device.service.ts
export const restaurantDeviceService = {
generatePairingCode: (stationId: string) =>
api.post<{ code: string; expiresInSeconds: number }>(
`/restaurant/stations/${stationId}/pairing-code`),

register: (pairingCode: string) =>
api.post<{ deviceToken: string }>(`/restaurant/devices/register`, { pairingCode }),

list: (locationId: string) =>
api.get<KdsDevice[]>(`/restaurant/devices`, { params: { locationId } }),

deregister: (deviceId: string) =>
api.delete(`/restaurant/devices/${deviceId}`),
};

All other services follow the same pattern. Service functions are thin — no state, no side effects beyond the HTTP call.


6. TanStack Query Hooks

// hooks/restaurant/useRestaurantMenu.ts
export function useRestaurantMenu(locationId: string) {
const { businessId } = useBusiness();
return useQuery({
queryKey: ['restaurant', 'menu', businessId, locationId],
queryFn: () => restaurantMenuService.getForLocation(locationId),
staleTime: 2 * 60 * 1000, // menus change rarely mid-shift
});
}

// hooks/restaurant/useRestaurantOrder.ts
export function useRestaurantOrder(orderId: string | null) {
return useQuery({
queryKey: ['restaurant', 'orders', orderId],
queryFn: () => restaurantOrderService.get(orderId!),
enabled: !!orderId,
staleTime: 0, // orders change frequently — always fresh
});
}

// hooks/restaurant/useRestaurantTables.ts
export function useRestaurantTables(locationId: string) {
const { businessId } = useBusiness();
return useQuery({
queryKey: ['restaurant', 'tables', businessId, locationId],
queryFn: () => restaurantTableService.list(locationId),
refetchInterval: 15_000, // fallback poll — WebSocket is primary
staleTime: 0,
});
}

// hooks/restaurant/useKitchenTickets.ts (REST fallback for non-KDS device access)
export function useKitchenTickets(stationId: string) {
return useQuery({
queryKey: ['restaurant', 'tickets', stationId],
queryFn: () => restaurantTicketService.list({ stationId, status: 'pending' }),
staleTime: 0,
});
}

Query key convention: ['restaurant', entity, ...scopeIds] — consistent with the rest of the PWA's query key patterns.


7. Shared Restaurant Components

// components/forms/restaurant/shared/

// Restaurant86Badge.tsx
// Shows "AGOTADO" overlay on any menu item card when product.is_86d = true
// Props: isAgotado: boolean; children: ReactNode

// RestaurantOrderTypeBadge.tsx
// Pill badge: "Mesa" (blue) | "Llevar" (green) | "Delivery" (purple)

// RestaurantStatusBadge.tsx
// Reusable status pill for table/order/reservation/waitlist status values

// CourseNumberBadge.tsx
// Small numbered badge showing course position (1 = Entrada, 2 = Plato fuerte, etc.)

// ModifierSummary.tsx
// Compact one-line modifier summary for order ticket rows
// e.g. "Tres cuartos • Sin cebolla • Extra queso"
// Props: modifiers: { name: string; type: 'add' | 'remove' | 'default' }[]

8. i18n Namespaces

Add a restaurant namespace to the existing i18n setup:

// locales/es/restaurant.json  (Guatemalan Spanish — primary)
{
"kds": {
"pairing": {
"title": "Conectar pantalla de cocina",
"instruction": "Ingresa el código que aparece en la aplicación del gerente",
"connect": "Conectar",
"invalid": "Código inválido o expirado"
},
"connection": {
"reconnecting": "Reconectando...",
"error": "Sin conexión — las comandas pueden perderse"
},
"ticket": {
"modified": "MODIFICADO",
"bump": "Listo",
"recall": "Deshacer"
}
},
"dining": {
"table": {
"idle": "Disponible",
"occupied": "Ocupada",
"bill_printed": "Cuenta impresa"
},
"open_order": "Abrir comanda",
"manage_order": "Ver comanda"
},
"orders": {
"send_to_kitchen": "Enviar a cocina",
"print_bill": "Imprimir cuenta",
"split_bill": "Dividir cuenta",
"item_86d": "Este producto no está disponible en este momento.",
"required_modifier": "Selecciona una opción para continuar"
},
"modifier": {
"archive_confirm": "Este grupo tiene historial de pedidos y será archivado en lugar de eliminado. Los pedidos existentes no se verán afectados.",
"archive": "Archivar",
"delete": "Eliminar"
},
"station": {
"printer": {
"online": "Impresora en línea",
"offline": "Impresora sin conexión",
"unknown": "Estado desconocido"
},
"pairing_code": "Código de vinculación",
"deregister_confirm": "Esta pantalla deberá vincularse nuevamente. ¿Continuar?"
}
}

// locales/en/restaurant.json (English — secondary)
// Mirror structure with English strings

9. Deferred to V2

ScreenWhy deferredWhat's needed first
restaurantExpoExpediter view of all stations — only valuable when KDS is stableKDS v1 in production
restaurantKitchenLoadBatch cook summary screenMulti-station KDS + data
restaurantPackingScannerBarcode scanning for order assembly verificationHardware integration
restaurantExternalPlatformMappingsUber Eats / Rappi mapping UIBackend integration not in v1

10. Implementation Ordering

Mirrors the backend phases — frontend phases align so frontend can test against real endpoints as they land.

Phase F1 — Foundation (after backend Phase 1 migrations)
F1a. Create folder structure under components/forms/restaurant/ and layouts/
F1b. RestaurantLayout.tsx — calls useRestaurantSocket; wraps all restaurant form
routes except restaurantKds; register in router config
F1c. Create service files (stub implementations)
F1d. Create hook files (stub queries)
F1e. Register all 10 v1 restaurant form routes in MainPage form router
F1f. Add restaurant i18n namespace (es + en)
F1g. Create shared restaurant components (badges, ModifierSummary)
F1h. useLongPress hook (hooks/useLongPress.ts — general utility)
F1i. useCountdown hook (hooks/useCountdown.ts — general utility)

Phase F2 — Menu management (after backend Phase 4-5)
F2a. RestaurantMenusForm with tabs: Menus, Items, Modifiers, Recipe
F2b. MenuItemForm (create/edit) with combo toggle
F2c. ModifierGroupForm + ModifierOptionForm (drag-to-reorder sort_order)
F2d. Archive confirmation dialog for modifiers with order history
F2e. RecipeForm with ingredient autocomplete

Phase F3 — Order taking (after backend Phase 6)
F3a. RestaurantOrdersForm shell with menu grid + order ticket sidebar
F3b. MenuItemCard with 86'd overlay and price rule display
F3c. ModifierDialog with required group gating (one level only — no nested groups)
F3d. OrderTicket, OrderItemRow, CourseControls
F3e. SplitBillSheet (by_item tap-to-assign, equal, by_seat modes)
F3f. Tests: ModifierDialog validation logic

Phase F4 — KDS (after backend Phase 7)
F4a. useKdsClock hook (single global 1s interval for the grid)
F4b. useTicketAge hook (pure calculation, receives now as param — no internal timer)
F4c. useKdsSocket hook with socketRef cleanup and reconnection
F4d. KdsPairingScreen (first launch, no deviceToken)
F4e. KdsConnectionBanner (connection state)
F4f. KdsTicketCard (border colours, modifier styling, MODIFIED badge,
useLongPress for BUMP confirm, receives now prop from grid)
F4g. KdsTicketGrid (calls useKdsClock, passes now to each card)
F4h. RestaurantKdsForm with kiosk mode (hides nav only when ?kiosk=true — NOT
when deviceToken present)
F4i. Tests: useKdsClock, useTicketAge, bump/recall state transitions

Phase F5 — Floor plan + real-time (after backend Phase 7)
F5a. useRestaurantSocket hook with socketRef cleanup pattern
(mounted by RestaurantLayout — not called here directly)
F5b. FloorPlanGrid + TableCard with status colours
F5c. OpenOrderSheet (open new order from idle table)
F5d. Real-time table status update via socket invalidating tables query
F5e. RestaurantDashboard summary cards

Phase F6 — Stations + devices (after backend Phase 7)
F6a. RestaurantKitchenStationsForm with station cards
F6b. StationOutputConfigForm + PrinterConfigForm
F6c. PairingCodeDialog — countdown from response.expiresInSeconds (not hardcoded);
"Generate new" enables immediately when countdown hits zero
F6d. DeviceList with deregister confirmation
F6e. PrinterHealthBadge with 30s refresh

Phase F7 — Shift, reservations, waitlist, price rules (after backend Phase 8-10)
F7a. Shift management: open/close shift flow, blind drop dialog
F7b. ReservationForm + list with status transitions
F7c. WaitlistEntryForm + queue list
F7d. PriceRuleForm with day-of-week selector

Phase F8 — Reports (after backend Phase 11)
F8a. ProductMixReport table with Stars/Dogs colouring
F8b. IngredientDepletionReport with variance highlighting
F8c. ShiftSummaryReport with variance colouring
F8d. TableTurnaroundReport

Phase F9 — Integration & polish
F9a. End-to-end flow tests (open table → add items → fire → KDS bump → settle)
F9b. Offline behaviour: queue failed mutations for retry on reconnect
F9c. i18n completeness audit
F9d. Accessibility pass (touch targets ≥ 44px, contrast ratios, keyboard nav)

11. Speckit Commands

Command FA — UI Foundation + Menu + Orders

/speckit.specify FlowPOS Restaurant PWA — frontend screens for menu management and
order taking. Stack: React 19, Vite 6, TypeScript, React Router 7, TanStack Query 5,
React Hook Form + Zod, Socket.IO (already in stack), Tailwind CSS, Shadcn/ui, i18next,
Firebase Auth. Follows existing PWA conventions: components/forms/, services/, hooks/,
schemas/, MainPage ?form= routing pattern, businessId/locationId from context.

Add the following frontend modules:

(1) RestaurantLayout / socket mount strategy — two options depending on routing:
Option A (distinct URL paths): RestaurantLayout wrapper in the router calling
useRestaurantSocket, wrapping all restaurant routes except restaurantKds.
Option B (MainPage ?form= query params, which is the existing PWA pattern):
useConditionalRestaurantSocket(enabled) called inside MainPage, enabled when
activeForm starts with "restaurant" and is not "restaurantKds". Both options produce
the same behaviour — a single persistent Socket.IO connection for the duration of any
restaurant form session. Inspect the actual routing before choosing.

(2) Folder structure: components/forms/restaurant/{dining,orders,kds,menus,
kitchen-stations,reports,reservations,waitlist,price-rules,shared}/;
hooks/restaurant/; services/restaurant/; schemas/restaurant/.
Register all 10 v1 form routes in MainPage form router.

(3) useLongPress hook (hooks/useLongPress.ts — general utility, not restaurant-specific).
Uses pointer events with a setTimeout ref, cancelling on pointerUp/pointerLeave/
pointerCancel. This is the correct implementation for touch devices — not onContextMenu
or onMouseDown with raw setTimeout.

(4) i18n restaurant namespace (es primary, en secondary). All user-facing strings
must use t('restaurant.*') — no hardcoded strings.

(5) Shared components: Restaurant86Badge (AGOTADO overlay), RestaurantOrderTypeBadge
(Mesa/Llevar/Delivery), RestaurantStatusBadge, CourseNumberBadge, ModifierSummary
(compact one-line modifier display).

(6) RestaurantMenusForm with four tabs: Menus (CRUD with time windows + location
assignment), Menu Items (CRUD with combo toggle and is_86d toggle labelled "Marcar
agotado"), Modifier Groups (per-item; drag-to-reorder sort_order; Archive not Delete
for groups with order history — show confirmation dialog explaining archival),
Recipes/BOM (ingredient autocomplete filtered to raw_material products, quantity +
UOM, is_optional).

(7) RestaurantOrdersForm: two-column layout (menu grid left, order ticket sidebar right).
MenuItemCard with 86'd AGOTADO overlay (non-tappable). ModifierDialog — opens on item
tap when modifier groups exist; required groups must reach minSelections before "Add"
button enables; maxSelections=1 renders radio group, >1 renders checkbox group with
counter; running total updates live; V1 renders ONE LEVEL of modifier groups only —
nested modifier groups are deferred to v2, do not implement recursive group rendering.
OrderTicket sidebar with course grouping, hold/fire toggles, send-to-kitchen /
print-bill / split-bill actions. SplitBillSheet with by_item (tap-to-assign — tap
item then tap destination check; NO drag-and-drop, no @dnd-kit dependency), equal
(spinner), by_seat (seat aliases from order_guest) modes.

(8) Service layer: restaurant-order.service.ts, restaurant-menu.service.ts,
restaurant-modifier.service.ts, restaurant-recipe.service.ts — typed functions
wrapping API endpoints, no business logic.

(9) TanStack Query hooks: useRestaurantMenu (staleTime 2min), useRestaurantOrder
(staleTime 0), useRestaurantTables (refetchInterval 15s as WebSocket fallback).
Query key convention: ['restaurant', entity, ...scopeIds].

Hexagonal backend already implemented. Additive to existing PWA — no existing screens modified.
Add or update unit tests to everything we add or update.

Command FB — KDS, Floor Plan, Stations, Remaining Screens

/speckit.specify FlowPOS Restaurant PWA — frontend KDS, floor plan, stations, and
remaining screens. Foundation from Command FA is already in place (RestaurantLayout,
folder structure, i18n, shared components, useLongPress, menu/order screens, service
layer).

Add the following:

(1) useConditionalRestaurantSocket hook (Option B for MainPage ?form= pattern) or
useRestaurantSocket inside RestaurantLayout (Option A for dedicated URL paths) —
see Command FA. socketRef pattern with synchronous cleanup. Race guard: `if
(socketRef.current) return` (clear intent — do not use double-negation). Handles
product:86, printer:offline, order:item:ready (payload is { orderId, orderItemId,
stationId } — no ticketId field).

(2) useKdsClock hook (hooks/restaurant/useKdsClock.ts) — single setInterval at 1s,
returns current Date.now() as `now`. Called once in KdsTicketGrid, passed as prop
to each KdsTicketCard. Prevents N concurrent intervals causing per-second stuttering
on Android tablets.

(3) useTicketAge hook (hooks/restaurant/useTicketAge.ts) — pure calculation, no
interval. Takes (firedAt: string, now: number). Returns { display, urgency }.

(4) useKdsSocket hook — KitchenTicket type must include orderItemId: string field.
order:ticket modification handler matches on orderItemId + status='pending', NOT on
ticket.id (modification creates a new row with a new id — matching by id never finds
the old ticket). order:item:ready handler matches on orderItemId (payload field), NOT
ticketId (which does not exist in the backend event). socketRef cleanup pattern.

(5) KdsPairingScreen — shown when no deviceToken in localStorage.

(6) RestaurantKdsForm — kiosk mode ONLY from ?kiosk=true. KdsConnectionBanner.
KdsTicketGrid: calls useKdsClock() once, passes now to each card; wrapped in
ErrorBoundary with "Error en pantalla KDS" fallback + reload button. KdsTicketCard:
wrapped in React.memo with custom comparator that re-renders only on ticket data
change or urgency level transition (normal→warning→critical) — NOT on every clock
tick; border colour from useTicketAge urgency; removal modifiers red uppercase,
addition modifiers green uppercase; MODIFICADO amber badge; BUMP uses useLongPress
(600ms) → confirm dialog; RECALL floating button.

(7) RestaurantDiningForm with FloorPlanGrid + TableCard status colours + OpenOrderSheet.

(8) RestaurantKitchenStationsForm — PairingCodeDialog countdown from
response.expiresInSeconds (not hardcoded). DeviceList with deregister confirmation.
PrinterHealthBadge 30s refresh.

(9) RestaurantDashboard, RestaurantReportsForm, shift management, reservations,
waitlist, price rules as specified in §4.7–4.10.

All user-facing strings via i18n including restaurant.kds.error.title and
restaurant.kds.error.reload. Additive — no existing screens modified. Add or update unit tests to everything we add or update.