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) => (
))}
onChange([...notes, ''])}
>
Add a note
);
// --- one table --------------------------------------------------------------
const TableCard = ({
table,
first,
last,
onSaved,
onMove,
onDelete,
onError,
}: {
table: CateringTable;
first: boolean;
last: boolean;
onSaved: (saved: CateringTable) => void;
onMove: (delta: number) => void;
onDelete: () => void;
onError: (message: string) => void;
}) => {
const [draft, setDraft] = useState(() => draftOf(table));
const [busy, setBusy] = useState(false);
const stored = draftOf(table);
const dirty = JSON.stringify(draft) !== JSON.stringify(stored);
// A reorder re-renders this card with a fresh copy from the server; the boxes should follow along
// unless they're being edited.
const [synced, setSynced] = useState(table);
if (synced !== table) {
setSynced(table);
if (!dirty) setDraft(draftOf(table));
}
const edit = (change: Partial) => setDraft({ ...draft, ...change });
// Columns. Every one of these keeps the lines in step — that is the whole job of this screen.
const addColumn = () =>
edit({
tiers: [...draft.tiers, { id: null, label: '', price: '' }],
rows: draft.rows.map((row) => ({ ...row, values: [...row.values, ''] })),
});
const removeColumn = (column: number) =>
edit({
tiers: draft.tiers.filter((_, at) => at !== column),
rows: draft.rows.map((row) => ({ ...row, values: row.values.filter((_, at) => at !== column) })),
});
const moveColumn = (column: number, delta: number) =>
edit({
tiers: shift(draft.tiers, column, delta),
rows: draft.rows.map((row) => ({ ...row, values: shift(row.values, column, delta) })),
});
const setColumn = (column: number, change: Partial) =>
edit({ tiers: draft.tiers.map((tier, at) => (at === column ? { ...tier, ...change } : tier)) });
// Lines.
const addLine = () =>
edit({ rows: [...draft.rows, { id: null, label: '', values: draft.tiers.map(() => '') }] });
const setLine = (line: number, change: Partial) =>
edit({ rows: draft.rows.map((row, at) => (at === line ? { ...row, ...change } : row)) });
const setCell = (line: number, column: number, value: string) =>
setLine(line, {
values: draft.rows[line].values.map((entry, at) => (at === column ? value : entry)),
});
const save = async () => {
setBusy(true);
try {
onSaved(
await saveCateringTable(table.id, {
name: draft.name.trim(),
blurb: draft.blurb.trim() || null,
tiers: draft.tiers.map((tier) => ({ id: tier.id, label: tier.label.trim(), price: tier.price.trim() })),
rows: draft.rows.map((row) => ({
id: row.id,
label: row.label.trim(),
values: row.values.map((entry) => entry.trim()),
})),
notes: draft.notes,
}),
);
} catch (e) {
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
const columns = draft.tiers.length;
return (
{/* Wide tables scroll here rather than making the page scroll sideways. */}
What they get
{draft.tiers.map((tier, column) => (
setColumn(column, { label: e.target.value })}
placeholder="Small"
aria-label={`Heading for column ${column + 1}`}
/>
setColumn(column, { price: e.target.value })}
placeholder="$24 — leave empty to ask"
aria-label={`Price for column ${column + 1}`}
/>
moveColumn(column, -1)}
aria-label="Move this column left"
>
moveColumn(column, 1)}
aria-label="Move this column right"
>
removeColumn(column)}
aria-label="Remove this column"
title="Removes this column and its entries on every line"
>
))}
Column
{draft.rows.map((row, line) => (
setLine(line, { label: e.target.value })}
placeholder="Mini muffins"
aria-label={`Name of line ${line + 1}`}
/>
{row.values.map((entry, column) => (
setCell(line, column, e.target.value)}
placeholder="—"
aria-label={`${row.label || `Line ${line + 1}`}, ${
draft.tiers[column]?.label || `column ${column + 1}`
}`}
/>
))}
edit({ rows: shift(draft.rows, line, -1) })}
aria-label="Move this line up"
>
edit({ rows: shift(draft.rows, line, 1) })}
aria-label="Move this line down"
>
edit({ rows: draft.rows.filter((_, at) => at !== line) })}
aria-label="Remove this line"
>
))}
Line
edit({ notes })}
/>
void save()}>
{busy ? 'Saving…' : 'Save this table'}
setDraft(draftOf(table))}
>
Undo my changes
{dirty && Not saved yet. }
{(columns === 0 || draft.rows.length === 0) && !dirty && (
Needs a column and a line before it shows on the page.
)}
Delete table
);
};
// --- the section ------------------------------------------------------------
const Catering = ({ onError }: { onError: (message: string) => void }) => {
const [tables, setTables] = useState(null);
const [pageNotes, setPageNotes] = useState([]);
const [storedNotes, setStoredNotes] = useState([]);
const [fresh, setFresh] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const menu = await adminCatering();
setTables(menu.packages);
setPageNotes(menu.notes);
setStoredNotes(menu.notes);
} catch (e) {
onError(e instanceof Error ? e.message : 'Could not load the catering tables.');
setTables([]);
}
}, [onError]);
useEffect(() => {
void load();
}, [load]);
/** Moving a table applies on screen first; this page shouldn't freeze between clicks. */
const settle = async (optimistic: CateringTable[], work: () => Promise) => {
const before = tables ?? [];
setTables(optimistic);
try {
await work();
} catch (e) {
setTables(before);
onError(e instanceof Error ? e.message : 'That did not save.');
}
};
const guard = async (work: () => Promise) => {
setBusy(true);
try {
await work();
} catch (e) {
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
const notesDirty = JSON.stringify(pageNotes) !== JSON.stringify(storedNotes);
return (
Goodie boxes & catering
The price tables, in the order they appear on the page. Each one saves on its own.
{tables === null ? (
Loading…
) : (
<>
{tables.map((table, i) => (
setTables(tables.map((t) => (t.id === saved.id ? saved : t)))}
onMove={(delta) => {
const moved = shift(tables, i, delta);
void settle(moved, () => reorderCateringTables(moved.map((t) => t.id)));
}}
onDelete={() => {
if (!confirm(`Delete the ${table.name} table and everything in it?`)) return;
void settle(
tables.filter((t) => t.id !== table.id),
() => deleteCateringTable(table.id),
);
}}
/>
))}
setFresh(e.target.value)}
placeholder="New table, e.g. Graduation parties"
/>
void guard(async () => {
const added = await addCateringTable(fresh.trim());
setTables([...(tables ?? []), added]);
setFresh('');
})
}
>
Add
Under the whole page
void guard(async () => {
const saved = await saveCateringNotes(pageNotes);
setPageNotes(saved);
setStoredNotes(saved);
})
}
>
Save these notes
{notesDirty && (
setPageNotes(storedNotes)}>
Undo
)}
>
)}
);
};
export default Catering;