"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 { useStaff } from "@/lib/staffclient"; import { ACCENT, GROUND, INK, IconRight, MONO, MStepper, OK } 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, group }: { ward: string; num: string; name: string; group?: string }) { return (
{name}
{/* The staff group belongs beside the ward and the number: it is what decides which garments this person is offered, and somebody declined "wrong item for the role" should be able to see the role they were measured against without asking. */}
{[ward, num, group].filter(Boolean).join(" · ") || "Staff"}
); } /* ---------------------------------------------------------------- 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: 16, 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} {/* Mono, like every other place a collection code is shown: the same four digits have to be recognisable on Home, on the order and on the full screen, and a proportional face makes 8 and B an argument at a counter. */} {value}
); } /** The action inside a dark card — "Show at the counter →". Ground-outlined rather than filled: * the card is already the loudest thing on the screen, and two fills inside one another read as * two separate things to do. */ export function DarkButton({ label, href, onClick }: { label: string; href?: string; onClick?: () => void }) { const st: React.CSSProperties = { display: "flex", alignItems: "center", gap: 12, width: "100%", minHeight: 48, marginTop: 16, border: `2px solid ${GROUND}`, borderRadius: 0, background: "transparent", color: GROUND, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", padding: "0 14px", cursor: "pointer", textDecoration: "none", }; const inner = <>{label}; if (href) return {inner}; return ; } /** 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 (
{/* Muted rather than accent: the code under it is the loudest thing on the screen, and a second colour above it competes with the digits somebody is reading across a counter. */}
{kicker}
{/* Mono and big, because it is read out across a counter; centred because there is nothing beside it to line up with. The digits are announced singly for the same reason the full screen's are — 54px of mono reads as one four-digit number. */}
{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 (
{/* One box, with 2px of ink between the segments. The border is what says these three are the same control; as separate fills on the ground they read as three buttons. */}
{options.map((o, i) => { 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 ( /* One box with 2px of ink between the tabs, inset from the edge — the same control `Segments` draws, because Open / Done and Holding / My sizes are the same gesture and reading as two different controls is how a system stops being one. The count is mono beside the word, never instead of it. */
{options.map((o, i) => { const on = o.key === value; return ( ); })}
); } /* ---------------------------------------------------------------- quick actions ---- */ export function QuickGrid({ items }: { items: { label: string; caption?: string; href?: string; onClick?: () => void; icon?: React.ReactNode }[] }) { return ( /* An ink frame with ink between the tiles, so four shortcuts read as one block. `icon` is optional: the approved tiles carry a caption over a label and nothing else, and an empty icon slot pushed the label off the bottom of the tile. */
{items.map((it) => { const inner = ( <> {it.icon && {it.icon}} {/* The caption sits above the label, muted: it says what the tile is for — "Last: Navy tunic" — and the label is what you tap, so the label reads last and loudest. */} {it.caption && {it.caption}} {it.label} ); const st: React.CSSProperties = { background: "#fff", minHeight: 88, padding: 12, border: 0, borderRadius: 0, font: "inherit", color: INK, textAlign: "left", cursor: "pointer", textDecoration: "none", display: "flex", flexDirection: "column", justifyContent: "flex-end", }; return it.href ? {inner} : ; })} {/* An odd number of tiles would leave the ink ground showing through the empty half of the last row, which reads as a broken tile rather than as a frame. It happens in the ordinary way: "Same again" is only drawn for somebody who has asked for something before. */} {items.length % 2 === 1 && (
); } /* ---------------------------------------------------------------- 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}
); } /* ---------------------------------------------------------------- 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}
{/* Mono, as the mockup has it: a column of stamps that line up is read down rather than across, and tabular figures are what make that possible. */} {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 (
{/* A 2px ink border rather than an accent edge: the accent edge is the linen room's voice on a row, and in a thread every bubble from them would wear it. The mockup draws both sides as bordered boxes, the wearer's own filled ink. */}
{body}
{/* Who said it and when, on one line under the words. As an uppercase kicker above the body it read as a heading on every message — three words of chrome per sentence, in a thread that is mostly one-liners. */}
{author ? `${author} · ${stamp}` : stamp}
); } /** The strip under the app bar saying which order this thread belongs to. * * The mockup's `.notice`: a bordered white box inset from the edge, not a full-bleed band. It is * about the order rather than from the linen room, so it takes the 2px ink border and none of the * accent edge that marks their voice. */ 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", gap: 8, flex: "0 0 auto", padding: "10px 16px calc(10px + env(safe-area-inset-bottom, 0px))", 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} // 16px, never smaller: anything under it and the phone zooms the whole screen when the box // takes focus. The box is drawn as a box, as the mockup has it. style={{ flex: 1, minWidth: 0, minHeight: 48, border: `2px solid ${INK}`, padding: "0 12px", font: "inherit", fontSize: 16, background: GROUND, 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 = { /* A top rule and nothing else. Boxed, it read as a second button competing with the 64px bar under it; the mockup's `.sbar` is the foot of the screen rather than a control floating on it. */ minHeight: 52, width: "100%", border: 0, borderTop: `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 ; } /** A full-width outlined button with its label centred — the mockup's `.btn`. * * Distinct from `SecondaryBar`, which is the foot of a screen: a top rule, a flush-left label and * an arrow. This one is a button sitting in the flow of a screen, where the mockup draws a box — * "Show at the counter" under the collection code, and the two actions on the Sent screen. */ export function OutlineButton({ label, href, onClick, disabled }: { label: string; href?: string; onClick?: () => void; 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.05em", textTransform: "uppercase", display: "flex", alignItems: "center", justifyContent: "center", padding: "0 14px", textDecoration: "none", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1, }; if (href && !disabled) return {label}; 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"; /* Filled, as the mockup draws it — but the word is still the whole message: somebody who cannot tell the three fills apart reads `Low` and `None on shelf` and knows exactly the same thing. The label stays this element's only child on purpose; the behavioural suite greps for a tag whose text is exactly `Low`. */ const fill = word === "in_stock" ? OK : word === "none" ? ACCENT : SURFACE; 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 && }
); } /* ---------------------------------------------------------------- banners, tabs, states ---- */ /** The one full-bleed banner at the top of Home: "3 requests waiting on you ›". * * A link, not a card with a button in it: the whole strip is the target, which is the only sensible * size for something somebody taps while walking. At most one is ever drawn — a home screen with * two things shouting at once has nothing at the top. */ export function AlertBar({ title, href }: { title: string; href: string }) { return ( {title} ); } /** A segmented control whose segments are URLs — the Team shell's tabs. * * Links rather than buttons, because each tab is a real screen with its own address: the back * button works, an emailed link lands on the right tab, and a screen reader is told these are * places to go. That is also why the chosen one carries `aria-current="page"` and not * `aria-pressed` — `Segments` is the button version, for views that swap a list in place. */ export function SegmentLinks({ options, active, label = "View" }: { options: { href: string; label: string; count?: number }[]; active: string; label?: string; }) { return ( ); } /** The in-app offline bar, drawn once by the (app) layout and never by a screen. * * It says the one thing somebody standing in a corridor needs to know — that nothing they typed * has been thrown away — and offers to look again. Retry never re-sends: none of the staff ops are * idempotent, so a replay raises a second request and emails the manager twice (see mutate()). */ export function OfflineBar() { const { online, retry } = useStaff(); if (online) return null; return (
No signal. Nothing you typed is lost.
); } /** The collection code, full screen and nothing else: this is held up across a counter, often at * arm's length, and everything else on the screen is something to read past. * * ⛔ Done is a real link to the order, never `history.back()`. This screen is opened cold at least * three ways — a notification tap, a refresh on a ward phone, an app link — and in each of them * there is no history to pop, so a back-button Done walks the person out of the app holding an * unread code. * * The digits are given an aria-label that reads them singly: 92px mono is announced as one * four-digit number, and somebody reading it out to a clerk needs the digits. */ export function FullCode({ code, name, lines, backHref }: { code: string; name: string; lines: string[]; backHref: string; }) { return (
Show at the counter
{code}
{name}
{lines.length > 0 && (
{lines.map((l, i) =>
{l}
)}
)} Done
); } /** "Signing — 2 of 3 signed", with a rule that fills as it goes. * * The words are in a `role="status"` so the count is announced as it climbs; the rule itself is * decoration and says nothing, so it is hidden. */ export function Progress({ label, done, total, unit }: { label: string; done: number; total: number; unit?: string }) { const pct = total > 0 ? Math.max(0, Math.min(100, Math.round((done / total) * 100))) : 0; return (
{label} — {done} of {total}{unit ? ` ${unit}` : ""}
); } /** One signed hand-over: the day, what was on it, and the signature underneath. * * Quantities appear here and nowhere else on a wearer's side of the app. This is their own slip, * which they signed at the counter, and three identical lines under one date read as a bug. */ export function SlipCard({ date, lines, sigSrc }: { date: string; lines: { item: string; size: string; qty: number }[]; sigSrc?: string; }) { return (
{date}
    {lines.map((l, i) => (
  • {lineText(l)}
  • ))}
{sigSrc && }
); } /** The signature under a slip, with the label the mockup gives it. * * `/api/staff/slip//sig` can 404 for an old slip whose image is no longer on disk, and a * broken-image icon on somebody's own signed record reads as a fault in the record rather than a * missing file. A failed load leaves the label and says, quietly, that it is not stored. */ function SlipSignature({ src }: { src: string }) { const [failed, setFailed] = useState(false); return (
Your signature {failed ? not stored // eslint-disable-next-line @next/next/no-img-element : Your signature setFailed(true)} style={{ display: "block", maxHeight: 70, maxWidth: "62%" }} />}
); } export { ACCENT, ACCENT_300, ACCENT_700, DIVIDER, GROUND, INK, N200, N300, N400, N500, N600, N700, OK, SURFACE };