From 748b1cd59b2bc3832bfcdef5bf5a92c8d947e1ff Mon Sep 17 00:00:00 2001 From: austin Date: Sun, 26 Jul 2026 15:13:26 -0500 Subject: [PATCH] Catering tables: the spreadsheet becomes data the bakery can edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 19 +- frontend/src/components/admin/Catering.tsx | 570 ++++++++++++++++++ frontend/src/components/admin/ui.tsx | 52 ++ frontend/src/lib/api.ts | 72 +++ frontend/src/pages/Admin.tsx | 72 +-- .../web/AdminCateringController.java | 77 +++ .../itsthevine/web/CateringController.java | 20 + .../java/com/itsthevine/web/CateringMenu.java | 194 ++++++ .../itsthevine/web/domain/CateringNote.java | 50 ++ .../web/domain/CateringNoteRepository.java | 10 + .../web/domain/CateringPackage.java | 214 +++++++ .../web/domain/CateringPackageRepository.java | 10 + .../itsthevine/web/domain/CateringRow.java | 84 +++ .../itsthevine/web/domain/CateringTier.java | 72 +++ .../java/com/itsthevine/web/domain/Money.java | 72 +++ .../java/com/itsthevine/web/domain/Text.java | 37 ++ .../resources/db/migration/V4__catering.sql | 141 +++++ .../itsthevine/web/AdminCateringApiTest.java | 150 +++++ .../com/itsthevine/web/AdminSecurityTest.java | 10 + .../com/itsthevine/web/CateringMenuTest.java | 295 +++++++++ 20 files changed, 2170 insertions(+), 51 deletions(-) create mode 100644 frontend/src/components/admin/Catering.tsx create mode 100644 frontend/src/components/admin/ui.tsx create mode 100644 src/main/java/com/itsthevine/web/AdminCateringController.java create mode 100644 src/main/java/com/itsthevine/web/CateringController.java create mode 100644 src/main/java/com/itsthevine/web/CateringMenu.java create mode 100644 src/main/java/com/itsthevine/web/domain/CateringNote.java create mode 100644 src/main/java/com/itsthevine/web/domain/CateringNoteRepository.java create mode 100644 src/main/java/com/itsthevine/web/domain/CateringPackage.java create mode 100644 src/main/java/com/itsthevine/web/domain/CateringPackageRepository.java create mode 100644 src/main/java/com/itsthevine/web/domain/CateringRow.java create mode 100644 src/main/java/com/itsthevine/web/domain/CateringTier.java create mode 100644 src/main/java/com/itsthevine/web/domain/Money.java create mode 100644 src/main/java/com/itsthevine/web/domain/Text.java create mode 100644 src/main/resources/db/migration/V4__catering.sql create mode 100644 src/test/java/com/itsthevine/web/AdminCateringApiTest.java create mode 100644 src/test/java/com/itsthevine/web/CateringMenuTest.java diff --git a/README.md b/README.md index e327116..a41d880 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ The SPA renders; it doesn't decide anything. - **`/api/products`**, **`/api/categories`** — the catalogue, its curated order, the category filter and the absolute image URLs. This was a TypeScript array shipped to every visitor; it's now a table (`V2__products.sql`) read through `ProductCatalog`. +- **`/api/catering`** — the goodie box and catering price tables (Office, Parties, Weddings): the + columns, the prices already written the way they should be read, the entries under each column, and + the small print. These came from the bakery as a spreadsheet and are stored as one (`V4__catering.sql`, + read through `CateringMenu`) rather than as markup, because the prices move and the last line of that + spreadsheet says the tables are "mostly just an idea for people". `Money` is the only thing that + decides what a typed price means or how it prints. A table with no columns or no lines is left off the + public response — adding a table and filling it in are two separate acts in the admin, and the gap + between them shouldn't put a bare heading on the live page. *(No public page renders this yet.)* - **`/api/contact`** — validates, **records the enquiry**, emails it, then fans out to the n8n hub. Recorded before sending on purpose: a relay outage costs a notification, not the enquiry. Undelivered ones are `enquiry.delivered = false`. Validation and delivery come from `platform-starter-contact`, @@ -39,8 +47,15 @@ Photos are resized, stripped of EXIF, converted to webp and put in the bucket on (`ProductPhotoService`, using `cwebp` from `libwebp-tools` — the pure-Java encoders either can't write webp or ship glibc natives that don't run on Alpine). -**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController` and -`AdminCategoryController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure +The catering tables are editable there too, but a table at a time rather than a field at a time. That +isn't a different taste in interfaces: a column heading, its price and the entries beneath it only mean +anything together, so `CateringPackage#arrange` takes the whole table and refuses one whose lines and +columns disagree. Drop the middle column on its own and every remaining entry shifts one place left — +the Large box then advertises the Medium box's contents at the Large price, and nothing about the page +looks broken. + +**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController`, +`AdminCategoryController` and `AdminCateringController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure Authentik gets 404s rather than catalogue writes open to the internet. `/admin` and `/api/admin/**` are both authenticated paths: a browser opening the page is sent to Authentik first, while `fetch` calls get a bare 401 to handle. diff --git a/frontend/src/components/admin/Catering.tsx b/frontend/src/components/admin/Catering.tsx new file mode 100644 index 0000000..ec8b3a8 --- /dev/null +++ b/frontend/src/components/admin/Catering.tsx @@ -0,0 +1,570 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + addCateringTable, + adminCatering, + deleteCateringTable, + reorderCateringTables, + saveCateringNotes, + saveCateringTable, + type CateringTable, +} from '@/lib/api'; +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 goodie box and catering price tables, editable by the person who quotes them. + * + * A table is edited as a table and saved in one go, unlike the catalogue next door where every change + * saves as you make it. That's not a different taste in interfaces: a column heading, its price and + * the entries beneath it only mean anything together, so they have to be moved, added and removed + * together. Adding a column here adds an empty entry to every line, and removing one takes its + * entries with it — the server refuses any table whose lines and columns disagree, because the + * alternative is the Large box quietly advertising the Medium box's contents at the Large price. + */ + +// --- what's on screen ------------------------------------------------------- + +type TierDraft = { id: number | null; label: string; price: string }; +type RowDraft = { id: number | null; label: string; values: string[] }; +type Draft = { name: string; blurb: string; tiers: TierDraft[]; rows: RowDraft[]; notes: string[] }; + +const draftOf = (table: CateringTable): Draft => ({ + name: table.name, + blurb: table.blurb ?? '', + // The price arrives written out ("$24"); it goes back as whatever the editor leaves in the box, and + // the server decides what that's worth. + tiers: table.tiers.map((tier) => ({ id: tier.id, label: tier.label, price: tier.price ?? '' })), + rows: table.rows.map((row) => ({ id: row.id, label: row.label, values: [...row.values] })), + notes: [...table.notes], +}); + +/** Notes are edited as a list; deleting one is an omission, exactly as the server expects. */ +const Notes = ({ + notes, + hint, + disabled, + onChange, +}: { + notes: string[]; + hint: string; + disabled?: boolean; + onChange: (notes: string[]) => void; +}) => ( +
+

{hint}

+
    + {notes.map((note, i) => ( +
  • +