diff --git a/backend/src/itemFilters.ts b/backend/src/itemFilters.ts index a075fac..3489303 100644 --- a/backend/src/itemFilters.ts +++ b/backend/src/itemFilters.ts @@ -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): 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 }; } diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 90488d6..94cadad 100755 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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); + } 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; diff --git a/backend/src/routes/adminCustomers.ts b/backend/src/routes/adminCustomers.ts index dfecf28..b84c1c0 100755 --- a/backend/src/routes/adminCustomers.ts +++ b/backend/src/routes/adminCustomers.ts @@ -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; diff --git a/backend/tests/integration/adminInventory.integration.test.ts b/backend/tests/integration/adminInventory.integration.test.ts new file mode 100644 index 0000000..5ed452d --- /dev/null +++ b/backend/tests/integration/adminInventory.integration.test.ts @@ -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 { + 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 { + 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 { + 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'); + }); +}); diff --git a/backend/tests/unit/itemFilters.test.ts b/backend/tests/unit/itemFilters.test.ts index 7015d50..95d1220 100644 --- a/backend/tests/unit/itemFilters.test.ts +++ b/backend/tests/unit/itemFilters.test.ts @@ -6,7 +6,8 @@ describe('parseItemFilters', () => { categoryId: null, tagIds: [], 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', () => { 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', () => { @@ -100,6 +120,12 @@ describe('buildItemFilterSql', () => { 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', () => { const built = buildItemFilterSql( parseItemFilters({ category: '4', min_price: '100', max_price: '900' }), diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index 57dcb9e..4f3ce63 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -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([]); const [tags, setTags] = useState([]); const [saving, setSaving] = useState(false); + const [filters, setFilters] = useState(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() { Inventory + + + setModalOpen(false)} destroyOnHidden width={720}> diff --git a/frontend/src/admin/Categories.tsx b/frontend/src/admin/Categories.tsx index 07f047a..6fc9d03 100644 --- a/frontend/src/admin/Categories.tsx +++ b/frontend/src/admin/Categories.tsx @@ -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, diff --git a/frontend/src/admin/CategoryTreeSelect.tsx b/frontend/src/admin/CategoryTreeSelect.tsx index ca13b36..59a716c 100644 --- a/frontend/src/admin/CategoryTreeSelect.tsx +++ b/frontend/src/admin/CategoryTreeSelect.tsx @@ -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'; diff --git a/frontend/src/admin/Customers.tsx b/frontend/src/admin/Customers.tsx index 8b705ba..1a3fece 100755 --- a/frontend/src/admin/Customers.tsx +++ b/frontend/src/admin/Customers.tsx @@ -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([]); @@ -11,10 +14,16 @@ export default function Customers() { const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); + const [reservedFor, setReservedFor] = useState(null); + const [reserved, setReserved] = useState([]); + const [reservedLoading, setReservedLoading] = useState(false); + const [releasing, setReleasing] = useState(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 = [ { title: 'Customer', @@ -50,6 +90,25 @@ export default function Customers() { onFilter: (value, row) => row.marketing_consent === value, render: (v: boolean) => {v ? 'Yes' : 'No'} }, + { + title: 'Reserved', + dataIndex: 'reserved_count', + render: (count: number, customer: CustomerSummary) => + Number(count) > 0 ? ( + + ) : ( + 0 + ) + }, { title: 'Orders', dataIndex: 'order_count', @@ -145,6 +204,54 @@ export default function Customers() { )} + + setReservedFor(null)} + footer={null} + destroyOnHidden + width={640} + > + {reservedLoading ? : null} + {!reservedLoading && !reserved.length ? ( + + ) : null} + {!reservedLoading && reserved.length > 0 && ( +
`$${(v / 100).toFixed(2)}` + }, + { + title: 'Reservation expires', + dataIndex: 'expires_at', + render: (v: string) => new Date(v).toLocaleString() + }, + { + title: '', + render: (_: unknown, item: ReservedItem) => ( + + ) + } + ]} + /> + )} + ); } diff --git a/frontend/src/admin/InventoryFilters.tsx b/frontend/src/admin/InventoryFilters.tsx new file mode 100644 index 0000000..9f1a36c --- /dev/null +++ b/frontend/src/admin/InventoryFilters.tsx @@ -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 ( +
+ onChange({ ...filters, categoryId: value ?? null })} + /> + + onChange({ ...filters, status: value ?? null })} + options={STATUS_OPTIONS} + /> + + {hasActiveFilters(filters) && } +
+ ); +} diff --git a/frontend/src/admin/Tags.tsx b/frontend/src/admin/Tags.tsx index 2ff929d..ce04524 100644 --- a/frontend/src/admin/Tags.tsx +++ b/frontend/src/admin/Tags.tsx @@ -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.name} }, - { title: 'Colour', dataIndex: 'color' }, + { title: 'Color', dataIndex: 'color' }, { title: 'Items', dataIndex: 'item_count' }, { title: 'Actions', @@ -133,7 +133,7 @@ export default function Tags() { /> {editing && ( <> - +