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}

+