fix(admin): theme, American English, and inventory/reservation tooling (#27)
Seven reported items, of which the first four had two root causes. The active tab was invisible in dark mode because colorPrimary was hardcoded to #1a1a1a in both themes. The accent now inverts with the theme, and colorTextLightSolid inverts with it, or a near-white accent would get antd's default white label and disappear. The Category tab, Tag tab, and item-form category selector ignored the theme entirely. antd declares main: lib/index.js and module: es/index.js, so importing from 'antd' resolves to the ES build while 'antd/lib/...' loads the CommonJS one — two copies, two React contexts, and no ConfigProvider for anything deep-imported. Switching those files to antd/es/* keeps the deep-import convention and shares the instance. This was introduced by my own use of the lib path; es is correct under Vite. Two storefront components had the same latent bug. "Colour" is now "Color". The Customers tab shows how many items each customer is holding, as a link opening the item list with a Release button. Release mirrors the customer's own cart removal — drop the cart row, return the item to available, guarded on 'reserved' so it can never resurrect a sold item — and deliberately sends no email about an action the customer did not take. The count is a subquery rather than another join, which would have multiplied rows and inflated order_count and total_spent_cents. The Inventory tab filters by category, tags, price, and status, reusing the storefront's parser and query builder so the two cannot drift. Reserved is one option in a Status filter rather than a standalone toggle. Also fixes two defects the screenshots exposed: the reserved-count link bubbled to the row handler and opened the customer drawer behind the dialog, and .admin-category-node had no CSS at all, so the tree node name, item count, and actions ran together as one string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Table, Drawer, Descriptions, Tag, Typography, Spin, Empty } from 'antd';
|
||||
import { Table, Drawer, Descriptions, Tag, Typography, Spin, Empty, Modal, Button, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { fetchCustomers, fetchCustomerDetail, CustomerSummary, CustomerDetail } from './adminCustomersApi';
|
||||
import {
|
||||
fetchCustomers, fetchCustomerDetail, fetchReservedItems, releaseReservedItem,
|
||||
CustomerSummary, CustomerDetail, ReservedItem
|
||||
} from './adminCustomersApi';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function Customers() {
|
||||
const [customers, setCustomers] = useState<CustomerSummary[]>([]);
|
||||
@@ -11,10 +14,16 @@ export default function Customers() {
|
||||
const [detail, setDetail] = useState<CustomerDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [reservedFor, setReservedFor] = useState<CustomerSummary | null>(null);
|
||||
const [reserved, setReserved] = useState<ReservedItem[]>([]);
|
||||
const [reservedLoading, setReservedLoading] = useState(false);
|
||||
const [releasing, setReleasing] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); });
|
||||
}, []);
|
||||
function load() {
|
||||
return fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); });
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function openDetail(id: number) {
|
||||
setDrawerOpen(true);
|
||||
@@ -24,6 +33,37 @@ export default function Customers() {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
|
||||
async function openReserved(customer: CustomerSummary) {
|
||||
setReservedFor(customer);
|
||||
setReservedLoading(true);
|
||||
try {
|
||||
setReserved(await fetchReservedItems(customer.id));
|
||||
} catch (err) {
|
||||
message.error((err as Error).message);
|
||||
setReserved([]);
|
||||
} finally {
|
||||
setReservedLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRelease(item: ReservedItem) {
|
||||
if (!reservedFor) return;
|
||||
setReleasing(item.item_id);
|
||||
try {
|
||||
await releaseReservedItem(reservedFor.id, item.item_id);
|
||||
} catch (err) {
|
||||
message.error(`Couldn't release "${item.name}" — ${(err as Error).message}`);
|
||||
return;
|
||||
} finally {
|
||||
setReleasing(null);
|
||||
}
|
||||
message.success(`Released "${item.name}"`);
|
||||
// Refresh both the popup and the row count behind it, so the count can't
|
||||
// disagree with the list it opened from.
|
||||
setReserved(await fetchReservedItems(reservedFor.id));
|
||||
load();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<CustomerSummary> = [
|
||||
{
|
||||
title: 'Customer',
|
||||
@@ -50,6 +90,25 @@ export default function Customers() {
|
||||
onFilter: (value, row) => row.marketing_consent === value,
|
||||
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? 'Yes' : 'No'}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Reserved',
|
||||
dataIndex: 'reserved_count',
|
||||
render: (count: number, customer: CustomerSummary) =>
|
||||
Number(count) > 0 ? (
|
||||
<Button
|
||||
type="link"
|
||||
style={{ padding: 0 }}
|
||||
// The whole row opens the customer drawer, so without this the
|
||||
// click reaches both handlers and the drawer opens behind the
|
||||
// reserved-items dialog.
|
||||
onClick={(event) => { event.stopPropagation(); openReserved(customer); }}
|
||||
>
|
||||
{count} item{Number(count) === 1 ? '' : 's'}
|
||||
</Button>
|
||||
) : (
|
||||
<Text type="secondary">0</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Orders',
|
||||
dataIndex: 'order_count',
|
||||
@@ -145,6 +204,54 @@ export default function Customers() {
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title={reservedFor ? `Items reserved by ${reservedFor.email}` : 'Reserved items'}
|
||||
open={reservedFor !== null}
|
||||
onCancel={() => setReservedFor(null)}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={640}
|
||||
>
|
||||
{reservedLoading ? <Spin /> : null}
|
||||
{!reservedLoading && !reserved.length ? (
|
||||
<Empty description="This customer isn't holding any items" />
|
||||
) : null}
|
||||
{!reservedLoading && reserved.length > 0 && (
|
||||
<Table
|
||||
rowKey="item_id"
|
||||
dataSource={reserved}
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: 'Item', dataIndex: 'name' },
|
||||
{
|
||||
title: 'Price',
|
||||
dataIndex: 'price_cents',
|
||||
render: (v: number) => `$${(v / 100).toFixed(2)}`
|
||||
},
|
||||
{
|
||||
title: 'Reservation expires',
|
||||
dataIndex: 'expires_at',
|
||||
render: (v: string) => new Date(v).toLocaleString()
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
render: (_: unknown, item: ReservedItem) => (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
loading={releasing === item.item_id}
|
||||
onClick={() => handleRelease(item)}
|
||||
>
|
||||
Release
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user