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>
407 lines
17 KiB
TypeScript
Executable File
407 lines
17 KiB
TypeScript
Executable File
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import Layout from 'antd/es/layout';
|
|
import Table from 'antd/es/table';
|
|
import Button from 'antd/es/button';
|
|
import Drawer from 'antd/es/drawer';
|
|
import Form from 'antd/es/form';
|
|
import Input from 'antd/es/input';
|
|
import InputNumber from 'antd/es/input-number';
|
|
import Upload from 'antd/es/upload';
|
|
import Modal from 'antd/es/modal';
|
|
import Space from 'antd/es/space';
|
|
import Tag from 'antd/es/tag';
|
|
import Typography from 'antd/es/typography';
|
|
import Switch from 'antd/es/switch';
|
|
import message from 'antd/es/message';
|
|
import AntImage from 'antd/es/image';
|
|
import theme from 'antd/es/theme';
|
|
import Tabs from 'antd/es/tabs';
|
|
import Select from 'antd/es/select';
|
|
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
|
import type { UploadFile } from 'antd/es/upload/interface';
|
|
import MDEditor from '@uiw/react-md-editor';
|
|
import '@uiw/react-md-editor/markdown-editor.css';
|
|
import '@uiw/react-markdown-preview/markdown.css';
|
|
import {
|
|
Item, Category, Tag as TagRecord,
|
|
fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable, unpublishItem,
|
|
fetchAdminCategories, fetchAdminTags
|
|
} from '../api';
|
|
import { useThemeMode } from '../theme/ThemeContext';
|
|
import Customers from './Customers';
|
|
import Emails from './Emails';
|
|
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';
|
|
import InventoryFilters from './InventoryFilters';
|
|
import { ItemFilters, EMPTY_FILTERS } from '../filters';
|
|
import { uploadUrl } from '../uploadUrl';
|
|
|
|
const { Header, Content } = Layout;
|
|
const { Title } = Typography;
|
|
|
|
// Anything not sold or reserved is available, so green is the default rather
|
|
// than a third entry — a new status shows up green instead of crashing.
|
|
// Pending is grey rather than a colour: it is the absence of being published,
|
|
// not a state of its own worth drawing the eye to.
|
|
const STATUS_TAG_COLORS: Record<string, string> = { sold: 'red', reserved: 'orange', pending: 'default' };
|
|
|
|
function Inventory() {
|
|
const [items, setItems] = useState<Item[]>([]);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editingItem, setEditingItem] = useState<Item | null>(null);
|
|
// The item whose storefront appearance is being previewed, or null when the
|
|
// panel is closed. Holds the row object itself — admin and storefront share
|
|
// one Item type, so there is nothing to convert and nothing to drift.
|
|
const [previewItem, setPreviewItem] = useState<Item | null>(null);
|
|
const [form] = Form.useForm();
|
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
|
const [description, setDescription] = useState<string>('');
|
|
const [categories, setCategories] = useState<Category[]>([]);
|
|
const [tags, setTags] = useState<TagRecord[]>([]);
|
|
const [saving, setSaving] = useState(false);
|
|
const [filters, setFilters] = useState<ItemFilters>(EMPTY_FILTERS);
|
|
const { mode } = useThemeMode();
|
|
|
|
// Typing in the price fields fires a request per keystroke, so responses can
|
|
// arrive out of order and an older one can repaint stale rows over a newer
|
|
// result. Only the most recently issued request is allowed to set state.
|
|
const latestRequest = useRef(0);
|
|
// useCallback rather than a plain function so the effect below can depend on
|
|
// it honestly: a function rebuilt every render would either loop forever in
|
|
// the dependency array or have to be suppressed out of it.
|
|
const load = useCallback((active: ItemFilters = filters) => {
|
|
const seq = ++latestRequest.current;
|
|
return fetchAdminItems(active)
|
|
.then(rows => {
|
|
if (seq === latestRequest.current) setItems(rows);
|
|
})
|
|
// Without this the table simply keeps showing whatever it had, so a
|
|
// failed refetch after a save looks identical to a save that did not
|
|
// change anything.
|
|
.catch(() => message.error('Could not load items'));
|
|
}, [filters]);
|
|
|
|
// The item form needs the current category tree and tag list; both change
|
|
// from the sibling tabs, so they're refetched whenever the modal opens.
|
|
const loadOptions = useCallback(() => Promise.all([
|
|
fetchAdminCategories().then(setCategories),
|
|
fetchAdminTags().then(setTags)
|
|
]).catch(() => message.error('Could not load categories and tags')), []);
|
|
|
|
// Refetch whenever the filters change — filtering is server-side so the
|
|
// result stays correct regardless of how many items exist.
|
|
useEffect(() => { void load(filters); }, [load, filters]);
|
|
useEffect(() => { void loadOptions(); }, [loadOptions]);
|
|
|
|
function applyFilters(next: ItemFilters) { setFilters(next); }
|
|
function clearFilters() { setFilters(EMPTY_FILTERS); }
|
|
|
|
function openNew() {
|
|
setEditingItem(null);
|
|
form.resetFields();
|
|
setFileList([]);
|
|
setDescription('');
|
|
void loadOptions();
|
|
setModalOpen(true);
|
|
}
|
|
|
|
function openEdit(item: Item) {
|
|
setEditingItem(item);
|
|
form.setFieldsValue({
|
|
name: item.name,
|
|
price: item.price_cents / 100,
|
|
category_id: item.category_id ?? undefined,
|
|
tags: item.tags.map(tag => tag.name)
|
|
});
|
|
setFileList([]);
|
|
setDescription(item.description || '');
|
|
void loadOptions();
|
|
setModalOpen(true);
|
|
}
|
|
|
|
async function handleOk() {
|
|
const values = await form.validateFields();
|
|
const fd = new FormData();
|
|
fd.append('name', values.name);
|
|
fd.append('description', description);
|
|
fd.append('price', String(values.price));
|
|
// An empty string clears the category server-side; undefined would be sent
|
|
// as the literal text "undefined".
|
|
fd.append('category_id', values.category_id == null ? '' : String(values.category_id));
|
|
fd.append('tags', JSON.stringify(values.tags ?? []));
|
|
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
|
|
|
|
// Only report success, close the form, and discard the entered values once
|
|
// the server has actually accepted the write.
|
|
setSaving(true);
|
|
try {
|
|
await saveItem(editingItem?.id ?? null, fd);
|
|
} catch (err) {
|
|
message.error(`Couldn't save item — ${(err as Error).message}`);
|
|
return;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
|
|
message.success(editingItem ? 'Item updated' : 'Item added');
|
|
setModalOpen(false);
|
|
void load();
|
|
void loadOptions();
|
|
}
|
|
|
|
// Status changes silently did nothing on failure — the row simply stayed put
|
|
// with no indication why.
|
|
async function handleStatusChange(action: (id: number) => Promise<Item>, id: number, label: string) {
|
|
try {
|
|
await action(id);
|
|
} catch (err) {
|
|
message.error(`Couldn't ${label} — ${(err as Error).message}`);
|
|
return;
|
|
}
|
|
void load();
|
|
}
|
|
|
|
async function handleDelete(id: number) {
|
|
try {
|
|
await deleteItem(id);
|
|
} catch (err) {
|
|
message.error(`Couldn't delete item — ${(err as Error).message}`);
|
|
return;
|
|
}
|
|
message.success('Item deleted');
|
|
void load();
|
|
}
|
|
|
|
async function handleDeleteImage(itemId: number, imageId: number) {
|
|
try {
|
|
await deleteItemImage(itemId, imageId);
|
|
} catch (err) {
|
|
message.error(`Couldn't remove image — ${(err as Error).message}`);
|
|
return;
|
|
}
|
|
message.success('Image removed');
|
|
void load();
|
|
setEditingItem(prev => prev && prev.id === itemId
|
|
? { ...prev, images: prev.images.filter(img => img.id !== imageId) }
|
|
: prev);
|
|
}
|
|
|
|
const columns = [
|
|
{
|
|
title: 'Image',
|
|
dataIndex: 'images',
|
|
render: (images: Item['images']) =>
|
|
images[0] ? (
|
|
<span style={{ position: 'relative', display: 'inline-block' }}>
|
|
<img src={uploadUrl(images[0].image_path)} alt="" style={{ width: 60 }} />
|
|
{images.length > 1 && (
|
|
<Tag style={{ position: 'absolute', bottom: -4, right: -8, fontSize: 10 }}>+{images.length - 1}</Tag>
|
|
)}
|
|
</span>
|
|
) : null
|
|
},
|
|
{
|
|
title: 'Name',
|
|
dataIndex: 'name',
|
|
// A button rather than a clickable cell so it is reachable by keyboard
|
|
// and announces itself as an action.
|
|
render: (name: string, item: Item) => (
|
|
<Button type="link" style={{ padding: 0, height: 'auto', textAlign: 'left' }} onClick={() => setPreviewItem(item)}>
|
|
{name}
|
|
</Button>
|
|
)
|
|
},
|
|
{
|
|
title: 'Category',
|
|
dataIndex: 'category_name',
|
|
render: (name: string | null) => name || <span style={{ opacity: 0.45 }}>Uncategorized</span>
|
|
},
|
|
{
|
|
title: 'Tags',
|
|
dataIndex: 'tags',
|
|
render: (itemTags: Item['tags']) =>
|
|
itemTags.length
|
|
? itemTags.map(tag => <Tag key={tag.id} color={tag.color}>{tag.name}</Tag>)
|
|
: null
|
|
},
|
|
{ title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
|
{
|
|
title: 'Status',
|
|
dataIndex: 'status',
|
|
render: (status: string) => (
|
|
<Tag color={STATUS_TAG_COLORS[status] ?? 'green'}>{status.toUpperCase()}</Tag>
|
|
)
|
|
},
|
|
{
|
|
title: 'Actions',
|
|
render: (_: unknown, item: Item) => (
|
|
<Space>
|
|
<Button size="small" onClick={() => openEdit(item)}>Edit</Button>
|
|
<Button size="small" danger onClick={() => handleDelete(item.id)}>Delete</Button>
|
|
{item.status !== 'sold'
|
|
? <Button size="small" onClick={() => handleStatusChange(markSold, item.id, 'mark sold')}>Mark Sold</Button>
|
|
: <Button size="small" onClick={() => handleStatusChange(markAvailable, item.id, 'mark available')}>Mark Available</Button>}
|
|
{/* Publishing is mark-available under a name that says what it means
|
|
here. Unpublish is offered only from available — the server
|
|
refuses reserved and sold and says why, and hiding the button in
|
|
those states keeps the refusal from being the way you find out. */}
|
|
{item.status === 'pending' && (
|
|
<Button size="small" type="primary" onClick={() => handleStatusChange(markAvailable, item.id, 'publish')}>
|
|
Publish
|
|
</Button>
|
|
)}
|
|
{item.status === 'available' && (
|
|
<Button size="small" onClick={() => handleStatusChange(unpublishItem, item.id, 'unpublish')}>
|
|
Unpublish
|
|
</Button>
|
|
)}
|
|
</Space>
|
|
)
|
|
}
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
|
<Title level={4} style={{ margin: 0 }}>Inventory</Title>
|
|
<Button type="primary" onClick={openNew}>Add Item</Button>
|
|
</div>
|
|
|
|
<InventoryFilters
|
|
categories={categories}
|
|
tags={tags}
|
|
filters={filters}
|
|
onChange={applyFilters}
|
|
onClear={clearFilters}
|
|
resultCount={items.length}
|
|
/>
|
|
|
|
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
|
|
|
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} confirmLoading={saving} onCancel={() => setModalOpen(false)} destroyOnHidden width={720}>
|
|
<div data-color-mode={mode}>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item label="Description (Markdown supported)">
|
|
<MDEditor value={description} onChange={(val) => setDescription(val || '')} height={220} preview="live" />
|
|
</Form.Item>
|
|
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
|
|
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="category_id" label="Category">
|
|
<CategoryTreeSelect categories={categories} onCategoriesChanged={setCategories} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="tags"
|
|
label="Tags"
|
|
extra="Pick existing tags or type a new one and press Enter to create it."
|
|
>
|
|
<Select
|
|
mode="tags"
|
|
placeholder="Add tags"
|
|
style={{ width: '100%' }}
|
|
options={tags.map(tag => ({ value: tag.name, label: tag.name }))}
|
|
/>
|
|
</Form.Item>
|
|
{editingItem && editingItem.images.length > 0 && (
|
|
<Form.Item label="Existing Images (front / back / etc.)">
|
|
<Space wrap>
|
|
{editingItem.images.map(img => (
|
|
<div key={img.id} style={{ position: 'relative' }}>
|
|
<AntImage src={uploadUrl(img.image_path)} width={80} height={80} style={{ objectFit: 'cover' }} />
|
|
<Button size="small" danger icon={<DeleteOutlined />} style={{ position: 'absolute', top: 0, right: 0 }}
|
|
onClick={() => handleDeleteImage(editingItem.id, img.id)} />
|
|
</div>
|
|
))}
|
|
</Space>
|
|
</Form.Item>
|
|
)}
|
|
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
|
|
{/* The exact three types the server accepts, not image/*. Offering
|
|
a type the API will refuse — SVG, notably — turns a picker
|
|
choice into a 400 the admin has to decode. The operating
|
|
system's own "All files" option is unaffected: `accept`
|
|
chooses the default filter, it does not remove that escape
|
|
hatch, and it is not a control either way. The list is
|
|
enforced in backend/src/uploadTypes.ts; change both. */}
|
|
<Upload accept="image/jpeg,image/png,image/webp"
|
|
fileList={fileList} beforeUpload={() => false} onChange={({ fileList }) => setFileList(fileList.slice(-6))}
|
|
maxCount={6} multiple listType="picture-card">
|
|
<div><UploadOutlined /><div style={{ marginTop: 8 }}>Upload</div></div>
|
|
</Upload>
|
|
</Form.Item>
|
|
</Form>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* The real storefront card, rendered inert. Width is pinned to what the
|
|
storefront grid actually gives a card at its widest column, so the
|
|
proportions here match what a customer sees rather than stretching to
|
|
fill the drawer. */}
|
|
<Drawer
|
|
title={previewItem ? `Preview: ${previewItem.name}` : 'Preview'}
|
|
open={previewItem !== null}
|
|
onClose={() => setPreviewItem(null)}
|
|
width={420}
|
|
destroyOnHidden
|
|
>
|
|
{previewItem && (
|
|
<div style={{ maxWidth: 340, margin: '0 auto' }}>
|
|
{/* onChanged never fires: every handler that would call it is
|
|
short-circuited by `preview`. */}
|
|
{/* A pending item is previewed as it will look once published.
|
|
No customer ever sees a pending item, so rendering that state
|
|
would answer a question nobody is asking — what is wanted here
|
|
is "how will this look when it is live". */}
|
|
<ItemCard
|
|
item={previewItem.status === 'pending' ? { ...previewItem, status: 'available' } : previewItem}
|
|
onChanged={() => undefined}
|
|
preview
|
|
/>
|
|
</div>
|
|
)}
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Admin() {
|
|
const { mode, toggle } = useThemeMode();
|
|
const { token } = theme.useToken();
|
|
|
|
return (
|
|
<Layout style={{ minHeight: '100vh' }}>
|
|
<Header className="site-header" style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
|
<Title level={3} className="site-header-title" style={{ color: token.colorText }}>Admin</Title>
|
|
<div className="site-header-actions">
|
|
<BuildStamp />
|
|
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
|
|
</div>
|
|
</Header>
|
|
<Content style={{ padding: 24 }}>
|
|
<Tabs
|
|
defaultActiveKey="inventory"
|
|
items={[
|
|
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
|
{ 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 /> }
|
|
]}
|
|
/>
|
|
</Content>
|
|
</Layout>
|
|
);
|
|
}
|