Merge pull request 'fix(admin): theme, American English, and inventory/reservation tooling (#27)' (#30) from fix/admin-theme-and-filters into main
Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
@@ -9,8 +9,16 @@ export interface ItemFilters {
|
|||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
minPriceCents: number | null;
|
minPriceCents: number | null;
|
||||||
maxPriceCents: 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 {
|
export interface BuiltFilter {
|
||||||
clauses: string[];
|
clauses: string[];
|
||||||
params: unknown[];
|
params: unknown[];
|
||||||
@@ -90,7 +98,16 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
|||||||
throw new FilterError('min_price may not exceed max_price');
|
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
|
// Returns WHERE fragments plus their parameters, with placeholders numbered
|
||||||
@@ -141,5 +158,11 @@ export function buildItemFilterSql(filters: ItemFilters, startIndex: number): Bu
|
|||||||
next++;
|
next++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.status !== null) {
|
||||||
|
params.push(filters.status);
|
||||||
|
clauses.push(`i.status = $${next}`);
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
|
||||||
return { clauses, params };
|
return { clauses, params };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { randomUUID } from 'crypto';
|
|||||||
import { PoolClient } from 'pg';
|
import { PoolClient } from 'pg';
|
||||||
import { pool } from '../db';
|
import { pool } from '../db';
|
||||||
import { ADMIN_ITEM_SELECT } from '../itemSelect';
|
import { ADMIN_ITEM_SELECT } from '../itemSelect';
|
||||||
|
import { asyncRoute } from '../asyncRoute';
|
||||||
|
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
|
||||||
import { tagColorFor } from '../utils';
|
import { tagColorFor } from '../utils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -111,10 +113,25 @@ async function setItemTags(client: PoolClient, itemId: number, tagIds: number[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/items', async (_req: Request, res: Response) => {
|
router.get('/items', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ORDER BY i.created_at DESC`);
|
// 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);
|
res.json(rows);
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.post('/items', uploadImages, async (req: Request, res: Response) => {
|
router.post('/items', uploadImages, async (req: Request, res: Response) => {
|
||||||
const { name, description, price } = req.body;
|
const { name, description, price } = req.body;
|
||||||
|
|||||||
@@ -1,24 +1,80 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { pool } from '../db';
|
import { pool } from '../db';
|
||||||
|
import { asyncRoute } from '../asyncRoute';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', async (_req: Request, res: Response) => {
|
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at,
|
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,
|
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,
|
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
|
FROM customers c
|
||||||
LEFT JOIN orders o ON o.customer_id = c.id
|
LEFT JOIN orders o ON o.customer_id = c.id
|
||||||
GROUP BY c.id
|
GROUP BY c.id
|
||||||
ORDER BY c.created_at DESC
|
ORDER BY c.created_at DESC
|
||||||
`);
|
`);
|
||||||
res.json(rows);
|
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(
|
const { rows: customerRows } = await pool.query(
|
||||||
`SELECT id, email, name, email_verified, marketing_consent, marketing_consent_at, created_at
|
`SELECT id, email, name, email_verified, marketing_consent, marketing_consent_at, created_at
|
||||||
FROM customers WHERE id = $1`,
|
FROM customers WHERE id = $1`,
|
||||||
@@ -35,6 +91,6 @@ router.get('/:id', async (req: Request, res: Response) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
res.json({ customer: customerRows[0], orders: orderRows });
|
res.json({ customer: customerRows[0], orders: orderRows });
|
||||||
});
|
}));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import app from '../../src/app';
|
||||||
|
import { pool } from '../../src/db';
|
||||||
|
import { resetDb, closeDb } from './setup/testDb';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool.end();
|
||||||
|
await closeDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createCategory(name: string, parentId: number | null = null): Promise<number> {
|
||||||
|
const res = await request(app).post('/api/admin/categories').send({ name, parent_id: parentId });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
return res.body.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTag(name: string): Promise<number> {
|
||||||
|
const res = await request(app).post('/api/admin/tags').send({ name });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
return res.body.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createItem(
|
||||||
|
name: string,
|
||||||
|
priceCents: number,
|
||||||
|
options: { categoryId?: number | null; tagIds?: number[]; status?: string } = {}
|
||||||
|
): Promise<number> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO items (name, price_cents, category_id, status) VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
[name, priceCents, options.categoryId ?? null, options.status ?? 'available']
|
||||||
|
);
|
||||||
|
const itemId = rows[0].id;
|
||||||
|
for (const tagId of options.tagIds ?? []) {
|
||||||
|
await pool.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [itemId, tagId]);
|
||||||
|
}
|
||||||
|
return itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerCustomer(email: string) {
|
||||||
|
const agent = request.agent(app);
|
||||||
|
await agent.post('/api/customers/register').send({ email, password: 'supersecret123' });
|
||||||
|
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
|
||||||
|
return { agent, id: rows[0].id as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = (body: { name: string }[]) => body.map(item => item.name).sort();
|
||||||
|
|
||||||
|
describe('GET /api/admin/items filtering', () => {
|
||||||
|
it('returns every item when nothing is filtered', async () => {
|
||||||
|
await createItem('A', 1000);
|
||||||
|
await createItem('B', 2000, { status: 'sold' });
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/admin/items');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by status, which is how Reserved is surfaced', async () => {
|
||||||
|
await createItem('Free', 1000, { status: 'available' });
|
||||||
|
await createItem('Held', 1000, { status: 'reserved' });
|
||||||
|
await createItem('Gone', 1000, { status: 'sold' });
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/admin/items?status=reserved');
|
||||||
|
expect(names(res.body)).toEqual(['Held']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a category and all of its descendants', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const tables = await createCategory('Tables', furniture);
|
||||||
|
const decor = await createCategory('Decor');
|
||||||
|
|
||||||
|
await createItem('Nested', 1000, { categoryId: tables });
|
||||||
|
await createItem('Top', 1000, { categoryId: furniture });
|
||||||
|
await createItem('Elsewhere', 1000, { categoryId: decor });
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/admin/items?category=${furniture}`);
|
||||||
|
expect(names(res.body)).toEqual(['Nested', 'Top']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires every listed tag rather than any of them', async () => {
|
||||||
|
const vintage = await createTag('vintage');
|
||||||
|
const oak = await createTag('oak');
|
||||||
|
await createItem('Both', 1000, { tagIds: [vintage, oak] });
|
||||||
|
await createItem('One', 1000, { tagIds: [vintage] });
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/admin/items?tags=${vintage},${oak}`);
|
||||||
|
expect(names(res.body)).toEqual(['Both']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds the price range inclusively', async () => {
|
||||||
|
await createItem('Under', 900);
|
||||||
|
await createItem('Edge', 1000);
|
||||||
|
await createItem('Over', 5100);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/admin/items?min_price=1000&max_price=5000');
|
||||||
|
expect(names(res.body)).toEqual(['Edge']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines every filter with AND', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const tables = await createCategory('Tables', furniture);
|
||||||
|
const vintage = await createTag('vintage');
|
||||||
|
|
||||||
|
await createItem('Match', 3000, { categoryId: tables, tagIds: [vintage], status: 'reserved' });
|
||||||
|
await createItem('Wrong status', 3000, { categoryId: tables, tagIds: [vintage], status: 'available' });
|
||||||
|
await createItem('Wrong category', 3000, { tagIds: [vintage], status: 'reserved' });
|
||||||
|
await createItem('Wrong price', 9000, { categoryId: tables, tagIds: [vintage], status: 'reserved' });
|
||||||
|
|
||||||
|
const res = await request(app).get(
|
||||||
|
`/api/admin/items?category=${furniture}&tags=${vintage}&min_price=1000&max_price=5000&status=reserved`
|
||||||
|
);
|
||||||
|
expect(names(res.body)).toEqual(['Match']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown status rather than returning everything', async () => {
|
||||||
|
await createItem('A', 1000);
|
||||||
|
const res = await request(app).get('/api/admin/items?status=pending');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still returns admin-only columns alongside the filters', async () => {
|
||||||
|
await createItem('A', 1000, { status: 'reserved' });
|
||||||
|
const res = await request(app).get('/api/admin/items?status=reserved');
|
||||||
|
expect(res.body[0]).toHaveProperty('reserved_until');
|
||||||
|
expect(res.body[0]).toHaveProperty('tags');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin customer reservations', () => {
|
||||||
|
it('reports how many items each customer is holding', async () => {
|
||||||
|
const itemId = await createItem('Held', 1000);
|
||||||
|
const { agent, id } = await registerCustomer('holder@example.com');
|
||||||
|
await agent.post(`/api/cart/items/${itemId}`);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/admin/customers');
|
||||||
|
const customer = res.body.find((c: { id: number }) => c.id === id);
|
||||||
|
expect(Number(customer.reserved_count)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports zero for a customer holding nothing', async () => {
|
||||||
|
const { id } = await registerCustomer('empty@example.com');
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/admin/customers');
|
||||||
|
const customer = res.body.find((c: { id: number }) => c.id === id);
|
||||||
|
expect(Number(customer.reserved_count)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists the items a customer is holding', async () => {
|
||||||
|
const itemId = await createItem('Oak table', 34000);
|
||||||
|
const { agent, id } = await registerCustomer('lister@example.com');
|
||||||
|
await agent.post(`/api/cart/items/${itemId}`);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/admin/customers/${id}/reserved`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(1);
|
||||||
|
expect(res.body[0].name).toBe('Oak table');
|
||||||
|
expect(res.body[0].item_id).toBe(itemId);
|
||||||
|
expect(res.body[0].price_cents).toBe(34000);
|
||||||
|
expect(res.body[0].expires_at).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty list for a customer holding nothing', async () => {
|
||||||
|
const { id } = await registerCustomer('nothing@example.com');
|
||||||
|
const res = await request(app).get(`/api/admin/customers/${id}/reserved`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releasing an item returns it to available and empties the cart row', async () => {
|
||||||
|
const itemId = await createItem('Oak table', 34000);
|
||||||
|
const { agent, id } = await registerCustomer('release@example.com');
|
||||||
|
await agent.post(`/api/cart/items/${itemId}`);
|
||||||
|
|
||||||
|
const res = await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
|
||||||
|
const item = await request(app).get(`/api/items/${itemId}`);
|
||||||
|
expect(item.body.status).toBe('available');
|
||||||
|
|
||||||
|
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM cart_items WHERE item_id = $1`, [itemId]);
|
||||||
|
expect(rows[0].n).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a released item stops counting against the customer', async () => {
|
||||||
|
const itemId = await createItem('Oak table', 34000);
|
||||||
|
const { agent, id } = await registerCustomer('recount@example.com');
|
||||||
|
await agent.post(`/api/cart/items/${itemId}`);
|
||||||
|
|
||||||
|
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/admin/customers');
|
||||||
|
const customer = res.body.find((c: { id: number }) => c.id === id);
|
||||||
|
expect(Number(customer.reserved_count)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a released item can be reserved again by someone else', async () => {
|
||||||
|
const itemId = await createItem('Oak table', 34000);
|
||||||
|
const { agent: first, id } = await registerCustomer('first@example.com');
|
||||||
|
await first.post(`/api/cart/items/${itemId}`);
|
||||||
|
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
|
||||||
|
|
||||||
|
const { agent: second } = await registerCustomer('second@example.com');
|
||||||
|
const res = await second.post(`/api/cart/items/${itemId}`);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to release an item the customer is not holding', async () => {
|
||||||
|
const itemId = await createItem('Not theirs', 1000);
|
||||||
|
const { id } = await registerCustomer('other@example.com');
|
||||||
|
|
||||||
|
const res = await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch a sold item when releasing', async () => {
|
||||||
|
const itemId = await createItem('Sold out', 1000);
|
||||||
|
const { agent, id } = await registerCustomer('sold@example.com');
|
||||||
|
await agent.post(`/api/cart/items/${itemId}`);
|
||||||
|
await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]);
|
||||||
|
|
||||||
|
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
|
||||||
|
expect(rows[0].status).toBe('sold');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,8 @@ describe('parseItemFilters', () => {
|
|||||||
categoryId: null,
|
categoryId: null,
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
minPriceCents: null,
|
minPriceCents: null,
|
||||||
maxPriceCents: null
|
maxPriceCents: null,
|
||||||
|
status: null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +73,25 @@ describe('parseItemFilters', () => {
|
|||||||
it('rejects a repeated query param rather than guessing which one to use', () => {
|
it('rejects a repeated query param rather than guessing which one to use', () => {
|
||||||
expect(() => parseItemFilters({ category: ['1', '2'] })).toThrow(FilterError);
|
expect(() => parseItemFilters({ category: ['1', '2'] })).toThrow(FilterError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses each of the item statuses', () => {
|
||||||
|
expect(parseItemFilters({ status: 'available' }).status).toBe('available');
|
||||||
|
expect(parseItemFilters({ status: 'reserved' }).status).toBe('reserved');
|
||||||
|
expect(parseItemFilters({ status: 'sold' }).status).toBe('sold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an absent or empty status as no status filter', () => {
|
||||||
|
expect(parseItemFilters({}).status).toBeNull();
|
||||||
|
expect(parseItemFilters({ status: '' }).status).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a status outside the known set', () => {
|
||||||
|
expect(() => parseItemFilters({ status: 'pending' })).toThrow(FilterError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a status differing only by case, rather than silently coercing it', () => {
|
||||||
|
expect(() => parseItemFilters({ status: 'Reserved' })).toThrow(FilterError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildItemFilterSql', () => {
|
describe('buildItemFilterSql', () => {
|
||||||
@@ -100,6 +120,12 @@ describe('buildItemFilterSql', () => {
|
|||||||
expect(built.clauses.join(' ')).toContain('$3');
|
expect(built.clauses.join(' ')).toContain('$3');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters on status', () => {
|
||||||
|
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1);
|
||||||
|
expect(built.clauses.join(' ')).toContain('i.status');
|
||||||
|
expect(built.params).toEqual(['reserved']);
|
||||||
|
});
|
||||||
|
|
||||||
it('continues numbering across multiple filters', () => {
|
it('continues numbering across multiple filters', () => {
|
||||||
const built = buildItemFilterSql(
|
const built = buildItemFilterSql(
|
||||||
parseItemFilters({ category: '4', min_price: '100', max_price: '900' }),
|
parseItemFilters({ category: '4', min_price: '100', max_price: '900' }),
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import Settings from './Settings';
|
|||||||
import Categories from './Categories';
|
import Categories from './Categories';
|
||||||
import Tags from './Tags';
|
import Tags from './Tags';
|
||||||
import CategoryTreeSelect from './CategoryTreeSelect';
|
import CategoryTreeSelect from './CategoryTreeSelect';
|
||||||
|
import InventoryFilters from './InventoryFilters';
|
||||||
|
import { ItemFilters, EMPTY_FILTERS } from '../filters';
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
const { Header, Content } = Layout;
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
@@ -34,9 +36,10 @@ function Inventory() {
|
|||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [tags, setTags] = useState<TagRecord[]>([]);
|
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [filters, setFilters] = useState<ItemFilters>(EMPTY_FILTERS);
|
||||||
const { mode } = useThemeMode();
|
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
|
// 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.
|
// from the sibling tabs, so they're refetched whenever the modal opens.
|
||||||
@@ -45,7 +48,13 @@ function Inventory() {
|
|||||||
fetchAdminTags().then(setTags)
|
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() {
|
function openNew() {
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
@@ -193,6 +202,15 @@ function Inventory() {
|
|||||||
<Title level={4} style={{ margin: 0 }}>Inventory</Title>
|
<Title level={4} style={{ margin: 0 }}>Inventory</Title>
|
||||||
<Button type="primary" onClick={openNew}>Add Item</Button>
|
<Button type="primary" onClick={openNew}>Add Item</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<InventoryFilters
|
||||||
|
categories={categories}
|
||||||
|
tags={tags}
|
||||||
|
filters={filters}
|
||||||
|
onChange={applyFilters}
|
||||||
|
onClear={clearFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
|
<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}>
|
<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 { useEffect, useRef, useState } from 'react';
|
||||||
import type { Key } from 'react';
|
import type { Key } from 'react';
|
||||||
import Tree from 'antd/lib/tree';
|
import Tree from 'antd/es/tree';
|
||||||
import Button from 'antd/lib/button';
|
import Button from 'antd/es/button';
|
||||||
import Input from 'antd/lib/input';
|
import Input from 'antd/es/input';
|
||||||
import Modal from 'antd/lib/modal';
|
import Modal from 'antd/es/modal';
|
||||||
import Select from 'antd/lib/select';
|
import Select from 'antd/es/select';
|
||||||
import Space from 'antd/lib/space';
|
import Space from 'antd/es/space';
|
||||||
import Typography from 'antd/lib/typography';
|
import Typography from 'antd/es/typography';
|
||||||
import Empty from 'antd/lib/empty';
|
import Empty from 'antd/es/empty';
|
||||||
import Spin from 'antd/lib/spin';
|
import Spin from 'antd/es/spin';
|
||||||
import message from 'antd/lib/message';
|
import message from 'antd/es/message';
|
||||||
import type { DataNode, TreeProps } from 'antd/es/tree';
|
import type { DataNode, TreeProps } from 'antd/es/tree';
|
||||||
import {
|
import {
|
||||||
Category,
|
Category,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import TreeSelect from 'antd/lib/tree-select';
|
import TreeSelect from 'antd/es/tree-select';
|
||||||
import Input from 'antd/lib/input';
|
import Input from 'antd/es/input';
|
||||||
import Button from 'antd/lib/button';
|
import Button from 'antd/es/button';
|
||||||
import Divider from 'antd/lib/divider';
|
import Divider from 'antd/es/divider';
|
||||||
import message from 'antd/lib/message';
|
import message from 'antd/es/message';
|
||||||
import { Category, createCategory, fetchAdminCategories } from '../api';
|
import { Category, createCategory, fetchAdminCategories } from '../api';
|
||||||
import { buildCategoryTree, CategoryNode } from '../filters';
|
import { buildCategoryTree, CategoryNode } from '../filters';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 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() {
|
export default function Customers() {
|
||||||
const [customers, setCustomers] = useState<CustomerSummary[]>([]);
|
const [customers, setCustomers] = useState<CustomerSummary[]>([]);
|
||||||
@@ -11,10 +14,16 @@ export default function Customers() {
|
|||||||
const [detail, setDetail] = useState<CustomerDetail | null>(null);
|
const [detail, setDetail] = useState<CustomerDetail | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [drawerOpen, setDrawerOpen] = 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(() => {
|
function load() {
|
||||||
fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); });
|
return fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); });
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
async function openDetail(id: number) {
|
async function openDetail(id: number) {
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
@@ -24,6 +33,37 @@ export default function Customers() {
|
|||||||
setDetailLoading(false);
|
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> = [
|
const columns: ColumnsType<CustomerSummary> = [
|
||||||
{
|
{
|
||||||
title: 'Customer',
|
title: 'Customer',
|
||||||
@@ -50,6 +90,25 @@ export default function Customers() {
|
|||||||
onFilter: (value, row) => row.marketing_consent === value,
|
onFilter: (value, row) => row.marketing_consent === value,
|
||||||
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? 'Yes' : 'No'}</Tag>
|
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',
|
title: 'Orders',
|
||||||
dataIndex: 'order_count',
|
dataIndex: 'order_count',
|
||||||
@@ -145,6 +204,54 @@ export default function Customers() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</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>
|
</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 { useEffect, useState } from 'react';
|
||||||
import Table from 'antd/lib/table';
|
import Table from 'antd/es/table';
|
||||||
import Button from 'antd/lib/button';
|
import Button from 'antd/es/button';
|
||||||
import Input from 'antd/lib/input';
|
import Input from 'antd/es/input';
|
||||||
import Modal from 'antd/lib/modal';
|
import Modal from 'antd/es/modal';
|
||||||
import Select from 'antd/lib/select';
|
import Select from 'antd/es/select';
|
||||||
import Space from 'antd/lib/space';
|
import Space from 'antd/es/space';
|
||||||
import Tag from 'antd/lib/tag';
|
import Tag from 'antd/es/tag';
|
||||||
import Typography from 'antd/lib/typography';
|
import Typography from 'antd/es/typography';
|
||||||
import message from 'antd/lib/message';
|
import message from 'antd/es/message';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { Tag as TagRecord, fetchAdminTags, createTag, updateTag, deleteTag } from '../api';
|
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 });
|
await updateTag(editing.id, { name: trimmed, color });
|
||||||
message.success('Tag updated');
|
message.success('Tag updated');
|
||||||
} else {
|
} 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.
|
// overridden straight afterwards by editing.
|
||||||
await createTag(trimmed);
|
await createTag(trimmed);
|
||||||
message.success('Tag added');
|
message.success('Tag added');
|
||||||
@@ -94,7 +94,7 @@ export default function Tags() {
|
|||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
render: (_: string, tag) => <Tag color={tag.color}>{tag.name}</Tag>
|
render: (_: string, tag) => <Tag color={tag.color}>{tag.name}</Tag>
|
||||||
},
|
},
|
||||||
{ title: 'Colour', dataIndex: 'color' },
|
{ title: 'Color', dataIndex: 'color' },
|
||||||
{ title: 'Items', dataIndex: 'item_count' },
|
{ title: 'Items', dataIndex: 'item_count' },
|
||||||
{
|
{
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
@@ -133,7 +133,7 @@ export default function Tags() {
|
|||||||
/>
|
/>
|
||||||
{editing && (
|
{editing && (
|
||||||
<>
|
<>
|
||||||
<label htmlFor="tag-color">Colour</label>
|
<label htmlFor="tag-color">Color</label>
|
||||||
<Select
|
<Select
|
||||||
id="tag-color"
|
id="tag-color"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ export interface CustomerSummary {
|
|||||||
order_count: number;
|
order_count: number;
|
||||||
total_spent_cents: number;
|
total_spent_cents: number;
|
||||||
last_order_at: string | null;
|
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 {
|
export interface CustomerOrder {
|
||||||
@@ -42,3 +51,21 @@ export async function fetchCustomerDetail(id: number): Promise<CustomerDetail> {
|
|||||||
const res = await fetch(`/api/admin/customers/${id}`);
|
const res = await fetch(`/api/admin/customers/${id}`);
|
||||||
return res.json();
|
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);
|
throw new Error(detail?.error ? `${action}: ${detail.error}` : action);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminItems(): Promise<Item[]> {
|
export async function fetchAdminItems(filters?: ItemFilters): Promise<Item[]> {
|
||||||
const res = await expectOk(await fetch('/api/admin/items'), 'failed to load items');
|
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Tag from 'antd/lib/tag';
|
import Tag from 'antd/es/tag';
|
||||||
import Button from 'antd/lib/button';
|
import Button from 'antd/es/button';
|
||||||
import type { FilterOptions } from '../api';
|
import type { FilterOptions } from '../api';
|
||||||
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
|
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Drawer from 'antd/lib/drawer';
|
import Drawer from 'antd/es/drawer';
|
||||||
import Button from 'antd/lib/button';
|
import Button from 'antd/es/button';
|
||||||
import Tree from 'antd/lib/tree';
|
import Tree from 'antd/es/tree';
|
||||||
import Tag from 'antd/lib/tag';
|
import Tag from 'antd/es/tag';
|
||||||
import Slider from 'antd/lib/slider';
|
import Slider from 'antd/es/slider';
|
||||||
import InputNumber from 'antd/lib/input-number';
|
import InputNumber from 'antd/es/input-number';
|
||||||
import Empty from 'antd/lib/empty';
|
import Empty from 'antd/es/empty';
|
||||||
import Grid from 'antd/lib/grid';
|
import Grid from 'antd/es/grid';
|
||||||
import type { DataNode } from 'antd/es/tree';
|
import type { DataNode } from 'antd/es/tree';
|
||||||
import type { FilterOptions } from '../api';
|
import type { FilterOptions } from '../api';
|
||||||
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
|
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import Card from 'antd/lib/card';
|
import Card from 'antd/es/card';
|
||||||
import Typography from 'antd/lib/typography';
|
import Typography from 'antd/es/typography';
|
||||||
import Alert from 'antd/lib/alert';
|
import Alert from 'antd/es/alert';
|
||||||
import Button from 'antd/lib/button';
|
import Button from 'antd/es/button';
|
||||||
import Spin from 'antd/lib/spin';
|
import Spin from 'antd/es/spin';
|
||||||
import { verifyEmail } from './customerApi';
|
import { verifyEmail } from './customerApi';
|
||||||
import { useCustomerAuth } from './CustomerAuthContext';
|
import { useCustomerAuth } from './CustomerAuthContext';
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -1,17 +1,23 @@
|
|||||||
import type { Category } from './api';
|
import type { Category } from './api';
|
||||||
|
|
||||||
|
export type ItemStatus = 'available' | 'reserved' | 'sold';
|
||||||
|
|
||||||
export interface ItemFilters {
|
export interface ItemFilters {
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
minPriceCents: number | null;
|
minPriceCents: number | null;
|
||||||
maxPriceCents: 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 = {
|
export const EMPTY_FILTERS: ItemFilters = {
|
||||||
categoryId: null,
|
categoryId: null,
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
minPriceCents: null,
|
minPriceCents: null,
|
||||||
maxPriceCents: null
|
maxPriceCents: null,
|
||||||
|
status: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filters live in the URL so a filtered view can be linked, bookmarked, and
|
// 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.tagIds.length) params.set('tags', filters.tagIds.join(','));
|
||||||
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
|
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
|
||||||
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
|
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
|
||||||
|
if (filters.status !== null) params.set('status', filters.status);
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +45,17 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
|||||||
.map((part) => readInt(part))
|
.map((part) => readInt(part))
|
||||||
.filter((id): id is number => id !== null && id > 0);
|
.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 {
|
return {
|
||||||
categoryId: readInt(params.get('category')),
|
categoryId: readInt(params.get('category')),
|
||||||
tagIds: tags,
|
tagIds: tags,
|
||||||
minPriceCents: readInt(params.get('min_price')),
|
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++;
|
if (filters.categoryId !== null) count++;
|
||||||
count += filters.tagIds.length;
|
count += filters.tagIds.length;
|
||||||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
||||||
|
if (filters.status !== null) count++;
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -16,6 +16,11 @@ import { CartProvider } from './cart/CartContext';
|
|||||||
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
|
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
|
||||||
import './styles.css';
|
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)';
|
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||||
|
|
||||||
// Respects the OS-level "reduce motion" accessibility setting by turning off
|
// Respects the OS-level "reduce motion" accessibility setting by turning off
|
||||||
@@ -44,7 +49,17 @@ function Root() {
|
|||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
theme={{
|
theme={{
|
||||||
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
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>
|
<BrowserRouter>
|
||||||
|
|||||||
@@ -115,3 +115,30 @@ body { margin: 0; }
|
|||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const RUN = `v${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
const NAMES = {
|
||||||
|
category: `Filterable ${RUN}`,
|
||||||
|
tag: `filt-${RUN}`,
|
||||||
|
cheap: `Cheap item ${RUN}`,
|
||||||
|
mid: `Mid item ${RUN}`,
|
||||||
|
dear: `Dear item ${RUN}`
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeAll(async ({ playwright }) => {
|
||||||
|
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
|
||||||
|
|
||||||
|
const categoryId = (await (await api.post('/api/admin/categories', {
|
||||||
|
data: { name: NAMES.category, parent_id: null }
|
||||||
|
})).json()).id;
|
||||||
|
await api.post('/api/admin/tags', { data: { name: NAMES.tag } });
|
||||||
|
|
||||||
|
const item = (name: string, price: string, inCategory: boolean) =>
|
||||||
|
api.post('/api/admin/items', {
|
||||||
|
multipart: {
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
price,
|
||||||
|
category_id: inCategory ? String(categoryId) : '',
|
||||||
|
tags: JSON.stringify(inCategory ? [NAMES.tag] : [])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await item(NAMES.cheap, '50', true);
|
||||||
|
await item(NAMES.mid, '150', true);
|
||||||
|
await item(NAMES.dear, '900', true);
|
||||||
|
|
||||||
|
await api.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = (page: Page, name: string) => page.getByRole('row').filter({ hasText: name });
|
||||||
|
|
||||||
|
// The Inventory table paginates and other specs create items concurrently, so
|
||||||
|
// an unfiltered page 1 is not a reliable place to look for a fixture. Every
|
||||||
|
// assertion below therefore runs against a category filter that narrows the
|
||||||
|
// table to this spec's own items.
|
||||||
|
async function filterToOwnCategory(page: Page) {
|
||||||
|
const category = page.getByRole('combobox', { name: 'Filter by category' });
|
||||||
|
await category.click();
|
||||||
|
await category.fill(NAMES.category);
|
||||||
|
await page.getByTitle(NAMES.category, { exact: true }).click();
|
||||||
|
await expect(row(page, NAMES.cheap)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Admin inventory filters', () => {
|
||||||
|
test('filters by category', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await filterToOwnCategory(page);
|
||||||
|
|
||||||
|
// All three fixtures share the category, and nothing else does.
|
||||||
|
await expect(row(page, NAMES.cheap)).toBeVisible();
|
||||||
|
await expect(row(page, NAMES.mid)).toBeVisible();
|
||||||
|
await expect(row(page, NAMES.dear)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by price range', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await filterToOwnCategory(page);
|
||||||
|
|
||||||
|
await page.getByLabel('Minimum price').fill('100');
|
||||||
|
await page.getByLabel('Maximum price').fill('500');
|
||||||
|
|
||||||
|
await expect(row(page, NAMES.mid)).toBeVisible();
|
||||||
|
await expect(row(page, NAMES.cheap)).toHaveCount(0);
|
||||||
|
await expect(row(page, NAMES.dear)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by status, which is how Reserved is reached', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await filterToOwnCategory(page);
|
||||||
|
|
||||||
|
await page.getByRole('combobox', { name: 'Filter by status' }).click();
|
||||||
|
await page.getByTitle('Sold', { exact: true }).click();
|
||||||
|
|
||||||
|
// Every fixture is available, so a Sold filter must exclude them all.
|
||||||
|
await expect(row(page, NAMES.cheap)).toHaveCount(0);
|
||||||
|
await expect(row(page, NAMES.mid)).toHaveCount(0);
|
||||||
|
await expect(row(page, NAMES.dear)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('combines filters, and clearing restores them', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await filterToOwnCategory(page);
|
||||||
|
await page.getByLabel('Minimum price').fill('800');
|
||||||
|
await expect(row(page, NAMES.cheap)).toHaveCount(0);
|
||||||
|
await expect(row(page, NAMES.dear)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Clear filters' }).click();
|
||||||
|
|
||||||
|
// Asserting on the controls rather than on the rows: with the filters gone
|
||||||
|
// the table is the whole paginated catalogue again, so a given fixture is
|
||||||
|
// not reliably on the first page.
|
||||||
|
await expect(page.getByRole('button', { name: 'Clear filters' })).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel('Minimum price')).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const suffix = () => `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
// Reserves an item by registering a customer and adding it to their cart, which
|
||||||
|
// is the only way an item legitimately reaches 'reserved'.
|
||||||
|
async function reserveItem(page: import('@playwright/test').Page, itemName: string) {
|
||||||
|
const email = `reserve-${suffix()}@example.com`;
|
||||||
|
|
||||||
|
const created = await page.request.post('/api/admin/items', {
|
||||||
|
multipart: { name: itemName, description: '', price: '75', category_id: '', tags: '[]' }
|
||||||
|
});
|
||||||
|
expect(created.ok()).toBeTruthy();
|
||||||
|
const itemId = (await created.json()).id as number;
|
||||||
|
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await page.getByLabel('Password').fill('supersecret123');
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/account/);
|
||||||
|
|
||||||
|
const added = await page.request.post(`/api/cart/items/${itemId}`);
|
||||||
|
expect(added.status()).toBe(201);
|
||||||
|
|
||||||
|
return { email, itemId };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Admin reserved items', () => {
|
||||||
|
test('shows a reserved count that opens the held items', async ({ page }) => {
|
||||||
|
const itemName = `Held ${suffix()}`;
|
||||||
|
const { email } = await reserveItem(page, itemName);
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: email });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.getByRole('button', { name: /item/ }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
|
||||||
|
await expect(dialog.getByText(itemName)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('releasing an item returns it to the storefront as available', async ({ page }) => {
|
||||||
|
const itemName = `Freed ${suffix()}`;
|
||||||
|
const { email, itemId } = await reserveItem(page, itemName);
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||||
|
const row = page.getByRole('row').filter({ hasText: email });
|
||||||
|
await row.getByRole('button', { name: /item/ }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
|
||||||
|
await dialog.getByRole('button', { name: 'Release' }).click();
|
||||||
|
await expect(page.getByText(`Released "${itemName}"`)).toBeVisible();
|
||||||
|
|
||||||
|
// The point of releasing is that the item becomes purchasable again.
|
||||||
|
const item = await (await page.request.get(`/api/items/${itemId}`)).json();
|
||||||
|
expect(item.status).toBe('available');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the count drops once the item is released', async ({ page }) => {
|
||||||
|
const itemName = `Recount ${suffix()}`;
|
||||||
|
const { email } = await reserveItem(page, itemName);
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||||
|
const row = page.getByRole('row').filter({ hasText: email });
|
||||||
|
await row.getByRole('button', { name: /item/ }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog', { name: /Items reserved by/ });
|
||||||
|
await dialog.getByRole('button', { name: 'Release' }).click();
|
||||||
|
await expect(dialog.getByText("This customer isn't holding any items")).toBeVisible();
|
||||||
|
|
||||||
|
// The row behind the dialog must agree with the dialog it opened.
|
||||||
|
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||||
|
await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a customer holding nothing shows no link to click', async ({ page }) => {
|
||||||
|
const email = `idle-${suffix()}@example.com`;
|
||||||
|
await page.goto('/register');
|
||||||
|
await page.getByRole('textbox', { name: 'Email' }).fill(email);
|
||||||
|
await page.getByLabel('Password').fill('supersecret123');
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/account/);
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('tab', { name: 'Customers' }).click();
|
||||||
|
const row = page.getByRole('row').filter({ hasText: email });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row.getByRole('button', { name: /item/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
// Relative luminance per WCAG, used to tell "light" from "dark" without
|
||||||
|
// asserting exact hex values, which would break on any palette tweak.
|
||||||
|
function luminance(rgb: string): number {
|
||||||
|
const [r, g, b] = (rgb.match(/\d+(\.\d+)?/g) ?? ['0', '0', '0']).slice(0, 3).map(Number);
|
||||||
|
const channel = (c: number) => {
|
||||||
|
const s = c / 255;
|
||||||
|
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||||
|
};
|
||||||
|
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
// The Categories and Tags tabs render an empty state when there is nothing to
|
||||||
|
// show, and the shared dev database gets truncated by the backend integration
|
||||||
|
// suite. Seed what each test needs rather than depending on what happens to be
|
||||||
|
// there.
|
||||||
|
async function seed(page: Page) {
|
||||||
|
const run = suffix();
|
||||||
|
await page.request.post('/api/admin/categories', { data: { name: `Theme ${run}`, parent_id: null } });
|
||||||
|
await page.request.post('/api/admin/tags', { data: { name: `theme-${run}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchToDark(page: Page) {
|
||||||
|
await page.goto('/admin');
|
||||||
|
const toggle = page.getByRole('switch');
|
||||||
|
if ((await toggle.getAttribute('aria-checked')) !== 'true') {
|
||||||
|
await toggle.click();
|
||||||
|
}
|
||||||
|
await expect(page.locator('body')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Admin dark mode', () => {
|
||||||
|
test('the active tab label is readable against the dark background', async ({ page }) => {
|
||||||
|
await switchToDark(page);
|
||||||
|
|
||||||
|
const label = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first();
|
||||||
|
|
||||||
|
// The bug: colorPrimary stayed #1a1a1a in dark mode, so the active tab was
|
||||||
|
// near-black text on a near-black background.
|
||||||
|
await expect
|
||||||
|
.poll(async () => luminance(await label.evaluate((el) => getComputedStyle(el).color)))
|
||||||
|
.toBeGreaterThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Categories tab follows the dark theme', async ({ page }) => {
|
||||||
|
await seed(page);
|
||||||
|
await switchToDark(page);
|
||||||
|
await page.getByRole('tab', { name: 'Categories' }).click();
|
||||||
|
|
||||||
|
const tree = page.locator('.ant-tabs-tabpane-active .ant-tree').first();
|
||||||
|
await expect(tree).toBeVisible();
|
||||||
|
const bg = await tree.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||||
|
|
||||||
|
// The bug: deep imports from antd/lib loaded a second copy of antd that
|
||||||
|
// never saw ConfigProvider, so this rendered pure white in dark mode.
|
||||||
|
expect(luminance(bg)).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Tags tab follows the dark theme', async ({ page }) => {
|
||||||
|
await seed(page);
|
||||||
|
await switchToDark(page);
|
||||||
|
await page.getByRole('tab', { name: 'Tags' }).click();
|
||||||
|
|
||||||
|
const table = page.locator('.ant-tabs-tabpane-active .ant-table').first();
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
const bg = await table.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||||
|
expect(luminance(bg)).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the item form category selector follows the dark theme', async ({ page }) => {
|
||||||
|
await switchToDark(page);
|
||||||
|
await page.getByRole('button', { name: 'Add Item' }).click();
|
||||||
|
await page.getByRole('dialog').getByLabel('Category', { exact: true }).click();
|
||||||
|
|
||||||
|
const popup = page.locator('.ant-select-dropdown').first();
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
const bg = await popup.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||||
|
expect(luminance(bg)).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Admin copy', () => {
|
||||||
|
test('uses American English spelling for color', async ({ page }) => {
|
||||||
|
await seed(page);
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('tab', { name: 'Tags' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Color' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Colour')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user