Blocks / Application
Records page
A list screen for records: header, toolbar with search and filters, a sortable grid with selection, and paging.
Stock
Everything on Marrow's shelves, counted from deliveries and sales. 20 items are below par before Saturday.
| Cornish musselsSKU-1180 | Fish | Shellseekers | Out | 0 kgpar 5 | £0.00 | |
| Crème fraîcheSKU-1061 | Dairy | Neal's Yard Dairy | Out | 0 kgpar 2 | £0.00 | |
| PuntarelleSKU-1110 | Veg | Natoora | Out | 0 kgpar 2 | £0.00 | |
| Rye flourSKU-1250 | Bakery | Wildfarmed | Out | 0 kgpar 5 | £0.00 | |
| Banana shallotsSKU-1082 | Veg | Natoora | Below par | 2 kgpar 5 | £9.80 | |
| Beef drippingSKU-1159 | Meat | Flock & Herd | Below par | 1 kgpar 2 | £6.50 | |
| Chicken thighsSKU-1145 | Meat | Flock & Herd | Below par | 4 kgpar 8 | £36.80 | |
| Cultured butterSKU-1040 | Dairy | Neal's Yard Dairy | Below par | 1.5 kgpar 6 | £21.30 | |
| Decaf beansSKU-1299 | Coffee | Square Mile | Below par | 0.5 kgpar 1 | £14.50 | |
| Double creamSKU-1047 | Dairy | Neal's Yard Dairy | Below par | 3 lpar 8 | £13.80 |
Showing 1 to 10 of 38 items
Install
Writes the block to components/blocks/records-page.tsx and its .css, and every part it uses to components/equip/, with the parts those are built from.
npx https://equip.studio311.app/equip.tgz add block:records-pageBuilt from 8 parts
Notes
- Every control works on the sample rows: filter, then sort, then slice a page. Swap ITEMS for your data and move those three steps to the server when the list is long.
- Changing the search or a filter goes back to page one, so the reader never lands on an empty page.
- Pagination renders real links; the screen takes their clicks and changes page in place. Point href at a real URL to make pages bookmarkable.
- Status sorts by urgency, out first, rather than alphabetically.
Source
components/blocks/records-page.tsx308 lines
"use client";
import { useMemo, useState } from "react";
import { Badge } from "@/components/equip/badge";
import { Button } from "@/components/equip/button";
import { DataGrid, type DataGridSort } from "@/components/equip/data-grid";
import { Icon } from "@/components/equip/icon";
import { PageHeader } from "@/components/equip/page-header";
import { Pagination } from "@/components/equip/pagination";
import { TableToolbar, type TableToolbarDensity } from "@/components/equip/table-toolbar";
import { Text } from "@/components/equip/text";
import "./records-page.css";
/*
* A records screen that really works: search, filter chips, sort, density, column
* visibility, selection with a bulk action, and paging, all on local sample data.
* Order of operations: filter, then sort, then slice a page.
*/
type Status = "ok" | "low" | "out";
type Item = {
id: string;
name: string;
category: string;
supplier: string;
onHand: number;
par: number;
unit: string;
value: number;
counted: string;
};
const RAW: [string, string, string, number, number, string, number][] = [
["Cultured butter", "Dairy", "Neal's Yard Dairy", 1.5, 6, "kg", 14.2],
["Double cream", "Dairy", "Neal's Yard Dairy", 3, 8, "l", 4.6],
["Montgomery cheddar", "Dairy", "Neal's Yard Dairy", 2.4, 2, "kg", 31.5],
["Crème fraîche", "Dairy", "Neal's Yard Dairy", 0, 2, "kg", 9.8],
["Whole milk", "Dairy", "Neal's Yard Dairy", 14, 12, "l", 1.1],
["Free range eggs", "Dairy", "Neal's Yard Dairy", 90, 120, "each", 0.42],
["Banana shallots", "Veg", "Natoora", 2, 5, "kg", 4.9],
["Cavolo nero", "Veg", "Natoora", 3, 3, "kg", 7.2],
["Blood oranges", "Veg", "Natoora", 4, 3, "kg", 5.4],
["Delica pumpkin", "Veg", "Natoora", 9, 6, "kg", 2.8],
["Puntarelle", "Veg", "Natoora", 0, 2, "kg", 12.5],
["Wild garlic", "Veg", "Natoora", 0.4, 1, "kg", 18],
["Charlotte potatoes", "Veg", "Natoora", 22, 15, "kg", 1.6],
["Lemons", "Veg", "Natoora", 30, 24, "each", 0.38],
["Hogget shoulder", "Meat", "Flock & Herd", 6.5, 6, "kg", 16.8],
["Chicken thighs", "Meat", "Flock & Herd", 4, 8, "kg", 9.2],
["Pork belly", "Meat", "Flock & Herd", 5, 4, "kg", 11.4],
["Beef dripping", "Meat", "Flock & Herd", 1, 2, "kg", 6.5],
["Smoked bacon lardons", "Meat", "Flock & Herd", 2.2, 2, "kg", 13.9],
["Hake fillet", "Fish", "Shellseekers", 3.2, 4, "kg", 24],
["Cornish mussels", "Fish", "Shellseekers", 0, 5, "kg", 6.8],
["Hand dived scallops", "Fish", "Shellseekers", 24, 30, "each", 2.6],
["Brown crab meat", "Fish", "Shellseekers", 1, 1, "kg", 38],
["Carlin peas", "Dry goods", "Hodmedod's", 4, 3, "kg", 4.4],
["Pearled barley", "Dry goods", "Hodmedod's", 6, 4, "kg", 3.1],
["Fava beans", "Dry goods", "Hodmedod's", 1.2, 3, "kg", 4.9],
["Arborio rice", "Dry goods", "Hodmedod's", 8, 5, "kg", 3.7],
["Type 00 flour", "Bakery", "Wildfarmed", 18, 25, "kg", 1.9],
["Strong white flour", "Bakery", "Wildfarmed", 40, 25, "kg", 1.4],
["Wholemeal flour", "Bakery", "Wildfarmed", 7, 10, "kg", 1.5],
["Rye flour", "Bakery", "Wildfarmed", 0, 5, "kg", 1.8],
["Extra virgin olive oil", "Oils", "Belazu", 9, 10, "l", 11.2],
["Cabernet vinegar", "Oils", "Belazu", 2, 1.5, "l", 8.6],
["Rose harissa", "Oils", "Belazu", 0.6, 1, "kg", 12.4],
["Preserved lemons", "Oils", "Belazu", 1.4, 1, "kg", 9.9],
["Espresso beans", "Coffee", "Square Mile", 3, 6, "kg", 27],
["Filter beans", "Coffee", "Square Mile", 2.5, 2, "kg", 25],
["Decaf beans", "Coffee", "Square Mile", 0.5, 1, "kg", 29],
];
// Built from the index alone, so the server and the browser agree on every row.
const ITEMS: Item[] = RAW.map(([name, category, supplier, onHand, par, unit, unitCost], i) => ({
id: `SKU-${String(1040 + i * 7)}`,
name,
category,
supplier,
onHand,
par,
unit,
value: Math.round(onHand * unitCost * 100) / 100,
counted: `2026-11-${String(13 - (i % 5)).padStart(2, "0")}`,
}));
const statusOf = (item: Item): Status => (item.onHand <= 0 ? "out" : item.onHand < item.par ? "low" : "ok");
const STATUS = {
out: { label: "Out", tone: "danger" },
low: { label: "Below par", tone: "warn" },
ok: { label: "Stocked", tone: "ok" },
} as const;
const PAGE = 10;
const CATEGORIES = [...new Set(RAW.map((r) => r[1]))];
const gbp = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" });
const qty = new Intl.NumberFormat("en-GB", { maximumFractionDigits: 1 });
const day = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", timeZone: "UTC" });
const collator = new Intl.Collator("en-GB", { numeric: true, sensitivity: "base" });
const COLUMNS = [
{ key: "name", label: "Item", hideable: false },
{ key: "category", label: "Category" },
{ key: "supplier", label: "Supplier" },
{ key: "status", label: "Status" },
{ key: "onHand", label: "On hand" },
{ key: "value", label: "Value" },
{ key: "counted", label: "Counted" },
];
export default function RecordsPage() {
const [items, setItems] = useState(ITEMS);
const [search, setSearch] = useState("");
const [filters, setFilters] = useState<Record<string, string[]>>({});
const [density, setDensity] = useState<TableToolbarDensity>("comfortable");
const [hidden, setHidden] = useState<string[]>(["counted"]);
const [sort, setSort] = useState<DataGridSort | null>({ key: "status", direction: "ascending" });
const [selected, setSelected] = useState<string[]>([]);
const [ordered, setOrdered] = useState<string[]>([]);
const [page, setPage] = useState(1);
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
const statuses = filters.status ?? [];
const categories = filters.category ?? [];
return items.filter(
(item) =>
(!needle || `${item.name} ${item.supplier} ${item.id}`.toLowerCase().includes(needle)) &&
(statuses.length === 0 || statuses.includes(statusOf(item))) &&
(categories.length === 0 || categories.includes(item.category)),
);
}, [items, search, filters]);
const sorted = useMemo(() => {
if (!sort) return filtered;
const factor = sort.direction === "ascending" ? 1 : -1;
// Status sorts by urgency: out, then below par, then stocked.
const rank = { out: 0, low: 1, ok: 2 };
return [...filtered].sort((a, b) => {
if (sort.key === "status") return (rank[statusOf(a)] - rank[statusOf(b)] || collator.compare(a.name, b.name)) * factor;
const va = a[sort.key as keyof Item];
const vb = b[sort.key as keyof Item];
return (typeof va === "number" && typeof vb === "number" ? va - vb : collator.compare(String(va), String(vb))) * factor;
});
}, [filtered, sort]);
const pages = Math.max(1, Math.ceil(sorted.length / PAGE));
const current = Math.min(page, pages);
const rows = sorted.slice((current - 1) * PAGE, current * PAGE);
const from = sorted.length === 0 ? 0 : (current - 1) * PAGE + 1;
const to = Math.min(current * PAGE, sorted.length);
const short = items.filter((item) => statusOf(item) !== "ok");
const shelfValue = items.reduce((sum, item) => sum + item.value, 0);
// Anything that changes which rows match starts again from the first page.
function refine<V>(set: (value: V) => void) {
return (value: V) => {
set(value);
setPage(1);
};
}
function addToOrder() {
setOrdered((prev) => [...new Set([...prev, ...selected])]);
setSelected([]);
}
function markCounted() {
const chosen = new Set(selected);
setItems((all) => all.map((item) => (chosen.has(item.id) ? { ...item, onHand: Math.max(item.onHand, item.par), counted: "2026-11-13" } : item)));
setSelected([]);
}
return (
<div className="blk-records-page">
<div className="blk-records-page-inner">
<PageHeader
title="Stock"
description={`Everything on Marrow's shelves, counted from deliveries and sales. ${short.length} items are below par before Saturday.`}
meta={[
<span key="value">Shelf value {gbp.format(shelfValue)}</span>,
<span key="count">Last full count Monday 9 Nov</span>,
ordered.length > 0 ? (
<Badge key="order" tone="ok" dot>
{ordered.length} on tomorrow's order
</Badge>
) : null,
].filter(Boolean)}
secondaryActions={
<Button variant="outline" lead={<Icon name="upload" />}>
Import
</Button>
}
primaryAction={
<Button variant="accent" lead={<Icon name="barcode" />}>
Start a count
</Button>
}
/>
<div className="blk-records-page-table">
<TableToolbar
label="Stock tools"
search={search}
onSearchChange={refine(setSearch)}
searchLabel="Search stock"
searchPlaceholder="Search items, suppliers or codes"
resultCount={sorted.length}
filters={[
{ key: "status", label: "Status", options: (["out", "low", "ok"] as Status[]).map((s) => ({ value: s, label: STATUS[s].label })) },
{ key: "category", label: "Category", options: CATEGORIES.map((c) => ({ value: c, label: c })) },
]}
filterValues={filters}
onFilterValuesChange={refine(setFilters)}
density={density}
onDensityChange={setDensity}
columns={COLUMNS}
hiddenColumns={hidden}
onHiddenColumnsChange={setHidden}
actions={
<Button size="s" variant="outline" lead={<Icon name="download" />}>
Export
</Button>
}
selectedCount={selected.length}
onClearSelection={() => setSelected([])}
bulkActions={
<>
<Button size="s" variant="solid" onClick={addToOrder}>
Add to order
</Button>
<Button size="s" variant="outline" onClick={markCounted}>
Mark as counted
</Button>
</>
}
/>
<DataGrid
label="Stock items"
dense={density === "compact"}
selectable="multiple"
selected={selected}
onSelectedChange={setSelected}
sort={sort}
onSortChange={setSort}
hiddenColumns={hidden}
rows={rows}
rowKey={(item) => item.id}
empty="Nothing on the shelf matches. Clear a filter or search for something else."
columns={[
{
key: "name",
header: "Item",
width: 200,
pin: "start",
sortable: true,
render: (item) => (
<span className="blk-records-page-item">
<span>{item.name}</span>
<span className="blk-records-page-sku">
{item.id}
{ordered.includes(item.id) && " · on order"}
</span>
</span>
),
},
{ key: "category", header: "Category", width: 112, sortable: true },
{ key: "supplier", header: "Supplier", width: 160, sortable: true },
{
key: "status",
header: "Status",
width: 116,
sortable: true,
render: (item) => <Badge tone={STATUS[statusOf(item)].tone}>{STATUS[statusOf(item)].label}</Badge>,
},
{
key: "onHand",
header: "On hand",
align: "end",
width: 132,
sortable: true,
render: (item) => (
<span className="blk-records-page-level">
<span>
{qty.format(item.onHand)} {item.unit}
</span>
<span className="blk-records-page-par">par {qty.format(item.par)}</span>
</span>
),
},
{ key: "value", header: "Value", align: "end", width: 104, sortable: true, format: (v) => gbp.format(Number(v)) },
{ key: "counted", header: "Counted", width: 104, sortable: true, format: (v) => day.format(new Date(`${String(v)}T00:00:00Z`)) },
]}
/>
<div className="blk-records-page-foot">
<Text size="s" tone="quiet" aria-live="polite">
{sorted.length === 0 ? "No items" : `Showing ${from} to ${to} of ${sorted.length} items`}
</Text>
<Pagination aria-label="Stock pages" page={current} count={pages} onPageChange={setPage} />
</div>
</div>
</div>
</div>
);
}components/blocks/records-page.css58 lines
@layer blocks {
.blk-records-page {
container: blk-records-page / inline-size;
background: var(--eq-bg);
color: var(--eq-ink);
}
.blk-records-page-inner {
display: grid;
gap: var(--eq-space-5);
max-width: 76rem;
margin-inline: auto;
padding: var(--eq-space-5) var(--eq-space-3) var(--eq-space-6);
}
.blk-records-page-table {
display: grid;
gap: var(--eq-space-3);
min-width: 0;
}
.blk-records-page-item {
display: grid;
line-height: 1.25;
}
.blk-records-page-sku,
.blk-records-page-par {
color: var(--eq-ink-3);
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-size: var(--eq-text-xs);
}
.blk-records-page-level {
display: grid;
justify-items: end;
line-height: 1.25;
}
.blk-records-page-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--eq-space-3);
}
.blk-records-page-foot p {
margin: 0;
}
@container blk-records-page (width >= 48rem) {
.blk-records-page-inner {
padding: var(--eq-space-6) var(--eq-space-5);
}
}
}