Catering tables: the spreadsheet becomes data the bakery can edit
build-and-publish / build (pull_request) Successful in 1m58s

The goodie box and catering prices arrived as a spreadsheet — Office, Parties and Weddings, each a
few columns of sizes and prices with lines of baked goods underneath. This puts it behind
/api/catering and makes every part of it editable at /admin, because the prices move and the
spreadsheet's own last line says the tables are "mostly just an idea for people".

A package is one table, its tiers are the columns, its rows are the lines, and a line holds one
value per column. That alignment is why this is an aggregate rather than three tables edited
separately: drop the middle column on its own and every remaining entry shifts one place left, so
the Large box advertises the Medium box's contents at the Large price and nothing looks broken.
CateringPackage#arrange takes a whole table, renumbers positions from the order it arrived in, and
refuses an arrangement whose lines and columns disagree.

Money owns prices — what "24", "$24" or "24.50" means and how it prints — so the browser never
formats money and never multiplies it by 100 in floating point. Cents in the column, "$24" in the
response. An empty price is "ask us", not zero.

Seeded from the bakery's own wording. Shorthand is expanded ("4 dz cc or sc") and typos fixed, since
customers read these lines; in the wedding table the labels and the values are offset in the source
spreadsheet, so they are carried over literally and can be renamed in the admin. The lines that are
named but never quantified keep their blank cells: dropping the blanks would shorten the line and
shift everything after it.

The public response leaves out a table with no columns or no lines — adding a table and filling it
in are two separate acts, and the gap between them shouldn't put a bare heading on the live page.
No public page renders any of this yet; this is the backend and the editor for it.

Admin endpoints are @ConditionalOnProperty on SECURITY_MODE=OIDC like the rest, so a deployment with
no identity provider has no price writes. 18 new tests: the seeded spreadsheet, the alignment
invariant, money in both directions, and the HTTP surface the screen actually calls (including that
/packages/order isn't read as a table id, and that a refusal arrives as a ProblemDetail sentence).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-07-26 15:13:39 -05:00
co-authored by Claude Opus 5
parent d5103e44b9
commit 748b1cd59b
20 changed files with 2170 additions and 51 deletions
+23 -49
View File
@@ -15,6 +15,24 @@ import {
type AdminCategory,
type AdminProduct,
} from '@/lib/api';
import Catering from '@/components/admin/Catering';
import {
ARROW_DOWN,
ARROW_LEFT,
ARROW_RIGHT,
ARROW_UP,
CHECK,
Icon,
PLUS,
TRASH,
X,
danger,
field,
iconButton,
primary,
secondary,
shift,
} from '@/components/admin/ui';
/**
* The catalogue, editable by the person who bakes it.
@@ -28,54 +46,6 @@ import {
* sent to the identity provider before this ever loads.
*/
// --- little pieces ----------------------------------------------------------
const Icon = ({ d, className = '' }: { d: string; className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={`w-4 h-4 ${className}`}
aria-hidden="true"
>
<path d={d} />
</svg>
);
const ARROW_UP = 'M12 19V5M5 12l7-7 7 7';
const ARROW_DOWN = 'M12 5v14M19 12l-7 7-7-7';
const ARROW_LEFT = 'M19 12H5M12 19l-7-7 7-7';
const ARROW_RIGHT = 'M5 12h14M12 5l7 7-7 7';
const TRASH = 'M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6';
const PLUS = 'M12 5v14M5 12h14';
const CHECK = 'M20 6L9 17l-5-5';
const X = 'M18 6L6 18M6 6l12 12';
const button =
'inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium ' +
'transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
const primary = `${button} bg-bakery-600 text-white hover:bg-bakery-700`;
const secondary = `${button} border border-bakery-300 text-bakery-800 hover:bg-bakery-100`;
const danger = `${button} text-red-700 hover:bg-red-50`;
const iconButton =
'inline-flex items-center justify-center w-7 h-7 rounded-md border border-bakery-300 ' +
'text-bakery-700 hover:bg-bakery-100 transition-colors disabled:opacity-30 disabled:cursor-not-allowed';
const field =
'w-full rounded-md border border-bakery-300 bg-white px-3 py-2 text-sm ' +
'focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500';
/** Moves one entry of a list by `delta`, or returns the list untouched if that would fall off an end. */
function shift<T>(items: T[], index: number, delta: number): T[] {
const target = index + delta;
if (target < 0 || target >= items.length) return items;
const next = [...items];
[next[index], next[target]] = [next[target], next[index]];
return next;
}
// --- photos -----------------------------------------------------------------
/**
@@ -628,7 +598,7 @@ const AdminPage = () => {
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-lejour text-4xl text-bakery-700">The Vine</h1>
<p className="text-bakery-600">Everything on the products page lives here.</p>
<p className="text-bakery-600">Everything on the products and catering pages lives here.</p>
</div>
<div className="flex items-center gap-2">
<a href="/products" className={secondary}>
@@ -707,6 +677,10 @@ const AdminPage = () => {
<p className="mt-3 text-bakery-600">Nothing here yet add something above.</p>
)}
</section>
{/* A different page, and a different shape of editing — see the note at the top of Catering. */}
<hr className="border-bakery-200" />
<Catering onError={setError} />
</div>
)}
</div>