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>
258 lines
9.0 KiB
TypeScript
Executable File
258 lines
9.0 KiB
TypeScript
Executable File
import { useEffect, useState } from 'react';
|
|
import { Table, Drawer, Descriptions, Tag, Typography, Spin, Empty, Modal, Button, message } from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import {
|
|
fetchCustomers, fetchCustomerDetail, fetchReservedItems, releaseReservedItem,
|
|
CustomerSummary, CustomerDetail, ReservedItem
|
|
} from './adminCustomersApi';
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
export default function Customers() {
|
|
const [customers, setCustomers] = useState<CustomerSummary[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
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);
|
|
|
|
function load() {
|
|
return fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); });
|
|
}
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
async function openDetail(id: number) {
|
|
setDrawerOpen(true);
|
|
setDetailLoading(true);
|
|
const data = await fetchCustomerDetail(id);
|
|
setDetail(data);
|
|
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',
|
|
dataIndex: 'email',
|
|
sorter: (a, b) => a.email.localeCompare(b.email),
|
|
render: (email: string, row: CustomerSummary) => (
|
|
<div>
|
|
<div>{row.name || <span style={{ opacity: 0.5 }}>No name</span>}</div>
|
|
<div style={{ fontSize: 12, opacity: 0.65 }}>{email}</div>
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
title: 'Verified',
|
|
dataIndex: 'email_verified',
|
|
filters: [{ text: 'Verified', value: true }, { text: 'Unverified', value: false }],
|
|
onFilter: (value, row) => row.email_verified === value,
|
|
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? 'Verified' : 'Unverified'}</Tag>
|
|
},
|
|
{
|
|
title: 'Subscribed',
|
|
dataIndex: 'marketing_consent',
|
|
filters: [{ text: 'Subscribed', value: true }, { text: 'Not subscribed', value: false }],
|
|
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',
|
|
sorter: (a, b) => a.order_count - b.order_count,
|
|
defaultSortOrder: 'descend'
|
|
},
|
|
{
|
|
title: 'Total Spent',
|
|
dataIndex: 'total_spent_cents',
|
|
sorter: (a, b) => a.total_spent_cents - b.total_spent_cents,
|
|
render: (v: number) => `$${(v / 100).toFixed(2)}`
|
|
},
|
|
{
|
|
title: 'Last Order',
|
|
dataIndex: 'last_order_at',
|
|
sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(),
|
|
render: (v: string | null) => (v ? new Date(v).toLocaleDateString() : '—')
|
|
},
|
|
{
|
|
title: 'Joined',
|
|
dataIndex: 'created_at',
|
|
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
render: (v: string) => new Date(v).toLocaleDateString()
|
|
}
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Title level={4}>Customers</Title>
|
|
<Table
|
|
rowKey="id"
|
|
loading={loading}
|
|
dataSource={customers}
|
|
columns={columns}
|
|
onRow={row => ({ onClick: () => openDetail(row.id), style: { cursor: 'pointer' } })}
|
|
pagination={{ pageSize: 10 }}
|
|
/>
|
|
|
|
<Drawer
|
|
title={detail?.customer.name || detail?.customer.email || 'Customer'}
|
|
open={drawerOpen}
|
|
onClose={() => { setDrawerOpen(false); setDetail(null); }}
|
|
width={480}
|
|
>
|
|
{detailLoading || !detail ? (
|
|
<Spin />
|
|
) : (
|
|
<>
|
|
<Descriptions column={1} size="small" bordered>
|
|
<Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item>
|
|
<Descriptions.Item label="Verified">
|
|
<Tag color={detail.customer.email_verified ? 'green' : 'default'}>
|
|
{detail.customer.email_verified ? 'Verified' : 'Unverified'}
|
|
</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Marketing consent">
|
|
<Tag color={detail.customer.marketing_consent ? 'blue' : 'default'}>
|
|
{detail.customer.marketing_consent ? 'Subscribed' : 'Not subscribed'}
|
|
</Tag>
|
|
{detail.customer.marketing_consent_at && (
|
|
<div style={{ fontSize: 12, opacity: 0.65, marginTop: 4 }}>
|
|
since {new Date(detail.customer.marketing_consent_at).toLocaleDateString()}
|
|
</div>
|
|
)}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Joined">
|
|
{new Date(detail.customer.created_at).toLocaleDateString()}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
|
|
<Title level={5} style={{ marginTop: 24 }}>Order History</Title>
|
|
{detail.orders.length === 0 ? (
|
|
<Empty description="No orders yet" />
|
|
) : (
|
|
<Table
|
|
rowKey="id"
|
|
size="small"
|
|
dataSource={detail.orders}
|
|
pagination={false}
|
|
columns={[
|
|
{ title: 'Item', dataIndex: 'item_name' },
|
|
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
|
{
|
|
title: 'Processor',
|
|
dataIndex: 'processor',
|
|
render: (v: string) => <Tag>{v}</Tag>
|
|
},
|
|
{ title: 'Status', dataIndex: 'status' },
|
|
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
|
|
]}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|