plans
2026-05-03
2026 05 03 Staff Operations Plan

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 / adminMutation not implemented. The existing convex/auth.ts only provides getCurrentUser, upsertUser, and isAdmin helpers. Any plan referencing staffMutation or adminMutation must be blocked on the foundation plan implementing these wrappers. Until then, use plain mutation from convex/_generated/server with role checks inside the handler. [P0 GAP: staffMutation/adminMutation not yet implemented in convex/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. Use consola for all structured logging.

[P0 RULE] useQuery API calls: Never double-call API functions. Use useQuery(api.shows.upcoming, { limit: 8 }) NOT useQuery(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 POS

Phase 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, orderItems tables
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 tableId and checkedInAt to reservations table
// 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. staffMutation and adminMutation do not exist in convex/auth.ts. All POS mutations that require role checking must use plain mutation from convex/_generated/server and check user.role === "STAFF" or user.role === "ADMIN" inside the handler until the auth wrappers are implemented. [P0 GAP: staffMutation/adminMutation not yet implemented — using inline role check pattern]

[P0 RULE] getOrCreateForTable is 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 plain mutation without auth wrapping. Authenticated mutations (create, update, delete) for tables, menu, and orders must use mutation with 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 getOrCreateForTable for 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. The tableId and token are 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;
MutationError CodeMessage KeyCondition
tables.createTABLE_NAME_REQUIREDerrors.table.nameRequiredEmpty name
tables.createINVALID_CAPACITYerrors.table.invalidCapacityCapacity < 1
menu.createMENU_ITEM_NOT_FOUNDerrors.menu.notFoundItem does not exist
menu.createMENU_ITEM_UNAVAILABLEerrors.menu.unavailableItem not available
orders.addItemORDER_NOT_FOUNDerrors.order.notFoundOrder does not exist
orders.addItemORDER_NOT_OPENerrors.order.notOpenOrder already submitted
orders.addItemINVALID_QUANTITYerrors.order.invalidQuantityQuantity < 1
orders.submitOrderORDER_EMPTYerrors.order.emptyNo items to submit
orders.updateItemStatusORDER_ITEM_NOT_FOUNDerrors.order.itemNotFoundItem 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

ComponentMobile Behavior
Kitchen KDSHorizontal scroll for kanban columns; sticky header with station filter
Floor PlanPinch-to-zoom SVG; tap table for slide-over details
Staff POSFull-screen table list on left; menu grid fills remaining space
Guest PWASingle-column menu grid; floating cart button at bottom
Cart DrawerBottom 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

DependencyPlanShared Schema
Required bypackage-bundle-pricingorders.totalAmount aggregated at checkout
Required bynotifications-crmreservation.tableId links to table
Shares schema withtable-pos-systemIdentical tables, menuItems, orders, orderItems tables
Depends onstaff-operationsKDS queries depend on orderItems status transitions
QR token validationguest-profiles (Spec 05)Token generation and validation logic

10. Performance Considerations

ScenarioAt Scale (100 tables, 500 concurrent guests)
Kitchen KDSConvex subscription filters by station + status; compound index by_station_status prevents full table scan
Table ListAll 100 tables displayed in scrollable list; no virtualization needed
Guest PWA menulistByCategory uses by_category index; results cached per category
Order submissionEach item update is independent; Convex batch handles 50 concurrent submissions
Real-time latencyConvex subscription latency target: < 500ms end-to-end

Acceptance Criteria

  1. Kitchen KDS kanban board displays items in columns (NEW -> PREPARING -> READY -> SERVED) with real-time updates
  2. Reception table list shows all tables with availability status
  3. Guest PWA allows browsing menu by category and adding items to cart
  4. Guest can submit order and see real-time status updates
  5. Staff POS allows selecting a table and entering orders on behalf of guests
  6. All mutations use POS_ERROR_CODES typed constants
  7. All user-facing strings use next-intl translation keys
  8. No console.log — use consola for all logging
  9. Guest PWA uses nuqs URL state (/table?tableId=xxx&token=xxx), not dynamic route segments

User Stories

IDAs a...I want to...So that...Priority
SO-US01Kitchen staffSee incoming orders on a Kanban boardI know what to prepare and whenMust
SO-US02Kitchen staffAdvance item status through stagesThe floor staff knows when to pick upMust
SO-US03Bar staffSee drink orders separate from food ordersI can work independently from the kitchenMust
SO-US04Floor staffSelect a table and enter an order for guestsI can place orders for guests who prefer not to use the PWAMust
SO-US05ReceptionSee a list of all tables with their statusI know which tables are occupied and which are freeMust
SO-US06GuestScan QR code and browse the menu at my tableI can order food and drinks without waiting for staffMust
SO-US07GuestSee my order status update in real-timeI know when my drinks are being preparedMust

Test Scenarios

IDScenarioGivenWhenThen
SO-TS01KDS shows new orderGuest submits order via PWAKitchen KDS openNew item appears in "New" column within 2s
SO-TS02KDS item advancesItem in "New" columnStaff taps "Start Preparing"Item moves to "Preparing" column
SO-TS03Reception table listTables configuredReception page openAll tables shown with status
SO-TS04Guest PWA menu loadMenu items existGuest scans QR codeMenu grid loads with categories and items
SO-TS05Guest adds itemGuest on PWA with open orderTaps "Add" on itemCart count increments, total updates
SO-TS06Guest submits orderGuest has items in cartTaps "Send Order"Order status changes to SUBMITTED, kitchen sees it
SO-TS07Staff POS orderStaff on POS pageSelects table, adds items, submitsKitchen sees order on KDS
SO-TS08Station routingItem added with station=BARKitchen KDS vs Bar KDSItem appears on Bar KDS, not Kitchen

Consistency Audit: staff-operations-plan

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
1Phase 2, Task 2References staffMutation/adminMutation which don't exist in convex/auth.tsChanged to plain mutation with inline role checks using ctx.auth.getUserIdentity()
2Phase 2, Task 2getOrCreateForTable was incorrectly marked as needing authClarified that getOrCreateForTable is a public mutation (no auth required)
3Phase 3, KitchenKanbanhandleMove was passing wrong statusFixed to pass item.status correctly; added NEXT_STATUS map
4Phase 5, TablePageMissing useTransition for async state updatesAdded useTransition with startTransition pattern
5Phase 5URL uses nuqs (/table?tableId=xxx&token=xxx) — CORRECT, no fix neededVerified compliance

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
1KitchenKanban componentconsole.log usageChanged to consola.debug with structured context
2MenuGrid componentMissing useTransition for mutationsAdded useTransition + startTransition pattern
3KitchenPageMissing Suspense boundary around KitchenKanbanAdded Suspense with KitchenLoading skeleton
4ReceptionPageMissing Suspense boundary around TableListAdded Suspense with ReceptionLoading skeleton
5MenuGrid componentUsed openOrder without type annotationAdded proper OpenOrder type alias

P0 Gaps (cannot fix in plan — requires codebase change)

#IssueAction Required
1staffMutation/adminMutation do not exist in convex/auth.tsFoundation 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
  • orderItems references orders and menuItems correctly

Auth Consistency

  • Plan uses plain mutation with inline role checks where staffMutation would go
  • getOrCreateForTable is correctly a plain mutation (public guest endpoint)
  • This is the correct pattern until auth wrappers are implemented