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 = { sold: 'red', reserved: 'orange' }; function Inventory() { const [items, setItems] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [editingItem, setEditingItem] = useState(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(null); const [form] = Form.useForm(); const [fileList, setFileList] = useState([]); const [description, setDescription] = useState(''); const [categories, setCategories] = useState([]); const [tags, setTags] = useState([]); const [saving, setSaving] = useState(false); const [filters, setFilters] = useState(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, 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] ? ( {images.length > 1 && ( +{images.length - 1} )} ) : 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) => ( ) }, { title: 'Category', dataIndex: 'category_name', render: (name: string | null) => name || Uncategorized }, { title: 'Tags', dataIndex: 'tags', render: (itemTags: Item['tags']) => itemTags.length ? itemTags.map(tag => {tag.name}) : null }, { title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` }, { title: 'Status', dataIndex: 'status', render: (status: string) => ( {status.toUpperCase()} ) }, { title: 'Actions', render: (_: unknown, item: Item) => ( {item.status !== 'sold' ? : } ) } ]; return (
Inventory
setModalOpen(false)} destroyOnHidden width={720}>
setDescription(val || '')} height={220} preview="live" />