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:
@@ -34,6 +34,7 @@ import Settings from './Settings';
|
|||||||
import Categories from './Categories';
|
import Categories from './Categories';
|
||||||
import Tags from './Tags';
|
import Tags from './Tags';
|
||||||
import UploadLinks from './UploadLinks';
|
import UploadLinks from './UploadLinks';
|
||||||
|
import DraftQueue from './DraftQueue';
|
||||||
import BuildStamp from './BuildStamp';
|
import BuildStamp from './BuildStamp';
|
||||||
import CategoryTreeSelect from './CategoryTreeSelect';
|
import CategoryTreeSelect from './CategoryTreeSelect';
|
||||||
import ItemCard from '../components/ItemCard';
|
import ItemCard from '../components/ItemCard';
|
||||||
@@ -393,6 +394,7 @@ export default function Admin() {
|
|||||||
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
||||||
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
||||||
{ key: 'upload-links', label: 'Upload links', children: <UploadLinks /> },
|
{ key: 'upload-links', label: 'Upload links', children: <UploadLinks /> },
|
||||||
|
{ key: 'review-queue', label: 'Review queue', children: <DraftQueue /> },
|
||||||
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
||||||
{ key: 'emails', label: 'Emails', children: <Emails /> },
|
{ key: 'emails', label: 'Emails', children: <Emails /> },
|
||||||
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ export type AdminTab =
|
|||||||
| 'Tags'
|
| 'Tags'
|
||||||
| 'Customers'
|
| 'Customers'
|
||||||
| 'Emails'
|
| 'Emails'
|
||||||
| 'Settings';
|
| 'Settings'
|
||||||
|
| 'Review queue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The admin shell: the tab strip and the panel it swaps.
|
* The admin shell: the tab strip and the panel it swaps.
|
||||||
|
|||||||
Reference in New Issue
Block a user