From dba8e178a2dc1c0f11bdeb221bcf95c298e5f451 Mon Sep 17 00:00:00 2001 From: Andrea Baccega Date: Mon, 6 Jul 2026 15:03:02 +0200 Subject: [PATCH] Add searchable admin Add items flow --- web/e2e/admin/admin-checks-real.spec.ts | 10 ++++-- web/messages/de.json | 6 +++- web/messages/en.json | 6 +++- web/messages/es.json | 6 +++- web/messages/fr.json | 6 +++- web/messages/hu.json | 6 +++- web/messages/it.json | 6 +++- web/messages/nl.json | 6 +++- web/messages/pt.json | 6 +++- web/messages/ru.json | 6 +++- web/messages/vec.json | 6 +++- .../admin/pages/AdminTableDetailPage.test.tsx | 33 +++++++++++++++++ .../admin/pages/AdminTableDetailPage.tsx | 35 ++++++++++++++----- 13 files changed, 117 insertions(+), 21 deletions(-) diff --git a/web/e2e/admin/admin-checks-real.spec.ts b/web/e2e/admin/admin-checks-real.spec.ts index 3737f8e..f2bbc21 100644 --- a/web/e2e/admin/admin-checks-real.spec.ts +++ b/web/e2e/admin/admin-checks-real.spec.ts @@ -72,7 +72,7 @@ test.describe.serial("Checks (conto) — real backend", () => { await expect(page.getByTestId("table-free")).toBeVisible(); await expect(page.getByText("Pagato").first()).toBeVisible(); }); - test("admin adds an order from the table page", async ({ page, request }) => { + test("admin adds searchable items from the table page", async ({ page, request }) => { await seedSessionWithOrder(request); await page.goto(`/admin/tables/detail?tableId=${SEEDED_TABLE.id}`); @@ -80,8 +80,12 @@ test.describe.serial("Checks (conto) — real backend", () => { const before = await page.locator('[data-testid^="order-"]').count(); expect(before).toBeGreaterThan(0); await page.getByTestId("add-order").click(); - await expect(page.getByText(/bruschetta/i).first()).toBeVisible({ timeout: 10000 }); - await page.locator("button").filter({ hasText: "+" }).first().click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: /aggiungi articoli|add items/i })).toBeVisible({ timeout: 10000 }); + await page.getByTestId("admin-item-search").fill("prosecco"); + await expect(dialog.getByText(/prosecco/i).first()).toBeVisible(); + await expect(dialog.getByText(/bruschetta/i).first()).not.toBeVisible(); + await page.getByTestId("admin-item-plus-demo-entry-prosecco").click(); const submitRes = page.waitForResponse((res) => res.url().includes("/admin/sessions/") && res.url().includes("/orders") && res.request().method() === "POST" && res.status() < 300); await page.getByTestId("submit-admin-order").click(); diff --git a/web/messages/de.json b/web/messages/de.json index f7f11a6..4056337 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -657,7 +657,11 @@ "tableDetail.startOrder": "Bestellung starten", "tableDetail.addOrder": "Bestellung hinzufügen", "tableDetail.submitOrder": "Bestellung senden", - "tableDetail.checkOpenOrderBlocked": "Offene Rechnung: erst bezahlen oder stornieren" + "tableDetail.checkOpenOrderBlocked": "Offene Rechnung: erst bezahlen oder stornieren", + "tableDetail.addItems": "Artikel hinzufügen", + "tableDetail.searchItems": "Alle verfügbaren Artikel suchen", + "tableDetail.noAvailableItems": "Keine verfügbaren Artikel gefunden.", + "tableDetail.submitItems": "Artikel hinzufügen" }, "outOfStock": "AUSVERKAUFT", "staff": { diff --git a/web/messages/en.json b/web/messages/en.json index 4a82799..23d5233 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -657,7 +657,11 @@ "tableDetail.startOrder": "Start order", "tableDetail.addOrder": "Add order", "tableDetail.submitOrder": "Send order", - "tableDetail.checkOpenOrderBlocked": "Open check: settle or void it before adding orders" + "tableDetail.checkOpenOrderBlocked": "Open check: settle or void it before adding orders", + "tableDetail.addItems": "Add items", + "tableDetail.searchItems": "Search all available items", + "tableDetail.noAvailableItems": "No available items found.", + "tableDetail.submitItems": "Add items" }, "outOfStock": "OUT OF STOCK", "staff": { diff --git a/web/messages/es.json b/web/messages/es.json index afe7d86..1442d61 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -657,7 +657,11 @@ "tableDetail.startOrder": "Iniciar pedido", "tableDetail.addOrder": "Añadir pedido", "tableDetail.submitOrder": "Enviar pedido", - "tableDetail.checkOpenOrderBlocked": "Cuenta abierta: cobra o anula antes de añadir pedidos" + "tableDetail.checkOpenOrderBlocked": "Cuenta abierta: cobra o anula antes de añadir pedidos", + "tableDetail.addItems": "Añadir artículos", + "tableDetail.searchItems": "Buscar artículos disponibles", + "tableDetail.noAvailableItems": "No se encontraron artículos disponibles.", + "tableDetail.submitItems": "Añadir artículos" }, "outOfStock": "AGOTADO", "staff": { diff --git a/web/messages/fr.json b/web/messages/fr.json index 00156a8..02c66b2 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -657,7 +657,11 @@ "tableDetail.startOrder": "Démarrer une commande", "tableDetail.addOrder": "Ajouter une commande", "tableDetail.submitOrder": "Envoyer la commande", - "tableDetail.checkOpenOrderBlocked": "Addition ouverte : encaissez ou annulez avant d’ajouter des commandes" + "tableDetail.checkOpenOrderBlocked": "Addition ouverte : encaissez ou annulez avant d’ajouter des commandes", + "tableDetail.addItems": "Ajouter des articles", + "tableDetail.searchItems": "Rechercher les articles disponibles", + "tableDetail.noAvailableItems": "Aucun article disponible trouvé.", + "tableDetail.submitItems": "Ajouter des articles" }, "outOfStock": "ÉPUISÉ", "staff": { diff --git a/web/messages/hu.json b/web/messages/hu.json index 192fa41..a29cf50 100644 --- a/web/messages/hu.json +++ b/web/messages/hu.json @@ -657,7 +657,11 @@ "tableDetail.startOrder": "Rendelés indítása", "tableDetail.addOrder": "Rendelés hozzáadása", "tableDetail.submitOrder": "Rendelés küldése", - "tableDetail.checkOpenOrderBlocked": "Nyitott számla: fizetés vagy törlés után adható hozzá rendelés" + "tableDetail.checkOpenOrderBlocked": "Nyitott számla: fizetés vagy törlés után adható hozzá rendelés", + "tableDetail.addItems": "Tételek hozzáadása", + "tableDetail.searchItems": "Elérhető tételek keresése", + "tableDetail.noAvailableItems": "Nem található elérhető tétel.", + "tableDetail.submitItems": "Tételek hozzáadása" }, "outOfStock": "OUT OF STOCK", "staff": { diff --git a/web/messages/it.json b/web/messages/it.json index b4085f3..fb7438b 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -657,7 +657,11 @@ "tableDetail.startOrder": "Apri ordine", "tableDetail.addOrder": "Aggiungi ordine", "tableDetail.submitOrder": "Invia ordine", - "tableDetail.checkOpenOrderBlocked": "Conto aperto: pagalo o annullalo prima di aggiungere ordini" + "tableDetail.checkOpenOrderBlocked": "Conto aperto: pagalo o annullalo prima di aggiungere ordini", + "tableDetail.addItems": "Aggiungi articoli", + "tableDetail.searchItems": "Cerca tutti gli articoli disponibili", + "tableDetail.noAvailableItems": "Nessun articolo disponibile trovato.", + "tableDetail.submitItems": "Aggiungi articoli" }, "outOfStock": "ESAURITO", "staff": { diff --git a/web/messages/nl.json b/web/messages/nl.json index ca74981..cf21a59 100644 --- a/web/messages/nl.json +++ b/web/messages/nl.json @@ -657,7 +657,11 @@ "tableDetail.startOrder": "Bestelling starten", "tableDetail.addOrder": "Bestelling toevoegen", "tableDetail.submitOrder": "Bestelling versturen", - "tableDetail.checkOpenOrderBlocked": "Open rekening: betaal of annuleer vóór nieuwe bestellingen" + "tableDetail.checkOpenOrderBlocked": "Open rekening: betaal of annuleer vóór nieuwe bestellingen", + "tableDetail.addItems": "Items toevoegen", + "tableDetail.searchItems": "Zoek beschikbare items", + "tableDetail.noAvailableItems": "Geen beschikbare items gevonden.", + "tableDetail.submitItems": "Items toevoegen" }, "outOfStock": "UITVERKOCHT", "staff": { diff --git a/web/messages/pt.json b/web/messages/pt.json index f4e6cfc..ce9c766 100644 --- a/web/messages/pt.json +++ b/web/messages/pt.json @@ -657,7 +657,11 @@ "tableDetail.startOrder": "Iniciar pedido", "tableDetail.addOrder": "Adicionar pedido", "tableDetail.submitOrder": "Enviar pedido", - "tableDetail.checkOpenOrderBlocked": "Conta aberta: pague ou anule antes de adicionar pedidos" + "tableDetail.checkOpenOrderBlocked": "Conta aberta: pague ou anule antes de adicionar pedidos", + "tableDetail.addItems": "Adicionar itens", + "tableDetail.searchItems": "Pesquisar itens disponíveis", + "tableDetail.noAvailableItems": "Nenhum item disponível encontrado.", + "tableDetail.submitItems": "Adicionar itens" }, "outOfStock": "ESGOTADO", "staff": { diff --git a/web/messages/ru.json b/web/messages/ru.json index bdc1751..0e44db1 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -657,7 +657,11 @@ "tableDetail.startOrder": "Начать заказ", "tableDetail.addOrder": "Добавить заказ", "tableDetail.submitOrder": "Отправить заказ", - "tableDetail.checkOpenOrderBlocked": "Счёт открыт: сначала оплатите или отмените его" + "tableDetail.checkOpenOrderBlocked": "Счёт открыт: сначала оплатите или отмените его", + "tableDetail.addItems": "Добавить позиции", + "tableDetail.searchItems": "Поиск доступных позиций", + "tableDetail.noAvailableItems": "Доступные позиции не найдены.", + "tableDetail.submitItems": "Добавить позиции" }, "outOfStock": "НЕТ В НАЛИЧИИ", "staff": { diff --git a/web/messages/vec.json b/web/messages/vec.json index 8c77457..5db465e 100644 --- a/web/messages/vec.json +++ b/web/messages/vec.json @@ -657,7 +657,11 @@ "tableDetail.startOrder": "Scominsia ordine", "tableDetail.addOrder": "Zonta ordine", "tableDetail.submitOrder": "Manda ordine", - "tableDetail.checkOpenOrderBlocked": "Conto verto: paga o anula prima de zontar ordini" + "tableDetail.checkOpenOrderBlocked": "Conto verto: paga o anula prima de zontar ordini", + "tableDetail.addItems": "Zonta voci", + "tableDetail.searchItems": "Serca tute łe voci disponìbiłi", + "tableDetail.noAvailableItems": "Nisuna vose disponìbiłe catada.", + "tableDetail.submitItems": "Zonta voci" }, "outOfStock": "FINIO", "staff": { diff --git a/web/src/components/admin/pages/AdminTableDetailPage.test.tsx b/web/src/components/admin/pages/AdminTableDetailPage.test.tsx index 17a3c99..be8ae7f 100644 --- a/web/src/components/admin/pages/AdminTableDetailPage.test.tsx +++ b/web/src/components/admin/pages/AdminTableDetailPage.test.tsx @@ -13,6 +13,9 @@ const apiMocks = vi.hoisted(() => ({ settleCheck: vi.fn(), voidCheck: vi.fn(), adminCloseSession: vi.fn(), + openAdminTableSession: vi.fn(), + createAdminSessionOrder: vi.fn(), + getAdminCatalog: vi.fn(), })); vi.mock("@/lib/api", () => apiMocks); @@ -89,4 +92,34 @@ describe("AdminTableDetailPage", () => { render(); expect(await screen.findByTestId("table-free")).toBeInTheDocument(); }); + + it("adds items with searchable sellable inventory only", async () => { + apiMocks.getAdminCatalog.mockResolvedValue({ + categories: [ + { id: "c1", name: "Antipasti", sortOrder: 0, i18n: null, entries: [ + { id: "bruschetta", name: "Bruschetta", description: null, internalCode: null, price: 7.5, priceUnit: null, imageUrl: null, outOfStock: false, frozen: false, sortOrder: 0, hidden: false, menuIds: [], labelIds: [], destinationIds: [], allergens: null, i18n: null, metadata: null }, + { id: "hidden", name: "Hidden wine", description: null, internalCode: null, price: 9, priceUnit: null, imageUrl: null, outOfStock: false, frozen: false, sortOrder: 1, hidden: true, menuIds: [], labelIds: [], destinationIds: [], allergens: null, i18n: null, metadata: null }, + ] }, + { id: "c2", name: "Bar", sortOrder: 1, i18n: null, entries: [ + { id: "prosecco", name: "Prosecco", description: null, internalCode: null, price: 6, priceUnit: null, imageUrl: null, outOfStock: false, frozen: false, sortOrder: 0, hidden: false, menuIds: [], labelIds: [], destinationIds: [], allergens: null, i18n: null, metadata: null }, + { id: "sold", name: "Sold out beer", description: null, internalCode: null, price: 5, priceUnit: null, imageUrl: null, outOfStock: true, frozen: false, sortOrder: 1, hidden: false, menuIds: [], labelIds: [], destinationIds: [], allergens: null, i18n: null, metadata: null }, + ] }, + ], + }); + render(); + fireEvent.click(await screen.findByTestId("add-order")); + expect(await screen.findByRole("heading", { name: "tableDetail.addItems" })).toBeInTheDocument(); + expect(screen.queryByText("Hidden wine")).toBeNull(); + expect(screen.queryByText("Sold out beer")).toBeNull(); + + fireEvent.click(screen.getByTestId("admin-item-plus-bruschetta")); + fireEvent.change(screen.getByTestId("admin-item-search"), { target: { value: "bar" } }); + expect(screen.queryByText("Bruschetta")).toBeNull(); + expect(screen.getByText("Prosecco")).toBeInTheDocument(); + fireEvent.change(screen.getByTestId("admin-item-search"), { target: { value: "brus" } }); + expect(screen.getByTestId("admin-item-qty-bruschetta")).toHaveTextContent("1"); + + fireEvent.click(screen.getByTestId("submit-admin-order")); + await waitFor(() => expect(apiMocks.createAdminSessionOrder).toHaveBeenCalledWith("s1", [{ entryId: "bruschetta", quantity: 1 }])); + }); }); diff --git a/web/src/components/admin/pages/AdminTableDetailPage.tsx b/web/src/components/admin/pages/AdminTableDetailPage.tsx index cfda13a..e13fbd2 100644 --- a/web/src/components/admin/pages/AdminTableDetailPage.tsx +++ b/web/src/components/admin/pages/AdminTableDetailPage.tsx @@ -254,7 +254,7 @@ function SessionCard({ data-testid="add-order" className="px-3 py-1.5 rounded-lg border border-primary text-primary text-xs font-semibold disabled:opacity-50" > - {t("tableDetail.addOrder")} + {t("tableDetail.addItems")} ) : ( {t("tableDetail.checkOpenOrderBlocked")} @@ -290,12 +290,21 @@ function AdminAddOrderModal({ t, onClose, onSubmit }: { t: T; onClose: () => voi const [catalog, setCatalog] = useState(null); const [cart, setCart] = useState>({}); const [error, setError] = useState(null); + const [query, setQuery] = useState(""); useEffect(() => { getAdminCatalog().then(setCatalog).catch((err) => setError(err instanceof Error ? err.message : String(err))); }, []); - const entries = catalog?.categories.flatMap((category) => category.entries).filter((e) => !e.hidden && !e.outOfStock).sort((a, b) => a.sortOrder - b.sortOrder) ?? []; + const normalizedQuery = query.trim().toLowerCase(); + const entries = catalog?.categories + .flatMap((category) => category.entries.map((entry) => ({ ...entry, categoryName: category.name, categorySortOrder: category.sortOrder }))) + .filter((entry) => !entry.hidden && !entry.outOfStock) + .filter((entry) => { + if (!normalizedQuery) return true; + return entry.name.toLowerCase().includes(normalizedQuery) || entry.categoryName.toLowerCase().includes(normalizedQuery); + }) + .sort((a, b) => a.categorySortOrder - b.categorySortOrder || a.sortOrder - b.sortOrder) ?? []; const lines = Object.entries(cart).filter(([, quantity]) => quantity > 0).map(([entryId, quantity]) => ({ entryId, quantity })); const inc = (id: string, delta: number) => setCart((prev) => ({ ...prev, [id]: Math.max(0, (prev[id] ?? 0) + delta) })); @@ -303,21 +312,31 @@ function AdminAddOrderModal({ t, onClose, onSubmit }: { t: T; onClose: () => voi
-

{t("tableDetail.addOrder")}

+

{t("tableDetail.addItems")}

{error &&
{error}
} +
+ setQuery(e.target.value)} + placeholder={t("tableDetail.searchItems")} + data-testid="admin-item-search" + className="h-10 w-full rounded-lg border border-gray-200 px-3 text-sm" + autoFocus + /> +
- {!catalog ?

{t("common.loading")}

: entries.map((entry) => ( + {!catalog ?

{t("common.loading")}

: entries.length === 0 ?

{t("tableDetail.noAvailableItems")}

: entries.map((entry) => (
{entry.name}
-
{euros(Math.round(entry.price * 100))}
+
{entry.categoryName} · {euros(Math.round(entry.price * 100))}
- {cart[entry.id] ?? 0} - + {cart[entry.id] ?? 0} +
))} @@ -325,7 +344,7 @@ function AdminAddOrderModal({ t, onClose, onSubmit }: { t: T; onClose: () => voi