From 2b06538ef9b973214e7ac5fee010005f0225df89 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 1 Sep 2026 14:19:49 -0500 Subject: [PATCH] feat(intake): review, edit and publish drafts from the admin (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen that makes the intake pipeline usable. Until now a draft existed only in item_drafts and nothing rendered it, so a successful draft and a failed one looked identical from the admin — the item shows its placeholder submission-timestamp name either way, and telling them apart needed SQL. The price carries the weight the schema no longer does. It is labelled with where the number came from, anything not set by a person is marked unconfirmed, and publishing at an unconfirmed price asks first rather than reporting afterwards. Editing the field is what confirms it, so opening the card and leaving the price alone is not recorded as approval — the same rule the server applies, which this only has to agree with. Discard is offered rather than delete, and a discarded card offers Restore in its place. The e2e page object's AdminTab union is extended alongside the tab itself. It is a closed union, so admin.open('Review queue') would not type-check without it — and the tab strip and that union have to be changed together or the next spec to use it fails to compile. Both frontend lint and build clean, still at zero warnings. Co-Authored-By: Claude Opus 5 --- frontend/src/admin/Admin.tsx | 2 + frontend/src/admin/DraftQueue.tsx | 195 ++++++++++++++++++++++++++ frontend/src/admin/draftsApi.ts | 73 ++++++++++ frontend/tests/e2e/pages/AdminPage.ts | 3 +- 4 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 frontend/src/admin/DraftQueue.tsx create mode 100644 frontend/src/admin/draftsApi.ts diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index 00a3745..0b115ac 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -34,6 +34,7 @@ import Settings from './Settings'; import Categories from './Categories'; import Tags from './Tags'; import UploadLinks from './UploadLinks'; +import DraftQueue from './DraftQueue'; import BuildStamp from './BuildStamp'; import CategoryTreeSelect from './CategoryTreeSelect'; import ItemCard from '../components/ItemCard'; @@ -393,6 +394,7 @@ export default function Admin() { { key: 'categories', label: 'Categories', children: }, { key: 'tags', label: 'Tags', children: }, { key: 'upload-links', label: 'Upload links', children: }, + { key: 'review-queue', label: 'Review queue', children: }, { key: 'customers', label: 'Customers', children: }, { key: 'emails', label: 'Emails', children: }, { key: 'settings', label: 'Settings', children: } diff --git a/frontend/src/admin/DraftQueue.tsx b/frontend/src/admin/DraftQueue.tsx new file mode 100644 index 0000000..9d271ed --- /dev/null +++ b/frontend/src/admin/DraftQueue.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useState } from 'react'; +import Card from 'antd/es/card'; +import Button from 'antd/es/button'; +import Input from 'antd/es/input'; +import InputNumber from 'antd/es/input-number'; +import Space from 'antd/es/space'; +import Tag from 'antd/es/tag'; +import Select from 'antd/es/select'; +import Empty from 'antd/es/empty'; +import Alert from 'antd/es/alert'; +import Modal from 'antd/es/modal'; +import message from 'antd/es/message'; +import { Draft, PriceSource, actOnDraft, fetchDrafts, publishDraft } from './draftsApi'; + +const { TextArea } = Input; + +/** Mirrors isUnconfirmed on the server: anything a person did not choose. */ +function isUnconfirmed(source: PriceSource): boolean { + return source !== 'admin'; +} + +function priceLabel(source: PriceSource): string { + if (source === 'admin') return 'you set this price'; + if (source === 'ai') return 'suggested by the model — nobody chose this'; + return 'default price — nobody chose this'; +} + +/** + * One submission, with everything needed to judge it. + * + * The price is why this screen exists. Items are priced on arrival, so nothing + * stops a number nobody chose from reaching the storefront except this saying + * so — and 80.00 is a plausible price rather than an obvious sentinel, which is + * exactly why it has to be called out rather than left to be noticed. + */ +function DraftCard({ draft, onChanged }: Readonly<{ draft: Draft; onChanged: () => void }>) { + const [name, setName] = useState(draft.ai_name ?? draft.item_name); + const [description, setDescription] = useState( + draft.ai_description ?? draft.item_description ?? '' + ); + const [priceCents, setPriceCents] = useState(draft.price_cents); + const [busy, setBusy] = useState(false); + + // Unconfirmed until the number is actually changed. Opening the field and + // leaving it alone is not a decision and must not be recorded as one — the + // server applies the same rule, this only has to agree with it. + const unconfirmed = isUnconfirmed(draft.price_source) && priceCents === draft.price_cents; + + const run = async (work: () => Promise) => { + setBusy(true); + try { + await work(); + onChanged(); + } catch (err) { + message.error(err instanceof Error ? err.message : 'that did not work'); + } finally { + setBusy(false); + } + }; + + const publish = () => { + const go = () => run(() => publishDraft(draft.item_id, { name, description, priceCents })); + if (!unconfirmed) { + void go(); + return; + } + // Said before, not after. Publishing at an unconfirmed price is allowed — + // it is a decision someone is entitled to make — but not by accident. + Modal.confirm({ + title: 'Publish at a price nobody chose?', + content: `This will go on sale at $${(priceCents / 100).toFixed(2)}, which is ${ + draft.price_source === 'ai' ? "the model's suggestion" : 'the default' + } rather than a price you set.`, + okText: 'Publish anyway', + okButtonProps: { danger: true }, + onOk: go + }); + }; + + return ( + {draft.state}} style={{ marginBottom: 16 }}> + + {draft.ai_error && } + + + {draft.images.map((image) => ( + + ))} + + + {draft.submitter_note && ( + + )} + {draft.upload_link_label && via {draft.upload_link_label}} + + setName(e.target.value)} aria-label="Name" /> +