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:
@@ -9,8 +9,16 @@ export interface ItemFilters {
|
||||
tagIds: number[];
|
||||
minPriceCents: number | null;
|
||||
maxPriceCents: number | null;
|
||||
status: ItemStatus | null;
|
||||
}
|
||||
|
||||
export type ItemStatus = 'available' | 'reserved' | 'sold';
|
||||
|
||||
// Matched exactly, not case-insensitively: `items.status` only ever holds these
|
||||
// lowercase values, so accepting 'Reserved' would quietly return nothing rather
|
||||
// than reporting that the filter was wrong.
|
||||
const ITEM_STATUSES: readonly string[] = ['available', 'reserved', 'sold'];
|
||||
|
||||
export interface BuiltFilter {
|
||||
clauses: string[];
|
||||
params: unknown[];
|
||||
@@ -90,7 +98,16 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
||||
throw new FilterError('min_price may not exceed max_price');
|
||||
}
|
||||
|
||||
return { categoryId, tagIds, minPriceCents, maxPriceCents };
|
||||
const statusRaw = singleValue(query.status, 'status');
|
||||
let status: ItemStatus | null = null;
|
||||
if (statusRaw !== null && statusRaw !== '') {
|
||||
if (!ITEM_STATUSES.includes(statusRaw)) {
|
||||
throw new FilterError('invalid status');
|
||||
}
|
||||
status = statusRaw as ItemStatus;
|
||||
}
|
||||
|
||||
return { categoryId, tagIds, minPriceCents, maxPriceCents, status };
|
||||
}
|
||||
|
||||
// Returns WHERE fragments plus their parameters, with placeholders numbered
|
||||
@@ -141,5 +158,11 @@ export function buildItemFilterSql(filters: ItemFilters, startIndex: number): Bu
|
||||
next++;
|
||||
}
|
||||
|
||||
if (filters.status !== null) {
|
||||
params.push(filters.status);
|
||||
clauses.push(`i.status = $${next}`);
|
||||
next++;
|
||||
}
|
||||
|
||||
return { clauses, params };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { randomUUID } from 'crypto';
|
||||
import { PoolClient } from 'pg';
|
||||
import { pool } from '../db';
|
||||
import { ADMIN_ITEM_SELECT } from '../itemSelect';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
|
||||
import { tagColorFor } from '../utils';
|
||||
|
||||
const router = Router();
|
||||
@@ -111,10 +113,25 @@ async function setItemTags(client: PoolClient, itemId: number, tagIds: number[])
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/items', async (_req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ORDER BY i.created_at DESC`);
|
||||
router.get('/items', asyncRoute(async (req: Request, res: Response) => {
|
||||
// Same parser and query builder as the storefront, so admin filtering cannot
|
||||
// drift from what customers see. The one addition is `status`, which is how
|
||||
// the Inventory tab surfaces Reserved.
|
||||
let filters;
|
||||
try {
|
||||
filters = parseItemFilters(req.query as Record<string, unknown>);
|
||||
} catch (err) {
|
||||
if (err instanceof FilterError) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { clauses, params } = buildItemFilterSql(filters, 1);
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
|
||||
res.json(rows);
|
||||
});
|
||||
}));
|
||||
|
||||
router.post('/items', uploadImages, async (req: Request, res: Response) => {
|
||||
const { name, description, price } = req.body;
|
||||
|
||||
@@ -1,24 +1,80 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at,
|
||||
COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count,
|
||||
COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents,
|
||||
MAX(o.created_at) AS last_order_at
|
||||
MAX(o.created_at) AS last_order_at,
|
||||
-- Counted with a subquery rather than another LEFT JOIN: joining a second
|
||||
-- one-to-many relation alongside orders would multiply the rows and
|
||||
-- inflate order_count and total_spent_cents.
|
||||
(SELECT COUNT(*)::int
|
||||
FROM cart_items ci
|
||||
JOIN carts ca ON ca.id = ci.cart_id
|
||||
JOIN items i ON i.id = ci.item_id
|
||||
WHERE ca.customer_id = c.id AND i.status = 'reserved') AS reserved_count
|
||||
FROM customers c
|
||||
LEFT JOIN orders o ON o.customer_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.created_at DESC
|
||||
`);
|
||||
res.json(rows);
|
||||
});
|
||||
}));
|
||||
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
router.get('/:id/reserved', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT i.id AS item_id, i.name, i.price_cents, ci.added_at, ci.expires_at
|
||||
FROM cart_items ci
|
||||
JOIN carts ca ON ca.id = ci.cart_id
|
||||
JOIN items i ON i.id = ci.item_id
|
||||
WHERE ca.customer_id = $1 AND i.status = 'reserved'
|
||||
ORDER BY ci.added_at`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
// Mirrors the customer's own cart removal: drop the cart row and return the
|
||||
// item to available. Deliberately no email — this is an action the customer
|
||||
// did not take, and an unprompted "we removed your item" invites confusion.
|
||||
router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res: Response) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(
|
||||
`DELETE FROM cart_items ci
|
||||
USING carts ca
|
||||
WHERE ci.cart_id = ca.id AND ca.customer_id = $1 AND ci.item_id = $2
|
||||
RETURNING ci.item_id`,
|
||||
[req.params.id, req.params.itemId]
|
||||
);
|
||||
if (!rows.length) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'that customer is not holding this item' });
|
||||
}
|
||||
// Guarded on 'reserved' so releasing never resurrects a sold item.
|
||||
await client.query(
|
||||
`UPDATE items SET status = 'available', reserved_until = NULL
|
||||
WHERE id = $1 AND status = 'reserved'`,
|
||||
[req.params.itemId]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}));
|
||||
|
||||
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows: customerRows } = await pool.query(
|
||||
`SELECT id, email, name, email_verified, marketing_consent, marketing_consent_at, created_at
|
||||
FROM customers WHERE id = $1`,
|
||||
@@ -35,6 +91,6 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||
);
|
||||
|
||||
res.json({ customer: customerRows[0], orders: orderRows });
|
||||
});
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user