"use client"; /* Shared building blocks for the coordinator portal (/app). Styles live in app/globals.css under * "portal redesign", scoped to .tc-shell or on class names nothing else in the product wears. */ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useId, useRef, useState } from "react"; export type IconName = "today" | "counter" | "stock" | "orders" | "people" | "reports" | "settings" | "phone" | "truck" | "bag" | "doc" | "search" | "scan" | "print" | "mail" | "check" | "plus" | "drag" | "chevronLeft" | "chevronDown" | "alert" | "camera"; /* One glyph per name: 1.8 strokes, square caps, mitred joins, drawn on a 24 grid. */ const GLYPHS: Record = { today: <>, counter: , stock: <>, orders: <>, people: <>, reports: , settings: <>, phone: , truck: <>, bag: <>, doc: <>, search: <>, scan: , print: <>, mail: <>, check: , plus: , drag: , chevronLeft: , chevronDown: , alert: <>, camera: <>, }; export function Icon({ name, size = 18, title }: { name: IconName; size?: 16 | 18; title?: string }) { return ( {title && {title}} {GLYPHS[name]} ); } export function MonoNum({ children, weight = 500, tone = "ink", size, as = "span" }: { children: React.ReactNode; weight?: 400 | 500 | 600; tone?: "ink" | "accent" | "muted"; size?: number; as?: "span" | "b" }) { const Tag = as; const color = tone === "accent" ? "var(--color-accent-700)" : tone === "muted" ? "var(--color-neutral-700)" : undefined; return {children}; } export function Kbd({ children, onAccent }: { children: React.ReactNode; onAccent?: boolean }) { return {children}; } export function Tag({ children, tone = "outline", mono, title }: { children: React.ReactNode; tone?: "outline" | "ink" | "accent" | "low" | "quiet"; mono?: boolean; title?: string }) { return {children}; } export function Meter({ label, value, of, size = "md", showLabel = true }: { label: string; value: number; of: number; size?: "md" | "sm"; showLabel?: boolean }) { const over = value > of; const drawn = Math.max(0, Math.min(of, 12)); const filled = Math.min(value, drawn); return (
{showLabel && }
); } export type SizeCell = { si: number; size: string; count: number | null; state: "ok" | "low" | "out" | "none"; title?: string }; /** `action` is the verb a tappable cell performs ("Adjust", "Add"), put in front of its name; * `opensDialog` marks cells that open a dialog. */ export function SizeStrip({ itemLabel, cells, onCell, selectedSi, action, opensDialog }: { itemLabel: string; cells: SizeCell[]; onCell?: (si: number) => void; selectedSi?: number | null; action?: string; opensDialog?: boolean }) { const note = (c: SizeCell) => (c.state === "low" ? ", at reorder" : c.state === "out" ? ", out" : c.state === "none" ? ", not stocked" : ""); return (
{cells.map((c) => { const cls = `tc-sizecell tc-sizecell-${c.state}${selectedSi === c.si ? " selected" : ""}`; const inner = <>{c.size}{c.state === "none" || c.count === null ? "–" : c.count}; const aria = c.state === "none" ? `${itemLabel} ${c.size}, not stocked` : `${itemLabel} ${c.size}: ${c.count ?? 0} on hand${note(c)}`; return onCell ? : {inner}; })}
); } export function Seg({ opts, value, onChange, label, labels, counts, hrefs, tone = "paper", size = "md", disabled, style }: { opts: readonly T[]; value: T; onChange: (v: T) => void; label?: string; labels?: Partial>; counts?: Partial>; hrefs?: Partial>; tone?: "paper" | "ink"; size?: "md" | "sm"; disabled?: boolean; style?: React.CSSProperties; }) { const body = (o: T) => ( <> {labels?.[o] ?? o} {counts?.[o] !== undefined && counts[o] !== "" && {counts[o]}} ); return (
{opts.map((o) => { const on = value === o; const cls = "seg-opt tc-seg-opt" + (on ? " on" : ""); const href = hrefs?.[o]; if (href && !disabled) return onChange(o)}>{body(o)}; return ; })}
); } export function Tabs({ label, tabs, value, hrefFor }: { label: string; tabs: { id: string; label: string; count?: number }[]; value: string; hrefFor: (id: string) => string }) { const router = useRouter(); const refs = useRef<(HTMLAnchorElement | null)[]>([]); function go(e: React.MouseEvent, id: string) { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); router.replace(hrefFor(id), { scroll: false }); } function key(e: React.KeyboardEvent, i: number) { const n = tabs.length; const to = e.key === "ArrowRight" ? (i + 1) % n : e.key === "ArrowLeft" ? (i - 1 + n) % n : e.key === "Home" ? 0 : e.key === "End" ? n - 1 : -1; if (to < 0) return; e.preventDefault(); refs.current[to]?.focus(); } return (
{tabs.map((t, i) => { const on = t.id === value; return ( { refs.current[i] = el; }} href={hrefFor(t.id)} role="tab" aria-selected={on} tabIndex={on ? 0 : -1} className={"tc-tab" + (on ? " on" : "")} onClick={(e) => go(e, t.id)} onKeyDown={(e) => key(e, i)} scroll={false}> {t.label} {t.count !== undefined && {t.count}} ); })}
); } export function Panel({ title, icon, count, aside, flag, foot, id, headingLevel = 2, children }: { title: React.ReactNode; icon?: IconName; count?: number; aside?: React.ReactNode; flag?: boolean; foot?: React.ReactNode; id?: string; headingLevel?: 2 | 3; children: React.ReactNode }) { const H = headingLevel === 3 ? "h3" : "h2"; const hid = useId(); return (
{icon && } {title} {count !== undefined && {count}} {aside && {aside}}
{children}
{foot &&
{foot}
}
); } export function QueueGroup({ id, icon, title, count, aside, children }: { id: string; icon: IconName; title: string; count: number; aside?: React.ReactNode; children: React.ReactNode }) { if (count === 0) return null; return {children}; } export function QueueRow({ age, ageLabel, urgent, title, titleMeta, meta, actions }: { age: React.ReactNode; ageLabel: string; urgent?: boolean; title: React.ReactNode; titleMeta?: React.ReactNode; meta: React.ReactNode; actions?: React.ReactNode; }) { return (
{age}
{ageLabel}
{title}{titleMeta && <> {titleMeta}}
{meta}
{actions &&
{actions}
}
); } export function QtyStepper({ value, onChange, min = 0, max, label, size = "md" }: { value: number; onChange: (n: number) => void; min?: number; max?: number; label: string; size?: "md" | "sm" }) { const atMin = value <= min, atMax = max !== undefined && value >= max; return ( {value} ); } export function SelectButton({ label, value, options, onChange, anyValue }: { label: string; value: string; options: { value: string; label: string }[]; onChange: (v: string) => void; anyValue?: string }) { const shown = value === anyValue ? "any" : options.find((o) => o.value === value)?.label ?? value; return ( ); } export type MoreMenuItem = { label: string; onSelect?: () => void; href?: string; newTab?: boolean; disabled?: boolean; danger?: boolean; hidden?: boolean }; export function MoreMenu({ label = "More", tone = "paper", items }: { label?: string; tone?: "paper" | "ink"; items: MoreMenuItem[] }) { const [open, setOpen] = useState(false); const wrap = useRef(null); const btn = useRef(null); const menuId = useId(); const shown = items.filter((i) => !i.hidden); const focusItem = (i: number) => { const els = wrap.current?.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])'); if (!els || !els.length) return; els[(i + els.length) % els.length].focus(); }; useEffect(() => { if (!open) return; const down = (e: MouseEvent) => { if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", down); requestAnimationFrame(() => focusItem(0)); return () => document.removeEventListener("mousedown", down); }, [open]); function onKey(e: React.KeyboardEvent) { const els = Array.from(wrap.current?.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])') || []); const at = els.indexOf(document.activeElement as HTMLElement); if (e.key === "ArrowDown") { e.preventDefault(); focusItem(at + 1); } else if (e.key === "ArrowUp") { e.preventDefault(); focusItem(at - 1); } else if (e.key === "Home") { e.preventDefault(); focusItem(0); } else if (e.key === "End") { e.preventDefault(); focusItem(els.length - 1); } else if (e.key === "Escape" || e.key === "Tab") { if (e.key === "Escape") e.preventDefault(); setOpen(false); if (e.key === "Escape") btn.current?.focus(); } } const done = () => { setOpen(false); btn.current?.focus(); }; return (
{open && ( )}
); } export function Figures({ items }: { items: { value: React.ReactNode; label: string; note?: React.ReactNode; flag?: boolean }[] }) { return (
{items.map((it) => (
{it.flag &&
{it.label}
{it.note &&
{it.note}
}
))}
); } export function Bar({ value, max, label }: { value: number; max: number; label: string }) { const pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; return
; }