183 lines
7.2 KiB
TypeScript
Executable File
183 lines
7.2 KiB
TypeScript
Executable File
import { useEffect, useState } from 'react';
|
|
import {
|
|
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
|
|
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs
|
|
} 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, fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable } from '../api';
|
|
import { useThemeMode } from '../theme/ThemeContext';
|
|
import Customers from './Customers';
|
|
import Settings from './Settings';
|
|
|
|
const { Header, Content } = Layout;
|
|
const { Title } = Typography;
|
|
|
|
function Inventory() {
|
|
const [items, setItems] = useState<Item[]>([]);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editingItem, setEditingItem] = useState<Item | null>(null);
|
|
const [form] = Form.useForm();
|
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
|
const [description, setDescription] = useState<string>('');
|
|
const { mode } = useThemeMode();
|
|
|
|
const load = () => fetchAdminItems().then(setItems);
|
|
useEffect(() => { load(); }, []);
|
|
|
|
function openNew() {
|
|
setEditingItem(null);
|
|
form.resetFields();
|
|
setFileList([]);
|
|
setDescription('');
|
|
setModalOpen(true);
|
|
}
|
|
|
|
function openEdit(item: Item) {
|
|
setEditingItem(item);
|
|
form.setFieldsValue({ name: item.name, price: item.price_cents / 100 });
|
|
setFileList([]);
|
|
setDescription(item.description || '');
|
|
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));
|
|
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
|
|
await saveItem(editingItem?.id ?? null, fd);
|
|
message.success(editingItem ? 'Item updated' : 'Item added');
|
|
setModalOpen(false);
|
|
load();
|
|
}
|
|
|
|
async function handleDelete(id: number) {
|
|
await deleteItem(id);
|
|
message.success('Item deleted');
|
|
load();
|
|
}
|
|
|
|
async function handleDeleteImage(itemId: number, imageId: number) {
|
|
await deleteItemImage(itemId, imageId);
|
|
message.success('Image removed');
|
|
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} 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' },
|
|
{ title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
|
{
|
|
title: 'Status',
|
|
dataIndex: 'status',
|
|
render: (status: string) => (
|
|
<Tag color={status === 'sold' ? 'red' : status === 'reserved' ? 'orange' : '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={() => markSold(item.id).then(load)}>Mark Sold</Button>
|
|
: <Button size="small" onClick={() => markAvailable(item.id).then(load)}>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>
|
|
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
|
|
|
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} onCancel={() => setModalOpen(false)} destroyOnClose 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>
|
|
{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>
|
|
</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: 'customers', label: 'Customers', children: <Customers /> },
|
|
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
|
]}
|
|
/>
|
|
</Content>
|
|
</Layout>
|
|
);
|
|
}
|