Files
threadcount-community/components/staffui.tsx
T
ThreadCount 7d650e4c10 ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-15 18:27:45 +10:00

721 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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" }) => (
<div style={{
fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase",
color: tone === "attention" ? ACCENT_700 : tone === "dark" ? ACCENT_300 : N600,
}}>{children}</div>
);
/* ---------------------------------------------------------------- identity ---- */
/** 1As 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 (
<div style={{ padding: "20px 16px 18px", borderBottom: "2px solid " + INK, background: GROUND }}>
<Kicker>{[ward, num].filter(Boolean).join(" · ") || "Staff"}</Kicker>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 28, letterSpacing: "-0.02em", lineHeight: 1.15, marginTop: 8 }}>{name}</div>
</div>
);
}
/* ---------------------------------------------------------------- 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 && <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>{kicker}</div>}
{title && <div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "-0.01em", lineHeight: 1.2, marginTop: 8 }}>{title}</div>}
{meta && <div style={{ fontSize: 13, color: N300, marginTop: 8, lineHeight: 1.5 }}>{meta}</div>}
{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 <Link href={href} style={{ ...st, textDecoration: "none" }} className="tcx-bar">{inner}</Link>;
if (onClick) return <button onClick={onClick} style={st} className="tcx-bar">{inner}</button>;
return <div style={st}>{inner}</div>;
}
/** A divided row inside a dark card — "COLLECTION CODE 4 8 2 6". */
export function DarkRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N400 }}>{label}</span>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "0.16em" }}>{value}</span>
</div>
);
}
/** 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 (
<div style={{ background: INK, color: GROUND, padding: 18 }}>
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>{kicker}</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 38, letterSpacing: "0.2em", marginTop: 10 }}>{code}</div>
</div>
);
}
/* ---------------------------------------------------------------- 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<T extends string>({ options, value, onPick, label = "View" }: {
options: { key: T; label: string }[]; value: T; onPick: (k: T) => void; label?: string;
}) {
return (
<div role="group" aria-label={label} style={{ display: "flex", gap: 2, padding: "12px 16px", background: GROUND }}>
{options.map((o) => {
const on = o.key === value;
return (
<button key={o.key} onClick={() => onPick(o.key)} aria-pressed={on} style={{
flex: 1, minHeight: 44, border: 0, background: on ? INK : "#fff", color: on ? GROUND : N700,
font: "inherit", fontWeight: on ? 800 : 600, fontSize: 13, letterSpacing: "0.06em",
textTransform: "uppercase", cursor: "pointer", borderRadius: 0,
}}>{o.label}</button>
);
})}
</div>
);
}
/** 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<T extends string>({ options, value, onPick, label = "Filter" }: {
options: { key: T; label: string; count?: number }[]; value: T; onPick: (k: T) => void; label?: string;
}) {
return (
<div role="group" aria-label={label} style={{ display: "flex", gap: 2, background: GROUND, borderBottom: "2px solid " + INK }}>
{options.map((o) => {
const on = o.key === value;
return (
<button key={o.key} onClick={() => onPick(o.key)} aria-pressed={on} style={{
flex: 1, minHeight: 48, border: 0, background: on ? INK : "transparent", color: on ? GROUND : N600,
font: "inherit", fontWeight: 800, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase",
cursor: "pointer", borderRadius: 0,
}}>
{o.label}{o.count === undefined ? "" : ` ${o.count}`}
</button>
);
})}
</div>
);
}
/* ---------------------------------------------------------------- quick actions ---- */
export function QuickGrid({ items }: { items: { label: string; href?: string; onClick?: () => void; icon: React.ReactNode }[] }) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, background: DIVIDER }}>
{items.map((it) => {
const inner = (
<>
<span style={{ color: ACCENT, display: "block" }}>{it.icon}</span>
<span style={{ fontSize: 15, fontWeight: 800, lineHeight: 1.3, marginTop: 12, display: "block" }}>{it.label}</span>
</>
);
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
? <Link key={it.label} href={it.href} style={st} className="tcx-bar">{inner}</Link>
: <button key={it.label} onClick={it.onClick} style={st} className="tcx-bar">{inner}</button>;
})}
</div>
);
}
/* ---------------------------------------------------------------- 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 (
<div style={{ background: "#fff", borderLeft: "6px solid " + ACCENT, padding: "14px 16px", margin: "16px 0 0" }}>
<Kicker>{kicker}</Kicker>
<div style={{ fontSize: 14, lineHeight: 1.5, marginTop: 6 }}>{children}</div>
</div>
);
}
/** 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 (
<div style={{ background: INK, color: GROUND, borderLeft: "6px solid " + ACCENT, padding: "12px 14px", display: "flex", gap: 12, alignItems: "flex-start" }}>
<button onClick={onOpen} style={{ flex: 1, minWidth: 0, background: "none", border: 0, padding: 0, font: "inherit", color: "inherit", textAlign: "left", cursor: "pointer" }}>
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>ThreadCount · now</div>
<div style={{ fontSize: 15, fontWeight: 800, marginTop: 5 }}>{title}</div>
<div style={{ fontSize: 13, color: N300, marginTop: 4, lineHeight: 1.45 }}>{body}</div>
</button>
{onDismiss && (
<button onClick={onDismiss} aria-label="Dismiss" style={{ background: "none", border: 0, color: N400, padding: 4, cursor: "pointer", flex: "0 0 auto", font: "inherit", fontSize: 18, lineHeight: 1 }}>×</button>
)}
</div>
);
}
/* ---------------------------------------------------------------- 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 (
<div style={{ padding: "16px 16px 4px" }}>
{steps.map((s, i) => {
const last = i === steps.length - 1;
return (
<div key={i} style={{
position: "relative", marginLeft: 5, paddingLeft: 20, paddingBottom: last ? 4 : 18,
borderLeft: last ? "2px solid transparent" : `2px solid ${s.state === "current" ? ACCENT : INK}`,
}}>
<span style={{
position: "absolute", left: -7, top: 2, width: 12, height: 12,
background: s.state === "future" ? "transparent" : s.state === "current" ? ACCENT : INK,
border: s.state === "future" ? `2px solid ${N500}` : "none",
}} />
<div style={{
fontSize: 15, fontWeight: 800, lineHeight: 1.3,
color: s.state === "current" ? ACCENT_700 : s.state === "future" ? N600 : INK,
}}>{s.label}</div>
{s.meta && <div style={{ fontSize: 12, color: N600, marginTop: 3, lineHeight: 1.45 }}>{s.meta}</div>}
</div>
);
})}
</div>
);
}
/* ---------------------------------------------------------------- messages ---- */
export function DateSeparator({ children }: { children: React.ReactNode }) {
// The only centred text in the system.
return (
<div style={{ textAlign: "center", fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600, padding: "16px 0 8px" }}>
{children}
</div>
);
}
export function Bubble({ mine, author, body, stamp }: { mine: boolean; author?: string; body: string; stamp: string }) {
return (
<div style={{ display: "flex", justifyContent: mine ? "flex-end" : "flex-start", padding: "4px 16px" }}>
<div style={{
maxWidth: 300, padding: 14, background: mine ? INK : "#fff", color: mine ? GROUND : INK,
borderLeft: mine ? undefined : "6px solid " + ACCENT,
}}>
{!mine && author && <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600, marginBottom: 6 }}>{author}</div>}
<div style={{ fontSize: 14.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{body}</div>
<div style={{ fontSize: 12, marginTop: 8, color: mine ? N400 : N600 }}>{stamp}</div>
</div>
</div>
);
}
/** The strip under the app bar saying which order this thread belongs to. */
export function ContextStrip({ children }: { children: React.ReactNode }) {
return (
<div style={{ background: N200, padding: "10px 16px", fontSize: 12, borderBottom: "2px solid " + INK, color: N700 }}>
{children}
</div>
);
}
export function Composer({ value, onChange, onSend, busy, placeholder = "Write a message" }: {
value: string; onChange: (v: string) => void; onSend: () => void; busy?: boolean; placeholder?: string;
}) {
return (
<form
onSubmit={(e) => { e.preventDefault(); if (value.trim() && !busy) onSend(); }}
style={{ display: "flex", flex: "0 0 60px", height: 60, borderTop: "2px solid " + INK, background: "#fff" }}
>
<input
value={value} onChange={(e) => 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 }}
/>
<button type="submit" disabled={busy || !value.trim()} style={{
flex: "0 0 auto", padding: "0 20px", background: ACCENT, color: "#fff", border: 0, borderRadius: 0,
fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em",
textTransform: "uppercase", cursor: busy || !value.trim() ? "not-allowed" : "pointer",
opacity: busy || !value.trim() ? 0.45 : 1,
}}>Send</button>
</form>
);
}
/* ---------------------------------------------------------------- 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<string | undefined>(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 (
<div style={{ padding: "18px 16px", borderTop: first ? "none" : "2px solid " + INK }}>
<div style={{ display: "flex", gap: 10, alignItems: "baseline" }}>
<span style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.1em", color: ACCENT }}>{String(n).padStart(2, "0")}</span>
<span id={labelId} style={{ fontSize: 13, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase" }}>{label}</span>
</div>
<div style={{ marginTop: 14 }}>
<FieldLabelId.Provider value={labelId}>{children}</FieldLabelId.Provider>
</div>
</div>
);
}
/** 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<T extends string>({ 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<HTMLDivElement>(null);
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
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<HTMLButtonElement>(`[data-opt="${CSS.escape(next.key)}"]`)?.focus();
}
return (
<div
ref={box}
role="radiogroup"
aria-label={label}
aria-labelledby={label ? undefined : fieldLabelId}
onKeyDown={onKeyDown}
style={{ display: "grid", gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 2 }}
>
{options.map((o) => {
const on = o.key === value;
return (
<button
key={o.key} data-opt={o.key} role="radio" aria-checked={on}
aria-disabled={o.disabled || undefined} disabled={o.disabled}
onClick={() => onPick(o.key)}
style={{
minHeight: 48, padding: "10px 14px", border: 0, borderRadius: 0, textAlign: "left", font: "inherit",
background: o.disabled ? N200 : on ? INK : "#fff",
color: o.disabled ? N500 : on ? GROUND : N700,
fontWeight: on ? 800 : 600, fontSize: 14.5,
cursor: o.disabled ? "not-allowed" : "pointer",
}}>
<span style={{ display: "block" }}>{o.label}</span>
{o.meta && <span style={{ display: "block", fontSize: 12, marginTop: 3, opacity: 0.85 }}>{o.meta}</span>}
</button>
);
})}
</div>
);
}
/** Square, like everything else. */
export function Toggle({ on, onChange, label, disabled }: { on: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) {
return (
<button role="switch" aria-checked={on} aria-label={label} disabled={disabled} onClick={() => onChange(!on)} style={{
width: 52, height: 30, padding: 3, border: `2px solid ${INK}`, borderRadius: 0, background: on ? ACCENT : "#fff",
display: "flex", justifyContent: on ? "flex-end" : "flex-start", alignItems: "center",
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1, flex: "0 0 auto",
}}>
<span style={{ width: 24, height: 24, background: on ? "#fff" : INK, display: "block" }} />
</button>
);
}
/** The optional photo. Dashed, because it is the one thing on the screen that isnt required. */
export function PhotoWell({ has, onPick, hint }: { has: boolean; onPick: () => void; hint?: string }) {
return (
<button onClick={onPick} style={{
width: "100%", minHeight: 96, border: `2px dashed ${has ? INK : DIVIDER}`, borderRadius: 0, background: "#fff",
display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "center",
padding: "16px 14px", cursor: "pointer", font: "inherit", color: INK, gap: 6,
}}>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke={ACCENT} strokeWidth="2.2" strokeLinecap="square" aria-hidden>
<path d="M3 8h4l2-3h6l2 3h4v11H3z" /><circle cx="12" cy="13" r="3.5" />
</svg>
<span style={{ fontSize: 15, fontWeight: 800 }}>{has ? "Photo attached" : "Add a photo"}</span>
{hint && <span style={{ fontSize: 12.5, color: N600 }}>{hint}</span>}
</button>
);
}
/* ---------------------------------------------------------------- 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 <Link href={href} style={st} className="tcx-bar">{children}</Link>;
if (onClick) return <button onClick={onClick} style={st} className="tcx-bar">{children}</button>;
return <div style={st}>{children}</div>;
}
/** A muted row — a job already done. */
export function DoneRow({ children }: { children: React.ReactNode }) {
return <div style={{ background: SURFACE, padding: "14px 16px", color: N600, borderTop: `1px solid ${DIVIDER}` }}>{children}</div>;
}
/** 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 (
<button onClick={onClick} disabled={disabled} style={{
minHeight: 44, padding: "0 14px", borderRadius: 0, font: "inherit",
border: tone === "accent" ? 0 : `2px solid ${INK}`,
background: tone === "accent" ? ACCENT : "transparent",
color: tone === "accent" ? "#fff" : INK,
fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase",
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1, flex: "0 0 auto",
}}>{label}</button>
);
}
/** 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 = <><span style={{ flex: 1, textAlign: "left" }}>{label}</span><IconRight /></>;
if (href && !disabled) return <Link href={href} style={st} className="tcx-bar">{inner}</Link>;
return <button onClick={onClick} disabled={disabled} style={st} className="tcx-bar">{inner}</button>;
}
/** 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 (
<span style={{
fontSize: 13, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase",
color: word === "none" ? ACCENT_700 : N700, whiteSpace: "nowrap",
}}>{label}</span>
);
}
/* ---------------------------------------------------------------- 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 (
<div style={{ display: "grid", gap: 1, background: DIVIDER }}>
{lines.map((l) => {
const off = l.status === "declined";
return (
<div key={l.id} style={{ background: "#fff", padding: "13px 14px" }}>
<div style={{
fontSize: 15.5, fontWeight: 800, lineHeight: 1.35,
textDecoration: off ? "line-through" : "none", color: off ? N600 : INK,
}}>{lineText(l)}</div>
{off && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_700, marginTop: 5 }}>
Declined{l.declineReason ? ` — ${l.declineReason}` : ""}
</div>
)}
{!off && mixed && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600, marginTop: 5 }}>
Approved
</div>
)}
</div>
);
})}
</div>
);
}
/** 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 (
<div style={{ display: "grid", gap: 2 }}>
{lines.map((l) => (
<div key={l.key} style={{ background: "#fff", padding: "12px 14px" }}>
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
<div style={{ flex: 1, minWidth: 0, fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>
{l.item} {l.size}
</div>
<button
onClick={() => onRemove(l.key)}
aria-label={`Take ${l.item}${l.size} off this request`}
style={{
flex: "0 0 auto", minWidth: 44, minHeight: 44, border: 0, background: "transparent",
color: N600, font: "inherit", fontSize: 20, lineHeight: 1, cursor: "pointer", borderRadius: 0,
}}
>×</button>
</div>
<div style={{ marginTop: 10 }}>
<MStepper n={l.qty} onChange={(v) => onQty(l.key, v)} min={1} max={maxQty} />
</div>
</div>
))}
</div>
);
}
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<I extends PickerItem>({ 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<string | null>(null);
const [si, setSi] = useState<number | null>(null);
const [qty, setQty] = useState(1);
const [cat, setCat] = useState<GarmentCategory | "all">("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<string, GarmentCategory> = {};
const counts = new Map<GarmentCategory, number>();
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 (
<div style={{ display: "grid", gap: 14 }}>
{filtering && (
<div role="group" aria-label="Show one kind of garment" style={{ display: "flex", flexWrap: "wrap", gap: 2 }}>
{[{ 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 (
<button
key={c.key} onClick={() => pickCat(c.key)} aria-pressed={on}
style={{
minHeight: 44, padding: "0 14px", border: 0, borderRadius: 0, cursor: "pointer",
background: on ? INK : "#fff", color: on ? GROUND : N600, font: "inherit",
fontWeight: 800, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase",
}}
>{c.label} {c.n}</button>
);
})}
</div>
)}
<OptionList
label={cat === "all" || !filtering ? "Garment" : `Garment — ${chips.find((c) => 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 && (
<>
<OptionList
label="Size"
columns={3}
value={si === null ? null : String(si)}
onPick={(k) => 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 && <div style={{ fontSize: 12.5, color: N600, lineHeight: 1.5 }}>{note(item, size)}</div>}
<div style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
<MStepper n={qty} onChange={setQty} min={1} max={maxQty} />
<span style={{ fontSize: 12.5, color: N600, flex: 1, minWidth: 120, lineHeight: 1.45 }}>
{qty === 1 ? "One garment" : `${qty} garments`}
</span>
</div>
</>
)}
<div style={{ display: "flex", gap: 2 }}>
<CompactAction
label={addLabel}
tone="accent"
disabled={!item || si === null}
onClick={() => {
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 && <CompactAction label="Cancel" onClick={onCancel} />}
</div>
</div>
);
}
export { ACCENT, ACCENT_300, ACCENT_700, DIVIDER, GROUND, INK, N200, N300, N400, N500, N600, N700, SURFACE };