Blocks / Application

Records page

A list screen for records: header, toolbar with search and filters, a sortable grid with selection, and paging.

Width

Stock

Everything on Marrow's shelves, counted from deliveries and sales. 20 items are below par before Saturday.

  • Shelf value £1,182.86
  • Last full count Monday 9 Nov
Cornish musselsSKU-1180FishShellseekersOut0 kgpar 5£0.00
Crème fraîcheSKU-1061DairyNeal's Yard DairyOut0 kgpar 2£0.00
PuntarelleSKU-1110VegNatooraOut0 kgpar 2£0.00
Rye flourSKU-1250BakeryWildfarmedOut0 kgpar 5£0.00
Banana shallotsSKU-1082VegNatooraBelow par2 kgpar 5£9.80
Beef drippingSKU-1159MeatFlock & HerdBelow par1 kgpar 2£6.50
Chicken thighsSKU-1145MeatFlock & HerdBelow par4 kgpar 8£36.80
Cultured butterSKU-1040DairyNeal's Yard DairyBelow par1.5 kgpar 6£21.30
Decaf beansSKU-1299CoffeeSquare MileBelow par0.5 kgpar 1£14.50
Double creamSKU-1047DairyNeal's Yard DairyBelow par3 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-page

Built 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&apos;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);
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index