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:
@@ -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 = {
|
||||
status: OrderStatusResult
|
||||
guestAccessToken: string | undefined
|
||||
backTo?: '/orders' | '/admin/orders'
|
||||
}
|
||||
|
||||
export const OrderStatusView = ({
|
||||
status,
|
||||
guestAccessToken,
|
||||
backTo = '/orders',
|
||||
}: OrderStatusViewProps) => {
|
||||
const router = useRouter()
|
||||
const [pendingItemId, setPendingItemId] = useState<number | null>(null)
|
||||
@@ -94,9 +96,13 @@ export const OrderStatusView = ({
|
||||
|
||||
return (
|
||||
<section className={styles.page}>
|
||||
<Link to="/orders" className={styles.backLink}>
|
||||
<Link
|
||||
to={backTo}
|
||||
className={styles.backLink}
|
||||
viewTransition={backTo !== '/admin/orders'}
|
||||
>
|
||||
<BackArrowIcon />
|
||||
Back to orders
|
||||
{backTo === '/admin/orders' ? 'Back to all orders' : 'Back to orders'}
|
||||
</Link>
|
||||
<div style={{ viewTransitionName: `order-card-${status.id}` }}>
|
||||
<h1 className={styles.heading}>Order #{status.id}</h1>
|
||||
|
||||
@@ -11,10 +11,46 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: $color-primary;
|
||||
svg {
|
||||
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>
|
||||
}
|
||||
|
||||
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']) =>
|
||||
items
|
||||
.map(({ menuItemName, quantity }) =>
|
||||
@@ -20,10 +36,16 @@ export const OrdersListView = ({ orders }: OrdersListViewProps) => (
|
||||
<section className={styles.page}>
|
||||
<h1 className={styles.heading}>Your orders</h1>
|
||||
{orders.length === 0 ? (
|
||||
<p className={styles.empty}>
|
||||
You haven't placed an order yet — <Link to="/">browse the menu</Link>{' '}
|
||||
to get started.
|
||||
</p>
|
||||
<div className={styles.empty}>
|
||||
<OrdersIcon />
|
||||
<p className={styles.emptyTitle}>No orders yet</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}>
|
||||
{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 = (
|
||||
order: Pick<
|
||||
@@ -418,6 +422,7 @@ const assertOrderAccess = (
|
||||
>,
|
||||
access: OrderAccess,
|
||||
) => {
|
||||
if (access.isAdmin) return
|
||||
if (order.customerId) {
|
||||
if (access.customerId === order.customerId) return
|
||||
} else if (order.guestAccessToken) {
|
||||
@@ -955,6 +960,40 @@ export const getOrdersForCustomer = async (
|
||||
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 }
|
||||
|
||||
// A guest's Order history, looked up from the (orderId, guestAccessToken)
|
||||
|
||||
@@ -29,6 +29,21 @@ const OrdersIcon = () => (
|
||||
</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 = () => (
|
||||
<svg
|
||||
className={styles.menuIcon}
|
||||
@@ -114,6 +129,16 @@ const BetterAuthHeader = () => {
|
||||
<OrdersIcon />
|
||||
Orders
|
||||
</Link>
|
||||
{session.user.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
role="menuitem"
|
||||
className={styles.menuButton}
|
||||
>
|
||||
<AdminIcon />
|
||||
Administration
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
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 { 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: {
|
||||
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: {
|
||||
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
||||
? {
|
||||
|
||||
@@ -10,12 +10,20 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AdminRouteImport } from './routes/admin'
|
||||
import { Route as BasketRouteImport } from './routes/basket'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
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 OrdersIndexRouteImport } from './routes/orders/index'
|
||||
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/$'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
@@ -23,6 +31,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminRoute = AdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BasketRoute = BasketRouteImport.update({
|
||||
id: '/basket',
|
||||
path: '/basket',
|
||||
@@ -38,6 +51,16 @@ const SignupRoute = SignupRouteImport.update({
|
||||
path: '/signup',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} 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({
|
||||
id: '/items/$menuItemId',
|
||||
path: '/items/$menuItemId',
|
||||
@@ -53,6 +76,31 @@ const OrdersOrderIdRoute = OrdersOrderIdRouteImport.update({
|
||||
path: '/orders/$orderId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} 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({
|
||||
id: '/api/auth/$',
|
||||
path: '/api/auth/$',
|
||||
@@ -61,70 +109,117 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRouteWithChildren
|
||||
'/basket': typeof BasketRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin/kitchen': typeof AdminKitchenRoute
|
||||
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
'/orders/': typeof OrdersIndexRoute
|
||||
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
|
||||
'/admin/menu/new': typeof AdminMenuNewRoute
|
||||
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/admin/menu/': typeof AdminMenuIndexRoute
|
||||
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/basket': typeof BasketRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin/kitchen': typeof AdminKitchenRoute
|
||||
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||
'/admin': typeof AdminIndexRoute
|
||||
'/orders': typeof OrdersIndexRoute
|
||||
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
|
||||
'/admin/menu/new': typeof AdminMenuNewRoute
|
||||
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/admin/menu': typeof AdminMenuIndexRoute
|
||||
'/admin/orders': typeof AdminOrdersIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRouteWithChildren
|
||||
'/basket': typeof BasketRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin/kitchen': typeof AdminKitchenRoute
|
||||
'/items/$menuItemId': typeof ItemsMenuItemIdRoute
|
||||
'/orders/$orderId': typeof OrdersOrderIdRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
'/orders/': typeof OrdersIndexRoute
|
||||
'/admin/menu/$menuItemId': typeof AdminMenuMenuItemIdRoute
|
||||
'/admin/menu/new': typeof AdminMenuNewRoute
|
||||
'/admin/orders/$orderId': typeof AdminOrdersOrderIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/admin/menu/': typeof AdminMenuIndexRoute
|
||||
'/admin/orders/': typeof AdminOrdersIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/basket'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/admin/kitchen'
|
||||
| '/items/$menuItemId'
|
||||
| '/orders/$orderId'
|
||||
| '/admin/'
|
||||
| '/orders/'
|
||||
| '/admin/menu/$menuItemId'
|
||||
| '/admin/menu/new'
|
||||
| '/admin/orders/$orderId'
|
||||
| '/api/auth/$'
|
||||
| '/admin/menu/'
|
||||
| '/admin/orders/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/basket'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/admin/kitchen'
|
||||
| '/items/$menuItemId'
|
||||
| '/orders/$orderId'
|
||||
| '/admin'
|
||||
| '/orders'
|
||||
| '/admin/menu/$menuItemId'
|
||||
| '/admin/menu/new'
|
||||
| '/admin/orders/$orderId'
|
||||
| '/api/auth/$'
|
||||
| '/admin/menu'
|
||||
| '/admin/orders'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/basket'
|
||||
| '/login'
|
||||
| '/signup'
|
||||
| '/admin/kitchen'
|
||||
| '/items/$menuItemId'
|
||||
| '/orders/$orderId'
|
||||
| '/admin/'
|
||||
| '/orders/'
|
||||
| '/admin/menu/$menuItemId'
|
||||
| '/admin/menu/new'
|
||||
| '/admin/orders/$orderId'
|
||||
| '/api/auth/$'
|
||||
| '/admin/menu/'
|
||||
| '/admin/orders/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AdminRoute: typeof AdminRouteWithChildren
|
||||
BasketRoute: typeof BasketRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
SignupRoute: typeof SignupRoute
|
||||
@@ -143,6 +238,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin': {
|
||||
id: '/admin'
|
||||
path: '/admin'
|
||||
fullPath: '/admin'
|
||||
preLoaderRoute: typeof AdminRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/basket': {
|
||||
id: '/basket'
|
||||
path: '/basket'
|
||||
@@ -164,6 +266,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SignupRouteImport
|
||||
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': {
|
||||
id: '/items/$menuItemId'
|
||||
path: '/items/$menuItemId'
|
||||
@@ -185,6 +301,41 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof OrdersOrderIdRouteImport
|
||||
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/$': {
|
||||
id: '/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 = {
|
||||
IndexRoute: IndexRoute,
|
||||
AdminRoute: AdminRouteWithChildren,
|
||||
BasketRoute: BasketRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
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 { getRequest } from '@tanstack/react-start/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { db } from '#/db/index.ts'
|
||||
import * as ordering from '#/domain/ordering.ts'
|
||||
import { auth } from '#/lib/auth.ts'
|
||||
import { getPaymentProvider } from '#/lib/payment.ts'
|
||||
|
||||
const getSessionCustomerId = async () => {
|
||||
const session = await auth.api.getSession({ headers: getRequest().headers })
|
||||
return session?.user.id
|
||||
}
|
||||
import { getSessionCustomerId, getSessionUser, requireAdmin } from '#/server/auth.ts'
|
||||
|
||||
const customerSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
@@ -58,10 +52,14 @@ const accessSchema = z.object({ guestAccessToken: z.string().optional() })
|
||||
|
||||
const resolveAccess = async (
|
||||
data: z.infer<typeof accessSchema>,
|
||||
): Promise<ordering.OrderAccess> => ({
|
||||
customerId: await getSessionCustomerId(),
|
||||
guestAccessToken: data.guestAccessToken,
|
||||
})
|
||||
): Promise<ordering.OrderAccess> => {
|
||||
const user = await getSessionUser()
|
||||
return {
|
||||
customerId: user?.id,
|
||||
guestAccessToken: data.guestAccessToken,
|
||||
isAdmin: user?.role === 'admin',
|
||||
}
|
||||
}
|
||||
|
||||
export const getOrderStatus = createServerFn({ method: 'GET' })
|
||||
.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' })
|
||||
.validator(
|
||||
z.object({
|
||||
|
||||
@@ -73,6 +73,18 @@ button {
|
||||
::view-transition-new(site-header) {
|
||||
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 —
|
||||
|
||||
Reference in New Issue
Block a user