Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions web/e2e/admin/admin-checks-real.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,20 @@ 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}`);
await expect(page.getByTestId("add-order")).toBeVisible({ timeout: 10000 });
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();
Expand Down
6 changes: 5 additions & 1 deletion web/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 5 additions & 1 deletion web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 5 additions & 1 deletion web/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 5 additions & 1 deletion web/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 5 additions & 1 deletion web/messages/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 5 additions & 1 deletion web/messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 5 additions & 1 deletion web/messages/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 5 additions & 1 deletion web/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 5 additions & 1 deletion web/messages/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,11 @@
"tableDetail.startOrder": "Начать заказ",
"tableDetail.addOrder": "Добавить заказ",
"tableDetail.submitOrder": "Отправить заказ",
"tableDetail.checkOpenOrderBlocked": "Счёт открыт: сначала оплатите или отмените его"
"tableDetail.checkOpenOrderBlocked": "Счёт открыт: сначала оплатите или отмените его",
"tableDetail.addItems": "Добавить позиции",
"tableDetail.searchItems": "Поиск доступных позиций",
"tableDetail.noAvailableItems": "Доступные позиции не найдены.",
"tableDetail.submitItems": "Добавить позиции"
},
"outOfStock": "НЕТ В НАЛИЧИИ",
"staff": {
Expand Down
6 changes: 5 additions & 1 deletion web/messages/vec.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
33 changes: 33 additions & 0 deletions web/src/components/admin/pages/AdminTableDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -89,4 +92,34 @@ describe("AdminTableDetailPage", () => {
render(<AdminTableDetailPage tableId="t1" />);
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(<AdminTableDetailPage tableId="t1" />);
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 }]));
});
});
35 changes: 27 additions & 8 deletions web/src/components/admin/pages/AdminTableDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
</button>
) : (
<span className="text-xs text-amber-700 bg-amber-50 rounded-lg px-3 py-1.5">{t("tableDetail.checkOpenOrderBlocked")}</span>
Expand Down Expand Up @@ -290,42 +290,61 @@ function AdminAddOrderModal({ t, onClose, onSubmit }: { t: T; onClose: () => voi
const [catalog, setCatalog] = useState<CatalogResponse | null>(null);
const [cart, setCart] = useState<Record<string, number>>({});
const [error, setError] = useState<string | null>(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) }));

return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center print-hide" role="dialog" aria-modal="true">
<div className="bg-white rounded-t-2xl sm:rounded-2xl shadow-xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-900">{t("tableDetail.addOrder")}</h2>
<h2 className="text-lg font-bold text-gray-900">{t("tableDetail.addItems")}</h2>
<button type="button" onClick={onClose} className="text-sm text-gray-500">{t("common.close")}</button>
</div>
{error && <div className="m-4 rounded-lg bg-red-50 text-red-700 px-4 py-3 text-sm">{error}</div>}
<div className="p-4 border-b border-gray-100">
<input
value={query}
onChange={(e) => 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
/>
</div>
<div className="p-4 overflow-y-auto space-y-2">
{!catalog ? <p className="text-sm text-gray-500">{t("common.loading")}</p> : entries.map((entry) => (
{!catalog ? <p className="text-sm text-gray-500">{t("common.loading")}</p> : entries.length === 0 ? <p className="text-sm text-gray-500">{t("tableDetail.noAvailableItems")}</p> : entries.map((entry) => (
<div key={entry.id} className="rounded-xl border border-gray-200 p-3 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-gray-900">{entry.name}</div>
<div className="text-sm text-gray-500">{euros(Math.round(entry.price * 100))}</div>
<div className="text-sm text-gray-500">{entry.categoryName} · {euros(Math.round(entry.price * 100))}</div>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={() => inc(entry.id, -1)} className="w-8 h-8 rounded-full border border-gray-200">−</button>
<span className="w-6 text-center text-sm font-semibold">{cart[entry.id] ?? 0}</span>
<button type="button" onClick={() => inc(entry.id, 1)} className="w-8 h-8 rounded-full bg-primary text-white">+</button>
<span data-testid={`admin-item-qty-${entry.id}`} className="w-6 text-center text-sm font-semibold">{cart[entry.id] ?? 0}</span>
<button type="button" data-testid={`admin-item-plus-${entry.id}`} onClick={() => inc(entry.id, 1)} className="w-8 h-8 rounded-full bg-primary text-white">+</button>
</div>
</div>
))}
</div>
<div className="p-4 border-t border-gray-100 flex justify-end gap-2">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg border border-gray-200 text-gray-600 text-sm font-semibold">{t("common.cancel")}</button>
<button type="button" disabled={lines.length === 0} onClick={() => onSubmit(lines)} data-testid="submit-admin-order" className="px-4 py-2 rounded-lg bg-primary text-white text-sm font-semibold disabled:opacity-50">
{t("tableDetail.submitOrder")}
{t("tableDetail.submitItems")}
</button>
</div>
</div>
Expand Down
Loading