feat(intake): review, edit and publish drafts from the admin (#225)

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 14:19:49 -05:00
co-authored by Claude Opus 5
parent d8a7588685
commit 2b06538ef9
4 changed files with 272 additions and 1 deletions
+2
View File
@@ -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: <Categories /> },
{ key: 'tags', label: 'Tags', children: <Tags /> },
{ key: 'upload-links', label: 'Upload links', children: <UploadLinks /> },
{ key: 'review-queue', label: 'Review queue', children: <DraftQueue /> },
{ key: 'customers', label: 'Customers', children: <Customers /> },
{ key: 'emails', label: 'Emails', children: <Emails /> },
{ key: 'settings', label: 'Settings', children: <Settings /> }
+195
View File
@@ -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<void>) => {
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 (
<Card title={draft.item_name} extra={<Tag>{draft.state}</Tag>} style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{draft.ai_error && <Alert type="warning" message={`Drafting failed: ${draft.ai_error}`} />}
<Space wrap>
{draft.images.map((image) => (
<img
key={image.id}
src={image.image_path}
alt=""
style={{ width: 120, height: 120, objectFit: 'cover' }}
/>
))}
</Space>
{draft.submitter_note && (
<Alert type="info" message={`Sender's note: ${draft.submitter_note}`} />
)}
{draft.upload_link_label && <Tag>via {draft.upload_link_label}</Tag>}
<Input value={name} onChange={(e) => setName(e.target.value)} aria-label="Name" />
<TextArea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
aria-label="Description"
/>
<Space direction="vertical" size={4}>
<InputNumber
value={priceCents / 100}
min={0}
step={0.01}
prefix="$"
aria-label="Price"
onChange={(value) => setPriceCents(Math.round((value ?? 0) * 100))}
/>
<Tag color={unconfirmed ? 'orange' : 'green'}>
{unconfirmed ? priceLabel(draft.price_source) : 'you set this price'}
</Tag>
</Space>
<Space>
<Button type="primary" loading={busy} onClick={publish}>
Publish
</Button>
<Button loading={busy} onClick={() => void run(() => actOnDraft(draft.item_id, 'regenerate'))}>
Regenerate
</Button>
{draft.state === 'discarded' ? (
<Button loading={busy} onClick={() => void run(() => actOnDraft(draft.item_id, 'restore'))}>
Restore
</Button>
) : (
<Button
danger
loading={busy}
onClick={() => void run(() => actOnDraft(draft.item_id, 'discard'))}
>
Discard
</Button>
)}
</Space>
</Space>
</Card>
);
}
export default function DraftQueue() {
const [drafts, setDrafts] = useState<Draft[]>([]);
const [state, setState] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setDrafts(await fetchDrafts(state));
setError(null);
} catch {
setError('Could not load the review queue.');
}
}, [state]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Select
value={state}
onChange={setState}
style={{ width: 220 }}
placeholder="All except discarded"
allowClear
aria-label="State"
options={[
{ value: 'queued', label: 'Queued' },
{ value: 'ready', label: 'Ready' },
{ value: 'failed', label: 'Failed' },
{ value: 'discarded', label: 'Discarded' }
]}
/>
<Button onClick={() => void load()}>Refresh</Button>
</Space>
{error && <Alert type="error" message={error} />}
{!error && drafts.length === 0 && <Empty description="Nothing waiting for review" />}
{drafts.map((draft) => (
<DraftCard key={draft.item_id} draft={draft} onChanged={() => void load()} />
))}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
export type PriceSource = 'default' | 'ai' | 'admin';
export interface DraftImage {
id: number;
image_path: string;
}
export interface Draft {
item_id: number;
state: string;
attempts: number;
submitter_note: string | null;
ai_error: string | null;
ai_name: string | null;
ai_description: string | null;
ai_suggested_price_cents: number | null;
price_source: PriceSource;
model: string | null;
item_name: string;
item_description: string | null;
price_cents: number;
status: string;
upload_link_label: string | null;
images: DraftImage[];
}
async function send(path: string, init?: RequestInit): Promise<Response> {
return fetch(`/api/admin/item-drafts${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }
});
}
export async function fetchDrafts(state?: string): Promise<Draft[]> {
const query = state ? `?state=${encodeURIComponent(state)}` : '';
const res = await send(query);
if (!res.ok) throw new Error('could not load the review queue');
return (await res.json()).drafts;
}
export interface PublishInput {
name: string;
description: string;
priceCents: number;
}
/**
* The server's message is preferred over a generic one because its refusals are
* specific and actionable — a missing name, a fractional price — and replacing
* them with "could not publish" would throw away the only thing that says how
* to fix it.
*/
export async function publishDraft(itemId: number, input: PublishInput): Promise<void> {
const res = await send(`/${itemId}/publish`, { method: 'POST', body: JSON.stringify(input) });
if (res.ok) return;
let message = 'could not publish';
try {
message = (await res.json()).error ?? message;
} catch {
// A non-JSON body is a proxy or gateway error rather than the app refusing.
// The generic message above is the honest thing to show in that case.
}
throw new Error(message);
}
export async function actOnDraft(
itemId: number,
action: 'regenerate' | 'discard' | 'restore'
): Promise<void> {
const res = await send(`/${itemId}/${action}`, { method: 'POST' });
if (!res.ok) throw new Error(`could not ${action} this draft`);
}