"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 (
{[ward, num].filter(Boolean).join(" · ") || "Staff"}
{name}
); } /* ---------------------------------------------------------------- dark card ---- */ /** The ink card: the live order on Home, the collection code, the waitlist position. */ export function DarkCard({ kicker, title, meta, children, onClick, href }: { kicker?: string; title?: React.ReactNode; meta?: React.ReactNode; children?: React.ReactNode; onClick?: () => void; href?: string; }) { const inner = ( <> {kicker &&
{kicker}
} {title &&
{title}
} {meta &&
{meta}
} {children} ); const st: React.CSSProperties = { background: INK, color: GROUND, padding: 18, display: "block", width: "100%", border: 0, borderRadius: 0, textAlign: "left", font: "inherit", cursor: onClick || href ? "pointer" : "default", }; if (href) return {inner}; if (onClick) return ; return
{inner}
; } /** 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 (
{options.map((o) => { const on = o.key === value; 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 (
{options.map((o) => { const on = o.key === value; return ( ); })}
); } /* ---------------------------------------------------------------- quick actions ---- */ export function QuickGrid({ items }: { items: { label: string; href?: string; onClick?: () => void; icon: React.ReactNode }[] }) { return (
{items.map((it) => { const inner = ( <> {it.icon} {it.label} ); const st: React.CSSProperties = { background: "#fff", minHeight: 96, padding: "16px 14px", border: 0, borderRadius: 0, font: "inherit", color: INK, textAlign: "left", cursor: "pointer", textDecoration: "none", display: "block", }; return it.href ? {inner} : ; })}
); } /* ---------------------------------------------------------------- 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 (
{children}
); } export function Bubble({ mine, author, body, stamp }: { mine: boolean; author?: string; body: string; stamp: string }) { return (
{!mine && author &&
{author}
}
{body}
{stamp}
); } /** 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 (
{ e.preventDefault(); if (value.trim() && !busy) onSend(); }} style={{ display: "flex", flex: "0 0 60px", height: 60, borderTop: "2px solid " + INK, background: "#fff" }} > onChange(e.target.value)} placeholder={placeholder} // The placeholder is the only thing naming this box, and it disappears the moment anybody // types — so the same words are given as the label. aria-label={placeholder} style={{ flex: 1, minWidth: 0, border: 0, padding: "0 16px", font: "inherit", fontSize: 15, background: "transparent", color: INK, borderRadius: 0 }} />
); } /* ---------------------------------------------------------------- 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 (
{options.map((o) => { const on = o.key === value; 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 (
{lines.map((l) => { const off = l.status === "declined"; return (
{lineText(l)}
{off && (
Declined{l.declineReason ? ` — ${l.declineReason}` : ""}
)} {!off && mixed && (
Approved
)}
); })}
); } /** 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 (
{filtering && (
{[{ key: "all" as const, label: "All", n: items.length }, ...chips.map((c) => ({ key: c.key, label: c.label, n: counts.get(c.key) || 0 }))].map((c) => { const on = c.key === cat; 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 && }
); } export { ACCENT, ACCENT_300, ACCENT_700, DIVIDER, GROUND, INK, N200, N300, N400, N500, N600, N700, SURFACE };