Files
redefined-designs/frontend/src/admin/Admin.tsx
T
bermudalambandClaude Opus 5 03f08074d1
SonarQube Analysis / sonarqube (pull_request) Failing after 34m39s
Tests / lint (pull_request) Successful in 5m14s
Tests / backend-unit (pull_request) Successful in 1m38s
Tests / frontend-e2e (pull_request) Failing after 23m16s
feat(frontend): preview an inventory item as a customer sees it (#89)
Clicking an item's name in the admin Inventory opens a drawer rendering the real storefront ItemCard for it, so the way a listing will look can be checked without publishing it and going to see.

The name cell is a link-styled button rather than a clickable cell, so it stays reachable by keyboard and announces itself as an action. Admin already imports Item from ../api, the same type the storefront uses, so the row object goes straight into the card with no adapter and nothing to drift.

The part that needed care is that ItemCard is not a passive component. It wires into the cart and favorites contexts and has working buttons, and both providers wrap the whole app — so a naive preview would have been fully functional, and an admin browsing inventory could have added their own stock to their own cart. On a one-of-a-kind catalogue that reserves the item and takes it off sale.

ItemCard therefore takes an optional preview prop that short-circuits its two click handlers. Those two are the only entry points, so guarding them also covers the shared auth modal and the favorite-alerts consent prompt hanging off them.

Deliberately not `disabled` on the buttons. A disabled antd button renders in a different colour with a different cursor and no hover, and the whole point of this panel is to show what a customer will actually see. The controls keep their normal appearance and their correct state for the item's status; only the handlers stop. The comment on the prop says so, because "simplifying" this to a disabled button would quietly defeat the feature while appearing to implement it.

Four end-to-end tests, two of which are the ones worth having. Clicking Add to Cart in the preview must do nothing — asserted by the sign-in prompt never appearing, which a real click on a signed-out card always raises, so its absence proves the handler stopped before doing any work. And the storefront card must still be live where it is actually used, or this change would have quietly broken buying things.

ItemCard's props are now Readonly, which was an existing lint warning on a file this change already touches: frontend warnings drop from 31 to 30.

Verified: build clean, lint 0 errors, 91 end-to-end tests passing against a freshly created database.

Refs #89
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:41:59 -05:00

352 lines
14 KiB
TypeScript
Executable File

import { useCallback, useEffect, useRef, useState } from 'react';
import {
Layout, Table, Button, Drawer, Form, Input, InputNumber, Upload, Modal,
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
Select
} from 'antd';
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,
fetchAdminCategories, fetchAdminTags
} from '../api';
import { useThemeMode } from '../theme/ThemeContext';
import Customers from './Customers';
import Settings from './Settings';
import Categories from './Categories';
import Tags from './Tags';
import CategoryTreeSelect from './CategoryTreeSelect';
import ItemCard from '../components/ItemCard';
import InventoryFilters from './InventoryFilters';
import { ItemFilters, EMPTY_FILTERS } from '../filters';
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.
const STATUS_TAG_COLORS: Record<string, string> = { sold: 'red', reserved: 'orange' };
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={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>}
</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}
/>
<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={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.)'}>
<Upload 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`. */}
<ItemCard item={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">
<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: 'customers', label: 'Customers', children: <Customers /> },
{ key: 'settings', label: 'Settings', children: <Settings /> }
]}
/>
</Content>
</Layout>
);
}