"use client";
/* The primitives the staff app needs that the counter app never did.
*
* components/m.tsx already carries the shared half of the system — app bar, 64px bars, rows,
* sections, dark panels, chips, steppers — and this file adds the rest of the recipes from the
* handoff rather than restating them. Anything that exists in m.tsx is imported, not re-drawn:
* two implementations of a 64px flush-left button is how a design system stops being one.
*
* House rules that every component here obeys, because they are the system:
* · radius 0, always. No shadows. Hierarchy comes from rules and fills.
* · 2px rules between sections, 1px between rows, 4px accent bar under the app bar.
* · 44px is the floor for anything you tap.
* · gaps between sibling options are 2px — never 0, never 8.
* · button labels are flush left with the icon pushed right. Nothing is centred except the
* date separators in a message thread.
* · status is carried by a word. Colour only ever reinforces it.
*/
import Link from "next/link";
import { createContext, useContext, useId, useRef, useState } from "react";
import { GARMENT_CATEGORIES, garmentCategory, type GarmentCategory } from "@/lib/compute";
import { ACCENT, GROUND, INK, IconRight, MStepper } from "./m";
const DIVIDER = "var(--color-divider)";
const ACCENT_300 = "var(--color-accent-300)";
const ACCENT_700 = "var(--color-accent-700)";
const N200 = "var(--color-neutral-200)";
const N300 = "var(--color-neutral-300)";
const N400 = "var(--color-neutral-400)";
const N500 = "var(--color-neutral-500)";
const N600 = "var(--color-neutral-600)";
const N700 = "var(--color-neutral-700)";
const SURFACE = "var(--color-surface)";
/* ---------------------------------------------------------------- text ---- */
export const Kicker = ({ children, tone = "quiet" }: { children: React.ReactNode; tone?: "quiet" | "attention" | "dark" }) => (
{children}
);
/* ---------------------------------------------------------------- identity ---- */
/** 1A’s identity block: who you are, then the sizes the linen room has on file. */
/** Who this is: the ward and staff number over the name, at the top of Home.
*
* It carried the recorded top and trouser sizes too, and they have gone. Home answers one
* question — is anything waiting for me — and a size is not something anybody acts on from here.
* They still sit on Kit, which is where a wearer goes to see their own record. */
export function IdentityBlock({ ward, num, name }: { ward: string; num: string; name: string }) {
return (
;
}
/** A divided row inside a dark card — "COLLECTION CODE 4 8 2 6". */
export function DarkRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
{label}{value}
);
}
/** The code someone holds up at the counter. Big, because it is read across a desk. */
export function CodeBlock({ code, kicker = "Show at the counter" }: { code: string; kicker?: string }) {
return (
{kicker}
{code}
);
}
/* ---------------------------------------------------------------- segments & tabs ---- */
/** Two or three mutually exclusive views. 2px gaps; the selected one inverts.
*
* The inversion is the only thing that says which view you are on, and an ink fill is not
* something a screen reader can see — so `aria-pressed` says it in words, and the row announces
* itself as one group rather than as two unrelated buttons. */
export function Segments({ options, value, onPick, label = "View" }: {
options: { key: T; label: string }[]; value: T; onPick: (k: T) => void; label?: string;
}) {
return (
);
}
/** Tabs with counts — OPEN 3 / DONE 11.
*
* Pressed rather than the full tablist/tabpanel machinery: these swap the list underneath rather
* than switching between labelled panels, and claiming a role the markup doesn't keep would be
* worse than the honest one. */
export function Tabs({ options, value, onPick, label = "Filter" }: {
options: { key: T; label: string; count?: number }[]; value: T; onPick: (k: T) => void; label?: string;
}) {
return (
);
}
/* ---------------------------------------------------------------- notice & banner ---- */
/** A broadcast from the linen room. Not a message — nobody replies to it. */
export function Notice({ kicker = "From the linen room", children }: { kicker?: string; children: React.ReactNode }) {
return (
{kicker}
{children}
);
}
/** An in-app notification, directly under the accent bar. Tapping it goes there and clears it. */
export function Banner({ title, body, onOpen, onDismiss }: {
title: string; body: string; onOpen?: () => void; onDismiss?: () => void;
}) {
return (
{onDismiss && (
)}
);
}
/* ---------------------------------------------------------------- timeline ---- */
export type Step = { label: string; meta?: string; state: "done" | "current" | "future" };
/** The order's progress. A step that hasn't happened is always shown, as an outlined dot — the
* point of the screen is what is still to come as much as what has happened. */
export function Timeline({ steps }: { steps: Step[] }) {
return (
{steps.map((s, i) => {
const last = i === steps.length - 1;
return (
{s.label}
{s.meta &&
{s.meta}
}
);
})}
);
}
/* ---------------------------------------------------------------- messages ---- */
export function DateSeparator({ children }: { children: React.ReactNode }) {
// The only centred text in the system.
return (
);
}
/** The strip under the app bar saying which order this thread belongs to. */
export function ContextStrip({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
export function Composer({ value, onChange, onSend, busy, placeholder = "Write a message" }: {
value: string; onChange: (v: string) => void; onSend: () => void; busy?: boolean; placeholder?: string;
}) {
return (
);
}
/* ---------------------------------------------------------------- forms ---- */
/* The id of the heading a NumberedField drew, handed down to whatever grouped control it wraps.
*
* A group of options needs a name, and the name is already on the screen — "02 SIZE AND QUANTITY".
* Passing the id through context rather than as a prop keeps every call site unchanged: the field
* knows what it wrote, the control inside it points at that, and nobody has to invent an id at each
* of the dozen places these are used. */
const FieldLabelId = createContext(undefined);
/** `01` in accent, then the label, then the control. Carried over from ThreadCount onboarding,
* where the numbering reinforces the counting identity. */
export function NumberedField({ n, label, children, first }: { n: number; label: string; children: React.ReactNode; first?: boolean }) {
const labelId = useId();
return (
{String(n).padStart(2, "0")}{label}
{children}
);
}
/** A list of mutually exclusive options at 2px gaps. Unavailable options are shown, greyed —
* never hidden, because "it isn't there" is information the person came for.
*
* Announced as a radio group, because that is what it is: exactly one answer, and the answer was
* previously carried by an ink fill and a heavier weight — nothing a screen reader could report,
* so every option sounded identical before and after it was chosen. `label` names the group when
* a NumberedField holds more than one of these; otherwise the field's own heading names it.
* Arrow keys move between options the way a radio group is expected to, and the buttons stay
* buttons, so tapping and the Enter key behave exactly as they did. */
export function OptionList({ options, value, onPick, columns = 1, label }: {
options: { key: T; label: string; meta?: string; disabled?: boolean }[];
value: T | null; onPick: (k: T) => void; columns?: number; label?: string;
}) {
const fieldLabelId = useContext(FieldLabelId);
const box = useRef(null);
function onKeyDown(e: React.KeyboardEvent) {
const step = e.key === "ArrowDown" || e.key === "ArrowRight" ? 1 : e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 0;
if (!step) return;
const live = options.filter((o) => !o.disabled);
if (live.length < 2) return;
e.preventDefault();
const at = live.findIndex((o) => o.key === value);
// Nothing picked yet: an arrow starts at whichever end it is heading away from.
const next = at < 0 ? (step > 0 ? live[0] : live[live.length - 1]) : live[(at + step + live.length) % live.length];
onPick(next.key);
box.current?.querySelector(`[data-opt="${CSS.escape(next.key)}"]`)?.focus();
}
return (
);
}
/** Square, like everything else. */
export function Toggle({ on, onChange, label, disabled }: { on: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) {
return (
);
}
/** The optional photo. Dashed, because it is the one thing on the screen that isn’t required. */
export function PhotoWell({ has, onPick, hint }: { has: boolean; onPick: () => void; hint?: string }) {
return (
);
}
/* ---------------------------------------------------------------- rows ---- */
/** A row with an emphasis border. accent = needs attention, divider = neutral, ink = informational. */
export function EdgeRow({ tone = "divider", onClick, href, children }: {
tone?: "accent" | "divider" | "ink"; onClick?: () => void; href?: string; children: React.ReactNode;
}) {
const edge = tone === "accent" ? ACCENT : tone === "ink" ? INK : DIVIDER;
const st: React.CSSProperties = {
display: "block", width: "100%", textAlign: "left", font: "inherit", color: INK,
background: "#fff", border: 0, borderLeft: `6px solid ${edge}`, borderRadius: 0,
padding: "14px 16px", cursor: onClick || href ? "pointer" : "default", textDecoration: "none",
};
if (href) return {children};
if (onClick) return ;
return
{children}
;
}
/** A muted row — a job already done. */
export function DoneRow({ children }: { children: React.ReactNode }) {
return
{children}
;
}
/** 44px is the floor for anything you tap. */
export function CompactAction({ label, onClick, tone = "outline", disabled }: {
label: string; onClick?: () => void; tone?: "outline" | "accent"; disabled?: boolean;
}) {
return (
);
}
/** Full-width, transparent, 2px ink border. The action you are allowed but not encouraged to take. */
export function SecondaryBar({ label, onClick, href, disabled }: { label: string; onClick?: () => void; href?: string; disabled?: boolean }) {
const st: React.CSSProperties = {
minHeight: 52, width: "100%", border: `2px solid ${INK}`, borderRadius: 0, background: "transparent",
color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14,
letterSpacing: "0.08em", textTransform: "uppercase", display: "flex", alignItems: "center",
padding: "0 20px", gap: 12, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1,
textDecoration: "none",
};
const inner = <>{label}>;
if (href && !disabled) return {inner};
return ;
}
/** The word a ward is allowed to see. Never a number, and never colour on its own. */
export function StockTag({ word }: { word: "in_stock" | "low" | "none" | string }) {
const label = word === "in_stock" ? "In stock" : word === "low" ? "Low" : "None on shelf";
return (
{label}
);
}
/* ---------------------------------------------------------------- request lines ---- */
/* One request covers as many garments as the person needed, so six screens draw a list where they
* used to print a single bold line. All of it lives here rather than in each screen, for the same
* reason the status words live in lib/staffreq: the manager deciding, the wearer reading the
* outcome, the desk signing for the bag and the linen room picking it have to describe the same
* garments the same way. A declined fleece struck through on one screen and silently missing on
* the next is exactly the confusion this flow exists to end.
*/
/** A garment line, however it reached the screen — a saved RequestLine or one being drafted. The
* decision fields are optional because nothing has been decided while somebody is still typing. */
export type LineLike = {
id: string; item: string; size: string; qty: number;
status?: string; declineReason?: string | null;
};
/** How a line reads everywhere, in one place. */
export function lineText(l: { qty: number; item: string; size: string }): string {
return `${l.qty} × ${l.item} — ${l.size}`;
}
/** The lines of a request, as a record. Declines are struck through and carry their reason: a
* wearer whose fleece was refused should be able to see that on the order rather than count the
* bag and wonder. The approved word only appears on a split decision — where everything was
* approved the request's own status has already said so. */
export function LineList({ lines }: { lines: readonly LineLike[] }) {
const mixed = lines.some((l) => l.status === "declined") && lines.some((l) => l.status === "approved");
return (
);
}
/** A line on a request nobody has sent yet. `key` is the row's identity while it is being edited —
* the same garment in two sizes is two rows, and neither has an id until the server writes one. */
export type DraftLine = { key: string; itemId: string; si: number; item: string; size: string; qty: number };
/** The list somebody is building. Every row can be counted up and down or taken out again, which is
* the whole difference between this and the old one-garment form: getting a line wrong costs a tap
* rather than a second request and a second approval. */
export function DraftLineList({ lines, maxQty, onQty, onRemove }: {
lines: readonly DraftLine[]; maxQty: number;
onQty: (key: string, qty: number) => void; onRemove: (key: string) => void;
}) {
return (
{lines.map((l) => (
{l.item} — {l.size}
onQty(l.key, v)} min={1} max={maxQty} />
))}
);
}
export type PickerSize = { size: string; si: number; word: string; countedOn?: string; held?: number };
export type PickerItem = { id: string; item: string; type: string; gender?: string; sizes: PickerSize[] };
/** Choosing one garment to add to a request. Shared by the wearer's own request screen and the one
* a manager raises on somebody else's behalf, because the only thing that differs between them is
* whose sizes and holdings are being shown — and that arrives as `defaultSi` and `note` rather
* than as a second copy of this.
*
* The garment list is narrowed by a row of category chips — Tops, Bottoms, Maternity, Outerwear,
* Everything else. On a real catalogue the flat list is the longest scroll in the flow and it is
* walked once per garment, on a request that often runs to three or four.
*
* Chips rather than a heading over each group, for two reasons. A heading labels a long scroll;
* only a filter shortens it, and the length is the complaint. And OptionList's arrow keys walk one
* radiogroup, so a separate list per category would trap the arrows in whichever section they
* started in — one filtered list keeps the keyboard walking the whole picker.
*
* The categories come from garmentCategory(), which reads the type already on the garment and
* falls back to its name, so this needs no new prop and no data entry: both screens that render
* the picker get the grouping without knowing it exists. */
export function GarmentPicker({ items, defaultSi, note, maxQty, addLabel = "Add to the request", onAdd, onCancel }: {
items: readonly I[];
defaultSi: (item: I) => number | null;
note?: (item: I, size: PickerSize | null) => React.ReactNode;
maxQty: number;
addLabel?: string;
onAdd: (line: { itemId: string; si: number; item: string; size: string; qty: number }) => void;
onCancel?: () => void;
}) {
const [itemId, setItemId] = useState(null);
const [si, setSi] = useState(null);
const [qty, setQty] = useState(1);
const [cat, setCat] = useState("all");
const item = items.find((i) => i.id === itemId) || null;
const size = item && si !== null ? item.sizes.find((s) => s.si === si) || null : null;
const catOf: Record = {};
const counts = new Map();
for (const i of items) {
const c = garmentCategory(i);
catOf[i.id] = c;
counts.set(c, (counts.get(c) || 0) + 1);
}
// Only the categories something actually falls in: a facility that stocks no maternity wear must
// never be shown the word, and an empty chip is a promise of garments that aren't there.
const chips = GARMENT_CATEGORIES.filter((c) => counts.has(c.key));
/* Below about a screenful there is nothing to shorten, and a filter row over a list you can
* already see whole is one more thing to read before you can start. Eight 48px options is
* roughly where the list stops fitting on a phone. One category is nothing to filter either. */
const filtering = chips.length > 1 && items.length > 8;
const shown = filtering && cat !== "all" ? items.filter((i) => catOf[i.id] === cat) : items;
function pickCat(k: GarmentCategory | "all") {
setCat(k);
/* A garment half-chosen under the old filter can fall outside the new one, and leaving its
* sizes, note and count on screen under a filter that hides the garment itself is the one
* thing a filter must not do: the next tap would add something nobody can see. */
if (itemId && k !== "all" && catOf[itemId] !== k) { setItemId(null); setSi(null); setQty(1); }
}
return (
)}
c.key === cat)?.label}`}
value={itemId}
onPick={(id) => {
setItemId(id);
setQty(1);
// Opening on the size the record already knows is the difference between three taps and
// one, and the wrong size is what generates the exchange this app exists to stop.
const it = items.find((i) => i.id === id);
setSi(it ? defaultSi(it) : null);
}}
options={shown.map((i) => ({
key: i.id,
label: i.item,
meta: [i.type, i.gender && i.gender !== "Unisex" ? i.gender : ""].filter(Boolean).join(" · "),
}))}
/>
{item && (
<>
setSi(Number(k))}
// Unavailable sizes are shown greyed, never hidden: "it isn't there" is the information
// the person came for.
options={item.sizes.map((s) => ({
key: String(s.si),
label: String(s.size),
meta: [s.word === "none" ? "none" : s.word === "low" ? "low" : "", s.held ? `${s.held} held` : ""].filter(Boolean).join(" · "),
}))}
/>
{note &&
{note(item, size)}
}
{qty === 1 ? "One garment" : `${qty} garments`}
>
)}
{
if (!item || !size) return;
onAdd({ itemId: item.id, si: size.si, item: item.item, size: String(size.size), qty });
// The chosen garment clears for the next one; the category filter deliberately does
// not. Somebody adding two tops is still looking at tops.
setItemId(null); setSi(null); setQty(1);
}}
/>
{onCancel && }