Staff Operations Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement the staff-facing POS system: Kitchen KDS (kanban board), Reception table list, Floor POS for taking table orders, and guest PWA for guest ordering. Real-time updates via Convex subscriptions.
Architecture: Kitchen KDS is a kanban board with columns (NEW -> PREPARING -> READY -> SERVED). Reception is a simple table list with status indicators. Staff POS is a table-ordering interface. Guest PWA is a mobile-first ordering interface accessed via QR code.
Tech Stack: Next.js 16 App Router, Convex (real-time DB), Clerk (auth), nuqs (URL state), Tailwind CSS v4, Framer Motion (kanban transitions), next-intl (en/vi).
Context & Key Constraints
[P0 GAP]
staffMutation/adminMutationnot implemented. The existingconvex/auth.tsonly providesgetCurrentUser,upsertUser, andisAdminhelpers. Any plan referencingstaffMutationoradminMutationmust be blocked on the foundation plan implementing these wrappers. Until then, use plainmutationfromconvex/_generated/serverwith role checks inside the handler. [P0 GAP:staffMutation/adminMutationnot yet implemented inconvex/auth.ts— use inline role check pattern]
[P0 RULE] No dynamic URL segments. All routing uses query params via
nuqs— never/table/[tableId]or/booking/[occurrenceId]. Guest PWA URL:/table?tableId=xxx&token=xxx. [P0 FIX: plan originally used/table/{tableId}route — changed to nuqs URL state/table?tableId=xxx&token=xxx]
[P1 RULE] No
console.log. Useconsolafor all structured logging.
[P0 RULE]
useQueryAPI calls: Never double-call API functions. UseuseQuery(api.shows.upcoming, { limit: 8 })NOTuseQuery(api.shows.upcoming({ limit: 8 })).
Business Summary
What this does: Implements the staff-facing POS system covering four surfaces: Kitchen Display (kanban board for kitchen/bar staff to track order items through stages), Reception (table list showing availability), Floor POS (staff taking orders on behalf of guests), and Guest PWA (mobile-first ordering accessed via QR codes at each table).
Why it matters: Currently, ordering is manual and disconnected. Kitchen staff rely on paper tickets, reception tracks tables mentally, and guests wait for staff to take orders. This system centralizes operations — guests can order directly from their table via QR code, kitchen sees real-time updates, and reception has a live table list. Staff productivity and table turnover improve significantly.
Time to implement: 5-8 days | Complexity: High
Dependencies: foundation-plan (for schema and auth wrappers), admin-backoffice (for sidebar/layout consistency)
File Map
convex/
├── schema.ts # MODIFY — add tables, menuItems, orders, orderItems
└── functions/
├── tables.ts # CREATE — table CRUD
├── menu.ts # CREATE — menu item CRUD
└── orders.ts # CREATE — order lifecycle mutations
apps/frontend/
├── app/
│ ├── pos/
│ │ ├── kitchen/
│ │ │ └── page.tsx # CREATE — KDS kanban board
│ │ ├── reception/
│ │ │ └── page.tsx # CREATE — table list + check-in
│ │ └── staff/
│ │ └── page.tsx # CREATE — POS for taking orders
│ └── table/
│ └── page.tsx # CREATE — guest PWA (mobile-first ordering, nuqs URL state)
├── components/
│ ├── pos/
│ │ ├── kitchen-kanban.tsx # CREATE — kanban columns + drag
│ │ ├── table-list.tsx # CREATE — table status list
│ │ └── menu-browser.tsx # CREATE — menu grid for staff ordering
│ └── table/
│ ├── menu-grid.tsx # CREATE — guest menu browsing
│ ├── cart.tsx # CREATE — guest cart
│ └── order-status.tsx # CREATE — live order status tracker
└── lib/
└── convex/
└── provider.tsx # MODIFY — ensure ConvexProvider wraps POSPhase 1: Schema — Table + Menu + Order Entities
Task 1: Add POS Tables to Schema
Files:
-
Modify:
convex/schema.ts -
Step 1: Read existing schema
cat convex/schema.ts- Step 2: Add
tables,menuItems,orders,orderItemstables
tables: defineTable({
name: v.string(), // Table number only (e.g., "T01")
capacity: v.number(),
status: v.union(v.literal("ACTIVE"), v.literal("INACTIVE")),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_status", ["status"]),
menuItems: defineTable({
name: v.string(),
description: v.string(),
price: v.number(),
imageUrl: v.optional(v.string()),
category: v.union(
v.literal("APPETIZER"), v.literal("MAIN"), v.literal("DESSERT"),
v.literal("DRINK"), v.literal("COCKTAIL"), v.literal("WINE"),
v.literal("BEER"), v.literal("SOFT_DRINK"), v.literal("OTHER")
),
station: v.union(v.literal("KITCHEN"), v.literal("BAR")),
available: v.boolean(),
sortOrder: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_category", ["category"])
.index("by_station", ["station"])
.index("by_available", ["available"]),
orders: defineTable({
tableId: v.id("tables"),
reservationId: v.optional(v.id("reservations")),
status: v.union(
v.literal("OPEN"), v.literal("SUBMITTED"),
v.literal("COMPLETED"), v.literal("CANCELLED")
),
totalAmount: v.number(),
notes: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_table", ["tableId"])
.index("by_reservation", ["reservationId"])
.index("by_status", ["status"]),
orderItems: defineTable({
orderId: v.id("orders"),
menuItemId: v.id("menuItems"),
quantity: v.number(),
unitPrice: v.number(),
status: v.union(
v.literal("PENDING"), v.literal("SUBMITTED"),
v.literal("PREPARING"), v.literal("READY"),
v.literal("SERVED"), v.literal("CANCELLED")
),
station: v.union(v.literal("KITCHEN"), v.literal("BAR")),
notes: v.optional(v.string()),
isComp: v.boolean().default(false),
compSource: v.optional(v.union(
v.literal("SPIN"), v.literal("PHOTO_WIN"), v.literal("GOOGLE_REVIEW")
)),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_order", ["orderId"])
.index("by_station_status", ["station", "status"])
.index("by_status", ["status"]),- Step 3: Add
tableIdandcheckedInAttoreservationstable
// Add to existing reservations table definition
tableId: v.optional(v.id("tables")),
checkedInAt: v.optional(v.number()),- Step 4: Commit
git add convex/schema.ts
git commit -m "feat(pos): add tables, menuItems, orders, orderItems to schema"Phase 2: Convex Functions — Tables, Menu, Orders
Task 2: Create Convex Functions for POS
Files:
- Create:
convex/functions/tables.ts - Create:
convex/functions/menu.ts - Create:
convex/functions/orders.ts
[P0 GAP] Auth wrappers.
staffMutationandadminMutationdo not exist inconvex/auth.ts. All POS mutations that require role checking must use plainmutationfromconvex/_generated/serverand checkuser.role === "STAFF"oruser.role === "ADMIN"inside the handler until the auth wrappers are implemented. [P0 GAP:staffMutation/adminMutationnot yet implemented — using inline role check pattern]
[P0 RULE]
getOrCreateForTableis a public mutation (no auth required). This mutation is called by the guest PWA accessed via QR code — guests are not authenticated. It should remain a plainmutationwithout auth wrapping. Authenticated mutations (create, update, delete) for tables, menu, and orders must usemutationwith role checks.
- Step 1: Create
tables.ts
// convex/functions/tables.ts
import { query, mutation } from "../_generated/server";
import { v } from "convex/values";
import { Id } from "../_generated/dataModel";
import { consola } from "consola";
// Inline role check — staffMutation/adminMutation not available yet
// Convex QueryBuilder types are inferred automatically
async function requireStaffOrAdmin(ctx: {
auth: { getUserIdentity: () => Promise<{ email?: string } | null> };
db: { query: (table: string) => { withIndex: (name: string, fn: (q: { eq: (field: string, value: string) => { first: () => Promise<unknown> } }) => { first: () => Promise<unknown> } }) => { first: () => Promise<unknown> } } };
}): Promise<{ _id: Id<"users">; role: string; email: string }> {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("UNAUTHORIZED");
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!user || (user.role !== "ADMIN" && user.role !== "STAFF")) {
throw new Error("UNAUTHORIZED");
}
return user as { _id: Id<"users">; role: string; email: string };
}
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("tables").collect();
},
});
export const create = mutation({
args: {
name: v.string(), // Table number (e.g., "T01")
capacity: v.number(),
},
handler: async (ctx, args) => {
// CORRECT: inline role check via ctx.auth.getUserIdentity()
await requireStaffOrAdmin(ctx);
const now = Date.now();
return await ctx.db.insert("tables", {
...args,
status: "ACTIVE",
createdAt: now,
updatedAt: now,
});
},
});- Step 2: Create
menu.ts
// convex/functions/menu.ts
import { query, mutation } from "../_generated/server";
import { v } from "convex/values";
import { Id } from "../_generated/dataModel";
import { consola } from "consola";
// Inline role check — staffMutation/adminMutation not available yet
async function requireStaffOrAdmin(ctx: {
auth: { getUserIdentity: () => Promise<{ email?: string } | null> };
db: { query: (table: string) => { withIndex: (name: string, fn: (q: { eq: (field: string, value: string) => { first: () => Promise<unknown> } }) => { first: () => Promise<unknown> } }) => { first: () => Promise<unknown> } } };
}): Promise<{ _id: Id<"users">; role: string; email: string }> {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("UNAUTHORIZED");
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", identity.email!))
.first();
if (!user || (user.role !== "ADMIN" && user.role !== "STAFF")) {
throw new Error("UNAUTHORIZED");
}
return user as { _id: Id<"users">; role: string; email: string };
}
export const listAvailable = query({
args: {},
handler: async (ctx) => {
return await ctx.db
.query("menuItems")
.withIndex("by_available", (q) => q.eq("available", true))
.collect();
},
});
export const listByCategory = query({
args: { category: v.string() },
handler: async (ctx, { category }) => {
return await ctx.db
.query("menuItems")
.withIndex("by_category", (q) => q.eq("category", category))
.collect();
},
});
export const create = mutation({
args: {
name: v.string(),
description: v.string(),
price: v.number(),
category: v.string(),
station: v.union(v.literal("KITCHEN"), v.literal("BAR")),
imageUrl: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Inline role check
await requireStaffOrAdmin(ctx);
const now = Date.now();
return await ctx.db.insert("menuItems", {
...args,
available: true,
sortOrder: 0,
createdAt: now,
updatedAt: now,
});
},
});- Step 3: Create
orders.ts
// convex/functions/orders.ts
import { query, mutation } from "../_generated/server";
import { v } from "convex/values";
import { Id } from "../_generated/dataModel";
import { consola } from "consola";
export const createOrder = mutation({
args: {
tableId: v.id("tables"),
reservationId: v.optional(v.id("reservations")),
},
handler: async (ctx, { tableId, reservationId }) => {
const now = Date.now();
return await ctx.db.insert("orders", {
tableId,
reservationId,
status: "OPEN",
totalAmount: 0,
createdAt: now,
updatedAt: now,
});
},
});
export const addItem = mutation({
args: {
orderId: v.id("orders"),
menuItemId: v.id("menuItems"),
quantity: v.number(),
notes: v.optional(v.string()),
},
handler: async (ctx, { orderId, menuItemId, quantity, notes }) => {
const menuItem = await ctx.db.get(menuItemId);
if (!menuItem) {
throw new Error("MENU_ITEM_NOT_FOUND");
}
const now = Date.now();
const itemId = await ctx.db.insert("orderItems", {
orderId,
menuItemId,
quantity,
unitPrice: menuItem.price,
status: "PENDING",
station: menuItem.station,
notes,
isComp: false,
createdAt: now,
updatedAt: now,
});
// Update order total
const order = await ctx.db.get(orderId);
if (order) {
await ctx.db.patch(orderId, {
totalAmount: order.totalAmount + menuItem.price * quantity,
updatedAt: now,
});
}
return itemId;
},
});
export const updateItemStatus = mutation({
args: {
itemId: v.id("orderItems"),
status: v.union(
v.literal("SUBMITTED"),
v.literal("PREPARING"),
v.literal("READY"),
v.literal("SERVED"),
v.literal("CANCELLED"),
),
},
handler: async (ctx, { itemId, status }) => {
await ctx.db.patch(itemId, { status, updatedAt: Date.now() });
consola.debug("KDS item status updated", { itemId, status });
},
});
export const submitOrder = mutation({
args: { orderId: v.id("orders") },
handler: async (ctx, { orderId }) => {
const now = Date.now();
await ctx.db.patch(orderId, { status: "SUBMITTED", updatedAt: now });
// Update all PENDING items to SUBMITTED
const items = await ctx.db
.query("orderItems")
.withIndex("by_order", (q) => q.eq("orderId", orderId))
.collect();
for (const item of items) {
if (item.status === "PENDING") {
await ctx.db.patch(item._id, {
status: "SUBMITTED",
updatedAt: now,
});
}
}
consola.info("Order submitted", { orderId, itemCount: items.length });
},
});
export const getByStationAndStatus = query({
args: {
station: v.union(v.literal("KITCHEN"), v.literal("BAR")),
status: v.optional(v.string()),
},
handler: async (ctx, { station, status }) => {
const items = await ctx.db
.query("orderItems")
.withIndex("by_station_status", (q) =>
status
? q.eq("station", station).eq("status", status)
: q.eq("station", station),
)
.collect();
// Enrich with order + menu item data
const enriched = [];
for (const item of items) {
const order = await ctx.db.get(item.orderId);
const menuItem = await ctx.db.get(item.menuItemId);
if (order && menuItem) {
enriched.push({ item, order, menuItem });
}
}
return enriched;
},
});
export const getOpenOrderByTable = query({
args: { tableId: v.id("tables") },
handler: async (ctx, { tableId }) => {
const orders = await ctx.db
.query("orders")
.withIndex("by_table", (q) => q.eq("tableId", tableId))
.collect();
return (
orders.find((o) => o.status === "OPEN" || o.status === "SUBMITTED") ??
null
);
},
});- Step 4: Add
getOrCreateForTablefor guest PWA (public, no auth)
// NOTE: This is a PUBLIC mutation — no auth required since guests access via QR code
export const getOrCreateForTable = mutation({
args: {
tableId: v.id("tables"),
reservationId: v.optional(v.id("reservations")),
},
handler: async (ctx, { tableId, reservationId }) => {
const existing = await ctx.db
.query("orders")
.withIndex("by_table", (q) => q.eq("tableId", tableId))
.collect()
.then((orders) =>
orders.find((o) => o.status === "OPEN" || o.status === "SUBMITTED"),
);
if (existing) {
return existing;
}
const id = await ctx.db.insert("orders", {
tableId,
reservationId: reservationId ?? undefined,
status: "OPEN",
totalAmount: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
});
return await ctx.db.get(id);
},
});- Step 5: Commit
git add convex/functions/tables.ts convex/functions/menu.ts convex/functions/orders.ts
git commit -m "feat(pos): add table, menu, and order Convex functions"Phase 3: Kitchen KDS — Kanban Board
Task 3: Create Kitchen KDS Page
Files:
-
Create:
apps/frontend/app/pos/kitchen/page.tsx -
Create:
apps/frontend/components/pos/kitchen-kanban.tsx -
Step 1: Create kitchen kanban component
// apps/frontend/components/pos/kitchen-kanban.tsx
"use client";
import { useCallback, useTransition } from "react";
import { useQuery, useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { consola } from "consola";
const COLUMNS = [
{ id: "SUBMITTED", labelKey: "pos.kanban.new", color: "border-blue-500" },
{ id: "PREPARING", labelKey: "pos.kanban.preparing", color: "border-orange-500" },
{ id: "READY", labelKey: "pos.kanban.ready", color: "border-green-500" },
{ id: "SERVED", labelKey: "pos.kanban.served", color: "border-gray-500" },
];
const NEXT_STATUS: Record<string, string> = {
SUBMITTED: "PREPARING",
PREPARING: "READY",
READY: "SERVED",
};
export function KitchenKanban() {
const t = useTranslations();
const [isPending, startTransition] = useTransition();
const station = "KITCHEN";
// CORRECT: useQuery(api.fn, args) — pass function reference, not call
const allItems = useQuery(api.orders.getByStationAndStatus, { station });
const updateStatus = useMutation(api.orders.updateItemStatus);
const byStatus = COLUMNS.reduce((acc, col) => {
acc[col.id] = allItems?.filter((e) => e.item.status === col.id) ?? [];
return acc;
}, {} as Record<string, typeof allItems>);
const handleMove = useCallback(
async (itemId: string, currentStatus: string) => {
const nextStatus = NEXT_STATUS[currentStatus];
if (!nextStatus) return;
startTransition(() => {
updateStatus({ itemId, status: nextStatus });
});
consola.debug("KDS item status advanced", { itemId, nextStatus });
},
[updateStatus]
);
const getMoveButtonLabel = (currentStatus: string): string => {
const next = NEXT_STATUS[currentStatus];
if (next === "PREPARING") return t("pos.kanban.moveTo", { target: t("pos.kanban.preparing") });
if (next === "READY") return t("pos.kanban.moveTo", { target: t("pos.kanban.ready") });
if (next === "SERVED") return t("pos.kanban.moveTo", { target: t("pos.kanban.served") });
return t("pos.kanban.served");
};
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{COLUMNS.map((col) => (
<div key={col.id} className={`flex-1 min-w-64 border-t-4 ${col.color}`}>
<div className="bg-[#2E2E2E] p-3 rounded-b-lg">
<h3 className="font-bold text-[#e6e6e6] mb-3">
{t(col.labelKey)} ({byStatus[col.id]?.length ?? 0})
</h3>
<div className="space-y-2" data-testid={`kanban-column-${col.id}`}>
{byStatus[col.id]?.map(({ item, order, menuItem }) => (
<div
key={item._id}
className="bg-[#1a1a1a] border border-[#4d4d4d] p-3 rounded-lg"
>
<p className="font-bold text-[#C5A059]">
{t("pos.kanban.table")} {order.tableId}
</p>
<p className="text-[#e6e6e6]">{menuItem.name} x {item.quantity}</p>
{item.notes && (
<p className="text-xs text-[#808080] mt-1">
{t("pos.kanban.note")}: {item.notes}
</p>
)}
{col.id !== "SERVED" && (
<button
onClick={() => handleMove(item._id, item.status)}
disabled={isPending}
data-testid="move-btn"
className="mt-2 w-full py-1 text-xs bg-[#C5A059]/20 text-[#C5A059] rounded hover:bg-[#C5A059]/30 disabled:opacity-50"
>
{getMoveButtonLabel(item.status)}
</button>
)}
</div>
))}
{byStatus[col.id]?.length === 0 && (
<p className="text-[#808080] text-sm text-center py-4" data-testid={`kanban-column-${col.id}-empty`}>
{t("pos.kanban.noItems")}
</p>
)}
</div>
</div>
</div>
))}
</div>
);
}- Step 2: Create kitchen page with Suspense boundary
// apps/frontend/app/pos/kitchen/page.tsx
"use client";
import { Suspense } from "react";
import { useTranslations } from "next-intl";
import { KitchenKanban } from "~/components/pos/kitchen-kanban";
const STATION_FILTERS = [
{ key: "ALL", labelKey: "pos.kitchen.filterAll" },
{ key: "KITCHEN", labelKey: "pos.kitchen.filterKitchen" },
{ key: "BAR", labelKey: "pos.kitchen.filterBar" },
{ key: "READY", labelKey: "pos.kitchen.filterReady" },
];
function KitchenLoading() {
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex-1 min-w-64 border-t-4 border-[#4d4d4d]">
<div className="bg-[#2E2E2E] p-3 rounded-b-lg animate-pulse">
<div className="h-6 bg-[#4d4d4d] rounded mb-3 w-24" />
<div className="space-y-2">
{[1, 2, 3].map((j) => (
<div key={j} className="bg-[#1a1a1a] border border-[#4d4d4d] p-3 rounded-lg h-24" />
))}
</div>
</div>
</div>
))}
</div>
);
}
export default function KitchenPage() {
const t = useTranslations();
return (
<div className="min-h-screen bg-[#1a1a1a] p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-serif text-[#C5A059]">
{t("pos.kitchen.title")}
</h1>
<div className="flex gap-2">
{STATION_FILTERS.map((filter) => (
<button
key={filter.key}
className="px-3 py-1 text-sm bg-[#2E2E2E] border border-[#4d4d4d] text-[#e6e6e6] rounded hover:border-[#C5A059] transition-colors"
>
{t(filter.labelKey)}
</button>
))}
</div>
</div>
<Suspense fallback={<KitchenLoading />}>
<KitchenKanban />
</Suspense>
</div>
);
}- Step 3: Commit
git add apps/frontend/app/pos/kitchen/ apps/frontend/components/pos/kitchen-kanban.tsx
git commit -m "feat(pos): add kitchen KDS kanban board"Phase 4: Reception + Floor Plan
Task 4: Create Reception Floor Plan
Files:
-
Create:
apps/frontend/app/pos/reception/page.tsx -
Create:
apps/frontend/components/pos/table-list.tsx -
Step 1: Create table list component
// apps/frontend/components/pos/table-list.tsx
"use client";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
type TableStatus = "AVAILABLE" | "OCCUPIED" | "RESERVED";
export function TableList() {
const t = useTranslations();
const tables = useQuery(api.tables.list);
const getStatusColor = (status: TableStatus) => {
switch (status) {
case "AVAILABLE": return "bg-green-500";
case "OCCUPIED": return "bg-[#C5A059]";
case "RESERVED": return "bg-orange-500";
default: return "bg-[#4d4d4d]";
}
};
return (
<div className="grid grid-cols-4 gap-4" data-testid="table-list">
{tables?.map((table) => (
<div
key={table._id}
className="bg-[#2E2E2E] p-4 rounded-lg border border-[#4d4d4d]"
data-testid={`table-${table.name}`}
>
<div className="font-bold text-lg text-[#e6e6e6]">{table.name}</div>
<div className="text-sm text-[#808080]">{table.capacity} seats</div>
<div className={`mt-2 w-3 h-3 rounded-full ${getStatusColor(table.status)}`} />
</div>
))}
</div>
);
}- Step 2: Create reception page with Suspense boundary
// apps/frontend/app/pos/reception/page.tsx
"use client";
import { Suspense } from "react";
import { useTranslations } from "next-intl";
import { TableList } from "~/components/pos/table-list";
function ReceptionLoading() {
return <div className="grid grid-cols-4 gap-4">
{[1,2,3,4].map(i => (
<div key={i} className="h-24 bg-[#2E2E2E] rounded-lg animate-pulse" />
))}
</div>;
}
export default function ReceptionPage() {
const t = useTranslations();
return (
<div className="min-h-screen bg-[#1a1a1a] p-6">
<h1 className="text-2xl font-serif text-[#C5A059] mb-6">
{t("pos.reception.title")}
</h1>
<Suspense fallback={<ReceptionLoading />}>
<TableList />
</Suspense>
</div>
);
}- Step 3: Commit
git add apps/frontend/app/pos/reception/ apps/frontend/components/pos/table-list.tsx
git commit -m "feat(pos): add reception table list"Phase 5: Guest PWA — Table Ordering
Task 5: Create Guest PWA for Table Ordering
Files:
- Create:
apps/frontend/app/table/page.tsx— nuqs URL state:/table?tableId=xxx&token=xxx - Create:
apps/frontend/components/table/menu-grid.tsx - Create:
apps/frontend/components/table/cart.tsx - Create:
apps/frontend/components/table/order-status.tsx
[P0 RULE] Guest PWA uses nuqs URL state — NOT
/table/[tableId]. URL is/table?tableId=xxx&token=xxx. ThetableIdandtokenare passed as query params, not dynamic route segments. [P0 FIX: plan originally referenced/table/{tableId}dynamic route — changed to nuqs URL state]
- Step 1: Create table page
// apps/frontend/app/table/page.tsx
"use client";
import { useEffect, useTransition } from "react";
import { useQuery, useMutation } from "convex/react";
import { useQueryState } from "nuqs";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { MenuGrid } from "~/components/table/menu-grid";
import { Cart } from "~/components/table/cart";
import { OrderStatus } from "~/components/table/order-status";
export default function TablePage() {
const t = useTranslations();
const [isPending, startTransition] = useTransition();
// CORRECT: nuqs URL state — no dynamic route segments
const [tableId] = useQueryState("tableId", { defaultValue: "" });
const [token] = useQueryState("token", { defaultValue: "" });
const [isSubmitted, setIsSubmitted] = useQueryState("submitted", { defaultValue: "false" });
// CORRECT: useQuery(api.fn, args) — pass function reference, not call
const openOrder = useQuery(
api.orders.getOpenOrderByTable,
tableId ? { tableId } : "skip"
);
const getOrCreateOrder = useMutation(api.orders.getOrCreateForTable);
useEffect(() => {
if (tableId) {
startTransition(() => {
getOrCreateOrder({ tableId });
});
}
}, [tableId, getOrCreateOrder]);
useEffect(() => {
if (openOrder) {
setIsSubmitted(openOrder.status === "SUBMITTED" || openOrder.status === "COMPLETED" ? "true" : "false");
}
}, [openOrder, setIsSubmitted]);
return (
<div className="min-h-screen bg-[#1a1a1a] text-[#e6e6e6]">
<header className="sticky top-0 z-10 bg-[#2E2E2E] border-b border-[#4d4d4d] px-4 py-3">
<div className="max-w-lg mx-auto flex justify-between items-center">
<div>
<h1 className="font-serif text-[#C5A059] text-lg">
{t("pos.table.title", { tableId: tableId || t("pos.table.noTable") })}
</h1>
<p className="text-xs text-[#808080]">{t("pos.table.venue")}</p>
</div>
{isSubmitted === "true" && (
<span className="text-xs bg-[#C5A059]/20 text-[#C5A059] px-2 py-1 rounded" data-testid="order-sent-badge">
{t("pos.table.orderSent")}
</span>
)}
</div>
</header>
<div className="max-w-lg mx-auto">
<MenuGrid tableId={tableId} openOrder={openOrder} />
<Cart tableId={tableId} openOrder={openOrder} onSubmitted={setIsSubmitted} />
</div>
</div>
);
}- Step 2: Create menu grid component
// apps/frontend/components/table/menu-grid.tsx
"use client";
import { useCallback, useState, useTransition } from "react";
import { useQuery, useMutation } from "convex/react";
import { useQueryState } from "nuqs";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
const CATEGORIES = [
{ key: "APPETIZER", labelKey: "menu.category.appetizer" },
{ key: "MAIN", labelKey: "menu.category.main" },
{ key: "DESSERT", labelKey: "menu.category.dessert" },
{ key: "DRINK", labelKey: "menu.category.drink" },
{ key: "COCKTAIL", labelKey: "menu.category.cocktail" },
];
type MenuItem = {
_id: string;
name: string;
description: string;
price: number;
category: string;
};
type OpenOrder = {
_id: string;
status: string;
items?: Array<{ _id: string; menuItemId: string; quantity: number; status: string }>;
} | null;
export function MenuGrid({ tableId, openOrder }: { tableId: string; openOrder: OpenOrder }) {
const t = useTranslations();
const [isPending, startTransition] = useTransition();
const [category, setCategory] = useQueryState("cat", {
defaultValue: "APPETIZER",
serialize: (v) => v,
parse: (v) => v,
});
// CORRECT: useQuery(api.fn, args) — pass function reference
const items = useQuery(api.menu.listByCategory, { category });
const addItem = useMutation(api.orders.addItem);
const [cart, setCart] = useState<Record<string, number>>({});
const handleAdd = useCallback(
async (menuItemId: string) => {
if (!openOrder) return;
const current = cart[menuItemId] ?? 0;
setCart((prev) => ({ ...prev, [menuItemId]: current + 1 }));
startTransition(() => {
addItem({ orderId: openOrder._id, menuItemId, quantity: 1 });
});
},
[openOrder, addItem, cart]
);
return (
<div data-testid="menu-grid">
{/* Category tabs */}
<div className="flex gap-1 px-4 py-3 overflow-x-auto">
{CATEGORIES.map((cat) => (
<button
key={cat.key}
onClick={() => setCategory(cat.key)}
data-testid={`category-tab-${cat.key}`}
className={`px-3 py-1.5 text-sm rounded whitespace-nowrap ${
category === cat.key
? "bg-[#C5A059] text-[#1a1a1a] font-medium"
: "bg-[#2E2E2E] text-[#808080] border border-[#4d4d4d]"
}`}
>
{t(cat.labelKey)}
</button>
))}
</div>
{/* Items grid */}
<div className="px-4 pb-24 space-y-3">
{items?.map((item: MenuItem) => {
const qty = cart[item._id] ?? 0;
return (
<div key={item._id} className="bg-[#2E2E2E] border border-[#4d4d4d] p-4 rounded-lg" data-testid="menu-item-card" data-category={item.category}>
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="font-medium text-[#e6e6e6]">{item.name}</h3>
<p className="text-sm text-[#808080] mt-1">{item.description}</p>
</div>
<span className="text-[#C5A059] font-medium ml-4">
{item.price.toLocaleString()}{t("common.currencyShort")}
</span>
</div>
<div className="mt-3 flex items-center gap-2">
{qty === 0 ? (
<button
onClick={() => handleAdd(item._id)}
data-testid="menu-item-add-btn"
className="text-sm bg-[#C5A059] text-[#1a1a1a] px-3 py-1.5 rounded font-medium hover:bg-[#DEC89E] transition-colors"
>
{t("common.add")}
</button>
) : (
<>
<button
data-testid="menu-item-remove-btn"
className="w-8 h-8 rounded border border-[#4d4d4d] flex items-center justify-center text-[#808080] hover:border-[#C5A059] transition-colors"
>
<span className="text-lg">-</span>
</button>
<span className="font-medium w-8 text-center" data-testid={`cart-count-${item._id}`}>{qty}</span>
<button
onClick={() => handleAdd(item._id)}
className="w-8 h-8 rounded border border-[#4d4d4d] flex items-center justify-center text-[#C5A059] hover:border-[#C5A059] transition-colors"
>
<span className="text-lg">+</span>
</button>
</>
)}
</div>
</div>
);
})}
</div>
</div>
);
}- Step 3: Commit
git add apps/frontend/app/table/ apps/frontend/components/table/
git commit -m "feat(pos): add guest table ordering PWA"Enrichment Sections
1. Zod Schemas
// convex/functions/tables.ts
import { z } from "zod";
export const CreateTableSchema = z.object({
name: z.string().min(1, "Table name is required"),
capacity: z.number().int().positive("Capacity must be at least 1"),
});
export const UpdateTableSchema = z.object({
id: z.string(),
name: z.string().min(1).optional(),
capacity: z.number().int().positive().optional(),
status: z.enum(["ACTIVE", "INACTIVE"]).optional(),
});
// convex/functions/menu.ts
export const CreateMenuItemSchema = z.object({
name: z.string().min(1, "Menu item name is required"),
description: z.string(),
price: z.number().nonnegative("Price must be non-negative"),
category: z.enum([
"APPETIZER",
"MAIN",
"DESSERT",
"DRINK",
"COCKTAIL",
"WINE",
"BEER",
"SOFT_DRINK",
"OTHER",
]),
station: z.enum(["KITCHEN", "BAR"]),
imageUrl: z.string().url().optional(),
sortOrder: z.number().int().default(0),
});
export const UpdateMenuItemSchema = z.object({
id: z.string(),
name: z.string().min(1).optional(),
description: z.string().optional(),
price: z.number().nonnegative().optional(),
category: z
.enum([
"APPETIZER",
"MAIN",
"DESSERT",
"DRINK",
"COCKTAIL",
"WINE",
"BEER",
"SOFT_DRINK",
"OTHER",
])
.optional(),
station: z.enum(["KITCHEN", "BAR"]).optional(),
imageUrl: z.string().url().optional(),
available: z.boolean().optional(),
sortOrder: z.number().int().optional(),
});
// convex/functions/orders.ts
export const CreateOrderSchema = z.object({
tableId: z.string().min(1, "Table ID is required"),
reservationId: z.string().optional(),
});
export const AddItemSchema = z.object({
orderId: z.string().min(1, "Order ID is required"),
menuItemId: z.string().min(1, "Menu item ID is required"),
quantity: z.number().int().positive("Quantity must be at least 1"),
notes: z.string().optional(),
});
export const UpdateItemStatusSchema = z.object({
itemId: z.string().min(1, "Item ID is required"),
status: z.enum(["SUBMITTED", "PREPARING", "READY", "SERVED", "CANCELLED"]),
});
export const SubmitOrderSchema = z.object({
orderId: z.string().min(1, "Order ID is required"),
});
export const GetOpenOrderByTableSchema = z.object({
tableId: z.string().min(1, "Table ID is required"),
});2. Error Handling
// convex/functions/orders.ts
export const POS_ERROR_CODES = {
TABLE_NAME_REQUIRED: "TABLE_NAME_REQUIRED",
INVALID_CAPACITY: "INVALID_CAPACITY",
MENU_ITEM_NOT_FOUND: "MENU_ITEM_NOT_FOUND",
MENU_ITEM_UNAVAILABLE: "MENU_ITEM_UNAVAILABLE",
ORDER_NOT_FOUND: "ORDER_NOT_FOUND",
ORDER_NOT_OPEN: "ORDER_NOT_OPEN",
INVALID_QUANTITY: "INVALID_QUANTITY",
ORDER_EMPTY: "ORDER_EMPTY",
ORDER_ITEM_NOT_FOUND: "ORDER_ITEM_NOT_FOUND",
UNAUTHORIZED: "UNAUTHORIZED",
} as const;
type POSErrorCode = keyof typeof POS_ERROR_CODES;| Mutation | Error Code | Message Key | Condition |
|---|---|---|---|
tables.create | TABLE_NAME_REQUIRED | errors.table.nameRequired | Empty name |
tables.create | INVALID_CAPACITY | errors.table.invalidCapacity | Capacity < 1 |
menu.create | MENU_ITEM_NOT_FOUND | errors.menu.notFound | Item does not exist |
menu.create | MENU_ITEM_UNAVAILABLE | errors.menu.unavailable | Item not available |
orders.addItem | ORDER_NOT_FOUND | errors.order.notFound | Order does not exist |
orders.addItem | ORDER_NOT_OPEN | errors.order.notOpen | Order already submitted |
orders.addItem | INVALID_QUANTITY | errors.order.invalidQuantity | Quantity < 1 |
orders.submitOrder | ORDER_EMPTY | errors.order.empty | No items to submit |
orders.updateItemStatus | ORDER_ITEM_NOT_FOUND | errors.order.itemNotFound | Item does not exist |
3. Convex Real-time Subscription Pattern
// Kitchen KDS — list view
// CORRECT: pass function reference, not call
const submittedItems = useQuery(api.orders.getByStationAndStatus, {
station: "KITCHEN",
});
// Guest PWA — individual order
// CORRECT: pass function reference, not call
const openOrder = useQuery(
api.orders.getOpenOrderByTable,
tableId ? { tableId } : "skip",
);
// Reception — all tables for floor plan
const tables = useQuery(api.tables.list);
// Staff POS — available menu items
const menuItems = useQuery(api.menu.listAvailable);
// Category-filtered menu items
const categoryItems = useQuery(api.menu.listByCategory, { category });4. Mobile/Responsive Considerations
| Component | Mobile Behavior |
|---|---|
| Kitchen KDS | Horizontal scroll for kanban columns; sticky header with station filter |
| Floor Plan | Pinch-to-zoom SVG; tap table for slide-over details |
| Staff POS | Full-screen table list on left; menu grid fills remaining space |
| Guest PWA | Single-column menu grid; floating cart button at bottom |
| Cart Drawer | Bottom sheet on mobile; slides up from bottom |
5. PWA / Offline Behavior
Guest PWA Service Worker Strategy:
/table page shell: Cache-First (revalidate in background)
/menu items: Network-First with 5s timeout, fallback to cached
/convex subscription: Always online (real-time required for ordering)
Offline state:
- Menu browsing: WORKS (cached menu items)
- Add to cart: BLOCKED (shows "Offline" banner)
- Send Order: BLOCKED (button disabled, tooltip explains)PWA Manifest Requirements:
{
"name": "House of Legends - Table Ordering",
"short_name": "HOL Table",
"display": "standalone",
"theme_color": "#C5A059",
"background_color": "#1a1a1a",
"start_url": "/table",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}iOS PWA:
<meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><link rel="apple-touch-icon" href="/icons/icon-192.png">
6. i18n / next-intl Requirements
{
"pos": {
"kanban": {
"new": "New",
"preparing": "Preparing",
"ready": "Ready",
"served": "Served",
"table": "Table",
"note": "Note",
"moveTo": "Move to {target}",
"noItems": "No items"
},
"kitchen": {
"title": "Kitchen Display",
"filterAll": "All",
"filterKitchen": "Kitchen",
"filterBar": "Bar",
"filterReady": "Ready"
},
"reception": {
"title": "Reception",
"legendAvailable": "Available",
"legendOccupied": "Occupied",
"legendNeedsAttention": "Needs Attention",
"checkIn": "Check In",
"noShow": "No Show",
"viewOrders": "View Orders",
"blockTable": "Block Table"
},
"staff": {
"title": "Floor POS",
"selectTable": "Select a table",
"tableTitle": "Table {name}",
"selectTablePrompt": "Select a table to start",
"cartEmpty": "Cart is empty",
"total": "Total",
"sendToKitchen": "Send to Kitchen"
},
"table": {
"title": "Table {tableId}",
"noTable": "Unknown Table",
"venue": "House of Legends",
"orderSent": "Order Sent",
"sendOrder": "Send Order",
"callStaff": "Call Staff"
},
"orderStatus": {
"pending": "Pending",
"submitted": "Submitted",
"preparing": "Preparing",
"ready": "Ready",
"served": "Served"
}
},
"menu": {
"category": {
"appetizer": "Appetizers",
"main": "Main Courses",
"dessert": "Desserts",
"drink": "Drinks",
"cocktail": "Cocktails",
"wine": "Wine",
"softDrink": "Soft Drinks"
}
},
"common": {
"add": "Add",
"currencyShort": " VND",
"loading": "Loading..."
},
"errors": {
"table": {
"nameRequired": "Table name is required",
"invalidCapacity": "Table capacity must be at least 1"
},
"menu": {
"notFound": "Menu item not found",
"unavailable": "Menu item is currently unavailable"
},
"order": {
"notFound": "Order not found",
"notOpen": "Order is not open for modifications",
"invalidQuantity": "Quantity must be at least 1",
"empty": "Cannot submit an empty order",
"itemNotFound": "Order item not found"
},
"auth": {
"unauthorized": "You must be signed in as staff or admin"
}
}
}7. Environment-Specific Configuration
# Server-only (never exposed to client):
CLERK_SECRET_KEY= # Clerk secret key
# Client-safe (NEXT_PUBLIC_ prefix):
NEXT_PUBLIC_CONVEX_URL= # Convex deployment URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= # Clerk publishable key
# QR token configuration:
QR_TOKEN_SECRET= # HMAC secret for table QR tokens (32+ bytes, server-only)8. TDD Test Cases
E2E Tests (Playwright):
// e2e/staff-operations.spec.ts
test("SO-E2E-1.1: Kitchen KDS displays incoming orders in real-time", async ({
page,
}) => {
// Given: Kitchen KDS page is open and an order with KITCHEN items exists
// When: Guest submits order via PWA
// Then: New order appears in the "New" column within 2 seconds
await page.goto("/en/pos/kitchen");
await page.waitForTimeout(500);
await expect(
page.locator('[data-testid="kanban-column-SUBMITTED"]'),
).toBeVisible();
});
test("SO-E2E-1.2: Kitchen staff can advance item status through kanban", async ({
page,
}) => {
// Given: An item is in "New" column on KDS
// When: Staff clicks "Start Preparing" button on that item
// Then: Item moves to "Preparing" column
await page.goto("/en/pos/kitchen");
const newItem = page
.locator('[data-testid="kanban-column-SUBMITTED"]')
.first();
await newItem.getByTestId("move-btn").click();
await expect(
page.locator('[data-testid="kanban-column-PREPARING"]'),
).toBeVisible();
});
test("SO-E2E-1.3: Reception floor plan shows table statuses", async ({
page,
}) => {
// Given: Reception page is open with tables configured
// When: Page loads
// Then: Floor plan shows all tables with status colors
await page.goto("/en/pos/reception");
await expect(page.locator('[data-testid="floor-plan"]')).toBeVisible();
await expect(page.locator('[data-testid="table-1"]')).toBeVisible();
});
test("SO-E2E-1.4: Guest PWA shows menu grid", async ({ page }) => {
// Given: Guest scans QR code and lands on /table?tableId=xxx&token=xxx
// When: Page loads
// Then: Menu categories are visible and items are listed
await page.goto("/en/table?tableId=table123&token=abc");
await expect(page.locator('[data-testid="menu-grid"]')).toBeVisible();
await expect(page.getByTestId("category-tab-APPETIZER")).toBeVisible();
});
test("SO-E2E-1.5: Guest PWA add to cart updates order total", async ({
page,
}) => {
// Given: Guest is on /table page with an open order
// When: Guest taps "Add" on a menu item
// Then: Cart count increments and total updates
await page.goto("/en/table?tableId=table123&token=abc");
await page.getByTestId("menu-item-add-btn").first().click();
await expect(
page.locator('[data-testid^="cart-count-"]').first(),
).toContainText("1");
});
test("SO-E2E-1.6: Guest PWA submit order sends to kitchen", async ({
page,
}) => {
// Given: Guest has added items to cart
// When: Guest taps "Send Order"
// Then: Order status changes to SUBMITTED, cart clears
await page.goto("/en/table?tableId=table123&token=abc");
await page.getByTestId("menu-item-add-btn").first().click();
await page.getByTestId("send-order-btn").click();
await expect(page.locator('[data-testid="order-sent-badge"]')).toBeVisible();
});Component Tests (Vitest + RTL):
// __tests__/components/kitchen-kanban.test.tsx
it("SO-CT-1.1: Renders all four kanban columns", async () => {
// Given: KitchenKanban component with mock data
const mockItems = [
{ item: { _id: "1", status: "SUBMITTED" }, order: { tableId: "A1" }, menuItem: { name: "Spring Rolls" } },
{ item: { _id: "2", status: "PREPARING" }, order: { tableId: "B2" }, menuItem: { name: "Curry" } },
];
render(<KitchenKanban items={mockItems} />);
// Then: All four columns visible
expect(screen.getByTestId("kanban-column-SUBMITTED")).toBeVisible();
expect(screen.getByTestId("kanban-column-PREPARING")).toBeVisible();
expect(screen.getByTestId("kanban-column-READY")).toBeVisible();
expect(screen.getByTestId("kanban-column-SERVED")).toBeVisible();
});
it("SO-CT-1.2: Move button advances item to next status", async () => {
// Given: An item in SUBMITTED status
const mockItem = { item: { _id: "1", status: "SUBMITTED" }, order: { tableId: "A1" }, menuItem: { name: "Spring Rolls" } };
render(<KitchenKanban items={[mockItem]} />);
// When: User clicks "Start Preparing"
await user.click(screen.getByTestId("move-btn"));
// Then: Item moves to PREPARING column
await waitFor(() => {
expect(screen.getByTestId("kanban-column-PREPARING")).toContainText("Spring Rolls");
});
});
it("SO-CT-1.3: Served column items have no move button", async () => {
// Given: An item in SERVED status
const mockItem = { item: { _id: "1", status: "SERVED" }, order: { tableId: "A1" }, menuItem: { name: "Spring Rolls" } };
render(<KitchenKanban items={[mockItem]} />);
// Then: No move button visible
expect(screen.queryByTestId("move-btn")).not.toBeInTheDocument();
});
it("SO-CT-1.4: Empty column shows placeholder message", async () => {
// Given: No items in PREPARING column
render(<KitchenKanban items={[]} />);
// Then: Placeholder shown
expect(screen.getByTestId("kanban-column-PREPARING")).toContainText("No items");
});// __tests__/components/menu-grid.test.tsx
it("SO-CT-2.1: Category tabs switch displayed items", async () => {
// Given: MenuGrid with items in APPETIZER and MAIN categories
render(<MenuGrid items={mockItems} />);
// When: User clicks "Main Courses" tab
await user.click(screen.getByTestId("category-tab-MAIN"));
// Then: Only MAIN items shown
const mainItems = screen.getAllByTestId("menu-item-card");
for (const item of mainItems) {
expect(item).toHaveAttribute("data-category", "MAIN");
}
});
it("SO-CT-2.2: Add button increments cart count", async () => {
// Given: MenuGrid with an item
render(<MenuGrid items={[mockItem]} openOrder={mockOrder} />);
// When: User clicks Add
await user.click(screen.getByTestId("menu-item-add-btn"));
// Then: Cart count shows 1
expect(screen.getByTestId("cart-count-item1")).toContainText("1");
});Backend Tests (Vitest):
// __tests__/convex/orders.test.ts
it("SO-BE-1.1: addItem throws MENU_ITEM_NOT_FOUND when menu item does not exist", async () => {
// Given: Mock context with no menu item for ID "nonexistent"
const ctx = createMockContext({ menuItems: {} });
// When: Calling addItem with menuItemId="nonexistent"
// Then: Throws "MENU_ITEM_NOT_FOUND"
await expect(
ctx.runMutation(api.orders.addItem, {
orderId: "order1",
menuItemId: "nonexistent",
quantity: 1,
}),
).rejects.toThrow("MENU_ITEM_NOT_FOUND");
});
it("SO-BE-1.2: addItem throws ORDER_NOT_OPEN when order is already submitted", async () => {
// Given: Mock context with order in SUBMITTED status
const ctx = createMockContext({
orders: { order1: { status: "SUBMITTED" } },
});
// When: Calling addItem
// Then: Throws "ORDER_NOT_OPEN"
await expect(
ctx.runMutation(api.orders.addItem, {
orderId: "order1",
menuItemId: "menu1",
quantity: 1,
}),
).rejects.toThrow("ORDER_NOT_OPEN");
});
it("SO-BE-1.3: addItem increments order totalAmount by item price times quantity", async () => {
// Given: Mock context with OPEN order (totalAmount=0) and menu item price=100000
const ctx = createMockContext({
orders: { order1: { totalAmount: 0, status: "OPEN" } },
menuItems: { menu1: { price: 100000 } },
});
// When: Calling addItem with quantity=2
// Then: order.totalAmount === 200000
await ctx.runMutation(api.orders.addItem, {
orderId: "order1",
menuItemId: "menu1",
quantity: 2,
});
const updated = await ctx.db.get("order1");
expect(updated.totalAmount).toBe(200000);
});
it("SO-BE-1.4: submitOrder throws ORDER_EMPTY when no items in order", async () => {
// Given: Mock context with order that has no items
const ctx = createMockContext({
orders: { order1: { status: "OPEN" } },
orderItems: [],
});
// When: Calling submitOrder
// Then: Throws "ORDER_EMPTY"
await expect(
ctx.runMutation(api.orders.submitOrder, { orderId: "order1" }),
).rejects.toThrow("ORDER_EMPTY");
});
it("SO-BE-1.5: submitOrder changes all PENDING items to SUBMITTED", async () => {
// Given: Mock context with order containing 3 PENDING items
const ctx = createMockContext({
orders: { order1: { status: "OPEN" } },
orderItems: [
{ _id: "item1", status: "PENDING" },
{ _id: "item2", status: "PENDING" },
{ _id: "item3", status: "PENDING" },
],
});
// When: Calling submitOrder
await ctx.runMutation(api.orders.submitOrder, { orderId: "order1" });
// Then: All 3 items have status === "SUBMITTED"
const items = await ctx.db
.query("orderItems")
.withIndex("by_order")
.eq("orderId", "order1")
.collect();
for (const item of items) {
expect(item.status).toBe("SUBMITTED");
}
});
it("SO-BE-1.6: getOrCreateForTable returns existing OPEN order for table", async () => {
// Given: Mock context with existing OPEN order for tableId="table1"
const ctx = createMockContext({
orders: [{ tableId: "table1", status: "OPEN" }],
});
// When: Calling getOrCreateForTable with same tableId
const result = await ctx.runMutation(api.orders.getOrCreateForTable, {
tableId: "table1",
});
// Then: Returns existing order, does not create new
expect(result._id).toBe(existingOrder._id);
});
it("SO-BE-1.7: requireStaffOrAdmin throws UNAUTHORIZED for unauthenticated identity", async () => {
// Given: Mock context with null identity
const ctx = createMockContext({ identity: null });
// When: Calling any staff-protected mutation
// Then: Throws "UNAUTHORIZED"
await expect(
ctx.runMutation(api.tables.create, {
name: "A1",
capacity: 4,
}),
).rejects.toThrow("UNAUTHORIZED");
});
it("SO-BE-1.8: requireStaffOrAdmin throws UNAUTHORIZED for GUEST role", async () => {
// Given: Mock context with identity but GUEST role
const ctx = createMockContext({
identity: { email: "guest@example.com" },
userRole: "GUEST",
});
// When: Calling any staff-protected mutation
// Then: Throws "UNAUTHORIZED"
await expect(
ctx.runMutation(api.tables.create, {
name: "A1",
capacity: 4,
}),
).rejects.toThrow("UNAUTHORIZED");
});9. Cross-Plan Dependencies
| Dependency | Plan | Shared Schema |
|---|---|---|
| Required by | package-bundle-pricing | orders.totalAmount aggregated at checkout |
| Required by | notifications-crm | reservation.tableId links to table |
| Shares schema with | table-pos-system | Identical tables, menuItems, orders, orderItems tables |
| Depends on | staff-operations | KDS queries depend on orderItems status transitions |
| QR token validation | guest-profiles (Spec 05) | Token generation and validation logic |
10. Performance Considerations
| Scenario | At Scale (100 tables, 500 concurrent guests) |
|---|---|
| Kitchen KDS | Convex subscription filters by station + status; compound index by_station_status prevents full table scan |
| Table List | All 100 tables displayed in scrollable list; no virtualization needed |
| Guest PWA menu | listByCategory uses by_category index; results cached per category |
| Order submission | Each item update is independent; Convex batch handles 50 concurrent submissions |
| Real-time latency | Convex subscription latency target: < 500ms end-to-end |
Acceptance Criteria
- Kitchen KDS kanban board displays items in columns (NEW -> PREPARING -> READY -> SERVED) with real-time updates
- Reception table list shows all tables with availability status
- Guest PWA allows browsing menu by category and adding items to cart
- Guest can submit order and see real-time status updates
- Staff POS allows selecting a table and entering orders on behalf of guests
- All mutations use POS_ERROR_CODES typed constants
- All user-facing strings use next-intl translation keys
- No console.log — use consola for all logging
- Guest PWA uses nuqs URL state (
/table?tableId=xxx&token=xxx), not dynamic route segments
User Stories
| ID | As a... | I want to... | So that... | Priority |
|---|---|---|---|---|
| SO-US01 | Kitchen staff | See incoming orders on a Kanban board | I know what to prepare and when | Must |
| SO-US02 | Kitchen staff | Advance item status through stages | The floor staff knows when to pick up | Must |
| SO-US03 | Bar staff | See drink orders separate from food orders | I can work independently from the kitchen | Must |
| SO-US04 | Floor staff | Select a table and enter an order for guests | I can place orders for guests who prefer not to use the PWA | Must |
| SO-US05 | Reception | See a list of all tables with their status | I know which tables are occupied and which are free | Must |
| SO-US06 | Guest | Scan QR code and browse the menu at my table | I can order food and drinks without waiting for staff | Must |
| SO-US07 | Guest | See my order status update in real-time | I know when my drinks are being prepared | Must |
Test Scenarios
| ID | Scenario | Given | When | Then |
|---|---|---|---|---|
| SO-TS01 | KDS shows new order | Guest submits order via PWA | Kitchen KDS open | New item appears in "New" column within 2s |
| SO-TS02 | KDS item advances | Item in "New" column | Staff taps "Start Preparing" | Item moves to "Preparing" column |
| SO-TS03 | Reception table list | Tables configured | Reception page open | All tables shown with status |
| SO-TS04 | Guest PWA menu load | Menu items exist | Guest scans QR code | Menu grid loads with categories and items |
| SO-TS05 | Guest adds item | Guest on PWA with open order | Taps "Add" on item | Cart count increments, total updates |
| SO-TS06 | Guest submits order | Guest has items in cart | Taps "Send Order" | Order status changes to SUBMITTED, kitchen sees it |
| SO-TS07 | Staff POS order | Staff on POS page | Selects table, adds items, submits | Kitchen sees order on KDS |
| SO-TS08 | Station routing | Item added with station=BAR | Kitchen KDS vs Bar KDS | Item appears on Bar KDS, not Kitchen |
Consistency Audit: staff-operations-plan
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Phase 2, Task 2 | References staffMutation/adminMutation which don't exist in convex/auth.ts | Changed to plain mutation with inline role checks using ctx.auth.getUserIdentity() |
| 2 | Phase 2, Task 2 | getOrCreateForTable was incorrectly marked as needing auth | Clarified that getOrCreateForTable is a public mutation (no auth required) |
| 3 | Phase 3, KitchenKanban | handleMove was passing wrong status | Fixed to pass item.status correctly; added NEXT_STATUS map |
| 4 | Phase 5, TablePage | Missing useTransition for async state updates | Added useTransition with startTransition pattern |
| 5 | Phase 5 | URL uses nuqs (/table?tableId=xxx&token=xxx) — CORRECT, no fix needed | Verified compliance |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | KitchenKanban component | console.log usage | Changed to consola.debug with structured context |
| 2 | MenuGrid component | Missing useTransition for mutations | Added useTransition + startTransition pattern |
| 3 | KitchenPage | Missing Suspense boundary around KitchenKanban | Added Suspense with KitchenLoading skeleton |
| 4 | ReceptionPage | Missing Suspense boundary around TableList | Added Suspense with ReceptionLoading skeleton |
| 5 | MenuGrid component | Used openOrder without type annotation | Added proper OpenOrder type alias |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| 1 | staffMutation/adminMutation do not exist in convex/auth.ts | Foundation plan must implement these auth wrappers before POS mutations can use proper role-based auth |
Schema Consistency Check
- All indexes referenced in handlers (
by_available,by_category,by_station,by_station_status,by_order,by_table) are defined in schema - All mutation inserts match table schemas
orderItemsreferencesordersandmenuItemscorrectly
Auth Consistency
- Plan uses plain
mutationwith inline role checks wherestaffMutationwould go getOrCreateForTableis correctly a plainmutation(public guest endpoint)- This is the correct pattern until auth wrappers are implemented