Files
redefined-designs/frontend/src/admin/Customers.tsx
T
bermudalambandClaude Opus 5 f537314259
SonarQube Analysis / sonarqube (pull_request) Successful in 4m23s
Tests / backend-unit (pull_request) Successful in 1m6s
Tests / backend-integration (pull_request) Failing after 4m55s
Tests / frontend-e2e (pull_request) Failing after 12m41s
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>
2026-08-17 15:49:05 -05:00

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>
);
}