Add an admin panel: orders dashboard, menu management, kitchen status
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 <noreply@anthropic.com>
This commit is contained in:
@@ -14,3 +14,9 @@ GOOGLE_CLIENT_ID=
|
|||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
APPLE_CLIENT_ID=
|
APPLE_CLIENT_ID=
|
||||||
APPLE_CLIENT_SECRET=
|
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=
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"db:pull": "drizzle-kit pull",
|
"db:pull": "drizzle-kit pull",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "tsx src/db/seed.ts",
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
|
"db:seed-admin": "tsx src/db/seed-admin.ts",
|
||||||
"auth:migrate": "better-auth migrate",
|
"auth:migrate": "better-auth migrate",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"format": "prettier --write . && eslint --fix",
|
"format": "prettier --write . && eslint --fix",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<AdminMenuItem>
|
||||||
|
onDelete: (menuItemId: number) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminMenuListView = ({
|
||||||
|
items,
|
||||||
|
onDelete,
|
||||||
|
}: AdminMenuListViewProps) => {
|
||||||
|
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||||
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<section>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h1 className={styles.heading}>Menu</h1>
|
||||||
|
<Link
|
||||||
|
to="/admin/menu/new"
|
||||||
|
className={styles.addLink}
|
||||||
|
viewTransition={false}
|
||||||
|
>
|
||||||
|
Add item
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className={styles.empty}>No menu items yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.items}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className={styles.item}>
|
||||||
|
<div className={styles.itemInfo}>
|
||||||
|
<div className={styles.itemHeader}>
|
||||||
|
<span className={styles.itemName}>{item.name}</span>
|
||||||
|
<span className={styles.itemPrice}>
|
||||||
|
{formatPrice(item.priceCents)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.itemMeta}>
|
||||||
|
{item.steps.length} step
|
||||||
|
{item.steps.length === 1 ? '' : 's'} ·{' '}
|
||||||
|
{item.options.length} option
|
||||||
|
{item.options.length === 1 ? '' : 's'}
|
||||||
|
{item.description ? ` · ${item.description}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.itemActions}>
|
||||||
|
<Link
|
||||||
|
to="/admin/menu/$menuItemId"
|
||||||
|
params={{ menuItemId: String(item.id) }}
|
||||||
|
className={styles.editLink}
|
||||||
|
viewTransition={false}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
{confirmingId === item.id ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.confirmButton}
|
||||||
|
disabled={deletingId === item.id}
|
||||||
|
onClick={() => void confirmDelete(item.id)}
|
||||||
|
>
|
||||||
|
{deletingId === item.id ? 'Deleting…' : 'Confirm?'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.deleteButton}
|
||||||
|
onClick={() => setConfirmingId(item.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<OptionRow> =>
|
||||||
|
item?.options.map((option) => ({
|
||||||
|
name: option.name,
|
||||||
|
priceDelta: (option.priceDeltaCents / 100).toString(),
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
const toStepRows = (item?: AdminMenuItem): Array<StepRow> =>
|
||||||
|
item?.steps.map((step) => ({
|
||||||
|
name: step.name,
|
||||||
|
machineTagId: String(step.machineTagId),
|
||||||
|
durationSeconds: String(step.estimatedDurationSeconds),
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
type MenuItemFormProps = {
|
||||||
|
machineTags: Array<MachineTagOption>
|
||||||
|
initialItem?: AdminMenuItem
|
||||||
|
submitLabel: string
|
||||||
|
onSubmit: (input: MenuItemInput) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Array<OptionRow>>(
|
||||||
|
toOptionRows(initialItem),
|
||||||
|
)
|
||||||
|
const [steps, setSteps] = useState<Array<StepRow>>(toStepRows(initialItem))
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const updateOption = (index: number, patch: Partial<OptionRow>) =>
|
||||||
|
setOptions((rows) =>
|
||||||
|
rows.map((row, i) => (i === index ? { ...row, ...patch } : row)),
|
||||||
|
)
|
||||||
|
const updateStep = (index: number, patch: Partial<StepRow>) =>
|
||||||
|
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 (
|
||||||
|
<form className={styles.form} onSubmit={submit}>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.field}>
|
||||||
|
Description
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.field}>
|
||||||
|
Price
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={price}
|
||||||
|
onChange={(event) => setPrice(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<p className={styles.sectionTitle}>Options</p>
|
||||||
|
</div>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<div key={index} className={styles.repeatingRow}>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={option.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateOption(index, { name: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={`${styles.field} ${styles.narrowField}`}>
|
||||||
|
Price delta
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={option.priceDelta}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateOption(index, { priceDelta: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeButton}
|
||||||
|
aria-label={`Remove option ${index + 1}`}
|
||||||
|
onClick={() =>
|
||||||
|
setOptions((rows) => rows.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.addButton}
|
||||||
|
onClick={() =>
|
||||||
|
setOptions((rows) => [...rows, { name: '', priceDelta: '0' }])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<p className={styles.sectionTitle}>Preparation steps</p>
|
||||||
|
</div>
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={index} className={styles.repeatingRow}>
|
||||||
|
<label className={`${styles.field} ${styles.stepName}`}>
|
||||||
|
Step name
|
||||||
|
<input
|
||||||
|
value={step.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStep(index, { name: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={`${styles.field} ${styles.narrowField}`}>
|
||||||
|
Machine
|
||||||
|
<select
|
||||||
|
value={step.machineTagId}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStep(index, { machineTagId: event.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select…
|
||||||
|
</option>
|
||||||
|
{machineTags.map((tag) => (
|
||||||
|
<option key={tag.id} value={tag.id}>
|
||||||
|
{tag.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={`${styles.field} ${styles.narrowField}`}>
|
||||||
|
Duration (s)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={step.durationSeconds}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStep(index, { durationSeconds: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeButton}
|
||||||
|
aria-label={`Remove step ${index + 1}`}
|
||||||
|
onClick={() =>
|
||||||
|
setSteps((rows) => rows.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.addButton}
|
||||||
|
onClick={() =>
|
||||||
|
setSteps((rows) => [
|
||||||
|
...rows,
|
||||||
|
{ name: '', machineTagId: '', durationSeconds: '60' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" className={styles.submitButton} disabled={submitting}>
|
||||||
|
{submitting ? 'Saving…' : submitLabel}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/admin/menu"
|
||||||
|
className={styles.cancelLink}
|
||||||
|
viewTransition={false}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: $space-5 $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0 0 $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order {
|
||||||
|
display: block;
|
||||||
|
padding: $space-3;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-surface;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: $space-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderStatus {
|
||||||
|
text-transform: capitalize;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderCustomer {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderItems {
|
||||||
|
margin: 0 0 $space-1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderMeta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import type { AdminOrderSummary } from '#/domain/ordering.ts'
|
||||||
|
import { formatPrice } from '#/lib/format.ts'
|
||||||
|
|
||||||
|
import styles from './AdminOrdersView.module.scss'
|
||||||
|
|
||||||
|
type AdminOrdersViewProps = {
|
||||||
|
orders: Array<AdminOrderSummary>
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatItems = (items: AdminOrderSummary['items']) =>
|
||||||
|
items
|
||||||
|
.map(({ menuItemName, quantity }) =>
|
||||||
|
quantity > 1 ? `${quantity}× ${menuItemName}` : menuItemName,
|
||||||
|
)
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
const formatCustomer = (customer: AdminOrderSummary['customer']) =>
|
||||||
|
customer.type === 'account'
|
||||||
|
? `Account ${customer.customerId}`
|
||||||
|
: `${customer.name} (guest) · ${customer.contact}`
|
||||||
|
|
||||||
|
export const AdminOrdersView = ({ orders }: AdminOrdersViewProps) => (
|
||||||
|
<section className={styles.page}>
|
||||||
|
<h1 className={styles.heading}>All orders</h1>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className={styles.empty}>No orders have been placed yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.orders}>
|
||||||
|
{orders.map((order) => (
|
||||||
|
<li key={order.id}>
|
||||||
|
<Link
|
||||||
|
to="/admin/orders/$orderId"
|
||||||
|
params={{ orderId: String(order.id) }}
|
||||||
|
className={styles.order}
|
||||||
|
viewTransition={false}
|
||||||
|
>
|
||||||
|
<div className={styles.orderHeader}>
|
||||||
|
<span>Order #{order.id}</span>
|
||||||
|
<span className={styles.orderStatus}>{order.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.orderCustomer}>
|
||||||
|
{formatCustomer(order.customer)}
|
||||||
|
</p>
|
||||||
|
<p className={styles.orderItems}>{formatItems(order.items)}</p>
|
||||||
|
<p className={styles.orderMeta}>
|
||||||
|
{order.createdAt.toLocaleString([], {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}{' '}
|
||||||
|
· {formatPrice(order.totalCents)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
@@ -69,11 +69,13 @@ export type OrderStatusResult = {
|
|||||||
type OrderStatusViewProps = {
|
type OrderStatusViewProps = {
|
||||||
status: OrderStatusResult
|
status: OrderStatusResult
|
||||||
guestAccessToken: string | undefined
|
guestAccessToken: string | undefined
|
||||||
|
backTo?: '/orders' | '/admin/orders'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OrderStatusView = ({
|
export const OrderStatusView = ({
|
||||||
status,
|
status,
|
||||||
guestAccessToken,
|
guestAccessToken,
|
||||||
|
backTo = '/orders',
|
||||||
}: OrderStatusViewProps) => {
|
}: OrderStatusViewProps) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [pendingItemId, setPendingItemId] = useState<number | null>(null)
|
const [pendingItemId, setPendingItemId] = useState<number | null>(null)
|
||||||
@@ -94,9 +96,13 @@ export const OrderStatusView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.page}>
|
<section className={styles.page}>
|
||||||
<Link to="/orders" className={styles.backLink}>
|
<Link
|
||||||
|
to={backTo}
|
||||||
|
className={styles.backLink}
|
||||||
|
viewTransition={backTo !== '/admin/orders'}
|
||||||
|
>
|
||||||
<BackArrowIcon />
|
<BackArrowIcon />
|
||||||
Back to orders
|
{backTo === '/admin/orders' ? 'Back to all orders' : 'Back to orders'}
|
||||||
</Link>
|
</Link>
|
||||||
<div style={{ viewTransitionName: `order-card-${status.id}` }}>
|
<div style={{ viewTransitionName: `order-card-${status.id}` }}>
|
||||||
<h1 className={styles.heading}>Order #{status.id}</h1>
|
<h1 className={styles.heading}>Order #{status.id}</h1>
|
||||||
|
|||||||
@@ -11,10 +11,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: $color-text-muted;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
padding: $space-5 $space-3;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-surface;
|
||||||
|
|
||||||
a {
|
svg {
|
||||||
color: $color-primary;
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
margin-bottom: $space-2;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
margin: 0 0 $space-3;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyCta {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ type OrdersListViewProps = {
|
|||||||
orders: Array<CustomerOrderSummary>
|
orders: Array<CustomerOrderSummary>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OrdersIcon = () => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M9 3h6l1 3H8l1-3Z" />
|
||||||
|
<path d="M4 6h16l-1.2 13.2A2 2 0 0 1 16.81 21H7.19a2 2 0 0 1-2-1.8L4 6Z" />
|
||||||
|
<path d="M9 11h6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const formatItems = (items: CustomerOrderSummary['items']) =>
|
const formatItems = (items: CustomerOrderSummary['items']) =>
|
||||||
items
|
items
|
||||||
.map(({ menuItemName, quantity }) =>
|
.map(({ menuItemName, quantity }) =>
|
||||||
@@ -20,10 +36,16 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
|
|||||||
<section className={styles.page}>
|
<section className={styles.page}>
|
||||||
<h1 className={styles.heading}>Your orders</h1>
|
<h1 className={styles.heading}>Your orders</h1>
|
||||||
{orders.length === 0 ? (
|
{orders.length === 0 ? (
|
||||||
<p className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
You haven't placed an order yet — <Link to="/">browse the menu</Link>{' '}
|
<OrdersIcon />
|
||||||
to get started.
|
<p className={styles.emptyTitle}>No orders yet</p>
|
||||||
</p>
|
<p className={styles.emptyText}>
|
||||||
|
Your placed orders will show up here.
|
||||||
|
</p>
|
||||||
|
<Link to="/" className={styles.emptyCta}>
|
||||||
|
Browse the menu
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className={styles.orders}>
|
<ul className={styles.orders}>
|
||||||
{orders.map((order) => (
|
{orders.map((order) => (
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
|
|
||||||
|
import type * as schema from '#/db/schema.ts'
|
||||||
|
import type { auth as Auth } from '#/lib/auth.ts'
|
||||||
|
|
||||||
|
// Drizzle types a raw `.execute()`'s rows as always present, even though
|
||||||
|
// zero rows is a real possibility at runtime (without
|
||||||
|
// noUncheckedIndexedAccess) — mirrors the firstRow helper in
|
||||||
|
// domain/ordering.ts.
|
||||||
|
const firstRow = <T>(rows: Array<T>): T | undefined => rows[0]
|
||||||
|
|
||||||
|
// The `role` field is `input: false` in the Better Auth config (see
|
||||||
|
// src/lib/auth.ts) so it can't be set through sign-up itself — an admin
|
||||||
|
// account is always a normal sign-up followed by a direct DB update here,
|
||||||
|
// never something the sign-up API can be asked to do.
|
||||||
|
export const seedAdmin = async (
|
||||||
|
db: NodePgDatabase<typeof schema>,
|
||||||
|
auth: typeof Auth,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
name = 'Admin',
|
||||||
|
) => {
|
||||||
|
let userId: string
|
||||||
|
try {
|
||||||
|
const signUp = await auth.api.signUpEmail({ body: { email, password, name } })
|
||||||
|
userId = signUp.user.id
|
||||||
|
} catch {
|
||||||
|
// Already signed up (e.g. this script has run before) — just make sure
|
||||||
|
// the existing account has the admin role.
|
||||||
|
const existing = await db.execute<{ id: string }>(
|
||||||
|
sql`select id from "user" where email = ${email}`,
|
||||||
|
)
|
||||||
|
const user = firstRow(existing.rows)
|
||||||
|
if (!user) throw new Error(`Sign-up failed and no existing user found for ${email}`)
|
||||||
|
userId = user.id
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(sql`update "user" set role = 'admin' where id = ${userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const { config } = await import('dotenv')
|
||||||
|
config({ path: ['.env.local', '.env'] })
|
||||||
|
|
||||||
|
const email = process.env.ADMIN_EMAIL
|
||||||
|
const password = process.env.ADMIN_PASSWORD
|
||||||
|
if (!email || !password) {
|
||||||
|
console.error(
|
||||||
|
'Set ADMIN_EMAIL and ADMIN_PASSWORD (in .env.local or the environment) before running this script.',
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imported dynamically, after dotenv config runs, since both #/db/index.ts
|
||||||
|
// and #/lib/auth.ts read DATABASE_URL from process.env at module load time.
|
||||||
|
const { drizzle } = await import('drizzle-orm/node-postgres')
|
||||||
|
const schemaModule = await import('#/db/schema.ts')
|
||||||
|
const { auth } = await import('#/lib/auth.ts')
|
||||||
|
const db = drizzle(process.env.DATABASE_URL!, { schema: schemaModule })
|
||||||
|
|
||||||
|
await seedAdmin(db, auth, email, password, process.env.ADMIN_NAME)
|
||||||
|
console.log(`Created admin account for ${email}.`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { and, eq, sql } from 'drizzle-orm'
|
||||||
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
|
|
||||||
|
import * as schema from '#/db/schema.ts'
|
||||||
|
|
||||||
|
type Db = NodePgDatabase<typeof schema>
|
||||||
|
|
||||||
|
export type MachineTagOption = { id: number; name: string }
|
||||||
|
|
||||||
|
export const getMachineTags = async (
|
||||||
|
db: Db,
|
||||||
|
): Promise<Array<MachineTagOption>> =>
|
||||||
|
db.select({ id: schema.machineTags.id, name: schema.machineTags.name }).from(
|
||||||
|
schema.machineTags,
|
||||||
|
)
|
||||||
|
|
||||||
|
export type MachineStatus = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
tagName: string
|
||||||
|
status: 'busy' | 'idle'
|
||||||
|
current: {
|
||||||
|
stepName: string
|
||||||
|
menuItemName: string
|
||||||
|
orderId: number
|
||||||
|
startedAt: Date | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TagQueueSummary = {
|
||||||
|
tagName: string
|
||||||
|
idleMachineCount: number
|
||||||
|
busyMachineCount: number
|
||||||
|
queuedStepCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KitchenStatus = {
|
||||||
|
machines: Array<MachineStatus>
|
||||||
|
queues: Array<TagQueueSummary>
|
||||||
|
}
|
||||||
|
|
||||||
|
// A snapshot of every Machine's current work, and how many Preparation
|
||||||
|
// Steps are still waiting per Tag — the admin "Kitchen" page's whole model.
|
||||||
|
// Read-only: nothing here mutates state (see assignQueuedStepsForTag in
|
||||||
|
// ordering.ts for how a Machine actually picks up a queued Step).
|
||||||
|
export const getKitchenStatus = async (db: Db): Promise<KitchenStatus> => {
|
||||||
|
const machines = await db
|
||||||
|
.select({
|
||||||
|
id: schema.machines.id,
|
||||||
|
name: schema.machines.name,
|
||||||
|
tagId: schema.machineTags.id,
|
||||||
|
tagName: schema.machineTags.name,
|
||||||
|
})
|
||||||
|
.from(schema.machines)
|
||||||
|
.innerJoin(
|
||||||
|
schema.machineTags,
|
||||||
|
eq(schema.machines.machineTagId, schema.machineTags.id),
|
||||||
|
)
|
||||||
|
.orderBy(schema.machineTags.name, schema.machines.name)
|
||||||
|
|
||||||
|
const busyInstances = await db
|
||||||
|
.select({
|
||||||
|
machineId: schema.preparationStepInstances.machineId,
|
||||||
|
startedAt: schema.preparationStepInstances.startedAt,
|
||||||
|
stepName: schema.orderItemSteps.name,
|
||||||
|
orderId: schema.orders.id,
|
||||||
|
menuItemName: schema.menuItems.name,
|
||||||
|
})
|
||||||
|
.from(schema.preparationStepInstances)
|
||||||
|
.innerJoin(
|
||||||
|
schema.orderItems,
|
||||||
|
eq(schema.preparationStepInstances.orderItemId, schema.orderItems.id),
|
||||||
|
)
|
||||||
|
.innerJoin(schema.orders, eq(schema.orderItems.orderId, schema.orders.id))
|
||||||
|
.innerJoin(
|
||||||
|
schema.menuItems,
|
||||||
|
eq(schema.orderItems.menuItemId, schema.menuItems.id),
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
schema.orderItemSteps,
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
schema.orderItemSteps.orderItemId,
|
||||||
|
schema.preparationStepInstances.orderItemId,
|
||||||
|
),
|
||||||
|
eq(
|
||||||
|
schema.orderItemSteps.sequence,
|
||||||
|
schema.preparationStepInstances.sequence,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(eq(schema.preparationStepInstances.status, 'in_progress'))
|
||||||
|
|
||||||
|
const busyByMachineId = new Map(
|
||||||
|
busyInstances
|
||||||
|
.filter((row) => row.machineId !== null)
|
||||||
|
.map((row) => [row.machineId, row]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const machineStatuses: Array<MachineStatus> = machines.map((machine) => {
|
||||||
|
const busy = busyByMachineId.get(machine.id)
|
||||||
|
return {
|
||||||
|
id: machine.id,
|
||||||
|
name: machine.name,
|
||||||
|
tagName: machine.tagName,
|
||||||
|
status: busy ? 'busy' : 'idle',
|
||||||
|
current: busy
|
||||||
|
? {
|
||||||
|
stepName: busy.stepName,
|
||||||
|
menuItemName: busy.menuItemName,
|
||||||
|
orderId: busy.orderId,
|
||||||
|
startedAt: busy.startedAt,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const queuedCounts = await db
|
||||||
|
.select({
|
||||||
|
machineTagId: schema.preparationStepInstances.machineTagId,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(schema.preparationStepInstances)
|
||||||
|
.where(eq(schema.preparationStepInstances.status, 'queued'))
|
||||||
|
.groupBy(schema.preparationStepInstances.machineTagId)
|
||||||
|
|
||||||
|
const queuedCountByTagId = new Map(
|
||||||
|
queuedCounts.map((row) => [row.machineTagId, row.count]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tagsById = new Map(
|
||||||
|
machines.map((machine) => [machine.tagId, machine.tagName]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const queues: Array<TagQueueSummary> = [...tagsById.entries()].map(
|
||||||
|
([tagId, tagName]) => {
|
||||||
|
const tagMachines = machineStatuses.filter((m) => m.tagName === tagName)
|
||||||
|
return {
|
||||||
|
tagName,
|
||||||
|
idleMachineCount: tagMachines.filter((m) => m.status === 'idle')
|
||||||
|
.length,
|
||||||
|
busyMachineCount: tagMachines.filter((m) => m.status === 'busy')
|
||||||
|
.length,
|
||||||
|
queuedStepCount: queuedCountByTagId.get(tagId) ?? 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return { machines: machineStatuses, queues }
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
|
|
||||||
|
import * as schema from '#/db/schema.ts'
|
||||||
|
|
||||||
|
type Db = NodePgDatabase<typeof schema>
|
||||||
|
|
||||||
|
export class MenuItemHasOrdersError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('This item cannot be deleted because it has past Orders')
|
||||||
|
this.name = 'MenuItemHasOrdersError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminMenuItem = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
priceCents: number
|
||||||
|
options: Array<{ id: number; name: string; priceDeltaCents: number }>
|
||||||
|
steps: Array<{
|
||||||
|
id: number
|
||||||
|
sequence: number
|
||||||
|
name: string
|
||||||
|
machineTagId: number
|
||||||
|
machineTagName: string
|
||||||
|
estimatedDurationSeconds: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuItemInput = {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
priceCents: number
|
||||||
|
options: Array<{ name: string; priceDeltaCents: number }>
|
||||||
|
steps: Array<{
|
||||||
|
name: string
|
||||||
|
machineTagId: number
|
||||||
|
estimatedDurationSeconds: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertHasSteps = (input: MenuItemInput) => {
|
||||||
|
if (input.steps.length === 0) {
|
||||||
|
throw new Error('A menu item needs at least one Preparation Step')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMenuItemsForAdmin = async (
|
||||||
|
db: Db,
|
||||||
|
brandId: number,
|
||||||
|
): Promise<Array<AdminMenuItem>> => {
|
||||||
|
const items = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.menuItems)
|
||||||
|
.where(eq(schema.menuItems.brandId, brandId))
|
||||||
|
.orderBy(schema.menuItems.name)
|
||||||
|
|
||||||
|
const options = await db.select().from(schema.menuItemOptions)
|
||||||
|
const steps = await db
|
||||||
|
.select({
|
||||||
|
id: schema.menuItemSteps.id,
|
||||||
|
menuItemId: schema.menuItemSteps.menuItemId,
|
||||||
|
sequence: schema.menuItemSteps.sequence,
|
||||||
|
name: schema.menuItemSteps.name,
|
||||||
|
machineTagId: schema.menuItemSteps.machineTagId,
|
||||||
|
machineTagName: schema.machineTags.name,
|
||||||
|
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds,
|
||||||
|
})
|
||||||
|
.from(schema.menuItemSteps)
|
||||||
|
.innerJoin(
|
||||||
|
schema.machineTags,
|
||||||
|
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id),
|
||||||
|
)
|
||||||
|
.orderBy(schema.menuItemSteps.sequence)
|
||||||
|
|
||||||
|
return items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
priceCents: item.priceCents,
|
||||||
|
options: options
|
||||||
|
.filter((option) => option.menuItemId === item.id)
|
||||||
|
.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
name: option.name,
|
||||||
|
priceDeltaCents: option.priceDeltaCents,
|
||||||
|
})),
|
||||||
|
steps: steps
|
||||||
|
.filter((step) => step.menuItemId === item.id)
|
||||||
|
.map(({ menuItemId: _menuItemId, ...step }) => step),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMenuItem = async (
|
||||||
|
db: Db,
|
||||||
|
brandId: number,
|
||||||
|
input: MenuItemInput,
|
||||||
|
): Promise<number> => {
|
||||||
|
assertHasSteps(input)
|
||||||
|
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const [item] = await tx
|
||||||
|
.insert(schema.menuItems)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? null,
|
||||||
|
priceCents: input.priceCents,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (input.options.length > 0) {
|
||||||
|
await tx.insert(schema.menuItemOptions).values(
|
||||||
|
input.options.map((option) => ({
|
||||||
|
menuItemId: item.id,
|
||||||
|
name: option.name,
|
||||||
|
priceDeltaCents: option.priceDeltaCents,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(schema.menuItemSteps).values(
|
||||||
|
input.steps.map((step, index) => ({
|
||||||
|
menuItemId: item.id,
|
||||||
|
sequence: index + 1,
|
||||||
|
name: step.name,
|
||||||
|
machineTagId: step.machineTagId,
|
||||||
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
return item.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options and Steps are replaced wholesale on every edit rather than diffed
|
||||||
|
// — simplest correct behavior for a form that resubmits its full list each
|
||||||
|
// time, and safe because placed Order Items snapshot their own copy (see
|
||||||
|
// orderItemSteps/orderItemOptions) so this never rewrites order history.
|
||||||
|
export const updateMenuItem = async (
|
||||||
|
db: Db,
|
||||||
|
menuItemId: number,
|
||||||
|
input: MenuItemInput,
|
||||||
|
): Promise<void> => {
|
||||||
|
assertHasSteps(input)
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(schema.menuItems)
|
||||||
|
.set({
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? null,
|
||||||
|
priceCents: input.priceCents,
|
||||||
|
})
|
||||||
|
.where(eq(schema.menuItems.id, menuItemId))
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemOptions)
|
||||||
|
.where(eq(schema.menuItemOptions.menuItemId, menuItemId))
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemSteps)
|
||||||
|
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
|
||||||
|
|
||||||
|
if (input.options.length > 0) {
|
||||||
|
await tx.insert(schema.menuItemOptions).values(
|
||||||
|
input.options.map((option) => ({
|
||||||
|
menuItemId,
|
||||||
|
name: option.name,
|
||||||
|
priceDeltaCents: option.priceDeltaCents,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(schema.menuItemSteps).values(
|
||||||
|
input.steps.map((step, index) => ({
|
||||||
|
menuItemId,
|
||||||
|
sequence: index + 1,
|
||||||
|
name: step.name,
|
||||||
|
machineTagId: step.machineTagId,
|
||||||
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isForeignKeyViolation = (error: unknown) =>
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'cause' in error &&
|
||||||
|
typeof error.cause === 'object' &&
|
||||||
|
error.cause !== null &&
|
||||||
|
'code' in error.cause &&
|
||||||
|
error.cause.code === '23503'
|
||||||
|
|
||||||
|
export const deleteMenuItem = async (
|
||||||
|
db: Db,
|
||||||
|
menuItemId: number,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemOptions)
|
||||||
|
.where(eq(schema.menuItemOptions.menuItemId, menuItemId))
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemSteps)
|
||||||
|
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
|
||||||
|
// No ON DELETE CASCADE from orderItems.menuItemId — this throws (and
|
||||||
|
// rolls back the deletes above) if any Order ever referenced it.
|
||||||
|
await tx.delete(schema.menuItems).where(eq(schema.menuItems.id, menuItemId))
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (isForeignKeyViolation(error)) throw new MenuItemHasOrdersError()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-1
@@ -409,7 +409,11 @@ export const placeOrder = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrderAccess = { customerId?: string; guestAccessToken?: string }
|
export type OrderAccess = {
|
||||||
|
customerId?: string
|
||||||
|
guestAccessToken?: string
|
||||||
|
isAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const assertOrderAccess = (
|
const assertOrderAccess = (
|
||||||
order: Pick<
|
order: Pick<
|
||||||
@@ -418,6 +422,7 @@ const assertOrderAccess = (
|
|||||||
>,
|
>,
|
||||||
access: OrderAccess,
|
access: OrderAccess,
|
||||||
) => {
|
) => {
|
||||||
|
if (access.isAdmin) return
|
||||||
if (order.customerId) {
|
if (order.customerId) {
|
||||||
if (access.customerId === order.customerId) return
|
if (access.customerId === order.customerId) return
|
||||||
} else if (order.guestAccessToken) {
|
} else if (order.guestAccessToken) {
|
||||||
@@ -955,6 +960,40 @@ export const getOrdersForCustomer = async (
|
|||||||
return summarizeOrders(db, orders)
|
return summarizeOrders(db, orders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminOrderSummary = CustomerOrderSummary & {
|
||||||
|
customer:
|
||||||
|
| { type: 'account'; customerId: string }
|
||||||
|
| { type: 'guest'; name: string; contact: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every Order across every customer, for the admin dashboard — unlike
|
||||||
|
// getOrdersForCustomer/getGuestOrderSummaries, this isn't scoped to one
|
||||||
|
// customer's own access, so it must only ever be called once the caller has
|
||||||
|
// already been checked for admin access.
|
||||||
|
export const getAllOrders = async (
|
||||||
|
db: Db,
|
||||||
|
): Promise<Array<AdminOrderSummary>> => {
|
||||||
|
const orders = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.orders)
|
||||||
|
.orderBy(desc(schema.orders.createdAt))
|
||||||
|
|
||||||
|
const summaries = await summarizeOrders(db, orders)
|
||||||
|
const ordersById = new Map(orders.map((order) => [order.id, order]))
|
||||||
|
|
||||||
|
return summaries.map((summary) => {
|
||||||
|
const order = ordersById.get(summary.id)!
|
||||||
|
const customer = order.customerId
|
||||||
|
? ({ type: 'account', customerId: order.customerId } as const)
|
||||||
|
: ({
|
||||||
|
type: 'guest',
|
||||||
|
name: order.guestName ?? 'Guest',
|
||||||
|
contact: order.guestContact ?? '',
|
||||||
|
} as const)
|
||||||
|
return { ...summary, customer }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type GuestOrderRef = { orderId: number; guestAccessToken: string }
|
export type GuestOrderRef = { orderId: number; guestAccessToken: string }
|
||||||
|
|
||||||
// A guest's Order history, looked up from the (orderId, guestAccessToken)
|
// A guest's Order history, looked up from the (orderId, guestAccessToken)
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ const OrdersIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const AdminIcon = () => (
|
||||||
|
<svg
|
||||||
|
className={styles.menuIcon}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 3l7 3v6c0 4.5-3 8-7 9-4-1-7-4.5-7-9V6l7-3Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const LogOutIcon = () => (
|
const LogOutIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className={styles.menuIcon}
|
className={styles.menuIcon}
|
||||||
@@ -114,6 +129,16 @@ const BetterAuthHeader = () => {
|
|||||||
<OrdersIcon />
|
<OrdersIcon />
|
||||||
Orders
|
Orders
|
||||||
</Link>
|
</Link>
|
||||||
|
{session.user.role === 'admin' && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.menuButton}
|
||||||
|
>
|
||||||
|
<AdminIcon />
|
||||||
|
Administration
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
// Errors cross the server-function boundary as a plain `Error` carrying only
|
||||||
|
// `.message` (TanStack Start's ShallowErrorPlugin drops the subclass), so
|
||||||
|
// requireAdmin's rejection is told apart by message rather than `instanceof`.
|
||||||
|
// Every /admin/* route loader wraps its data fetch in this so a signed-out
|
||||||
|
// or non-admin visitor is redirected to sign in instead of crashing.
|
||||||
|
export const withAdminGuard = async <T>(load: () => Promise<T>): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await load()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Admin access required') {
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
import { createAuthClient } from 'better-auth/react'
|
import { createAuthClient } from 'better-auth/react'
|
||||||
|
import { inferAdditionalFields } from 'better-auth/client/plugins'
|
||||||
|
|
||||||
export const authClient = createAuthClient()
|
import type { auth } from '#/lib/auth.ts'
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
plugins: [inferAdditionalFields<typeof auth>()],
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ export const auth = betterAuth({
|
|||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
// `input: false` keeps this out of the public sign-up/update schema so
|
||||||
|
// a client can't self-assign admin — it's only ever set by a direct DB
|
||||||
|
// update (see src/db/seed-admin.ts).
|
||||||
|
role: {
|
||||||
|
type: 'string',
|
||||||
|
defaultValue: 'customer',
|
||||||
|
input: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -10,12 +10,20 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as AdminRouteImport } from './routes/admin'
|
||||||
import { Route as BasketRouteImport } from './routes/basket'
|
import { Route as BasketRouteImport } from './routes/basket'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as SignupRouteImport } from './routes/signup'
|
import { Route as SignupRouteImport } from './routes/signup'
|
||||||
|
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
||||||
|
import { Route as AdminKitchenRouteImport } from './routes/admin/kitchen'
|
||||||
import { Route as ItemsMenuItemIdRouteImport } from './routes/items/$menuItemId'
|
import { Route as ItemsMenuItemIdRouteImport } from './routes/items/$menuItemId'
|
||||||
import { Route as OrdersIndexRouteImport } from './routes/orders/index'
|
import { Route as OrdersIndexRouteImport } from './routes/orders/index'
|
||||||
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
|
import { Route as OrdersOrderIdRouteImport } from './routes/orders/$orderId'
|
||||||
|
import { Route as AdminMenuIndexRouteImport } from './routes/admin/menu/index'
|
||||||
|
import { Route as AdminMenuMenuItemIdRouteImport } from './routes/admin/menu/$menuItemId'
|
||||||
|
import { Route as AdminMenuNewRouteImport } from './routes/admin/menu/new'
|
||||||
|
import { Route as AdminOrdersIndexRouteImport } from './routes/admin/orders/index'
|
||||||
|
import { Route as AdminOrdersOrderIdRouteImport } from './routes/admin/orders/$orderId'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
@@ -23,6 +31,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminRoute = AdminRouteImport.update({
|
||||||
|
id: '/admin',
|
||||||
|
path: '/admin',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const BasketRoute = BasketRouteImport.update({
|
const BasketRoute = BasketRouteImport.update({
|
||||||
id: '/basket',
|
id: '/basket',
|
||||||
path: '/basket',
|
path: '/basket',
|
||||||
@@ -38,6 +51,16 @@ const SignupRoute = SignupRouteImport.update({
|
|||||||
path: '/signup',
|
path: '/signup',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminIndexRoute = AdminIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminKitchenRoute = AdminKitchenRouteImport.update({
|
||||||
|
id: '/kitchen',
|
||||||
|
path: '/kitchen',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const ItemsMenuItemIdRoute = ItemsMenuItemIdRouteImport.update({
|
const ItemsMenuItemIdRoute = ItemsMenuItemIdRouteImport.update({
|
||||||
id: '/items/$menuItemId',
|
id: '/items/$menuItemId',
|
||||||
path: '/items/$menuItemId',
|
path: '/items/$menuItemId',
|
||||||
@@ -53,6 +76,31 @@ const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
|
|||||||
path: '/orders/$orderId',
|
path: '/orders/$orderId',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminMenuIndexRoute = AdminMenuIndexRouteImport.update({
|
||||||
|
id: '/menu/',
|
||||||
|
path: '/menu/',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminMenuMenuItemIdRoute = AdminMenuMenuItemIdRouteImport.update({
|
||||||
|
id: '/menu/$menuItemId',
|
||||||
|
path: '/menu/$menuItemId',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminMenuNewRoute = AdminMenuNewRouteImport.update({
|
||||||
|
id: '/menu/new',
|
||||||
|
path: '/menu/new',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminOrdersIndexRoute = AdminOrdersIndexRouteImport.update({
|
||||||
|
id: '/orders/',
|
||||||
|
path: '/orders/',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
|
const AdminOrdersOrderIdRoute = AdminOrdersOrderIdRouteImport.update({
|
||||||
|
id: '/orders/$orderId',
|
||||||
|
path: '/orders/$orderId',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||||
id: '/api/auth/$',
|
id: '/api/auth/$',
|
||||||
path: '/api/auth/$',
|
path: '/api/auth/$',
|
||||||
@@ -61,70 +109,117 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/admin': typeof AdminRouteWithChildren
|
||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
|
'/admin/kitchen': typeof AdminKitchenRoute
|
||||||
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
|
'/admin/': typeof AdminIndexRoute
|
||||||
'/orders/': typeof OrdersIndexRoute
|
'/orders/': typeof OrdersIndexRoute
|
||||||
|
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
|
||||||
|
'/admin/menu/new': typeof AdminMenuNewRoute
|
||||||
|
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/admin/menu/': typeof AdminMenuIndexRoute
|
||||||
|
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
|
'/admin/kitchen': typeof AdminKitchenRoute
|
||||||
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
|
'/admin': typeof AdminIndexRoute
|
||||||
'/orders': typeof OrdersIndexRoute
|
'/orders': typeof OrdersIndexRoute
|
||||||
|
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
|
||||||
|
'/admin/menu/new': typeof AdminMenuNewRoute
|
||||||
|
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/admin/menu': typeof AdminMenuIndexRoute
|
||||||
|
'/admin/orders': typeof AdminOrdersIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/admin': typeof AdminRouteWithChildren
|
||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
|
'/admin/kitchen': typeof AdminKitchenRoute
|
||||||
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||||
|
'/admin/': typeof AdminIndexRoute
|
||||||
'/orders/': typeof OrdersIndexRoute
|
'/orders/': typeof OrdersIndexRoute
|
||||||
|
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
|
||||||
|
'/admin/menu/new': typeof AdminMenuNewRoute
|
||||||
|
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/admin/menu/': typeof AdminMenuIndexRoute
|
||||||
|
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/admin'
|
||||||
| '/basket'
|
| '/basket'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
|
| '/admin/kitchen'
|
||||||
| '/items/$menuItemId'
|
| '/items/$menuItemId'
|
||||||
| '/orders/$orderId'
|
| '/orders/$orderId'
|
||||||
|
| '/admin/'
|
||||||
| '/orders/'
|
| '/orders/'
|
||||||
|
| '/admin/menu/$menuItemId'
|
||||||
|
| '/admin/menu/new'
|
||||||
|
| '/admin/orders/$orderId'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
|
| '/admin/menu/'
|
||||||
|
| '/admin/orders/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/basket'
|
| '/basket'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
|
| '/admin/kitchen'
|
||||||
| '/items/$menuItemId'
|
| '/items/$menuItemId'
|
||||||
| '/orders/$orderId'
|
| '/orders/$orderId'
|
||||||
|
| '/admin'
|
||||||
| '/orders'
|
| '/orders'
|
||||||
|
| '/admin/menu/$menuItemId'
|
||||||
|
| '/admin/menu/new'
|
||||||
|
| '/admin/orders/$orderId'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
|
| '/admin/menu'
|
||||||
|
| '/admin/orders'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/admin'
|
||||||
| '/basket'
|
| '/basket'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
|
| '/admin/kitchen'
|
||||||
| '/items/$menuItemId'
|
| '/items/$menuItemId'
|
||||||
| '/orders/$orderId'
|
| '/orders/$orderId'
|
||||||
|
| '/admin/'
|
||||||
| '/orders/'
|
| '/orders/'
|
||||||
|
| '/admin/menu/$menuItemId'
|
||||||
|
| '/admin/menu/new'
|
||||||
|
| '/admin/orders/$orderId'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
|
| '/admin/menu/'
|
||||||
|
| '/admin/orders/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
AdminRoute: typeof AdminRouteWithChildren
|
||||||
BasketRoute: typeof BasketRoute
|
BasketRoute: typeof BasketRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
SignupRoute: typeof SignupRoute
|
SignupRoute: typeof SignupRoute
|
||||||
@@ -143,6 +238,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/admin': {
|
||||||
|
id: '/admin'
|
||||||
|
path: '/admin'
|
||||||
|
fullPath: '/admin'
|
||||||
|
preLoaderRoute: typeof AdminRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/basket': {
|
'/basket': {
|
||||||
id: '/basket'
|
id: '/basket'
|
||||||
path: '/basket'
|
path: '/basket'
|
||||||
@@ -164,6 +266,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SignupRouteImport
|
preLoaderRoute: typeof SignupRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/admin/': {
|
||||||
|
id: '/admin/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/admin/'
|
||||||
|
preLoaderRoute: typeof AdminIndexRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/kitchen': {
|
||||||
|
id: '/admin/kitchen'
|
||||||
|
path: '/kitchen'
|
||||||
|
fullPath: '/admin/kitchen'
|
||||||
|
preLoaderRoute: typeof AdminKitchenRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/items/$menuItemId': {
|
'/items/$menuItemId': {
|
||||||
id: '/items/$menuItemId'
|
id: '/items/$menuItemId'
|
||||||
path: '/items/$menuItemId'
|
path: '/items/$menuItemId'
|
||||||
@@ -185,6 +301,41 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof OrdersOrderIdRouteImport
|
preLoaderRoute: typeof OrdersOrderIdRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/admin/menu/': {
|
||||||
|
id: '/admin/menu/'
|
||||||
|
path: '/menu'
|
||||||
|
fullPath: '/admin/menu/'
|
||||||
|
preLoaderRoute: typeof AdminMenuIndexRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/menu/$menuItemId': {
|
||||||
|
id: '/admin/menu/$menuItemId'
|
||||||
|
path: '/menu/$menuItemId'
|
||||||
|
fullPath: '/admin/menu/$menuItemId'
|
||||||
|
preLoaderRoute: typeof AdminMenuMenuItemIdRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/menu/new': {
|
||||||
|
id: '/admin/menu/new'
|
||||||
|
path: '/menu/new'
|
||||||
|
fullPath: '/admin/menu/new'
|
||||||
|
preLoaderRoute: typeof AdminMenuNewRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/orders/': {
|
||||||
|
id: '/admin/orders/'
|
||||||
|
path: '/orders'
|
||||||
|
fullPath: '/admin/orders/'
|
||||||
|
preLoaderRoute: typeof AdminOrdersIndexRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
|
'/admin/orders/$orderId': {
|
||||||
|
id: '/admin/orders/$orderId'
|
||||||
|
path: '/orders/$orderId'
|
||||||
|
fullPath: '/admin/orders/$orderId'
|
||||||
|
preLoaderRoute: typeof AdminOrdersOrderIdRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/api/auth/$': {
|
'/api/auth/$': {
|
||||||
id: '/api/auth/$'
|
id: '/api/auth/$'
|
||||||
path: '/api/auth/$'
|
path: '/api/auth/$'
|
||||||
@@ -195,8 +346,31 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AdminRouteChildren {
|
||||||
|
AdminKitchenRoute: typeof AdminKitchenRoute
|
||||||
|
AdminIndexRoute: typeof AdminIndexRoute
|
||||||
|
AdminMenuMenuItemIdRoute: typeof AdminMenuMenuItemIdRoute
|
||||||
|
AdminMenuNewRoute: typeof AdminMenuNewRoute
|
||||||
|
AdminOrdersOrderIdRoute: typeof AdminOrdersOrderIdRoute
|
||||||
|
AdminMenuIndexRoute: typeof AdminMenuIndexRoute
|
||||||
|
AdminOrdersIndexRoute: typeof AdminOrdersIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminRouteChildren: AdminRouteChildren = {
|
||||||
|
AdminKitchenRoute: AdminKitchenRoute,
|
||||||
|
AdminIndexRoute: AdminIndexRoute,
|
||||||
|
AdminMenuMenuItemIdRoute: AdminMenuMenuItemIdRoute,
|
||||||
|
AdminMenuNewRoute: AdminMenuNewRoute,
|
||||||
|
AdminOrdersOrderIdRoute: AdminOrdersOrderIdRoute,
|
||||||
|
AdminMenuIndexRoute: AdminMenuIndexRoute,
|
||||||
|
AdminOrdersIndexRoute: AdminOrdersIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
AdminRoute: AdminRouteWithChildren,
|
||||||
BasketRoute: BasketRoute,
|
BasketRoute: BasketRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
SignupRoute: SignupRoute,
|
SignupRoute: SignupRoute,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
@use '../styles/variables' as *;
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $space-4;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: $space-4 $space-3;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-tablet) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
flex: 0 0 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-1;
|
||||||
|
position: sticky;
|
||||||
|
top: $space-4;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-tablet) {
|
||||||
|
position: static;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarTitle {
|
||||||
|
margin: 0 0 $space-2;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: $color-text-muted;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-tablet) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink {
|
||||||
|
padding: $space-2 $space-3;
|
||||||
|
border-radius: $radius;
|
||||||
|
color: $color-text;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLinkActive {
|
||||||
|
background: $color-surface;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Link, Outlet, createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
|
import { requireAdminSession } from '#/server/auth.ts'
|
||||||
|
|
||||||
|
import styles from './admin.module.scss'
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: '/admin/orders', label: 'Orders' },
|
||||||
|
{ to: '/admin/menu', label: 'Menu' },
|
||||||
|
{ to: '/admin/kitchen', label: 'Kitchen' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const AdminLayout = () => (
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<nav
|
||||||
|
className={styles.sidebar}
|
||||||
|
style={{ viewTransitionName: 'admin-sidebar' }}
|
||||||
|
>
|
||||||
|
<p className={styles.sidebarTitle}>Administration</p>
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={styles.navLink}
|
||||||
|
activeProps={{ className: styles.navLinkActive }}
|
||||||
|
// The sidebar and overall layout never change between /admin/*
|
||||||
|
// pages — a view transition's root crossfade has nothing useful
|
||||||
|
// to animate here and just makes the static sidebar look like
|
||||||
|
// it's moving, so navigation within the admin panel skips it.
|
||||||
|
viewTransition={false}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin')({
|
||||||
|
loader: () => withAdminGuard(() => requireAdminSession()),
|
||||||
|
component: AdminLayout,
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
// Orders is the only admin section today — this is the stable entry point
|
||||||
|
// the account menu's "Administration" link points at, so adding more
|
||||||
|
// sections later doesn't mean touching the menu again. /admin/orders itself
|
||||||
|
// still enforces the admin check.
|
||||||
|
export const Route = createFileRoute('/admin/')({
|
||||||
|
loader: () => {
|
||||||
|
throw redirect({ to: '/admin/orders' })
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $space-3;
|
||||||
|
margin-bottom: $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshButton {
|
||||||
|
padding: $space-1 $space-3;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: $color-background;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagSection {
|
||||||
|
margin-bottom: $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: $space-2;
|
||||||
|
margin-bottom: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagName {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagQueue {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machines {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machineInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machineName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machineCurrent {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 2px $space-2;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusIdle {
|
||||||
|
background: rgba($color-success, 0.12);
|
||||||
|
color: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBusy {
|
||||||
|
background: rgba($color-primary, 0.12);
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import type { KitchenStatus } from '#/domain/kitchen.ts'
|
||||||
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
|
import { getKitchenStatus } from '#/server/kitchen.ts'
|
||||||
|
|
||||||
|
import styles from './kitchen.module.scss'
|
||||||
|
|
||||||
|
const formatStartedAt = (startedAt: Date | null) =>
|
||||||
|
startedAt
|
||||||
|
? startedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||||
|
: 'just now'
|
||||||
|
|
||||||
|
const groupByTag = (machines: KitchenStatus['machines']) => {
|
||||||
|
const byTag = new Map<string, KitchenStatus['machines']>()
|
||||||
|
for (const machine of machines) {
|
||||||
|
byTag.set(machine.tagName, [...(byTag.get(machine.tagName) ?? []), machine])
|
||||||
|
}
|
||||||
|
return byTag
|
||||||
|
}
|
||||||
|
|
||||||
|
const KitchenRoute = () => {
|
||||||
|
const { machines, queues } = Route.useLoaderData()
|
||||||
|
const router = useRouter()
|
||||||
|
const machinesByTag = groupByTag(machines)
|
||||||
|
const queueByTag = new Map(queues.map((queue) => [queue.tagName, queue]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h1 className={styles.heading}>Kitchen</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.refreshButton}
|
||||||
|
onClick={() => void router.invalidate()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{machines.length === 0 ? (
|
||||||
|
<p className={styles.empty}>No machines are configured.</p>
|
||||||
|
) : (
|
||||||
|
[...machinesByTag.entries()].map(([tagName, tagMachines]) => {
|
||||||
|
const queue = queueByTag.get(tagName)
|
||||||
|
return (
|
||||||
|
<div key={tagName} className={styles.tagSection}>
|
||||||
|
<div className={styles.tagHeader}>
|
||||||
|
<h2 className={styles.tagName}>{tagName}</h2>
|
||||||
|
{queue && (
|
||||||
|
<span className={styles.tagQueue}>
|
||||||
|
{queue.idleMachineCount} idle · {queue.busyMachineCount}{' '}
|
||||||
|
busy
|
||||||
|
{queue.queuedStepCount > 0 &&
|
||||||
|
` · ${queue.queuedStepCount} waiting`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className={styles.machines}>
|
||||||
|
{tagMachines.map((machine) => (
|
||||||
|
<li key={machine.id} className={styles.machine}>
|
||||||
|
<div className={styles.machineInfo}>
|
||||||
|
<span className={styles.machineName}>
|
||||||
|
{machine.name}
|
||||||
|
</span>
|
||||||
|
{machine.current && (
|
||||||
|
<span className={styles.machineCurrent}>
|
||||||
|
{machine.current.menuItemName} ·{' '}
|
||||||
|
{machine.current.stepName} · Order #
|
||||||
|
{machine.current.orderId} · since{' '}
|
||||||
|
{formatStartedAt(machine.current.startedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`${styles.status} ${
|
||||||
|
machine.status === 'busy'
|
||||||
|
? styles.statusBusy
|
||||||
|
: styles.statusIdle
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{machine.status}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/kitchen')({
|
||||||
|
loader: () => withAdminGuard(() => getKitchenStatus()),
|
||||||
|
component: KitchenRoute,
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createFileRoute, notFound, useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
|
||||||
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
|
import { getAdminMenu, getMachineTagOptions, updateMenuItem } from '#/server/menu-admin.ts'
|
||||||
|
|
||||||
|
const EditMenuItemRoute = () => {
|
||||||
|
const { item, machineTags } = Route.useLoaderData()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1>Edit {item.name}</h1>
|
||||||
|
<MenuItemForm
|
||||||
|
machineTags={machineTags}
|
||||||
|
initialItem={item}
|
||||||
|
submitLabel="Save changes"
|
||||||
|
onSubmit={async (input) => {
|
||||||
|
await updateMenuItem({ data: { menuItemId: item.id, ...input } })
|
||||||
|
await navigate({ to: '/admin/menu', viewTransition: false })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/menu/$menuItemId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const [items, machineTags] = await withAdminGuard(() =>
|
||||||
|
Promise.all([getAdminMenu(), getMachineTagOptions()]),
|
||||||
|
)
|
||||||
|
const item = items.find((i) => i.id === Number(params.menuItemId))
|
||||||
|
if (!item) throw notFound()
|
||||||
|
return { item, machineTags }
|
||||||
|
},
|
||||||
|
component: EditMenuItemRoute,
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { AdminMenuListView } from '#/components/admin/AdminMenuListView.tsx'
|
||||||
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
|
import { deleteMenuItem, getAdminMenu } from '#/server/menu-admin.ts'
|
||||||
|
|
||||||
|
const AdminMenuRoute = () => {
|
||||||
|
const items = Route.useLoaderData()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminMenuListView
|
||||||
|
items={items}
|
||||||
|
onDelete={async (menuItemId) => {
|
||||||
|
await deleteMenuItem({ data: { menuItemId } })
|
||||||
|
await router.invalidate()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/menu/')({
|
||||||
|
loader: () => withAdminGuard(() => getAdminMenu()),
|
||||||
|
component: AdminMenuRoute,
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
|
||||||
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
|
import { createMenuItem, getMachineTagOptions } from '#/server/menu-admin.ts'
|
||||||
|
|
||||||
|
const NewMenuItemRoute = () => {
|
||||||
|
const machineTags = Route.useLoaderData()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1>Add menu item</h1>
|
||||||
|
<MenuItemForm
|
||||||
|
machineTags={machineTags}
|
||||||
|
submitLabel="Create item"
|
||||||
|
onSubmit={async (input) => {
|
||||||
|
await createMenuItem({ data: input })
|
||||||
|
await navigate({ to: '/admin/menu', viewTransition: false })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/menu/new')({
|
||||||
|
loader: () => withAdminGuard(() => getMachineTagOptions()),
|
||||||
|
component: NewMenuItemRoute,
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createFileRoute, notFound } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { OrderStatusView } from '#/components/orders/OrderStatusView.tsx'
|
||||||
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
|
import { getOrderStatus } from '#/server/orders.ts'
|
||||||
|
|
||||||
|
// Same "both collapse to notFound" reasoning as routes/orders/$orderId.tsx —
|
||||||
|
// an admin session bypasses the ownership check entirely (see isAdmin in
|
||||||
|
// assertOrderAccess), so in practice only "Order not found" is ever hit
|
||||||
|
// here, but the guard stays for a torn-down/renumbered order id.
|
||||||
|
const notFoundMessages = new Set([
|
||||||
|
'Order not found',
|
||||||
|
'You do not have access to this Order',
|
||||||
|
])
|
||||||
|
|
||||||
|
const AdminOrderStatusRoute = () => {
|
||||||
|
const status = Route.useLoaderData()
|
||||||
|
return (
|
||||||
|
<OrderStatusView
|
||||||
|
status={status}
|
||||||
|
guestAccessToken={undefined}
|
||||||
|
backTo="/admin/orders"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/orders/$orderId')({
|
||||||
|
loader: ({ params }) =>
|
||||||
|
withAdminGuard(async () => {
|
||||||
|
try {
|
||||||
|
return await getOrderStatus({
|
||||||
|
data: { orderId: Number(params.orderId) },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && notFoundMessages.has(error.message)) {
|
||||||
|
throw notFound()
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
component: AdminOrderStatusRoute,
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { AdminOrdersView } from '#/components/orders/AdminOrdersView.tsx'
|
||||||
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
|
import { getAllOrders } from '#/server/orders.ts'
|
||||||
|
|
||||||
|
const AdminOrdersRoute = () => {
|
||||||
|
const orders = Route.useLoaderData()
|
||||||
|
return <AdminOrdersView orders={orders} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/orders/')({
|
||||||
|
loader: () => withAdminGuard(() => getAllOrders()),
|
||||||
|
component: AdminOrdersRoute,
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
|
||||||
|
import { getRequest } from '@tanstack/react-start/server'
|
||||||
|
|
||||||
|
import { auth } from '#/lib/auth.ts'
|
||||||
|
|
||||||
|
// Wrapped in createServerOnlyFn: this file is exported for reuse by several
|
||||||
|
// server/*.ts modules, so (unlike a private, unexported helper) the
|
||||||
|
// framework's client/server code-split can't tree-shake it away on its own
|
||||||
|
// — without this it drags getRequest (and the rest of this file) into any
|
||||||
|
// client bundle that transitively imports one of this module's exports,
|
||||||
|
// which @tanstack/react-start's import-protection then refuses to load.
|
||||||
|
export const getSessionUser = createServerOnlyFn(async () => {
|
||||||
|
const session = await auth.api.getSession({ headers: getRequest().headers })
|
||||||
|
return session?.user
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getSessionCustomerId = createServerOnlyFn(
|
||||||
|
async () => (await getSessionUser())?.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defense in depth: an admin-only server function must check this itself
|
||||||
|
// rather than trust the caller's route already gated on role, since a
|
||||||
|
// server function is reachable directly over its RPC endpoint.
|
||||||
|
export const requireAdmin = createServerOnlyFn(async () => {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (user?.role !== 'admin') throw new Error('Admin access required')
|
||||||
|
})
|
||||||
|
|
||||||
|
// A minimal round trip the admin layout route's loader can call to gate
|
||||||
|
// every /admin/* page behind one check, without fetching any page's data.
|
||||||
|
export const requireAdminSession = createServerFn({ method: 'GET' }).handler(
|
||||||
|
async () => {
|
||||||
|
await requireAdmin()
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
|
|
||||||
|
import { db } from '#/db/index.ts'
|
||||||
|
import * as kitchen from '#/domain/kitchen.ts'
|
||||||
|
import { requireAdmin } from '#/server/auth.ts'
|
||||||
|
|
||||||
|
export const getKitchenStatus = createServerFn({ method: 'GET' }).handler(
|
||||||
|
async () => {
|
||||||
|
await requireAdmin()
|
||||||
|
return kitchen.getKitchenStatus(db)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { db } from '#/db/index.ts'
|
||||||
|
import { getMachineTags } from '#/domain/kitchen.ts'
|
||||||
|
import * as menu from '#/domain/menu.ts'
|
||||||
|
import { requireAdmin } from '#/server/auth.ts'
|
||||||
|
import { getBrand } from '#/server/brand.ts'
|
||||||
|
|
||||||
|
const menuItemInputSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
priceCents: z.number().int().nonnegative(),
|
||||||
|
options: z.array(
|
||||||
|
z.object({ name: z.string().min(1), priceDeltaCents: z.number().int() }),
|
||||||
|
),
|
||||||
|
steps: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
machineTagId: z.number(),
|
||||||
|
estimatedDurationSeconds: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getAdminMenu = createServerFn({ method: 'GET' }).handler(
|
||||||
|
async () => {
|
||||||
|
await requireAdmin()
|
||||||
|
const brand = await getBrand()
|
||||||
|
return menu.getMenuItemsForAdmin(db, brand.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const getMachineTagOptions = createServerFn({ method: 'GET' }).handler(
|
||||||
|
async () => {
|
||||||
|
await requireAdmin()
|
||||||
|
return getMachineTags(db)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const createMenuItem = createServerFn({ method: 'POST' })
|
||||||
|
.validator(menuItemInputSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
await requireAdmin()
|
||||||
|
const brand = await getBrand()
|
||||||
|
return menu.createMenuItem(db, brand.id, data)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateMenuItem = createServerFn({ method: 'POST' })
|
||||||
|
.validator(menuItemInputSchema.extend({ menuItemId: z.number() }))
|
||||||
|
.handler(async ({ data: { menuItemId, ...input } }) => {
|
||||||
|
await requireAdmin()
|
||||||
|
return menu.updateMenuItem(db, menuItemId, input)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteMenuItem = createServerFn({ method: 'POST' })
|
||||||
|
.validator(z.object({ menuItemId: z.number() }))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
await requireAdmin()
|
||||||
|
return menu.deleteMenuItem(db, data.menuItemId)
|
||||||
|
})
|
||||||
+16
-11
@@ -1,16 +1,10 @@
|
|||||||
import { createServerFn } from '@tanstack/react-start'
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
import { getRequest } from '@tanstack/react-start/server'
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { db } from '#/db/index.ts'
|
import { db } from '#/db/index.ts'
|
||||||
import * as ordering from '#/domain/ordering.ts'
|
import * as ordering from '#/domain/ordering.ts'
|
||||||
import { auth } from '#/lib/auth.ts'
|
|
||||||
import { getPaymentProvider } from '#/lib/payment.ts'
|
import { getPaymentProvider } from '#/lib/payment.ts'
|
||||||
|
import { getSessionCustomerId, getSessionUser, requireAdmin } from '#/server/auth.ts'
|
||||||
const getSessionCustomerId = async () => {
|
|
||||||
const session = await auth.api.getSession({ headers: getRequest().headers })
|
|
||||||
return session?.user.id
|
|
||||||
}
|
|
||||||
|
|
||||||
const customerSchema = z.discriminatedUnion('type', [
|
const customerSchema = z.discriminatedUnion('type', [
|
||||||
z.object({
|
z.object({
|
||||||
@@ -58,10 +52,14 @@ const accessSchema = z.object({ guestAccessToken: z.string().optional() })
|
|||||||
|
|
||||||
const resolveAccess = async (
|
const resolveAccess = async (
|
||||||
data: z.infer<typeof accessSchema>,
|
data: z.infer<typeof accessSchema>,
|
||||||
): Promise<ordering.OrderAccess> => ({
|
): Promise<ordering.OrderAccess> => {
|
||||||
customerId: await getSessionCustomerId(),
|
const user = await getSessionUser()
|
||||||
guestAccessToken: data.guestAccessToken,
|
return {
|
||||||
})
|
customerId: user?.id,
|
||||||
|
guestAccessToken: data.guestAccessToken,
|
||||||
|
isAdmin: user?.role === 'admin',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const getOrderStatus = createServerFn({ method: 'GET' })
|
export const getOrderStatus = createServerFn({ method: 'GET' })
|
||||||
.validator(accessSchema.extend({ orderId: z.number() }))
|
.validator(accessSchema.extend({ orderId: z.number() }))
|
||||||
@@ -79,6 +77,13 @@ export const getMyOrders = createServerFn({ method: 'GET' }).handler(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const getAllOrders = createServerFn({ method: 'GET' }).handler(
|
||||||
|
async () => {
|
||||||
|
await requireAdmin()
|
||||||
|
return ordering.getAllOrders(db)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
export const getGuestOrders = createServerFn({ method: 'GET' })
|
export const getGuestOrders = createServerFn({ method: 'GET' })
|
||||||
.validator(
|
.validator(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ button {
|
|||||||
::view-transition-new(site-header) {
|
::view-transition-new(site-header) {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same reasoning as site-header: the admin sidebar nav is identical across
|
||||||
|
// every /admin/* route, so it should stay put across those navigations
|
||||||
|
// instead of fading/translating along with the root snapshot.
|
||||||
|
::view-transition-group(admin-sidebar) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(admin-sidebar),
|
||||||
|
::view-transition-new(admin-sidebar) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// document.startViewTransition() itself doesn't check this preference —
|
// document.startViewTransition() itself doesn't check this preference —
|
||||||
|
|||||||
@@ -665,6 +665,50 @@ describe('access control', () => {
|
|||||||
ordering.getOrderStatus(testDb.db, order.id, {}),
|
ordering.getOrderStatus(testDb.db, order.id, {}),
|
||||||
).rejects.toThrow(ordering.ForbiddenError)
|
).rejects.toThrow(ordering.ForbiddenError)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('lets an admin view any Order, guest or account, without a token', async () => {
|
||||||
|
const guestOrder = await placeGuestOrder([
|
||||||
|
{ menuItemId: seed.menuItems.pizza.id },
|
||||||
|
])
|
||||||
|
const accountOrder = await placeAccountOrder(
|
||||||
|
[{ menuItemId: seed.menuItems.burger.id }],
|
||||||
|
'test-customer-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ordering.getOrderStatus(testDb.db, guestOrder.id, { isAdmin: true }),
|
||||||
|
).resolves.toMatchObject({ id: guestOrder.id })
|
||||||
|
await expect(
|
||||||
|
ordering.getOrderStatus(testDb.db, accountOrder.id, { isAdmin: true }),
|
||||||
|
).resolves.toMatchObject({ id: accountOrder.id })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('admin order listing', () => {
|
||||||
|
it("lists every Order across every customer, with each one's placer", async () => {
|
||||||
|
const guestOrder = await placeGuestOrder([
|
||||||
|
{ menuItemId: seed.menuItems.pizza.id },
|
||||||
|
])
|
||||||
|
const accountOrder = await placeAccountOrder(
|
||||||
|
[{ menuItemId: seed.menuItems.burger.id }],
|
||||||
|
'test-customer-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
const orders = await ordering.getAllOrders(testDb.db)
|
||||||
|
|
||||||
|
expect(orders).toHaveLength(2)
|
||||||
|
const guestSummary = orders.find((order) => order.id === guestOrder.id)
|
||||||
|
const accountSummary = orders.find((order) => order.id === accountOrder.id)
|
||||||
|
expect(guestSummary?.customer).toEqual({
|
||||||
|
type: 'guest',
|
||||||
|
name: 'Ada',
|
||||||
|
contact: 'ada@example.com',
|
||||||
|
})
|
||||||
|
expect(accountSummary?.customer).toEqual({
|
||||||
|
type: 'account',
|
||||||
|
customerId: 'test-customer-1',
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('order history', () => {
|
describe('order history', () => {
|
||||||
|
|||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import { devtools } from '@tanstack/devtools-vite'
|
|
||||||
|
|
||||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||||
|
|
||||||
@@ -11,7 +10,7 @@ const config = defineConfig({
|
|||||||
// port — a silent port change breaks Better Auth's origin check, since
|
// port — a silent port change breaks Better Auth's origin check, since
|
||||||
// BETTER_AUTH_URL is pinned to http://localhost:3000.
|
// BETTER_AUTH_URL is pinned to http://localhost:3000.
|
||||||
server: { strictPort: true },
|
server: { strictPort: true },
|
||||||
plugins: [devtools(), tanstackStart(), viteReact()],
|
plugins: [tanstackStart(), viteReact()],
|
||||||
})
|
})
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
|
|||||||
Reference in New Issue
Block a user