Show live order progress, delays, and kitchen cancellations

- Order cards derive their label from item progress (Not Started,
  Preparing, Partly Ready, Ready, Picked Up, Cancelled) instead of the
  order's own status; final states are coloured
- Order page: animated status banner per item (ready/picked up check,
  cancelled cross, running-late clock), steps collapse once an item is
  settled with a Show/Hide steps toggle, done steps are faded instead
  of struck through, and the per-item status badge is gone
- New Delay concept: machines can report extra time on a claimed step
  (preparation_step_instances.delay_seconds), which counts toward the ETA
- The kitchen can cancel an item at any point before it's ready, with a
  refund (order_items.cancelled_by records who cancelled)
- Dev kitchen simulation now runs server-side across every order, so a
  step nobody is watching no longer blocks the queue; each settled step
  may randomly be delayed or cancelled
- Humanize snake_case statuses in the UI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:11:08 +02:00
co-authored by Claude Opus 5.5
parent c124b0415a
commit a6ac32d783
17 changed files with 1651 additions and 111 deletions
@@ -0,0 +1,69 @@
import { motion, useReducedMotion } from 'framer-motion'
import styles from './ItemStatusBanner.module.scss'
export type ItemStatusBannerVariant =
'ready' | 'pickedUp' | 'cancelled' | 'delayed'
const iconPaths = {
ready: ['M7.5 12.5l3 3 6-6.5'],
pickedUp: ['M7.5 12.5l3 3 6-6.5'],
cancelled: ['M8.5 8.5l7 7', 'M15.5 8.5l-7 7'],
delayed: ['M12 7v5', 'M12 12l3.5 2'],
} satisfies Record<ItemStatusBannerVariant, Array<string>>
type ItemStatusBannerProps = {
variant: ItemStatusBannerVariant
label: string
detail?: string
animateIn: boolean
}
export const ItemStatusBanner = ({
variant,
label,
detail,
animateIn,
}: ItemStatusBannerProps) => {
const reduceMotion = useReducedMotion()
const skipAnimation = !animateIn || reduceMotion
return (
<motion.div
className={styles.banner}
data-variant={variant}
role="status"
initial={skipAnimation ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<svg className={styles.icon} viewBox="0 0 24 24" aria-hidden="true">
<motion.circle
cx="12"
cy="12"
r="10"
initial={skipAnimation ? false : { pathLength: 0, rotate: -90 }}
animate={{ pathLength: 1, rotate: -90 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
{iconPaths[variant].map((d, index) => (
<motion.path
key={d}
d={d}
initial={skipAnimation ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{
duration: 0.3,
ease: 'easeOut',
delay: 0.45 + index * 0.2,
}}
/>
))}
</svg>
<span className={styles.text}>
<span className={styles.label}>{label}</span>
{detail && <span className={styles.detail}>{detail}</span>}
</span>
</motion.div>
)
}