From c8d172135833954b16a29f2af5f8e84bf0e67372 Mon Sep 17 00:00:00 2001 From: lukas Date: Thu, 10 Sep 2026 23:14:28 +0200 Subject: [PATCH] Add an admin panel: orders dashboard, menu management, kitchen status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a role-gated /admin section (sidebar layout, requireAdmin checked both in route loaders and independently in every admin server function) with three sections: - Orders: every order across all customers, and an admin-only order detail view nested under /admin so the sidebar persists and admin access bypasses the guest-token check. - Menu: create/edit/delete menu items, their options, and their preparation steps; deleting an item with past orders is refused (protected by the orderItems FK) instead of crashing. - Kitchen: read-only live view of every machine's busy/idle status and per-tag queue depth, with a manual refresh. Also: - Adds a `role` field to the user table (Better Auth additionalFields, input:false so it can't be self-assigned) and a db:seed-admin script to create/promote an admin account. - Fixes a real bug found while building this: src/server/auth.ts's shared session helpers were plain exported functions using a server-only import, which the framework couldn't tree-shake out of the client bundle once shared across multiple server/*.ts modules — this silently broke hydration app-wide. Fixed with createServerOnlyFn. - Removes the now-fully-unused @tanstack/devtools-vite plugin. - Disables view transitions for in-admin navigation so the persistent sidebar doesn't cross-fade/shift on every click. - Gives the "your orders" empty state the same visual treatment as the basket page's. Co-Authored-By: Claude Sonnet 5 --- .env.example | 6 + package.json | 1 + .../admin/AdminMenuListView.module.scss | 129 +++++++++ src/components/admin/AdminMenuListView.tsx | 108 +++++++ src/components/admin/MenuItemForm.module.scss | 145 ++++++++++ src/components/admin/MenuItemForm.tsx | 268 ++++++++++++++++++ .../orders/AdminOrdersView.module.scss | 69 +++++ src/components/orders/AdminOrdersView.tsx | 62 ++++ src/components/orders/OrderStatusView.tsx | 10 +- .../orders/OrdersListView.module.scss | 42 ++- src/components/orders/OrdersListView.tsx | 30 +- src/db/seed-admin.ts | 65 +++++ src/domain/kitchen.ts | 150 ++++++++++ src/domain/menu.ts | 216 ++++++++++++++ src/domain/ordering.ts | 41 ++- src/integrations/better-auth/header-user.tsx | 25 ++ src/lib/admin-guard.ts | 17 ++ src/lib/auth-client.ts | 7 +- src/lib/auth.ts | 12 + src/routeTree.gen.ts | 174 ++++++++++++ src/routes/admin.module.scss | 67 +++++ src/routes/admin.tsx | 46 +++ src/routes/admin/index.tsx | 11 + src/routes/admin/kitchen.module.scss | 107 +++++++ src/routes/admin/kitchen.tsx | 98 +++++++ src/routes/admin/menu/$menuItemId.tsx | 37 +++ src/routes/admin/menu/index.tsx | 25 ++ src/routes/admin/menu/new.tsx | 29 ++ src/routes/admin/orders/$orderId.tsx | 42 +++ src/routes/admin/orders/index.tsx | 15 + src/server/auth.ts | 35 +++ src/server/kitchen.ts | 12 + src/server/menu-admin.ts | 61 ++++ src/server/orders.ts | 27 +- src/styles/globals.scss | 12 + tests/ordering.test.ts | 44 +++ vite.config.ts | 3 +- 37 files changed, 2224 insertions(+), 24 deletions(-) create mode 100644 src/components/admin/AdminMenuListView.module.scss create mode 100644 src/components/admin/AdminMenuListView.tsx create mode 100644 src/components/admin/MenuItemForm.module.scss create mode 100644 src/components/admin/MenuItemForm.tsx create mode 100644 src/components/orders/AdminOrdersView.module.scss create mode 100644 src/components/orders/AdminOrdersView.tsx create mode 100644 src/db/seed-admin.ts create mode 100644 src/domain/kitchen.ts create mode 100644 src/domain/menu.ts create mode 100644 src/lib/admin-guard.ts create mode 100644 src/routes/admin.module.scss create mode 100644 src/routes/admin.tsx create mode 100644 src/routes/admin/index.tsx create mode 100644 src/routes/admin/kitchen.module.scss create mode 100644 src/routes/admin/kitchen.tsx create mode 100644 src/routes/admin/menu/$menuItemId.tsx create mode 100644 src/routes/admin/menu/index.tsx create mode 100644 src/routes/admin/menu/new.tsx create mode 100644 src/routes/admin/orders/$orderId.tsx create mode 100644 src/routes/admin/orders/index.tsx create mode 100644 src/server/auth.ts create mode 100644 src/server/kitchen.ts create mode 100644 src/server/menu-admin.ts diff --git a/.env.example b/.env.example index 7c4bd57..de831bd 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,9 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= APPLE_CLIENT_ID= APPLE_CLIENT_SECRET= + +# Used by `npm run db:seed-admin` to create/promote one admin account +# (can view every order at /admin/orders). Not read by the app itself. +ADMIN_EMAIL= +ADMIN_PASSWORD= +ADMIN_NAME= diff --git a/package.json b/package.json index c00a514..c3cf975 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "db:pull": "drizzle-kit pull", "db:studio": "drizzle-kit studio", "db:seed": "tsx src/db/seed.ts", + "db:seed-admin": "tsx src/db/seed-admin.ts", "auth:migrate": "better-auth migrate", "lint": "eslint", "format": "prettier --write . && eslint --fix", diff --git a/src/components/admin/AdminMenuListView.module.scss b/src/components/admin/AdminMenuListView.module.scss new file mode 100644 index 0000000..cd6048e --- /dev/null +++ b/src/components/admin/AdminMenuListView.module.scss @@ -0,0 +1,129 @@ +@use '../../styles/variables' as *; + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-3; + margin-bottom: $space-4; +} + +.heading { + margin: 0; +} + +.addLink { + padding: $space-2 $space-4; + border-radius: $radius; + background: $color-primary; + color: $color-primary-contrast; + font-weight: 600; + text-decoration: none; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.9; + } +} + +.empty { + color: $color-text-muted; +} + +.items { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: $space-3; +} + +.item { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-3; + padding: $space-3; + border: 1px solid $color-border; + border-radius: $radius; + background: $color-surface; +} + +.itemInfo { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.itemHeader { + display: flex; + align-items: baseline; + gap: $space-2; +} + +.itemName { + font-weight: 600; +} + +.itemPrice { + color: $color-text-muted; + font-size: 0.9rem; +} + +.itemMeta { + margin: 0; + font-size: 0.85rem; + color: $color-text-muted; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemActions { + display: flex; + align-items: center; + gap: $space-2; + flex: 0 0 auto; +} + +.editLink { + padding: $space-1 $space-3; + border: 1px solid $color-border; + border-radius: $radius; + color: $color-text; + text-decoration: none; + + &:hover { + border-color: $color-primary; + } +} + +.deleteButton { + padding: $space-1 $space-3; + border: 1px solid $color-border; + border-radius: $radius; + background: $color-background; + color: $color-danger; + cursor: pointer; + + &:hover { + border-color: $color-danger; + } +} + +.confirmButton { + padding: $space-1 $space-3; + border: none; + border-radius: $radius; + background: $color-danger; + color: $color-primary-contrast; + cursor: pointer; +} + +.error { + color: $color-danger; + font-size: 0.85rem; + margin: $space-2 0 0; +} diff --git a/src/components/admin/AdminMenuListView.tsx b/src/components/admin/AdminMenuListView.tsx new file mode 100644 index 0000000..fd4bf4e --- /dev/null +++ b/src/components/admin/AdminMenuListView.tsx @@ -0,0 +1,108 @@ +import { Link } from '@tanstack/react-router' +import { useState } from 'react' + +import type { AdminMenuItem } from '#/domain/menu.ts' +import { formatPrice } from '#/lib/format.ts' + +import styles from './AdminMenuListView.module.scss' + +type AdminMenuListViewProps = { + items: Array + onDelete: (menuItemId: number) => Promise +} + +export const AdminMenuListView = ({ + items, + onDelete, +}: AdminMenuListViewProps) => { + const [confirmingId, setConfirmingId] = useState(null) + const [deletingId, setDeletingId] = useState(null) + const [error, setError] = useState(null) + + const confirmDelete = async (menuItemId: number) => { + setDeletingId(menuItemId) + setError(null) + try { + await onDelete(menuItemId) + } catch (deleteError) { + setError( + deleteError instanceof Error + ? deleteError.message + : 'Something went wrong.', + ) + } finally { + setDeletingId(null) + setConfirmingId(null) + } + } + + return ( +
+
+

Menu

+ + Add item + +
+ + {items.length === 0 ? ( +

No menu items yet.

+ ) : ( +
    + {items.map((item) => ( +
  • +
    +
    + {item.name} + + {formatPrice(item.priceCents)} + +
    +

    + {item.steps.length} step + {item.steps.length === 1 ? '' : 's'} ·{' '} + {item.options.length} option + {item.options.length === 1 ? '' : 's'} + {item.description ? ` · ${item.description}` : ''} +

    +
    +
    + + Edit + + {confirmingId === item.id ? ( + + ) : ( + + )} +
    +
  • + ))} +
+ )} + {error &&

{error}

} +
+ ) +} diff --git a/src/components/admin/MenuItemForm.module.scss b/src/components/admin/MenuItemForm.module.scss new file mode 100644 index 0000000..6183be6 --- /dev/null +++ b/src/components/admin/MenuItemForm.module.scss @@ -0,0 +1,145 @@ +@use '../../styles/variables' as *; + +.form { + display: flex; + flex-direction: column; + gap: $space-4; + max-width: 640px; +} + +.field { + display: flex; + flex-direction: column; + gap: $space-1; + font-size: 0.85rem; + color: $color-text-muted; + + input, + textarea, + select { + padding: $space-2; + border: 1px solid $color-border; + border-radius: $radius; + font-size: 1rem; + font-family: inherit; + color: $color-text; + } + + textarea { + resize: vertical; + min-height: 4.5rem; + } +} + +.row { + display: flex; + gap: $space-2; +} + +.section { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; +} + +.sectionTitle { + margin: 0; + font-size: 0.95rem; + font-weight: 600; +} + +.repeatingRow { + display: flex; + align-items: flex-start; + gap: $space-2; + + .field { + flex: 1; + } +} + +.stepName { + flex: 2; +} + +.narrowField { + flex: 1; +} + +.removeButton { + flex: 0 0 auto; + width: 2.25rem; + height: 2.25rem; + margin-top: 1.35rem; + border: 1px solid $color-border; + border-radius: $radius; + background: $color-background; + color: $color-danger; + cursor: pointer; + + &:hover { + border-color: $color-danger; + } +} + +.addButton { + align-self: flex-start; + padding: $space-1 $space-3; + border: 1px dashed $color-border; + border-radius: $radius; + background: $color-background; + cursor: pointer; + font-size: 0.85rem; + + &:hover { + border-color: $color-primary; + color: $color-primary; + } +} + +.actions { + display: flex; + align-items: center; + gap: $space-3; +} + +.submitButton { + padding: $space-2 $space-4; + border: none; + border-radius: $radius; + background: $color-primary; + color: $color-primary-contrast; + cursor: pointer; + font-weight: 600; + transition: opacity 0.15s ease; + + &:hover:not(:disabled) { + opacity: 0.9; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.cancelLink { + color: $color-text-muted; + text-decoration: none; + + &:hover { + color: $color-text; + } +} + +.error { + color: $color-danger; + font-size: 0.85rem; + margin: 0; +} diff --git a/src/components/admin/MenuItemForm.tsx b/src/components/admin/MenuItemForm.tsx new file mode 100644 index 0000000..1586192 --- /dev/null +++ b/src/components/admin/MenuItemForm.tsx @@ -0,0 +1,268 @@ +import { Link } from '@tanstack/react-router' +import { useState } from 'react' +import type { FormEvent } from 'react' + +import type { AdminMenuItem, MenuItemInput } from '#/domain/menu.ts' +import type { MachineTagOption } from '#/domain/kitchen.ts' + +import styles from './MenuItemForm.module.scss' + +type OptionRow = { name: string; priceDelta: string } +type StepRow = { name: string; machineTagId: string; durationSeconds: string } + +const toOptionRows = (item?: AdminMenuItem): Array => + item?.options.map((option) => ({ + name: option.name, + priceDelta: (option.priceDeltaCents / 100).toString(), + })) ?? [] + +const toStepRows = (item?: AdminMenuItem): Array => + item?.steps.map((step) => ({ + name: step.name, + machineTagId: String(step.machineTagId), + durationSeconds: String(step.estimatedDurationSeconds), + })) ?? [] + +type MenuItemFormProps = { + machineTags: Array + initialItem?: AdminMenuItem + submitLabel: string + onSubmit: (input: MenuItemInput) => Promise +} + +export const MenuItemForm = ({ + machineTags, + initialItem, + submitLabel, + onSubmit, +}: MenuItemFormProps) => { + const [name, setName] = useState(initialItem?.name ?? '') + const [description, setDescription] = useState( + initialItem?.description ?? '', + ) + const [price, setPrice] = useState( + initialItem ? (initialItem.priceCents / 100).toString() : '', + ) + const [options, setOptions] = useState>( + toOptionRows(initialItem), + ) + const [steps, setSteps] = useState>(toStepRows(initialItem)) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const updateOption = (index: number, patch: Partial) => + setOptions((rows) => + rows.map((row, i) => (i === index ? { ...row, ...patch } : row)), + ) + const updateStep = (index: number, patch: Partial) => + setSteps((rows) => + rows.map((row, i) => (i === index ? { ...row, ...patch } : row)), + ) + + const submit = async (event: FormEvent) => { + event.preventDefault() + setError(null) + + if (steps.length === 0) { + setError('Add at least one Preparation Step — otherwise this item can never be cooked.') + return + } + + const input: MenuItemInput = { + name, + description: description || undefined, + priceCents: Math.round(Number(price) * 100), + options: options + .filter((option) => option.name.trim() !== '') + .map((option) => ({ + name: option.name, + priceDeltaCents: Math.round(Number(option.priceDelta || '0') * 100), + })), + steps: steps.map((step) => ({ + name: step.name, + machineTagId: Number(step.machineTagId), + estimatedDurationSeconds: Math.round(Number(step.durationSeconds)), + })), + } + + setSubmitting(true) + try { + await onSubmit(input) + } catch (submitError) { + setError( + submitError instanceof Error + ? submitError.message + : 'Something went wrong.', + ) + } finally { + setSubmitting(false) + } + } + + return ( +
+ + +