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:
@@ -20,6 +20,8 @@ import Settings from './Settings';
|
||||
import Categories from './Categories';
|
||||
import Tags from './Tags';
|
||||
import CategoryTreeSelect from './CategoryTreeSelect';
|
||||
import InventoryFilters from './InventoryFilters';
|
||||
import { ItemFilters, EMPTY_FILTERS } from '../filters';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
const { Title } = Typography;
|
||||
@@ -34,9 +36,10 @@ function Inventory() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [filters, setFilters] = useState<ItemFilters>(EMPTY_FILTERS);
|
||||
const { mode } = useThemeMode();
|
||||
|
||||
const load = () => fetchAdminItems().then(setItems);
|
||||
const load = (active: ItemFilters = filters) => fetchAdminItems(active).then(setItems);
|
||||
|
||||
// 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.
|
||||
@@ -45,7 +48,13 @@ function Inventory() {
|
||||
fetchAdminTags().then(setTags)
|
||||
]);
|
||||
|
||||
useEffect(() => { load(); loadOptions(); }, []);
|
||||
// Refetch whenever the filters change — filtering is server-side so the
|
||||
// result stays correct regardless of how many items exist.
|
||||
useEffect(() => { load(filters); }, [filters]);
|
||||
useEffect(() => { loadOptions(); }, []);
|
||||
|
||||
function applyFilters(next: ItemFilters) { setFilters(next); }
|
||||
function clearFilters() { setFilters(EMPTY_FILTERS); }
|
||||
|
||||
function openNew() {
|
||||
setEditingItem(null);
|
||||
@@ -193,6 +202,15 @@ function Inventory() {
|
||||
<Title level={4} style={{ margin: 0 }}>Inventory</Title>
|
||||
<Button type="primary" onClick={openNew}>Add Item</Button>
|
||||
</div>
|
||||
|
||||
<InventoryFilters
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
filters={filters}
|
||||
onChange={applyFilters}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
|
||||
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
||||
|
||||
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} confirmLoading={saving} onCancel={() => setModalOpen(false)} destroyOnHidden width={720}>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import Tree from 'antd/lib/tree';
|
||||
import Button from 'antd/lib/button';
|
||||
import Input from 'antd/lib/input';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Select from 'antd/lib/select';
|
||||
import Space from 'antd/lib/space';
|
||||
import Typography from 'antd/lib/typography';
|
||||
import Empty from 'antd/lib/empty';
|
||||
import Spin from 'antd/lib/spin';
|
||||
import message from 'antd/lib/message';
|
||||
import Tree from 'antd/es/tree';
|
||||
import Button from 'antd/es/button';
|
||||
import Input from 'antd/es/input';
|
||||
import Modal from 'antd/es/modal';
|
||||
import Select from 'antd/es/select';
|
||||
import Space from 'antd/es/space';
|
||||
import Typography from 'antd/es/typography';
|
||||
import Empty from 'antd/es/empty';
|
||||
import Spin from 'antd/es/spin';
|
||||
import message from 'antd/es/message';
|
||||
import type { DataNode, TreeProps } from 'antd/es/tree';
|
||||
import {
|
||||
Category,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import TreeSelect from 'antd/lib/tree-select';
|
||||
import Input from 'antd/lib/input';
|
||||
import Button from 'antd/lib/button';
|
||||
import Divider from 'antd/lib/divider';
|
||||
import message from 'antd/lib/message';
|
||||
import TreeSelect from 'antd/es/tree-select';
|
||||
import Input from 'antd/es/input';
|
||||
import Button from 'antd/es/button';
|
||||
import Divider from 'antd/es/divider';
|
||||
import message from 'antd/es/message';
|
||||
import { Category, createCategory, fetchAdminCategories } from '../api';
|
||||
import { buildCategoryTree, CategoryNode } from '../filters';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useMemo } from 'react';
|
||||
import TreeSelect from 'antd/es/tree-select';
|
||||
import Select from 'antd/es/select';
|
||||
import InputNumber from 'antd/es/input-number';
|
||||
import Button from 'antd/es/button';
|
||||
import type { Category, Tag } from '../api';
|
||||
import { ItemFilters, ItemStatus, buildCategoryTree, CategoryNode, hasActiveFilters } from '../filters';
|
||||
|
||||
interface CategoryTreeOption {
|
||||
value: number;
|
||||
title: string;
|
||||
children?: CategoryTreeOption[];
|
||||
}
|
||||
|
||||
function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||
return nodes.map((node) => ({
|
||||
value: node.id,
|
||||
title: node.name,
|
||||
children: node.children.length ? toTreeData(node.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: { value: ItemStatus; label: string }[] = [
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'reserved', label: 'Reserved' },
|
||||
{ value: 'sold', label: 'Sold' }
|
||||
];
|
||||
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
tags: Tag[];
|
||||
filters: ItemFilters;
|
||||
onChange: (filters: ItemFilters) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
|
||||
const dollarsToCents = (dollars: number | null): number | null =>
|
||||
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
|
||||
|
||||
// An always-visible row rather than the storefront's drawer: this sits above a
|
||||
// data table, where hiding the controls behind a click costs more than the
|
||||
// space it saves, and a drawer would overlay the very rows being filtered.
|
||||
export default function InventoryFilters({ categories, tags, filters, onChange, onClear }: Props) {
|
||||
const treeData = useMemo(() => toTreeData(buildCategoryTree(categories)), [categories]);
|
||||
|
||||
return (
|
||||
<div className="inventory-filters">
|
||||
<TreeSelect
|
||||
allowClear
|
||||
showSearch
|
||||
treeNodeFilterProp="title"
|
||||
listHeight={256}
|
||||
placeholder="Any category"
|
||||
aria-label="Filter by category"
|
||||
style={{ minWidth: 200 }}
|
||||
treeData={treeData}
|
||||
value={filters.categoryId ?? undefined}
|
||||
onChange={(value) => onChange({ ...filters, categoryId: value ?? null })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
allowClear
|
||||
mode="multiple"
|
||||
placeholder="Any tags"
|
||||
aria-label="Filter by tags"
|
||||
style={{ minWidth: 200 }}
|
||||
value={filters.tagIds}
|
||||
onChange={(value: number[]) => onChange({ ...filters, tagIds: value })}
|
||||
options={tags.map((tag) => ({ value: tag.id, label: tag.name }))}
|
||||
/>
|
||||
|
||||
<InputNumber
|
||||
aria-label="Minimum price"
|
||||
prefix="$"
|
||||
min={0}
|
||||
placeholder="Min"
|
||||
style={{ width: 110 }}
|
||||
value={centsToDollars(filters.minPriceCents)}
|
||||
onChange={(value) => onChange({ ...filters, minPriceCents: dollarsToCents(value) })}
|
||||
/>
|
||||
<span style={{ opacity: 0.6 }}>to</span>
|
||||
<InputNumber
|
||||
aria-label="Maximum price"
|
||||
prefix="$"
|
||||
min={0}
|
||||
placeholder="Max"
|
||||
style={{ width: 110 }}
|
||||
value={centsToDollars(filters.maxPriceCents)}
|
||||
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="Any status"
|
||||
aria-label="Filter by status"
|
||||
style={{ minWidth: 150 }}
|
||||
value={filters.status ?? undefined}
|
||||
onChange={(value: ItemStatus | undefined) => onChange({ ...filters, status: value ?? null })}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
|
||||
{hasActiveFilters(filters) && <Button onClick={onClear}>Clear filters</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
-12
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Table from 'antd/lib/table';
|
||||
import Button from 'antd/lib/button';
|
||||
import Input from 'antd/lib/input';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Select from 'antd/lib/select';
|
||||
import Space from 'antd/lib/space';
|
||||
import Tag from 'antd/lib/tag';
|
||||
import Typography from 'antd/lib/typography';
|
||||
import message from 'antd/lib/message';
|
||||
import Table from 'antd/es/table';
|
||||
import Button from 'antd/es/button';
|
||||
import Input from 'antd/es/input';
|
||||
import Modal from 'antd/es/modal';
|
||||
import Select from 'antd/es/select';
|
||||
import Space from 'antd/es/space';
|
||||
import Tag from 'antd/es/tag';
|
||||
import Typography from 'antd/es/typography';
|
||||
import message from 'antd/es/message';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Tag as TagRecord, fetchAdminTags, createTag, updateTag, deleteTag } from '../api';
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function Tags() {
|
||||
await updateTag(editing.id, { name: trimmed, color });
|
||||
message.success('Tag updated');
|
||||
} else {
|
||||
// New tags take the colour the server derives from the name; it can be
|
||||
// New tags take the color the server derives from the name; it can be
|
||||
// overridden straight afterwards by editing.
|
||||
await createTag(trimmed);
|
||||
message.success('Tag added');
|
||||
@@ -94,7 +94,7 @@ export default function Tags() {
|
||||
dataIndex: 'name',
|
||||
render: (_: string, tag) => <Tag color={tag.color}>{tag.name}</Tag>
|
||||
},
|
||||
{ title: 'Colour', dataIndex: 'color' },
|
||||
{ title: 'Color', dataIndex: 'color' },
|
||||
{ title: 'Items', dataIndex: 'item_count' },
|
||||
{
|
||||
title: 'Actions',
|
||||
@@ -133,7 +133,7 @@ export default function Tags() {
|
||||
/>
|
||||
{editing && (
|
||||
<>
|
||||
<label htmlFor="tag-color">Colour</label>
|
||||
<label htmlFor="tag-color">Color</label>
|
||||
<Select
|
||||
id="tag-color"
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -8,6 +8,15 @@ export interface CustomerSummary {
|
||||
order_count: number;
|
||||
total_spent_cents: number;
|
||||
last_order_at: string | null;
|
||||
reserved_count: number;
|
||||
}
|
||||
|
||||
export interface ReservedItem {
|
||||
item_id: number;
|
||||
name: string;
|
||||
price_cents: number;
|
||||
added_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface CustomerOrder {
|
||||
@@ -42,3 +51,21 @@ export async function fetchCustomerDetail(id: number): Promise<CustomerDetail> {
|
||||
const res = await fetch(`/api/admin/customers/${id}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchReservedItems(customerId: number): Promise<ReservedItem[]> {
|
||||
const res = await fetch(`/api/admin/customers/${customerId}/reserved`);
|
||||
if (!res.ok) throw new Error('failed to load reserved items');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function releaseReservedItem(customerId: number, itemId: number): Promise<void> {
|
||||
const res = await fetch(`/api/admin/customers/${customerId}/reserved/${itemId}/release`, {
|
||||
method: 'POST'
|
||||
});
|
||||
// Reporting success for a release that failed would leave the item held with
|
||||
// nothing to indicate why.
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({}));
|
||||
throw new Error(detail.error || 'failed to release item');
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -77,8 +77,12 @@ async function expectOk(res: Response, action: string): Promise<Response> {
|
||||
throw new Error(detail?.error ? `${action}: ${detail.error}` : action);
|
||||
}
|
||||
|
||||
export async function fetchAdminItems(): Promise<Item[]> {
|
||||
const res = await expectOk(await fetch('/api/admin/items'), 'failed to load items');
|
||||
export async function fetchAdminItems(filters?: ItemFilters): Promise<Item[]> {
|
||||
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
||||
const res = await expectOk(
|
||||
await fetch(query ? `/api/admin/items?${query}` : '/api/admin/items'),
|
||||
'failed to load items'
|
||||
);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Tag from 'antd/lib/tag';
|
||||
import Button from 'antd/lib/button';
|
||||
import Tag from 'antd/es/tag';
|
||||
import Button from 'antd/es/button';
|
||||
import type { FilterOptions } from '../api';
|
||||
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Drawer from 'antd/lib/drawer';
|
||||
import Button from 'antd/lib/button';
|
||||
import Tree from 'antd/lib/tree';
|
||||
import Tag from 'antd/lib/tag';
|
||||
import Slider from 'antd/lib/slider';
|
||||
import InputNumber from 'antd/lib/input-number';
|
||||
import Empty from 'antd/lib/empty';
|
||||
import Grid from 'antd/lib/grid';
|
||||
import Drawer from 'antd/es/drawer';
|
||||
import Button from 'antd/es/button';
|
||||
import Tree from 'antd/es/tree';
|
||||
import Tag from 'antd/es/tag';
|
||||
import Slider from 'antd/es/slider';
|
||||
import InputNumber from 'antd/es/input-number';
|
||||
import Empty from 'antd/es/empty';
|
||||
import Grid from 'antd/es/grid';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { FilterOptions } from '../api';
|
||||
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import Card from 'antd/lib/card';
|
||||
import Typography from 'antd/lib/typography';
|
||||
import Alert from 'antd/lib/alert';
|
||||
import Button from 'antd/lib/button';
|
||||
import Spin from 'antd/lib/spin';
|
||||
import Card from 'antd/es/card';
|
||||
import Typography from 'antd/es/typography';
|
||||
import Alert from 'antd/es/alert';
|
||||
import Button from 'antd/es/button';
|
||||
import Spin from 'antd/es/spin';
|
||||
import { verifyEmail } from './customerApi';
|
||||
import { useCustomerAuth } from './CustomerAuthContext';
|
||||
|
||||
|
||||
+16
-2
@@ -1,17 +1,23 @@
|
||||
import type { Category } from './api';
|
||||
|
||||
export type ItemStatus = 'available' | 'reserved' | 'sold';
|
||||
|
||||
export interface ItemFilters {
|
||||
categoryId: number | null;
|
||||
tagIds: number[];
|
||||
minPriceCents: number | null;
|
||||
maxPriceCents: number | null;
|
||||
// Only the admin Inventory tab sets this; the storefront leaves it null and
|
||||
// shows every status, as it always has.
|
||||
status: ItemStatus | null;
|
||||
}
|
||||
|
||||
export const EMPTY_FILTERS: ItemFilters = {
|
||||
categoryId: null,
|
||||
tagIds: [],
|
||||
minPriceCents: null,
|
||||
maxPriceCents: null
|
||||
maxPriceCents: null,
|
||||
status: null
|
||||
};
|
||||
|
||||
// Filters live in the URL so a filtered view can be linked, bookmarked, and
|
||||
@@ -23,6 +29,7 @@ export function filtersToSearchParams(filters: ItemFilters): URLSearchParams {
|
||||
if (filters.tagIds.length) params.set('tags', filters.tagIds.join(','));
|
||||
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
|
||||
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
|
||||
if (filters.status !== null) params.set('status', filters.status);
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -38,11 +45,17 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
||||
.map((part) => readInt(part))
|
||||
.filter((id): id is number => id !== null && id > 0);
|
||||
|
||||
const rawStatus = params.get('status');
|
||||
const status = rawStatus === 'available' || rawStatus === 'reserved' || rawStatus === 'sold'
|
||||
? rawStatus
|
||||
: null;
|
||||
|
||||
return {
|
||||
categoryId: readInt(params.get('category')),
|
||||
tagIds: tags,
|
||||
minPriceCents: readInt(params.get('min_price')),
|
||||
maxPriceCents: readInt(params.get('max_price'))
|
||||
maxPriceCents: readInt(params.get('max_price')),
|
||||
status
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +66,7 @@ export function activeFilterCount(filters: ItemFilters): number {
|
||||
if (filters.categoryId !== null) count++;
|
||||
count += filters.tagIds.length;
|
||||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
||||
if (filters.status !== null) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -16,6 +16,11 @@ import { CartProvider } from './cart/CartContext';
|
||||
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
|
||||
import './styles.css';
|
||||
|
||||
// The brand accent is monochrome, so it inverts between themes rather than
|
||||
// switching to a different hue.
|
||||
const LIGHT_ACCENT = '#1a1a1a';
|
||||
const DARK_ACCENT = '#f0f0f0';
|
||||
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
// Respects the OS-level "reduce motion" accessibility setting by turning off
|
||||
@@ -44,7 +49,17 @@ function Root() {
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
token: { colorPrimary: '#1a1a1a', motion: !prefersReducedMotion }
|
||||
token: {
|
||||
// The accent inverts with the theme rather than staying near-black.
|
||||
// Held constant, it rendered the active tab label in #1a1a1a on a
|
||||
// dark background — invisible.
|
||||
colorPrimary: mode === 'dark' ? DARK_ACCENT : LIGHT_ACCENT,
|
||||
// Text drawn *on* the accent (primary buttons, selected rows) has to
|
||||
// invert with it too, or a near-white accent gets antd's default
|
||||
// white label and disappears.
|
||||
colorTextLightSolid: mode === 'dark' ? LIGHT_ACCENT : '#ffffff',
|
||||
motion: !prefersReducedMotion
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -115,3 +115,30 @@ body { margin: 0; }
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Admin inventory filter row — always visible above the table, wrapping onto
|
||||
further lines on narrow screens rather than scrolling horizontally. */
|
||||
.inventory-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Admin category tree node: name, item count, then actions pushed to the
|
||||
right. Without this the three run together as one unbroken string. */
|
||||
.admin-category-node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-category-node > span:first-child {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-category-node .ant-space {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user