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
- Screen Scope
- WebSocket Client Design
- Folder Structure
- 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
- Service Layer
- TanStack Query Hooks
- Shared Restaurant Components
- i18n Namespaces
- Deferred to V2
- Implementation Ordering
- Speckit Commands
1. Screen Scope
V1 — In Scope
| Form route | Screen | Complexity | Notes |
|---|---|---|---|
restaurantDining | Floor plan | High | Real-time table status via Socket.IO |
restaurantOrders | Order taking | High | Modifier dialog, split bill, 86 state |
restaurantKds | Kitchen Display | High | Kiosk mode, real-time, aging timers |
restaurantMenus | Menu management | Medium | Menu/item/modifier/recipe CRUD |
restaurantKitchenStations | Station & device config | Medium | Printer config, pairing code UI |
restaurantReports | Reports | Medium | P-Mix, depletion, shift, turnaround |
restaurantDashboard | Summary dashboard | Low | Cards pulling existing data |
restaurantReservations | Reservations | Low | List + create/edit form |
restaurantWaitlist | Waitlist | Low | List + join/seat actions |
restaurantPriceRules | Price rules | Low | Time-based discount CRUD |
V2 — Deferred
| Form route | Reason |
|---|---|
restaurantExpo | Expediter view — depends on KDS being stable first |
restaurantKitchenLoad | Batch summary — nice-to-have, not operational blocker |
restaurantPackingScanner | Barcode scanning workflow — separate device consideration |
restaurantExternalPlatformMappings | Uber 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:
| Status | Border | Background | Label |
|---|---|---|---|
IDLE | gray | white | Table name |
OCCUPIED | blue | blue-50 | Table + server initials + elapsed time |
BILL_PRINTED | amber | amber-50 | Table + "Cuenta" badge |
CLOSED | green fading | white | Briefly shows before returning to IDLE |
Interactions:
- Tap idle table →
OpenOrderSheetslides up → select service type → creates order - Tap occupied table →
OpenOrderSheetwith 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
MenuItemCardshows: item name, price, thumbnail, 86'd overlay whenis_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
isRequiredgroups haveminSelectionsmet maxSelections = 1→ radio button groupmaxSelections > 1→ checkbox group with counter (2 / 3 seleccionados)- Each option shows
priceAdjustmentif 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_idbut the dialog must not attempt to render recursive groups in v1.
Order ticket sidebar:
- Lists
order_itemrows 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 fromorder_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)deviceTokeninlocalStorage→ determines which auth pathuseKdsSocketuses (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-lgfor item name,text-2xl font-boldfor very short names - Border colour derived from
useTicketAge(ticket.firedAt, now)wherenowis passed as a prop fromKdsTicketGrid(see aging timer section below):urgency === 'normal'→border-green-500urgency === 'warning'→border-yellow-500urgency === '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
- Removal modifiers ("SIN CEBOLLA", "SIN GLUTEN") →
isModification: true→ amberMODIFICADO ⚠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 hook — onLongPress 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):
- Menus — list of named menus; create/edit with
available_from/totime pickers; location assignment via multi-select - Menu Items — filterable list by menu/category; create/edit with:
- Basic fields: name, description, price, category, image upload
- Availability: time window,
is_active,is_86dtoggle (prominent — "Marcar agotado") - Combo toggle: if enabled, show
combo_items_jsonbbuilder (product multi-select + qty)
- 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
- Recipes (BOM) — scoped to selected menu item; ingredient rows with quantity + UOM
- Ingredient autocomplete searches
productwhereinventory_type = raw_material is_optionalcheckbox per ingredient line
- Ingredient autocomplete searches
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:
- 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
- Ingredient Depletion — table: ingredient, theoretical usage, actual ledger usage, variance; highlights items with variance > 10%
- Shift Summary — table of shifts: staff name, opened/closed times, opening float, declared cash, variance; colour-code variance (red if > Q10 over/under)
- 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
salerecords 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
OpenOrderSheetfor 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:
percentageorfixedtoggle - 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
| Screen | Why deferred | What's needed first |
|---|---|---|
restaurantExpo | Expediter view of all stations — only valuable when KDS is stable | KDS v1 in production |
restaurantKitchenLoad | Batch cook summary screen | Multi-station KDS + data |
restaurantPackingScanner | Barcode scanning for order assembly verification | Hardware integration |
restaurantExternalPlatformMappings | Uber Eats / Rappi mapping UI | Backend 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.