fix(admin): theme, American English, and inventory/reservation tooling (#27)
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

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:
2026-08-17 15:49:05 -05:00
co-authored by Claude Opus 5
parent b6ef2bb63c
commit f537314259
22 changed files with 1027 additions and 65 deletions
+61 -5
View File
@@ -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;