ThreadCount Community edition
Release / release (push) Has been skipped

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-17 05:49:16 +10:00
commit f72f0626b0
481 changed files with 59411 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
"use client";
/* Mounts the Umami script and reports page views by hand.
*
* `data-auto-track="false"` is the important attribute: left on, Umami reports
* `location.pathname + location.search` by itself, which for this app would mean posting staff and
* location ids — and every `?next=` — straight into the analytics database. With it off, the only
* thing ever reported is what `pageview()` sends, and that has been through `scrubPath`.
*
* `data-do-not-track="true"` makes the script stand down entirely for anyone whose browser asks
* not to be tracked. */
import Script from "next/script";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { APP_ID, MARKETING_ID, UMAMI_SRC, pageview, setSite, surface, track } from "@/lib/analytics";
export default function Analytics({ site }: { site: "marketing" | "app" }) {
const id = site === "app" ? APP_ID : MARKETING_ID;
setSite(site);
// usePathname only — never useSearchParams. It would drag every static marketing page into
// dynamic rendering, and the query strings here (?next=, ?new=) are exactly what we don't want.
const pathname = usePathname();
const [ready, setReady] = useState(false);
const last = useRef<string | null>(null);
const openedSent = useRef(false);
/* Umami's `tag` — "android" in the shell, "web" in a browser — on every pageview and event, so
* the dashboard's own filter can split the two without a custom-property pivot. Every event
* already carries `surface` in its data; this puts the same fact where Umami's UI reads it.
* Decided after mount: the server render cannot know which shell it is being sent to and must
* produce the same markup as the client's first paint. */
const [tag, setTag] = useState<"android" | "web" | null>(null);
useEffect(() => {
/* The hosted defaults report only from a page really served on threadcount.tech: the e2e
suites render these same layouts on localhost, and without this every local run counted.
A tracker the operator named explicitly (NEXT_PUBLIC_UMAMI_SRC) mounts wherever it runs. */
if (!process.env.NEXT_PUBLIC_UMAMI_SRC && !/(^|\.)threadcount\.tech$/.test(location.hostname)) return;
setTag(surface());
}, []);
/* Clicks on anything carrying data-umami-event. Umami's own handler for that attribute is part
of auto-track, which is off here on purpose (it would report raw, id-bearing URLs), so the
public site's calls to action never counted. One delegated
listener reads the attribute and its data-umami-event-* companions and sends them through the
same scrubbing `track()` everything else uses. */
useEffect(() => {
if (!ready) return;
const onClick = (e: MouseEvent) => {
const el = (e.target as Element | null)?.closest?.("[data-umami-event]") as HTMLElement | null;
if (!el) return;
const name = el.dataset.umamiEvent;
if (!name) return;
const data: Record<string, string> = {};
for (const [k, v] of Object.entries(el.dataset)) {
if (k !== "umamiEvent" && k.startsWith("umamiEvent") && v) data[k.slice("umamiEvent".length).toLowerCase()] = v;
}
track(name, data);
};
document.addEventListener("click", onClick, true);
return () => document.removeEventListener("click", onClick, true);
}, [ready]);
useEffect(() => {
if (!ready || !pathname || pathname === last.current) return;
last.current = pathname;
pageview(pathname);
// One event per app launch, so installs from Play can be reconciled with people who actually
// open the thing. Fires once per page-load of the shell, not once per screen.
if (site === "app" && !openedSent.current) {
openedSent.current = true;
track("app_opened");
}
}, [ready, pathname, site]);
if (!id || !tag || !UMAMI_SRC) return null;
return (
<Script
src={UMAMI_SRC}
data-website-id={id}
data-tag={tag}
data-auto-track="false"
data-do-not-track="true"
strategy="afterInteractive"
onReady={() => setReady(true)}
/>
);
}
+507
View File
@@ -0,0 +1,507 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
import { track } from "@/lib/analytics";
import { Field, LiveRegion } from "@/components/ui";
import PlanChoice, { type SignupPlan } from "@/components/PlanChoice";
import { HAS_SITE, PRIVACY_URL, TERMS_URL } from "@/lib/links";
/* The desktop door: sign in and create a facility.
*
* Sign-in asks for the address first and only then shows the one thing that applies — a password,
* the facility's single sign-on, or a pointer to the staff app — instead of every door at once.
* Creating a facility is three short steps (you, the facility, the plan) rather than one long form,
* and ends on the short list a new admin actually needs. */
const POINTS = [
"Bring your catalogue, staff register and cost centres in from CSV, or type them in over an afternoon.",
"Issue with a scan, and the order to replace it starts itself.",
"Month-end reporting your finance team can open without ringing you about it.",
];
/* What a failed single sign-on says when it lands back here. The codes come from
* /api/auth/sso/callback; every one of them means "you are not signed in", and the sentence says
* what to do about it. */
const SSO_ERRORS: Record<string, string> = {
sso_no_account: "Your identity provider signed you in, but that address has no account at this facility yet. Ask your admin to add you, using the same address.",
sso_inactive: "That account has been deactivated. Ask an admin at your facility.",
sso_state: "That sign-in link had expired or was opened in a different browser. Start again from here.",
sso_failed: "Single sign-on didnt complete. Try again, or use your password if you have one.",
sso_unavailable: "Single sign-on isnt available for that address.",
};
const SETTINGS: [string, string][] = [["", "Choose one (optional)"], ["hospital", "Hospital"], ["aged_care", "Aged care"], ["community", "Community health"], ["other", "Other"]];
const STATES: [string, string][] = [["", "Choose one (optional)"], ["QLD", "Queensland"], ["NSW", "New South Wales"], ["VIC", "Victoria"], ["ACT", "Australian Capital Territory"], ["TAS", "Tasmania"], ["SA", "South Australia"], ["NT", "Northern Territory"], ["WA", "Western Australia"], ["NZ", "New Zealand"]];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** Four bars: length 8, length 12, mixed case, a digit or symbol. A sentence scores full marks. */
function strength(pw: string): { score: number; word: string } {
const score = [pw.length >= 8, pw.length >= 12, /[a-z]/.test(pw) && /[A-Z]/.test(pw), /\d|[^\w\s]/.test(pw)].filter(Boolean).length;
return { score, word: ["Too short", "Weak", "Fair", "Good", "Strong"][pw.length < 8 ? 0 : score] };
}
type LoginStep = "email" | "password" | "sso" | "staff" | "2fa" | "reset";
export default function AuthForm({ initialMode, next, signupsOpen, plansLive = false, ssoError = "", sso: ssoOffered = false }: { initialMode: "login" | "signup"; next: string; signupsOpen: boolean; plansLive?: boolean; ssoError?: string; sso?: boolean }) {
const router = useRouter();
const [mode, setMode] = useState<"login" | "signup">(initialMode);
const [busy, setBusy] = useState(false);
const [cfToken, setCfToken] = useState("");
/** The security check was asked for and never answered — see cfNow. */
const [cfSlow, setCfSlow] = useState(false);
// ---- sign in ----
const [step, setStep] = useState<LoginStep>("email");
const [li, setLi] = useState({ email: "", pw: "", err: SSO_ERRORS[ssoError] || "" });
const [showPw, setShowPw] = useState(false);
const [caps, setCaps] = useState(false);
const [remember, setRemember] = useState(false);
const [sso, setSso] = useState<{ on: boolean; required: boolean; facility: string }>({ on: false, required: false, facility: "" });
const [ticket, setTicket] = useState("");
const [code, setCode] = useState("");
const [recoveryMode, setRecoveryMode] = useState(false);
const [trust, setTrust] = useState(false);
const [resetAgain, setResetAgain] = useState(0);
// ---- create a facility ----
const [su, setSu] = useState({ first: "", last: "", facility: "", email: "", pw: "", setting: "", state: "", err: "" });
const [suStep, setSuStep] = useState<1 | 2 | 3>(1);
const [showSuPw, setShowSuPw] = useState(false);
const [plan, setPlan] = useState<SignupPlan>("hosted_small");
const [agree, setAgree] = useState(false);
/** Sign-in's own consent tick: asked on the first screen so password, single sign-on and the
* staff-app pointer all pass through it. */
const [liAgree, setLiAgree] = useState(false);
/** Set once the facility exists, so the address it was created with can be shown back. */
const [made, setMade] = useState<{ email: string; mailed: boolean; mail: boolean } | null>(null);
const legal = !!(TERMS_URL || PRIVACY_URL);
/* The Turnstile token, waited for at submit time rather than demanded before the button works.
* On a network that filters challenges.cloudflare.com the token never comes; wait up to eight
* seconds, then go anyway and let the server give an honest refusal, with a line saying what
* didn't finish. */
async function cfNow(): Promise<string> {
if (!turnstileOn()) return "";
const t = cfToken || await awaitTurnstile();
setCfSlow(!t);
return t;
}
const cfNote = cfSlow ? "The security check didnt finish. If this keeps happening, your network may be blocking challenges.cloudflare.com — try again, or ask IT." : "";
const email = li.email.trim().toLowerCase();
/** Step one: the address decides the door. Any failure to decide falls back to the password box. */
async function decideDoor() {
if (legal && !liAgree) { setLi({ ...li, err: "Tick that you agree to the Terms of use and the Privacy policy." }); return; }
if (!EMAIL_RE.test(email)) { setLi({ ...li, err: "Enter your work email." }); return; }
setBusy(true);
try {
const post = (url: string) => fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email }) }).then((r) => r.json()).catch(() => ({}));
const [s, l] = await Promise.all([ssoOffered ? post("/api/auth/sso/lookup") : Promise.resolve({}), post("/api/auth/lookup")]);
const on = !!s.sso;
setSso({ on, required: !!s.required, facility: String(s.facility || "") });
setLi({ ...li, err: "" });
if (on) { setStep("sso"); return; }
if (l.staff) { setStep("staff"); return; }
setStep("password");
} finally {
setBusy(false);
}
}
function goSso() {
// A full navigation: the route answers with a redirect to the broker, and the state cookie it
// sets has to belong to this tab's navigation, not to a fetch.
window.location.assign("/api/auth/sso/start?email=" + encodeURIComponent(email));
}
function backToEmail() { setStep("email"); setLi({ ...li, pw: "", err: "" }); setTicket(""); setCode(""); setRecoveryMode(false); }
/* Every one of these used to `await fetch` with nothing around it. A dropped connection rejects,
* so setBusy(false) never ran and the button stayed disabled for good. The finally clause is the
* fix; the catch is what turns "nothing happened" into a sentence somebody can act on. */
async function doLogin() {
if (!li.pw) { setLi({ ...li, err: "Enter your password." }); return; }
setBusy(true);
try {
const token = await cfNow();
const r = await fetch("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password: li.pw, cfToken: token, remember }) });
const j = await r.json().catch(() => ({}));
// The facility signs in through its identity provider: the password was right and is still
// refused. Nothing to explain — carry on to single sign-on with the same address.
if (j.ssoRequired) { track("signin_sso_redirect", { screen: "desktop" }); goSso(); return; }
if (!r.ok) {
track("signin_failed", { screen: "desktop", reason: r.status === 429 ? "throttled" : r.status === 400 ? "security_check" : "credentials" });
setLi({ ...li, err: j.error || "Email or password doesnt match." }); setCfToken(""); resetTurnstile(); return;
}
// Password accepted, but the account carries a second factor — nothing is signed in yet.
if (j.need2fa) { track("signin_2fa_required", { screen: "desktop" }); setTicket(j.ticket); setLi({ ...li, err: "" }); setStep("2fa"); return; }
track("signin", { screen: "desktop", kind: j.staff ? "staff" : "coordinator" });
// A wearer, not a coordinator: the cookie just set is the staff one and /my is the staff app.
// A full navigation, so no prefetched signed-out copy of the destination is served.
if (j.staff) { window.location.assign("/my"); return; }
router.push(next); router.refresh();
} catch {
setLi({ ...li, err: "No connection — check the network and try again." });
} finally {
setBusy(false);
}
}
async function doCode() {
if (!code.trim()) { setLi({ ...li, err: recoveryMode ? "Enter one of your recovery codes." : "Enter the code from your authenticator app." }); return; }
setBusy(true);
try {
const r = await fetch("/api/auth/2fa", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ticket, code, trust, remember }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) {
track("signin_failed", { screen: "desktop", reason: "second_factor" });
setLi({ ...li, err: j.error || "That code isnt right." });
if (r.status === 400) { setTicket(""); setCode(""); setStep("password"); } // expired — back a step
return;
}
track("signin", { screen: "desktop", kind: "coordinator", second_factor: true });
router.push(next); router.refresh();
} catch {
setLi({ ...li, err: "No connection — check the network and try again." });
} finally {
setBusy(false);
}
}
/** Always reports the same thing, so the reply cant be used to test whether an address exists. */
async function sendReset() {
// Said before the request goes: the answer is the same either way by design, and waiting on the
// security check first would leave the link looking dead for up to eight seconds.
setStep("reset"); setResetAgain((n) => n + 1);
const token = await cfNow();
await fetch("/api/auth/forgot", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, cfToken: token }) }).catch(() => {});
}
// ---- create a facility ----
const suEmailOk = EMAIL_RE.test(su.email);
const pwOk = su.pw.length >= 8;
const step1Ok = !!(su.first.trim() && su.last.trim()) && suEmailOk && pwOk && (!legal || agree);
const step2Ok = !!su.facility.trim();
const st = strength(su.pw);
async function doSignup() {
if (!(step1Ok && step2Ok) || (legal && !agree)) return;
setBusy(true);
try {
const token = await cfNow();
const r = await fetch("/api/auth/signup", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ first: su.first, last: su.last, facility: su.facility, email: su.email, password: su.pw, setting: su.setting, state: su.state, agreed: agree, cfToken: token, ...(plansLive ? { plan } : {}) }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) {
track("signup_failed", { screen: "desktop", reason: r.status === 429 ? "throttled" : r.status === 400 ? "rejected" : "other" });
setSu({ ...su, err: j.error || "Couldnt create the account." }); setCfToken(""); resetTurnstile();
// A refusal about the address or the password belongs on the step that asked for it.
if (r.status === 409 || /email|password/i.test(String(j.error || ""))) setSuStep(1);
return;
}
track("signup_completed", { screen: "desktop" });
/* Show the address back before going anywhere: it is where a password reset goes and the only
* route back into a facility whose one admin is locked out, and a typo is invisible until the
* day it matters. */
setMade({ email: String(j.email || su.email), mailed: !!j.mailed, mail: j.mail !== false });
} catch {
setSu({ ...su, err: "No connection — try again. If the address is already taken, the account was created." });
} finally {
setBusy(false);
}
}
// A LiveRegion rather than a plain div: a refusal that only appears is a refusal a screen-reader
// user never hears, and this is the box that says why they are not signed in.
const errBox = (msg: string) => (
<LiveRegion tone="alert" msg={msg} style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", marginTop: 12 }} />
);
const cfBox = cfNote ? (
<LiveRegion msg={cfNote} style={{ borderLeft: "6px solid var(--color-text)", padding: "8px 12px", fontSize: 12.5, lineHeight: 1.55, color: "var(--color-neutral-800)", marginTop: 12 }} />
) : null;
const title: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.01em", margin: 0 };
const sub: React.CSSProperties = { fontSize: 13.5, color: "var(--color-neutral-800)", marginTop: 8, lineHeight: 1.6 };
const small: React.CSSProperties = { fontSize: 12, color: "var(--color-neutral-700)", lineHeight: 1.6 };
const kicker: React.CSSProperties = { fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-700)" };
const linkBtn: React.CSSProperties = { background: "none", border: 0, padding: 0, font: "inherit", fontWeight: 700, color: "var(--color-accent-700)", textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" };
const arrowBtn = (label: string, extra?: React.CSSProperties) => (
<span style={{ display: "flex", justifyContent: "space-between", width: "100%", ...extra }}><span>{label}</span><span aria-hidden></span></span>
);
/** The address being signed in, with the way back to change it. */
const whoLine = (
<div style={{ ...small, display: "flex", gap: 8, alignItems: "center", marginBottom: 14 }}>
<button type="button" onClick={backToEmail} style={linkBtn}> Not you?</button>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{email}</span>
</div>
);
const showToggle = (on: boolean, set: (v: boolean) => void) => (
<button type="button" onClick={() => set(!on)} aria-pressed={on} style={{ ...linkBtn, fontSize: 12, position: "absolute", right: 10, top: 0, height: 36, display: "flex", alignItems: "center" }}>{on ? "Hide" : "Show"}</button>
);
// ---------------- left pane ----------------
const brand = (
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 10, textDecoration: "none", color: "var(--color-text)" }}>
<div style={{ width: 16, height: 16, background: "var(--color-accent)" }} />
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "-0.01em" }}>ThreadCount</div>
</Link>
);
const bigTitle: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 42, lineHeight: 1.05, letterSpacing: "-0.02em", textWrap: "balance" as never };
const paneFoot = <div style={{ fontSize: 11, color: "var(--color-neutral-600)", textTransform: "uppercase", letterSpacing: "0.08em" }}>{plansLive ? "Priced per facility, never per seat · your data stays yours" : "No per-seat pricing · your data stays yours"}</div>;
const pane = mode === "login" ? (
<div>
<div style={bigTitle}>Welcome back.</div>
<div style={{ marginTop: 18, fontSize: 14, lineHeight: 1.6, color: "var(--color-neutral-800)", maxWidth: 400 }}>One address, one door. Type your work email and you are taken the right way: password, single sign-on, or the staff app if that is where you belong.</div>
<div style={{ marginTop: 32, borderTop: "2px solid var(--color-text)", paddingTop: 16, display: "flex", flexDirection: "column", gap: 10, maxWidth: 400 }}>
<div style={kicker}>Not the linen room?</div>
<div style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 13 }}><span style={{ width: 8, height: 8, background: "var(--color-text)", flex: "none" }} /><span>Wearing the uniform? <Link href="/my/signin" style={{ fontWeight: 700 }}>Staff sign-in</Link></span></div>
<div style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 13 }}><span style={{ width: 8, height: 8, background: "var(--color-text)", flex: "none" }} /><span>At the counter on a phone? <Link href="/m/login" style={{ fontWeight: 700 }}>Counter sign-in</Link></span></div>
</div>
</div>
) : (
<div>
<div style={bigTitle}>Set up your facility in three short steps.</div>
<div style={{ marginTop: 24, display: "flex", flexDirection: "column", gap: 14 }}>
{(["You", "Your facility", plansLive ? "Plan" : "Confirm"] as const).map((t, i) => {
const on = made ? true : suStep === i + 1, done = made || suStep > i + 1;
return (
<div key={t} style={{ display: "flex", gap: 14, alignItems: "center" }}>
<div style={{ width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, ...(on || done ? { background: "var(--color-text)", color: "var(--color-bg)" } : { border: "2px solid var(--color-text)" }) }}>{done ? "✓" : i + 1}</div>
<div style={{ fontSize: 14, fontWeight: on ? 700 : 500, color: on ? "var(--color-text)" : "var(--color-neutral-700)" }}>{t}</div>
</div>
);
})}
</div>
<div style={{ marginTop: 32, display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }}>
{POINTS.map((pt) => (
<div key={pt} style={{ display: "flex", gap: 12, alignItems: "baseline", fontSize: 13.5, lineHeight: 1.55, color: "var(--color-neutral-800)" }}>
<span style={{ width: 8, height: 8, background: "var(--color-accent)", flex: "none", transform: "translateY(-1px)" }} />{pt}
</div>
))}
</div>
</div>
);
/** The terms line with its tick box. Shown only where the server publishes documents (a Community
* instance without TERMS_URL/PRIVACY_URL asks nothing). */
const consentBox = (checked: boolean, set: (v: boolean) => void, tail: string) => legal ? (
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", fontSize: 13, lineHeight: 1.5, marginTop: 14, cursor: "pointer" }}>
<input type="checkbox" checked={checked} onChange={(e) => set(e.target.checked)} style={{ width: 16, height: 16, margin: "2px 0 0", flex: "none" }} />
<span>I agree to the {TERMS_URL ? <a href={TERMS_URL} target="_blank" rel="noopener">Terms of use</a> : "Terms of use"}{TERMS_URL && PRIVACY_URL ? " and the " : ""}{PRIVACY_URL ? <a href={PRIVACY_URL} target="_blank" rel="noopener">Privacy policy</a> : null}{tail}.</span>
</label>
) : null;
// ---------------- sign-in steps ----------------
const loginBody = (() => {
if (step === "email") return (
<form onSubmit={(e) => { e.preventDefault(); void decideDoor(); }}>
<h1 style={title}>Sign in</h1>
<p style={sub}>Your work email decides the way in.</p>
<div style={{ marginTop: 20 }}>
<Field label="Work email">{(c) => <input {...c} className="input" type="email" inputMode="email" autoCapitalize="none" autoCorrect="off" spellCheck={false} enterKeyHint="next" autoComplete="email" autoFocus value={li.email} onChange={(e) => setLi({ ...li, email: e.target.value, err: "" })} placeholder="you@yourfacility.org" />}</Field>
</div>
{consentBox(liAgree, (v) => { setLiAgree(v); setLi({ ...li, err: "" }); }, "")}
{errBox(li.err)}
<button type="submit" className="btn btn-primary" disabled={busy || (legal && !liAgree)} style={{ marginTop: 16, width: "100%" }}>{arrowBtn(busy ? "One moment…" : "Continue")}</button>
{signupsOpen && <div style={{ ...small, marginTop: 14 }}>New facility? <button type="button" onClick={() => { setMode("signup"); setSu({ ...su, err: "" }); }} style={linkBtn}>Create its account</button> · one per facility, you become its first admin.</div>}
</form>
);
if (step === "password") return (
<form onSubmit={(e) => { e.preventDefault(); void doLogin(); }}>
{whoLine}
<h1 style={title}>Welcome back</h1>
{sso.on && <p style={sub}>{sso.facility} signs in with single sign-on. This password is the break-glass admins only.</p>}
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 20 }}>
<div style={{ position: "relative" }}>
<Field label="Password">{(c) => <input {...c} className="input" type={showPw ? "text" : "password"} enterKeyHint="go" autoComplete="current-password" autoFocus value={li.pw} style={{ paddingRight: 56 }}
onChange={(e) => setLi({ ...li, pw: e.target.value, err: "" })}
onKeyDown={(e) => setCaps(e.getModifierState && e.getModifierState("CapsLock"))} onKeyUp={(e) => setCaps(e.getModifierState && e.getModifierState("CapsLock"))} />}</Field>
<div style={{ position: "absolute", right: 0, top: 19 }}>{showToggle(showPw, setShowPw)}</div>
</div>
<label style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 13, cursor: "pointer" }}>
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} style={{ width: 16, height: 16, margin: 0 }} />
<span>Keep me signed in on this computer for 30 days</span>
</label>
</div>
{caps && <LiveRegion msg="Caps Lock is on." style={{ borderLeft: "6px solid var(--color-text)", padding: "8px 12px", fontSize: 12.5, color: "var(--color-neutral-800)", marginTop: 12 }} />}
<Turnstile action="login" onToken={setCfToken} />
{cfBox}
{errBox(li.err)}
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: 16, width: "100%" }}>{arrowBtn(busy ? "Signing in…" : "Sign in")}</button>
<div style={{ ...small, marginTop: 14 }}><button type="button" onClick={() => void sendReset()} style={linkBtn}>Forgot your password?</button></div>
</form>
);
if (step === "sso") return (
<div>
{whoLine}
<h1 style={title}>{sso.facility || "Your facility"} signs in with single sign-on</h1>
<p style={sub}>You will be taken to your organisations login and brought straight back.</p>
{errBox(li.err)}
<button type="button" className="btn btn-primary" onClick={goSso} style={{ marginTop: 20, width: "100%" }}>{arrowBtn(`Continue with ${sso.facility || "single sign-on"}`)}</button>
<details style={{ marginTop: 18 }}>
<summary style={{ fontSize: 12, fontWeight: 700, color: "var(--color-neutral-700)", cursor: "pointer" }}>{sso.required ? "Break-glass admin? Sign in with a password instead" : "Prefer your password?"}</summary>
<div style={{ ...small, marginTop: 8 }}>
{sso.required ? "Only the facilitys break-glass admin has a password here; everybody else uses the button above." : "Single sign-on is optional at this facility. A password set on your account still works."}
{" "}<button type="button" onClick={() => setStep("password")} style={linkBtn}>Use a password</button>
</div>
</details>
</div>
);
if (step === "staff") return (
<div>
{whoLine}
<h1 style={title}>That address is a staff sign-in</h1>
<p style={sub}>It belongs to a uniform record, not a linen-room account. Your kit, requests and orders live in the staff app.</p>
<button type="button" className="btn btn-primary" onClick={() => window.location.assign("/my/signin")} style={{ marginTop: 20, width: "100%" }}>{arrowBtn("Open the staff sign-in")}</button>
<div style={{ ...small, marginTop: 14 }}>Work in the linen room as well? Ask its admin to add you under Settings Users; the same address can hold both. <button type="button" onClick={() => setStep("password")} style={linkBtn}>I have a linen-room password</button></div>
</div>
);
if (step === "2fa") return (
<form onSubmit={(e) => { e.preventDefault(); void doCode(); }}>
{whoLine}
<h1 style={title}>Two-factor</h1>
<p style={sub}>{recoveryMode ? "Enter one of the recovery codes you saved when you set this up. Each works once." : "Your password was right. Enter the six-digit code from your authenticator app."}</p>
<div style={{ marginTop: 20 }}>
<Field label={recoveryMode ? "Recovery code" : "Code"}>{(c) => (
<input {...c} className="input" inputMode={recoveryMode ? "text" : "numeric"} autoComplete="one-time-code" autoFocus autoCapitalize="characters" spellCheck={false}
value={code} onChange={(e) => { setCode(e.target.value); setLi({ ...li, err: "" }); }}
placeholder={recoveryMode ? "XXXXX-XXXXX" : "000000"}
style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: recoveryMode ? 18 : 24, letterSpacing: recoveryMode ? "0.08em" : "0.35em", minHeight: 52 }} />
)}</Field>
</div>
<label style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 13, cursor: "pointer", marginTop: 14 }}>
<input type="checkbox" checked={trust} onChange={(e) => setTrust(e.target.checked)} style={{ width: 16, height: 16, margin: 0 }} />
<span>Trust this computer for 30 days</span>
</label>
{errBox(li.err)}
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: 16, width: "100%" }}>{arrowBtn(busy ? "Checking…" : "Verify")}</button>
<div style={{ ...small, marginTop: 14, display: "flex", flexDirection: "column", gap: 4 }}>
<button type="button" onClick={() => { setRecoveryMode(!recoveryMode); setCode(""); setLi({ ...li, err: "" }); }} style={{ ...linkBtn, alignSelf: "flex-start" }}>{recoveryMode ? "Use the authenticator app instead" : "Use a recovery code instead"}</button>
<details><summary style={{ cursor: "pointer" }}>Lost the phone and the codes?</summary><div style={{ marginTop: 6 }}>{/* Only the account holder can remove two-factor; no admin control clears it for someone else. */}Your recovery codes are the way back in. {HAS_SITE ? "Without them, write to support with the facility name and the address on the account." : "Without them, ask whoever runs this server."}</div></details>
</div>
</form>
);
// reset
return (
<div>
<div style={{ ...small, marginBottom: 14 }}><button type="button" onClick={() => setStep("password")} style={linkBtn}> Back to sign in</button></div>
<h1 style={title}>Reset link sent</h1>
<p style={sub}>To <b>{email}</b>. It works once and expires in an hour.</p>
<LiveRegion msg={<>Nothing there after a few minutes? The address above is the one on the account. If it is wrong, another admin at your facility can fix it under Settings Users.{resetAgain > 1 ? " Sent again just now." : ""}</>} style={{ borderLeft: "6px solid var(--color-text)", padding: "8px 12px", fontSize: 12.5, lineHeight: 1.55, color: "var(--color-neutral-800)", marginTop: 12 }} />
<button type="button" className="btn btn-secondary" onClick={() => void sendReset()} style={{ marginTop: 16, width: "100%" }}>Send it again</button>
<details style={{ ...small, marginTop: 14 }}><summary style={{ cursor: "pointer", fontWeight: 700 }}>You are the only admin and cannot get the mail?</summary><div style={{ marginTop: 6 }}>The facility is not lost. Write to support from any address you control, naming the facility and the address on the account; identity is checked before anything is changed.</div></details>
</div>
);
})();
// ---------------- create a facility ----------------
const signupBody = made ? (
<div>
<div style={{ display: "inline-block", fontSize: 10, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", padding: "3px 6px", background: "var(--color-text)", color: "var(--color-bg)" }}>Facility created</div>
<h1 style={{ ...title, marginTop: 12 }}>{su.facility.trim() || "Your facility"} is ready{su.first.trim() ? `, ${su.first.trim()}` : ""}</h1>
<p style={sub}>You are signed in as <b>{made.email}</b>.</p>
<LiveRegion msg={!made.mail
? "No mail is configured on this server, so that address hasnt been checked. Make sure it is right — it is where a password reset would go."
: made.mailed
? "We sent a note to that address. If it does not arrive, the address is wrong — fix it under Settings → Account before you sign out, or a forgotten password locks the facility."
: "We couldnt send a note to that address. Check it is right under Settings → Account before you sign out — otherwise a forgotten password locks the facility."}
style={{ borderLeft: "6px solid var(--color-text)", padding: "8px 12px", fontSize: 12.5, lineHeight: 1.55, color: "var(--color-neutral-800)", marginTop: 12 }} />
<div style={{ marginTop: 20 }}>
{[
[made.mail && made.mailed ? "Check the note we sent" : "Check the address is right", made.mail && made.mailed ? "If it never arrives, the address on the account is wrong. Resets cannot reach you until it is fixed." : "It is the only route back in if the password is forgotten."],
["Add a second admin", "Settings → Users. Two people can always get back in; one cannot."],
["Bring in the catalogue and staff register", "CSV templates are on the import screen, or type them over an afternoon."],
["Put the apps on the rooms phones", "The counter app for the linen room, the staff app for wearers. Both sign in with this facilitys addresses."],
].map(([t, s], i) => (
<div key={t} style={{ display: "flex", gap: 12, alignItems: "flex-start", padding: "12px 0", borderTop: "1px solid var(--color-divider)" }}>
<div style={{ width: 26, height: 26, flex: "none", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 12, border: "2px solid var(--color-text)" }}>{i + 1}</div>
<div><div style={{ fontWeight: 700, fontSize: 14 }}>{t}</div><div style={{ ...small, marginTop: 2 }}>{s}</div></div>
</div>
))}
</div>
<button className="btn btn-primary" style={{ marginTop: 20, width: "100%" }} onClick={() => { router.push("/app?welcome=1"); router.refresh(); }}>{arrowBtn("Open ThreadCount")}</button>
</div>
) : suStep === 1 ? (
<form onSubmit={(e) => { e.preventDefault(); if (step1Ok) { setSu({ ...su, err: "" }); setSuStep(2); } }}>
<div style={{ ...kicker, marginBottom: 10 }}>Step 1 of 3 · You</div>
<h1 style={title}>Who is setting this up?</h1>
<p style={sub}>You will be the facilitys first admin. Everything else can be handed to a colleague later.</p>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginTop: 20 }}>
<Field label="First name">{(c) => <input {...c} className="input" autoComplete="given-name" autoCapitalize="words" autoFocus value={su.first} onChange={(e) => setSu({ ...su, first: e.target.value, err: "" })} />}</Field>
<Field label="Last name">{(c) => <input {...c} className="input" autoComplete="family-name" autoCapitalize="words" value={su.last} onChange={(e) => setSu({ ...su, last: e.target.value, err: "" })} />}</Field>
<Field label="Work email" style={{ gridColumn: "1/-1" }} hint="Password resets go here, so it has to be right.">{(c) => <input {...c} className="input" type="email" inputMode="email" autoCapitalize="none" autoCorrect="off" spellCheck={false} autoComplete="email" value={su.email} onChange={(e) => setSu({ ...su, email: e.target.value, err: "" })} placeholder="you@yourfacility.org" />}</Field>
<div style={{ gridColumn: "1/-1", position: "relative" }}>
<Field label="Password" hint={su.pw ? `${st.word}. At least 8 characters; a sentence works best.` : "At least 8 characters; a sentence works best."}>{(c) => <input {...c} className="input" type={showSuPw ? "text" : "password"} autoComplete="new-password" value={su.pw} style={{ paddingRight: 56 }} onChange={(e) => setSu({ ...su, pw: e.target.value, err: "" })} />}</Field>
<div style={{ position: "absolute", right: 0, top: 19 }}>{showToggle(showSuPw, setShowSuPw)}</div>
<div aria-hidden style={{ display: "flex", gap: 4, marginTop: 6 }}>{[1, 2, 3, 4].map((n) => <div key={n} style={{ flex: 1, height: 4, background: su.pw.length >= 8 && st.score >= n ? "var(--color-text)" : "var(--color-neutral-300)" }} />)}</div>
</div>
</div>
{consentBox(agree, setAgree, ", and I can act for the facility I am setting up")}
{errBox(su.err)}
<button type="submit" className="btn btn-primary" disabled={!step1Ok} style={{ marginTop: 20, width: "100%" }}>{arrowBtn("Next: your facility")}</button>
<div style={{ ...small, marginTop: 14 }}>Already set up? <button type="button" onClick={() => { setMode("login"); setStep("email"); }} style={linkBtn}>Sign in</button>. Joining a facility that already uses ThreadCount? Ask its admin to add you under Settings Users instead.</div>
</form>
) : suStep === 2 ? (
<form onSubmit={(e) => { e.preventDefault(); if (step2Ok) setSuStep(3); }}>
<div style={{ ...kicker, marginBottom: 10 }}>Step 2 of 3 · Your facility</div>
<h1 style={title}>Name the facility</h1>
<p style={sub}>It appears on every screen, report and order sheet.</p>
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 20 }}>
<Field label="Facility name">{(c) => <input {...c} className="input" autoComplete="organization" autoCapitalize="words" autoFocus value={su.facility} onChange={(e) => setSu({ ...su, facility: e.target.value, err: "" })} placeholder="e.g. St Vincents Private" />}</Field>
<Field label="Setting" hint="Sets the starting staff groups; rename or remove them any time.">{(c) => <select {...c} className="input" value={su.setting} onChange={(e) => setSu({ ...su, setting: e.target.value })}>{SETTINGS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select>}</Field>
<Field label="State or territory" hint="Sets the time zone for counts and month-end, nothing else.">{(c) => <select {...c} className="input" value={su.state} onChange={(e) => setSu({ ...su, state: e.target.value })}>{STATES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select>}</Field>
</div>
<div style={{ display: "flex", gap: 10, marginTop: 20 }}>
<button type="button" className="btn btn-secondary" onClick={() => setSuStep(1)}> Back</button>
<button type="submit" className="btn btn-primary" disabled={!step2Ok} style={{ flex: 1 }}>{arrowBtn(plansLive ? "Next: plan" : "Next: confirm")}</button>
</div>
</form>
) : (
<form onSubmit={(e) => { e.preventDefault(); void doSignup(); }}>
<div style={{ ...kicker, marginBottom: 10 }}>Step 3 of 3 · {plansLive ? "Plan" : "Confirm"}</div>
<h1 style={title}>{plansLive ? "Start free, or start the trial" : "Ready to create it"}</h1>
<p style={sub}>{plansLive ? "Both give you the whole product. No card either way." : `${su.facility.trim()} will be created with you as its first admin.`}</p>
{plansLive && (
<fieldset style={{ border: 0, padding: 0, margin: "20px 0 0" }}>
<legend style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>Plan</legend>
<PlanChoice value={plan} onChange={setPlan} />
</fieldset>
)}
<Turnstile action="signup" onToken={setCfToken} />
{cfBox}
{errBox(su.err)}
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
<button type="button" className="btn btn-secondary" onClick={() => setSuStep(2)}> Back</button>
<button type="submit" className="btn btn-primary" disabled={busy || (legal && !agree)} style={{ flex: 1, minWidth: 0 }}>{arrowBtn(busy ? "Creating…" : `Create ${su.facility.trim() || "the facility"}`, { overflow: "hidden" })}</button>
</div>
</form>
);
return (
<div className="tc-auth" style={{ minHeight: "100vh", display: "grid", gridTemplateColumns: "1fr 1fr", fontFamily: "var(--font-body)", color: "var(--color-text)", background: "var(--color-bg)" }}>
<div className="tc-brandpane" style={{ borderRight: "2px solid var(--color-text)", padding: 48, display: "flex", flexDirection: "column", justifyContent: "space-between", background: "var(--color-surface)" }}>
{brand}
{pane}
{paneFoot}
</div>
<div className="tc-authpane" style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: 400, maxWidth: "100%" }}>
<div className="tc-brandmobile" style={{ display: "none", alignItems: "center", justifyContent: "space-between", gap: 12, paddingBottom: 14, marginBottom: 18, borderBottom: "2px solid var(--color-text)" }}>
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", color: "var(--color-text)" }}>
<div style={{ width: 14, height: 14, background: "var(--color-accent)" }} />
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.01em" }}>ThreadCount</div>
</Link>
<span style={{ fontSize: 11, color: "var(--color-neutral-700)", textTransform: "uppercase", letterSpacing: "0.08em", textAlign: "right" }}>{mode === "signup" && !made ? `Step ${suStep} of 3` : "Uniform management"}</span>
</div>
{mode === "login" ? loginBody : signupBody}
{HAS_SITE && (
<div className="tc-authfoot" style={{ display: "flex", justifyContent: "space-between", gap: 12, marginTop: 28, paddingTop: 14, borderTop: "1px solid var(--color-divider)", fontSize: 12 }}>
<Link href="/" style={{ fontWeight: 700 }}> threadcount.tech</Link>
<Link href="/demo" style={{ fontWeight: 700 }}>Try the working demo</Link>
</div>
)}
</div>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useEffect, useState } from "react";
/* Open the print dialog on arrival, and say so when there isn't one.
*
* The Android WebView the counter app runs in has no window.print at all. The effect's try/catch
* swallowed the TypeError, the visible button threw uncaught, and the result was a print screen
* where nothing happened and nothing explained why — the worst possible answer, because the slip
* looks correct and the person keeps tapping. The one /m route that reached this page has since
* been closed off, but /print is still reachable by hand and by a shared link, so the page itself
* has to be honest.
*
* The capability is read after mount rather than during render: the server has no window, and the
* first client render has to match what the server sent.
*/
export default function AutoPrint() {
const [canPrint, setCanPrint] = useState(true);
useEffect(() => {
if (typeof window.print !== "function") { setCanPrint(false); return; }
const t = setTimeout(() => { try { window.print(); } catch { /* a dialog the browser wouldn't open — the button is still there to try again */ } }, 500);
return () => clearTimeout(t);
}, []);
if (!canPrint) {
return (
<span style={{ marginLeft: "auto", color: "#b8240e", fontWeight: 600 }}>
This app can&apos;t print. Open this page in a browser on the computer at the counter to print it.
</span>
);
}
return <button className="btn btn-primary" style={{ marginLeft: "auto" }} onClick={() => window.print()}>Print</button>;
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Detector = { detect: (src: HTMLVideoElement) => Promise<{ rawValue?: string; format?: string }[]> };
/* The symbologies a garment is actually labelled with, kept deliberately in step with SCAN_FORMATS
* in lib/nativescan.ts so the desk and the phone bind the same code off the same tag. Code 93 is
* here because suppliers print it and the phone has always read it; leaving it out made a label
* that binds fine on a ward invisible at the desk. No QR: nothing in this product is identified by
* one, and the QR on a polybag points at the suppliers product page — binding that to a size
* stores a chopped URL that no printed label can reproduce and that blocks the real EAN. */
const SCAN_FORMATS = ["ean_13", "ean_8", "upc_a", "upc_e", "code_128", "code_39", "code_93"];
/** Full-screen camera scanner using getUserMedia + BarcodeDetector (350ms polls, 1.8s duplicate suppression). */
export default function Camera({ onHit, message, onClose }: { onHit: (raw: string) => void; message: string; onClose: () => void }) {
const vid = useRef<HTMLVideoElement | null>(null);
const [status, setStatus] = useState("Starting camera…");
const hit = useRef(onHit);
hit.current = onHit;
useEffect(() => {
let stream: MediaStream | null = null, timer: ReturnType<typeof setInterval> | null = null, lastRaw = "", lastT = 0, stopped = false;
const w = window as unknown as { BarcodeDetector?: new (o?: { formats?: string[] }) => Detector };
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("No camera in this browser — type the barcode instead."); return; }
navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }).then((st) => {
if (stopped) { st.getTracks().forEach((t) => t.stop()); return; }
stream = st;
if (vid.current) { vid.current.srcObject = st; vid.current.play().catch(() => {}); }
if (w.BarcodeDetector) {
let bd: Detector;
try { bd = new w.BarcodeDetector({ formats: SCAN_FORMATS }); } catch { bd = new w.BarcodeDetector(); }
setStatus("Point the camera at a barcode");
timer = setInterval(() => {
const v = vid.current; if (!v || v.readyState < 2) return;
bd.detect(v).then((codes) => {
// A QR on a polybag is high-contrast and decodes before the small EAN on the swing tag,
// so the first code in frame is often not the garments. Take the first one whose symbology
// we actually bind — and filter here as well as in the constructor, because the fallback
// above builds an unrestricted detector when the browser rejects the format list.
const found = (codes || []).find((c) => !c.format || SCAN_FORMATS.includes(c.format));
if (!found) return;
const raw = String(found.rawValue || "").trim();
if (!raw) return;
if (raw === lastRaw && Date.now() - lastT < 1800) return;
lastRaw = raw; lastT = Date.now();
hit.current(raw);
}).catch(() => {});
}, 350);
} else setStatus("Live barcode reading isnt supported in this browser — type the code instead.");
}).catch((e: Error) => setStatus("Camera blocked or unavailable — " + e.message));
return () => { stopped = true; if (timer) clearInterval(timer); if (stream) stream.getTracks().forEach((t) => t.stop()); };
}, []);
return (
<div style={{ position: "fixed", inset: 0, zIndex: 90, background: "#201e1d", display: "flex", flexDirection: "column" }}>
<video ref={vid} autoPlay playsInline muted style={{ flex: 1, width: "100%", objectFit: "cover", minHeight: 0 }} />
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "center", padding: "var(--space-4)", background: "var(--color-bg)", borderTop: "2px solid var(--color-text)" }}>
<div style={{ flex: 1, fontSize: 14, fontWeight: 600 }}>{message || status}</div>
<button className="btn btn-primary" onClick={onClose}>Done</button>
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useSnap } from "@/lib/client";
/* The first-run checklist. Six things a new room does once, each read from the records rather than
* remembered: a tick appears because the staff register has a row in it, not because somebody
* clicked "done". Every row links to the screen that does it. The panel goes on its own once all
* six are ticked, or once the room is 60 days old with three or more ticked, and an admin can put
* it away with Dismiss (Facility.checklistDismissed). Today renders it through useSetupSteps(). */
const DAY = 86_400_000;
export type SetupStep = { label: string; done: boolean; href: string; cta: string };
/** The steps, the tally and whether the checklist should show. `welcome` comes from ?welcome=1. */
export function useSetupSteps(): { steps: SetupStep[]; done: number; finished: boolean; visible: boolean; dismiss: () => Promise<void> } {
const { s, mutate } = useSnap();
const [hidden, setHidden] = useState(false);
const [welcome, setWelcome] = useState(false);
useEffect(() => {
if (new URLSearchParams(window.location.search).get("welcome") === "1") setWelcome(true);
}, []);
const admin = s.session.role === "Admin";
const steps: SetupStep[] = [
{ label: "Add staff, or import the register", done: s.staff.length > 0, href: "/app/settings?tab=data", cta: "Settings Data" },
{ label: "Add garments", done: s.catalog.length > 0, href: "/app/settings?tab=data", cta: "Settings Data" },
{ label: "Set reorder levels", done: Object.values(s.stock).some((x) => x.reorder !== null && x.reorder !== undefined), href: "/app/stock", cta: "Stock" },
{ label: "Record opening stock", done: s.moves.length > 0 || Object.values(s.stock).some((x) => x.opening > 0), href: "/app/stock", cta: "Stock" },
{ label: "Issue a garment", done: s.issues.length > 0, href: "/app/counter", cta: "Counter" },
{ label: "Bind a barcode or print labels", done: Object.keys(s.barcodes).length > 0, href: "/app/stock", cta: "Stock" },
];
const done = steps.filter((x) => x.done).length;
const ageDays = Math.floor((Date.now() - new Date(s.createdAt).getTime()) / DAY);
const finished = done === steps.length || (ageDays >= 60 && done >= 3);
const visible = !hidden && !s.settings.checklistDismissed && (!finished || welcome);
async function dismiss() {
setHidden(true);
if (admin) await mutate("settings.checklist", { dismissed: true });
}
return { steps, done, finished, visible, dismiss };
}
export default function Checklist({ welcome, onDismissWelcome }: { welcome: boolean; onDismissWelcome: () => void }) {
const { s } = useSnap();
const admin = s.session.role === "Admin";
const { steps, done, finished, visible, dismiss: hide } = useSetupSteps();
const [gone, setGone] = useState(false);
const show = !gone && (visible || (welcome && !s.settings.checklistDismissed));
if (!show) return null;
async function dismiss() {
setGone(true);
onDismissWelcome();
await hide();
}
return (
<div className="tc-panel" style={{ marginBottom: "var(--space-6)" }}>
<div className="tc-panel-head">
<div>{welcome ? "Welcome to ThreadCount" : "Getting set up"}</div>
<div className="tc-panel-aside">{done} of {steps.length}</div>
</div>
<div className="tc-panel-list">
{steps.map((st) => (
<div key={st.label} className="tc-row" style={{ alignItems: "center" }}>
<div className="tc-row-fig" style={{ width: 32, flex: "none", fontSize: 16, color: st.done ? "var(--color-accent-700)" : "var(--color-neutral-600)" }} aria-label={st.done ? "done" : "to do"}>{st.done ? "✓" : "○"}</div>
<div className="tc-row-main">
<div className="tc-row-name" style={{ textDecoration: st.done ? "line-through" : "none", color: st.done ? "var(--color-neutral-700)" : undefined }}>{st.label}</div>
</div>
{!st.done && <Link href={st.href} className="btn btn-secondary">{st.cta}</Link>}
</div>
))}
</div>
{(admin || welcome) && (
<div className="tc-panel-body" style={{ display: "flex", justifyContent: "flex-end", paddingTop: 0 }}>
<button className="btn btn-ghost" onClick={dismiss}>{finished ? "Done" : "Dismiss"}</button>
</div>
)}
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
/* The search-and-scan panel: people, their waiting bags, the requests they approve, garments and
* orders, one keyboard list. Opened from the top bar, "/", Ctrl/Cmd+K, an unknown scan, or the
* phone's SCAN button (camera mode). */
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { addDays, facilityDate, formatInZone, isOverdue, label, type Snapshot } from "@/lib/compute";
import { resolveScan, searchPortal, type SearchHit, type SearchRequest } from "@/lib/search";
import { Icon, Kbd, Tag } from "@/components/portal";
import { LiveRegion } from "@/components/ui";
import { BindDialog } from "@/components/dialogs";
import Camera from "@/components/Camera";
/* The request queue, fetched when the panel first opens and reused for a minute. A failure leaves
the Approves group out rather than showing an error in a search box. */
let requestCache: { at: number; rows: SearchRequest[] } | null = null;
async function loadRequests(): Promise<SearchRequest[] | null> {
if (requestCache && Date.now() - requestCache.at < 60_000) return requestCache.rows;
try {
const r = await fetch("/api/requests");
if (!r.ok) return null;
const j = (await r.json()) as { requests?: SearchRequest[] };
const rows = Array.isArray(j.requests) ? j.requests : [];
requestCache = { at: Date.now(), rows };
return rows;
} catch {
return null;
}
}
type Row = { id: string; hit: SearchHit; primary: () => void; secondary?: () => void };
const GROUPS: { key: "person" | "waiting" | "approves" | "garment" | "order"; label: string }[] = [
{ key: "person", label: "Person" },
{ key: "waiting", label: "Waiting" },
{ key: "approves", label: "Approves" },
{ key: "garment", label: "Garments" },
{ key: "order", label: "Orders" },
];
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
function whenLabel(s: Snapshot, iso: string | null): string {
if (!iso) return "";
const d = facilityDate(iso, s.tz);
if (!d) return "";
if (d === s.today) return "today";
if (d === addDays(s.today, -1)) return "yesterday";
return formatInZone(d, s.tz, { day: "numeric", month: "short" }).replace("Sept", "Sep");
}
export default function CommandBar({ open, onClose, initialQuery = "", camera = false, onScan, unknownCode }: {
open: boolean;
onClose: () => void;
initialQuery?: string;
camera?: boolean;
/** Where a scan from the camera, or a scanned code typed into the box, is routed. Shell's routeScan. */
onScan?: (code: string) => void;
/** Set when the panel was opened by a scan nothing matched. */
unknownCode?: string;
}) {
if (!open) return null;
if (camera) return <Camera message="Scan a staff badge or a garment barcode" onClose={onClose} onHit={(raw) => { onClose(); onScan?.(raw); }} />;
return <Panel onClose={onClose} initialQuery={initialQuery} onScan={onScan} unknownCode={unknownCode} />;
}
function Panel({ onClose, initialQuery, onScan, unknownCode }: { onClose: () => void; initialQuery: string; onScan?: (code: string) => void; unknownCode?: string }) {
const { s, isAdmin, mutate, busy } = useSnap();
const d = useDerived();
const router = useRouter();
const [q, setQ] = useState(initialQuery);
const [active, setActive] = useState(0);
const [requests, setRequests] = useState<SearchRequest[] | undefined>(requestCache?.rows);
const [msg, setMsg] = useState<{ tone: "status" | "alert"; text: string } | null>(null);
const [binding, setBinding] = useState<string | null>(null);
const box = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const listId = useId();
const opener = useRef<Element | null>(null);
if (opener.current === null && typeof document !== "undefined") opener.current = document.activeElement;
useEffect(() => {
let live = true;
loadRequests().then((rows) => { if (live && rows) setRequests(rows); });
return () => { live = false; };
}, []);
// Modal: everything behind the panel leaves the tab order and the accessibility tree, as ui Dialog does.
useEffect(() => {
const node = box.current;
if (!node) return;
const opened = opener.current;
const off: HTMLElement[] = [];
for (let el: HTMLElement | null = node.parentElement; el && el !== document.body && el.parentElement; el = el.parentElement) {
for (const sib of Array.from(el.parentElement.children)) {
if (sib !== el && sib instanceof HTMLElement && !sib.inert) { sib.inert = true; off.push(sib); }
}
}
input.current?.focus();
input.current?.select();
return () => {
for (const el of off) el.inert = false;
const back = opened instanceof HTMLElement && opened.isConnected && opened !== document.body ? opened : document.getElementById("tc-search-trigger");
back?.focus();
};
}, []);
const groups = useMemo(() => searchPortal(s, d, q, requests), [s, d, q, requests]);
const staffById = d.staffById;
const go = (href: string) => { onClose(); router.push(href); };
async function pickedUp(id: string, what: string) {
setMsg(null);
const r = await mutate("pickup.pickedUp", { id });
setMsg(r.ok ? { tone: "status", text: `Picked up: ${what}.` } : { tone: "alert", text: r.error });
}
const rows: Row[] = [];
const byGroup: Record<string, Row[]> = {};
for (const g of GROUPS) {
byGroup[g.key] = groups[g.key].map((hit, i) => {
const id = `${listId}-${g.key}-${i}`;
let row: Row;
switch (hit.kind) {
case "person": row = { id, hit, primary: () => go(`/app/counter?staff=${encodeURIComponent(hit.staff.id)}`), secondary: () => go(`/app/staff/${encodeURIComponent(hit.staff.id)}`) }; break;
case "waiting": {
const first = hit.pickup.lines[0];
const what = first ? `${label(d.byId[first.itemId])} ${first.size}` : hit.pickup.orderCode;
row = { id, hit, primary: () => { void pickedUp(hit.pickup.id, what); } };
break;
}
case "approves": row = { id, hit, primary: () => go(`/app/requests?open=${encodeURIComponent(hit.request.id)}`) }; break;
case "garment": row = { id, hit, primary: () => go(`/app/stock/${encodeURIComponent(hit.item.id)}`) }; break;
default: row = { id, hit, primary: () => go(`/app/orders/${encodeURIComponent(hit.order.id)}`) };
}
rows.push(row);
return row;
});
}
const at = Math.min(active, Math.max(0, rows.length - 1));
const current = rows[at];
useEffect(() => { setActive(0); }, [q]);
useEffect(() => {
if (!current) return;
document.getElementById(current.id)?.scrollIntoView({ block: "nearest" });
}, [current]);
function onKey(e: React.KeyboardEvent) {
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onClose(); return; }
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
if (!rows.length) return;
e.preventDefault();
setActive((a) => (Math.min(a, rows.length - 1) + (e.key === "ArrowDown" ? 1 : rows.length - 1)) % rows.length);
return;
}
if (e.key === "Enter" && e.target === input.current) {
e.preventDefault();
const scan = resolveScan(s, q);
// A garment barcode typed or scanned into the box goes straight to it: no row carries barcodes.
if (scan.kind === "garment" && onScan) { onClose(); onScan(q.trim()); return; }
if (!current) { if (scan.kind === "staff" && onScan) { onClose(); onScan(q.trim()); } return; }
if (e.shiftKey && current.secondary) current.secondary();
else current.primary();
return;
}
if (e.key === "Tab") {
const node = box.current;
if (!node) return;
const f = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => el.getClientRects().length > 0);
if (!f.length) return;
const now = document.activeElement;
if (e.shiftKey && now === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && now === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
}
}
if (binding !== null) {
return (
<BindDialog code={binding} onClose={onClose}
onBound={(itemId, si) => { onClose(); router.push(`/app/stock/${encodeURIComponent(itemId)}?size=${si}`); }} />
);
}
const trimmed = q.trim();
const showUnknown = !!unknownCode && trimmed === unknownCode.trim();
const empty = !trimmed;
const cap = s.settings.capSets;
const rowBody = (row: Row, on: boolean) => {
const h = row.hit;
switch (h.kind) {
case "person": {
const st = h.staff;
const bits = [st.group, st.dept, `holds ${h.held.sets} of ${h.cap || cap} sets`];
if (h.waitingBags > 0) bits.push(`${plural(h.waitingBags, "bag", "bags")} waiting ${plural(h.oldestWaitDays ?? 0, "day", "days")}`);
if (st.inactive) bits.push("inactive");
return (
<>
<div className="tc-cmd-main">
<div className="tc-cmd-title">{st.first} {st.last} <span className="tc-mono tc-cmd-num">{st.num}</span></div>
<div className="tc-cmd-meta">{bits.filter(Boolean).join(" · ")}<span className="sr-only">. Enter for the counter, Shift+Enter for the record.</span></div>
</div>
<button type="button" tabIndex={-1} aria-hidden="true" className={"btn tc-cmd-act" + (on ? " lead" : "")} onClick={(e) => { e.stopPropagation(); row.primary(); }}>Counter <Kbd></Kbd></button>
<button type="button" tabIndex={-1} aria-hidden="true" className="btn tc-cmd-act quiet" onClick={(e) => { e.stopPropagation(); row.secondary?.(); }}>Record <Kbd></Kbd></button>
</>
);
}
case "waiting": {
const p = h.pickup;
const first = p.lines[0];
const more = p.lines.length > 1 ? ` +${p.lines.length - 1}` : "";
const name = h.staff ? `${h.staff.first} ${h.staff.last}` : "someone no longer on the register";
return (
<>
<div className="tc-cmd-main">
<div className="tc-cmd-title">{first ? <>{label(d.byId[first.itemId])} · <span className="tc-mono">{first.size}</span>{more}</> : "A bag"} for {name}</div>
<div className="tc-cmd-meta">{[p.orderCode, `${plural(h.days, "day", "days")} at the counter`].filter(Boolean).join(" · ")}</div>
</div>
<button type="button" tabIndex={-1} className="btn btn-ghost tc-cmd-ghost" disabled={busy} onClick={(e) => { e.stopPropagation(); row.primary(); }}>Picked up</button>
</>
);
}
case "approves": {
const r = h.request;
const firstName = (r.managerName || "").trim().split(/\s+/)[0] || (staffById[r.managerId || ""]?.first ?? "");
const when = whenLabel(s, r.decidedAt);
return (
<>
<div className="tc-cmd-main">
<div className="tc-cmd-title">{r.code} · {r.staffName}</div>
<div className="tc-cmd-meta">approved by {firstName}{when ? ` ${when}` : ""} · ready to pick</div>
</div>
<button type="button" tabIndex={-1} className="btn btn-ghost tc-cmd-ghost" onClick={(e) => { e.stopPropagation(); row.primary(); }}>Open</button>
</>
);
}
case "garment":
return (
<div className="tc-cmd-main">
<div className="tc-cmd-title">{label(h.item)}{h.item.sku && <> <span className="tc-mono tc-cmd-num">{h.item.sku}</span></>}</div>
<div className="tc-cmd-meta">{[h.item.supplier, `${h.onhand} on hand`].filter(Boolean).join(" · ")}</div>
</div>
);
default: {
const o = h.order;
const late = isOverdue(o, s.today);
const tone = late ? "accent" : o.status === "Received" || o.status === "Cancelled" ? "quiet" : "outline";
const exp = o.expected ? `expected ${formatInZone(o.expected, s.tz, { day: "numeric", month: "short" }).replace("Sept", "Sep")}` : "";
return (
<div className="tc-cmd-main">
<div className="tc-cmd-title"><span className="tc-mono">{o.code}</span> <Tag tone={tone}>{late ? "Overdue" : o.status}</Tag></div>
<div className="tc-cmd-meta">{[o.supplier, exp].filter(Boolean).join(" · ")}</div>
</div>
);
}
}
};
return (
<div className="tc-cmd-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div ref={box} className={"tc-cmd" + (empty && !showUnknown ? " empty" : "")} role="dialog" aria-modal="true" aria-label="Search" onKeyDown={onKey}>
<div className="tc-cmd-inputrow">
<Icon name="search" size={18} />
<input ref={input} className="tc-cmd-input" value={q} onChange={(e) => setQ(e.target.value)}
aria-label="Search people, garments and orders" role="combobox" aria-expanded={rows.length > 0} aria-controls={listId}
aria-activedescendant={current ? current.id : undefined} aria-describedby={`${listId}-keys`} aria-autocomplete="list" autoComplete="off" spellCheck={false} />
<button type="button" className="tc-cmd-esc" onClick={onClose} aria-label="Close search"><Kbd>esc</Kbd></button>
</div>
<span id={`${listId}-keys`} className="sr-only">Arrow keys move through results. Enter opens the result; for a person, Enter opens the counter and Shift+Enter opens their record.</span>
<LiveRegion tone={msg?.tone} msg={msg?.text} className={"tc-cmd-live" + (msg?.tone === "alert" ? " err" : "")} />
{showUnknown && (
<div className="tc-cmd-unknown">
<span>No person or garment has <span className="tc-mono">{unknownCode}</span>.</span>
{isAdmin && <button type="button" className="btn btn-ghost tc-cmd-ghost" onClick={() => setBinding(unknownCode || "")}>Bind it to a garment</button>}
</div>
)}
<div className="tc-cmd-results" id={listId} role="listbox" aria-label="Results">
{GROUPS.map((g) => byGroup[g.key].length > 0 && (
<div key={g.key} role="group" aria-labelledby={`${listId}-${g.key}`}>
<div id={`${listId}-${g.key}`} className="tc-lbl tc-cmd-group" role="presentation">{g.label}</div>
{byGroup[g.key].map((row) => {
const on = row === current;
return (
<div key={row.id} id={row.id} role="option" aria-selected={on} className={"tc-cmd-row" + (on ? " active" : "")}
onMouseMove={() => { const i = rows.indexOf(row); if (i !== at) setActive(i); }}
onClick={() => row.primary()}>
{rowBody(row, on)}
</div>
);
})}
</div>
))}
{!empty && !showUnknown && rows.length === 0 && <div className="tc-cmd-empty">Nothing matches {trimmed}.</div>}
</div>
<div className="tc-cmd-foot" aria-hidden="true">
<span><Kbd></Kbd> move</span>
<span><Kbd></Kbd> open</span>
<span>scan jumps straight to it</span>
</div>
</div>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
/* Community edition: there is no shared demo facility. */
export default function DemoBanner() { return null; }
+45
View File
@@ -0,0 +1,45 @@
"use client";
/* Installs client-side error reporting.
*
* Two jobs. It registers the global handlers, because most client crashes never reach a React
* error boundary — a failed fetch in an event handler, a promise nobody awaited, a script that
* threw during hydration. And it publishes the reporter that lib/errors.ts looks for, so the
* branded boundaries in error.tsx and global-error.tsx start sending without importing any of
* this themselves.
*
* Mounted once in the root layout so it covers the marketing site, the desktop app and /m alike. */
import { useEffect } from "react";
import { report, errorReportingOn } from "@/lib/glitchtip";
import { scrubPath } from "@/lib/analytics";
export default function ErrorReporting() {
useEffect(() => {
if (!errorReportingOn()) return;
const path = () => (typeof location === "undefined" ? "" : scrubPath(location.pathname));
// The seam the error boundaries call. Publishing it here means those files never need to know
// which reporter is in use.
(window as unknown as { __tcReporter?: unknown }).__tcReporter = {
captureException(e: unknown, ctx?: { tags?: Record<string, string>; extra?: Record<string, unknown> }) {
report({ error: e, where: ctx?.tags?.boundary || "boundary", url: path(), tags: ctx?.tags, extra: ctx?.extra });
},
};
const onError = (ev: ErrorEvent) => {
report({ error: ev.error ?? ev.message, where: "window", url: path() });
};
const onRejection = (ev: PromiseRejectionEvent) => {
report({ error: ev.reason, where: "unhandled-rejection", url: path() });
};
window.addEventListener("error", onError);
window.addEventListener("unhandledrejection", onRejection);
return () => {
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onRejection);
};
}, []);
return null;
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
/* This facility's rules, as the counter applies them: the panel at the top of Help. It was the whole
* Help page until the manual arrived (docs/manual); it stays because it is the one place the rules
* are stated with this facility's own figures rather than the defaults.
*
* Every figure is read from this facility's own settings rather than written in, so the page can't
* quote a number the facility has changed. The import rules are the templates' own notes, so they
* can't drift from what the importer accepts. */
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { CSV_TEMPLATES } from "@/lib/csv";
import { FTE_SETS, SLIP_DAYS } from "@/lib/compute";
import { SET_GARMENTS, setsCap, setsOnStart } from "@/lib/sets";
function Section({ title, more, children }: { title: string; more?: string; children: React.ReactNode }) {
return (
<div className="tc-panel" style={{ marginBottom: "var(--space-3)" }}>
<div className="tc-panel-head"><span>{title}</span>{more && <Link href={`/app/help/${more}`} style={{ fontSize: 12, fontWeight: 600 }}>In the manual</Link>}</div>
<div className="tc-panel-body" style={{ fontSize: 14, lineHeight: 1.6 }}>{children}</div>
</div>
);
}
const list = { margin: 0, paddingLeft: "1.2em", display: "grid", gap: "var(--space-1)" } as const;
export default function FacilityRules() {
const { s } = useSnap();
const cap = setsCap(s.settings.capSets);
const start = Math.min(cap, setsOnStart(s.settings.initialSets));
// The table as the form lists it: a full-timer's figure first, down to the smallest.
const table = Object.entries(FTE_SETS).filter(([, n]) => n !== null) as [string, number][];
return (
<div className="mn-rules">
<Section title="What anyone may hold" more="people/entitlement-rule">
<ul style={list}>
<li>Up to <b>{cap} sets</b> at any time {cap} tops and {cap} pairs of trousers. The same for every staff group, nursing included.</li>
<li>It counts everything issued and not handed in or returned, plus anything on order for them, waiting at the counter, or approved and not yet collected. Pre-loved garments count.</li>
<li>Garments that aren&apos;t part of a set fleeces, jackets, maternity wear have their own ceiling of {cap}.</li>
<li>It isn&apos;t a yearly allowance and nothing resets in July. At the ceiling, the next garment comes by handing one in first, or on a coordinator&apos;s override, which is recorded.</li>
<li>Change the figure under Settings Issuing rules, as Most anyone holds.</li>
</ul>
</Section>
<Section title="Ordering from suppliers" more="stock/order-list">
<ul style={list}>
<li><b>Orders To order</b> lists every size at or below its reorder level, topped up to twice the level and netted off what is already on order, one panel per supplier. Adjust the quantities, add a line, type the supplier order no., then <b>Order and email</b> (<b>Order</b> when the supplier has no order email).</li>
<li>A person&apos;s order from the counter stays its own order one per supplier per person so an order placed under their account at the supplier is never merged into the shelf&apos;s.</li>
<li><b>Sheet</b> prints the supplier&apos;s A4 sheet with their product codes. The email goes to the Order email under Settings Catalogue &amp; suppliers. Enter each size&apos;s code once, on the garment&apos;s page under Ordering. Invoice and tracking numbers go on the order&apos;s own page, opened from On the way.</li>
</ul>
</Section>
<Section title="The three routes" more="people/groups-and-routes">
<p style={{ margin: "0 0 var(--space-2)" }}>Each staff group is on one route, chosen under Settings Issuing rules, on the staff groups board. All three stop at the same {cap} sets.</p>
<ul style={list}>
<li><b>FTE table</b> the hours someone works propose their starting kit: {table.map(([fte, n]) => `${fte} FTE ${n}`).join(", ")} sets; a casual is at the manager&apos;s discretion. A manager may sign for more.</li>
<li><b>Starting kit</b> {start} sets on the first day ({start * SET_GARMENTS} garments), then more as needed. Nothing has to be handed back first.</li>
<li><b>Manager approval</b> no starting kit; the manager approves each set that is asked for. The counter checks the ceiling, not whether an approval is on file.</li>
<li>A group can&apos;t be on two routes. Taking a group with staff in it off the list puts them on manager approval, so move them to another group or rename it instead.</li>
</ul>
</Section>
<Section title="The yearly figure" more="reports/the-nine-reports">
<p style={{ margin: 0 }}>&ldquo;Items (FY)&rdquo; on Reports People counts what someone has drawn since 1 July. It feeds the reports and the Exceptions list, and never limits what the counter issues. Groups on the FTE table aren&apos;t measured against one.</p>
</Section>
<Section title="Hand-ins" more="counter/exchanges-and-returns">
<ul style={list}>
<li>Handing a garment in frees room at the counter straight away, whether or not the credit box is ticked.</li>
<li>The credit tick adds the good garments back to the yearly figure and to the manager&apos;s approval. Pre-loved garments earn neither.</li>
<li>Good garments join the pre-loved pool and are reissued free; rags are counted for disposal.</li>
</ul>
</Section>
<Section title="Garment types" more="stock/catalogue-sizes-and-cuts">
<p style={{ margin: 0 }}>A garment&apos;s type decides how it counts. Tops and trousers are each half a set; every other type counts toward the separate ceiling. A type typed in by hand that isn&apos;t on the list counts toward no set, so pick from the list.</p>
<p style={{ margin: "var(--space-2) 0 0" }}>Each garment is tagged for the staff groups that wear it, or for all groups. Staff can only request their own groups&apos; garments, and the counter needs a coordinator&apos;s override, which is recorded, to issue anyone a garment outside their group.</p>
<p style={{ margin: "var(--space-2) 0 0" }}>A garment is also men&apos;s, women&apos;s or unisex. Somebody is offered the cut set as their Uniform style plus everything unisex; blank means every style until a coordinator sets it, and the counter needs the same override, also recorded, to issue anyone another cut.</p>
</Section>
<Section title="The staff app" more="apps/staff-app">
<ul style={list}>
<li>On the person&apos;s record, under Details &amp; access Staff app, generate a code and print the slip. A code works once and expires after {SLIP_DAYS} days.</li>
<li>Record their manager first, under Manager on the same tab nobody can raise a request without one.</li>
</ul>
</Section>
<Section title="Requests and approvals" more="counter/manager-approvals">
<ul style={list}>
<li>A request goes to the person&apos;s manager the same person who signs their paper order form.</li>
<li>A manager can raise requests for the people who report to them; those go to the manager above. With nobody above, the request waits on Requests under Needs an approver.</li>
<li>Nobody approves a request they raised for somebody else.</li>
<li>Anyone can be set as their own manager; what they approve for themselves is marked Self-approved.</li>
</ul>
</Section>
<Section title="Stock takes" more="stock/stocktakes">
<p style={{ margin: 0 }}>A count in progress on Stock Count is saved in this browser only, under your sign-in. It survives a reload, but not a move to another computer or the phone finish a count where you started it.</p>
</Section>
<Section title="Importing and exporting" more="reference/csv-templates">
<p style={{ margin: "0 0 var(--space-2)" }}>Settings Data &amp; audit log imports each list from a CSV file. The rules for each:</p>
<ul style={list}>
{Object.entries(CSV_TEMPLATES).map(([k, t]) => <li key={k}><b>{t.name}</b> {t.note}</li>)}
</ul>
<p style={{ margin: "var(--space-2) 0 0" }}>People Export CSV writes headers the import reads back, so a ward&apos;s list can go to its manager, come back with Manager number filled in, and be imported again.</p>
</Section>
<Section title="Month-end journal" more="reports/journal-export">
<p style={{ margin: 0 }}>Reports Spend Journal: one debit line per cost centre, priced at each garment&apos;s cost on the day it was issued. Finance posts the balancing credit.</p>
</Section>
<Section title="Who ThreadCount emails" more="selfhost/email">
<ul style={list}>
<li>You password resets, and updates you&apos;ve subscribed to.</li>
<li>Staff only about their own requests, once they&apos;ve set up the staff app.</li>
<li>Managers the link to approve or decline a request.</li>
</ul>
</Section>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { Suspense } from "react";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { helpFor } from "@/lib/manual-links";
/* The help mark beside every app screen's title: one square that opens the manual page about the
* screen you are on. Only on the coordinator app (/app); the phone apps have their own furniture
* and Help itself needs no mark. Screens with tabs (Settings, Stock, Reports, a staff record) pick
* the page by ?tab=, which switches through router.replace, so the tab is read with
* useSearchParams. That reader sits in its own Suspense boundary, so no screen that renders a page
* head needs one; until it resolves the mark points at the screen's untabbed page. */
export default function HelpMark() {
const path = usePathname() || "";
if (!path.startsWith("/app") || path.startsWith("/app/help")) return null;
return (
<Suspense fallback={<Mark path={path} tab={null} />}>
<TabbedMark path={path} />
</Suspense>
);
}
function TabbedMark({ path }: { path: string }) {
const sp = useSearchParams();
return <Mark path={path} tab={sp?.get("tab") ?? null} />;
}
function Mark({ path, tab }: { path: string; tab: string | null }) {
return (
<Link href={`/app/help/${helpFor(path, tab)}`} className="tc-helpmark no-print" aria-label="Help for this screen" title="Help for this screen">?</Link>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
/* The helpdesk chat widget (Chatwoot, self-hosted).
*
* Mounted on the public site and the coordinator web app; never in the phone apps. Off unless an
* operator sets NEXT_PUBLIC_CHATWOOT_URL + NEXT_PUBLIC_CHATWOOT_TOKEN (the Community edition ships without). Like the
* analytics tag it only loads on a page really served from threadcount.tech, so the e2e suites
* on localhost never open conversations against the live desk. The widget itself sets a cookie
* on the helpdesk host to keep a visitor's conversation across pages; it never reads anything
* from this page beyond what the visitor types into it. */
import Script from "next/script";
import { useEffect, useState } from "react";
const BASE = (process.env.NEXT_PUBLIC_CHATWOOT_URL || "").replace(/\/$/, "");
const TOKEN = process.env.NEXT_PUBLIC_CHATWOOT_TOKEN || "";
declare global {
interface Window { chatwootSDK?: { run: (o: { websiteToken: string; baseUrl: string }) => void }; chatwootSettings?: Record<string, unknown>; $chatwoot?: { toggle: (state?: "open" | "close") => void } }
}
export default function Helpdesk() {
const [on, setOn] = useState(false);
useEffect(() => {
if (!TOKEN || !BASE) return;
// The widget has no built-in default: it exists only where an operator set the two variables,
// so a Community instance pointing at its own Chatwoot gets it on its own hostname. Local
// `next start`/e2e runs never set them, which is what keeps the hosted desk out of test noise.
window.chatwootSettings = { position: "right", type: "standard", launcherTitle: "Chat with us", darkMode: "auto" };
setOn(true);
}, []);
if (!on) return null;
return (
<Script
src={`${BASE}/packs/js/sdk.js`}
strategy="lazyOnload"
onLoad={() => window.chatwootSDK?.run({ websiteToken: TOKEN, baseUrl: BASE })}
/>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
/* The onboarding auth chrome: an ink header, the accent rule, a scrolling form and a docked
primary bar. Shared by sign in and create account so the two can't drift apart.
Fields are numbered — 01, 02, 03 — a counted form on an app about counting. */
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useId, useState } from "react";
export const INK = "var(--color-text)";
export const PAPER = "var(--color-bg)";
export function MAuthHeader({ kicker, title, back = true }: { kicker: string; title: React.ReactNode; back?: boolean }) {
const router = useRouter();
const path = usePathname();
/* The chevron used to be a plain router.back(), which on a fresh install did nothing at all.
* The Android shell leaves its welcome screen with location.replace(), so these two screens are
* the first entry in the WebView's history: someone who taps "Sign up", changes their mind and
* taps back was stuck, with only the footer link out — the hardware back button quits the app
* from here, because MainActivity hands the gesture to the system once canGoBack() is false.
*
* So: pop the history when there really is something behind, and otherwise fall back to sign in,
* which is where every other auth screen is reached from. Sign in itself has nothing behind it,
* so it draws no chevron at all rather than a dead one. history.length can only be read after
* mount, hence the state — the server render assumes the worst case of no history. */
const [canPop, setCanPop] = useState(false);
useEffect(() => { setCanPop(window.history.length > 1); }, []);
const fallback = path === "/m/login" ? null : "/m/login";
const showBack = back && (canPop || fallback !== null);
return (
<header style={{ flex: "0 0 auto", background: INK, color: PAPER, padding: "calc(20px + env(safe-area-inset-top, 0px)) 24px 28px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{showBack && (
<button onClick={() => { if (canPop) router.back(); else if (fallback) router.push(fallback); }} aria-label="Back"
style={{ width: 44, height: 44, marginLeft: -12, border: 0, background: "none", color: "inherit", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="square" aria-hidden="true"><path d="M15 18 9 12l6-6" /></svg>
</button>
)}
<span style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 9 }}>
<span aria-hidden="true" style={{ width: 15, height: 15, background: "#fff" }} />
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, letterSpacing: "-0.01em" }}>ThreadCount</span>
</span>
</div>
<div style={{ marginTop: 34, fontSize: 11, fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>{kicker}</div>
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 46, lineHeight: 0.94, letterSpacing: "-0.03em", marginTop: 12 }}>{title}</h1>
</header>
);
}
/** A numbered field. The index is the device that makes this form ThreadCounts.
*
* The label is a real <label> tied to the control by id, and the control comes in as a function so
* the id can be handed to it: these two screens are the whole of both Play listings' sign-in, and
* drawing the label as a <span> meant a screen reader announced "edit box" with no name at all —
* on a password box, which has no placeholder to fall back on either. The 01/02/03 index is
* hidden from the accessibility tree; it is a counted-form flourish, not part of the field's name. */
export function MField({ n, label, right, children }: {
n: string; label: string; right?: React.ReactNode; children: (control: { id: string }) => React.ReactNode;
}) {
const id = useId();
return (
<div>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span aria-hidden="true" style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 11, color: "var(--color-accent)" }}>{n}</span>
<label htmlFor={id} style={{ fontWeight: 600, fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-800)" }}>{label}</label>
{right && <span style={{ marginLeft: "auto" }}>{right}</span>}
</div>
{children({ id })}
</div>
);
}
export const authInput: React.CSSProperties = {
width: "100%", border: 0, borderBottom: "2px solid var(--color-text)", background: "transparent",
// 16px keeps Android from zooming the page when the field takes focus.
fontSize: 17, fontWeight: 500, padding: "10px 0", marginTop: 8, borderRadius: 0, color: "var(--color-text)",
};
export function MShowHide({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return (
// "Show" on its own says nothing about what it shows once the label is read out separately.
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={on ? "Hide password" : "Show password"}
style={{ background: "none", border: 0, padding: 0, fontWeight: 600, fontSize: 11, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-700)", cursor: "pointer" }}>
{on ? "Hide" : "Show"}
</button>
);
}
export function MAuthError({ msg }: { msg: string }) {
if (!msg) return null;
return (
<div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "10px 12px", fontSize: 13.5, fontWeight: 600, color: "var(--color-accent-700)", background: "#fff" }}>
{msg}
</div>
);
}
/** Footer: a secondary line above the docked primary bar. */
export function MAuthFooter({ secondary, label, onSubmit, busy, disabled }: {
secondary: React.ReactNode; label: string; onSubmit: () => void; busy?: boolean; disabled?: boolean;
}) {
return (
<div style={{ flex: "0 0 auto", borderTop: "2px solid var(--color-divider)", background: PAPER }}>
<div style={{ padding: "14px 24px", fontSize: 13, color: "var(--color-neutral-800)" }}>{secondary}</div>
<button onClick={onSubmit} disabled={busy || disabled}
style={{ width: "100%", height: 66, border: 0, background: "var(--color-accent)", color: "#fff", display: "flex", alignItems: "center", gap: 12, padding: "0 24px calc(0px + env(safe-area-inset-bottom, 0px))", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: busy || disabled ? "not-allowed" : "pointer", opacity: busy || disabled ? 0.55 : 1 }}>
<span style={{ flex: 1, textAlign: "left" }}>{busy ? "One moment…" : label}</span>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="square" aria-hidden="true"><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></svg>
</button>
</div>
);
}
export const authLink: React.CSSProperties = { color: "var(--color-accent-700)", fontWeight: 600, textDecoration: "none" };
export function useAuthShell() {
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
return { busy, setBusy, err, setErr };
}
export { Link };
+106
View File
@@ -0,0 +1,106 @@
"use client";
/* The counter phone's work in progress between a screen and its Sign step: the lines being issued to
* somebody, what they are handing back, what has been picked off a request, and how many sets come
* off a manager's approval.
*
* Memory, mirrored to sessionStorage so a reload or the Android back button does not throw a half
* built basket away. It is NOT an offline queue: nothing in here is ever sent to the server until the
* person presses the bar, and it is cleared on sign out alongside the open counts. */
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useSnap } from "@/lib/client";
export type IssueLine = { key: string; itemId: string; si: number; qty: number; reason: string | null };
export type BackLine = { uid: string; itemId: string; si: number; cond: "Good" | "Damaged" | "Condemn" | "Lost"; swapSi: number | null };
type State = {
issue: Record<string, IssueLine[]>;
back: Record<string, BackLine[]>;
picked: Record<string, Record<string, number>>;
deduct: Record<string, number | null>;
};
const EMPTY: State = { issue: {}, back: {}, picked: {}, deduct: {} };
export type Basket = {
issue: (staffId: string) => IssueLine[];
setIssue: (staffId: string, lines: IssueLine[]) => void;
back: (staffId: string) => BackLine[];
setBack: (staffId: string, lines: BackLine[]) => void;
picked: (requestId: string) => Record<string, number>;
setPicked: (requestId: string, p: Record<string, number>) => void;
deduct: (staffId: string) => number | null;
setDeduct: (staffId: string, n: number | null) => void;
clear: (kind: "issue" | "back" | "picked", id: string) => void;
};
export const basketKey = (userId: string) => `tc.basket.${userId}`;
/** Forget this person's basket on the device (sign out). The in-memory copy goes with the page. */
export function clearBasket(userId: string) {
try { sessionStorage.removeItem(basketKey(userId)); } catch { /* storage blocked: nothing kept */ }
}
const NONE_ISSUE: IssueLine[] = [];
const NONE_BACK: BackLine[] = [];
const NONE_PICKED: Record<string, number> = {};
const BasketContext = createContext<Basket | null>(null);
export function MBasketProvider({ children }: { children: React.ReactNode }) {
const { s } = useSnap();
const userId = s.session.userId;
const [st, setSt] = useState<State>(EMPTY);
const loaded = useRef(false);
// Read after mount: the server render has no sessionStorage, and reading during render would make
// the first paint disagree with the server's.
useEffect(() => {
try {
const raw = sessionStorage.getItem(basketKey(userId));
if (raw) {
const j = JSON.parse(raw) as Partial<State>;
setSt({ issue: j.issue || {}, back: j.back || {}, picked: j.picked || {}, deduct: j.deduct || {} });
}
} catch { /* unreadable or blocked: start empty */ }
loaded.current = true;
}, [userId]);
useEffect(() => {
if (!loaded.current) return;
try { sessionStorage.setItem(basketKey(userId), JSON.stringify(st)); } catch { /* blocked: memory only */ }
}, [st, userId]);
const setIssue = useCallback((id: string, lines: IssueLine[]) => setSt((x) => ({
...x, issue: { ...x.issue, [id]: lines },
// A changed basket is a different number of sets, so the approval deduction is asked again.
deduct: { ...x.deduct, [id]: null },
})), []);
const setBack = useCallback((id: string, lines: BackLine[]) => setSt((x) => ({ ...x, back: { ...x.back, [id]: lines } })), []);
const setPicked = useCallback((id: string, p: Record<string, number>) => setSt((x) => ({ ...x, picked: { ...x.picked, [id]: p } })), []);
const setDeduct = useCallback((id: string, n: number | null) => setSt((x) => ({ ...x, deduct: { ...x.deduct, [id]: n } })), []);
const clear = useCallback((kind: "issue" | "back" | "picked", id: string) => setSt((x) => {
const next: State = { issue: { ...x.issue }, back: { ...x.back }, picked: { ...x.picked }, deduct: { ...x.deduct } };
delete next[kind][id];
if (kind === "issue") delete next.deduct[id];
return next;
}), []);
const value = useMemo<Basket>(() => ({
issue: (id) => st.issue[id] || NONE_ISSUE,
setIssue,
back: (id) => st.back[id] || NONE_BACK,
setBack,
picked: (id) => st.picked[id] || NONE_PICKED,
setPicked,
deduct: (id) => (st.deduct[id] === undefined ? null : st.deduct[id]),
setDeduct,
clear,
}), [st, setIssue, setBack, setPicked, setDeduct, clear]);
return <BasketContext.Provider value={value}>{children}</BasketContext.Provider>;
}
export function useBasket(): Basket {
const c = useContext(BasketContext);
if (!c) throw new Error("useBasket outside MBasketProvider");
return c;
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
/* The ink header that identifies whoever is at the counter, plus the "what are they holding"
derivation the person, issue, return and exchange screens all need. */
import { useMemo } from "react";
import { approvalRemaining, capCheck, ccOf, isNursing, type IssueRec, itemMap, type Snapshot, staffName, type StaffRec, variantName } from "@/lib/compute";
import { GROUND, INK, ON_DARK } from "@/components/m";
export function MPersonHead({ s, st, sub }: { s: Snapshot; st: StaffRec; sub?: React.ReactNode }) {
const cc = ccOf(s, st);
const sizes = [st.top && `Top ${st.top}`, st.pants && `Pants ${st.pants}`].filter(Boolean) as string[];
return (
<section style={{ background: INK, color: GROUND, padding: "18px 16px 20px" }}>
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>
{[st.dept || st.group || "Staff", st.num].filter(Boolean).join(" · ")}
</div>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05, marginTop: 8 }}>{staffName(st)}</h2>
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 20px", marginTop: 12, fontSize: 14, color: ON_DARK }}>
{sizes.length ? sizes.map((x) => <span key={x}>{x}</span>) : <span>No sizes recorded yet</span>}
{cc && <span>Cost centre {cc}</span>}
</div>
{sub}
</section>
);
}
/** What this person is holding, against what anybody may hold, in the words the coordinator uses.
*
* Six sets at any time — a top and a pair of trousers to a set — for every group, nursing included.
* Not a figure that starts again in July: six sets is what somebody has on their back and in their
* locker, so the bar fills on what they are holding plus whatever is in the bag on the counter, and
* the ways past a full six are a hand-in or a coordinator's override.
*
* `cart` is that bag. The head bar and the issue screen under it ask one function, because each
* keeping its own copy of the sum is what let the head demand a tick the counter didn't want — two
* halves of one screen contradicting each other is worse than either being wrong.
*
* A manager's approval is a different control and still belongs on a nurse's header: the approval is
* what pays for the garments, this is how much uniform one person may walk around with. */
export function MEntitlement({ s, st, cart = [] }: { s: Snapshot; st: StaffRec; cart?: { itemId: string; qty: number }[] }) {
const nursing = isNursing(s, st);
const cap = capCheck(s, st, cart);
const sets = approvalRemaining(s, st.id);
// Filled by whichever of the three is nearest the ceiling — tops, pairs, or garments outside a set —
// because that is the one the counter turns somebody away on. Filled by sets, six tops and two
// pairs with a seventh top in the bag showed a bar a third full directly above "Past what one
// person holds", and a coordinator reading the two has no way to tell which one is lying.
const pct = cap.cap > 0 && cap.otherCap > 0 ? Math.min(1, Math.max(cap.afterTops / cap.cap, cap.afterPants / cap.cap, cap.afterOther / cap.otherCap)) : 1;
const over = cap.over;
const approval = sets > 0 ? `Managers approval — ${sets} set${sets === 1 ? "" : "s"} still approved` : "No managers approval open — anything issued now needs a new form";
return (
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 13, color: ON_DARK }}>
{nursing ? `${approval} · ${cap.note}` : cap.note}
</div>
<div style={{ height: 8, background: "var(--color-neutral-900)", border: "1.5px solid var(--color-neutral-600)", marginTop: 8 }}>
<div style={{ height: "100%", width: `${pct * 100}%`, background: over || (nursing && sets === 0) ? "var(--color-accent-300)" : "var(--color-accent)" }} />
</div>
{over && <div style={{ fontSize: 13, fontWeight: 700, color: "var(--color-accent-300)", marginTop: 8 }}>Past what one person holds this needs a coordinator override.</div>}
</div>
);
}
export type Held = { key: string; itemId: string; si: number; size: string; name: string; qty: number; issues: IssueRec[] };
/** What a staff member has out right now, grouped by garment and size, newest issue first. */
export function useHeld(s: Snapshot, staffId: string): Held[] {
return useMemo(() => {
const byId = itemMap(s);
const m: Record<string, Held> = {};
for (const i of s.issues) {
if (i.staffId !== staffId || i.returned || i.handedIn) continue;
const it = byId[i.itemId];
const k = `${i.itemId}:${i.si}`;
const size = String(it?.sizes[i.si] ?? i.si);
(m[k] ||= { key: k, itemId: i.itemId, si: i.si, size, name: `${variantName(it, size)}`, qty: 0, issues: [] });
m[k].qty += i.qty;
m[k].issues.push(i);
}
return Object.values(m).sort((a, b) => a.name.localeCompare(b.name));
}, [s, staffId]);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
/* The app's barcode camera. Two modes:
- "single": read one code and hand it back (issue, hand back, pick, count).
- "live": keep decoding, one tally per decode, with a debounce so a garment held in frame
isn't counted twice. Beep and haptic tick on every accepted scan.
Native Android gets a real scanner through Capacitor's MLKit plugin when one is present;
the browser path uses BarcodeDetector through lib/webscan.ts, shared with the Scan tab. */
import { useCallback, useEffect, useRef, useState } from "react";
import { ACCENT, GROUND, INK, IconX, MAction, ON_DARK } from "@/components/m";
import { isNative, scanOnce, startLive } from "@/lib/nativescan";
import { startWebLive } from "@/lib/webscan";
import { scanTick } from "@/lib/feedback";
import { track } from "@/lib/analytics";
import { useKeepAwake } from "@/lib/wakelock";
export default function MScan({ onHit, onClose, live = false, title, figure, log = [], running, onToggle, debounceMs = 900, control }: {
onHit: (raw: string) => void;
onClose: () => void;
live?: boolean;
title: string;
figure?: React.ReactNode;
log?: string[];
running?: boolean;
onToggle?: () => void;
debounceMs?: number;
/** Drawn in place of the Start/Stop (or Done) bar, e.g. the counting screen's Hands-free switch. */
control?: React.ReactNode;
}) {
const vid = useRef<HTMLVideoElement | null>(null);
const [status, setStatus] = useState("Starting the camera…");
const [denied, setDenied] = useState(false);
const hit = useRef(onHit); hit.current = onHit;
// In live mode the header's Start/Stop is the truth about whether a scan counts. Everything that
// can accept a code checks it, native and web alike.
const active = live ? !!running : true;
const on = useRef(active); on.current = active;
// Inside the Android shell MLKit does the reading; the browser path below is left alone.
const native = isNative();
// Whether MLKit is actually there. A shell built without the plugin answers "native-unavailable",
// and the honest thing to do then is hand the job back to the browser scanner rather than sit on
// a black screen — so this flips to false and the web effect below stops standing down.
const [nativeOk, setNativeOk] = useState(native);
useEffect(() => {
if (!native || !nativeOk) return;
let session: { stop: () => Promise<void> } | null = null;
let cancelled = false;
let lastRaw = "", lastT = 0;
const accept = (raw: string) => {
// Paused is paused. Without this the MLKit listener kept tallying garments onto the count
// while the header said PAUSED and the phone was being carried to the next bay.
if (!on.current) return;
// Same debounce as the web path: a garment held in frame is one garment.
if (raw === lastRaw && Date.now() - lastT < debounceMs) return;
lastRaw = raw; lastT = Date.now();
scanTick();
hit.current(raw);
};
(async () => {
if (live) {
// `active` is in the dependency list, so stopping the scan tears this effect down and the
// cleanup below stops the MLKit session: the camera is off while the header says PAUSED,
// and starting again re-runs this and asks for the camera afresh.
if (!active) return;
const r = await startLive(accept);
if (cancelled) { await r.stop(); return; }
if (r.error === "native-unavailable") { setNativeOk(false); return; }
if (r.error) { setStatus(r.error); setDenied(true); return; }
setStatus("Hold each garment up to the camera");
setDenied(false);
session = r;
} else {
const r = await scanOnce();
if (cancelled) return;
if (r.error === "native-unavailable") { setNativeOk(false); return; }
if (r.error) { setStatus(r.error); setDenied(true); return; }
if (r.code) accept(r.code);
onClose();
}
})();
return () => { cancelled = true; if (session) session.stop(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [native, nativeOk, live, active, debounceMs]);
useEffect(() => {
if (nativeOk) return; // the shell has its own scanner running
const v = vid.current;
if (!v) return;
const session = startWebLive(v, (raw) => hit.current(raw), debounceMs, {
active: () => on.current,
onStatus: (st) => {
if (st.state === "ready") { setStatus(live ? "Hold each garment up to the camera" : "Point the camera at the barcode"); return; }
setStatus(st.msg);
if (st.state === "denied") setDenied(true);
},
});
return () => session.stop();
}, [nativeOk, live, debounceMs]);
// Which scanner actually ran matters: the MLKit path is why the Android app exists, and the
// browser fallback silently taking over would otherwise be invisible. A shell that had to fall
// back files a second event naming its own engine, which is the number worth watching.
useEffect(() => {
track("scan_opened", { mode: live ? "live" : "single", engine: nativeOk ? "mlkit" : native ? "mlkit-missing" : "browser" });
}, [native, nativeOk, live]);
useKeepAwake(true);
const close = useCallback(() => onClose(), [onClose]);
// `tcx-scanui` is what spares this overlay while the Android shell scans: globals.css hides every
// other child of .tcx-app so MLKit's camera preview, painted behind the WebView, can be seen.
// Drop the class off the root below and the rule hides the scan UI along with everything else,
// leaving a coordinator mid-count with a bare picture and no "Stop scanning" bar to press.
return (
<div className="tcx-scanui" style={{ position: "fixed", inset: 0, zIndex: 90, background: INK, color: GROUND, display: "flex", flexDirection: "column" }}>
<header className="tcx-topbar" style={{ height: 56, flex: "0 0 56px", display: "flex", alignItems: "center", gap: 10, padding: "0 16px", background: INK }}>
{live && <span aria-hidden="true" style={{ width: 9, height: 9, background: running ? ACCENT : "var(--color-neutral-600)" }} />}
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{live ? (running ? "LIVE" : "PAUSED") : title}
</span>
<button onClick={close} aria-label="Close the camera" style={{ width: 44, height: 44, marginRight: -12, border: 0, background: "none", color: "inherit", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}><IconX /></button>
</header>
<div style={{ height: 4, flex: "0 0 4px", background: ACCENT }} />
<div className="tcx-camwin" style={{ flex: 1, position: "relative", minHeight: 0, background: "var(--color-neutral-900)", overflow: "hidden" }}>
<video ref={vid} autoPlay playsInline muted style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }} />
{!denied && (
<div aria-hidden="true" style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: "76%", maxWidth: 380, height: 132, border: "3px solid #fff", position: "relative" }}>
<span className="tcx-laser" style={{ position: "absolute", left: 0, right: 0, height: 3, background: ACCENT }} />
</div>
</div>
)}
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, padding: "12px 16px", background: "color-mix(in srgb, #201e1d 82%, transparent)" }}>
{live && log.length > 0 && (
<>
<div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>Last scans</div>
<div style={{ marginTop: 6 }}>
{log.slice(0, 4).map((l, i) => (
<div key={i} style={{ fontSize: 13.5, fontWeight: i === 0 ? 700 : 400, color: i === 0 ? GROUND : ON_DARK, lineHeight: 1.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{l}</div>
))}
</div>
</>
)}
{/* Drawn in live mode too. A refused camera or an MLKit that wouldn't start otherwise
left the person looking at a black rectangle with nothing to read and nothing to do. */}
<div role={denied ? "alert" : undefined} style={{ fontSize: 13.5, marginTop: live && log.length > 0 ? 8 : 0, color: denied ? "var(--color-accent-300)" : ON_DARK }}>{status}</div>
</div>
</div>
{figure}
{control !== undefined ? control : live && onToggle ? (
<MAction label={running ? "Stop scanning" : "Start scanning"} onClick={onToggle} tone={running ? "grey" : "accent"} glyph="scan" />
) : (
<MAction label="Done" onClick={close} tone="grey" />
)}
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
/* Create account — onboarding screen 04. This creates a whole facility, which is why it asks for
the hospital's name: the person signing up becomes its first administrator. */
import { PRIVACY_URL, TERMS_URL } from "@/lib/links";
import Link from "next/link";
import { useState } from "react";
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
import { track } from "@/lib/analytics";
import { MAuthError, MAuthFooter, MAuthHeader, MField, MShowHide, authInput, authLink } from "@/components/MAuth";
import PlanChoice from "@/components/PlanChoice";
/* `plansLive` comes from the server wrapper at app/m/signup/page.tsx: while it is false the screen
says free and asks nothing about plans; once true it offers the two hosted plans. */
export default function MSignup({ plansLive }: { plansLive: boolean }) {
const [plan, setPlan] = useState<"hosted_small" | "hosted_facility">("hosted_small");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [facility, setFacility] = useState("");
const [pw, setPw] = useState("");
const [show, setShow] = useState(false);
// Nothing to agree to on a Community instance whose operator has not set the document URLs.
const legal = !!(TERMS_URL || PRIVACY_URL);
const [agree, setAgree] = useState(!legal);
const [cfToken, setCfToken] = useState("");
const [busy, setBusy] = useState(false);
const [reveal, setReveal] = useState(false);
const [err, setErr] = useState("");
/** Set once the facility exists, so the address it was created with can be shown back. */
const [made, setMade] = useState<{ email: string; mailed: boolean; mail: boolean } | null>(null);
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
// The API wants first and last separately; the design asks for one "Full name" field, which is
// the kinder question. Split on the last space and keep whatever they typed.
const parts = name.trim().split(/\s+/);
const first = parts.length > 1 ? parts.slice(0, -1).join(" ") : parts[0] || "";
const last = parts.length > 1 ? parts[parts.length - 1] : "";
const ready = !!(first && last && emailOk && facility.trim() && pw.length >= 8 && agree);
async function submit() {
if (!ready) {
setErr(!name.trim() ? "Enter your name." : !emailOk ? "That email doesnt look right."
: !last ? "Enter your first and last name." : !facility.trim() ? "Which hospital is this for?"
: pw.length < 8 ? "Password must be at least 8 characters." : "Tick the box to continue.");
return;
}
setBusy(true);
// The widget draws nothing in quiet mode, so nobody can see that it hasn't finished. Wait for
// the token rather than posting an empty one and blaming the person for it.
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
if (turnstileOn() && !token) {
// Eight seconds and no token. Either the check needs an interaction we've asked Turnstile
// not to draw, or it couldn't reach Cloudflare at all. Show the widget rather than send an
// empty token and let the server answer with a check the person was never shown.
setBusy(false);
setReveal(true);
track("security_check_shown", { screen: "signup" });
setErr("Finish the security check below, then try again.");
return;
}
const r = await fetch("/api/auth/signup", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ first, last, facility, email, password: pw, cfToken: token, ...(plansLive ? { plan } : {}) }),
}).catch(() => null);
const j = r ? await r.json().catch(() => ({})) : {};
setBusy(false);
/* The answer never came back, which is not the same as nothing having happened: the request may
well have reached the server and made the facility before the connection went. Without this
the promise rejected and the Create button stayed disabled on its spinner for ever. Trying
again is safe — a second attempt on an address that did get through is refused as already
having an account, which is itself the answer. */
if (!r) {
track("signup_failed", { reason: "network" });
setErr("Couldnt reach the server, so we cant say whether the account was made. Try again — if it was, youll be told the email is already taken.");
setCfToken(""); resetTurnstile();
return;
}
if (!r.ok) {
track("signup_failed", { reason: r.status === 429 ? "throttled" : r.status === 400 ? "rejected" : "other" });
setErr(j.error || "Couldnt create the account."); setCfToken(""); resetTurnstile(); return;
}
// A facility created itself from a phone — the whole point of building sign-up into the app.
track("signup_completed");
/* Show the address back before going anywhere.
*
* The account is made and signed in either way. But this is the address a password reset goes
* to and the only route back into a facility whose one admin is locked out, and a typo in it is
* invisible until the day it matters — so it is put in front of the person once, with what
* actually happened to it. */
setMade({ email: String(j.email || email), mailed: !!j.mailed, mail: j.mail !== false });
}
if (made) {
return (
<>
<MAuthHeader kicker="Your facility is set up" title={<>Facility<br />created</>} back={false} />
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflowY: "auto", padding: "30px 24px", display: "grid", gap: 18, alignContent: "start" }}>
<div style={{ fontSize: 15, lineHeight: 1.6, color: "var(--color-text)" }}>
You&rsquo;re signed in as <b>{made.email}</b>.
</div>
<div style={{ fontSize: 14, lineHeight: 1.6, color: "var(--color-neutral-700)" }}>
{!made.mail
? "No mail is configured on this server, so that address hasnt been checked. Make sure it is right — it is where a password reset would go."
: made.mailed
? "Weve sent a note there — that is the address a password reset goes to. If it doesnt arrive, the address is wrong: add a second admin under Settings → Users while youre still signed in."
: "We couldnt send a note to that address. Check it is right, and add a second admin under Settings → Users while youre still signed in — otherwise a forgotten password locks the facility out."}
</div>
</div>
<MAuthFooter
secondary={<>A second admin under Settings is the way back in if this account is ever locked out.</>}
label="Start" onSubmit={() => window.location.replace("/m/signed-in?new=1")} busy={false} />
</>
);
}
return (
<>
<MAuthHeader kicker={plansLive ? "No card, no seat limit" : "Free — no card, no seat limit"} title={<>Create<br />account</>} />
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflowY: "auto", padding: "30px 24px", display: "grid", gap: 24, alignContent: "start" }}>
<MAuthError msg={err} />
<MField n="01" label="Full name">
{(c) => (
<input {...c} style={authInput} autoComplete="name" enterKeyHint="next" placeholder="Sam Whitfield"
value={name} onChange={(e) => { setName(e.target.value); setErr(""); }} />
)}
</MField>
<MField n="02" label="Work email">
{(c) => (
<input {...c} style={authInput} type="email" inputMode="email" autoCapitalize="none" autoCorrect="off"
spellCheck={false} autoComplete="email" enterKeyHint="next" placeholder="you@yourfacility.org"
value={email} onChange={(e) => { setEmail(e.target.value); setErr(""); }} />
)}
</MField>
<MField n="03" label="Hospital or facility">
{(c) => (
<input {...c} style={authInput} autoComplete="organization" enterKeyHint="next" placeholder="Riverside General"
value={facility} onChange={(e) => { setFacility(e.target.value); setErr(""); }} />
)}
</MField>
{plansLive && (
<fieldset style={{ border: 0, padding: 0, margin: 0 }}>
<legend style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 8 }}>Plan</legend>
<PlanChoice value={plan} onChange={setPlan} />
</fieldset>
)}
<MField n="04" label="Password" right={<MShowHide on={show} onToggle={() => setShow(!show)} />}>
{(c) => (
<input {...c} style={authInput} type={show ? "text" : "password"} autoComplete="new-password"
enterKeyHint="go" placeholder="8 characters minimum" value={pw}
onChange={(e) => { setPw(e.target.value); setErr(""); }}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
)}
</MField>
{legal && <label style={{ display: "flex", gap: 12, alignItems: "flex-start", background: "#fff", borderLeft: "6px solid var(--color-text)", padding: 14, cursor: "pointer" }}>
<input type="checkbox" checked={agree} onChange={(e) => { setAgree(e.target.checked); setErr(""); }}
style={{ width: 20, height: 20, accentColor: "var(--color-accent)", flex: "0 0 20px", marginTop: 1 }} />
<span style={{ fontSize: 13, lineHeight: 1.5, color: "var(--color-neutral-800)" }}>
I agree to the {TERMS_URL ? <a href={TERMS_URL} target="_blank" rel="noopener" style={authLink}>terms of use</a> : "terms of use"}{TERMS_URL && PRIVACY_URL ? " and the " : ""}{PRIVACY_URL ? <a href={PRIVACY_URL} target="_blank" rel="noopener" style={authLink}>privacy notice</a> : null}.
</span>
</label>}
{turnstileOn() && <Turnstile onToken={setCfToken} action="signup" quiet={!reveal} />}
</div>
<MAuthFooter
secondary={<>Already have one? <Link href="/m/login" style={authLink}>Sign in</Link></>}
label="Create account" onSubmit={submit} busy={busy} />
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import Link from "next/link";
import type { ManualBase } from "@/components/ManualView";
import { MANUAL_REVIEWED, manual, tree } from "@/lib/manual";
/* The manual's front page: every section with every page and its one-line summary, so the whole
* table of contents is readable without opening a thing, plus the questions people arrive with. */
const ASKED: [string, string][] = [
["Load my staff list and catalogue", "reference/csv-templates"],
["Issue a set to a new starter", "counter/issue-a-garment"],
["Handle someone who asks for more than six sets", "people/entitlement-rule"],
["Raise an order to a supplier with their codes", "stock/order-list"],
["Receive a box that came short", "stock/receiving-and-back-orders"],
["Run a stocktake", "stock/stocktakes"],
["Give finance the month-end journal", "reports/journal-export"],
["Print a barcode for a garment that has none", "stock/barcodes"],
["Add a second admin", "account/users"],
["Export everything", "account/export-and-backup"],
];
export default function ManualHome({ base, title, lede, children }: { base: ManualBase; title: string; lede: string; children?: React.ReactNode }) {
const sections = tree();
const known = new Set(manual().map((p) => `${p.section}/${p.slug}`));
const asked = ASKED.filter(([, h]) => known.has(h));
const pages = sections.reduce((n, s) => n + s.pages.length, 0);
return (
<div className="mn-article mn-homepage">
<h1 className="mn-h1">{title}</h1>
<p className="mn-lede">{lede}</p>
<div className="mn-facts">
<div><b>Pages</b><span className="mn-mono">{pages}</span></div>
<div><b>Sections</b><span className="mn-mono">{sections.length}</span></div>
<div><b>Checked against the code</b><span className="mn-mono">{MANUAL_REVIEWED}</span></div>
<div><b>Search</b><span>press <kbd>/</kbd></span></div>
</div>
{children}
{asked.length > 0 && (
<section className="mn-section" id="asked">
<h2 className="mn-h2"><span className="n">01</span><span>How do I</span></h2>
<div className="mn-asked">
{asked.map(([q, h]) => <Link key={h + q} href={`${base}/${h}`}><span>{q.charAt(0).toLowerCase() + q.slice(1)}?</span><span className="k">{h.split("/")[0]}</span></Link>)}
</div>
</section>
)}
<section className="mn-section" id="contents">
<h2 className="mn-h2"><span className="n">02</span><span>Every page</span></h2>
<div className="mn-sections">
{sections.map((s) => (
<div key={s.id} id={s.id} className="mn-sec">
<div className="hd"><b>{s.title}</b><span className="mn-mono">{s.pages.length}</span></div>
<p>{s.blurb}</p>
<ol>
{s.pages.map((p) => (
<li key={p.slug}><Link href={`${base}/${s.id}/${p.slug}`}><b>{p.title}</b><span>{p.summary}</span></Link></li>
))}
</ol>
</div>
))}
</div>
</section>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type { SearchEntry } from "@/lib/manual";
/* The manual's search box. The index is small (titles, summaries, keywords and headings of every
* page) and arrives with the page, so it answers with no request and works offline once loaded.
* `/` focuses it from anywhere on a manual page, unless the reader is already typing somewhere. */
type Hit = { e: SearchEntry; score: number; anchor?: { id: string; text: string } };
function find(index: SearchEntry[], q: string): Hit[] {
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
if (!terms.length) return [];
const hits: Hit[] = [];
for (const e of index) {
const title = e.t.toLowerCase(), summary = e.s.toLowerCase(), heads = e.hd.map((h) => h.text.toLowerCase());
const hay = [title, summary, e.k, ...heads].join(" ");
if (!terms.every((t) => hay.includes(t))) continue;
let score = 0, anchor: Hit["anchor"];
for (const t of terms) {
if (title.includes(t)) score += 10;
if (e.k.includes(t)) score += 6;
const hi = heads.findIndex((h) => h.includes(t));
if (hi >= 0) { score += 4; anchor ??= e.hd[hi]; }
if (summary.includes(t)) score += 2;
}
if (title.startsWith(terms[0])) score += 5;
hits.push({ e, score, anchor: title.includes(terms[0]) ? undefined : anchor });
}
return hits.sort((a, b) => b.score - a.score).slice(0, 8);
}
export default function ManualSearch({ index, base }: { index: SearchEntry[]; base: string }) {
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const [sel, setSel] = useState(0);
const input = useRef<HTMLInputElement>(null);
const box = useRef<HTMLDivElement>(null);
const router = useRouter();
const hits = useMemo(() => find(index, q), [index, q]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = document.activeElement as HTMLElement | null;
const typing = el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
if (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey) { e.preventDefault(); input.current?.focus(); }
};
const onClick = (e: MouseEvent) => { if (box.current && !box.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener("keydown", onKey);
document.addEventListener("click", onClick);
return () => { document.removeEventListener("keydown", onKey); document.removeEventListener("click", onClick); };
}, []);
const href = (h: Hit) => `${base}/${h.e.h}${h.anchor ? "#" + h.anchor.id : ""}`;
const go = (h: Hit) => { setOpen(false); setQ(""); input.current?.blur(); router.push(href(h)); };
return (
<div className="mn-search" ref={box} role="search">
<label htmlFor="mn-q" className="mn-sr">Search the manual</label>
<input
id="mn-q"
ref={input}
type="search"
value={q}
placeholder="Search the manual"
autoComplete="off"
aria-controls="mn-results"
aria-expanded={open && q.length > 0}
onChange={(e) => { setQ(e.target.value); setSel(0); setOpen(true); }}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => Math.min(s + 1, hits.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)); }
else if (e.key === "Enter" && hits[sel]) { e.preventDefault(); go(hits[sel]); }
else if (e.key === "Escape") { setOpen(false); input.current?.blur(); }
}}
/>
<kbd aria-hidden>/</kbd>
{open && q.trim() && (
<div id="mn-results" className="mn-results" role="listbox">
{hits.length === 0 && <div className="none">Nothing matches &ldquo;{q}&rdquo;. Try another word, or the glossary.</div>}
{hits.map((h, i) => (
<a key={h.e.h + (h.anchor?.id ?? "")} href={href(h)} role="option" aria-selected={i === sel} className={i === sel ? "sel" : undefined}
onMouseEnter={() => setSel(i)} onClick={(e) => { e.preventDefault(); go(h); }}>
<span><b>{h.e.t}</b>{h.anchor && <small> {h.anchor.text}</small>}<em>{h.e.s}</em></span>
<span className="sec">{h.e.sec}</span>
</a>
))}
</div>
)}
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Link from "next/link";
import ManualSearch from "@/components/ManualSearch";
import ManualToc from "@/components/ManualToc";
import type { ManualBase } from "@/components/ManualView";
import { searchIndex, tree, type Heading } from "@/lib/manual";
/* The manual's frame, the same on the website and inside the app: the search box and the section
* tree on the left, the article, and the on-this-page rail on the right. The tree is plain
* <details>, so it opens and closes with no script; the section being read starts open. */
export const GUIDES: [string, string][] = [
["How to run a uniform stocktake", "/guides/uniform-stocktake"],
["Where the uniforms actually go", "/guides/uniform-loss"],
["Charging uniforms to the right cost centre", "/guides/cost-centre-reporting"],
["Entitlements and manager approvals", "/guides/manager-approvals"],
];
export default function ManualShell({ base, current, headings, children }: {
base: ManualBase;
current?: { section: string; slug: string };
headings?: Heading[];
children: React.ReactNode;
}) {
const sections = tree();
const app = base === "/app/help";
return (
<div className={"mn-shell" + (app ? " mn-app" : " mn-site")}>
<nav className="mn-tree" aria-label="Manual">
<ManualSearch index={searchIndex()} base={base} />
<Link href={base} className={"mn-home" + (current ? "" : " cur")}>Manual home</Link>
{sections.map((s) => (
<details key={s.id} open={!current || current.section === s.id}>
<summary>{s.title}</summary>
<ol>
{s.pages.map((p, i) => {
const on = current?.section === s.id && current.slug === p.slug;
return (
<li key={p.slug}>
<Link href={`${base}/${s.id}/${p.slug}`} className={on ? "cur" : undefined} aria-current={on ? "page" : undefined}>
<span className="n">{String(i + 1).padStart(2, "0")}</span><span>{p.title}</span>
</Link>
</li>
);
})}
</ol>
</details>
))}
<details open={!current}>
<summary>Guides</summary>
<ol>
{GUIDES.map(([t, h], i) => (
<li key={h}>
{app
? <a href={`https://threadcount.tech${h}`} target="_blank" rel="noopener"><span className="n">{String(i + 1).padStart(2, "0")}</span><span>{t}</span></a>
: <Link href={h}><span className="n">{String(i + 1).padStart(2, "0")}</span><span>{t}</span></Link>}
</li>
))}
</ol>
</details>
</nav>
<div className="mn-main">{children}</div>
{current && headings && <ManualToc headings={headings} page={`${current.section}/${current.slug}`} />}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import { track } from "@/lib/analytics";
/* The right-hand rail of a manual page: the headings with the one on screen marked, whether the
* page helped, and print and copy-link. The answer to "was this useful" goes to the product's own
* usage statistics as a counted event naming the page and nothing about the reader. */
export default function ManualToc({ headings, page }: { headings: { id: string; n: string; text: string }[]; page: string }) {
const [cur, setCur] = useState(headings[0]?.id ?? "");
const [voted, setVoted] = useState<"" | "yes" | "no">("");
const [copied, setCopied] = useState(false);
useEffect(() => {
if (typeof IntersectionObserver === "undefined") return;
const seen = new Map<string, boolean>();
const io = new IntersectionObserver((entries) => {
for (const e of entries) seen.set((e.target as HTMLElement).id, e.isIntersecting);
const first = headings.find((h) => seen.get(h.id));
if (first) setCur(first.id);
}, { rootMargin: "-80px 0px -60% 0px" });
for (const h of headings) { const el = document.getElementById(h.id); if (el) io.observe(el); }
return () => io.disconnect();
}, [headings]);
return (
<aside className="mn-toc" aria-label="On this page">
{headings.length > 0 && (
<>
<div className="mn-kick">On this page</div>
<ol>
{headings.map((h) => (
<li key={h.id}><a href={`#${h.id}`} className={h.id === cur ? "cur" : undefined} aria-current={h.id === cur ? "location" : undefined}>{h.text}</a></li>
))}
</ol>
</>
)}
<div className="mn-fb">
<b>Did this page answer it?</b>
{voted ? (
<p>{voted === "yes" ? "Thanks. Noted against this page." : "Thanks. Noted against this page. If you tell support what was missing, the page gains the answer."}</p>
) : (
<div className="row">
<button type="button" onClick={() => { setVoted("yes"); track("docs-feedback", { page, useful: true }); }}>Yes</button>
<button type="button" onClick={() => { setVoted("no"); track("docs-feedback", { page, useful: false }); }}>Not quite</button>
</div>
)}
</div>
<div className="mn-tools">
<button type="button" onClick={() => window.print()}>Print</button>
<button type="button" onClick={() => { navigator.clipboard?.writeText(window.location.href.split("#")[0]).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1800); }).catch(() => {}); }}>{copied ? "Copied" : "Copy link"}</button>
</div>
</aside>
);
}
+95
View File
@@ -0,0 +1,95 @@
import Link from "next/link";
import type { Block, Inline, ManualPage } from "@/lib/manual";
import { MANUAL_REVIEWED, readMinutes } from "@/lib/manual";
import { SITE_ORIGIN, sectionTitle } from "@/lib/manual-links";
/* Renders one manual page. `base` is where the manual lives on this surface: "/docs" on the
* website, "/app/help" inside the app. Manual links are rewritten to it; inside the app, links to
* website-only pages go to the public site, because the Community edition has no website. */
export type ManualBase = "/docs" | "/app/help";
function Href({ href, base, children }: { href: string; base: ManualBase; children: React.ReactNode }) {
if (/^(https?:|mailto:)/.test(href)) return <a href={href} target={href.startsWith("http") ? "_blank" : undefined} rel="noopener">{children}</a>;
if (href.startsWith("#")) return <a href={href}>{children}</a>;
if (href === "/docs" || href.startsWith("/docs/") || href.startsWith("/docs#")) return <Link href={base + href.slice(5)}>{children}</Link>;
if (base === "/app/help") return <a href={SITE_ORIGIN + href} target="_blank" rel="noopener">{children}</a>;
return <Link href={href}>{children}</Link>;
}
export function Inl({ c, base }: { c: Inline[]; base: ManualBase }) {
return (
<>
{c.map((x, i) =>
typeof x === "string" ? <span key={i}>{x}</span>
: x.t === "code" ? <code key={i} className="mn-code">{x.v}</code>
: x.t === "b" ? <b key={i}><Inl c={x.c} base={base} /></b>
: <Href key={i} href={x.href} base={base}><Inl c={x.c} base={base} /></Href>,
)}
</>
);
}
function BlockView({ b, base }: { b: Block; base: ManualBase }) {
switch (b.t) {
case "p": return <p className="mn-p"><Inl c={b.c} base={base} /></p>;
case "ul": return <ul className="mn-ul">{b.items.map((it, i) => <li key={i}><Inl c={it} base={base} /></li>)}</ul>;
case "ol": return <ol className="mn-ol">{b.items.map((it, i) => <li key={i}><Inl c={it} base={base} /></li>)}</ol>;
case "code": return <pre className="mn-pre"><code>{b.v}</code></pre>;
case "callout": return <div className={"mn-callout " + b.tone}><b>{b.label}</b><span><Inl c={b.c} base={base} /></span></div>;
case "table":
return (
<div className="mn-tablewrap">
<table className="mn-table">
<thead><tr>{b.head.map((h, i) => <th key={i} scope="col"><Inl c={h} base={base} /></th>)}</tr></thead>
<tbody>{b.rows.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}><Inl c={c} base={base} /></td>)}</tr>)}</tbody>
</table>
</div>
);
case "h2": return null;
}
}
/** The page body, grouped into one <section> per heading so the rail can follow along. */
export function ManualBody({ blocks, base }: { blocks: Block[]; base: ManualBase }) {
const groups: { h?: Extract<Block, { t: "h2" }>; body: Block[] }[] = [{ body: [] }];
for (const b of blocks) {
if (b.t === "h2") groups.push({ h: b, body: [] });
else groups[groups.length - 1].body.push(b);
}
return (
<>
{groups.map((g, i) =>
g.h ? (
<section key={g.h.id} id={g.h.id} className="mn-section">
<h2 className="mn-h2"><span className="n">{g.h.n}</span><span>{g.h.text}</span></h2>
{g.body.map((b, j) => <BlockView key={j} b={b} base={base} />)}
</section>
) : g.body.length ? <div key={"intro" + i} className="mn-intro">{g.body.map((b, j) => <BlockView key={j} b={b} base={base} />)}</div> : null,
)}
</>
);
}
/** A whole article: breadcrumb, title, summary, the facts strip, the body, and previous and next. */
export function ManualArticle({ page, base, prev, next }: { page: ManualPage; base: ManualBase; prev?: ManualPage; next?: ManualPage }) {
const release = process.env.NEXT_PUBLIC_RELEASE;
return (
<article className="mn-article">
<div className="mn-crumbs"><Link href={base}>Docs</Link><span aria-hidden></span><Link href={`${base}#${page.section}`}>{sectionTitle(page.section)}</Link></div>
<h1 className="mn-h1">{page.title}</h1>
{page.summary && <p className="mn-lede">{page.summary}</p>}
<div className="mn-facts">
{page.screen && <div><b>Screen</b><code className="mn-code">{page.screen}</code></div>}
{page.role && <div><b>Who</b><span>{page.role}</span></div>}
<div><b>Read time</b><span className="mn-mono">{readMinutes(page.words)} min</span></div>
<div><b>Checked against</b><span className="mn-mono">{release ? `release ${release}` : MANUAL_REVIEWED}</span></div>
</div>
<ManualBody blocks={page.blocks} base={base} />
<nav className="mn-prevnext" aria-label="Previous and next page">
{prev ? <Link href={`${base}/${prev.section}/${prev.slug}`}><span className="k">Previous</span><b>{prev.title}</b></Link> : <span />}
{next ? <Link className="r" href={`${base}/${next.section}/${next.slug}`}><span className="k">Next</span><b>{next.title}</b></Link> : <span />}
</nav>
</article>
);
}
+2
View File
@@ -0,0 +1,2 @@
/* The order list now lives in the To order column of /app/orders (components/orders/ToOrder.tsx). */
export { default } from "@/components/orders/ToOrder";
+2
View File
@@ -0,0 +1,2 @@
/* Community edition: no plans, so no plan banner. */
export default function PlanBanner() { return null; }
+3
View File
@@ -0,0 +1,3 @@
/* Community edition: there are no plans, so sign-up asks nothing about them. */
export type SignupPlan = "hosted_small" | "hosted_facility";
export default function PlanChoice(_: { value: SignupPlan; onChange: (v: SignupPlan) => void }) { return null; }
+2
View File
@@ -0,0 +1,2 @@
/* Community edition: no plan, no ceiling, nothing to pay. */
export default function PlanTab() { return null; }
+324
View File
@@ -0,0 +1,324 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { daysBetween, facilityDate, formatInZone } from "@/lib/compute";
import { PortalCountsContext, portalCounts, type ServerCounts } from "@/lib/portalcounts";
import { resolveScan } from "@/lib/search";
import DemoBanner from "@/components/DemoBanner";
import PlanBanner from "@/components/PlanBanner";
import CommandBar from "@/components/CommandBar";
import { Icon, Kbd, type IconName } from "@/components/portal";
import { LiveRegion } from "@/components/ui";
/* Seven screens. Each is active on its own whole path segments and on the older screens folded
into it, so /app/stocktake lights Stock and /app/requests lights People. */
type Screen = { key: string; href: string; label: string; icon: IconName; on: string[] };
const SCREENS: Screen[] = [
{ key: "today", href: "/app", label: "Today", icon: "today", on: ["/app/rounds"] },
{ key: "counter", href: "/app/counter", label: "Counter", icon: "counter", on: ["/app/issue"] },
{ key: "stock", href: "/app/stock", label: "Stock", icon: "stock", on: ["/app/stocktake"] },
{ key: "orders", href: "/app/orders", label: "Orders", icon: "orders", on: [] },
{ key: "people", href: "/app/staff", label: "People", icon: "people", on: ["/app/requests"] },
{ key: "reports", href: "/app/report", label: "Reports", icon: "reports", on: [] },
{ key: "settings", href: "/app/settings", label: "Settings", icon: "settings", on: ["/app/activity", "/app/checkout"] },
];
type Badge = { text: string; tone: "accent" | "quiet"; sr: string };
type CmdState = { open: boolean; query: string; camera: boolean; unknown?: string };
const RAIL_OUT = (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true" focusable="false">
<path d="M10 4H4v16h6M13 12h8M18 9l3 3-3 3" />
</svg>
);
const RAIL_ME = (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true" focusable="false">
<circle cx="12" cy="8" r="4" /><path d="M4 21c1-4.5 4-7 8-7s7 2.5 8 7" />
</svg>
);
const isEditable = (el: Element | null) =>
!!el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement || (el instanceof HTMLElement && el.isContentEditable));
const dialogOpen = () => !!document.querySelector('[aria-modal="true"]');
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
export default function Shell({ children, serverCounts }: { children: React.ReactNode; serverCounts: ServerCounts }) {
const { s, isAdmin } = useSnap();
const d = useDerived();
const path = usePathname() || "/app";
const router = useRouter();
const counts = useMemo(() => portalCounts(s, d, serverCounts, isAdmin), [s, d, serverCounts, isAdmin]);
const inSeg = (h: string) => path === h || path.startsWith(h + "/");
const screenOn = (sc: Screen) => (sc.href === "/app" ? path === "/app" : inSeg(sc.href)) || sc.on.some(inSeg);
const backupStale = (() => {
const last = s.settings.lastBackup ? facilityDate(s.settings.lastBackup, s.tz) : "";
return !last || daysBetween(last, s.today) > 7;
})();
const badges: Record<string, Badge | null> = {
today: counts.today.total > 0
? { text: String(counts.today.total), tone: counts.today.overdue > 0 ? "accent" : "quiet", sr: `, ${plural(counts.today.total, "thing needs", "things need")} a person${counts.today.overdue > 0 ? `, ${counts.today.overdue} overdue` : ""}` }
: null,
counter: null,
stock: counts.stock.garmentsAtReorder > 0 ? { text: String(counts.stock.garmentsAtReorder), tone: "quiet", sr: `, ${plural(counts.stock.garmentsAtReorder, "garment", "garments")} at reorder` } : null,
orders: counts.orders.overdue > 0
? { text: String(counts.orders.overdue), tone: "accent", sr: `, ${plural(counts.orders.overdue, "order", "orders")} overdue` }
: isAdmin && counts.orders.toOrderLines > 0 ? { text: String(counts.orders.toOrderLines), tone: "quiet", sr: `, ${plural(counts.orders.toOrderLines, "line", "lines")} to order` } : null,
people: counts.people.attention > 0
? { text: String(counts.people.attention), tone: counts.people.stranded > 0 ? "accent" : "quiet", sr: `, ${plural(counts.people.attention, "item needs", "items need")} attention${counts.people.stranded > 0 ? `, ${plural(counts.people.stranded, "request has", "requests have")} no approver` : ""}` }
: null,
reports: null,
settings: isAdmin && backupStale ? { text: "!", tone: "quiet", sr: ", backup overdue" } : null,
};
/* Collapsing the rail is a habit of the machine, not the account, so it lives in localStorage,
read after mount so the server's HTML and the first paint agree. */
const [narrow, setNarrow] = useState(false);
useEffect(() => {
try { setNarrow(localStorage.getItem("tc.rail") === "narrow"); } catch { /* storage is off: the rail starts open */ }
}, []);
function toggleRail() {
setNarrow((n) => {
const next = !n;
try { localStorage.setItem("tc.rail", next ? "narrow" : "wide"); } catch { /* nothing to remember it with */ }
return next;
});
}
const [more, setMore] = useState(false);
useEffect(() => { setMore(false); }, [path]);
// The chat bubble is hidden under 780px (globals.css), so More offers chat once the widget has loaded.
const [chat, setChat] = useState(false);
useEffect(() => { if (more) setChat(!!window.$chatwoot); }, [more]);
const [cmd, setCmd] = useState<CmdState>({ open: false, query: "", camera: false });
const openCmd = useCallback((over: Partial<CmdState> = {}) => setCmd({ open: true, query: "", camera: false, ...over }), []);
const closeCmd = useCallback(() => setCmd({ open: false, query: "", camera: false }), []);
const routeScan = useCallback((raw: string) => {
const code = raw.trim();
if (!code) return;
const t = resolveScan(s, code);
if (t.kind === "staff") { router.push(`/app/counter?staff=${encodeURIComponent(t.staffId)}`); return; }
if (t.kind === "garment") {
const sp = new URLSearchParams(window.location.search);
const here = window.location.pathname;
const counterWithPerson = (here === "/app/counter" || here === "/app/issue") && !!sp.get("staff");
const counting = (here === "/app/stock" && sp.get("tab") === "count") || here === "/app/stocktake";
if (counterWithPerson || counting) window.dispatchEvent(new CustomEvent("tc-scan-garment", { detail: { itemId: t.itemId, si: t.si } }));
else router.push(`/app/stock/${encodeURIComponent(t.itemId)}?size=${t.si}`);
return;
}
openCmd({ query: code, unknown: code });
}, [s, router, openCmd]);
/* "/" and Ctrl/Cmd+K open the panel. A hardware scanner types fast and ends in Enter: printable
keys no more than 35ms apart, at least four of them, outside any field and any dialog. The
counter's and the count's own scan boxes handle scans typed into them. */
const cmdOpen = cmd.open;
const scanBuf = useRef<{ chars: string; last: number }>({ chars: "", last: 0 });
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const buf = scanBuf.current;
if (cmdOpen || isEditable(document.activeElement) || dialogOpen()) { buf.chars = ""; return; }
const now = e.timeStamp || performance.now();
if (e.key === "Enter") {
const fast = buf.chars.length >= 4 && now - buf.last <= 35;
const code = buf.chars;
buf.chars = "";
if (fast) { e.preventDefault(); e.stopPropagation(); routeScan(code); }
return;
}
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
buf.chars = buf.chars && now - buf.last <= 35 ? buf.chars + e.key : e.key;
buf.last = now;
return;
}
if (e.key !== "Shift") buf.chars = "";
};
const onShortcut = (e: KeyboardEvent) => {
if (cmdOpen || e.defaultPrevented) return;
const slash = e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey;
const k = (e.key === "k" || e.key === "K") && (e.ctrlKey || e.metaKey) && !e.altKey;
if (!slash && !k) return;
if (isEditable(document.activeElement) || dialogOpen()) return;
e.preventDefault();
openCmd();
};
window.addEventListener("keydown", onKey, true);
window.addEventListener("keydown", onShortcut);
return () => { window.removeEventListener("keydown", onKey, true); window.removeEventListener("keydown", onShortcut); };
}, [cmdOpen, routeScan, openCmd]);
// The clock: display only, facility zone, after mount, every 30 seconds.
const [now, setNow] = useState<Date | null>(null);
useEffect(() => {
setNow(new Date());
const t = window.setInterval(() => setNow(new Date()), 30_000);
return () => window.clearInterval(t);
}, []);
const clock = now
? formatInZone(now, s.tz, { weekday: "short", day: "numeric", month: "short", year: "numeric" }).replace(/,/g, "").replace("Sept", "Sep")
+ " · " + formatInZone(now, s.tz, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" })
: "";
function fabScan() {
setMore(false);
const tab = new URLSearchParams(window.location.search).get("tab");
if (path === "/app/counter" || path === "/app/issue" || path === "/app/stocktake" || (path === "/app/stock" && tab === "count")) window.dispatchEvent(new CustomEvent("tc-scan"));
else openCmd({ camera: true });
}
/* The session cookie is httpOnly, so only the server can end a session. If the request never lands,
say so and leave the button to try again. */
const [out, setOut] = useState<"" | "busy" | "err">("");
async function signOut() {
if (out === "busy") return;
setOut("busy");
const ok = await fetch("/api/auth/logout", { method: "POST" }).then((r) => r.ok).catch(() => false);
if (!ok) { setOut("err"); return; }
router.push("/auth"); router.refresh();
}
const mobMain: { href: string; label: string; on: boolean }[] = [
{ href: "/app", label: "Today", on: path === "/app" || inSeg("/app/rounds") },
{ href: "/app/counter", label: "Counter", on: inSeg("/app/counter") || inSeg("/app/issue") },
{ href: "/app/stock", label: "Stock", on: inSeg("/app/stock") || inSeg("/app/stocktake") },
];
const mobMore: { href: string; label: string; badge: Badge | null; on: boolean }[] = [
{ href: "/app/orders", label: "Orders", badge: badges.orders, on: inSeg("/app/orders") },
{ href: "/app/staff", label: "People", badge: badges.people, on: inSeg("/app/staff") },
{ href: "/app/requests", label: "Requests", badge: counts.people.stranded > 0 ? { text: String(counts.people.stranded), tone: "accent", sr: `, ${plural(counts.people.stranded, "request has", "requests have")} no approver` } : null, on: inSeg("/app/requests") },
{ href: "/app/rounds", label: "Delivery rounds", badge: counts.today.groups.round > 0 ? { text: String(counts.today.groups.round), tone: "quiet", sr: `, ${plural(counts.today.groups.round, "bag", "bags")} for the round` } : null, on: inSeg("/app/rounds") },
{ href: "/app/report", label: "Reports", badge: null, on: inSeg("/app/report") },
{ href: "/app/settings", label: "Settings", badge: badges.settings, on: inSeg("/app/settings") || inSeg("/app/activity") || inSeg("/app/checkout") },
{ href: "/app/help", label: "Help", badge: null, on: inSeg("/app/help") },
{ href: "/m", label: "Counter app", badge: null, on: false },
];
const moreOn = !mobMain.some((m) => m.on);
useEffect(() => {
if (!more) return;
const h = (e: KeyboardEvent) => { if (e.key === "Escape") setMore(false); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [more]);
const badgeEl = (b: Badge | null, cls: string) => b && (
<>
<span className={`${cls} ${b.tone}`} aria-hidden="true">{cls === "tc-rail-dot" ? null : b.text}</span>
</>
);
return (
<PortalCountsContext.Provider value={serverCounts}>
<div className="tc-shell">
<a className="skip-link" href="#content">Skip to content</a>
<aside id="tc-side" className={narrow ? "tc-rail-narrow" : undefined}>
<div className="tc-rail-head">
<div className="tc-rail-brand">
<span className="tc-rail-mark" aria-hidden="true" />
<span className="tc-rail-word">ThreadCount</span>
<button type="button" className="tc-rail-toggle" onClick={toggleRail} aria-expanded={!narrow} aria-controls="tc-rail-nav"
aria-label={narrow ? "Expand the menu" : "Collapse the menu"} title={narrow ? "Expand the menu" : "Collapse the menu"}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true" focusable="false"
style={narrow ? { transform: "scaleX(-1)" } : undefined}>
<path d="M11 6l-6 6 6 6M19 6l-6 6 6 6" />
</svg>
</button>
</div>
<div className="tc-rail-facility" title={s.settings.facility}>{s.settings.facility}</div>
</div>
<nav id="tc-rail-nav" className="tc-rail-nav" aria-label="Screens">
{SCREENS.map((sc) => {
const on = screenOn(sc);
const b = badges[sc.key];
return (
<Link key={sc.key} href={sc.href} className={"tc-rail-item" + (on ? " active" : "")} aria-current={on ? "page" : undefined}>
<span className="tc-rail-icon"><Icon name={sc.icon} />{badgeEl(b, "tc-rail-dot")}</span>
<span className="tc-rail-label">{sc.label}{b && <span className="sr-only">{b.sr}</span>}</span>
{badgeEl(b, "tc-rail-badge")}
</Link>
);
})}
</nav>
<div className="tc-rail-foot">
<div className="tc-rail-loc">{s.settings.location}</div>
<div className="tc-rail-user">
<Link href="/app/settings?tab=people" className="tc-rail-name" title="Edit your profile">{s.session.name}</Link>
<span className="tc-rail-role">{s.session.role}</span>
<button type="button" className="tc-rail-signout" onClick={signOut} disabled={out === "busy"}>{out === "busy" ? "Signing out…" : "Sign out"}</button>
</div>
<div className="tc-rail-foot-narrow">
<Link href="/app/settings?tab=people" className="tc-rail-item" title="Edit your profile">
<span className="tc-rail-icon">{RAIL_ME}</span>
<span className="tc-rail-label">{s.session.name}</span>
</Link>
<button type="button" className="tc-rail-item" onClick={signOut} disabled={out === "busy"}>
<span className="tc-rail-icon">{RAIL_OUT}</span>
<span className="tc-rail-label">{out === "busy" ? "Signing out…" : "Sign out"}</span>
</button>
</div>
<LiveRegion tone="alert" className="tc-rail-live" msg={out === "err" ? "Network error — you are still signed in." : ""} />
</div>
</aside>
<div className="tc-column">
<header className="tc-topbar no-print">
<button type="button" id="tc-search-trigger" className="input tc-search-trigger" aria-haspopup="dialog" aria-keyshortcuts="/" onClick={() => openCmd()}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#57534f" strokeWidth="1.8" strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="6.5" /><path d="M16 16l5 5" /></svg>
<span className="tc-search-ph">Search or scan: a person, a garment, an order</span>
<Kbd>/</Kbd>
</button>
<button type="button" className="tc-topbar-icon" aria-haspopup="dialog" aria-label="Search or scan" onClick={() => openCmd()}>
<Icon name="search" />
</button>
<div className="tc-scanner">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#57534f" strokeWidth="1.8" strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true" focusable="false"><path d="M3 8V4h4M17 4h4v4M21 16v4h-4M7 20H3v-4M7 8v8M10 8v8M13 8v8M16 8v8" /></svg>
<span>Scanner ready · a badge opens the counter</span>
</div>
<div className="tc-clock tc-mono" suppressHydrationWarning>{clock}</div>
</header>
<main id="content" tabIndex={-1} className="tc-main"><DemoBanner /><PlanBanner />{children}</main>
</div>
<nav id="tc-mobilebar" aria-label="Screens">
{mobMain.map((m) => (
<Link key={m.href} href={m.href} className={"tc-mob-btn" + (m.on ? " on" : "")} aria-current={m.on ? "page" : undefined}>
{m.label}
{m.href === "/app" && badges.today && <span className={`tc-mob-badge ${badges.today.tone}`} aria-hidden="true">{badges.today.text}</span>}
{m.href === "/app" && badges.today && <span className="sr-only">{badges.today.sr}</span>}
</Link>
))}
<button type="button" className={"tc-mob-btn" + (moreOn || more ? " on" : "")} onClick={() => setMore(!more)} aria-expanded={more} aria-controls="tc-more-sheet">More</button>
</nav>
<button type="button" id="tc-scanfab" onClick={fabScan} title="Scan a barcode" aria-label="Scan a barcode">SCAN</button>
{more && (
<div className="tc-more-sheet" onClick={() => setMore(false)}>
<nav id="tc-more-sheet" className="tc-more-sheet-panel" aria-label="More screens" onClick={(e) => e.stopPropagation()}>
{mobMore.map((m) => (
<Link key={m.href} href={m.href} className={"tc-more-row" + (m.on ? " on" : "")} aria-current={m.on ? "page" : undefined} onClick={() => setMore(false)}>
<span>{m.label}{m.badge && <span className="sr-only">{m.badge.sr}</span>}</span>
{m.badge && <span className={`tc-mob-badge ${m.badge.tone}`} aria-hidden="true">{m.badge.text}</span>}
</Link>
))}
{chat && <button type="button" className="tc-more-row" onClick={() => { setMore(false); window.$chatwoot?.toggle("open"); }}>Chat with us</button>}
<div className="tc-more-me">
<Link href="/app/settings?tab=people" className="tc-more-row" onClick={() => setMore(false)}>
<span>{s.session.name}</span>
<span className="tc-more-role">{s.session.role}</span>
</Link>
<button type="button" className="tc-more-row" onClick={signOut} disabled={out === "busy"}>{out === "busy" ? "Signing out…" : "Sign out"}</button>
<LiveRegion tone="alert" msg={out === "err" ? "Network error — you are still signed in." : ""} />
</div>
</nav>
</div>
)}
<CommandBar key={cmd.open ? `open-${cmd.query}-${cmd.camera}` : "closed"} open={cmd.open} onClose={closeCmd} initialQuery={cmd.query} camera={cmd.camera} onScan={routeScan} unknownCode={cmd.unknown} />
</div>
</PortalCountsContext.Provider>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
/* The Sign step shared by the counter phone's three hand-overs (an issue, a request, a ward round),
* and the Done screen every finished job lands on.
*
* SignFlow draws what is being handed over, the caller's extra section (approval, Received by), the
* signature and the slip switch; uploads the signature as a Photo; hands the id to the caller's
* commit; and on success replaces the screen with DoneScreen, then router.replace()s the form with
* /m/done (useShowDone), so Back does not return to a signed, spent form and a refresh keeps Done. */
import { useCallback, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { label, onOrderText, onhand, reorderAt, splitKey } from "@/lib/compute";
import { uploadPhoto } from "@/lib/photo";
import { MBar, MBody, MButton, MDone, MError, MRow, MRule, MSection, MShelfNow, MSignature, MSwitchRow, MTop } from "@/components/m";
export type SignKind = "issue" | "request" | "round";
export type DoneProps = { head: string; sub: string; shelfKeys: string[]; next: "scan" | "work" | "today" };
export type SignFlowProps = {
kind: SignKind;
/** MHead, or the round head. */
head: React.ReactNode;
lines: { key: string; name: string; qty: number }[];
/** Shown in the signature hint. */
signerName: string;
/** Approval section (issue) or Received by field (round). */
extra?: React.ReactNode;
/** Undefined for a round (no switch). */
slip?: { available: boolean };
/** "Issue 4 items" | "Hand over 3 items" | "Delivered" */
barLabel: string;
commit: (a: { sigId: string; slip: boolean }) => Promise<{ ok: true; done: DoneProps } | { ok: false; error: string }>;
/** A page still under the form in history that is spent once this commits (the request pick list):
* it sends Back on to Work instead of reading "moved on". */
spentHref?: string;
};
/* The Done props of the last finished job: module memory for client navigation and refreshes,
sessionStorage for a reload of /m/done. */
const DONE_KEY = "tc.m.done";
const SPENT_KEY = "tc.m.spent";
let DONE: DoneProps | null = null;
export function readDone(): DoneProps | null {
if (DONE) return DONE;
try {
const v = window.sessionStorage.getItem(DONE_KEY);
if (v) DONE = JSON.parse(v) as DoneProps;
} catch { /* storage blocked: nothing to show */ }
return DONE;
}
/** True when `href` was the form (or pick list) under a job that has since been finished here. */
export function wasSpent(href: string): boolean {
try { return (JSON.parse(window.sessionStorage.getItem(SPENT_KEY) || "[]") as string[]).includes(href); } catch { return false; }
}
/** Keeps the Done props and replaces the current entry with /m/done. */
export function useShowDone() {
const router = useRouter();
return useCallback((done: DoneProps, spentHref?: string) => {
DONE = done;
try {
window.sessionStorage.setItem(DONE_KEY, JSON.stringify(done));
if (spentHref) {
const list = (JSON.parse(window.sessionStorage.getItem(SPENT_KEY) || "[]") as string[]).filter((x) => x !== spentHref);
window.sessionStorage.setItem(SPENT_KEY, JSON.stringify([spentHref, ...list].slice(0, 20)));
}
} catch { /* module memory still carries it this session */ }
router.replace("/m/done");
}, [router]);
}
const plural = (n: number, one: string, many = one + "s") => `${n} ${n === 1 ? one : many}`;
export default function SignFlow(props: SignFlowProps) {
const { kind, head, lines, signerName, extra, slip, barLabel, commit, spentHref } = props;
const { mutate } = useSnap();
const showDone = useShowDone();
const pad = useRef<{ clear: () => void; dataUrl: () => string | null } | null>(null);
const [signed, setSigned] = useState(false);
const [sendSlip, setSendSlip] = useState(!!slip?.available);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [done, setDone] = useState<DoneProps | null>(null);
const total = lines.reduce((t, l) => t + l.qty, 0);
if (done) return <DoneScreen {...done} />;
async function go() {
if (busy) return;
const png = pad.current?.dataUrl() || null;
if (!png) { setErr("Ask them to sign first"); return; }
setBusy(true); setErr("");
const up = await uploadPhoto(mutate, "sig", png);
if ("error" in up) { setBusy(false); setErr(up.error); return; }
const r = await commit({ sigId: up.id, slip: !!slip?.available && sendSlip });
setBusy(false);
if (!r.ok) { setErr(r.error); return; } // the signature stays on the pad
setDone(r.done);
showDone(r.done, spentHref);
}
return (
<>
<MTop title="Sign" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{head}
<MSection flush label="Handing over" right={plural(total, "item")} />
{lines.map((l) => <MRow key={l.key} dense mark="ink" title={l.name} right={`×${l.qty}`} />)}
{extra}
<MSection label="Signature" />
<MSignature name={signerName} onReady={(api) => { pad.current = api; }} onChange={setSigned} />
<MButton small label="Clear the signature" onClick={() => pad.current?.clear()} />
{slip && (
<MSwitchRow title="Send the slip to their staff app" sub={slip.available ? "Garments and sizes only" : "No staff app account"}
on={slip.available && sendSlip} onToggle={() => setSendSlip((v) => !v)} disabled={!slip.available} />
)}
</MBody>
<MBar label={busy ? "Recording…" : barLabel} glyph={kind === "round" ? "check" : "arrow"} small={busy ? undefined : signed ? "signed" : "needs a signature"}
disabled={busy || !signed} offReason={busy ? undefined : "Ask them to sign first"} onClick={go} />
</>
);
}
/** The finish screen: top bar "Done" (no back), the tick, what is left on the shelf, and the next job. */
export function DoneScreen({ head, sub, shelfKeys, next }: DoneProps) {
const { s } = useSnap();
const { L, byId } = useDerived();
const lines = useMemo(() => [...new Set(shelfKeys)].map((k) => {
const { itemId, si } = splitKey(k);
const it = byId[itemId];
return { key: k, name: `${label(it)} ${String(it?.sizes[si] ?? si)}`, onHand: Math.max(0, onhand(s, L, k)), par: reorderAt(s, k), onOrder: onOrderText(s, itemId, si) };
}), [shelfKeys, s, L, byId]);
return (
<>
<MTop title="Done" />
<MRule />
<MBody pad>
<MDone head={head} sub={sub}>
<MShelfNow lines={lines} />
{next !== "today" && <MButton label="Back to Today" href="/m" />}
</MDone>
</MBody>
{next === "scan" ? <MBar label="Scan next badge" glyph="scan" href="/m/scan" />
: next === "work" ? <MBar label="Back to Work" href="/m/work" />
: <MBar label="Back to Today" href="/m" />}
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { COMMUNITY } from "@/lib/edition";
import { SOURCE_URL, editionVersion } from "@/lib/source";
/* The licence's offer of source, on the screen rather than in a file nobody opens.
*
* The Community edition is AGPL-3.0-only. Section 13 asks that anyone who modifies it and then
* lets other people use their version over a network gives those users a way to get that modified
* source — so the offer belongs where those users are, which is the product itself, not the
* repository. It names the build so a report of a bug can name it too.
*
* A facility running its own build points SOURCE_URL at wherever it keeps that build; unset, this
* names the release the code came from. Renders nothing on the hosted service, which is not
* licensed under the AGPL and makes no such offer.
*
* Server component: EDITION is server-side only (lib/edition.ts). */
export default function SourceNotice({ align = "center" }: { align?: "center" | "left" }) {
if (!COMMUNITY) return null;
return (
<p style={{ textAlign: align, fontSize: 12, lineHeight: 1.5, color: "var(--color-neutral-700)", margin: "18px 0 0", padding: "0 16px" }}>
{`ThreadCount Community · ${editionVersion()} · `}
<a href={SOURCE_URL} target="_blank" rel="noreferrer" style={{ color: "inherit" }}>Source code</a>
{", under the "}
<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noreferrer" style={{ color: "inherit" }}>GNU AGPL v3</a>.
</p>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
/* Community edition: single sign-on is part of the hosted service (it needs the broker
* threadcount.tech runs). The settings screen still has a place for it, so this says so. */
type Props = { isAdmin: boolean; demo: boolean; sso: { enabled: boolean; required: boolean; staff: boolean; domains: string[] }; users: unknown[]; onChanged: () => void; mutate: unknown };
export default function SsoSettings(_: Props) {
void _;
return (
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", lineHeight: 1.6, marginTop: "var(--space-3)" }}>
Single sign-on through an identity provider is not part of the Community edition. Coordinators sign in with a password and, where enrolled, an authenticator code.
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef } from "react";
declare global { interface Window { turnstile?: { render: (el: HTMLElement, opts: Record<string, unknown>) => string; reset: (id?: string) => void; remove: (id: string) => void }; } }
const SITEKEY = process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY || "";
export const turnstileOn = () => !!SITEKEY;
/* The token, held outside React so a form can wait for it.
*
* Turnstile hands its token back whenever it is ready — usually before anyone has finished typing
* a password, occasionally a second or two later. A form that posts whatever it happens to hold
* at submit time will sometimes post nothing, and the server answers "Please complete the security
* check", which is both alarming and untrue. `awaitTurnstile` lets the button wait instead. */
let current = "";
const waiters = new Set<(t: string) => void>();
function publish(t: string) {
current = t;
if (!t) return;
for (const w of waiters) w(t);
waiters.clear();
}
/** The token, waiting up to `ms` for it to arrive. Resolves "" if it never does — the server
* refusal is the right outcome then, and it will be the honest one. */
export function awaitTurnstile(ms = 8000): Promise<string> {
if (current) return Promise.resolve(current);
return new Promise((resolve) => {
const done = (t: string) => { clearTimeout(timer); resolve(t); };
const timer = setTimeout(() => { waiters.delete(done); resolve(""); }, ms);
waiters.add(done);
});
}
/** Cloudflare Turnstile (managed mode): invisible for humans, a checkbox only when in doubt.
* Renders nothing when no site key is configured.
*
* `quiet` asks Turnstile to draw nothing at all unless it actually wants an interaction. The
* phone's onboarding screens use it: a white Cloudflare card under the fields is the one thing
* on those screens that isn't ink, paper and a red rule, and for the overwhelming majority of
* sign-ins it is a box that says "Success" about a test nobody was aware of taking. */
export default function Turnstile({ onToken, action, quiet = false }: { onToken: (t: string) => void; action: string; quiet?: boolean }) {
const ref = useRef<HTMLDivElement | null>(null);
const id = useRef<string | null>(null);
const cb = useRef(onToken); cb.current = onToken;
useEffect(() => {
if (!SITEKEY || !ref.current) return;
let cancelled = false;
const hand = (t: string) => { publish(t); cb.current(t); };
const render = () => {
if (cancelled || !ref.current || !window.turnstile || id.current) return;
id.current = window.turnstile.render(ref.current, {
sitekey: SITEKEY, action, theme: "light", size: "flexible",
...(quiet ? { appearance: "interaction-only" } : {}),
callback: (t: string) => hand(t),
"expired-callback": () => hand(""),
"error-callback": () => hand(""),
});
};
if (window.turnstile) render();
else {
const s = document.querySelector<HTMLScriptElement>("script[data-turnstile]") || Object.assign(document.createElement("script"), { src: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit", async: true, defer: true });
if (!s.dataset.turnstile) { s.dataset.turnstile = "1"; document.head.appendChild(s); }
s.addEventListener("load", render);
}
return () => { cancelled = true; if (id.current && window.turnstile) { try { window.turnstile.remove(id.current); } catch { /* gone */ } id.current = null; } };
}, [action, quiet]);
if (!SITEKEY) return null;
// In quiet mode the widget contributes no height until it has something to show, so it must not
// reserve any either.
return <div ref={ref} style={quiet ? undefined : { marginTop: 12, minHeight: 0 }} />;
}
export function resetTurnstile() { current = ""; try { window.turnstile?.reset(); } catch { /* ignore */ } }
+187
View File
@@ -0,0 +1,187 @@
"use client";
/* Turning a second factor on, from your own account settings.
*
* Optional, with admins prompted rather than forced. Issuers are casual counter users on shared
* ward phones, where mandatory TOTP gets worked around — shared logins, codes written on the wall
* — which is worse than not having it. Admins can change pricing, delete records and wipe the
* facility, so they get a standing nudge.
*
* Recovery codes are shown exactly once, at the moment they are created, because they are stored
* hashed. The copy says so plainly: an admin locked out with no codes is a locked-out facility,
* since deleting the last admin deletes everything.
*/
import { useCallback, useEffect, useState } from "react";
import { Field } from "@/components/ui";
type Status = { enabled: boolean; enabledAt: string | null; recoveryLeft: number };
export default function TwoFactor({ isAdmin }: { isAdmin: boolean }) {
const [st, setSt] = useState<Status | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [setup, setSetup] = useState<{ secret: string; qr: string } | null>(null);
const [code, setCode] = useState("");
const [pw, setPw] = useState("");
const [codes, setCodes] = useState<string[] | null>(null);
const [confirming, setConfirming] = useState<"disable" | "regenerate" | null>(null);
/* A failed status fetch used to render nothing at all.
*
* With no catch and no error state, `if (!st) return null` meant a dropped connection or a 500
* deleted the whole two-factor section out of Settings → Account — silently, so somebody who
* came here to turn 2FA on found no control and no explanation, and the honest conclusion is
* that ThreadCount doesn't offer it. Saying so and offering the retry is the difference between
* a hiccup and a feature that appears not to exist. */
const [loadErr, setLoadErr] = useState("");
const load = useCallback(async () => {
setLoadErr("");
try {
const r = await fetch("/api/2fa");
if (!r.ok) { const j = await r.json().catch(() => ({})); setLoadErr(j.error || "Couldnt check whether two-factor is on."); return; }
setSt(await r.json());
} catch {
setLoadErr("Couldnt reach the server, so we cant say whether two-factor is on.");
}
}, []);
useEffect(() => { void load(); }, [load]);
async function post(body: Record<string, unknown>) {
setBusy(true); setErr("");
const r = await fetch("/api/2fa", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
setBusy(false);
if (!r.ok) { setErr(j.error || "That didnt work."); return null; }
return j;
}
const box: React.CSSProperties = { border: "2px solid var(--color-text)", padding: 20, marginTop: 16 };
if (loadErr) {
return (
<div style={box}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20 }}>Two-factor authentication</div>
<p role="alert" style={{ fontSize: 13.5, lineHeight: 1.6, marginTop: 8, color: "var(--color-accent-700)", fontWeight: 600, maxWidth: "60ch" }}>{loadErr}</p>
<div style={{ marginTop: 16 }}><button className="btn btn-secondary" onClick={() => void load()}>Try again</button></div>
</div>
);
}
if (!st) return null;
// Shown once, immediately after enabling or regenerating.
if (codes) {
return (
<div style={box}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20 }}>Save these recovery codes</div>
<p style={{ fontSize: 13.5, lineHeight: 1.6, marginTop: 8, color: "var(--color-neutral-800)", maxWidth: "60ch" }}>
Each works once, in place of a code from your app. This is the only time they can be
shown they are stored hashed, so nobody, including us, can read them back. Print them or
put them somewhere you would still reach without your phone.
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 8, marginTop: 16, fontFamily: "monospace", fontSize: 15 }}>
{codes.map((c) => <div key={c} style={{ border: "1px solid var(--color-divider)", padding: "8px 10px", background: "#fff" }}>{c}</div>)}
</div>
<div style={{ display: "flex", gap: 10, marginTop: 16, flexWrap: "wrap" }}>
<button className="btn btn-secondary" onClick={() => navigator.clipboard?.writeText(codes.join("\n")).catch(() => {})}>Copy all</button>
<button className="btn btn-primary" onClick={() => { setCodes(null); setSetup(null); setCode(""); void load(); }}>I have saved them</button>
</div>
</div>
);
}
if (setup) {
return (
<div style={box}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20 }}>Scan this with your authenticator</div>
<p style={{ fontSize: 13.5, lineHeight: 1.6, marginTop: 8, color: "var(--color-neutral-800)", maxWidth: "60ch" }}>
Any authenticator app will do. Then type the six-digit code it shows to prove it worked
nothing changes until you do.
</p>
<div style={{ display: "flex", gap: 24, marginTop: 16, flexWrap: "wrap", alignItems: "flex-start" }}>
<div style={{ background: "#fff", padding: 10, border: "1px solid var(--color-divider)" }} dangerouslySetInnerHTML={{ __html: setup.qr }} />
<div>
<div style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>Or type it in</div>
<div style={{ fontFamily: "monospace", fontSize: 14, wordBreak: "break-all", maxWidth: 260, marginTop: 6 }}>{setup.secret}</div>
</div>
</div>
<Field style={{ marginTop: 16, maxWidth: 220 }} label="Code from the app">
{(c) => (
<input {...c} className="input" inputMode="numeric" autoComplete="one-time-code" value={code} placeholder="000000"
onChange={(e) => { setCode(e.target.value); setErr(""); }} />
)}
</Field>
{err && <div style={{ marginTop: 10, color: "var(--color-accent-700)", fontWeight: 700, fontSize: 13.5 }}>{err}</div>}
<div style={{ display: "flex", gap: 10, marginTop: 16, flexWrap: "wrap" }}>
<button className="btn btn-primary" disabled={busy} onClick={async () => {
const j = await post({ action: "enable", code });
if (j?.codes) setCodes(j.codes);
}}>{busy ? "Checking…" : "Turn it on"}</button>
<button className="btn btn-secondary" onClick={() => { setSetup(null); setCode(""); setErr(""); }}>Cancel</button>
</div>
</div>
);
}
return (
<div style={box}>
<div style={{ display: "flex", alignItems: "baseline", gap: 12, flexWrap: "wrap" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20 }}>Two-factor authentication</div>
<span className={st.enabled ? "tag tag-accent" : "tag tag-neutral"}>{st.enabled ? "On" : "Off"}</span>
</div>
{st.enabled ? (
<>
<p style={{ fontSize: 13.5, lineHeight: 1.6, marginTop: 8, color: "var(--color-neutral-800)", maxWidth: "60ch" }}>
Signing in asks for a code from your authenticator app as well as your password.{" "}
{st.recoveryLeft} recovery code{st.recoveryLeft === 1 ? "" : "s"} left.
{st.recoveryLeft <= 2 && " Worth generating a fresh set."}
</p>
{confirming ? (
<div style={{ marginTop: 14, maxWidth: 320 }}>
<Field label="Confirm with your password">
{(c) => (
<input {...c} className="input" type="password" autoComplete="current-password" value={pw}
onChange={(e) => { setPw(e.target.value); setErr(""); }} />
)}
</Field>
{err && <div style={{ marginTop: 8, color: "var(--color-accent-700)", fontWeight: 700, fontSize: 13.5 }}>{err}</div>}
<div style={{ display: "flex", gap: 10, marginTop: 12, flexWrap: "wrap" }}>
<button className="btn btn-primary" disabled={busy} onClick={async () => {
const j = await post({ action: confirming, password: pw });
if (!j) return;
setPw(""); setConfirming(null);
if (j.codes) setCodes(j.codes); else void load();
}}>{busy ? "Working…" : confirming === "disable" ? "Turn it off" : "Generate new codes"}</button>
<button className="btn btn-secondary" onClick={() => { setConfirming(null); setPw(""); setErr(""); }}>Cancel</button>
</div>
</div>
) : (
<div style={{ display: "flex", gap: 10, marginTop: 14, flexWrap: "wrap" }}>
<button className="btn btn-secondary" onClick={() => setConfirming("regenerate")}>New recovery codes</button>
<button className="btn btn-secondary" onClick={() => setConfirming("disable")}>Turn off</button>
</div>
)}
</>
) : (
<>
<p style={{ fontSize: 13.5, lineHeight: 1.6, marginTop: 8, color: "var(--color-neutral-800)", maxWidth: "60ch" }}>
A code from your phone as well as your password. ThreadCount holds names, payroll
numbers and phone numbers for every person on your register, and a password on its own
is thin protection for that.
</p>
{isAdmin && (
<p style={{ fontSize: 13.5, lineHeight: 1.6, marginTop: 10, color: "var(--color-accent-700)", fontWeight: 600, maxWidth: "60ch" }}>
You are an admin you can change pricing, delete records and delete the facility.
Worth turning on.
</p>
)}
{err && <div style={{ marginTop: 10, color: "var(--color-accent-700)", fontWeight: 700, fontSize: 13.5 }}>{err}</div>}
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={busy} onClick={async () => {
const j = await post({ action: "setup" });
if (j) setSetup({ secret: j.secret, qr: j.qr });
}}>{busy ? "Preparing…" : "Set up two-factor"}</button>
</>
)}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
"use client";
import Link from "next/link";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Icon, Panel, SizeStrip, Tag, type SizeCell } from "@/components/portal";
import { ErrorLine } from "@/components/ui";
import { ALL_GROUPS, bcParse, garmentForStyle, groupBucket, inBucket, key, label, onhand, plOf, reorderAt, setHalf, type IssueRec, type Item, type StaffRec } from "@/lib/compute";
import { dayMonth, pronoun, sizeOf, type HoldGroup } from "./lib";
import styles from "./counter.module.css";
const HOLD_SHOWN = 8;
export default function AddGarments({ st, isAdmin, onAdd, onBind, onCamera, onReturn, onRepeat, repeatDate, groups, owed, inputRef, listOpenRef }: {
st: StaffRec;
isAdmin: boolean;
onAdd: (itemId: string, si: number) => void;
onBind: (code: string) => void;
onCamera: () => void;
onReturn: (issue: IssueRec) => void;
onRepeat: () => void;
repeatDate: string | null;
groups: HoldGroup[];
owed: number;
inputRef: React.RefObject<HTMLInputElement | null>;
listOpenRef: React.RefObject<boolean>;
}) {
const { s } = useSnap();
const { L, byId } = useDerived();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const [err, setErr] = useState("");
const [showAll, setShowAll] = useState(false);
const popId = useId();
const popRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const pr = pronoun(st);
// For this person's group and cut: the same test the old quick-add list used.
const bucket = groupBucket(st.group) || ALL_GROUPS;
const forThem = (it: Item) => inBucket(it, bucket) && garmentForStyle(it, st.uniformStyle);
const qq = q.trim().toLowerCase();
const found = useMemo(() => {
if (qq.length < 2) return { rel: [] as Item[], other: [] as Item[] };
const all = s.catalog
.filter((it) => !it.archived && (it.item.toLowerCase().includes(qq) || it.sku.toLowerCase().includes(qq) || label(it).toLowerCase().includes(qq)))
.sort((a, b) => a.sort - b.sort);
const rel = all.filter(forThem).slice(0, 8);
const other = all.filter((it) => !rel.includes(it) && !forThem(it)).slice(0, 8 - rel.length);
return { rel, other };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [s.catalog, qq, bucket, st.uniformStyle]);
const listShown = open && qq.length >= 2;
useEffect(() => { listOpenRef.current = listShown; }, [listShown, listOpenRef]);
// Usual chips: set garments at the recorded size, then anything outside a set they have had before,
// at the size of their latest issue of it. Most issued to them first.
const usual = useMemo(() => {
const issued: Record<string, number> = {};
const latest: Record<string, IssueRec> = {};
for (const i of s.issues) {
if (i.staffId !== st.id) continue;
issued[i.itemId] = (issued[i.itemId] || 0) + i.qty;
const l = latest[i.itemId];
if (!l || i.date > l.date || (i.date === l.date && i.createdAt > l.createdAt)) latest[i.itemId] = i;
}
const rank = (a: { it: Item }, b: { it: Item }) => (issued[b.it.id] || 0) - (issued[a.it.id] || 0) || a.it.sort - b.it.sort;
const sets: { it: Item; si: number }[] = [], others: { it: Item; si: number }[] = [];
for (const it of s.catalog) {
if (it.archived) continue;
const half = setHalf(it);
if (half) {
const want = half === "top" ? st.top : st.pants;
if (!want || !forThem(it)) continue;
const si = it.sizes.map(String).indexOf(String(want));
if (si >= 0) sets.push({ it, si });
} else if (latest[it.id] && latest[it.id].si < it.sizes.length) {
others.push({ it, si: latest[it.id].si });
}
}
return [...sets.sort(rank), ...others.sort(rank)].slice(0, 6);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [s.catalog, s.issues, st.id, st.top, st.pants, bucket, st.uniformStyle]);
const cells = (it: Item): SizeCell[] => it.sizes.map((sz, si) => {
const k = key(it.id, si), oh = onhand(s, L, k), pl = plOf(s, k), ro = reorderAt(s, k);
return { si, size: String(sz), count: oh, state: oh <= 0 ? "out" : ro > 0 && oh <= ro ? "low" : "ok", title: `${oh} on shelf · ${pl} pre-loved` };
});
function add(itemId: string, si: number) {
onAdd(itemId, si);
setErr("");
inputRef.current?.focus();
}
function onKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Escape" && listShown) { e.preventDefault(); setOpen(false); return; }
if (e.key === "ArrowDown" && listShown) { e.preventDefault(); popRef.current?.querySelector<HTMLButtonElement>("button")?.focus(); return; }
if (e.key !== "Enter" || e.ctrlKey || e.metaKey) return;
const raw = q.trim();
if (!raw) return;
e.preventDefault();
const hit = bcParse(s, raw);
if (hit) { setQ(""); add(hit.itemId, hit.si); return; }
// A name with no size can't be added; a code nobody knows gets bound (admins) or reported.
if (/\d/.test(raw) && !/\s/.test(raw)) {
setQ("");
if (isAdmin) onBind(raw);
else setErr(`No garment has ${raw}.`);
}
}
const option = (it: Item) => (
<div key={it.id} className={styles.opt}>
<div className={styles.optHead}><span>{label(it)}</span>{it.sku && <span className="tc-mono" style={{ fontWeight: 400, fontSize: 12, color: "#57534f" }}>{it.sku}</span>}</div>
<SizeStrip itemLabel={label(it)} cells={cells(it)} action="Add" onCell={(si) => add(it.id, si)} />
</div>
);
const shownGroups = showAll ? groups : groups.slice(0, HOLD_SHOWN);
const holdingQty = groups.reduce((t, g) => t + g.qty, 0);
const recordHref = `/app/staff/${st.id}?tab=details${isAdmin ? "&edit=1" : ""}`;
return (
<Panel title="Add garments" aside={repeatDate ? <button type="button" className={`btn btn-ghost ${styles.rowGhost}`} onClick={onRepeat}>Repeat last · {dayMonth(repeatDate)}</button> : undefined}>
<div className={styles.scanWrap} ref={wrapRef}
onBlur={(e) => { if (!wrapRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false); }}>
<div className={styles.scanBox}>
<Icon name="scan" size={16} />
<input ref={inputRef} className={styles.scanInput} role="combobox" aria-expanded={listShown} aria-controls={popId} aria-autocomplete="list" aria-haspopup="dialog"
aria-label="Scan a garment or type a garment name" placeholder="Scan a garment, or type a name" autoFocus autoComplete="off"
value={q} onChange={(e) => { setQ(e.target.value); setOpen(true); setErr(""); }} onFocus={() => setOpen(true)} onKeyDown={onKey} />
<button type="button" className={`btn btn-ghost ${styles.camBtn}`} onClick={onCamera} aria-label="Scan with the camera"><Icon name="camera" size={16} /></button>
</div>
{listShown && (
<div id={popId} ref={popRef} className={styles.pop} role="dialog" aria-label="Matching garments"
onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); setOpen(false); inputRef.current?.focus(); } }}>
{found.rel.length + found.other.length === 0 && <div className={styles.popEmpty}>No garment matches {q.trim()}.</div>}
{found.rel.map(option)}
{found.other.length > 0 && <div className={`tc-lbl ${styles.optDivider}`}>Other garments</div>}
{found.other.map(option)}
</div>
)}
</div>
{err && <div className={styles.scanErr}><ErrorLine msg={err} /></div>}
<div className={`tc-lbl ${styles.usualLbl}`}>{pr.poss} usual · one tap adds it</div>
<div className={styles.chips}>
{usual.map(({ it, si }) => (
<button type="button" key={it.id} className={`btn btn-secondary ${styles.chip}`} onClick={() => add(it.id, si)} aria-label={`Add ${label(it)} size ${sizeOf(it, si)}`}>
{label(it)} <Tag tone="ink" mono>{sizeOf(it, si)}</Tag>
</button>
))}
{!st.top && !st.pants && (
<span className={styles.meta}>No usual sizes recorded · <Link href={recordHref} className="btn btn-ghost" style={{ minHeight: 0, padding: 0, fontSize: 12 }}>Record them</Link></span>
)}
</div>
<div className={`tc-lbl ${styles.holdLbl}`}>Holding now · {holdingQty} {holdingQty === 1 ? "garment" : "garments"}</div>
{groups.length === 0 ? (
<div className={styles.empty} style={{ paddingTop: 0 }}>Nothing out.</div>
) : (
<div className={styles.tableWrap}>
<table className="tc-table">
<tbody>
{shownGroups.map((g) => {
const it = byId[g.itemId];
return (
<tr key={g.itemId + ":" + g.si}>
<td>{label(it)} · <span className="tc-mono">{sizeOf(it, g.si)}</span></td>
<td className="num">×{g.qty}</td>
<td className={styles.meta}>last {dayMonth(g.last)}</td>
<td style={{ textAlign: "right" }}>
<button type="button" className={`btn btn-ghost ${styles.rowGhost}`} aria-label={`Return ${label(it)} size ${sizeOf(it, g.si)}`} onClick={() => onReturn(g.latest)}>Return</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{(groups.length > HOLD_SHOWN || owed > 0) && (
<div className={styles.holdFoot}>
{groups.length > HOLD_SHOWN && <button type="button" className={`btn btn-ghost ${styles.rowGhost}`} aria-expanded={showAll} onClick={() => setShowAll(!showAll)}>{showAll ? "Show fewer" : "Show all"}</button>}
{owed > 0 && <span className={styles.meta}>+{owed} on order or waiting</span>}
</div>
)}
</Panel>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
/* The Counter (/app/counter?staff=&mode=): person first, then Issue, Return, Hand in or Swap a size. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { ErrorLine, LiveRegion, PageHead } from "@/components/ui";
import { Kbd, Seg } from "@/components/portal";
import { AdjustDialog, BindDialog, HandInDialog, ReturnDialog } from "@/components/dialogs";
import Camera from "@/components/Camera";
import { bcParse, capCheck, label, type IssueRec } from "@/lib/compute";
import { MODES, MODE_LABELS, holdingGroups, openIssuesOf, overlayOpen, sizeOf, type Mode } from "./lib";
import { useCounterCart } from "./useCounterCart";
import PersonPicker from "./PersonPicker";
import PersonPanel from "./PersonPanel";
import AddGarments from "./AddGarments";
import PickupCart from "./PickupCart";
import { HandInPanel, HoldingPanel, ReturnedToday, SwapPanel, SwappedToday } from "./Modes";
import styles from "./counter.module.css";
export default function Counter() {
const { s, isAdmin } = useSnap();
const { byId, staffById } = useDerived();
const router = useRouter();
const sp = useSearchParams();
const staffParam = sp.get("staff") || "";
const sel = staffParam ? staffById[staffParam] || s.staff.find((x) => x.num === staffParam) : undefined;
const modeParam = sp.get("mode") as Mode | null;
const mode: Mode = modeParam && MODES.includes(modeParam) ? modeParam : "issue";
const setUrl = useCallback((staff: string | null, m: Mode) => {
const q = new URLSearchParams();
if (staff) q.set("staff", staff);
if (staff && m !== "issue") q.set("mode", m);
const qs = q.toString();
router.replace(`/app/counter${qs ? `?${qs}` : ""}`, { scroll: false });
}, [router]);
const cart = useCounterCart(sel);
const [msg, setMsg] = useState("");
const [err, setErr] = useState("");
const [cam, setCam] = useState(false);
const [camMsg, setCamMsg] = useState("");
const [bind, setBind] = useState("");
const [ret, setRet] = useState<IssueRec | null>(null);
const [handin, setHandin] = useState(false);
const [adjust, setAdjust] = useState(false);
const [mac, setMac] = useState(false);
useEffect(() => { setMac(/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)); }, []);
// Messages belong to one person.
const [msgFor, setMsgFor] = useState(sel?.id || "");
if (msgFor !== (sel?.id || "")) { setMsgFor(sel?.id || ""); setMsg(""); setErr(""); }
const scanRef = useRef<HTMLInputElement>(null);
const listOpenRef = useRef(false);
const held = useMemo(() => (sel ? capCheck(s, sel, []) : null), [s, sel]);
const open = useMemo(() => (sel ? openIssuesOf(s, sel.id) : []), [s, sel]);
const groups = useMemo(() => holdingGroups(open), [open]);
const owed = held ? held.owed.tops + held.owed.pants + held.owed.other : 0;
// Repeat last issue: every unreturned line from their most recent issue date.
const lastSet = useMemo(() => {
if (!open.length) return [];
const latest = open.reduce((d, i) => (i.date > d ? i.date : d), "");
return open.filter((i) => i.date === latest && byId[i.itemId] && !byId[i.itemId].archived);
}, [open, byId]);
const pick = (id: string) => { setMsg(""); setErr(""); setUrl(id, mode); };
const changePerson = () => { setMsg(""); setErr(""); setCam(false); setUrl(null, "issue"); };
const setMode = (m: Mode) => { if (sel) setUrl(staffParam, m); };
const addGarment = (itemId: string, si: number) => { cart.add(itemId, si); setMsg(""); };
async function doIssue() {
setErr("");
const r = await cart.record();
if (!r) return;
if (r.ok) setMsg(r.msg); else setErr(r.error);
scanRef.current?.focus();
}
function camHit(raw: string) {
const code = raw.trim();
if (!sel) {
const st = s.staff.find((x) => x.num === code);
setCam(false);
if (st) pick(st.id); else setErr(`No one on the register has ${code}.`);
return;
}
const p = bcParse(s, code);
if (!p) {
setCam(false);
if (isAdmin) setBind(code); else setErr(`No garment has ${code}.`);
return;
}
if (mode !== "issue") setMode("issue");
addGarment(p.itemId, p.si);
setCamMsg(`Added ${label(byId[p.itemId])} · ${sizeOf(byId[p.itemId], p.si)}`);
}
// Window events and keys read the latest render through a ref.
const cartLines = cart.cart.length;
const latest = useRef({ sel, mode, cam, cartLines, setMode, addGarment, changePerson, doIssue });
useEffect(() => { latest.current = { sel, mode, cam, cartLines, setMode, addGarment, changePerson, doIssue }; });
useEffect(() => {
const onScan = () => { setCamMsg(""); setCam(true); };
const onGarment = (e: Event) => {
const d = (e as CustomEvent<{ itemId: string; si: number }>).detail;
const cur = latest.current;
if (!d || !cur.sel) return;
if (cur.mode !== "issue") cur.setMode("issue");
cur.addGarment(d.itemId, d.si);
};
const onKey = (e: KeyboardEvent) => {
const cur = latest.current;
if (e.defaultPrevented || !cur.sel || cur.cam || overlayOpen()) return;
if (e.key === "Escape") {
// A pickup being built is never thrown away by a stray Escape: change person from the panel.
if (listOpenRef.current || cur.cartLines > 0) return;
e.preventDefault();
cur.changePerson();
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && cur.mode === "issue") {
e.preventDefault();
void cur.doIssue();
}
};
window.addEventListener("tc-scan", onScan);
window.addEventListener("tc-scan-garment", onGarment);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("tc-scan", onScan);
window.removeEventListener("tc-scan-garment", onGarment);
window.removeEventListener("keydown", onKey);
};
}, []);
return (
<section>
<PageHead title="Counter">
<button type="button" className="btn btn-onink" onClick={() => setAdjust(true)}>Hand-in without a person</button>
</PageHead>
{!sel ? (
<div className={styles.stack}>
{staffParam && <ErrorLine msg="No one on the register matches that link or badge." />}
<PersonPicker onPick={pick} />
<ErrorLine msg={err} />
</div>
) : (
<div className={styles.stack}>
{held && <PersonPanel st={sel} held={held} onChange={changePerson} />}
<div className={styles.modeRow}>
<div className={styles.modeSeg}>
<Seg<Mode> label="Counter mode" opts={MODES} value={mode} labels={MODE_LABELS} onChange={setMode} />
</div>
{!cart.cart.length && <div className={styles.kbdHint}><Kbd>Esc</Kbd> change person</div>}
</div>
{mode === "issue" && (
<div className={`${styles.grid}${cart.cart.length ? " " + styles.cartFirst : ""}`}>
<div>
<AddGarments st={sel} isAdmin={isAdmin} onAdd={addGarment} onBind={setBind} onCamera={() => { setCamMsg(""); setCam(true); }}
onReturn={setRet} onRepeat={() => { cart.replace(lastSet.map((i) => ({ itemId: i.itemId, si: i.si, qty: i.qty, src: "stock" as const }))); setMsg(""); }}
repeatDate={lastSet.length ? lastSet[0].date : null} groups={groups} owed={owed} inputRef={scanRef} listOpenRef={listOpenRef} />
</div>
<div className={styles.pickup}>
<PickupCart st={sel} cart={cart} mac={mac} onRecord={doIssue} />
</div>
</div>
)}
{mode === "return" && (
<div className={styles.grid}>
<HoldingPanel st={sel} onReturn={setRet} onError={setErr} />
<ReturnedToday st={sel} />
</div>
)}
{mode === "handin" && (
<div className={styles.grid}>
<HoldingPanel st={sel} onError={setErr} />
<HandInPanel st={sel} onRecord={() => setHandin(true)} />
</div>
)}
{mode === "swap" && (
<div className={styles.grid}>
<SwapPanel st={sel} onDone={(m) => { setErr(""); setMsg(m); }} onError={(e) => { setMsg(""); setErr(e); }} />
<SwappedToday st={sel} />
</div>
)}
<LiveRegion msg={msg} className={styles.msg} />
<ErrorLine msg={err} />
</div>
)}
{cam && <Camera onHit={camHit} message={camMsg} onClose={() => { setCam(false); scanRef.current?.focus(); }} />}
{bind && <BindDialog code={bind} onClose={() => setBind("")} onBound={(itemId, si) => { if (mode !== "issue") setMode("issue"); addGarment(itemId, si); }} />}
{ret && <ReturnDialog issue={ret} onClose={() => setRet(null)} />}
{handin && sel && <HandInDialog staff={sel} onClose={() => setHandin(false)} onDone={(m) => { setErr(""); setMsg(m); }} />}
{adjust && <AdjustDialog init={{ itemId: "", si: 0, preloved: true }} onClose={() => setAdjust(false)} />}
</section>
);
}
+192
View File
@@ -0,0 +1,192 @@
"use client";
/* Return, Hand in and Swap a size: the counter's other three modes. */
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Panel, QtyStepper, Tag } from "@/components/portal";
import { printHandInReceipt } from "@/components/dialogs";
import { key, label, onhand, plOf, type IssueRec, type StaffRec } from "@/lib/compute";
import { dayMonth, openIssuesOf, owedLinesOf, plural, sizeOf } from "./lib";
import styles from "./counter.module.css";
/** Every open issue line, ungrouped, with the signed toggle; Return when `onReturn` is given. */
export function HoldingPanel({ st, onReturn, onError }: { st: StaffRec; onReturn?: (i: IssueRec) => void; onError: (e: string) => void }) {
const { s, mutate } = useSnap();
const { byId } = useDerived();
const open = useMemo(() => openIssuesOf(s, st.id), [s, st.id]);
const owed = useMemo(() => owedLinesOf(s, st.id), [s, st.id]);
const qty = open.reduce((t, i) => t + i.qty, 0);
async function sign(i: IssueRec) {
const r = await mutate("issue.receipt", { id: i.id, receipt: !i.receipt });
if (!r.ok) onError(r.error);
}
return (
<Panel title="Holding" aside={plural(qty, "garment", "garments")}>
{open.length === 0 ? <div className={styles.empty}>Nothing out.</div> : (
<div className={styles.tableWrap}>
<table className="tc-table">
<thead><tr><th>Garment</th><th>Size</th><th className="num">Qty</th><th>Issued</th><th>Signed</th>{onReturn && <th><span className="sr-only">Action</span></th>}</tr></thead>
<tbody>
{open.map((i) => {
const it = byId[i.itemId];
return (
<tr key={i.id}>
<td>{label(it)}{i.preloved ? <span className={styles.meta}> · pre-loved</span> : null}</td>
<td className="tc-mono">{sizeOf(it, i.si)}</td>
<td className="num">{i.qty}</td>
<td className="tc-mono" style={{ fontSize: 12 }}>{dayMonth(i.date)}</td>
<td>
<button type="button" className={`btn ${i.receipt ? "btn-secondary" : "btn-ghost"} ${styles.rowGhost}`} aria-pressed={i.receipt}
aria-label={`${label(it)} size ${sizeOf(it, i.si)} signed for`} onClick={() => sign(i)}>{i.receipt ? "Signed" : "Mark signed"}</button>
</td>
{onReturn && (
<td style={{ textAlign: "right" }}>
<button type="button" className={`btn btn-ghost ${styles.rowGhost}`} aria-label={`Return ${label(it)} size ${sizeOf(it, i.si)}`} onClick={() => onReturn(i)}>Return</button>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
{owed.length > 0 && (
<>
<div className={`tc-lbl ${styles.subLbl}`}>On order or waiting</div>
<div className={styles.tableWrap}>
<table className="tc-table">
<tbody>
{owed.map((o) => (
<tr key={o.key}>
<td>{label(byId[o.itemId])}</td>
<td className="tc-mono">{o.size || ""}</td>
<td className="num">{o.qty}</td>
<td className={styles.meta}>{o.where}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Panel>
);
}
export function ReturnedToday({ st }: { st: StaffRec }) {
const { s } = useSnap();
const { byId } = useDerived();
const rows = s.issues.filter((i) => i.staffId === st.id && i.returned?.date === s.today);
return (
<Panel title="Returned today" aside={rows.length ? plural(rows.reduce((t, i) => t + i.qty, 0), "garment", "garments") : undefined}>
{rows.length === 0 ? <div className={styles.empty}>Nothing returned today.</div> : rows.map((i) => {
const it = byId[i.itemId];
const cond = i.returned!.cond;
return (
<div key={i.id} className={styles.listRow}>
<div className={styles.listMain}>{label(it)} · <span className="tc-mono">{sizeOf(it, i.si)}</span> <span className="tc-mono">×{i.qty}</span></div>
<Tag tone={cond === "Returned - Good" ? "outline" : "low"}>{cond.replace("Returned - ", "")}</Tag>
{i.returned!.photoId && <a className={`btn btn-ghost ${styles.rowGhost}`} href={`/api/photo/${i.returned!.photoId}`} target="_blank" rel="noopener">Photo</a>}
</div>
);
})}
</Panel>
);
}
export function HandInPanel({ st, onRecord }: { st: StaffRec; onRecord: () => void }) {
const { s } = useSnap();
const { byId } = useDerived();
const list = s.handins.filter((h) => h.staffId === st.id).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
return (
<Panel title="Hand in">
<div className={styles.panelPad}>
<button type="button" className="btn btn-primary" onClick={onRecord}>Record a hand-in</button>
</div>
<div className={`tc-lbl ${styles.subLbl}`}>Hand-ins</div>
{list.length === 0 ? <div className={styles.empty}>No hand-ins on file.</div> : list.map((h) => {
const good = h.lines.filter((l) => l.cond === "Good").reduce((t, l) => t + l.qty, 0);
const rag = h.lines.filter((l) => l.cond === "Rag").reduce((t, l) => t + l.qty, 0);
return (
<div key={h.id} className={styles.listRow}>
<span className="tc-mono" style={{ fontSize: 12, width: 52 }}>{dayMonth(h.date)}</span>
<div className={styles.listMain}>{good} to pre-loved · {rag} rag</div>
{h.credit && <Tag>Credited</Tag>}
<button type="button" className={`btn btn-ghost ${styles.rowGhost}`} onClick={() => printHandInReceipt(s, st, h, byId)} aria-label={`Receipt for the hand-in on ${dayMonth(h.date)}`}>Receipt</button>
</div>
);
})}
</Panel>
);
}
function SwapRow({ issue, onDone, onError }: { issue: IssueRec; onDone: (msg: string) => void; onError: (e: string) => void }) {
const { s, mutate } = useSnap();
const { L, byId } = useDerived();
const it = byId[issue.itemId];
const [si, setSi] = useState(-1);
const [qty, setQty] = useState(1);
const [busy, setBusy] = useState(false);
const n = Math.min(Math.max(1, qty), issue.qty);
if (!it) return null;
async function swap() {
if (si < 0 || busy) return;
setBusy(true);
const r = await mutate<{ size: string; qty: number }>("issue.exchange", { id: issue.id, si, qty: n });
setBusy(false);
if (!r.ok) { onError(r.error); return; }
setSi(-1); setQty(1);
onDone(`Swapped ${label(it)} ${sizeOf(it, issue.si)} for ${r.result.size} ×${r.result.qty}.`);
}
return (
<div className={styles.listRow}>
<div className={styles.listMain}>{label(it)} · <span className="tc-mono">{sizeOf(it, issue.si)}</span> <span className="tc-mono">×{issue.qty}</span></div>
<select className={`input ${styles.swapSelect}`} aria-label={`New size for ${label(it)}`} value={si} onChange={(e) => setSi(+e.target.value)}>
<option value={-1}>New size</option>
{it.sizes.map((sz, j) => {
if (j === issue.si) return null;
const k = key(it.id, j);
return <option key={j} value={j}>{String(sz)} ({issue.preloved ? `${plOf(s, k)} pre-loved` : `${onhand(s, L, k)} on shelf`})</option>;
})}
</select>
{issue.qty > 1 && <QtyStepper size="sm" value={n} min={1} max={issue.qty} label={`${label(it)} to swap`} onChange={setQty} />}
<button type="button" className="btn btn-secondary" disabled={si < 0 || busy} onClick={swap}>Swap</button>
</div>
);
}
export function SwapPanel({ st, onDone, onError }: { st: StaffRec; onDone: (msg: string) => void; onError: (e: string) => void }) {
const { s } = useSnap();
const open = useMemo(() => openIssuesOf(s, st.id), [s, st.id]);
return (
<Panel title="Holding" aside={plural(open.reduce((t, i) => t + i.qty, 0), "garment", "garments")}>
{open.length === 0 ? <div className={styles.empty}>Nothing out.</div> : open.map((i) => <SwapRow key={i.id} issue={i} onDone={onDone} onError={onError} />)}
</Panel>
);
}
/** Best effort: today's good returns with a new issue of the same garment, another size, today. */
export function SwappedToday({ st }: { st: StaffRec }) {
const { s } = useSnap();
const { byId } = useDerived();
const mine = s.issues.filter((i) => i.staffId === st.id);
const rows = mine
.filter((i) => i.returned?.date === s.today && i.returned.cond === "Returned - Good")
.flatMap((old) => {
const nu = mine.find((n) => n.date === s.today && n.itemId === old.itemId && n.si !== old.si && n.qty === old.qty);
return nu ? [{ old, nu }] : [];
});
return (
<Panel title="Swapped today">
{rows.length === 0 ? <div className={styles.empty}>Nothing swapped today.</div> : rows.map(({ old, nu }) => {
const it = byId[old.itemId];
return (
<div key={old.id} className={styles.listRow}>
<div className={styles.listMain}>{label(it)} · <span className="tc-mono">{sizeOf(it, old.si)}</span> <span className="tc-mono">{sizeOf(it, nu.si)}</span></div>
<span className="tc-mono">×{old.qty}</span>
</div>
);
})}
</Panel>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { Meter, Tag } from "@/components/portal";
import { printCreditSlip } from "@/components/dialogs";
import { allowanceRouteOf, approvalRemaining, ccOf, initialRemaining, initialSets, openApproval, setsForFte, type CapCheck, type StaffRec } from "@/lib/compute";
import { dayMonth } from "./lib";
import styles from "./counter.module.css";
const Mono = ({ children }: { children: React.ReactNode }) => <span className="tc-mono">{children}</span>;
/* Who is at the counter: details, their route and signed form, and the six-set meters. */
export default function PersonPanel({ st, held, onChange }: { st: StaffRec; held: CapCheck; onChange: () => void }) {
const { s } = useSnap();
const cc = ccOf(s, st);
const cut = st.uniformStyle === "Women's" ? "Womens cut" : st.uniformStyle === "Men's" ? "Mens cut" : "";
const details: React.ReactNode[] = [];
if (st.group) details.push(st.group);
if (st.dept) details.push(st.dept);
if (cc) details.push(<Mono key="cc">{cc}</Mono>);
if (cut) details.push(cut);
if (st.top || st.pants) details.push(<span key="usual">usual <Mono>{st.top || ""}</Mono> / <Mono>{st.pants || ""}</Mono></span>);
const route = allowanceRouteOf(s, st);
const ap = openApproval(s, st.id);
const apRem = approvalRemaining(s, st.id);
const signed = ap ? ` · manager signed ${ap.sets} on ${dayMonth(ap.date)}, ${apRem} left` : " · nothing signed";
let sentence: React.ReactNode;
if (route === "fte") {
const n = setsForFte(st.fte);
sentence = <>FTE table{st.fte ? <> · <Mono>{st.fte}</Mono>{n !== null ? ` proposes ${n} sets` : ""}</> : " · no FTE recorded"}{signed}</>;
} else if (route === "kit") {
const sets = initialSets(s, st);
const left = initialRemaining(s, st) ?? 0;
sentence = <>Starting kit{sets !== null ? ` · ${sets} sets` : ""}{left > 0 ? ` · ${left} garments still to issue` : ""}{ap ? signed : ""}</>;
} else {
sentence = <>Manager approval{signed}</>;
}
return (
<div className={styles.person}>
<div style={{ minWidth: 0 }}>
<div className={styles.line1}>
<span className={styles.name}>{st.first} {st.last}</span>
<span className={`tc-mono ${styles.meta}`}>{st.num}</span>
{st.selfEmail && <Tag>Staff app</Tag>}
{st.inactive && <Tag tone="accent">Inactive</Tag>}
</div>
{details.length > 0 && (
<div className={styles.details}>{details.map((d, i) => <span key={i}>{i > 0 ? " · " : ""}{d}</span>)}</div>
)}
<div className={styles.route}>
<span>{sentence}</span>
<button type="button" className={`btn btn-ghost ${styles.inlineGhost}`} onClick={() => window.open(`/print/order-form?staff=${encodeURIComponent(st.id)}`, "_blank")}>Order form</button>
{ap && <button type="button" className={`btn btn-ghost ${styles.inlineGhost}`} onClick={() => printCreditSlip(s, st, ap)}>Credit slip</button>}
</div>
</div>
<div className={styles.meters}>
<Meter label="Tops" value={held.tops} of={held.cap} />
<Meter label="Pants" value={held.pants} of={held.cap} />
{held.other > 0 && <span className={styles.meta}>+{held.other} outside a set</span>}
</div>
<div className={styles.personActions}>
<Link href={`/app/staff/${st.id}`} className="btn btn-ghost">Open record</Link>
<button type="button" className="btn btn-ghost" onClick={onChange}>Change person</button>
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import { Meter, Panel } from "@/components/portal";
import { heldByStaff, setsCap } from "@/lib/compute";
import styles from "./counter.module.css";
/* No person chosen yet: find one by name or staff number (a badge scan types the number). */
export default function PersonPicker({ onPick }: { onPick: (id: string) => void }) {
const { s } = useSnap();
const [q, setQ] = useState("");
// One walk of the issues for the whole register, not one per row.
const held = useMemo(() => heldByStaff(s), [s]);
const cap = setsCap(s.settings.capSets);
const qq = q.trim().toLowerCase();
const active = s.staff.filter((st) => !st.inactive);
const matches = active
.filter((st) => !qq || `${st.first} ${st.last}`.toLowerCase().includes(qq) || `${st.last} ${st.first}`.toLowerCase().includes(qq) || st.num.toLowerCase().includes(qq))
.slice(0, 8);
function onKey(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key !== "Enter") return;
const exact = qq ? s.staff.find((st) => st.num.toLowerCase() === qq) : undefined;
const first = exact || matches[0];
if (first) { e.preventDefault(); onPick(first.id); }
}
return (
<Panel title="Find a person" aside={s.staff.length ? `${active.length} on the register` : undefined}>
<div className={styles.search}>
<input className={`input ${styles.searchInput}`} aria-label="Search the register by name or staff number" placeholder="Name or staff number, or scan a badge"
value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey} autoFocus />
</div>
{s.staff.length === 0 ? (
<div className={styles.empty}>No staff on the register yet. <Link href="/app/staff">Add staff</Link></div>
) : matches.length === 0 ? (
<div className={styles.empty}>Nobody matches {q.trim()}.</div>
) : (
<div>
{matches.map((st) => {
const h = held[st.id] || { tops: 0, pants: 0, other: 0, sets: 0 };
return (
<button type="button" key={st.id} className={styles.pickRow} onClick={() => onPick(st.id)}>
<span className={styles.pickMain}>
<span style={{ display: "block", fontWeight: 700 }}>{st.first} {st.last} <span className="tc-mono" style={{ fontWeight: 400, color: "#57534f", fontSize: 12 }}>{st.num}</span></span>
<span className={styles.meta} style={{ display: "block" }}>{[st.group, st.dept].filter(Boolean).join(" · ") || "—"}</span>
</span>
<span className={styles.pickMeters}>
<Meter label="Tops" value={h.tops} of={cap} size="sm" />
<Meter label="Pants" value={h.pants} of={cap} size="sm" />
</span>
</button>
);
})}
</div>
)}
</Panel>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useId } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Icon, Kbd, Panel, QtyStepper, Seg, Tag } from "@/components/portal";
import { ccOf, genderLabel, groupsLabel, key, label, money, onhand, plOf, setHalf, type StaffRec } from "@/lib/compute";
import { plural, pronoun, sizeOf, type Source } from "./lib";
import type { CounterCart } from "./useCounterCart";
import styles from "./counter.module.css";
export default function PickupCart({ st, cart: c, mac, onRecord }: { st: StaffRec; cart: CounterCart; mac: boolean; onRecord: () => void }) {
const { s } = useSnap();
const { L, byId } = useDerived();
const reasonsId = useId();
const pr = pronoun(st);
const cap = c.cap;
const costCentre = ccOf(s, st);
const apClause = c.apN > 0 ? (c.apN === c.apRem ? ` · uses the last ${c.apN} signed` : ` · uses ${c.apN} signed`) : "";
return (
<Panel title="This pickup" aside={c.cart.length ? `${plural(c.cart.length, "line", "lines")} · ${plural(c.garments, "garment", "garments")}` : undefined}>
{c.cart.length === 0 && <div className={styles.empty}>Scan, type or tap a usual to add garments.</div>}
{c.cart.length > 0 && (
<div>
{c.cart.map((line, i) => {
const it = byId[line.itemId];
const size = sizeOf(it, line.si);
const k = key(line.itemId, line.si);
const oh = onhand(s, L, k), pl = plOf(s, k);
const opts: Source[] = pl > 0 || line.src === "preloved" ? ["stock", "preloved", "order"] : ["stock", "order"];
const half = setHalf(it);
const want = half === "top" ? st.top : half === "pants" ? st.pants : "";
const offUsual = !!want && !!it && it.sizes.map(String).includes(String(want)) && size !== String(want);
const shortShelf = line.src === "stock" && line.qty > oh;
const shortPool = line.src === "preloved" && line.qty > pl;
return (
<div key={line.itemId + ":" + line.si} className={styles.cartLine}>
<div className={styles.lineMain}>
<div className={styles.lineTitle}>{label(it)} · <span className="tc-mono">{size}</span></div>
<div className={styles.lineSeg}>
<Seg<Source> size="sm" label={`Where ${label(it)} ${size} comes from`} opts={opts} value={(line.src ?? "") as Source}
onChange={(v) => c.setLine(i, { src: v })}
labels={{
stock: <>Shelf <b className="tc-mono" style={{ fontWeight: 500 }}>{oh}</b></>,
preloved: <>Pre-loved <b className="tc-mono" style={{ fontWeight: 500 }}>{pl}</b></>,
order: "Order in",
}} />
{line.src === null && <span className={styles.hint}>Pick a source</span>}
{shortShelf && <Tag tone="low">Not enough on the shelf</Tag>}
{shortPool && <Tag tone="low">Not enough pre-loved</Tag>}
</div>
{offUsual && <div className={styles.hint} style={{ marginTop: 4 }}>Usual size {want}</div>}
</div>
<QtyStepper value={line.qty} min={0} label={`${label(it)} ${size}`} onChange={(n) => c.setQty(i, n)} />
<div className={`tc-mono ${styles.cost}`}>{money(line.src === "preloved" ? 0 : line.qty * (it?.cost || 0))}</div>
</div>
);
})}
</div>
)}
{c.cart.length > 0 && cap && (
<div className={styles.after}>
<div className={styles.afterRow}>
{c.needsTick ? <span className="tc-mark" aria-hidden="true" style={{ marginRight: 6 }} /> : <Icon name="check" size={16} />}
<span>After this {pr.subj} {pr.holds} <b className="tc-mono">{cap.afterSets} of {cap.cap}</b> sets{apClause}</span>
</div>
{c.needsTick && (
<>
<div id={reasonsId}>
{c.overCap && (
<div className={styles.reason}>
{cap.breach === "other" ? `Past ${cap.otherCap} garments outside a set — holds ${cap.afterOther}` : `Past ${cap.cap} sets — holds ${cap.afterTops} tops and ${cap.afterPants} pairs`}
</div>
)}
{c.offItems.map((it) => <div key={"g" + it.id} className={styles.reason}>{it.item} is for {groupsLabel(it.groups)}</div>)}
{c.offStyleItems.map((it) => <div key={"c" + it.id} className={styles.reason}>{it.item} is the {genderLabel(it.gender)} cut</div>)}
</div>
<label className={styles.tick}>
<input type="checkbox" checked={c.override} onChange={() => c.setOverride(!c.override)} aria-describedby={reasonsId} />
Record as an override
</label>
</>
)}
{c.ap && (
<div className={styles.afterRow}>
<span>Sets off the signed form</span>
<QtyStepper size="sm" value={c.apN} min={0} max={c.apRem} label="set off the signed form" onChange={(n) => c.setApDeduct(n)} />
<span className={styles.meta}>{c.apRem} left</span>
</div>
)}
</div>
)}
<div className={styles.charge}>
<div>
<div className="tc-lbl">Charged to {costCentre || "no cost centre"}</div>
<div className={`tc-mono ${styles.total}`}>{money(c.cartVal)}</div>
</div>
<div className={styles.chargeBtns}>
<button type="button" className="btn btn-secondary" onClick={c.printSlip} disabled={c.cannot || c.handed.length === 0}><Icon name="print" size={16} /> Collection slip</button>
<button type="button" className="btn btn-primary" onClick={onRecord} disabled={c.cannot} aria-keyshortcuts="Control+Enter Meta+Enter">
Record issue <span className={styles.kbdHint}><Kbd onAccent>{mac ? "⌘↵" : "Ctrl↵"}</Kbd></span>
</button>
</div>
{c.inactive && <div className={styles.hint} style={{ width: "100%" }}>Inactive on the register: reactivate to issue</div>}
</div>
</Panel>
);
}
+94
View File
@@ -0,0 +1,94 @@
/* Counter (/app/counter). Layout only; the shared parts (Panel, Seg, Meter, QtyStepper, Tag) keep their own styles. */
.stack { display: flex; flex-direction: column; gap: 18px; }
/* Person panel */
.person { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) auto; gap: 24px; align-items: center; padding: 16px 20px; border: 2px solid var(--color-text); background: var(--color-bg); }
.line1 { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.name { font-family: var(--font-heading); font-weight: 800; font-size: 24px; letter-spacing: -0.01em; line-height: 1.15; }
.meta { font-size: 12px; color: #57534f; }
.details { margin-top: 4px; font-size: 13px; color: #57534f; }
.route { margin-top: 6px; font-size: 12px; color: #57534f; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 14px; }
.inlineGhost { min-height: 0 !important; padding: 0 !important; font-size: 12px; }
.meters { display: flex; flex-direction: column; gap: 8px; }
.personActions { display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
/* Mode row and grid */
.modeRow { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.modeSeg { max-width: 100%; overflow-x: auto; }
.kbdHint { font-size: 12px; color: #57534f; white-space: nowrap; }
.grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }
.grid > * { min-width: 0; }
/* Person search */
.search { padding: 14px 16px; border-bottom: 1px solid #cfcccb; }
.searchInput { width: 100%; }
.pickRow { display: flex; align-items: center; gap: 14px; width: 100%; padding: 11px 16px; border: none; border-top: 1px solid #cfcccb; background: transparent; color: inherit; text-align: left; cursor: pointer; font: inherit; }
.pickRow:first-child { border-top: 0; }
.pickRow:hover { background: var(--color-neutral-200); }
.pickRow:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -3px; }
.pickMain { flex: 1; min-width: 0; }
.pickMeters { flex: none; display: flex; flex-direction: column; gap: 4px; }
/* Add garments */
.scanWrap { position: relative; padding: 14px 16px; border-bottom: 1px solid #cfcccb; }
.scanBox { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 4px 0 10px; background: #fff; border: 2px solid var(--color-text); color: #57534f; }
.scanBox:focus-within { border-color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); }
.scanInput { flex: 1; min-width: 0; border: none; outline: none; background: transparent; padding: 6px 0; font-family: var(--font-body); font-size: 14px; color: var(--color-text); }
.scanInput::placeholder { color: #928d8a; }
.camBtn { min-height: 28px !important; padding: 0 8px !important; }
.pop { position: absolute; left: 16px; right: 16px; top: calc(100% - 12px); z-index: 30; max-height: 440px; overflow: auto; background: var(--color-bg); border: 2px solid var(--color-text); }
.opt { padding: 10px 14px; border-top: 1px solid #cfcccb; }
.opt:first-child { border-top: 0; }
.optHead { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; font-weight: 700; }
.optDivider { padding: 8px 14px 4px; border-top: 2px solid var(--color-text); }
.popEmpty { padding: 12px 14px; font-size: 13px; color: #57534f; }
.scanErr { padding: 0 16px 12px; }
.usualLbl { padding: 12px 16px 4px; }
.chips { padding: 6px 16px 14px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.chip { gap: 10px !important; }
.holdLbl { padding: 10px 16px; border-top: 2px solid var(--color-text); }
.holdFoot { padding: 8px 16px 12px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
.tableWrap { overflow-x: auto; }
.rowGhost { min-height: 0 !important; padding-top: 0 !important; padding-bottom: 0 !important; }
/* This pickup */
.cartLine { display: flex; align-items: center; gap: 14px; padding: 11px 16px; border-top: 1px solid #cfcccb; }
.cartLine:first-child { border-top: 0; }
.lineMain { flex: 1; min-width: 0; }
.lineTitle { font-weight: 700; }
.lineSeg { margin-top: 6px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.hint { font-size: 12px; font-weight: 600; color: var(--color-accent-700); }
.cost { width: 70px; flex: none; text-align: right; }
.empty { padding: 14px 16px; font-size: 13px; color: #57534f; }
.after { padding: 12px 16px; border-top: 1px solid #cfcccb; display: flex; flex-direction: column; gap: 8px; font-size: 14px; }
.afterRow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.reason { font-size: 13px; font-weight: 600; color: var(--color-accent-700); padding-left: 26px; }
.tick { display: flex; align-items: center; gap: 10px; padding-left: 26px; font-weight: 600; cursor: pointer; }
.tick input { width: 16px; height: 16px; accent-color: var(--color-accent); }
.charge { padding: 14px 16px; border-top: 2px solid var(--color-text); display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.total { font-size: 24px; font-weight: 600; line-height: 1.2; }
.chargeBtns { margin-left: auto; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.msg { font-size: 13px; font-weight: 600; border-top: 2px solid var(--color-text); padding-top: 8px; }
/* Return / Hand in / Swap */
.panelPad { padding: 14px 16px; }
.listRow { display: flex; align-items: center; gap: 12px; padding: 11px 16px; border-top: 1px solid #cfcccb; flex-wrap: wrap; }
.listRow:first-child { border-top: 0; }
.listMain { flex: 1; min-width: 0; }
.subLbl { padding: 10px 16px; border-top: 2px solid var(--color-text); }
.swapSelect { min-height: 32px; max-width: 220px; }
@media screen and (max-width: 780px) {
.person { grid-template-columns: minmax(0, 1fr); gap: 14px; padding: 14px 16px; }
.personActions { flex-direction: row; align-items: center; gap: 18px; flex-wrap: wrap; }
.modeSeg :global(.tc-seg) { flex-wrap: nowrap; }
.kbdHint { display: none; }
.grid { grid-template-columns: minmax(0, 1fr); }
.cartFirst > .pickup { order: -1; }
.cartLine { flex-wrap: wrap; }
.chargeBtns { margin-left: 0; width: 100%; flex-direction: column; align-items: stretch; }
.chargeBtns > :global(.btn) { width: 100%; justify-content: center; }
.scanInput { font-size: 16px; }
.route :global(.btn), .personActions :global(.btn), .rowGhost, .camBtn { min-height: 44px !important; }
.swapSelect { max-width: 100%; }
}
+74
View File
@@ -0,0 +1,74 @@
/* Small shared pieces for the person-first counter (/app/counter). */
import { isOpen, type IssueRec, type Item, type Snapshot, type StaffRec } from "@/lib/compute";
export type Source = "stock" | "preloved" | "order";
/** src null = both shelf and pre-loved stock exist, so the coordinator must pick one. */
export type CartLine = { itemId: string; si: number; qty: number; src: Source | null };
export type Mode = "issue" | "return" | "handin" | "swap";
export const MODES: readonly Mode[] = ["issue", "return", "handin", "swap"];
export const MODE_LABELS: Record<Mode, string> = { issue: "Issue", return: "Return", handin: "Hand in", swap: "Swap a size" };
export const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
/** "2 Sep": the counter's short date. */
export function dayMonth(iso: string): string {
if (!iso || iso.length < 10) return "—";
const d = new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-AU", { day: "numeric", month: "short" }).replace("Sept", "Sep");
}
/** Words for the person, from the cut their record is set to. */
export function pronoun(st: StaffRec | undefined) {
if (st?.uniformStyle === "Women's") return { poss: "Her", subj: "she", holds: "holds" } as const;
if (st?.uniformStyle === "Men's") return { poss: "His", subj: "he", holds: "holds" } as const;
return { poss: "Their", subj: "they", holds: "hold" } as const;
}
/** Garments out with this person now: issued, not returned, not handed in. Newest first. */
export function openIssuesOf(s: Snapshot, staffId: string): IssueRec[] {
return s.issues
.filter((i) => i.staffId === staffId && !i.returned && !i.handedIn)
.sort((a, b) => (a.date === b.date ? (a.createdAt < b.createdAt ? 1 : -1) : a.date < b.date ? 1 : -1));
}
export type HoldGroup = { itemId: string; si: number; qty: number; last: string; latest: IssueRec };
/** Open issues grouped by garment and size, quantities summed, most recent first. */
export function holdingGroups(open: IssueRec[]): HoldGroup[] {
const m = new Map<string, HoldGroup>();
for (const i of open) {
const k = i.itemId + ":" + i.si;
const g = m.get(k);
if (!g) m.set(k, { itemId: i.itemId, si: i.si, qty: i.qty, last: i.date, latest: i });
else { g.qty += i.qty; if (i.date > g.last) { g.last = i.date; g.latest = i; } }
}
return [...m.values()].sort((a, b) => (a.last < b.last ? 1 : a.last > b.last ? -1 : 0));
}
export type OwedLine = { key: string; itemId: string; size: string; qty: number; where: string };
/** What is committed to this person and not handed over yet, line by line: open orders (less what
* has arrived), pickups waiting, approved request lines. The same three things capCheck counts. */
export function owedLinesOf(s: Snapshot, staffId: string): OwedLine[] {
const out: OwedLine[] = [];
for (const o of s.orders) {
if (o.staffId !== staffId || !isOpen(o)) continue;
const got: Record<string, number> = {};
for (const rc of o.receipts) for (const l of rc.lines) got[l.itemId + "|" + l.size] = (got[l.itemId + "|" + l.size] || 0) + l.qty;
for (const l of o.lines) {
const k = l.itemId + "|" + l.size, done = Math.min(l.qty, got[k] || 0);
got[k] = (got[k] || 0) - done;
if (l.qty > done) out.push({ key: "o" + l.id, itemId: l.itemId, size: l.size, qty: l.qty - done, where: `On order · ${o.code}` });
}
}
for (const p of s.pickups) if (p.staffId === staffId && !p.pickedUp) p.lines.forEach((l, i) => out.push({ key: "p" + p.id + i, itemId: l.itemId, size: l.size, qty: l.qty, where: "Waiting to collect" }));
(s.owedRequestLines || []).forEach((r, i) => { if (r.staffId === staffId) out.push({ key: "r" + i, itemId: r.itemId, size: "", qty: r.qty, where: "Approved request" }); });
return out;
}
export const sizeOf = (it: Item | undefined, si: number) => (it ? String(it.sizes[si] ?? "?") : "?");
/** A real dialog, the command panel or any overlay is up, so page shortcuts stand down. */
export function overlayOpen(): boolean {
return typeof document !== "undefined" && !!document.querySelector('[role="dialog"][aria-modal="true"], .overlay, .tc-cmd-overlay');
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
/* The pickup being put together at the counter, and every rule it is checked against before it can
* be recorded. The rules are asked of lib/compute (capCheck, garmentForGroup, garmentForStyle) so the
* screen and the server's refusal can never disagree. */
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { openSlip } from "@/components/dialogs";
import { approvalRemaining, capCheck, garmentForGroup, garmentForStyle, isPantItem, isTopItem, key, longLabel, onhand, openApproval, plOf, staffName, type StaffRec } from "@/lib/compute";
import { plural, type CartLine } from "./lib";
export function useCounterCart(sel: StaffRec | undefined) {
const { s, mutate } = useSnap();
const { L, byId } = useDerived();
const selId = sel?.id || "";
const [cart, setCart] = useState<CartLine[]>([]);
const [override, setOverride] = useState(false);
const [apDeduct, setApDeduct] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
// A new person starts with an empty bag and no deduction.
const [owner, setOwner] = useState(selId);
if (owner !== selId) { setOwner(selId); setCart([]); setApDeduct(null); setOverride(false); }
// An override is about one person and one bag: the tick goes the moment either changes, cleared
// as the page draws so the new bag is never on screen with the old tick behind it.
const bagKey = selId ? `${selId}|${cart.map((c) => `${c.itemId}:${c.si}:${c.qty}:${c.src}`).join(",")}` : "";
const [tickedFor, setTickedFor] = useState(bagKey);
if (tickedFor !== bagKey) { setTickedFor(bagKey); setOverride(false); }
function add(itemId: string, si: number) {
setCart((c) => {
const f = c.find((x) => x.itemId === itemId && x.si === si);
if (f) return c.map((x) => (x === f ? { ...x, qty: x.qty + 1 } : x));
const oh = onhand(s, L, key(itemId, si)), pl = plOf(s, key(itemId, si));
return [...c, { itemId, si, qty: 1, src: pl > 0 && oh >= 1 ? null : pl > 0 ? "preloved" : oh >= 1 ? "stock" : "order" }];
});
}
const setLine = (i: number, p: Partial<CartLine>) => setCart((c) => c.map((x, j) => (j === i ? { ...x, ...p } : x)));
const setQty = (i: number, n: number) => setCart((c) => (n < 1 ? c.filter((_, j) => j !== i) : c.map((x, j) => (j === i ? { ...x, qty: n } : x))));
// The whole cart goes to the ceiling, pre-loved and ordered-in lines included.
const cap = useMemo(() => (sel ? capCheck(s, sel, cart.map((c) => ({ itemId: c.itemId, qty: c.qty }))) : null), [s, sel, cart]);
const cartItems = useMemo(() => [...new Set(cart.map((c) => c.itemId))].map((id) => byId[id]).filter((it): it is NonNullable<typeof it> => !!it), [cart, byId]);
const offItems = sel ? cartItems.filter((it) => !garmentForGroup(it, sel.group)) : [];
const offStyleItems = sel ? cartItems.filter((it) => !garmentForStyle(it, sel.uniformStyle)) : [];
const overCap = !!cap && cap.over;
const needsTick = overCap || offItems.length > 0 || offStyleItems.length > 0;
const anyShort = cart.some((c) => (c.src === "stock" && c.qty > onhand(s, L, key(c.itemId, c.si))) || (c.src === "preloved" && c.qty > plOf(s, key(c.itemId, c.si))));
const anyUnpicked = cart.some((c) => c.src === null);
const inactive = !!sel?.inactive;
// Pre-loved is free: out of the charge and out of what a signed form pays for.
const cartVal = cart.filter((c) => c.src !== "preloved").reduce((t, c) => t + c.qty * (byId[c.itemId]?.cost || 0), 0);
const garments = cart.reduce((t, c) => t + c.qty, 0);
const ap = sel ? openApproval(s, sel.id) : undefined;
const apRem = sel ? approvalRemaining(s, sel.id) : 0;
const cartTops = cart.reduce((t, c) => t + (isTopItem(byId[c.itemId]) && c.src !== "preloved" ? c.qty : 0), 0);
const cartPants = cart.reduce((t, c) => t + (isPantItem(byId[c.itemId]) && c.src !== "preloved" ? c.qty : 0), 0);
const apDefault = ap ? Math.min(apRem, Math.max(cartTops, cartPants)) : 0;
const apN = ap ? (apDeduct === null ? apDefault : Math.min(apDeduct, apRem)) : 0;
const cannot = !sel || inactive || cart.length === 0 || anyShort || anyUnpicked || (needsTick && !override) || busy;
async function record(): Promise<{ ok: true; msg: string } | { ok: false; error: string } | null> {
if (cannot || !sel) return null;
setBusy(true);
// Only the ticked box, and only while the box is on the screen.
const r = await mutate<{ stock: number; ordered: number; preloved: number; apDeducted: number; apRemaining: number }>("issue.create", { staffId: sel.id, override: needsTick && override, apDeduct: ap ? apN : 0, lines: cart });
setBusy(false);
if (!r.ok) return { ok: false, error: r.error };
const parts = [
r.result.stock ? `${r.result.stock} from stock` : "",
r.result.ordered ? `${r.result.ordered} ordered in` : "",
r.result.preloved ? `${r.result.preloved} pre-loved` : "",
r.result.apDeducted ? `${r.result.apDeducted} off the signed form` : "",
].filter(Boolean);
setCart([]); setOverride(false); setApDeduct(null);
return { ok: true, msg: `Recorded for ${staffName(sel)}: ${parts.length ? parts.join(" · ") : plural(garments, "garment", "garments")}.` };
}
// The collection slip covers what crosses the counter today: shelf and pre-loved lines.
const handed = cart.filter((c) => c.src === "stock" || c.src === "preloved");
function printSlip() {
if (!sel) return;
openSlip("collection", {
staffName: staffName(sel), dept: sel.dept, sets: handed.reduce((t, c) => t + c.qty, 0), po: "",
lines: handed.map((c) => `${c.qty} × ${longLabel(byId[c.itemId])}${byId[c.itemId]?.sizes[c.si] ?? "?"}${c.src === "preloved" ? " (pre-loved)" : ""}`).join("\n"),
dateReceived: s.today, requestedBy: sel.num, deliveredBy: s.settings.coordinator, dateTime: s.today,
});
}
return {
cart, add, setLine, setQty, replace: setCart,
cap, overCap, offItems, offStyleItems, needsTick, override, setOverride,
anyShort, anyUnpicked, inactive, cartVal, garments,
ap, apRem, apN, setApDeduct,
busy, cannot, record, handed, printSlip,
};
}
export type CounterCart = ReturnType<typeof useCounterCart>;
File diff suppressed because it is too large Load Diff
+946
View File
@@ -0,0 +1,946 @@
"use client";
/* The phone app's shared furniture, built once from the approved counter redesign.
Every screen is a top bar, an accent rule, a scrolling body and (usually) one primary action bar;
the five tab roots add the tab bar. Sizes are the mockup's px straight: the app runs at device width.
Square corners, 2px ink borders, 44px minimum targets, IBM Plex Mono for codes, sizes and counts. */
import Link from "next/link";
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useWorkCount } from "@/lib/workcount";
export const INK = "var(--color-text)";
export const GROUND = "var(--color-bg)";
export const ACCENT = "var(--color-accent)";
export const ON_DARK = "var(--color-neutral-400)"; // meta text on ink — 500/600 fail contrast there
/** Codes, sizes, counts and money. */
export const MONO = "var(--font-plex-mono), 'IBM Plex Mono', ui-monospace, monospace";
export const OK = "#1e7a4f";
export const AC3 = "var(--color-accent-300)";
export const AC7 = "var(--color-accent-700)";
export const MUTED = "var(--color-neutral-600)";
export const DIVIDER = "var(--color-divider)";
const WHITE = "#ffffff";
const OFF_GREY = "#b5b1af";
const DARK_RULE = "#3a3735";
const DARK_EDGE = "#57534f";
/* Rules inline styles cannot express: first-section margin, focus rings, the viewfinder laser.
React hoists and de-duplicates a <style> carrying href + precedence, so every component can
render it and the page gets one copy. */
const M_CSS = `
.tcx-sec{margin:22px 0 4px}
.tcx-sec:first-child{margin-top:4px}
.tcx-app a:focus-visible,.tcx-app button:focus-visible,.tcx-app input:focus-visible,.tcx-app [tabindex]:focus-visible{outline:3px solid var(--color-accent);outline-offset:-3px}
@keyframes tcx-vfsweep{from{top:12%}to{top:88%}}
.tcx-vf-laser{animation:tcx-vfsweep 2.2s ease-in-out infinite alternate;top:12%}
@media (prefers-reduced-motion:reduce){.tcx-vf-laser{animation:none;top:50%}}
`;
export function MStyles() {
return <style href="tcx-m-furniture" precedence="medium">{M_CSS}</style>;
}
// ---------- icons (stroke 2.2, square caps)
const ic = (d: React.ReactNode, size: number, stroke = 2.2) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true">{d}</svg>
);
export const IconLeft = ({ size = 22 }: { size?: number }) => ic(<path d="M15 18 9 12l6-6" />, size);
export const IconRight = ({ size = 22 }: { size?: number }) => ic(<><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></>, size);
export const IconCheck = ({ size = 22 }: { size?: number }) => ic(<path d="m4 12 5 5L20 6" />, size);
export const IconPrinter = ({ size = 22 }: { size?: number }) => ic(<><path d="M6 9V3h12v6" /><path d="M6 18H3v-6h18v6h-3" /><path d="M6 14h12v7H6z" /></>, size);
export const IconScan = ({ size = 22 }: { size?: number }) => ic(<><path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" /><path d="M7 8v8M10 8v8M13 8v8M17 8v8" /></>, size, 2.4);
export const IconSearch = ({ size = 22 }: { size?: number }) => ic(<><circle cx="10.5" cy="10.5" r="6.5" /><path d="M15.5 15.5 21 21" /></>, size);
export const IconX = ({ size = 22 }: { size?: number }) => ic(<><path d="M5 5 19 19" /><path d="M19 5 5 19" /></>, size);
export const IconPlus = ({ size = 22 }: { size?: number }) => ic(<><path d="M12 5v14" /><path d="M5 12h14" /></>, size);
export const IconToday = ({ size = 24 }: { size?: number }) => ic(<><rect x="3" y="4" width="18" height="17" /><path d="M3 9h18M8 2v4M16 2v4M7 13h4M7 17h7" /></>, size);
export const IconWork = ({ size = 24 }: { size?: number }) => ic(<path d="M4 6h2M4 12h2M4 18h2M9 6h11M9 12h11M9 18h11" />, size);
export const IconStock = ({ size = 24 }: { size?: number }) => ic(<><path d="M3 3v18M21 3v18M3 9h18M3 15h18M3 21h18" /><rect x="6" y="5" width="4" height="4" /><rect x="12" y="11" width="5" height="4" /></>, size);
export const IconPeople = ({ size = 24 }: { size?: number }) => ic(<><circle cx="12" cy="8" r="4" /><path d="M4 21c0-4.4 3.6-7 8-7s8 2.6 8 7" /></>, size);
export const IconGear = ({ size = 24 }: { size?: number }) => ic(<><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9 7 7M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" /></>, size);
export const IconBack = ({ size = 24 }: { size?: number }) => ic(<path d="M15 5l-7 7 7 7" />, size, 2.6);
const TickMark = ({ size = 44 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth={3.2} aria-hidden="true"><path d="M4 12.5l5 5L20 6.5" /></svg>
);
// ---------- top bar
/** The top bar. Its title is the screen's only <h1>. `right` is an MTopAction, an MTopCount, or a
* short string (drawn small and muted, as before).
*
* `backHref` is where the chevron goes when there is nothing to go back to. A screen opened cold
* a tapped notification, a launcher shortcut, a refresh on a ward phone, a pasted link has a
* history of one, where router.back() does nothing at all and leaves a 48px control that never
* responds. Screens that are never opened cold pass nothing and keep the plain behaviour. */
export function MTop({ title, right, back, backHref, onBack, dark = true }: { title: string; right?: React.ReactNode; back?: boolean; backHref?: string; onBack?: () => void; dark?: boolean }) {
const router = useRouter();
// Only readable after mount, so the server render assumes the worst case and the effect corrects
// it — the same guard components/MAuth.tsx uses on the auth screens.
const [canPop, setCanPop] = useState(false);
useEffect(() => { setCanPop(window.history.length > 1); }, []);
const plain = typeof right === "string" || typeof right === "number";
return (
// The bar runs under the status bar so the ink reaches the top of the screen, but a hairline
// keeps the phone's own clock and battery from reading as part of ThreadCount's header.
<header className="tcx-topbar" style={{ height: 56, flex: "0 0 56px", background: dark ? INK : GROUND, color: dark ? GROUND : INK, display: "flex", alignItems: "center", gap: 4, paddingLeft: back ? 0 : 16, paddingRight: 6, backgroundImage: dark ? "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)" : undefined, backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px" }}>
<MStyles />
{back && (
<button onClick={() => {
if (onBack) return onBack();
if (!canPop && backHref) return router.push(backHref);
router.back();
}} aria-label="Back"
style={{ minWidth: 48, height: 48, border: 0, background: "none", color: "inherit", display: "grid", placeItems: "center", cursor: "pointer", flex: "0 0 48px", padding: 0 }}>
<IconBack />
</button>
)}
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.07em", textTransform: "uppercase", margin: 0, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</h1>
{right !== undefined && right !== null && (plain
? <div style={{ fontSize: 12, color: dark ? ON_DARK : MUTED, margin: "0 10px", whiteSpace: "nowrap" }}>{right}</div>
: right)}
</header>
);
}
/** A top-bar action (Settings gear, Finish): 48px, 13px/800 uppercase, ground colour. */
export function MTopAction({ label, onClick, href, icon, ariaLabel }: { label?: string; onClick?: () => void; href?: string; icon?: "gear" | "print" | "scan"; ariaLabel?: string }) {
const st: React.CSSProperties = { minWidth: 48, height: 48, border: 0, background: "none", color: GROUND, display: "grid", placeItems: "center", fontFamily: "var(--font-heading)", fontSize: 13, fontWeight: 800, letterSpacing: "0.05em", textTransform: "uppercase", padding: "0 10px", cursor: "pointer", textDecoration: "none" };
const inner = icon === "gear" ? <IconGear /> : icon === "print" ? <IconPrinter size={24} /> : icon === "scan" ? <IconScan size={24} /> : label;
const aria = ariaLabel || (icon ? label : undefined);
if (href) return <Link href={href} aria-label={aria} style={st}>{inner}</Link>;
return <button type="button" onClick={onClick} aria-label={aria} style={st}>{inner}</button>;
}
/** A count at the right of the top bar ("142", "7 open"). */
export function MTopCount({ children }: { children: React.ReactNode }) {
return <span style={{ fontFamily: MONO, fontWeight: 500, fontSize: 13, color: GROUND, padding: "0 10px", whiteSpace: "nowrap" }}>{children}</span>;
}
/* Wordmark and facility bar: the staff app home (components/screens/Home.tsx). */
export function MTopBrand({ facility, right }: { facility: string; right?: React.ReactNode }) {
return (
<header
className="tcx-topbar"
style={{
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex",
alignItems: "center", gap: 10, paddingLeft: 16, paddingRight: 16,
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
backgroundPosition: "0 env(safe-area-inset-top, 0px)",
backgroundRepeat: "no-repeat",
backgroundSize: "100% 1px",
}}
>
<span aria-hidden="true" style={{ width: 16, height: 16, background: "#fff", flex: "0 0 16px" }} />
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.02em", flex: 1 }}>ThreadCount</span>
<span style={{ fontSize: 12, color: "var(--color-neutral-400)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: right ? "32%" : "48%" }}>{facility}</span>
{right}
</header>
);
}
/** The 4px accent rule under the top bar. Given `of`, it doubles as the count progress bar. */
export function MRule({ n, of }: { n?: number; of?: number }) {
const pct = of && of > 0 ? Math.max(0, Math.min(1, (n || 0) / of)) : null;
return (
<div style={{ height: 4, flex: "0 0 4px", position: "relative", background: pct === null ? ACCENT : DARK_EDGE }}>
{pct !== null && <div style={{ position: "absolute", inset: "0 auto 0 0", width: `${pct * 100}%`, background: ACCENT, transition: "width 200ms linear" }} />}
</div>
);
}
/* Android 15 draws the app edge to edge, so a bar docked at the foot of the column has the gesture
handle sitting on its bottom edge. The inset goes inside the bar. It reads through a custom
property that MBody and MSplit set to zero, because the same bars are also used away from the foot
of the window, where the inset would open a band of dead colour mid-screen. */
const SAFE_BOTTOM = "var(--tcx-safe-bottom, env(safe-area-inset-bottom, 0px))";
export const NOT_DOCKED = { "--tcx-safe-bottom": "0px" } as React.CSSProperties;
/** The scrolling body. `pad` gives the mockup's 16px; `dark` is the ink ground of the Scan tab. */
export function MBody({ children, pad = false, dark = false, className }: { children?: React.ReactNode; pad?: boolean; dark?: boolean; className?: string }) {
return (
<div className={className} style={{
...NOT_DOCKED, flex: 1, overflowY: "auto", WebkitOverflowScrolling: "touch", scrollbarWidth: "thin", position: "relative",
background: dark ? INK : GROUND, color: dark ? GROUND : INK, padding: pad ? 16 : 0,
...(dark ? { "--tcx-sec-rule": "var(--color-bg)", "--tcx-sec-sub": OFF_GREY } as React.CSSProperties : null),
}}>
<MStyles />
{children}
</div>
);
}
// ---------- toast and scan flash
type ToastApi = { toast: (msg: string) => void; flash: (kind: string, label: string, then: () => void) => void };
const ToastContext = createContext<ToastApi | null>(null);
/** Mounts the toast (2200ms, over the column) and the full-screen scan confirmation (650ms). Renders
* no wrapper element: screens stay direct children of .tcx-app. */
export function MToastProvider({ children }: { children: React.ReactNode }) {
const [msg, setMsg] = useState<string>("");
const [fl, setFl] = useState<{ kind: string; label: string } | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const toast = useCallback((m: string) => {
setMsg(m);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setMsg(""), 2200);
}, []);
const flash = useCallback((kind: string, label: string, then: () => void) => {
setFl({ kind, label });
setTimeout(() => { setFl(null); then(); }, 650);
}, []);
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
const api = useRef<ToastApi>({ toast, flash });
return (
<ToastContext.Provider value={api.current}>
{children}
{fl && (
<div className="tcx-scanui" role="status" aria-live="assertive" style={{ position: "fixed", inset: 0, zIndex: 99, background: "rgba(32,30,29,.92)", color: WHITE, display: "grid", placeItems: "center", textAlign: "center", padding: 24 }}>
<div>
<div style={{ width: 72, height: 72, background: ACCENT, display: "grid", placeItems: "center", margin: "0 auto 14px" }}><TickMark /></div>
<span style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.1em", textTransform: "uppercase", color: "#d6d3d2" }}>{fl.kind}</span>
<b style={{ display: "block", fontSize: 22, fontWeight: 900 }}>{fl.label}</b>
</div>
</div>
)}
<div role="status" aria-live="polite" className="tcx-scanui" style={msg ? {
position: "fixed", left: 12, right: 12, bottom: "calc(84px + env(safe-area-inset-bottom, 0px))", zIndex: 100,
background: INK, color: GROUND, padding: "14px 16px", fontWeight: 700, fontSize: 15, borderLeft: "6px solid " + ACCENT,
boxShadow: "0 10px 30px rgba(0,0,0,.25)", maxWidth: 536, margin: "0 auto", width: "auto",
} : { position: "fixed", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" }}>{msg}</div>
</ToastContext.Provider>
);
}
const noopToast = () => {};
/** Show a one-line toast. Outside MToastProvider (the staff app) it does nothing. */
export function useToast(): (msg: string) => void {
return useContext(ToastContext)?.toast ?? noopToast;
}
/** Full-screen "Staff badge / Priya Nair" confirmation, then `then()` after 650ms. */
export function useScanFlash(): (kind: string, label: string, then: () => void) => void {
const c = useContext(ToastContext);
return c?.flash ?? ((_k: string, _l: string, then: () => void) => then());
}
// ---------- action bars
/** Full-bleed 64px primary action: label at the left, small mono text or a glyph at the right.
* A disabled bar with `offReason` stays tappable and says why in a toast. */
export function MBar({ label, onClick, href, glyph = "arrow", disabled, tone = "accent", sub, small, offReason }: {
label: string; onClick?: () => void; href?: string; glyph?: "arrow" | "check" | "printer" | "scan" | "none"; disabled?: boolean; tone?: "accent" | "ink"; sub?: string;
small?: string; offReason?: string;
}) {
const toast = useToast();
const G = glyph === "check" ? IconCheck : glyph === "printer" ? IconPrinter : glyph === "scan" ? IconScan : IconRight;
const bg = disabled ? OFF_GREY : tone === "ink" ? INK : ACCENT;
const inner = (
<>
<span style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>{label}</span>
{sub && <span style={{ fontSize: 12, fontWeight: 600, opacity: 0.9, textTransform: "none", letterSpacing: 0 }}>{sub}</span>}
</span>
{small !== undefined
? <small style={{ fontFamily: MONO, fontWeight: 500, fontSize: 13, letterSpacing: 0, textTransform: "none", opacity: 0.9, whiteSpace: "nowrap" }}>{small}</small>
: glyph !== "none" && <G />}
</>
);
const st: React.CSSProperties = {
height: `calc(64px + ${SAFE_BOTTOM})`, flex: `0 0 calc(64px + ${SAFE_BOTTOM})`, width: "100%", background: bg, color: WHITE, border: 0, borderRadius: 0,
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: `0 20px ${SAFE_BOTTOM}`, cursor: disabled ? "not-allowed" : "pointer",
textDecoration: "none", textAlign: "left",
};
if (disabled && offReason) {
return <button type="button" aria-disabled="true" onClick={() => toast(offReason)} style={st} className="tcx-bar">{inner}</button>;
}
if (href && !disabled) return <Link href={href} style={st} className="tcx-bar">{inner}</Link>;
return <button type="button" onClick={onClick} disabled={disabled} style={st} className="tcx-bar">{inner}</button>;
}
/** Two actions sharing the 64px bar, e.g. Undo (1fr, ink) / Scan (2fr) on the counting screen. */
export function MSplit({ children }: { children: React.ReactNode }) {
return <div style={{ ...NOT_DOCKED, display: "flex", flex: "0 0 64px", height: 64 }}>{children}</div>;
}
// `flex` is only meaningful inside an MSplit; on its own the bar is a fixed 64px like MBar.
export function MAction({ label, onClick, disabled, flex, tone = "accent", glyph, glyphAt = "left", small }: {
label: string; onClick?: () => void; disabled?: boolean; flex?: number; tone?: "accent" | "grey" | "ink"; glyph?: "scan" | "plus" | "none";
/** "right" puts the glyph at the far end, as the Scan half of the counting split. */
glyphAt?: "left" | "right"; small?: string;
}) {
const bg = disabled && tone !== "grey" ? OFF_GREY : tone === "accent" ? ACCENT : tone === "ink" ? INK : "var(--color-neutral-200)";
const fg = tone === "grey" ? INK : WHITE;
const g = glyph === "scan" ? <IconScan size={26} /> : glyph === "plus" ? <IconPlus /> : null;
const right = glyphAt === "right";
return (
<button type="button" onClick={onClick} disabled={disabled} className="tcx-bar" style={{ flex: flex ?? `0 0 calc(64px + ${SAFE_BOTTOM})`, height: `calc(64px + ${SAFE_BOTTOM})`, background: bg, color: fg, border: 0, display: "flex", alignItems: "center", justifyContent: right || small ? "space-between" : glyph ? "flex-start" : "center", gap: 10, padding: `0 20px ${SAFE_BOTTOM}`, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled && tone === "grey" ? 0.45 : 1, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
{!right && g}
<span>{label}</span>
{small !== undefined && <small style={{ fontFamily: MONO, fontWeight: 500, fontSize: 13, letterSpacing: 0, textTransform: "none" }}>{small}</small>}
{right && g}
</button>
);
}
// ---------- tab bar
export type MTab = "today" | "work" | "scan" | "stock" | "people";
const TAB_ROOTS: [MTab, string[]][] = [
["work", ["/m/work", "/m/request", "/m/receive", "/m/round"]],
["scan", ["/m/scan"]],
["stock", ["/m/stock", "/m/line", "/m/count", "/m/reorder", "/m/variance", "/m/catalogue"]],
["people", ["/m/people", "/m/person"]],
];
function tabOf(path: string): MTab | undefined {
if (path === "/m" || path === "/m/") return "today";
for (const [t, roots] of TAB_ROOTS) if (roots.some((r) => path === r || path.startsWith(r + "/"))) return t;
return undefined;
}
/** The five-tab bar, drawn only on Today, Work, Scan, Stock and People. */
export function MTabs({ active, workBadge }: { active?: MTab; workBadge?: number }) {
if (workBadge === undefined) return <MTabsCounted active={active} />;
return <MTabsView active={active} workBadge={workBadge} />;
}
function MTabsCounted({ active }: { active?: MTab }) {
const w = useWorkCount();
return <MTabsView active={active} workBadge={w.total} />;
}
function MTabsView({ active, workBadge }: { active?: MTab; workBadge: number }) {
const path = usePathname() || "";
const on = active ?? tabOf(path);
const tab = (t: MTab, href: string, label: string, icon: React.ReactNode, badge?: number) => {
const cur = on === t;
return (
<Link key={t} href={href} aria-current={cur ? "page" : undefined} aria-label={badge ? `${label}, ${badge} open` : undefined}
style={{ position: "relative", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 3, fontSize: 12, fontWeight: 700, color: cur ? INK : MUTED, textDecoration: "none", minHeight: 66 }}>
{cur && <span aria-hidden="true" style={{ position: "absolute", top: -2, left: "22%", right: "22%", height: 4, background: ACCENT }} />}
{icon}
<span>{label}</span>
{!!badge && (
<span aria-hidden="true" style={{ position: "absolute", top: 7, left: "calc(50% + 6px)", background: ACCENT, color: WHITE, fontFamily: MONO, fontSize: 11, fontWeight: 800, minWidth: 18, height: 18, padding: "0 5px", display: "grid", placeItems: "center" }}>{badge}</span>
)}
</Link>
);
};
return (
<nav aria-label="Main" style={{ position: "relative", zIndex: 2, flex: "0 0 auto", height: "calc(66px + env(safe-area-inset-bottom, 0px))", paddingBottom: "env(safe-area-inset-bottom, 0px)", background: WHITE, borderTop: "2px solid " + INK, display: "grid", gridTemplateColumns: "repeat(5, 1fr)", alignItems: "stretch" }}>
{tab("today", "/m", "Today", <IconToday />)}
{tab("work", "/m/work", "Work", <IconWork />, workBadge)}
<Link href="/m/scan" aria-current={on === "scan" ? "page" : undefined}
style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 3, fontSize: 12, textDecoration: "none", color: WHITE, minHeight: 66 }}>
<span style={{ width: 62, height: 62, background: ACCENT, display: "grid", placeItems: "center", marginTop: -22, border: "3px solid " + WHITE, boxShadow: "0 0 0 2px " + INK, boxSizing: "border-box" }}><IconScan size={30} /></span>
<b style={{ color: INK, fontWeight: 800 }}>Scan</b>
</Link>
{tab("stock", "/m/stock", "Stock", <IconStock />)}
{tab("people", "/m/people", "People", <IconPeople />)}
</nav>
);
}
/** @deprecated The old four-tab bar. Now the five-tab bar; removed at integration. */
export const MNav = MTabs;
// ---------- lists
/** Section heading: 12px/800 uppercase with a 2px rule, count or note at the right in mono. */
export function MSection({ label, right, flush }: { label: string; right?: React.ReactNode; /** No top margin (straight under an MHead). */ flush?: boolean }) {
return (
<div className="tcx-sec" style={{ ...(flush ? { marginTop: 0 } : null), display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 8, paddingBottom: 6, borderBottom: "2px solid var(--tcx-sec-rule, " + INK + ")", fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: "inherit" }}>
<span>{label}</span>
{right !== undefined && <span style={{ color: "var(--tcx-sec-sub, " + MUTED + ")", fontFamily: MONO, fontWeight: 500, letterSpacing: 0, textTransform: "none" }}>{right}</span>}
</div>
);
}
/* Can a tap on a link to the marketing site actually get out of here? In a browser, yes. Inside the
* Android shell only a browser plugin can do it, and asking the plugin registry is the only honest
* way to find out. */
function browserPlugin(): { open?: (o: { url: string }) => Promise<unknown> } | null {
if (typeof window === "undefined") return null;
const cap = (window as unknown as {
Capacitor?: { isNativePlatform?: () => boolean; Plugins?: { Browser?: { open?: (o: { url: string }) => Promise<unknown> } } };
}).Capacitor;
if (!cap?.isNativePlatform?.()) return null;
return cap.Plugins?.Browser ?? null;
}
type Opens = "tab" | "browser" | "inline";
function whereItOpens(): Opens {
if (typeof window === "undefined") return "tab";
const cap = (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
if (!cap?.isNativePlatform?.()) return "tab";
return browserPlugin()?.open ? "browser" : "inline";
}
function handedToTheBrowser(url: string): boolean {
const browser = browserPlugin();
if (!browser?.open) return false;
browser.open({ url }).catch(() => { window.location.href = url; });
return true;
}
export type Mark = "ink" | "accent" | "mute" | "ok" | "none";
const markColour = (m: Mark) => (m === "accent" ? ACCENT : m === "mute" ? DIVIDER : m === "ok" ? OK : INK);
function rowBody(bar: React.ReactNode, title: React.ReactNode, sub: React.ReactNode, right: React.ReactNode, note?: string, chev?: boolean) {
return (
<>
{bar}
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 15, fontWeight: 700 }}>{title}</span>
{sub !== undefined && sub !== "" && sub !== null && <span style={{ display: "block", fontSize: 13, color: MUTED, marginTop: 1 }}>{sub}</span>}
{note && <span style={{ display: "block", fontSize: 12, fontWeight: 600, color: "var(--color-neutral-700)", marginTop: 4 }}>{note}</span>}
</span>
{right !== undefined && right !== null && <span style={{ flex: "0 0 auto", textAlign: "right", fontFamily: MONO, fontSize: 14, fontWeight: 600, whiteSpace: "nowrap" }}>{right}</span>}
{chev && <span aria-hidden="true" style={{ color: MUTED, fontSize: 18, flex: "0 0 auto" }}></span>}
</>
);
}
/** A list row (`dense`: 48px, for short check lists). `mark` is the 4px status stripe at the left; `active` is the selected row (white,
* accent edge); `attention` lifts the row to white; `chev` says it opens something. */
export function MRow({ title, sub, right, mark = "none", attention, active, chev, onClick, href, external, disabled, dense }: {
title: React.ReactNode; sub?: React.ReactNode; right?: React.ReactNode; mark?: Mark; attention?: boolean; active?: boolean; chev?: boolean; dense?: boolean;
onClick?: () => void; href?: string; external?: boolean; disabled?: boolean;
}) {
const bar = mark === "none" ? null : (
<span aria-hidden="true" style={{ width: 4, alignSelf: "stretch", minHeight: 34, flex: "0 0 4px", background: markColour(mark), visibility: active ? "hidden" : undefined }} />
);
const body = rowBody(bar, title, sub, right, undefined, chev);
const st: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 12, width: "100%", minHeight: dense ? 48 : 62, padding: dense ? "6px 0" : "9px 0",
background: active || attention ? WHITE : "none", boxShadow: active ? "inset 4px 0 0 " + ACCENT : undefined,
border: "none", borderBottom: "1px solid " + DIVIDER,
color: "inherit", textAlign: "left", textDecoration: "none", font: "inherit", cursor: onClick || href ? "pointer" : "default", opacity: disabled ? 0.5 : 1,
};
if (href && external && !disabled) return <MExternal href={href} st={st} bar={bar} title={title} sub={sub} right={right} />;
if (href && !disabled) return <Link href={href} style={st}>{body}</Link>;
if (onClick) return <button type="button" onClick={onClick} disabled={disabled} aria-pressed={active === undefined ? undefined : active} style={st}>{body}</button>;
return <div style={st}>{body}</div>;
}
/* A row pointing at the marketing site (privacy, terms, deleting an account). In a browser a new
* tab; in the shell, Chrome through the Browser plugin, or the page itself with a note on how to
* come back when there is no plugin. Settled after mount. */
function MExternal({ href, st, bar, title, sub, right }: {
href: string; st: React.CSSProperties; bar: React.ReactNode; title: React.ReactNode; sub: React.ReactNode; right: React.ReactNode;
}) {
const [opens, setOpens] = useState<Opens>("tab");
useEffect(() => { setOpens(whereItOpens()); }, []);
if (opens === "inline") {
return <a href={href} style={st}>{rowBody(bar, title, sub, right, "Opens here in ThreadCount — the back button brings you back")}</a>;
}
return (
<a href={href} target="_blank" rel="noopener noreferrer" style={st}
onClick={(e) => { if (handedToTheBrowser(href)) e.preventDefault(); }}>
{rowBody(bar, title, sub, right, opens === "browser" ? "Opens in your browser" : "Opens in a new tab")}
</a>
);
}
/** A link to the marketing site inside a sentence, with the same three behaviours as the row above. */
export function MExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
const [opens, setOpens] = useState<Opens>("tab");
useEffect(() => { setOpens(whereItOpens()); }, []);
const st: React.CSSProperties = { color: "inherit", fontWeight: 800, textDecoration: "underline", textUnderlineOffset: 3 };
if (opens === "inline") return <a href={href} style={st}>{children}</a>;
return (
<a href={href} target="_blank" rel="noopener noreferrer" style={st}
onClick={(e) => { if (handedToTheBrowser(href)) e.preventDefault(); }}>
{children}
</a>
);
}
/** The ink panel (old screens). */
export function MPanel({ kicker, kickerRight, children, pad = 16 }: { kicker?: string; kickerRight?: React.ReactNode; children: React.ReactNode; pad?: number }) {
return (
<section style={{ background: INK, color: GROUND, padding: pad }}>
{(kicker || kickerRight) && (
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 10 }}>
<span style={{ flex: 1, fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY }}>{kicker}</span>
{kickerRight}
</div>
)}
{children}
</section>
);
}
/** A link inside an ink panel (old screens). */
export function MInkLink({ label, onClick, href }: { label: string; onClick?: () => void; href?: string }) {
const st: React.CSSProperties = { background: "none", border: 0, padding: 0, color: "#fff", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase", borderBottom: "2px solid " + ACCENT, paddingBottom: 2, cursor: "pointer", textDecoration: "none" };
return href ? <Link href={href} style={st}>{label}</Link> : <button type="button" onClick={onClick} style={st}>{label}</button>;
}
/** Counted / expected / delta (old counting screen). */
export function MFigures({ counted, expected, unit = "COUNTED" }: { counted: number; expected: number; unit?: string }) {
const d = counted - expected;
return <MFigRow figs={[{ label: unit, n: counted }, { label: "Expected", n: expected }, { label: "Gap", n: d === 0 ? 0 : d > 0 ? `+${d}` : `${-d}`, tone: d === 0 ? "ok" : "accent" }]} />;
}
// ---------- odds and ends
/** Nothing to show: a bold line and at most one short line under it. */
export function MEmpty({ title, sub, action }: { title: string; sub?: string; action?: React.ReactNode }) {
return (
<div style={{ padding: "28px 0", textAlign: "left" }}>
<div style={{ fontSize: 17, fontWeight: 700 }}>{title}</div>
{sub && <div style={{ fontSize: 14, color: MUTED, marginTop: 2 }}>{sub}</div>}
{action && <div style={{ marginTop: 14 }}>{action}</div>}
</div>
);
}
export function MNote({ children, tone = "ink" }: { children: React.ReactNode; tone?: "ink" | "warn" }) {
return (
<div style={{ margin: 16, padding: 16, background: tone === "ink" ? INK : "#fff", color: tone === "ink" ? GROUND : INK, borderLeft: tone === "warn" ? "4px solid " + ACCENT : undefined, fontSize: 13.5, lineHeight: 1.6 }}>
{children}
</div>
);
}
/** Something went wrong, said plainly, full-bleed under the rule. */
export function MError({ msg, onDismiss }: { msg: string; onDismiss?: () => void }) {
if (!msg) return null;
return (
<div role="alert" style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "12px 16px", background: ACCENT, color: "#fff", fontSize: 14, lineHeight: 1.45, fontWeight: 700 }}>
<span style={{ flex: 1 }}>{msg}</span>
{onDismiss && <button type="button" onClick={onDismiss} aria-label="Dismiss" style={{ width: 44, height: 44, margin: "-12px -12px -12px 0", border: 0, background: "none", color: "#fff", padding: 0, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "0 0 44px" }}><IconX size={18} /></button>}
</div>
);
}
const chipStyle = (on: boolean, off: boolean, warn = false): React.CSSProperties => ({
minHeight: 44, minWidth: 48, padding: "0 12px", border: "2px solid " + (off ? DIVIDER : on && warn ? ACCENT : INK),
background: on ? (warn ? ACCENT : INK) : "transparent", color: off ? OFF_GREY : on ? (warn ? WHITE : GROUND) : INK,
fontFamily: "inherit", fontSize: 14, fontWeight: 700, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6,
cursor: off ? "not-allowed" : "pointer", borderRadius: 0,
});
const chipSmall = (on: boolean): React.CSSProperties => ({ fontFamily: MONO, fontSize: 11, fontWeight: 500, color: on ? OFF_GREY : MUTED });
/** Size chips; the selected one inverts. `counts` adds how many are on the shelf. */
export function MChips({ sizes, value, onPick, disabled, counts }: { sizes: string[]; value: number; onPick: (i: number) => void; disabled?: (i: number) => boolean; counts?: number[] }) {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
{sizes.map((sz, i) => {
const off = !!disabled?.(i);
const on = i === value;
const n = counts?.[i];
return (
<button type="button" key={i} onClick={() => onPick(i)} disabled={off} aria-pressed={on} aria-label={n === undefined ? undefined : `${sz}, ${n} on the shelf`} style={chipStyle(on, off)}>
{sz}{n !== undefined && <small style={chipSmall(on)}>{n}</small>}
</button>
);
})}
</div>
);
}
/** Chips with optional small counts. `tone="warn"` turns the chosen chip accent (reasons). */
export function MChipRow<V extends string>({ options, value, onPick, tone = "ink", label, disabled, grid }: {
options: { value: V; label: string; n?: number }[]; value: V | null; onPick: (v: V) => void;
tone?: "ink" | "warn"; label: string; disabled?: (v: V) => boolean;
/** Equal columns instead of wrapping (the four condition chips). */
grid?: number;
}) {
return (
<div role="group" aria-label={label} style={grid ? { display: "grid", gridTemplateColumns: `repeat(${grid}, 1fr)`, gap: 6, marginTop: 8 } : { display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
{options.map((o) => {
const on = value === o.value;
const off = !!disabled?.(o.value);
return (
<button type="button" key={o.value} onClick={() => onPick(o.value)} disabled={off} aria-pressed={on}
aria-label={o.n === undefined ? undefined : `${o.label}, ${o.n} on the shelf`}
style={{ ...chipStyle(on, off, tone === "warn"), ...(grid ? { minWidth: 0, padding: "0 4px", fontSize: 13 } : null) }}>
{o.label}{o.n !== undefined && <small style={chipSmall(on)}>{o.n}</small>}
</button>
);
})}
</div>
);
}
/** The fixed reasons for a flagged line or a count gap. Tapping the chosen chip clears it. */
export function MReasonChips({ reasons, value, onPick, label }: { reasons: readonly string[]; value: string | null; onPick: (r: string | null) => void; label: string }) {
return (
<MChipRow tone="warn" label={label} value={value} options={reasons.map((r) => ({ value: r, label: r }))}
onPick={(r) => onPick(r === value ? null : r)} />
);
}
/** n + with 44px buttons. `label` names what is counted for screen readers. */
export function MStepper({ n, onChange, min = 0, max = 999, label = "" }: { n: number; onChange: (v: number) => void; min?: number; max?: number; label?: string }) {
const b = (off: boolean): React.CSSProperties => ({ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 700, lineHeight: 1, cursor: off ? "not-allowed" : "pointer", opacity: off ? 0.35 : 1, padding: 0, fontFamily: "inherit" });
const what = label ? ` ${label}` : "";
return (
<div style={{ display: "flex", alignItems: "stretch", height: 44, flex: "none" }}>
<button type="button" style={b(n <= min)} onClick={() => onChange(Math.max(min, n - 1))} disabled={n <= min} aria-label={`One fewer${what}`}></button>
<b aria-live="polite" aria-label={label ? `${n} ${label}` : undefined} style={{ minWidth: 44, background: INK, color: GROUND, display: "grid", placeItems: "center", fontFamily: MONO, fontSize: 16, padding: "0 6px" }}>{n}</b>
<button type="button" style={b(n >= max)} onClick={() => onChange(Math.min(max, n + 1))} disabled={n >= max} aria-label={`One more${what}`}>+</button>
</div>
);
}
export function MField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label style={{ display: "block", padding: "12px 0" }}>
<span style={{ display: "block", fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: MUTED, marginBottom: 6 }}>{label}</span>
{children}
</label>
);
}
/** 52px field, 2px ink border, white. Add `paddingLeft: 44` when an icon sits in it. */
export const inputStyle: React.CSSProperties = {
width: "100%", minHeight: 52, padding: "0 14px", border: "2px solid " + INK, background: "#fff", color: INK,
fontSize: 16, fontFamily: "inherit", borderRadius: 0, boxSizing: "border-box", // 16px keeps Android from zooming on focus
};
/** Numerals that line up in a column (old screens). */
export function MNum({ a, b: bb, tone }: { a: number | string; b?: number | string; tone?: "accent" | "mute" }) {
return (
<span style={{ fontFamily: MONO, fontWeight: 600, fontSize: 15, fontVariantNumeric: "tabular-nums", color: tone === "accent" ? AC7 : tone === "mute" ? "var(--color-neutral-500)" : INK }}>
{a}{bb !== undefined && <span style={{ color: "var(--color-neutral-700)" }}>/{bb}</span>}
</span>
);
}
// ---------- redesign components
/** Kicker line: 12px/800 uppercase, muted. */
export function MKick({ children, mono }: { children: React.ReactNode; mono?: boolean }) {
return (
<div style={mono
? { fontFamily: MONO, fontSize: 12, fontWeight: 500, color: MUTED }
: { fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: "var(--tcx-kick, " + MUTED + ")" }}>
{children}
</div>
);
}
/** The ink person header. Bleeds to the body edges (use inside MBody pad). */
export function MHead({ name, meta, children }: { name: string; meta: string; children?: React.ReactNode }) {
return (
<section style={{ background: INK, color: GROUND, margin: "-16px -16px 14px", padding: "14px 16px 16px" }}>
<h2 style={{ fontSize: 24, fontWeight: 900, letterSpacing: "-0.01em", lineHeight: 1.1, margin: 0 }}>{name}</h2>
{meta && <div style={{ fontSize: 13, color: "#d6d3d2", marginTop: 3 }}>{meta}</div>}
{children}
</section>
);
}
/** Held-against-the-cap meters: held in ground, what is being added in accent after it. */
export function MMeterPair({ items }: { items: { label: string; held: number; adding: number; cap: number }[] }) {
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginTop: 14 }}>
{items.map((m) => {
const total = m.held + m.adding;
const cap = Math.max(1, m.cap);
const heldPct = Math.min(100, (m.held / cap) * 100);
const addPct = Math.max(0, Math.min(100 - heldPct, (m.adding / cap) * 100));
return (
<div key={m.label} role="meter" aria-valuenow={total} aria-valuemin={0} aria-valuemax={m.cap} aria-label={`${m.label} ${total} of ${m.cap}`}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: "#d6d3d2" }}>
<span>{m.label}</span>
<b style={{ fontFamily: MONO, fontSize: 14, letterSpacing: 0, color: total > m.cap ? AC3 : GROUND }}>{total}/{m.cap}</b>
</div>
<div style={{ height: 8, background: DARK_RULE, marginTop: 5, position: "relative", overflow: "hidden" }}>
<i style={{ position: "absolute", inset: "0 auto 0 0", width: `${heldPct}%`, background: GROUND }} />
<em style={{ position: "absolute", top: 0, bottom: 0, left: `${heldPct}%`, width: `${addPct}%`, background: ACCENT }} />
</div>
</div>
);
})}
</div>
);
}
/** One line under the meters ("Manager approval · 1 set left"). */
export function MHeadRow({ label, value }: { label: string; value: string }) {
return (
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 13, borderTop: "1px solid " + DARK_RULE, paddingTop: 10, color: "#d6d3d2" }}>
<span>{label}</span>
<b style={{ color: GROUND, fontFamily: MONO }}>{value}</b>
</div>
);
}
/** Segmented control. */
export function MSeg<K extends string>({ value, options, onPick, label }: { value: K; options: { key: K; label: string; n?: number }[]; onPick: (k: K) => void; label: string }) {
return (
<div role="tablist" aria-label={label} style={{ display: "flex", border: "2px solid " + INK, marginBottom: 6 }}>
{options.map((o, i) => {
const on = o.key === value;
return (
<button type="button" key={o.key} role="tab" aria-selected={on} onClick={() => onPick(o.key)}
style={{ flex: 1, minHeight: 44, border: 0, borderRight: i === options.length - 1 ? 0 : "2px solid " + INK, background: on ? INK : "transparent", color: on ? GROUND : INK, fontFamily: "inherit", fontSize: 13, fontWeight: 800, letterSpacing: "0.03em", textTransform: "uppercase", padding: "0 4px", whiteSpace: "nowrap", cursor: "pointer" }}>
{o.label}{o.n !== undefined && <span style={{ fontFamily: MONO, fontWeight: 500, marginLeft: 4 }}>{o.n}</span>}
</button>
);
})}
</div>
);
}
/** A garment in their kit: size button, on-shelf count, and a big plus. The size chips go in children. */
export function MKitCard({ title, size, onShelf, onSize, sizeOpen, onAdd, addDisabled, addLabel, children }: {
title: string; size: string | null; onShelf: number | null; onSize: () => void; sizeOpen: boolean;
onAdd: () => void; addDisabled: boolean; addLabel: string; children?: React.ReactNode;
}) {
return (
<>
<div style={{ display: "flex", alignItems: "center", gap: 12, border: "2px solid " + INK, background: WHITE, padding: "10px 10px 10px 14px", marginTop: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontWeight: 800 }}>{title}</span>
<div style={{ display: "flex", gap: 10, alignItems: "center", marginTop: 4, fontSize: 13, color: MUTED, flexWrap: "wrap" }}>
<button type="button" onClick={onSize} aria-expanded={sizeOpen}
style={{ border: 0, background: "var(--color-surface, #eae9e9)", color: INK, fontFamily: "inherit", fontWeight: 800, fontSize: 13, minHeight: 44, padding: "0 10px", display: "inline-flex", gap: 6, alignItems: "center", cursor: "pointer" }}>
{size === null ? "Pick a size" : `Size ${size}`}<span aria-hidden="true"></span>
</button>
{onShelf !== null && <span style={{ fontFamily: MONO }}>{onShelf} on shelf</span>}
</div>
</div>
<button type="button" onClick={onAdd} disabled={addDisabled} aria-label={addLabel}
style={{ width: 56, height: 56, border: 0, background: addDisabled ? DIVIDER : INK, color: GROUND, fontSize: 30, fontWeight: 600, lineHeight: 1, flex: "none", cursor: addDisabled ? "not-allowed" : "pointer", fontFamily: "inherit" }}>+</button>
</div>
{sizeOpen && children && <div style={{ border: "2px solid " + INK, borderTop: 0, padding: "4px 12px 12px", background: WHITE }}>{children}</div>}
</>
);
}
/** A basket line. `flag` draws the accent stripe and says why the line needs a reason. */
export function MLine({ title, size, flag, right, children }: { title: string; size?: string; flag?: string; right?: React.ReactNode; children?: React.ReactNode }) {
return (
<div style={{ borderBottom: "1px solid " + DIVIDER, padding: "10px 0", ...(flag ? { background: `linear-gradient(90deg, ${ACCENT} 0 4px, transparent 4px)`, paddingLeft: 12 } : null) }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<b style={{ fontWeight: 700 }}>{title}</b>{size !== undefined && <> <span style={{ fontFamily: MONO }}>{size}</span></>}
{flag && <div style={{ fontSize: 13, fontWeight: 800, color: AC7, marginTop: 2 }}>{flag}</div>}
</div>
{right}
</div>
{children}
</div>
);
}
/** Bottom sheet. Backdrop tap and Escape close it; focus moves in and returns on close. */
export function MSheet({ open, onClose, labelId, children, bar }: { open: boolean; onClose: () => void; labelId: string; children: React.ReactNode; bar?: React.ReactNode }) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const back = document.activeElement as HTMLElement | null;
ref.current?.focus();
const key = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", key);
return () => { document.removeEventListener("keydown", key); back?.focus?.(); };
}, [open, onClose]);
if (!open) return null;
return (
<div className="tcx-scanui" onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 80, background: "rgba(32,30,29,.5)", display: "flex", alignItems: "flex-end", justifyContent: "center" }}>
<div ref={ref} role="dialog" aria-modal="true" aria-labelledby={labelId} tabIndex={-1} onClick={(e) => e.stopPropagation()}
style={{ ...NOT_DOCKED, background: GROUND, color: INK, width: "100%", maxWidth: 560, borderTop: "4px solid " + ACCENT, padding: "16px 16px 0", outline: "none" }}>
{children}
{bar && <div style={{ margin: "16px -16px 0", paddingBottom: "env(safe-area-inset-bottom, 0px)", background: GROUND }}>{bar}</div>}
</div>
</div>
);
}
/** Finish screen head. The top bar "Done" is the h1; this is an h2. */
export function MDone({ head, sub, children }: { head: string; sub: string; children?: React.ReactNode }) {
return (
<div>
<div aria-hidden="true" style={{ width: 84, height: 84, background: ACCENT, display: "grid", placeItems: "center", margin: "18px 0 16px" }}><TickMark /></div>
<h2 style={{ fontSize: 30, fontWeight: 900, lineHeight: 1.05, letterSpacing: "-0.015em", margin: 0 }}>{head}</h2>
<div style={{ color: MUTED, marginTop: 6 }}>{sub}</div>
{children}
</div>
);
}
/** "Shelf now" on a Done screen: what is left of each variant that moved. */
export function MShelfNow({ lines }: { lines: { key: string; name: string; onHand: number; par: number; onOrder: string }[] }) {
if (!lines.length) return null;
return (
<>
<MSection label="Shelf now" />
{lines.map((l) => (
<MRow key={l.key} mark={l.onHand < l.par ? "accent" : "ok"} title={l.name}
sub={l.onHand < l.par ? `${l.par - l.onHand} below par${l.onOrder ? ` · ${l.onOrder}` : ""}` : "At par"}
right={`${l.onHand}/${l.par}`} />
))}
</>
);
}
/** Three figures in a white box (Today's "Your day"). */
export function MDay({ figs }: { figs: { n: number | string; label: string }[] }) {
return (
<div style={{ display: "grid", gridTemplateColumns: `repeat(${Math.max(1, figs.length)}, 1fr)`, border: "2px solid " + INK, background: WHITE, marginTop: 8 }}>
{figs.map((f, i) => (
<div key={f.label} style={{ padding: "10px 12px", borderRight: i === figs.length - 1 ? 0 : "1px solid " + DIVIDER }}>
<b style={{ display: "block", fontSize: 30, fontWeight: 900, fontVariantNumeric: "tabular-nums", lineHeight: 1.1 }}>{f.n}</b>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: MUTED }}>{f.label}</span>
</div>
))}
</div>
);
}
/** Counted / Expected / Gap on the ink counting panel. */
export function MFigRow({ figs }: { figs: { label: string; n: number | string; tone?: "accent" | "ok" }[] }) {
return (
<div style={{ display: "grid", gridTemplateColumns: `repeat(${Math.max(1, figs.length)}, 1fr)`, marginTop: 12, borderTop: "1px solid " + DARK_RULE, paddingTop: 10 }}>
{figs.map((f) => (
<div key={f.label} style={{ display: "grid" }}>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY }}>{f.label}</span>
<b style={{ fontSize: 34, fontWeight: 900, fontVariantNumeric: "tabular-nums", lineHeight: 1.1, color: f.tone === "accent" ? AC3 : f.tone === "ok" ? "#9fd8b8" : undefined }}>{f.n}</b>
</div>
))}
</div>
);
}
/** A to-do row on Today: the number block, what to do, and where it goes. */
export function MTodo({ n, accent, title, sub, href }: { n: string | number; accent?: boolean; title: string; sub: string; href: string }) {
return (
<Link href={href} style={{ display: "flex", gap: 12, alignItems: "center", width: "100%", minHeight: 68, borderBottom: "1px solid " + DIVIDER, padding: "10px 0", textDecoration: "none", color: "inherit" }}>
<span style={{ minWidth: 52, height: 52, padding: "0 6px", display: "grid", placeItems: "center", fontFamily: MONO, fontSize: 22, fontWeight: 600, background: accent ? ACCENT : INK, color: accent ? WHITE : GROUND, flex: "none", boxSizing: "border-box" }}>{n}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontWeight: 800 }}>{title}</span>
{sub && <span style={{ display: "block", fontSize: 13, color: MUTED }}>{sub}</span>}
</span>
<span aria-hidden="true" style={{ color: MUTED, fontSize: 18 }}></span>
</Link>
);
}
/** A small status label. */
export function MPill({ tone = "ink", children, mono }: { tone?: "ink" | "accent" | "ok" | "mute"; children: React.ReactNode; mono?: boolean }) {
const [bg, fg] = tone === "accent" ? [ACCENT, WHITE] : tone === "ok" ? [OK, WHITE] : tone === "mute" ? [DIVIDER, INK] : [INK, GROUND];
return (
<span style={{ display: "inline-flex", alignItems: "center", minHeight: 26, padding: "0 8px", fontSize: 12, fontWeight: 800, letterSpacing: mono ? 0 : "0.05em", textTransform: mono ? "none" : "uppercase", fontFamily: mono ? MONO : "var(--font-body)", background: bg, color: fg, whiteSpace: "nowrap" }}>{children}</span>
);
}
/** A setting you switch on or off. `dark` is the Hands-free switch on the ink counting panel. */
export function MSwitchRow({ title, sub, on, onToggle, disabled, tone = "ink", dark }: { title: string; sub?: string; on: boolean; onToggle: () => void; disabled?: boolean; tone?: "ink" | "accent"; dark?: boolean }) {
const onBg = tone === "accent" || dark ? ACCENT : INK;
const tog = (
<span aria-hidden="true" style={{ width: 52, height: 30, background: on ? onBg : dark ? DARK_EDGE : DIVIDER, position: "relative", flex: "none" }}>
<span style={{ position: "absolute", top: 3, left: on ? 25 : 3, width: 24, height: 24, background: WHITE, transition: "left .15s" }} />
</span>
);
const st: React.CSSProperties = dark
? { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginTop: 12, border: 0, background: "#2d2b2b", width: "100%", minHeight: 48, padding: "0 12px", color: GROUND, fontFamily: "inherit", fontWeight: 800, fontSize: 13, letterSpacing: "0.05em", textTransform: "uppercase", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, textAlign: "left" }
: { display: "flex", alignItems: "center", gap: 12, minHeight: 60, border: 0, borderBottom: "1px solid " + DIVIDER, width: "100%", background: "none", padding: 0, color: "inherit", fontFamily: "inherit", fontSize: 15, textAlign: "left", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1 };
return (
<button type="button" role="switch" aria-checked={on} onClick={disabled ? undefined : onToggle} aria-disabled={disabled || undefined} style={st}>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontWeight: dark ? 800 : 700 }}>{title}</span>
{sub && <span style={{ display: "block", fontSize: 13, color: MUTED, fontWeight: 400, textTransform: "none", letterSpacing: 0 }}>{sub}</span>}
</span>
{tog}
</button>
);
}
/** A pick-list row: the tick box is the button. */
export function MPickRow({ done, onToggle, title, sub, right, children, tickLabel }: { done: boolean; onToggle: () => void; title: string; sub?: string; right?: React.ReactNode; children?: React.ReactNode; tickLabel?: string }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 64, borderBottom: "1px solid " + DIVIDER, width: "100%", padding: "8px 0" }}>
<button type="button" onClick={onToggle} aria-pressed={done} aria-label={tickLabel || `Picked ${title}`}
style={{ padding: 7, margin: -7, border: 0, background: "none", cursor: "pointer", flex: "none" }}>
<span style={{ width: 30, height: 30, border: "2px solid " + INK, display: "grid", placeItems: "center", background: done ? INK : WHITE, color: GROUND, boxSizing: "border-box" }}>
{done && <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3.2} aria-hidden="true"><path d="M4 12.5l5 5L20 6.5" /></svg>}
</span>
</button>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontWeight: 700 }}>{title}</span>
{sub && <span style={{ display: "block", fontSize: 13, color: MUTED }}>{sub}</span>}
</span>
{right !== undefined && <span style={{ fontFamily: MONO, fontSize: 14, fontWeight: 600, whiteSpace: "nowrap" }}>{right}</span>}
{children}
</div>
);
}
/** A card with a strip of three action chips (Pickups). */
export function MCard({ title, sub, pill, actions }: { title: string; sub: string; pill?: React.ReactNode; actions: React.ReactNode }) {
return (
<div style={{ border: "2px solid " + INK, background: WHITE, padding: "12px 14px", marginTop: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "flex-start" }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 800 }}>{title}</div>
{sub && <div style={{ fontSize: 13, color: MUTED }}>{sub}</div>}
</div>
{pill}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 6, marginTop: 10 }}>{actions}</div>
</div>
);
}
/** A chip for MCard's action strip (a button, or a link such as tel:). */
export function MCardChip({ label, onClick, href, on, disabled }: { label: string; onClick?: () => void; href?: string; on?: boolean; disabled?: boolean }) {
const st: React.CSSProperties = { ...chipStyle(!!on, !!disabled), minWidth: 0, fontSize: 13, padding: "0 6px", textDecoration: "none" };
if (href && !disabled) return <a href={href} style={st}>{label}</a>;
return <button type="button" onClick={onClick} disabled={disabled} aria-pressed={on === undefined ? undefined : on} style={st}>{label}</button>;
}
/** Big button: 52px (44 when small), 2px ink, optional icon at the left. */
export function MButton({ label, onClick, href, icon, tone = "line", disabled, small }: { label: string; onClick?: () => void; href?: string; icon?: "scan" | "print" | "plus"; tone?: "line" | "ink"; disabled?: boolean; small?: boolean }) {
const ink = tone === "ink";
const st: React.CSSProperties = {
minHeight: small ? 44 : 52, width: "100%", border: "2px solid " + (disabled ? DIVIDER : INK), background: ink ? (disabled ? DIVIDER : INK) : "transparent",
color: disabled ? OFF_GREY : ink ? GROUND : INK, fontFamily: "inherit", fontSize: 14, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase",
display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "0 14px", cursor: disabled ? "not-allowed" : "pointer", textDecoration: "none", boxSizing: "border-box", marginTop: 10,
};
const g = icon === "scan" ? <IconScan /> : icon === "print" ? <IconPrinter /> : icon === "plus" ? <IconPlus /> : null;
if (href && !disabled) return <Link href={href} style={st}>{g}{label}</Link>;
return <button type="button" onClick={onClick} disabled={disabled} style={st}>{g}{label}</button>;
}
/** Search field: leading icon, trailing accent scan button. Never focuses itself on load. */
export function MSearch({ value, onChange, placeholder, label, scanHref, onScan, scanLabel = "Scan a badge" }: { value: string; onChange: (v: string) => void; placeholder: string; label: string; scanHref?: string; onScan?: () => void; scanLabel?: string }) {
const id = useId();
const scanSt: React.CSSProperties = { position: "absolute", right: 4, top: 4, width: 44, height: 44, background: ACCENT, border: 0, display: "grid", placeItems: "center", color: WHITE, cursor: "pointer" };
return (
<div style={{ position: "relative" }}>
<label htmlFor={id} style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" }}>{label}</label>
<span aria-hidden="true" style={{ position: "absolute", left: 12, top: 15, color: MUTED, display: "flex" }}><IconSearch /></span>
<input id={id} type="search" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} autoComplete="off"
style={{ ...inputStyle, paddingLeft: 44, paddingRight: scanHref || onScan ? 56 : 14 }} />
{scanHref ? <Link href={scanHref} aria-label={scanLabel} style={scanSt}><IconScan /></Link>
: onScan ? <button type="button" onClick={onScan} aria-label={scanLabel === "Scan a badge" ? "Scan" : scanLabel} style={scanSt}><IconScan /></button> : null}
</div>
);
}
/** Signature box. The canvas is sized to its CSS box at the device pixel ratio. */
export function MSignature({ name, onReady, onChange }: { name: string; onReady: (api: { clear: () => void; dataUrl: () => string | null }) => void; onChange: (signed: boolean) => void }) {
const ref = useRef<HTMLCanvasElement | null>(null);
const dirty = useRef(false);
const change = useRef(onChange); change.current = onChange;
useEffect(() => {
const el = ref.current; if (!el) return;
const dpr = window.devicePixelRatio || 1;
const r = el.getBoundingClientRect();
el.width = Math.max(1, Math.round(r.width * dpr));
el.height = Math.max(1, Math.round(r.height * dpr));
const ctx = el.getContext("2d")!;
ctx.scale(dpr, dpr);
ctx.lineWidth = 2.6; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = "#201e1d";
let draw = false;
const pos = (ev: PointerEvent) => { const b = el.getBoundingClientRect(); return [ev.clientX - b.left, ev.clientY - b.top] as const; };
const down = (ev: PointerEvent) => {
ev.preventDefault(); draw = true; el.setPointerCapture(ev.pointerId);
const [x, y] = pos(ev); ctx.beginPath(); ctx.moveTo(x, y);
if (!dirty.current) { dirty.current = true; change.current(true); }
};
const move = (ev: PointerEvent) => { if (!draw) return; const [x, y] = pos(ev); ctx.lineTo(x, y); ctx.stroke(); };
const up = () => { draw = false; };
el.addEventListener("pointerdown", down); el.addEventListener("pointermove", move); el.addEventListener("pointerup", up); el.addEventListener("pointercancel", up);
onReady({
clear: () => { ctx.clearRect(0, 0, el.width, el.height); if (dirty.current) { dirty.current = false; change.current(false); } },
dataUrl: () => (dirty.current ? el.toDataURL("image/png") : null),
});
return () => { el.removeEventListener("pointerdown", down); el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); el.removeEventListener("pointercancel", up); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div style={{ position: "relative", border: "2px solid " + INK, background: WHITE, height: 150, marginTop: 8, touchAction: "none" }}>
<canvas ref={ref} role="img" aria-label={`Signature box for ${name}`} style={{ display: "block", width: "100%", height: "100%", touchAction: "none" }} />
<div aria-hidden="true" style={{ position: "absolute", left: 14, right: 14, bottom: 12, borderTop: "1px solid " + DIVIDER, paddingTop: 6, fontSize: 12, color: MUTED, pointerEvents: "none", display: "flex", justifyContent: "space-between", gap: 8 }}>
<span>{name}</span><span>Sign above the line</span>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
/* The ink "Now counting" panel (mockup .panel): garment · size, Counted / Expected / Gap, the
* Hands-free switch, and the inline "Type a count" field when it is open. The same pieces are the
* figure and control of the live camera overlay when hands-free falls back to it. */
import { useEffect, useRef, useState } from "react";
import { GROUND, INK, MFigRow, MKick, MSwitchRow } from "@/components/m";
import { signed } from "@/components/m/count/lines";
const OFF_GREY_TEXT = "#b5b1af"; // mockup .panel .kick
export function CountFigures({ name, counted, expected }: { name: string; counted: number; expected: number }) {
const d = counted - expected;
return (
<>
<div style={{ ["--tcx-kick" as string]: OFF_GREY_TEXT } as React.CSSProperties}><MKick>Now counting</MKick></div>
<div aria-live="polite" style={{ fontSize: 19, fontWeight: 800, marginTop: 2, lineHeight: 1.2 }}>{name}</div>
<MFigRow figs={[
{ label: "Counted", n: counted },
{ label: "Expected", n: expected },
{ label: "Gap", n: signed(d), tone: d === 0 ? "ok" : "accent" },
]} />
</>
);
}
export function HandsFree({ on, onToggle, disabled }: { on: boolean; onToggle: () => void; disabled?: boolean }) {
return <MSwitchRow dark tone="accent" title="Hands-free" on={on} onToggle={onToggle} disabled={disabled} />;
}
/** Inline count field: Enter or Set applies it. Keyed by the caller on the line, so it never shows
* the previous line's figure under the new line's name. */
export function TypeCount({ name, value, onSet }: { name: string; value: number; onSet: (n: number) => void }) {
const [v, setV] = useState(String(value));
const ref = useRef<HTMLInputElement | null>(null);
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
const set = () => { const n = parseInt(v, 10); if (Number.isFinite(n) && n >= 0) onSet(n); };
return (
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
<input ref={ref} type="text" inputMode="numeric" pattern="[0-9]*" aria-label={`Counted for ${name}`} value={v}
onChange={(e) => setV(e.target.value.replace(/[^0-9]/g, "").slice(0, 5))}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); set(); } }}
style={{ flex: 1, minWidth: 0, height: 52, border: "2px solid #57534f", background: "#fff", color: INK, fontFamily: "inherit", fontSize: 16, padding: "0 14px", borderRadius: 0, boxSizing: "border-box" }} />
<button type="button" onClick={set}
style={{ minHeight: 52, padding: "0 18px", border: "2px solid " + GROUND, background: "transparent", color: GROUND, fontFamily: "inherit", fontSize: 14, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Set</button>
</div>
);
}
/** The panel as it sits at the top of the counting body (bleeds over MBody pad). */
export function CountPanel({ children }: { children: React.ReactNode }) {
return <section aria-label="Now counting" style={{ background: INK, color: GROUND, margin: "-16px -16px 12px", padding: "14px 16px 16px" }}>{children}</section>;
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
/* The lines a shelf count lists, shared by the counting screen and Check the gaps.
*
* The two screens have to list exactly the same set: anything countable on one and missing on the
* other is counted on the phone and then dropped at commit, with the tally cleared behind it.
*
* A placed size is countable even with no stock history (a shelf being set up). The unplaced bucket
* needs a test or it would be the whole catalogue: stock history, or a bound barcode (somebody
* scanned that label onto that size, so the garment physically exists). /m/count uses the same
* test through `looseVariants` for its "Not on a shelf yet" row. */
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { UNPLACED, bcBound, label, locMap, locSubtree, locTrail, locUnder, onhand, touched } from "@/lib/compute";
import type { Item, Ledger, Snapshot, Variant } from "@/lib/compute";
export type CountLine = {
key: string; itemId: string; si: number; size: string; item: Item;
expected: number; code: string; where: string;
};
/** Unplaced sizes worth counting: stock history or a bound barcode. */
export function looseVariants(s: Snapshot, L: Ledger, variants: Variant[]): Variant[] {
return variants.filter((v) => !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si)));
}
export function useCountLines(locationId: string) {
const { s } = useSnap();
const { L, variants } = useDerived();
const locs = useMemo(() => locMap(s), [s]);
const lines = useMemo<CountLine[]>(() => {
const picked = locationId === UNPLACED
? looseVariants(s, L, variants)
: (() => { const sub = locSubtree(s, locationId); return variants.filter((v) => sub.has(s.placed[v.key] || "")); })();
return picked.map((v) => ({
key: v.key, itemId: v.itemId, si: v.si, size: v.size, item: v.item,
expected: onhand(s, L, v.key), code: bcBound(s, v.item, v.si), where: locUnder(locs, s.placed[v.key], locationId),
}));
}, [s, L, variants, locationId, locs]);
const locName = locationId === UNPLACED ? "Not on a shelf" : locTrail(locs, locationId, 0) || locs[locationId]?.name || "Location";
return { lines, locName, locs };
}
/** "Scrub top (W)": the garment as a row title; the size follows it. */
export const lineTitle = (l: Pick<CountLine, "item">) => label(l.item);
/** Gap with its sign, using a true minus: "+3", "12", "0". */
export const signed = (d: number) => (d === 0 ? "0" : d > 0 ? `+${d}` : `${-d}`);
/** The reasons a count gap can carry, stored as the stocktake line's reason. A short count gets the
* first list, a count over what was expected the second. */
export const SHORT_REASONS = ["At laundry", "Condemned", "Missing", "Other"] as const;
export const OVER_REASONS = ["Found extra", "Other"] as const;
+235
View File
@@ -0,0 +1,235 @@
"use client";
/* Person Hand back (HANDBACK workstream). One basket for everything a person brings to the window:
* each line is one garment with its condition, and any line can swap for another size. The whole
* list is recorded in one handback.commit, so a return and its swap never land half way. */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { bcParse, key, label, onhand, plOf, staffName } from "@/lib/compute";
import { DIVIDER, INK, MBar, MButton, MChipRow, MEmpty, MONO, MRow, MSection, MUTED, useScanFlash, useToast } from "@/components/m";
import { useHeld, type Held } from "@/components/MPerson";
import { useBasket, type BackLine } from "@/components/MBasket";
import MScan from "@/components/MScan";
import type { DoneProps } from "@/components/SignFlow";
export type PersonTabProps = {
staffId: string;
/** The tab calls it in an effect; the shell renders it docked below MBody (History sets null). */
setBar: (bar: React.ReactNode) => void;
/** The shell swaps the whole screen for <DoneScreen {...d} />. */
onDone: (d: DoneProps) => void;
/** The shell shows MError under the rule. */
onError: (msg: string) => void;
};
type Cond = BackLine["cond"];
const CONDS: { value: Cond; label: string }[] = [
{ value: "Good", label: "Good" }, { value: "Damaged", label: "Damaged" }, { value: "Condemn", label: "Condemn" }, { value: "Lost", label: "Lost" },
];
/** The phone's four words for the record's four return conditions. */
const RECORDED: Record<Cond, "Returned - Good" | "Returned - Damaged" | "Written Off" | "Lost"> = {
Good: "Returned - Good", Damaged: "Returned - Damaged", Condemn: "Written Off", Lost: "Lost",
};
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
let seq = 0;
const newUid = () => `${Date.now().toString(36)}${(seq++).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
// The mockup's .chip and .x; m.tsx keeps its chip style private.
const chipBtn: React.CSSProperties = {
minHeight: 44, minWidth: 48, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
fontFamily: "inherit", fontSize: 14, fontWeight: 700, borderRadius: 0, display: "inline-flex", alignItems: "center",
justifyContent: "center", cursor: "pointer", flex: "none",
};
const xBtn: React.CSSProperties = {
border: 0, background: "none", width: 44, height: 44, fontSize: 22, color: MUTED, padding: 0, cursor: "pointer", flex: "none", fontFamily: "inherit",
};
export default function HandBackTab({ staffId, setBar, onDone, onError }: PersonTabProps) {
const { s, mutate } = useSnap();
const { L, byId } = useDerived();
const toast = useToast();
const flash = useScanFlash();
const basket = useBasket();
const { setBack, clear } = basket;
const st = s.staff.find((x) => x.id === staffId);
const held = useHeld(s, staffId);
const lines = basket.back(staffId);
const [scanning, setScanning] = useState(false);
const [swapOpen, setSwapOpen] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const heldBy = useMemo(() => {
const m: Record<string, Held> = {};
for (const h of held) m[h.key] = h;
return m;
}, [held]);
const usedOf = (k: string) => lines.filter((l) => key(l.itemId, l.si) === k).length;
// The record moved under the basket (a garment taken back elsewhere): drop lines past what they hold.
useEffect(() => {
if (!lines.length) return;
const seen: Record<string, number> = {};
const kept = lines.filter((l) => {
const k = key(l.itemId, l.si);
seen[k] = (seen[k] || 0) + 1;
return seen[k] <= (heldBy[k]?.qty || 0);
});
if (kept.length !== lines.length) setBack(staffId, kept);
}, [heldBy, lines, setBack, staffId]);
/* The server takes new garments back before pre-loved ones, so the first N lines of a variant are
* new (a swap comes off the shelf, a Good one goes back on it) and the rest pre-loved (the pool). */
const preloved = useMemo(() => {
const out: Record<string, boolean> = {};
const seen: Record<string, number> = {};
for (const l of lines) {
const k = key(l.itemId, l.si);
const newUnits = (heldBy[k]?.issues || []).filter((i) => !i.preloved).reduce((t, i) => t + i.qty, 0);
out[l.uid] = (seen[k] || 0) >= newUnits;
seen[k] = (seen[k] || 0) + 1;
}
return out;
}, [lines, heldBy]);
/** What a swap to size `si` can still draw on for this line, after the other lines' swaps and
* whatever the other lines put back in good condition. */
const swapStock = (line: BackLine, si: number) => {
const k = key(line.itemId, si);
const pl = preloved[line.uid];
let n = pl ? plOf(s, k) : onhand(s, L, k);
for (const o of lines) {
if (o.uid === line.uid || preloved[o.uid] !== pl || o.itemId !== line.itemId) continue;
if (o.swapSi === si) n--;
if (o.cond === "Good" && o.si === si) n++;
}
return Math.max(0, n);
};
const add = (h: Held) => {
setBack(staffId, [...lines, { uid: newUid(), itemId: h.itemId, si: h.si, cond: "Good", swapSi: null }]);
};
const tapHeld = (h: Held) => {
if (usedOf(h.key) >= h.qty) { toast(`They only hold ${h.qty}`); return; }
add(h);
};
const onScan = (raw: string) => {
setScanning(false);
const code = raw.trim();
const v = bcParse(s, code);
const h = v ? heldBy[key(v.itemId, v.si)] : undefined;
if (!h) { toast(`${code} isnt something ${st?.first || "they"} holds`); return; }
if (held.every((x) => usedOf(x.key) >= x.qty)) { toast("Everything they hold is already on the list"); return; }
if (usedOf(h.key) >= h.qty) { toast(`They only hold ${h.qty}`); return; }
flash("Garment", `${label(byId[h.itemId])} ${h.size}`, () => add(h));
};
const update = (uid: string, patch: Partial<BackLine>) =>
setBack(staffId, lines.map((l) => (l.uid === uid ? { ...l, ...patch } : l)));
const remove = (uid: string) => {
setBack(staffId, lines.filter((l) => l.uid !== uid));
setSwapOpen(null);
};
const commit = useCallback(async () => {
if (!lines.length || saving) return;
setSaving(true);
onError("");
const sent = lines;
const r = await mutate<{ back: number; swaps: number }>("handback.commit", {
staffId,
lines: sent.map((l) => ({ itemId: l.itemId, si: l.si, cond: RECORDED[l.cond], swapSi: l.swapSi })),
});
setSaving(false);
if (!r.ok) { onError(r.error); return; }
const shelfKeys: string[] = [];
for (const l of sent) {
if (l.cond === "Good" && !preloved[l.uid]) shelfKeys.push(key(l.itemId, l.si));
if (l.swapSi !== null) shelfKeys.push(key(l.itemId, l.swapSi));
}
const n = r.result?.back ?? sent.length;
const swaps = r.result?.swaps ?? sent.filter((l) => l.swapSi !== null).length;
clear("back", staffId);
onDone({
head: `${plural(n, "item")} handed back`,
sub: staffName(st) + (swaps ? ` · ${plural(swaps, "size swap")} issued` : ""),
shelfKeys: [...new Set(shelfKeys)],
next: "scan",
});
}, [lines, saving, onError, mutate, staffId, preloved, clear, onDone, st]);
useEffect(() => {
setBar(saving
? <MBar label="Recording…" disabled small={plural(lines.length, "item")} />
: lines.length
? <MBar label="Record hand back" small={plural(lines.length, "item")} onClick={commit} />
: <MBar label="Record hand back" small="nothing yet" disabled offReason="Scan or tap what they hand back" />);
}, [setBar, saving, lines.length, commit]);
return (
<>
<MButton tone="ink" icon="scan" label="Scan what they hand back" onClick={() => setScanning(true)} />
{lines.length > 0 && (
<>
<MSection label="Handing back" right={plural(lines.length, "item")} />
{lines.map((l) => {
const it = byId[l.itemId];
const name = label(it);
const size = String(it?.sizes[l.si] ?? l.si);
const swapSize = l.swapSi === null ? null : String(it?.sizes[l.swapSi] ?? l.swapSi);
const open = swapOpen === l.uid;
const others = (it?.sizes || []).map((sz, i) => ({ sz: String(sz), i })).filter((x) => x.i !== l.si);
const counts: Record<string, number> = {};
for (const x of others) counts[String(x.i)] = swapStock(l, x.i);
const cur = l.swapSi === null ? null : String(l.swapSi);
return (
// The mockup's .cl: MLine's layout, with the swap note in ink rather than the flag's accent.
<div key={l.uid} style={{ borderBottom: "1px solid " + DIVIDER, padding: "10px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<b style={{ fontWeight: 700 }}>{name}</b> <span style={{ fontFamily: MONO }}>{size}</span>
{swapSize && <div style={{ fontSize: 13, fontWeight: 800, color: INK, marginTop: 2 }}>Swap for {swapSize}</div>}
</div>
<button type="button" style={{ ...chipBtn, opacity: l.cond === "Good" ? 1 : 0.4, cursor: l.cond === "Good" ? "pointer" : "not-allowed" }}
disabled={l.cond !== "Good"} aria-expanded={open} aria-controls={`swap-${l.uid}`}
aria-label={swapSize ? `Size ${swapSize}: change the swap for ${name} ${size}` : `Swap size for ${name} ${size}`}
onClick={() => setSwapOpen(open ? null : l.uid)}>
{swapSize ? `Size ${swapSize}` : "Swap size"}
</button>
<button type="button" style={xBtn} aria-label={`Remove ${name} ${size}`} onClick={() => remove(l.uid)}>×</button>
</div>
<MChipRow grid={4} label={`Condition of ${name} ${size}`} options={CONDS} value={l.cond}
onPick={(c) => {
// Only a garment going back on the shelf swaps; anything else is replaced by an issue.
if (c === "Good") { update(l.uid, { cond: c }); return; }
update(l.uid, { cond: c, swapSi: null });
if (open) setSwapOpen(null);
}} />
{open && l.cond === "Good" && (
<div id={`swap-${l.uid}`}>
<MChipRow label={`Swap ${name} ${size} for`} value={cur}
options={others.map((x) => ({ value: String(x.i), label: x.sz, n: counts[String(x.i)] }))}
disabled={(v) => counts[v] <= 0 && cur !== v}
onPick={(v) => { update(l.uid, { swapSi: cur === v ? null : Number(v) }); setSwapOpen(null); }} />
</div>
)}
</div>
);
})}
</>
)}
<MSection label="Holding now" />
{held.length === 0
? <MEmpty title="Nothing out" />
: held.map((h) => (
<MRow key={h.key} mark="ink" title={`${label(byId[h.itemId])} ${h.size}`} sub="Tap to hand one back" right={`×${h.qty}`}
onClick={() => tapHeld(h)} />
))}
{scanning && <MScan title="Scan what they hand back" onHit={onScan} onClose={() => setScanning(false)} />}
</>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
/* Person History: what was issued, handed back and handed in, grouped per day, newest first; and
* their staff app account, with the admin-only code. */
import { useEffect, useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import { fmtDate, itemMap, label, type IssueRec } from "@/lib/compute";
import { MButton, MEmpty, MONO, MPill, MRow, MSection } from "@/components/m";
import type { PersonTabProps } from "@/components/m/handback/HandBackTab";
type Group = { key: string; date: string; kind: number; verb: string; parts: Record<string, { name: string; qty: number }>; signed: boolean; at: string };
const COND_NOTE: Record<string, string> = { "Returned - Damaged": " (damaged)", "Written Off": " (condemned)", Lost: " (lost)" };
/** "Today", "12 Aug", or "12 Aug 2025" for another year (the mockup's short date). */
function dayLabel(iso: string, today: string): string {
if (!iso || iso.length < 10) return fmtDate(iso);
if (iso.slice(0, 10) === today) return "Today";
const d = new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
if (Number.isNaN(d.getTime())) return fmtDate(iso);
const mon = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getMonth()];
const short = `${d.getDate()} ${mon}`;
return iso.slice(0, 4) === (today || "").slice(0, 4) ? short : `${short} ${iso.slice(0, 4)}`;
}
const lower = (t: string) => t; // catalogue names keep their own casing (acronyms such as RN)
export default function HistoryTab({ staffId, setBar, onError }: PersonTabProps) {
const { s, isAdmin, mutate } = useSnap();
const st = s.staff.find((x) => x.id === staffId);
// Shown once, then gone: the code is a credential and is never in the snapshot.
const [code, setCode] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => { setBar(null); }, [setBar]);
const groups = useMemo(() => {
const byId = itemMap(s);
const m: Record<string, Group> = {};
const put = (date: string, kind: number, verb: string, i: IssueRec, note = "") => {
const gk = `${date}|${kind}`;
const g = (m[gk] ||= { key: gk, date, kind, verb, parts: {}, signed: false, at: "" });
const it = byId[i.itemId];
const pk = `${i.itemId}:${i.si}${note}`;
const p = (g.parts[pk] ||= { name: `${lower(label(it))} ${String(it?.sizes[i.si] ?? i.si)}${note}`, qty: 0 });
p.qty += i.qty;
if (kind === 0) { if (i.receipt) g.signed = true; if ((i.createdAt || "") > g.at) g.at = i.createdAt || ""; }
};
for (const i of s.issues) {
if (i.staffId !== staffId) continue;
put(i.date, 0, "Issued", i);
if (i.returned) put(i.returned.date, 1, "Handed back", i, COND_NOTE[i.returned.cond] || "");
if (i.handedIn) put(i.handedIn, 2, "Handed in", i);
}
return Object.values(m)
.sort((a, b) => b.date.localeCompare(a.date) || a.kind - b.kind)
.slice(0, 25)
.map((g) => ({
key: g.key, date: g.date, signed: g.signed,
title: `${g.verb} ${Object.values(g.parts).map((p) => {
// A condition note sits after the count: "fleece L ×1 (damaged)".
const at = p.name.indexOf(" (");
return at > 0 ? `${p.name.slice(0, at)} ×${p.qty}${p.name.slice(at)}` : `${p.name} ×${p.qty}`;
}).join(", ")}`,
}));
}, [s, staffId]);
if (!st) return null;
const account = st.selfEmail ? `Signed up · ${st.selfEmail}` : st.selfCode ? "Code out, not used" : "No account";
const generate = async () => {
setBusy(true);
const r = await mutate<{ code: string }>("staff.selfCode", { id: st.id });
setBusy(false);
if (!r.ok) { onError(r.error); return; }
setCode(r.result.code);
};
return (
<>
<MSection label="History" />
{groups.length === 0
? <MEmpty title="Nothing recorded yet" />
: groups.map((g) => <MRow key={g.key} mark="mute" title={g.title} sub={dayLabel(g.date, s.today)} right={g.signed ? <MPill tone="ok">Signed</MPill> : undefined} />)}
<MSection label="Staff app" />
<MRow title="Staff app" sub={account} />
{code ? (
<>
<div aria-live="polite" style={{ fontFamily: MONO, fontSize: 26, fontWeight: 600, letterSpacing: "0.06em", marginTop: 14 }}>{code}</div>
<MButton small label="Done" onClick={() => setCode(null)} />
</>
) : isAdmin && !st.selfEmail ? (
<MButton small tone="ink" label={busy ? "Generating…" : st.selfCode ? "New code" : "Generate a code"} disabled={busy} onClick={generate} />
) : null}
</>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
/* Person Issue: their kit in their size, a basket with per-line flags and reasons, and the bar to
* the Sign step. The flags are issueLineFlags(), the same function issue.create checks inside its
* lock, and the cap is capCheck() (the shell draws the meters from the same basket). */
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { OVERRIDE_REASONS, bcParse, ccOf, garmentForGroup, garmentForStyle, isPantItem, isTopItem, issueLineFlags, key, label, money, onhand, sizeIndexOf, splitKey, type Item } from "@/lib/compute";
import { scanReject } from "@/lib/feedback";
import MScan from "@/components/MScan";
import { MBar, MButton, MChipRow, MKitCard, MLine, MONO, MReasonChips, MSection, MStepper, useToast } from "@/components/m";
import { useBasket, type IssueLine } from "@/components/MBasket";
import type { PersonTabProps } from "@/components/m/handback/HandBackTab";
import { plural } from "@/components/m/issue/meta";
const rank = (it: Item) => (isTopItem(it) ? 0 : isPantItem(it) ? 1 : 2);
export default function IssueTab({ staffId, setBar }: PersonTabProps) {
const { s } = useSnap();
const { L, byId } = useDerived();
const router = useRouter();
const toast = useToast();
const basket = useBasket();
const st = s.staff.find((x) => x.id === staffId);
const lines = basket.issue(staffId);
const [pick, setPick] = useState<Record<string, number>>({});
const [open, setOpen] = useState<string | null>(null);
const [scan, setScan] = useState(false);
const shelf = (k: string) => Math.max(0, onhand(s, L, k));
const sizeOf = (it: Item | undefined, si: number) => String(it?.sizes[si] ?? si);
/* Their kit: garments the server would issue them without an override (group and cut) that are a
* top, a pair of pants, or something they have held before. Anything else comes in by scanning. */
const kit = useMemo(() => {
if (!st) return [];
const last: Record<string, { si: number; at: string }> = {};
for (const i of s.issues) {
if (i.staffId !== st.id) continue;
const at = i.date + (i.createdAt || "");
if (!last[i.itemId] || at > last[i.itemId].at) last[i.itemId] = { si: i.si, at };
}
return s.catalog
.filter((it) => !it.archived && garmentForGroup(it, st.group) && garmentForStyle(it, st.uniformStyle) && (rank(it) < 2 || !!last[it.id]))
.sort((a, b) => rank(a) - rank(b) || label(a).localeCompare(label(b)))
.map((it) => {
const want = isTopItem(it) ? st.top : isPantItem(it) ? st.pants : "";
let si = want ? sizeIndexOf(it, want) : -1;
if (si < 0 && last[it.id] && last[it.id].si < it.sizes.length) si = last[it.id].si;
return { it, si: si < 0 ? null : si };
});
}, [s, st]);
const flags = useMemo(() => (st ? issueLineFlags(s, st, lines) : []), [s, st, lines]);
const add = (itemId: string, si: number) => {
const it = byId[itemId];
if (!it) return;
const k = key(itemId, si);
const cur = basket.issue(staffId);
const inBasket = cur.find((l) => l.key === k)?.qty || 0;
if (shelf(k) - inBasket <= 0) { toast(`None of ${label(it)} ${sizeOf(it, si)} on the shelf`); return; }
const next: IssueLine[] = inBasket
? cur.map((l) => (l.key === k ? { ...l, qty: l.qty + 1 } : l))
: [...cur, { key: k, itemId, si, qty: 1, reason: null }];
basket.setIssue(staffId, next);
};
const setQty = (k: string, v: number) => {
const cur = basket.issue(staffId);
const oh = shelf(k);
let n = v;
if (n > oh) { toast(`Only ${oh} on the shelf`); n = oh; }
basket.setIssue(staffId, n <= 0 ? cur.filter((l) => l.key !== k) : cur.map((l) => (l.key === k ? { ...l, qty: n } : l)));
};
const setReason = (k: string, r: string | null) => {
basket.setIssue(staffId, basket.issue(staffId).map((l) => (l.key === k ? { ...l, reason: r } : l)));
};
const onCode = (raw: string) => {
const code = raw.trim();
const hit = bcParse(s, code);
if (!hit) {
scanReject();
const bound = s.barcodes[code];
let it: Item | undefined = bound ? byId[splitKey(bound).itemId] : undefined;
if (!it && /^93\d{7}$/.test(code)) it = s.catalog.find((x) => x.sort === Math.floor((+code - 930000000) / 100));
toast(it?.archived ? `${label(it)} is discontinued` : `${code} isnt a garment ThreadCount knows`);
return;
}
add(hit.itemId, hit.si);
};
// The bar and the scanner are drawn by the shell as direct children of the app column (the native
// scanner hides everything else), so the scanner reads the latest handler through a ref.
const onCodeRef = useRef(onCode);
useEffect(() => { onCodeRef.current = onCode; });
const n = lines.reduce((t, l) => t + l.qty, 0);
const needReason = flags.some((f, i) => !!f && !lines[i]?.reason);
const short = lines.find((l) => l.qty > shelf(l.key));
const shortText = short ? `Only ${shelf(short.key)} of ${label(byId[short.itemId])} ${sizeOf(byId[short.itemId], short.si)} on the shelf` : "";
const inactive = !!st?.inactive;
const off = inactive || n === 0 || needReason || !!short;
const small = n === 0 ? "nothing yet" : needReason ? "reason each flag" : plural(n, "item");
const offReason = inactive ? "Reactivate them in the portal first" : n === 0 ? "Nothing to issue yet" : needReason ? "Pick a reason for each flagged line" : shortText;
useEffect(() => {
setBar(
<>
<MBar label="Review and sign" small={small} disabled={off} offReason={off ? offReason : undefined}
onClick={() => router.push(`/m/person/${encodeURIComponent(staffId)}/sign`)} />
{scan && <MScan title="Scan a garment" onHit={(raw) => { setScan(false); onCodeRef.current(raw); }} onClose={() => setScan(false)} />}
</>,
);
}, [setBar, small, off, offReason, scan, router, staffId]);
useEffect(() => () => setBar(null), [setBar]);
if (!st) return null;
const cc = ccOf(s, st);
const total = lines.reduce((t, l) => t + l.qty * (byId[l.itemId]?.cost || 0), 0);
return (
<>
<MSection label="Their size" />
{kit.map(({ it, si: def }) => {
const si = pick[it.id] ?? def;
const k = si === null ? "" : key(it.id, si);
const oh = si === null ? null : shelf(k);
const inBasket = si === null ? 0 : lines.find((l) => l.key === k)?.qty || 0;
const size = si === null ? null : sizeOf(it, si);
return (
<MKitCard key={it.id} title={label(it)} size={size} onShelf={oh} sizeOpen={open === it.id}
onSize={() => setOpen((o) => (o === it.id ? null : it.id))}
onAdd={() => { if (si !== null) add(it.id, si); }}
addDisabled={si === null || (oh ?? 0) - inBasket <= 0}
addLabel={size === null ? `Pick a size for ${label(it)}` : `Add ${label(it)} ${size}`}>
<MChipRow label={`Sizes of ${label(it)}`} value={si === null ? null : String(si)}
options={it.sizes.map((sz, i) => ({ value: String(i), label: String(sz), n: shelf(key(it.id, i)) }))}
disabled={(v) => shelf(key(it.id, +v)) <= 0}
onPick={(v) => { setPick((p) => ({ ...p, [it.id]: +v })); setOpen(null); }} />
</MKitCard>
);
})}
<MButton icon="scan" label="Scan a garment" onClick={() => setScan(true)} />
{lines.length > 0 && (
<>
<MSection label="Issuing now" right={plural(n, "item")} />
{lines.map((l, i) => {
const it = byId[l.itemId];
const f = flags[i];
return (
<MLine key={l.key} title={label(it)} size={sizeOf(it, l.si)} flag={f?.label}
right={<MStepper label={label(it)} n={l.qty} min={0} max={999} onChange={(v) => setQty(l.key, v)} />}>
{f && <MReasonChips reasons={OVERRIDE_REASONS} value={l.reason} label={`Reason for ${label(it)}`} onPick={(r) => setReason(l.key, r)} />}
</MLine>
);
})}
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, padding: "10px 0", fontWeight: 800 }}>
<span>{cc ? `To cost centre ${cc}` : "To cost centre"}</span>
<b style={{ fontFamily: MONO }}>{money(total)}</b>
</div>
</>
)}
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
/* The meta line under a person's name on the counter phone: group · number · dept · CC. */
import { ccOf, type Snapshot, type StaffRec } from "@/lib/compute";
export function personMeta(s: Snapshot, st: StaffRec): string {
const cc = ccOf(s, st);
return [st.group, st.num, st.dept, cc && `CC ${cc}`].map((x) => (x || "").trim()).filter(Boolean).join(" · ");
}
export const plural = (n: number, one: string, many = one + "s") => `${n} ${n === 1 ? one : many}`;
+65
View File
@@ -0,0 +1,65 @@
"use client";
/* Count a shelf: every location holding garments, plus the unplaced bucket, each shelf with a
shelf-label print chip. Mounted by app/m/(app)/count/page.tsx (a Stock detail, no tab bar). */
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { UNPLACED, daysBetween } from "@/lib/compute";
import { printShelfLabel } from "@/lib/nativeprint";
import { isNative } from "@/lib/nativescan";
import { INK, MBody, MEmpty, MRow, MRule, MSection, MTop, MTopCount, useToast } from "@/components/m";
import { countRows } from "@/components/m/stock/stockdata";
function lastCounted(last: string | undefined, today: string): string {
if (!last) return "Never counted";
const n = daysBetween(last, today);
if (n <= 0) return "Counted today";
if (n === 1) return "Counted yesterday";
return `Counted ${n} days ago`;
}
export default function CountList() {
const { s } = useSnap();
const { L, variants } = useDerived();
const toast = useToast();
const rows = useMemo(() => countRows(s, L, variants), [s, L, variants]);
const print = async (id: string, name: string) => {
const r = await printShelfLabel({ locationId: id, copies: 1 });
if (!r.ok) toast(r.error);
else if (isNative()) toast(`${name} label sent to the shelf printer`);
};
const chip: React.CSSProperties = {
minHeight: 44, minWidth: 64, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
fontFamily: "inherit", fontSize: 13, fontWeight: 800, letterSpacing: "0.05em", textTransform: "uppercase", cursor: "pointer", flex: "none",
};
return (
<>
<MTop title="Count a shelf" back right={<MTopCount>{rows.length}</MTopCount>} />
<MRule />
<MBody pad>
{rows.length === 0 ? (
<MEmpty title="Nothing to count yet" sub="Place garments on a shelf in the portal." />
) : (
<>
<MSection label="Shelves" right="lines · units" />
{rows.map((r) => (
<div key={r.id} style={{ display: "flex", alignItems: "center", gap: 10, borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ flex: 1, minWidth: 0, marginBottom: -1 }}>
<MRow href={`/m/count/${r.id}`} mark={r.id === UNPLACED ? "mute" : "ink"}
title={<span style={{ paddingLeft: r.depth * 14 }}>{r.name}</span>}
sub={<span style={{ paddingLeft: r.depth * 14 }}>{lastCounted(r.last, s.today)}</span>}
right={`${r.lines} · ${r.units}`} />
</div>
{r.id !== UNPLACED && (
<button type="button" style={chip} onClick={() => print(r.id, r.name)} aria-label={`Print a shelf label for ${r.name}`}>Label</button>
)}
</div>
))}
</>
)}
</MBody>
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
/* The print sheet over a stock line: copies, why, and the bar that sends it. A native shell hands the
label page to Android printing; a browser opens the printable label page. */
import { useEffect, useState } from "react";
import { printLabels, printState, type PrintState } from "@/lib/nativeprint";
import { MBar, MChipRow, MKick, MRow, MSheet, MStepper, useToast } from "@/components/m";
const REASONS = ["Torn", "Faded", "New shelf"] as const;
type Reason = (typeof REASONS)[number];
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
export default function PrintSheet({ open, onClose, code, title }: { open: boolean; onClose: () => void; code: string; title: string }) {
const toast = useToast();
const [copies, setCopies] = useState(1);
const [reason, setReason] = useState<Reason>("Torn");
const [state, setState] = useState<PrintState | null>(null);
const [sending, setSending] = useState(false);
useEffect(() => {
if (!open) return;
setCopies(1); setReason("Torn");
let live = true;
printState().then((p) => { if (live) setState(p); });
return () => { live = false; };
}, [open]);
const go = async () => {
if (sending) return;
setSending(true);
const r = await printLabels({ code, copies, reason });
setSending(false);
if (!r.ok) { toast(r.error); return; }
onClose();
toast(state?.state === "browser" ? `${plural(copies, "label")} opened to print` : `${plural(copies, "label")} sent to the shelf printer`);
};
return (
<MSheet open={open} onClose={onClose} labelId="tc-print-title"
bar={<MBar label={sending ? "Sending…" : `Print ${plural(copies, "label")}`} onClick={go} disabled={sending} />}>
<MKick>Shelf printer{state ? ` · ${state.label}` : ""}</MKick>
<h2 id="tc-print-title" style={{ fontSize: 20, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.15 }}>{title}</h2>
<MRow title="Copies" sub="Barcode, garment, size, shelf"
right={<MStepper n={copies} onChange={setCopies} min={1} max={20} label="copies" />} />
<MChipRow label="Why it is being reprinted" value={reason} onPick={setReason}
options={REASONS.map((r) => ({ value: r, label: r }))} />
</MSheet>
);
}
+72
View File
@@ -0,0 +1,72 @@
/* Figures the Stock tab and a stock line's page both read, so the two never disagree. Pure. */
import { UNPLACED, bcBound, daysBetween, isOpen, key, locSubtree, locTree, onhand, touched, type Item, type Ledger, type Snapshot, type Variant } from "@/lib/compute";
export type CountRow = { id: string; name: string; depth: number; lines: number; units: number; last: string | undefined };
/** The shelves Count a shelf lists: every location holding placed garments, then the unplaced bucket
* (the same test the counting and variance screens use for it). */
export function countRows(s: Snapshot, L: Ledger, variants: Variant[]): CountRow[] {
const lastAt: Record<string, string> = {};
for (const t of s.stocktakes) if (t.mode !== "preloved" && t.locationId && (!lastAt[t.locationId] || t.date > lastAt[t.locationId])) lastAt[t.locationId] = t.date;
const out: CountRow[] = locTree(s).map(({ loc, depth }) => {
const sub = locSubtree(s, loc.id);
const mine = variants.filter((v) => sub.has(s.placed[v.key] || ""));
return { id: loc.id, name: loc.name, depth, lines: mine.length, units: mine.reduce((t, v) => t + onhand(s, L, v.key), 0), last: lastAt[loc.id] };
}).filter((r) => r.lines > 0);
const loose = variants.filter((v) => !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si)));
if (loose.length) out.push({ id: UNPLACED, name: "Not on a shelf yet", depth: 0, lines: loose.length, units: loose.reduce((t, v) => t + onhand(s, L, v.key), 0), last: undefined });
return out;
}
/** Outstanding units per variant key on open orders placed with a supplier (drafts excluded)
* the same set onOrderText() reads for one size. */
export function placedOnOrder(s: Snapshot, byId: Record<string, Item>): Record<string, number> {
const out: Record<string, number> = {};
for (const o of s.orders) {
if (!isOpen(o) || o.status === "Draft") continue;
const got: Record<string, number> = {};
for (const rc of o.receipts) for (const l of rc.lines) got[l.itemId + "|" + l.size] = (got[l.itemId + "|" + l.size] || 0) + l.qty;
for (const l of o.lines) {
const g = l.itemId + "|" + l.size;
const done = Math.min(l.qty, got[g] || 0);
got[g] = (got[g] || 0) - done;
if (l.qty <= done) continue;
const it = byId[l.itemId];
if (!it) continue;
const si = it.sizes.findIndex((z) => String(z) === l.size);
if (si < 0) continue;
const k = key(it.id, si);
out[k] = (out[k] || 0) + l.qty - done;
}
}
return out;
}
/** "Today", "Yesterday", "n days ago" or "Never", from the newest shelf count holding this line. */
export function lastCountedText(s: Snapshot, itemId: string, si: number): string {
let last = "";
for (const t of s.stocktakes) {
if (t.mode === "preloved" || (last && t.date <= last)) continue;
if (t.lines.some((l) => l.itemId === itemId && l.si === si)) last = t.date;
}
if (!last) return "Never";
const n = daysBetween(last, s.today);
return n <= 0 ? "Today" : n === 1 ? "Yesterday" : `${n} days ago`;
}
/** Units of one size out in staff hands now: issued, not returned, not handed in. */
export function heldByStaff(s: Snapshot, itemId: string, si: number): number {
let n = 0;
for (const i of s.issues) if (i.itemId === itemId && i.si === si && !i.returned && !i.handedIn) n += i.qty;
return n;
}
type Ranked = { oh: number; par: number; name: string };
/** Worst first: on hand as a share of par, par 0 last. */
export function byShortfall(a: Ranked, b: Ranked): number {
const ra = a.par > 0 ? a.oh / a.par : Infinity, rb = b.par > 0 ? b.oh / b.par : Infinity;
if (ra !== rb) return ra < rb ? -1 : 1;
return a.oh - b.oh || a.name.localeCompare(b.name);
}
/** Rows drawn per page on the long lists; the rest arrive with "Show more", never silently cut. */
export const PAGE = 200;
+35
View File
@@ -0,0 +1,35 @@
"use client";
/* The green line at the top of Today after something finished elsewhere: a shelf count committed
* (?flash=counted&loc=<name>&gaps=<n>, or the older ?counted=1) or a facility just created
* (?flash=created). Dismissing it drops the query so a reload does not bring it back. */
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { OK } from "@/components/m";
export function bannerText(q: URLSearchParams): string {
const flash = q.get("flash");
if (flash === "created") return "Facility created";
if (flash === "counted" || q.get("counted")) {
const loc = (q.get("loc") || "").slice(0, 80).trim();
const gaps = Math.max(0, parseInt(q.get("gaps") || "0", 10) || 0);
return `${loc || "Shelf"} counted${gaps > 0 ? ` · ${gaps} ${gaps === 1 ? "gap" : "gaps"}` : ""}`;
}
return "";
}
export default function TodayBanner() {
const sp = useSearchParams();
const router = useRouter();
const [gone, setGone] = useState(false);
const text = bannerText(new URLSearchParams(sp.toString()));
if (!text || gone) return null;
return (
<div role="status" style={{ background: OK, color: "#ffffff", padding: "12px 14px", margin: "-16px -16px 12px", fontWeight: 700, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
<span>{text}</span>
<button type="button" aria-label="Dismiss" onClick={() => { setGone(true); router.replace("/m"); }}
style={{ background: "none", border: 0, color: "#ffffff", fontSize: 22, width: 44, height: 44, flex: "none", cursor: "pointer", padding: 0, fontFamily: "inherit" }}>
×
</button>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
/* Today's figures, worked out once from the snapshot: the To do rows (each only when there is
* something to do), Your day, and Recent. Membership comes from the shared selectors
* (lib/portalcounts.ts through lib/today.ts and lib/workcount.ts), so Today, the Work badge and the
* Work segments cannot disagree about what is waiting. */
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { formatInZone, onhand, reorderAt, staffName, variantName } from "@/lib/compute";
import { PICKUP_LATE_DAYS, atReorderVariants } from "@/lib/portalcounts";
import { collectRows, countRows, dayMonth, plural, receiveRows } from "@/lib/today";
import { PICK_STATUSES } from "@/lib/workcount";
import { useRequests } from "@/components/requests/RequestList";
export type TodoRow = { key: string; n: string; accent: boolean; title: string; sub: string; href: string };
export type RecentRow = { key: string; title: string; sub: string; right: string; href?: string };
export type TodayView = {
kicker: string;
todo: TodoRow[];
day: { issued: number; back: number; counted: number };
recent: RecentRow[];
};
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** "Wed 16 Sep" from a facility date, without the locale's comma or four-letter "Sept". */
export function shortDate(iso: string): string {
const y = +iso.slice(0, 4), m = +iso.slice(5, 7) - 1, d = +iso.slice(8, 10);
if (!y || m < 0 || !d) return "";
return `${WEEKDAYS[new Date(Date.UTC(y, m, d)).getUTCDay()]} ${d} ${MONTHS[m]}`;
}
/** Names, at most four, then "+n". */
function names(list: string[]): string {
const uniq = [...new Set(list.filter(Boolean))];
return uniq.slice(0, 4).join(", ") + (uniq.length > 4 ? ` +${uniq.length - 4}` : "");
}
const first = (full: string) => full.trim().split(/\s+/)[0] || "";
export function useToday(): TodayView {
const { s } = useSnap();
const { L, byId, staffById } = useDerived();
const { data } = useRequests();
return useMemo(() => {
const today = s.today;
const todo: TodoRow[] = [];
// 1. Requests to pick.
if (data) {
const picks = data.requests.filter((r) => PICK_STATUSES.has(r.status));
if (picks.length) {
todo.push({
key: "picks", n: String(picks.length), accent: false, title: "Requests to pick",
sub: names(picks.map((r) => staffById[r.staffId]?.first || first(r.staffName))), href: "/m/work?seg=picks",
});
}
}
// 2. The most overdue shelf count (countsDue sorts never-counted first, then oldest).
const due = countRows(s, byId)[0];
if (due) {
const never = due.age === "—";
const days = never ? 0 : parseInt(due.age, 10) || 0;
const what = due.garments.join(", ");
todo.push({
key: "count", n: never ? "New" : `${days}d`, accent: true, title: `Count ${due.trail || due.loc.name}`,
sub: [what, never ? "never counted" : `not counted for ${plural(days, "day")}`].filter(Boolean).join(" · "),
href: `/m/count/${encodeURIComponent(due.loc.id)}`,
});
}
// 3. Deliveries to receive.
const inn = receiveRows(s, staffById);
if (inn.length) {
const o = inn[0].o;
todo.push({
key: "in", n: String(inn.length), accent: false, title: inn.length === 1 ? "Delivery to receive" : "Deliveries to receive",
sub: [o.code, o.supplier].filter(Boolean).join(" · "), href: "/m/work?seg=in",
});
}
// 4. Pickups waiting past the late threshold.
const late = collectRows(s, byId, staffById, { includeRound: true }).filter((r) => r.late);
if (late.length) {
todo.push({
key: "pickups", n: String(late.length), accent: true,
title: `${late.length === 1 ? "Pickup" : "Pickups"} waiting ${PICKUP_LATE_DAYS}+ days`,
sub: names(late.map((r) => r.name)), href: "/m/work?seg=pickups",
});
}
// 5. Lines at or below par, the worst one named (lowest on hand against par).
const low = atReorderVariants(s, L);
if (low.length) {
let worst = low[0], worstRatio = Infinity, worstOh = 0;
for (const v of low) {
const oh = onhand(s, L, v.key), par = reorderAt(s, v.key);
const ratio = par > 0 ? oh / par : oh;
if (ratio < worstRatio) { worst = v; worstRatio = ratio; worstOh = oh; }
}
todo.push({
key: "below", n: String(low.length), accent: false, title: "Lines below par",
sub: `Worst: ${variantName(worst.item, worst.size)}, ${worstOh <= 0 ? "none on the shelf" : `${worstOh} on the shelf`}`,
href: "/m/stock?seg=below",
});
}
// Your day.
let issued = 0, back = 0;
for (const i of s.issues) {
if (i.date === today) issued += i.qty;
if (i.returned?.date === today || (!i.returned && i.handedIn === today)) back += i.qty;
}
const countsToday = s.stocktakes.filter((t) => t.date === today && t.mode !== "preloved");
// Recent: one row per person per kind per day, plus today's counts and deliveries. Newest first.
type R = RecentRow & { sort: string };
const groups: Record<string, { staffId: string; kind: "Issued" | "Handed back"; at: string; qty: number; last: string }> = {};
for (const i of s.issues.slice(-200)) {
const add = (kind: "Issued" | "Handed back", at: string, stamp: string) => {
const g = (groups[`${i.staffId}|${kind}|${at}`] ||= { staffId: i.staffId, kind, at, qty: 0, last: "" });
g.qty += i.qty;
if (stamp > g.last) g.last = stamp;
};
add("Issued", i.date, i.createdAt);
if (i.returned) add("Handed back", i.returned.date, "");
else if (i.handedIn) add("Handed back", i.handedIn, "");
}
const time = (iso: string) => formatInZone(iso, s.tz, { hour: "numeric", minute: "2-digit", hourCycle: "h23" }).replace(/^0(\d)/, "$1");
const rows: R[] = Object.entries(groups).map(([k, g]) => ({
key: k, title: staffName(staffById[g.staffId], "Staff"), sub: `${g.kind} ${plural(g.qty, "item")}`,
right: g.at === today ? (g.last ? time(g.last) : "Today") : dayMonth(g.at, s.tz),
href: `/m/person/${g.staffId}`,
// A record with a time today sorts by it; a date with no time sorts to the end of that day.
sort: g.last && g.at === today ? g.last : `${g.at}T23:59:59`,
}));
const locName = (id: string | null) => (id ? s.locations.find((l) => l.id === id)?.name || "Shelf" : "Whole room");
for (const t of countsToday) {
rows.push({
key: `st|${t.id}`, title: locName(t.locationId),
sub: t.variances > 0 ? `Counted, ${plural(t.variances, "gap")}` : "Counted, all lines match",
right: "Today", sort: `${today}T23:59:59`,
});
}
for (const o of s.orders) {
for (const r of o.receipts) {
if (r.date !== today) continue;
const n = r.lines.reduce((a, l) => a + l.qty, 0);
rows.push({ key: `rc|${r.id}`, title: `Delivery ${o.code}`, sub: `Received ${plural(n, "item")}`, right: "Today", sort: `${today}T23:59:59` });
}
}
const recent = rows
.sort((a, b) => (a.sort < b.sort ? 1 : a.sort > b.sort ? -1 : 0))
.slice(0, 4)
.map(({ key, title, sub, right, href }) => ({ key, title, sub, right, href }));
const kicker = [shortDate(today), s.settings.facility, s.settings.location].filter(Boolean).join(" · ");
return { kicker, todo, day: { issued, back, counted: countsToday.length }, recent };
}, [s, L, byId, staffById, data]);
}
+72
View File
@@ -0,0 +1,72 @@
/* Shared shaping for the counter phone's Work tab and the screens it opens (pick a request, receive a
* delivery, sign a ward round). No rules of its own: membership comes from lib/today.ts and
* lib/portalcounts.ts, so the Work badge, the segments and Today agree. */
import {
ccOf, daysBetween, isPlacedOpen, label, locTrail, sizeIndexOf,
type Item, type LocationRec, type OrderRec, type Snapshot, type StaffRec,
} from "@/lib/compute";
import { weekdayDayMonth, type RoundSheetRow } from "@/lib/today";
/** "Scrub top, navy M": the garment and its size as the Work rows print them. */
export const garmentSize = (it: Item | undefined, fallbackName: string, size: string | number) =>
`${it ? label(it) : fallbackName} ${size}`;
/** "Theatres · 2291 · Theatres · CC 4200", blank parts dropped. */
export function personMeta(s: Snapshot, st: StaffRec | undefined, ward?: string): string {
if (!st) return ward || "";
const cc = ccOf(s, st);
return [st.group, st.num, st.dept, cc ? `CC ${cc}` : ""].filter((x) => x && String(x).trim()).join(" · ");
}
export const locMap = (s: Snapshot): Record<string, LocationRec> => Object.fromEntries(s.locations.map((l) => [l.id, l]));
/** Where a variant lives on the shelves, or "". */
export const shelfOf = (s: Snapshot, locs: Record<string, LocationRec>, itemId: string, si: number) =>
locTrail(locs, s.placed[`${itemId}:${si}`], 0);
/** Orders a delivery can be received against: placed with the supplier and not closed. */
export const RECEIVABLE = ["Ordered", "Shipped", "Back Order"];
export type OutLine = { id: string; itemId: string; size: string; si: number; ordered: number; outstanding: number };
/** Each line still owed on an order after earlier part deliveries. */
export function outstandingLines(o: OrderRec, byId: Record<string, Item>): OutLine[] {
return o.lines.map((l) => {
const already = o.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === l.itemId && x.size === l.size).reduce((a, x) => a + x.qty, 0), 0);
return { id: l.id, itemId: l.itemId, size: l.size, si: sizeIndexOf(byId[l.itemId], l.size), ordered: l.qty, outstanding: Math.max(0, l.qty - already) };
}).filter((l) => l.outstanding > 0);
}
export const outstandingTotal = (o: OrderRec, byId: Record<string, Item>) =>
outstandingLines(o, byId).reduce((t, l) => t + l.outstanding, 0);
/** Every open placed order that still has something to come. */
export function openReceivable(s: Snapshot, byId: Record<string, Item>): OrderRec[] {
return s.orders.filter((o) => isPlacedOpen(o) && RECEIVABLE.includes(o.status) && outstandingLines(o, byId).length > 0);
}
/** "Due today", "3d overdue", "Due Thu 18 Sep", or the order's status when nobody gave a date. */
export function orderWhen(s: Snapshot, o: OrderRec): string {
if (!o.expected) return o.status;
if (o.expected < s.today) return `${daysBetween(o.expected, s.today)}d overdue`;
if (o.expected === s.today) return "Due today";
return `Due ${weekdayDayMonth(o.expected, s.tz)}`;
}
export type RoundLine = { key: string; itemId: string; size: string; name: string; qty: number };
/** A ward's waiting bags summed by garment and size, for the round's Handing over list. */
export function roundLines(rows: RoundSheetRow[], byId: Record<string, Item>): RoundLine[] {
const m = new Map<string, RoundLine>();
for (const r of rows) for (const l of r.p.lines) {
const k = `${l.itemId}|${l.size}`;
const cur = m.get(k);
if (cur) cur.qty += l.qty;
else m.set(k, { key: k, itemId: l.itemId, size: l.size, name: garmentSize(byId[l.itemId], "Garment", l.size), qty: l.qty });
}
return [...m.values()];
}
/** A route segment, decoded once whether or not the router already decoded it. */
export function segment(v: string | string[] | undefined): string {
const raw = Array.isArray(v) ? v[0] : v || "";
try { return decodeURIComponent(raw); } catch { return raw; }
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
/* The sign-in chrome for the staff app.
*
* Deliberately not the app shell. Everything past /my/signin is a fixed-height phone column with
* an ink app bar and a bottom nav; this one screen is a centred page, because it is also what a
* person meets when they open a link from a printed slip on a desktop browser.
*
* The screens themselves live in components/screens/ and share components/staffui.tsx.
*/
export const INK = "#201e1d";
export const ACCENT = "#ec3013";
/* Under /my this sits inside `.tcx-app` a fixed, overflow-hidden column so it has to be the
thing that scrolls. It wasn't: a centred flex child with no overflow of its own, which clipped a
four-line approval (and its decline reasons) at both ends on a phone with nothing to scroll and
the Approve/Decline bar out of reach. `margin: auto` on the inner block centres a short page the
way `alignItems: center` did, without pinning the middle of a tall one. */
export function MyShell({ children }: { children: React.ReactNode }) {
return (
<div style={{ fontFamily: "var(--font-body)", color: INK, background: "var(--color-bg)", minHeight: "100dvh", display: "flex", flexDirection: "column", width: "100%", flex: 1, minWidth: 0, overflowY: "auto", WebkitOverflowScrolling: "touch" }}>
<div style={{ maxWidth: 460, margin: "auto", padding: "clamp(24px,6vw,56px) 20px", width: "100%" }}>{children}</div>
</div>
);
}
export const kicker: React.CSSProperties = { fontSize: 11.5, letterSpacing: "0.16em", textTransform: "uppercase", fontWeight: 800, color: ACCENT };
export const h1: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(26px,6vw,40px)", lineHeight: 1.05, letterSpacing: "-0.03em", margin: "12px 0 0", textWrap: "balance" };
export const lead: React.CSSProperties = { fontSize: 15, lineHeight: 1.6, color: "var(--color-neutral-800)", margin: "12px 0 0", maxWidth: "46ch" };
export const label: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 6 };
export const input: React.CSSProperties = { width: "100%", minHeight: 50, padding: "10px 12px", border: "2px solid " + INK, background: "#fff", fontSize: 16, fontWeight: 600, borderRadius: 0, fontFamily: "inherit", color: INK };
export const primary = (busy: boolean): React.CSSProperties => ({
minHeight: 56, background: ACCENT, color: "#fff", border: 0, font: "inherit", fontFamily: "var(--font-heading)",
fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase",
cursor: busy ? "wait" : "pointer", opacity: busy ? 0.6 : 1, textAlign: "left", padding: "0 20px",
});
export function Err({ children }: { children: React.ReactNode }) {
return <div role="alert" style={{ background: ACCENT, color: "#fff", padding: "10px 12px", fontSize: 13.5, fontWeight: 600, lineHeight: 1.5 }}>{children}</div>;
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Panel, Tag } from "@/components/portal";
import { ReceiveDialog } from "@/components/dialogs";
import { daysBetween, isOverdue, isPlacedOpen, label, staffName, type OrderRec } from "@/lib/compute";
import { plural, shortDate } from "./bits";
/* Placed orders still open: overdue first (most days late first), then by expected date, then undated. */
export default function OnTheWay() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const [rcv, setRcv] = useState<OrderRec | null>(null);
const rows = useMemo(() => s.orders.filter(isPlacedOpen).map((o) => ({ o, late: isOverdue(o, s.today) ? daysBetween(o.expected, s.today) : 0 })).sort((a, b) => {
if ((a.late > 0) !== (b.late > 0)) return a.late > 0 ? -1 : 1;
if (a.late !== b.late) return b.late - a.late;
if (!a.o.expected !== !b.o.expected) return a.o.expected ? -1 : 1;
return a.o.expected.localeCompare(b.o.expected);
}), [s.orders, s.today]);
return (
<Panel title="On the way" aside={plural(rows.length, "order")}>
{rows.length === 0 && <div className="tc-orders-row"><span className="tc-orders-rowmeta">Nothing on the way.</span></div>}
{rows.map(({ o, late }) => {
const st = o.staffId ? staffById[o.staffId] : undefined;
const what = o.lines.length === 1 ? `${label(byId[o.lines[0].itemId])} ×${o.lines[0].qty}` : plural(o.lines.length, "line");
const sup = (o.supplier || "No supplier").split(/\s+/)[0];
return (
<div key={o.id} className={"tc-orders-row" + (late > 0 ? " urgent" : "")} style={{ flexWrap: "nowrap" }}>
<div className="tc-orders-rowmain">
<div className="tc-orders-rowtitle">
<Link href={`/app/orders/${o.id}`} style={{ color: "inherit" }}>{o.code}</Link>
{late > 0 ? <Tag tone="accent">{late === 1 ? "1 day late" : `${late} days late`}</Tag> : o.staffId ? <Tag>staff</Tag> : null}
{(o.status === "Shipped" || o.status === "Back Order") && <Tag tone="quiet">{o.status}</Tag>}
</div>
<div className="tc-orders-rowmeta">
{sup} · {what} · {o.expected ? `expected ${shortDate(o.expected)}` : "no date"}{st ? ` · for ${staffName(st)}` : ""}
</div>
</div>
<button type="button" className={"btn " + (late > 0 ? "btn-primary" : "btn-ghost")} onClick={() => setRcv(o)} aria-label={`Receive ${o.code}`}>Receive</button>
</div>
);
})}
{rcv && <ReceiveDialog order={rcv} onClose={() => setRcv(null)} />}
</Panel>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Panel } from "@/components/portal";
import { money, orderTotal, staffName, statusTag } from "@/lib/compute";
import { shortDate } from "./bits";
/* An issuer's left column: raising orders from the list is an admin task, so they see the latest ten. */
export default function RecentOrders() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const rows = useMemo(() => [...s.orders].sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt)).slice(0, 10), [s.orders]);
return (
<Panel title="Recent orders" foot={<Link href="/app/orders/all" className="btn btn-ghost">Open the ledger</Link>}>
{rows.length === 0 && <div className="tc-orders-row"><span className="tc-orders-rowmeta">No orders yet.</span></div>}
{rows.map((o) => (
<Link key={o.id} href={`/app/orders/${o.id}`} className="tc-orders-row">
<div className="tc-orders-rowmain">
<div className="tc-orders-rowtitle">{o.code}</div>
<div className="tc-orders-rowmeta">{o.staffId ? `For ${staffName(staffById[o.staffId], "staff member")}` : "For stock"} · {o.supplier} · {shortDate(o.date)}</div>
</div>
<span className={statusTag(o.status)}>{o.status}</span>
<span className="tc-mono">{money(orderTotal(o, byId))}</span>
</Link>
))}
</Panel>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
/* One supplier's panel in To order: the sizes at reorder with editable quantities, the drafts that
* belong to the supplier, and one "Order and email" that raises the lot through order.raiseList. */
import Link from "next/link";
import { useId, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { ErrorLine, Field, LiveRegion } from "@/components/ui";
import { Icon, QtyStepper, Tag } from "@/components/portal";
import { csvOf, fmtDate, key, label, money, staffName, supplierCodeOf } from "@/lib/compute";
import { downloadCsv, esc, openPrintWindow, tbl } from "@/lib/print";
import { draftValue, lineFor, siOf, type SupplierGroup, type ToOrderLine } from "./toOrder";
import { plural } from "./bits";
/** Number column headers: right-aligned, but in the 11px uppercase label face like Code and Garment. */
const NUM_TH = { textAlign: "right" } as const;
export type Raised = { id: string; code: string; supplier: string; ref: string; lines: { itemId: string; size: string; qty: number }[] };
export function SupplierPanel({ group, oo, raised, onRaised, onDone }: {
group: SupplierGroup;
oo: Record<string, number>;
raised?: { orders: Raised[]; mail: Record<string, string> };
onRaised: (orders: Raised[], mail: Record<string, string>) => void;
onDone: () => void;
}) {
const { s, mutate } = useSnap();
const { L, byId, staffById } = useDerived();
const hid = useId();
const [qty, setQty] = useState<Record<string, number>>({});
const [removed, setRemoved] = useState<string[]>([]);
const [added, setAdded] = useState<{ itemId: string; si: number }[]>([]);
const [ref, setRef] = useState("");
// A draft keeps its own supplier order no.; the panel's box is for the stock order only.
const [draftRefs, setDraftRefs] = useState<Record<string, string>>({});
const [pick, setPick] = useState<{ itemId: string; si: number; qty: number } | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [mailMsg, setMailMsg] = useState<Record<string, string>>({});
const { supplier, lead, email, drafts } = group;
const lines: ToOrderLine[] = useMemo(() => {
const base = group.lines.filter((l) => !removed.includes(l.key));
const have = new Set(group.lines.map((l) => l.key));
const extra = added.map((a) => lineFor(s, L, byId, oo, a.itemId, a.si)).filter((l): l is ToOrderLine => !!l && !have.has(l.key));
return [...base, ...extra].map((l) => (qty[l.key] !== undefined ? { ...l, qty: qty[l.key] } : l));
}, [group.lines, removed, added, qty, s, L, byId, oo]);
const catalog = useMemo(() => s.catalog.filter((it) => !it.archived).sort((a, b) => Number((b.supplier || "") === supplier) - Number((a.supplier || "") === supplier) || label(a).localeCompare(label(b))), [s.catalog, supplier]);
const draftLines = drafts.reduce((t, o) => t + o.lines.length, 0);
const total = lines.reduce((t, l) => t + l.qty * l.cost, 0) + drafts.reduce((t, o) => t + draftValue(o, byId), 0);
const nLines = lines.length + draftLines;
const canRaise = lines.some((l) => l.qty > 0) || drafts.length > 0;
function removeLine(l: ToOrderLine) {
if (group.lines.some((g) => g.key === l.key)) setRemoved((r) => [...r, l.key]);
else setAdded((a) => a.filter((x) => key(x.itemId, x.si) !== l.key));
}
function addLine() {
if (!pick || !pick.itemId) return;
const k = key(pick.itemId, pick.si);
setRemoved((r) => r.filter((x) => x !== k));
if (!group.lines.some((g) => g.key === k)) setAdded((a) => (a.some((x) => key(x.itemId, x.si) === k) ? a : [...a, { itemId: pick.itemId, si: pick.si }]));
setQty((q) => ({ ...q, [k]: Math.max(0, pick.qty) }));
setPick(null);
}
function sheet() {
const facts = [lead ? `Lead ${lead} days` : "", email || "No email on file", fmtDate(s.today)].filter(Boolean).join(" · ");
const cols = [{ t: "Code" }, { t: "Garment" }, { t: "Size" }, { t: "Qty", r: true }, { t: "Unit", r: true }, { t: "Total", r: true }];
const stockRows = lines.filter((l) => l.qty > 0).map((l) => [l.code || "no code", l.name, l.size, l.qty, money(l.cost), money(l.qty * l.cost)]);
let body = `<h1><span class="sq"></span>${esc(supplier)}</h1><div class="meta">${esc(facts)}</div>`;
if (stockRows.length) body += `<h2>For stock${ref ? ` · ${esc(ref)}` : ""}</h2>` + tbl(cols, stockRows);
for (const o of drafts) {
const who = o.staffId ? staffName(staffById[o.staffId], "staff member") : "stock";
const dRef = draftRefs[o.id] ?? o.ref ?? "";
body += `<h2>${esc(o.code)} · for ${esc(who)}${dRef ? ` · ${esc(dRef)}` : ""}</h2>` + tbl(cols, o.lines.map((l) => { const it = byId[l.itemId]; const c = it?.cost || 0; return [supplierCodeOf(s, key(l.itemId, siOf(it, l.size))) || "no code", label(it), l.size, l.qty, money(c), money(l.qty * c)]; }));
}
body += `<div class="meta" style="margin-top:12px;text-align:right;font-weight:700">Total ${esc(money(total))}</div>`;
openPrintWindow(`${supplier} order`, body);
}
async function orderAndEmail() {
setBusy(true); setErr("");
const stockLines = lines.filter((l) => l.qty > 0).map((l) => ({ itemId: l.itemId, size: l.size, qty: l.qty }));
const groups = [
...(stockLines.length ? [{ kind: "stock", supplier, ref, lines: stockLines }] : []),
...drafts.map((d) => ({ kind: "draft", id: d.id, ref: draftRefs[d.id] ?? "" })),
];
const r = await mutate<{ raised: Raised[] }>("order.raiseList", { groups });
if (!r.ok) { setBusy(false); setErr(r.error); return; }
const mail: Record<string, string> = {};
if (email) {
for (const o of r.result.raised) {
const m = await mutate<{ sentTo: string }>("order.email", { id: o.id });
mail[o.id] = m.ok ? `Sent to ${m.result.sentTo}` : m.error;
}
}
setBusy(false);
setQty({}); setRemoved([]); setAdded([]); setRef(""); setDraftRefs({}); setPick(null);
onRaised(r.result.raised, mail);
}
const costOf = (itemId: string) => byId[itemId]?.cost || 0;
function csv(o: Raised) {
const rows = o.lines.map((l) => { const it = byId[l.itemId]; return [supplierCodeOf(s, key(l.itemId, siOf(it, l.size))) || it?.sku || "", label(it), l.size, l.qty, costOf(l.itemId)]; });
downloadCsv(`${o.code}${o.ref ? "-" + o.ref.replace(/[^A-Za-z0-9-]+/g, "_") : ""}-${o.supplier.replace(/[^A-Za-z0-9]+/g, "_")}.csv`, `Order,${o.code}\nSupplier,${o.supplier}\nSupplier order no.,${o.ref}\n\n` + csvOf(["Supplier code", "Description", "Size", "Qty", "Unit cost"], rows));
}
async function emailAgain(o: Raised) {
setMailMsg((m) => ({ ...m, [o.id]: "Sending…" }));
const r = await mutate<{ sentTo: string }>("order.email", { id: o.id });
setMailMsg((m) => ({ ...m, [o.id]: r.ok ? `Sent to ${r.result.sentTo}` : r.error }));
}
const head = (aside: React.ReactNode) => (
<div className="tc-pp-head">
<span className="tc-pp-title" style={{ flexWrap: "wrap" }}>
<h3 id={hid} className="tc-pp-h">{supplier}</h3>
<span className="tc-pp-aside">{lead ? `lead ${lead} days · ` : ""}{email || "no email on file"}</span>
</span>
{aside}
</div>
);
if (raised) {
return (
<section className="tc-pp" aria-labelledby={hid}>
{head(<span className="tc-pp-aside tc-mono">{plural(raised.orders.length, "order")} raised</span>)}
<div>
{raised.orders.map((o) => {
const msg = mailMsg[o.id] ?? raised.mail[o.id];
return (
<div key={o.id} className="tc-orders-row">
<div className="tc-orders-rowmain">
<div className="tc-orders-rowtitle"><Link href={`/app/orders/${o.id}`}>{o.code}</Link> · {o.supplier}{o.ref ? ` · ${o.ref}` : ""}</div>
<div className="tc-orders-rowmeta">
<span className="tc-mono">{plural(o.lines.length, "line")} · {money(o.lines.reduce((t, l) => t + l.qty * costOf(l.itemId), 0))}</span>
</div>
<LiveRegion msg={msg} className="tc-orders-rowmeta" />
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<a className="btn btn-secondary" href={`/print/supplier-order?id=${o.id}`} target="_blank" rel="noreferrer" onClick={() => { void mutate("order.printed", { id: o.id }); }}>Print</a>
<button type="button" className="btn btn-ghost" onClick={() => csv(o)}>CSV</button>
<button type="button" className="btn btn-ghost" onClick={() => emailAgain(o)}>Email</button>
</div>
</div>
);
})}
</div>
<div className="tc-orders-foot"><button type="button" className="btn btn-ghost" onClick={onDone}>Done</button></div>
</section>
);
}
return (
<section className="tc-pp" aria-labelledby={hid}>
{head(<span className="tc-pp-aside tc-mono">{plural(nLines, "line")} · {money(total)}</span>)}
{lines.length > 0 && (
<div className="table-wrap">
<table className="tc-table" style={{ minWidth: 760 }}>
<thead>
<tr>
<th scope="col">Code</th><th scope="col">Garment</th>
<th scope="col" style={NUM_TH}>On hand</th><th scope="col" style={NUM_TH}>Reorder</th><th scope="col" style={NUM_TH}>On order</th><th scope="col" style={NUM_TH}>Per week</th>
<th scope="col" style={NUM_TH}>Order</th><th scope="col" style={NUM_TH}>Cost</th>
<th scope="col"><span className="sr-only">Remove</span></th>
</tr>
</thead>
<tbody>
{lines.map((l) => (
<tr key={l.key}>
{/* Codes and "no code" stay on one line, and the garment keeps enough width for a
long name and its size: squeezed, both broke over two or three lines. */}
<td className="tc-mono" style={{ fontSize: 12, color: "#57534f", whiteSpace: "nowrap" }}>{l.code || <Link href={`/app/stock/${l.itemId}`} style={{ color: "var(--color-accent-700)", fontWeight: 600 }}>no code</Link>}</td>
<td style={{ minWidth: 180 }}>
<div style={{ fontWeight: 600 }}>{l.name} · <span className="tc-mono" style={{ whiteSpace: "nowrap" }}>{l.size}</span></div>
{l.runsOut && <div style={{ fontSize: 12, fontWeight: 600, color: "var(--color-accent-700)" }}>runs out before this arrives</div>}
</td>
<td className="num" style={l.oh <= 0 ? { color: "var(--color-accent-700)", fontWeight: 600 } : undefined}>{l.oh}</td>
<td className="num">{l.ro}</td>
<td className="num">{l.onOrder}</td>
<td className="num">{l.perWeek === null ? "" : l.perWeek.toFixed(1)}</td>
<td className="num"><QtyStepper size="sm" min={0} value={l.qty} label={`${l.name} ${l.size}`} onChange={(n) => setQty((q) => ({ ...q, [l.key]: n }))} /></td>
<td className="num">{money(l.qty * l.cost)}</td>
<td style={{ padding: "9px 8px 9px 0" }}>
<button type="button" className="btn btn-ghost" style={{ minHeight: 26, padding: "0 4px" }} aria-label={`Remove ${l.name} ${l.size} from the list`} title="Remove from the list" onClick={() => removeLine(l)}>×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{drafts.map((o) => {
const who = o.staffId ? staffName(staffById[o.staffId], "staff member") : "stock";
return (
<div key={o.id} className="tc-orders-draft">
<div className="tc-orders-drafthead">
<Tag tone={o.staffId ? "outline" : "quiet"}>{o.staffId ? "staff" : "draft"}</Tag>
<span>for {who} · <Link href={`/app/orders/${o.id}`} className="tc-mono">{o.code}</Link></span>
<span className="tc-mono" style={{ marginLeft: "auto", fontSize: 12, color: "#57534f" }}>{plural(o.lines.length, "line")}</span>
<input className="input tc-orders-ref" placeholder="Supplier order no." aria-label={`${o.code} supplier order number`} maxLength={120}
value={draftRefs[o.id] ?? o.ref ?? ""} onChange={(e) => { const v = e.target.value; setDraftRefs((m) => ({ ...m, [o.id]: v })); }} />
</div>
<div className="table-wrap">
<table className="tc-table" style={{ minWidth: 520 }}>
<thead className="sr-only"><tr><th scope="col">Code</th><th scope="col">Garment</th><th scope="col">Qty</th><th scope="col">Cost</th></tr></thead>
<tbody>
{o.lines.map((l) => {
const it = byId[l.itemId];
const code = supplierCodeOf(s, key(l.itemId, siOf(it, l.size)));
return (
<tr key={l.id}>
<td className="tc-mono" style={{ fontSize: 12, color: "#57534f", width: "18%" }}>{code || "no code"}</td>
<td><span style={{ fontWeight: 600 }}>{label(it)} · <span className="tc-mono">{l.size}</span></span></td>
<td className="num">{l.qty}</td>
<td className="num">{money(l.qty * (it?.cost || 0))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
{nLines === 0 && <div className="tc-orders-rowmeta" style={{ padding: "11px 16px" }}>Nothing to order.</div>}
{pick && (
<div className="tc-orders-add">
<Field label="Garment" style={{ minWidth: 220 }}>{(c) => (
<select {...c} className="input" value={pick.itemId} onChange={(e) => setPick({ ...pick, itemId: e.target.value, si: 0 })}>
<option value="">Choose</option>
{catalog.map((it) => <option key={it.id} value={it.id}>{label(it)}{it.supplier && it.supplier !== supplier ? ` · ${it.supplier}` : ""}</option>)}
</select>
)}</Field>
<Field label="Size">{(c) => (
<select {...c} className="input" value={pick.si} disabled={!pick.itemId} onChange={(e) => setPick({ ...pick, si: parseInt(e.target.value, 10) })}>
{(byId[pick.itemId]?.sizes || []).map((sz, i) => <option key={i} value={i}>{String(sz)}</option>)}
</select>
)}</Field>
<Field label="Qty">{(c) => <input {...c} className="input" style={{ width: 80 }} type="number" min={0} inputMode="numeric" value={pick.qty} onChange={(e) => setPick({ ...pick, qty: Math.max(0, parseInt(e.target.value || "0", 10) || 0) })} />}</Field>
<button type="button" className="btn btn-secondary" disabled={!pick.itemId} onClick={addLine}>Add</button>
<button type="button" className="btn btn-ghost" onClick={() => setPick(null)}>Cancel</button>
</div>
)}
<div className="tc-orders-foot">
{lines.length > 0 && <input className="input tc-orders-ref" placeholder="Supplier order no." aria-label={`${supplier} stock order number`} maxLength={120} value={ref} onChange={(e) => setRef(e.target.value)} />}
{!pick && <button type="button" className="btn btn-ghost" onClick={() => setPick({ itemId: "", si: 0, qty: 1 })}>Add a line</button>}
<button type="button" className="btn btn-secondary" style={{ marginLeft: "auto" }} onClick={sheet} disabled={nLines === 0}><Icon name="print" size={16} />Sheet</button>
<button type="button" className="btn btn-primary" onClick={orderAndEmail} disabled={busy || !canRaise}>
{email && <Icon name="mail" size={16} />}{busy ? "Ordering…" : email ? "Order and email" : "Order"}
</button>
</div>
{err && <div style={{ padding: "0 16px 12px" }}><ErrorLine msg={err} /></div>}
</section>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Panel } from "@/components/portal";
import { money, monthLabel, orderTotal } from "@/lib/compute";
import { plural } from "./bits";
/* Placed uses the Reports rule (dated this month, placed, not a back order); Received is by receipt month. */
export default function ThisMonth() {
const { s } = useSnap();
const { byId } = useDerived();
const month = s.today.slice(0, 7);
const f = useMemo(() => {
const placed = s.orders.filter((o) => o.date.slice(0, 7) === month && o.status !== "Draft" && o.status !== "Cancelled" && !o.parentId);
const received = s.orders.filter((o) => o.status === "Received" && (o.received || "").slice(0, 7) === month);
const sum = (xs: typeof placed) => xs.reduce((t, o) => t + orderTotal(o, byId), 0);
return { placed: placed.length, placedVal: sum(placed), received: received.length, receivedVal: sum(received) };
}, [s.orders, byId, month]);
return (
<Panel title="This month" aside={monthLabel(month, { month: "long" })}>
<div className="tc-orders-row"><span style={{ flex: 1 }}>Placed</span><span className="tc-mono">{plural(f.placed, "order")} · {money(f.placedVal)}</span></div>
<div className="tc-orders-row"><span style={{ flex: 1 }}>Received</span><span className="tc-mono">{plural(f.received, "order")} · {money(f.receivedVal)}</span></div>
<div className="tc-orders-row">
<span className="tc-orders-rowmeta" style={{ flex: 1, marginTop: 0 }}>All orders, drafts and history</span>
<Link href="/app/orders/all" className="btn btn-ghost" style={{ minHeight: 0 }}>Open the ledger</Link>
</div>
</Panel>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
/* The left column of /app/orders for admins: "To order", one panel per supplier. It replaces Order
* flagged, the Suggested order panel and the separate order list. */
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { onOrderByKey, supplierMeta, toOrderGroups, type SupplierGroup } from "./toOrder";
import { SupplierPanel, type Raised } from "./SupplierPanel";
import { plural } from "./bits";
export default function ToOrder() {
const { s } = useSnap();
const { L, byId } = useDerived();
const groups = useMemo(() => toOrderGroups(s, L, byId), [s, L, byId]);
const oo = useMemo(() => onOrderByKey(s, byId), [s, byId]);
// Results stay on screen after raising even when the supplier no longer has anything to order.
const [raised, setRaised] = useState<Record<string, { orders: Raised[]; mail: Record<string, string> }>>({});
const panels: SupplierGroup[] = [...groups];
for (const sup of Object.keys(raised)) {
if (!panels.some((g) => g.supplier === sup)) panels.push({ supplier: sup, ...supplierMeta(s, sup), lines: [], drafts: [] });
}
panels.sort((a, b) => a.supplier.localeCompare(b.supplier));
const lineCount = groups.reduce((t, g) => t + g.lines.length + g.drafts.reduce((u, o) => u + o.lines.length, 0), 0);
const supplierCount = groups.filter((g) => g.lines.length + g.drafts.length > 0).length;
return (
<>
<div className="tc-orders-title">
<h2>To order</h2>
<span className="tc-mono" style={{ fontSize: 12, color: "#57534f" }}>{plural(lineCount, "line")} · {plural(supplierCount, "supplier")}</span>
</div>
{panels.length === 0 && <div className="tc-orders-rowmeta">Nothing to order.</div>}
{panels.map((g) => {
const full = groups.find((x) => x.supplier === g.supplier) || g;
return (
<SupplierPanel key={g.supplier} group={full} oo={oo} raised={raised[g.supplier]}
onRaised={(orders, mail) => setRaised((r) => ({ ...r, [g.supplier]: { orders, mail } }))}
onDone={() => setRaised((r) => { const n = { ...r }; delete n[g.supplier]; return n; })} />
);
})}
</>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
/* Small pieces the Orders screens share. */
import Link from "next/link";
import { Icon } from "@/components/portal";
import { NewOrderDialog } from "@/components/dialogs";
/* ` Parent / Current` directly under the page head (the staff-record pattern). */
export function Crumb({ href, parent, current }: { href: string; parent: string; current: string }) {
return (
<nav aria-label="Breadcrumb" className="tc-orders-crumb">
<Icon name="chevronLeft" size={16} />
<Link href={href}>{parent}</Link>
<span aria-hidden="true">/</span>
<span aria-current="page" className="tc-orders-crumb-here">{current}</span>
</nav>
);
}
/* NewOrderDialog with the S2 pre-fill props (initOrderFor / initStaffId / initSupplier). Typed here so
* this file compiles whether or not the shared dialog has picked the props up yet. */
type NewOrderProps = Parameters<typeof NewOrderDialog>[0] & { initOrderFor?: "Stock" | "Staff Member"; initStaffId?: string; initSupplier?: string };
export const NewOrder = NewOrderDialog as unknown as (p: NewOrderProps) => React.ReactElement;
export const plural = (n: number, one: string, many = one + "s") => `${n} ${n === 1 ? one : many}`;
/* "11 Sep": the short date the boards print. */
export function shortDate(iso: string): string {
if (!iso || iso.length < 10) return "";
const d = new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("en-AU", { day: "numeric", month: "short" });
}
/* Layout for the Orders screens. The page grid needs a media query for the phone order
* (On the way, To order, This month), which an inline style cannot carry. */
export function OrdersStyles() {
return null; // the rules are in app/globals.css under portal redesign
}
+77
View File
@@ -0,0 +1,77 @@
/* The To order list, pure. Membership comes only from atReorderVariants() (lib/portalcounts.ts) so
* the Orders badge, the Stock filter and this list agree; this file adds the per-line fields. */
import { atReorderVariants } from "@/lib/portalcounts";
import {
forecastFor, key, label, onOrderMap, onhand, reorderAt, supplierCodeOf, supplierInfo,
type Item, type Ledger, type OrderRec, type Snapshot,
} from "@/lib/compute";
export type ToOrderLine = {
key: string; itemId: string; si: number; size: string; name: string;
supplier: string; code: string; oh: number; ro: number; onOrder: number;
qty: number; perWeek: number | null; runsOut: boolean; cost: number;
};
export type SupplierGroup = {
supplier: string; lead: number | null; email: string;
lines: ToOrderLine[]; drafts: OrderRec[];
};
export const supplierOf = (s: Snapshot, it: Item | undefined) => it?.supplier || s.settings.suppliers[0] || "Supplier";
export function supplierMeta(s: Snapshot, name: string): { lead: number | null; email: string } {
const info = supplierInfo(s, name);
return { lead: info?.lead && info.lead > 0 ? info.lead : null, email: info?.email || "" };
}
/** Stock on every open order, drafts included. Drafts are raised from the same panel as the
* suggested lines, so a quantity already sitting in any draft (a replenishment draft too) must not
* be suggested a second time. */
export function onOrderByKey(s: Snapshot, byId: Record<string, Item>): Record<string, number> {
return onOrderMap(s, byId).byKey;
}
/** One size's figures, whether it came from the reorder rule or was added by hand. */
export function lineFor(s: Snapshot, L: Ledger, byId: Record<string, Item>, oo: Record<string, number>, itemId: string, si: number): ToOrderLine | null {
const it = byId[itemId];
if (!it || si < 0 || si >= it.sizes.length) return null;
const k = key(itemId, si);
const oh = onhand(s, L, k), ro = reorderAt(s, k);
const onOrder = oo[k] || 0;
const f = forecastFor(s, L, byId, k);
return {
key: k, itemId, si, size: String(it.sizes[si]), name: label(it), supplier: supplierOf(s, it),
code: supplierCodeOf(s, k), oh, ro, onOrder,
qty: Math.max(ro * 2 - oh - onOrder, 0),
perWeek: f.avgWeekly === null ? null : Math.round(f.avgWeekly * 10) / 10,
runsOut: f.runsOutBeforeDelivery || (oh <= 0 && f.avgWeekly !== null),
cost: it.cost || 0,
};
}
export const sortLines = (a: ToOrderLine, b: ToOrderLine) =>
Number(b.runsOut) - Number(a.runsOut) || a.name.localeCompare(b.name) || a.si - b.si;
/** Supplier panels AZ: every supplier with a size at reorder or a draft with lines. */
export function toOrderGroups(s: Snapshot, L: Ledger, byId: Record<string, Item>): SupplierGroup[] {
const oo = onOrderByKey(s, byId);
const by = new Map<string, SupplierGroup>();
const group = (name: string) => {
let g = by.get(name);
if (!g) { g = { supplier: name, ...supplierMeta(s, name), lines: [], drafts: [] }; by.set(name, g); }
return g;
};
for (const v of atReorderVariants(s, L)) {
const line = lineFor(s, L, byId, oo, v.itemId, v.si);
if (line) group(line.supplier).lines.push(line);
}
for (const o of s.orders) if (o.status === "Draft" && o.lines.length > 0) group(o.supplier || "No supplier").drafts.push(o);
for (const g of by.values()) { g.lines.sort(sortLines); g.drafts.sort((a, b) => a.code.localeCompare(b.code)); }
return [...by.values()].sort((a, b) => a.supplier.localeCompare(b.supplier));
}
/** A draft's value at catalogue cost (a draft has no receipts). */
export const draftValue = (o: OrderRec, byId: Record<string, Item>) => o.lines.reduce((t, l) => t + l.qty * (byId[l.itemId]?.cost || 0), 0);
/** The size index of an order line, or -1. */
export const siOf = (it: Item | undefined, size: string) => (it ? it.sizes.map(String).indexOf(size) : -1);
+169
View File
@@ -0,0 +1,169 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useSnap } from "@/lib/client";
import { ErrorLine, Field, LiveRegion } from "@/components/ui";
import { Panel, Tag } from "@/components/portal";
import { FTE_OPTIONS, UNIFORM_STYLES, ccOf, entOf, fmtDate, isNursing, type StaffRec } from "@/lib/compute";
import ManagerBox from "./ManagerBox";
import ReportsBox from "./ReportsBox";
import SelfService from "./SelfService";
import { fullName, type Act } from "./shared";
export default function DetailsTab({ st, act, edit, base }: { st: StaffRec; act: Act; edit: boolean; base: string }) {
const { s, isAdmin, mutate } = useSnap();
const [offMsg, setOffMsg] = useState("");
const reports = s.staff.filter((x) => x.managerId === st.id && !x.inactive)
.sort((a, b) => `${a.last} ${a.first}`.localeCompare(`${b.last} ${b.first}`));
const hasHistory = s.issues.some((i) => i.staffId === st.id) || s.orders.some((o) => o.staffId === st.id);
/* Taking somebody off the register also closes the requests still waiting on approval (staff.patch,
same transaction). Nobody is emailed, so the count is said out loud here. */
async function deactivate() {
setOffMsg("");
const r = await mutate<{ closedRequests?: number }>("staff.patch", { id: st.id, inactive: true });
if (!r.ok) { setOffMsg(r.error); return; }
const n = r.result?.closedRequests ?? 0;
if (n) setOffMsg(`${n} ${n === 1 ? "request was" : "requests were"} waiting on approval and ${n === 1 ? "has" : "have"} been closed.`);
}
return (
<div className="tc-people-two">
<div className="tc-people-stack">
{edit ? <EditDetails st={st} base={base} /> : <ReadDetails st={st} act={act} isAdmin={isAdmin} base={base} />}
<Panel title="Manager">
<div className="tc-people-pad">
<ManagerBox people={s.staff} subject={st} isAdmin={isAdmin} act={act} />
</div>
</Panel>
</div>
<div className="tc-people-stack">
<Panel title={`Whose requests ${st.first || "they"} approves`} aside={reports.length ? `${reports.length} ${reports.length === 1 ? "person" : "people"}` : undefined}
foot={st.dept ? <Link href={`/app/requests?ward=${encodeURIComponent(st.dept)}`} className="btn btn-ghost tc-people-ghost">Requests from {st.dept}</Link> : undefined}>
<ReportsBox subject={st} reports={reports} people={s.staff} isAdmin={isAdmin} act={act} />
</Panel>
<Panel title="Staff app">
<div className="tc-people-pad">
{isAdmin ? (
<label style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 13 }}>
<input type="checkbox" checked={st.wardDesk} onChange={(e) => act("staff.patch", { id: st.id, wardDesk: e.target.checked })} style={{ width: 18, height: 18, flex: "none" }} />
<span><b>On the ward desk</b>{st.dept ? ` · signs for ${st.dept} round bags` : " · no ward recorded"}</span>
</label>
) : (
<div style={{ fontSize: 13 }}>{st.wardDesk ? <Tag>{st.dept ? `Ward desk · ${st.dept}` : "Ward desk"}</Tag> : "Not on a ward desk."}</div>
)}
<SelfService st={st} act={act} mutate={mutate} isAdmin={isAdmin} facility={s.settings.facility} tz={s.settings.timezone} />
</div>
</Panel>
{isAdmin && (
<Panel title="Register">
<div className="tc-people-pad" style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
{!st.inactive
? <button type="button" className="btn btn-secondary" onClick={deactivate}>Deactivate</button>
: <button type="button" className="btn btn-secondary" onClick={() => { setOffMsg(""); act("staff.patch", { id: st.id, inactive: false }); }}>Reactivate</button>}
{!hasHistory && (
<button type="button" className="btn btn-ghost" style={{ color: "var(--color-accent-700)" }}
onClick={() => { if (confirm(`Delete ${fullName(st)} from the register?`)) act("staff.delete", { id: st.id }).then((ok) => { if (ok) window.location.href = "/app/staff"; }); }}>Delete</button>
)}
</div>
<LiveRegion msg={offMsg} className="tc-people-pad" style={{ paddingTop: 0, fontSize: 13, fontWeight: 600 }} />
</Panel>
)}
</div>
</div>
);
}
function ReadDetails({ st, act, isAdmin, base }: { st: StaffRec; act: Act; isAdmin: boolean; base: string }) {
const { s } = useSnap();
const of = entOf(s, st), limited = Number.isFinite(of);
const fte = st.fte || "";
const Row = ({ k, children }: { k: string; children: React.ReactNode }) => (
<div className="tc-people-kv"><span className="k muted">{k}</span><span className="v">{children}</span></div>
);
return (
<Panel title="Details" aside={isAdmin ? <Link href={`${base}?tab=details&edit=1`} scroll={false} className="btn btn-ghost tc-people-ghost">Edit details</Link> : undefined}>
<div>
<Row k="Phone">{st.phone ? <span className="tc-mono">{st.phone}</span> : ""}</Row>
<Row k="Ward">{st.dept || ""}</Row>
<Row k="Cost centre"><span className="tc-mono">{ccOf(s, st) || ""}</span> <span className="tc-meta-line">{st.ccOverride ? "override" : "from ward"}</span></Row>
<Row k="Sizes"><span className="tc-mono">{st.top || ""} / {st.pants || ""}</span></Row>
<Row k="Uniform style">{st.uniformStyle || "Not set (every style)"}</Row>
<Row k="Start date">{st.start ? fmtDate(st.start) : ""}</Row>
<Row k="Yearly report figure">{limited ? <><span className="tc-mono">{of}</span> garments</> : "Not measured"}</Row>
<div className="tc-people-kv">
<span className="k muted">Combined FTE</span>
{isAdmin ? (
<select className="input" aria-label="Combined FTE" value={fte} style={{ width: 150 }} onChange={(e) => act("staff.patch", { id: st.id, fte: e.target.value })}>
<option value="">Not recorded</option>
{fte && !FTE_OPTIONS.includes(fte) && <option value={fte}>{fte}</option>}
{FTE_OPTIONS.map((v) => <option key={v}>{v}</option>)}
</select>
) : <span className="v tc-mono">{fte || "Not recorded"}</span>}
</div>
</div>
</Panel>
);
}
function EditDetails({ st, base }: { st: StaffRec; base: string }) {
const { s, mutate } = useSnap();
const router = useRouter();
const [f, setF] = useState({ first: st.first, last: st.last, phone: st.phone, top: st.top, pants: st.pants, ent: st.ent === null ? "" : String(st.ent), group: st.group, dept: st.dept, ccOverride: st.ccOverride, start: st.start, uniformStyle: st.uniformStyle });
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const ccCodes = [...new Set(s.depts.map((d) => d.cc).filter(Boolean))];
const nursing = isNursing(s, { ...st, group: f.group });
const close = () => router.replace(`${base}?tab=details`, { scroll: false });
async function save() {
if (!f.first.trim() || !f.last.trim()) return;
setBusy(true); setErr("");
const r = await mutate("staff.save", { id: st.id, num: st.num, ...f, ent: f.ent === "" ? null : parseInt(f.ent, 10) || 0, notes: st.notes });
setBusy(false);
if (!r.ok) { setErr(r.error); return; }
close();
}
return (
<Panel title="Details" aside="Editing" foot={<>
<button type="button" className="btn btn-primary" onClick={save} disabled={busy || !f.first.trim() || !f.last.trim()}>Save</button>
<button type="button" className="btn btn-ghost" onClick={close}>Cancel</button>
<span className="tc-meta-line" style={{ marginLeft: "auto" }}>Staff no. <span className="tc-mono">{st.num}</span> can&apos;t change</span>
</>}>
<div className="tc-people-pad">
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
{([["first", "First name"], ["last", "Last name"], ["phone", "Phone"], ["top", "Top size"], ["pants", "Pants size"]] as const).map(([k, lbl]) => (
<Field key={k} label={lbl} error={(k === "first" || k === "last") && !f[k].trim() ? "A record needs a name." : undefined}>
{(c) => <input {...c} className="input" value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })} />}
</Field>
))}
<Field label="Uniform style" hint="Which cut the counter offers.">
{(c) => (
<select {...c} className="input" value={f.uniformStyle} onChange={(e) => setF({ ...f, uniformStyle: e.target.value })}>
<option value="">Not set (every style)</option>
{f.uniformStyle && !UNIFORM_STYLES.includes(f.uniformStyle) && <option value={f.uniformStyle}>{f.uniformStyle}</option>}
{UNIFORM_STYLES.map((v) => <option key={v}>{v}</option>)}
</select>
)}
</Field>
<Field label="Yearly report figure (garments)" hint={nursing ? undefined : "Reports only; the counter ignores it."}>
{(c) => nursing
? <input {...c} className="input" value="Not measured on the FTE table" disabled />
: <input {...c} className="input" inputMode="numeric" value={f.ent} placeholder={`Default ${s.settings.defaultEntitlement}`} onChange={(e) => setF({ ...f, ent: e.target.value.replace(/[^0-9]/g, "") })} />}
</Field>
<Field label="Staff group">
{(c) => <select {...c} className="input" value={f.group} onChange={(e) => setF({ ...f, group: e.target.value })}>{!s.settings.staffGroups.includes(f.group) && <option value={f.group}>{f.group || ""}</option>}{s.settings.staffGroups.map((g) => <option key={g}>{g}</option>)}</select>}
</Field>
<Field label="Ward">
{(c) => <select {...c} className="input" value={f.dept} onChange={(e) => setF({ ...f, dept: e.target.value })}>{!s.depts.find((d) => d.name === f.dept) && <option value={f.dept}>{f.dept || ""}</option>}{s.depts.map((d) => <option key={d.id} value={d.name}>{d.name}{d.cc ? ` (${d.cc})` : ""}</option>)}</select>}
</Field>
<Field label="Start date">{(c) => <input {...c} className="input" type="date" value={f.start} onChange={(e) => setF({ ...f, start: e.target.value })} />}</Field>
<Field label="Cost centre override">
{(c) => <select {...c} className="input" value={f.ccOverride} onChange={(e) => setF({ ...f, ccOverride: e.target.value })}><option value="">None (from ward)</option>{ccCodes.map((x) => <option key={x} value={x}>{x}</option>)}{f.ccOverride && !ccCodes.includes(f.ccOverride) && <option value={f.ccOverride}>{f.ccOverride}</option>}</select>}
</Field>
</div>
<ErrorLine msg={err} />
</div>
</Panel>
);
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Empty, LiveRegion } from "@/components/ui";
import { Panel, Tag } from "@/components/portal";
import { HandInDialog, ReturnDialog, printCreditSlip, printHandInReceipt } from "@/components/dialogs";
import { viewPhoto } from "@/lib/photo";
import { statusText } from "@/lib/staffreq";
import { approvalDeparture, facilityDate, fmtDate, issueCost, label, money, statusTag, type ApprovalRec, type IssueRec, type StaffRec } from "@/lib/compute";
import type { RequestRow } from "@/components/requests/RequestList";
import SignedToggle from "./SignedToggle";
import type { RequestsHook } from "./RequestsTab";
import type { Act } from "./shared";
/** A request whose approver is the person it is for, in the words of where it has got to. */
const selfTag = (status: string) => (status === "awaiting" ? "Theirs to approve" : status === "declined" ? "Declined by themselves" : "Self-approved");
type FormRow = { kind: "approval"; on: string; a: ApprovalRec } | { kind: "request"; on: string; r: RequestRow };
export default function HistoryTab({ st, act, setErr, req }: { st: StaffRec; act: Act; setErr: (e: string) => void; req: RequestsHook }) {
const { s, isAdmin } = useSnap();
const { byId } = useDerived();
const [ret, setRet] = useState<IssueRec | null>(null);
const [handin, setHandin] = useState(false);
const [hiMsg, setHiMsg] = useState("");
const [alt, setAlt] = useState({ garment: "", desc: "" });
const issues = s.issues.filter((i) => i.staffId === st.id).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : b.createdAt.localeCompare(a.createdAt)));
const handins = s.handins.filter((h) => h.staffId === st.id);
const alterations = s.alterations.filter((a) => a.staffId === st.id);
const orders = s.orders.filter((o) => o.staffId === st.id);
const approvals = s.approvals.filter((a) => a.staffId === st.id).reverse().sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
const openForm = (qs: string) => window.open(`/print/order-form?${qs}`, "_blank", "noopener");
/* Approvals come off the snapshot; requests off /api/requests?staff=. Each row is labelled, because a
signed approval and an unapproved ask weigh differently in an audit. */
const forms: FormRow[] = [
...approvals.map((a) => ({ kind: "approval" as const, on: a.date, a })),
...(req.data?.requests ?? []).filter((r) => r.staffId === st.id).map((r) => ({ kind: "request" as const, on: facilityDate(r.createdAt, s.settings.timezone), r })),
].sort((x, y) => (x.on < y.on ? 1 : x.on > y.on ? -1 : 0));
return (
<div className="tc-people-stack">
<Panel title="Issue history" aside={issues.length ? `${issues.length} ${issues.length === 1 ? "line" : "lines"}` : undefined}>
{issues.length === 0 ? <div className="tc-people-pad"><Empty pad={1}>Nothing issued yet.</Empty></div> : (
<div className="table-wrap">
<table className="tc-table">
<thead><tr><th>Date</th><th>Garment</th><th>Size</th><th className="num">Qty</th><th className="num">Value</th><th>Status</th><th>Signed</th><th><span className="sr-only">Action</span></th></tr></thead>
<tbody>
{issues.map((i) => {
const it = byId[i.itemId];
const size = it?.sizes[i.si] ?? "";
return (
<tr key={i.id}>
<td className="tc-mono" style={{ fontSize: 12, whiteSpace: "nowrap" }}>{fmtDate(i.date)}</td>
<td>{label(it)}</td>
<td className="tc-mono">{size}</td>
<td className="num">{i.qty}</td>
<td className="num">{money(i.qty * issueCost(i, byId))}</td>
<td>
<span className="tc-people-tags">
<Tag tone={i.returned ? "outline" : "quiet"}>{i.returned ? i.returned.cond.replace("Returned - ", "Returned ") : i.preloved ? "Pre-loved" : i.direct ? "Collected" : "Issued"}</Tag>
{i.handedIn && <Tag title={`Handed in ${fmtDate(i.handedIn)}`}>Handed in</Tag>}
{i.override && <Tag tone="low">Override</Tag>}
{i.returned?.photoId && <button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => viewPhoto(i.returned!.photoId!)}>Photo</button>}
</span>
</td>
<td><SignedToggle issue={i} what={`${label(it)} ${size}`} act={act} /></td>
<td style={{ textAlign: "right" }}>
{!i.returned && !i.handedIn && <button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Return ${label(it)} ${size}`} onClick={() => setRet(i)}>Return</button>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Panel>
<div className="tc-people-two">
<div className="tc-people-stack">
<Panel title="Hand-ins" aside={<button type="button" className="btn btn-secondary" style={{ minHeight: 28, padding: "2px 10px" }} onClick={() => setHandin(true)}>Record hand-in</button>}>
<LiveRegion msg={hiMsg} className="tc-people-pad" style={{ fontWeight: 600 }} />
{handins.length === 0 ? <div className="tc-people-pad"><Empty pad={1}>No hand-ins on file.</Empty></div> : (
<div className="tc-people-list">
{handins.map((h) => {
const good = h.lines.filter((l) => l.cond === "Good").reduce((t, l) => t + l.qty, 0);
const rag = h.lines.filter((l) => l.cond === "Rag").reduce((t, l) => t + l.qty, 0);
return (
<div key={h.id} className="tc-people-item">
<span className="d">{fmtDate(h.date)}</span>
<span className="grow">{[good ? `${good} good` : "", rag ? `${rag} rag` : ""].filter(Boolean).join(" · ") || ""} · received by {h.by}</span>
{h.credit && <Tag tone="accent">Credited</Tag>}
<button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => printHandInReceipt(s, st, h, byId)}>Receipt</button>
</div>
);
})}
</div>
)}
</Panel>
<Panel title="Alterations">
<div className="tc-people-pad" style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<input className="input" style={{ flex: 1, minWidth: 150 }} aria-label="Garment to be altered" placeholder="Garment" value={alt.garment} onChange={(e) => setAlt({ ...alt, garment: e.target.value })} />
<input className="input" style={{ flex: 1.4, minWidth: 180 }} aria-label="What the alteration is" placeholder="Alteration, e.g. hem 4cm" value={alt.desc} onChange={(e) => setAlt({ ...alt, desc: e.target.value })} />
<button type="button" className="btn btn-secondary" disabled={!alt.garment.trim()} onClick={async () => { if (await act("alteration.add", { staffId: st.id, ...alt })) setAlt({ garment: "", desc: "" }); }}>Log alteration</button>
</div>
{alterations.length === 0 ? <div className="tc-people-pad" style={{ paddingTop: 0 }}><Empty pad={1}>No alterations recorded.</Empty></div> : (
<div className="tc-people-list" style={{ borderTop: "1px solid #cfcccb" }}>
{alterations.map((a) => (
<div key={a.id} className="tc-people-item">
<span className="d">{fmtDate(a.date)}</span>
<span style={{ fontWeight: 600 }}>{a.garment}</span>
<span className="grow">{a.desc || ""}</span>
<Tag tone={a.status === "Returned to staff" ? "quiet" : a.status === "At tailor" ? "accent" : "outline"}>{a.status}</Tag>
{a.status !== "Returned to staff" && <button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => act("alteration.advance", { id: a.id })}>{a.status === "Requested" ? "Send to tailor" : "Mark returned"}</button>}
<button type="button" className="btn btn-ghost btn-icon" title="Remove entry" aria-label={`Remove the alteration logged for ${a.garment}`} onClick={() => act("alteration.remove", { id: a.id })}>×</button>
</div>
))}
</div>
)}
</Panel>
</div>
<div className="tc-people-stack">
<Panel title="Previous order forms">
<LiveRegion msg={req.error ? "The requests raised for them couldnt be loaded, so only approvals are listed." : undefined} tone="alert" className="tc-people-pad" style={{ fontWeight: 600, color: "var(--color-accent-700)" }} />
{req.data?.moreRequests && <div className="tc-people-pad tc-meta-line">Only the latest {req.data.requestLimit} requests are listed.</div>}
{!req.data && !req.error && <div className="tc-people-pad tc-meta-line">Loading requests</div>}
{forms.length === 0 && (req.data || req.error) && <div className="tc-people-pad"><Empty pad={1}>No order form printed or recorded yet.</Empty></div>}
<div className="tc-people-list">
{forms.map((x) => x.kind === "approval" ? (
<ApprovalRow key={`a${x.a.id}`} a={x.a} st={st} today={s.today} isAdmin={isAdmin} act={act} openForm={openForm}
credit={() => printCreditSlip(s, st, x.a)} />
) : (
<div key={`r${x.r.id}`} className="tc-people-item">
<span className="d">{fmtDate(x.on)}</span>
<Tag tone="quiet">Request</Tag>
<span className="grow"><b className="tc-mono">{x.r.code}</b> · {x.r.garments} {x.r.garments === 1 ? "garment" : "garments"}{x.r.reason ? ` · ${x.r.reason}` : ""} · {statusText(x.r).label}</span>
{x.r.managerId === st.id && <Tag tone="accent">{selfTag(x.r.status)}</Tag>}
<button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Print the order form again for request ${x.r.code}`} onClick={() => openForm(`request=${encodeURIComponent(x.r.id)}`)}>Print the form</button>
</div>
))}
</div>
</Panel>
<Panel title={`Orders for ${st.first || "them"}`}>
{orders.length === 0 ? <div className="tc-people-pad"><Empty pad={1}>No orders placed for them.</Empty></div> : (
<div className="tc-people-list">
{orders.map((o) => (
<div key={o.id} className="tc-people-item">
<Link href={`/app/orders/${o.id}`} className="tc-mono" style={{ fontWeight: 600 }}>{o.code}</Link>
<span className="d" style={{ minWidth: 0 }}>{fmtDate(o.date)}</span>
<span className="grow">{o.lines.reduce((t, l) => t + l.qty, 0)} items · {o.supplier}</span>
<span className={statusTag(o.status)}>{o.status}</span>
</div>
))}
</div>
)}
</Panel>
</div>
</div>
{ret && <ReturnDialog issue={ret} onClose={() => setRet(null)} />}
{handin && <HandInDialog staff={st} onClose={() => setHandin(false)} onDone={(m) => { setErr(""); setHiMsg(m); }} />}
</div>
);
}
function ApprovalRow({ a, st, today, isAdmin, act, openForm, credit }: {
a: ApprovalRec; st: StaffRec; today: string; isAdmin: boolean; act: Act; openForm: (qs: string) => void; credit: () => void;
}) {
const rem = a.sets - a.used;
const recorded = a.notes.trim();
const over = recorded ? null : approvalDeparture(a);
return (
<div>
<div className="tc-people-item">
<span className="d">{fmtDate(a.date)}</span>
<Tag>Approval</Tag>
<span className="grow">{a.sets} {a.sets === 1 ? "set" : "sets"} approved by {a.by}{a.fte ? <> · FTE <span className="tc-mono">{a.fte}</span></> : ""}</span>
{a.byStaffId === st.id && <Tag tone="accent">Self-approved</Tag>}
{a.date > today && <Tag title="Dated after today, usually a mistyped year.">Dated ahead of today</Tag>}
<Tag tone={rem > 0 ? "low" : "quiet"}>{rem > 0 ? `${rem} of ${a.sets} left` : "Fully collected"}</Tag>
{a.photoId && <button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Open the signed form photographed on ${fmtDate(a.date)}`} onClick={() => viewPhoto(a.photoId!)}>Signed form</button>}
<button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Print the approval recorded ${fmtDate(a.date)} as it was recorded`} onClick={() => openForm(`approval=${encodeURIComponent(a.id)}`)}>Print the form</button>
<button type="button" className="btn btn-ghost tc-people-ghost" onClick={credit}>Credit slip</button>
{isAdmin && <button type="button" className="btn btn-ghost btn-icon" title="Remove approval" aria-label={`Remove the ${fmtDate(a.date)} approval by ${a.by}`} onClick={() => { if (confirm("Remove this approval?")) act("approval.remove", { id: a.id }); }}>×</button>}
</div>
{(recorded || over) && <div className="tc-people-sub">{recorded || `${over} No reason was written down.`}</div>}
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { Field } from "@/components/ui";
import { Tag } from "@/components/portal";
import type { StaffRec } from "@/lib/compute";
import { fullName, type Act } from "./shared";
/* The person's manager: approves their requests in the staff app and signs their paper form. One box,
* found by search and saved on the pick. Anyone may be their own manager, marked Self-approved. */
export default function ManagerBox({ people, subject, isAdmin, act }: { people: StaffRec[]; subject: StaffRec; isAdmin: boolean; act: Act }) {
const [q, setQ] = useState("");
const [changing, setChanging] = useState(false);
const [busy, setBusy] = useState(false);
const results = useRef<(HTMLButtonElement | null)[]>([]);
const needle = q.trim().toLowerCase();
const mgr = subject.managerId ? people.find((x) => x.id === subject.managerId) : undefined;
const matches = useMemo(() => {
if (!needle) return [];
return people.filter((x) => !x.inactive && (`${x.first} ${x.last}`.toLowerCase().includes(needle) || x.num.toLowerCase().includes(needle))).slice(0, 8);
}, [people, needle]);
function onListKey(e: React.KeyboardEvent) {
const fwd = e.key === "ArrowDown", back = e.key === "ArrowUp";
if ((!fwd && !back) || matches.length === 0) return;
e.preventDefault();
const at = results.current.findIndex((el) => el === document.activeElement);
const next = at < 0 ? 0 : at + (fwd ? 1 : -1);
results.current[Math.max(0, Math.min(matches.length - 1, next))]?.focus();
}
async function save(managerId: string) {
setBusy(true);
const ok = await act("staff.patch", { id: subject.id, managerId });
setBusy(false);
if (ok) { setQ(""); setChanging(false); }
}
if (mgr && !changing) {
const self = mgr.id === subject.id;
const name = fullName(mgr);
return (
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", fontSize: 14 }}>
<b>{name}{self ? " (themselves)" : ""}</b>
{mgr.num && <span className="tc-mono" style={{ fontSize: 12, color: "#57534f" }}>{mgr.num}</span>}
{self && <Tag tone="accent">Self-approved</Tag>}
{mgr.inactive && <Tag>Inactive</Tag>}
{isAdmin && (
<span style={{ marginLeft: "auto", display: "flex", gap: 14 }}>
<button type="button" className="btn btn-ghost tc-people-ghost" disabled={busy} aria-label={`Change ${subject.first}s manager, ${name} is set`} onClick={() => { setQ(""); setChanging(true); }}>Change</button>
<button type="button" className="btn btn-ghost tc-people-ghost" disabled={busy} aria-label={`Remove ${name} as ${subject.first}s manager`}
onClick={() => { if (confirm(`Remove ${name} as ${subject.first}'s manager? ${subject.first} can't raise requests until one is set.`)) save(""); }}>Remove</button>
</span>
)}
</div>
);
}
if (!isAdmin) return <div className="tc-meta-line" style={{ fontSize: 13 }}>None set.</div>;
return (
<div>
<Field label="Manager" hint={changing ? undefined : "Approves requests and signs order forms."}>
{(c) => (
<input {...c} className="input" value={q} autoComplete="off" placeholder="Name or staff number" onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown" && matches.length) { e.preventDefault(); results.current[0]?.focus(); }
else if (e.key === "Escape" && changing) { e.preventDefault(); setQ(""); setChanging(false); }
}} />
)}
</Field>
{changing && <button type="button" className="btn btn-ghost tc-people-ghost" style={{ marginTop: 8 }} onClick={() => { setQ(""); setChanging(false); }}>Cancel</button>}
{needle !== "" && (
<div role="group" aria-label="Matching staff" className="tc-people-pick" onKeyDown={onListKey}>
{matches.map((x, i) => {
const self = x.id === subject.id;
return (
<button key={x.id} type="button" ref={(el) => { results.current[i] = el; }} disabled={busy}
aria-label={`Set ${x.first} ${x.last}${self ? " (themselves)" : ""}${x.num ? `, staff number ${x.num}` : ""} as ${subject.first}s manager`}
onClick={() => save(x.id)}>
<b>{x.first} {x.last}</b>{self ? " (themselves)" : ""}
<span style={{ color: "#57534f", marginLeft: 8 }}>{[x.num, x.dept].filter(Boolean).join(" · ")}</span>
</button>
);
})}
{matches.length === 0 && <div className="tc-meta-line">Nobody on the register matches that.</div>}
</div>
)}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useState } from "react";
import { useSnap } from "@/lib/client";
import { PageHead, Empty, ErrorLine } from "@/components/ui";
import { Icon, Tabs } from "@/components/portal";
import { StaffDialog } from "@/components/dialogs";
import { requestCounts, useRequests } from "@/components/requests/RequestList";
import type { StaffRec } from "@/lib/compute";
import { PeopleStyles, fullName } from "./shared";
import Summary from "./Summary";
import UniformTab from "./UniformTab";
import RequestsTab from "./RequestsTab";
import HistoryTab from "./HistoryTab";
import DetailsTab from "./DetailsTab";
const TAB_IDS = ["uniform", "requests", "history", "details"] as const;
type TabId = (typeof TAB_IDS)[number];
const asTab = (v: string | null): TabId => ((TAB_IDS as readonly string[]).includes(v || "") ? (v as TabId) : "uniform");
/** Head actions shared by the register and the record (ADMIN only). */
function HeadActions({ onAdd }: { onAdd: () => void }) {
const { isAdmin } = useSnap();
if (!isAdmin) return null;
return (
<>
<Link href="/app/settings?tab=data&import=staff" className="btn btn-onink">Import the register</Link>
<button type="button" className="btn btn-primary" onClick={onAdd}>Add a person</button>
</>
);
}
function Crumb({ name }: { name: string }) {
return (
<nav aria-label="Breadcrumb" className="tc-people-crumb">
<Icon name="chevronLeft" size={16} />
<Link href="/app/staff">People</Link>
<span aria-hidden="true">/</span>
<span aria-current="page">{name}</span>
</nav>
);
}
export default function StaffRecord() {
const { id } = useParams<{ id: string }>();
const { s } = useSnap();
const [add, setAdd] = useState(false);
const st = s.staff.find((x) => x.id === id);
if (!st) {
return (
<section>
<PeopleStyles />
<PageHead title="People"><HeadActions onAdd={() => setAdd(true)} /></PageHead>
<Crumb name="Not found" />
<Empty>Nobody on the register has this record. <Link href="/app/staff">Back to People</Link></Empty>
{add && <StaffDialog staff={null} onClose={() => setAdd(false)} />}
</section>
);
}
/* Keyed on the person, so walking from one record to another (through "Whose requests they
approve") starts every draft, dialog and one-time code afresh instead of carrying it over. */
return <RecordBody key={st.id} st={st} />;
}
function RecordBody({ st }: { st: StaffRec }) {
const { isAdmin, mutate } = useSnap();
const sp = useSearchParams();
const tab = asTab(sp.get("tab"));
const edit = isAdmin && tab === "details" && sp.get("edit") === "1";
const [add, setAdd] = useState(false);
const [err, setErr] = useState("");
const act = useCallback(async (op: string, payload: unknown) => {
setErr("");
const r = await mutate(op, payload);
if (!r.ok) setErr(r.error);
return r.ok;
}, [mutate]);
/* One fetch of this person's requests feeds the tab count, the Requests tab and the order-form history. */
const req = useRequests({ staffId: st.id });
const counts = req.data ? requestCounts(req.data, { staffId: st.id }) : null;
const base = `/app/staff/${st.id}`;
const hrefFor = (t: string) => (t === "uniform" ? base : `${base}?tab=${t}`);
const tabs = [
{ id: "uniform", label: "Uniform" },
{ id: "requests", label: "Requests", count: counts ? counts.open : undefined },
{ id: "history", label: "History" },
{ id: "details", label: "Details & access" },
];
const label = tabs.find((t) => t.id === tab)!.label;
return (
<section>
<PeopleStyles />
<PageHead title="People"><HeadActions onAdd={() => setAdd(true)} /></PageHead>
<Crumb name={fullName(st)} />
<Summary st={st} editHref={`${base}?tab=details&edit=1`} />
<div className="tc-people-tabs">
<Tabs label="Staff record" tabs={tabs} value={tab} hrefFor={hrefFor} />
</div>
<ErrorLine msg={err} />
<div role="tabpanel" aria-label={label} style={{ marginTop: err ? 16 : 0 }}>
{tab === "uniform" && <UniformTab st={st} act={act} setErr={setErr} />}
{tab === "requests" && <RequestsTab st={st} req={req} />}
{tab === "history" && <HistoryTab st={st} act={act} setErr={setErr} req={req} />}
{tab === "details" && <DetailsTab st={st} act={act} edit={edit} base={base} />}
</div>
{add && <StaffDialog staff={null} onClose={() => setAdd(false)} />}
</section>
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import { usePortalCounts } from "@/lib/portalcounts";
import { PageHead, Empty } from "@/components/ui";
import { Meter, Panel, Seg, SelectButton, Tag } from "@/components/portal";
import { StaffDialog } from "@/components/dialogs";
import { capState, ccFor, ccOf, csvOf, heldByStaff, isNursing, slipLive, staffName, type CapState, type GarmentCounts, type Snapshot, type StaffRec } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
import { PeopleStyles } from "./shared";
/* The register as this screen reads it: the snapshot, and the same staff indexed by id, so the
* approver gap is a lookup rather than a scan of the register per row. */
type Reg = { s: Snapshot; byId: Record<string, StaffRec> };
const approverOf = (r: Reg, st: StaffRec) => (st.managerId ? r.byId[st.managerId] : undefined);
const NOTHING: GarmentCounts = { tops: 0, pants: 0, other: 0, sets: 0 };
/** AT LIMIT is a full half (six tops, or six pairs, or six outside a set); OVER is the counter's own answer. */
const holdState = (c: CapState): "OVER" | "AT LIMIT" | "OK" =>
c.over ? "OVER" : c.tops >= c.cap || c.pants >= c.cap || c.other >= c.otherCap ? "AT LIMIT" : "OK";
/** The tooltip behind the status tag: capState's own words inside the ceiling, what is true of the locker past it. */
const holdWhy = (c: CapState) => {
if (!c.over) return c.note;
const past = [
...(c.overTops || c.overPants ? [`${c.tops} ${c.tops === 1 ? "top" : "tops"} and ${c.pants} ${c.pants === 1 ? "pair" : "pairs"}, past the ${c.cap}-set ceiling`] : []),
...(c.overOther ? [`${c.other} ${c.other === 1 ? "garment" : "garments"} outside a set, past the ${c.otherCap} allowed`] : []),
];
return `Holding ${past.join("; and ")}.`;
};
/** A code is outstanding and would still be accepted at activation (slipLive is the activation route's own test). */
const liveSlip = (s: Snapshot, st: StaffRec) => !!st.selfCode && slipLive(st.selfCodeAt, s.today, s.tz);
/* What a record still needs before the product can do its job for the person on it. `required`
* gaps stop something working; the staff app and uniform style are offers, never a backlog.
* Inactive staff have no gaps. */
type GapKey = "manager" | "fte" | "sizes" | "app" | "style";
const GAPS: { key: GapKey; short: string; filter: string; why: string; required: boolean; done?: string; missing: (r: Reg, st: StaffRec) => boolean }[] = [
{ key: "manager", short: "Approver", filter: "No approver", required: true, why: "No approver on the register, so they can't raise a request.", missing: (r, st) => { const mgr = approverOf(r, st); return !mgr || mgr.inactive; } },
{ key: "fte", short: "FTE", filter: "No FTE", required: true, why: "No FTE, so no initial kit is proposed.", missing: (r, st) => isNursing(r.s, st) && !st.fte.trim() },
{ key: "sizes", short: "Sizes", filter: "No sizes", required: true, why: "No top or pants size recorded.", missing: (_r, st) => !st.top.trim() || !st.pants.trim() },
{ key: "app", short: "Staff app", filter: "No staff app", required: false, why: "Optional: no account, and no code that still works.", done: "Nobody is waiting on that: everyone has an account or has been offered one.", missing: (r, st) => !st.selfEmail && !liveSlip(r.s, st) },
{ key: "style", short: "Uniform style", filter: "No uniform style", required: false, why: "Optional: no cut set, so every style is offered.", done: "Nobody is waiting on that: every record has a uniform style set.", missing: (_r, st) => !st.uniformStyle.trim() },
];
const REQUIRED = GAPS.filter((g) => g.required);
const SHOW = ["all", "any", "manager", "fte", "sizes", "app", "style", "unsigned", "over"] as const;
type Show = (typeof SHOW)[number];
const asShow = (v: string | null): Show => (SHOW as readonly string[]).includes(v || "") ? (v as Show) : "all";
export default function Register() {
const { s, isAdmin } = useSnap();
const counts = usePortalCounts();
const router = useRouter();
const pathname = usePathname() || "/app/staff";
const sp = useSearchParams();
const [add, setAdd] = useState(false);
const [q, setQ] = useState(() => sp.get("q") || "");
const [group, setGroup] = useState(() => sp.get("group") || "All");
const [show, setShow] = useState<Show>(() => asShow(sp.get("filter")));
const [inactive, setInactive] = useState<"hide" | "show">(() => (sp.get("inactive") === "1" ? "show" : "hide"));
/* The filters live in the address, so Today's "Chase" link and a copied URL open the same list. */
const sync = useCallback((next: { group?: string; show?: Show; inactive?: "hide" | "show" }) => {
const p = new URLSearchParams();
const g = next.group ?? group, f = next.show ?? show, i = next.inactive ?? inactive;
if (q.trim()) p.set("q", q.trim());
if (g !== "All") p.set("group", g);
if (f !== "all") p.set("filter", f);
if (i === "show") p.set("inactive", "1");
const qs = p.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}, [group, show, inactive, q, pathname, router]);
const reg = useMemo<Reg>(() => { const byId: Record<string, StaffRec> = {}; for (const st of s.staff) byId[st.id] = st; return { s, byId }; }, [s]);
const gapsOf = useCallback((st: StaffRec) => (st.inactive ? [] : GAPS.filter((g) => g.missing(reg, st))), [reg]);
const held = useMemo(() => heldByStaff(s), [s]);
const holdingOf = useCallback((st: StaffRec) => capState({ held: held[st.id] || NOTHING, capSets: s.settings.capSets }), [held, s.settings.capSets]);
/** People holding at least one garment whose receipt is not signed. */
const unsigned = useMemo(() => {
const set = new Set<string>();
for (const i of s.issues) if (!i.receipt && !i.returned && !i.handedIn) set.add(i.staffId);
return set;
}, [s]);
/* Every queue count is over the whole active register, so narrowing the search never shrinks the job. */
const tally = useMemo(() => {
const c = { any: 0, manager: 0, fte: 0, sizes: 0, app: 0, style: 0, unsigned: 0, over: 0 } as Record<Exclude<Show, "all">, number>;
for (const st of reg.s.staff) {
if (st.inactive) continue;
let some = false;
for (const g of GAPS) if (g.missing(reg, st)) { c[g.key]++; if (g.required) some = true; }
if (some) c.any++;
if (unsigned.has(st.id)) c.unsigned++;
if (holdingOf(st).over) c.over++;
}
return c;
}, [reg, unsigned, holdingOf]);
const rows = useMemo(() => {
const ql = q.trim().toLowerCase();
const wanted = (st: StaffRec) => {
if (show === "all") return true;
if (st.inactive) return false;
if (show === "any") return REQUIRED.some((g) => g.missing(reg, st));
if (show === "unsigned") return unsigned.has(st.id);
if (show === "over") return holdingOf(st).over;
return GAPS.find((g) => g.key === show)!.missing(reg, st);
};
return reg.s.staff.filter((st) => (inactive === "show" || !st.inactive) && (group === "All" || st.group === group) && wanted(st)
&& (!ql || `${st.first} ${st.last}`.toLowerCase().includes(ql) || st.num.toLowerCase().includes(ql) || st.dept.toLowerCase().includes(ql)));
}, [reg, q, group, inactive, show, unsigned, holdingOf]);
const groups = ["All", ...new Set(s.staff.map((st) => st.group).filter(Boolean))];
const nInactive = s.staff.filter((st) => st.inactive).length;
const nActive = s.staff.length - nInactive;
const nDesk = s.staff.filter((st) => !st.inactive && st.wardDesk).length;
const total = inactive === "show" ? s.staff.length : nActive;
const gapSel = GAPS.find((g) => g.key === show);
/* The file is the rows on screen. Headers are the staff import template's, so an edited register
imports back in Settings > Data. Notes and start dates stay out: this file gets emailed to wards.
Department cost centre is the ward's own code (the template's cc); Cost centre in use is what an
issue is charged to. Manager number is read back by the importer; the approver name is reference only. */
function exportCsv() {
const cols = ["Staff no.", "First name", "Last name", "Phone", "Group", "Department", "Department cost centre", "Cost centre override", "Cost centre in use", "Top", "Pants", "FTE", "Uniform style", "Manager number", "Approver name (reference only)", "Ward desk", "Entitlement", "Sets held", "Tops held", "Pairs held", "Outside a set held", "Ceiling (sets)", "Holding status", "Register status", "Missing"];
downloadCsv(`threadcount-staff-${s.today}.csv`, csvOf(cols, rows.map((st) => {
const c = holdingOf(st);
const mgr = approverOf(reg, st);
return [st.num, st.first, st.last, st.phone, st.group, st.dept, ccFor(s, st.dept), st.ccOverride, ccOf(s, st), st.top, st.pants, st.fte, st.uniformStyle, mgr?.num ?? "", mgr?.inactive ? `${staffName(mgr)} — no longer on the register` : staffName(mgr), st.wardDesk ? "Yes" : "No", st.ent ?? "", c.sets, c.tops, c.pants, c.other, c.cap,
// One value per header: holding status and register status are separate columns. They were
// written as one ("Inactive" or the holding state), which shifted every later column left
// and left Missing blank.
holdState(c), st.inactive ? "Inactive" : "Active", gapsOf(st).filter((g) => g.required).map((g) => g.short).join(", ")];
})));
}
const showOptions = [
{ value: "all", label: "Everyone" },
{ value: "any", label: `Missing something (${tally.any})` },
...REQUIRED.map((g) => ({ value: g.key, label: `${g.filter} (${tally[g.key]})` })),
...GAPS.filter((g) => !g.required).map((g) => ({ value: g.key, label: `${g.filter} (${tally[g.key]})` })),
{ value: "unsigned", label: `Receipts to sign (${tally.unsigned})` },
{ value: "over", label: `Over the ceiling (${tally.over})` },
];
const emptyText = s.staff.length === 0
? "No staff on the register yet. Add a person, or import the register."
: show === "all" ? "No staff match."
: show === "unsigned" ? (tally.unsigned === 0 ? "Every receipt is signed." : "Nobody in this search has a receipt to sign.")
: show === "over" ? (tally.over === 0 ? "Nobody is over the ceiling." : "Nobody in this search is over the ceiling.")
: tally[show] === 0
? show === "any" ? "Every record is finished." : gapSel && !gapSel.required ? gapSel.done ?? "Nobody is waiting on that." : "Nobody on the register is missing that. That list is done."
: "Nobody in this search matches. Clear the search or the group to see the rest.";
return (
<section>
<PeopleStyles />
<PageHead title="People">
{isAdmin && <Link href="/app/settings?tab=data&import=staff" className="btn btn-onink">Import the register</Link>}
{isAdmin && <button type="button" className="btn btn-primary" onClick={() => setAdd(true)}>Add a person</button>}
</PageHead>
<div className="tc-people-filters">
<input className="input" type="search" style={{ width: 260 }} aria-label="Search the register by name, number or ward" placeholder="Name, number or ward" value={q}
onChange={(e) => setQ(e.target.value)} onBlur={() => sync({})} onKeyDown={(e) => { if (e.key === "Enter") sync({}); }} />
<SelectButton label="Group" value={group} anyValue="All" options={groups.map((g) => ({ value: g, label: g }))} onChange={(v) => { setGroup(v); sync({ group: v }); }} />
<SelectButton label="Show" value={show} anyValue="all" options={showOptions} onChange={(v) => { const f = asShow(v); setShow(f); sync({ show: f }); }} />
{nInactive > 0 && (
<Seg label="Inactive" opts={["hide", "show"] as const} value={inactive} labels={{ hide: "Active", show: `Include inactive (${nInactive})` }}
onChange={(v) => { setInactive(v); sync({ inactive: v }); }} />
)}
<div className="tc-people-right">
<button type="button" className="btn btn-ghost" onClick={exportCsv} disabled={rows.length === 0}>Export CSV</button>
<Link href="/app/requests" className="btn btn-secondary">Requests <Tag tone="ink" mono>{counts.people.attention}</Tag></Link>
</div>
</div>
<p className="tc-people-metaline">
<span className="tc-mono">{nActive}</span> on the register · <span className="tc-mono">{nDesk}</span> on a ward desk · <span className={tally.over > 0 ? "hot" : undefined}><span className="tc-mono">{tally.over}</span> over the ceiling</span> · <span className={tally.any > 0 ? "hot" : undefined}><span className="tc-mono">{tally.any}</span> records to finish</span>
</p>
<Panel title={q.trim() || group !== "All" || show !== "all" ? "Matching staff" : "The register"} foot={<span className="tc-mono" style={{ fontSize: 12 }}>Showing {rows.length} of {total}</span>}>
{rows.length > 0 && (
<div className="table-wrap">
<table className="tc-table tc-people-reg">
<thead>
<tr><th>Staff no.</th><th>Name</th><th>Group</th><th>Ward</th><th>Ward desk</th><th>Cost centre</th><th>Sizes</th><th>Holding</th><th>Status</th><th>Missing</th><th><span className="sr-only">Open</span></th></tr>
</thead>
<tbody>
{rows.map((st) => {
const c = holdingOf(st), state = holdState(c);
const missing = gapsOf(st);
const name = `${st.first} ${st.last}`;
return (
<tr key={st.id} className={st.inactive ? "inactive" : undefined}>
<td className="dsk tc-mono">{st.num}</td>
<td className="nm">
<Link href={`/app/staff/${st.id}`} className="link-name">{name}</Link>
{st.phone && <div className="tc-mono dsk-meta" style={{ fontSize: 11, color: "#57534f" }}>{st.phone}</div>}
<div className="mob"><span className="tc-mono">{st.num}</span>{[st.group, st.dept].filter(Boolean).length ? ` · ${[st.group, st.dept].filter(Boolean).join(" · ")}` : ""}</div>
</td>
<td className="dsk wrapcap">{st.group}</td>
<td className="dsk wrapcap">{st.dept}</td>
<td className="dsk">{st.wardDesk ? <Tag>desk</Tag> : <span style={{ color: "var(--color-neutral-600)" }}></span>}</td>
<td className="dsk tc-mono">{ccOf(s, st) || ""}</td>
<td className="dsk sz tc-mono">{st.top || ""} / {st.pants || ""}</td>
<td className="hold">
<div className="meters">
<Meter label="Tops" value={c.tops} of={c.cap} size="sm" />
<Meter label="Pants" value={c.pants} of={c.cap} size="sm" />
{c.other > 0 && <Meter label="Other" value={c.other} of={c.otherCap} size="sm" />}
</div>
</td>
<td>
{st.inactive ? <Tag>Inactive</Tag> : <Tag tone={state === "OVER" ? "accent" : state === "AT LIMIT" ? "outline" : "quiet"} title={holdWhy(c)}>{state}</Tag>}
</td>
<td>
{missing.length
? <span className="tc-people-tags">{missing.map((g) => g.required
? <Tag key={g.key} title={g.why}>{g.short}</Tag>
: <span key={g.key} style={{ fontSize: 11, color: "var(--color-neutral-600)" }} title={g.why}>{g.short}</span>)}</span>
: <span className="dsk-dash" style={{ color: "var(--color-neutral-600)" }}></span>}
</td>
<td className="open" style={{ textAlign: "right" }}>
<Link href={`/app/staff/${st.id}`} className="btn btn-ghost tc-people-ghost" aria-label={`View ${name}`}>View </Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{rows.length === 0 && <div style={{ padding: "0 16px" }}><Empty pad={4}>{emptyText}</Empty></div>}
</Panel>
{add && <StaffDialog staff={null} onClose={() => setAdd(false)} />}
</section>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Empty, Field } from "@/components/ui";
import type { StaffRec } from "@/lib/compute";
import { fullName, type Act } from "./shared";
/* Who this person approves for: the Manager arrow drawn from the manager's end. Adding somebody here
* rewrites THEIR record, so the confirm card names that person and the manager they leave first. */
export default function ReportsBox({ subject, reports, people, isAdmin, act }: { subject: StaffRec; reports: StaffRec[]; people: StaffRec[]; isAdmin: boolean; act: Act }) {
const [q, setQ] = useState("");
const [pick, setPick] = useState<StaffRec | null>(null);
const needle = q.trim().toLowerCase();
const matches = useMemo(() => {
if (!needle) return [];
return people.filter((x) => !x.inactive && x.id !== subject.id && x.managerId !== subject.id
&& (`${x.first} ${x.last}`.toLowerCase().includes(needle) || x.num.toLowerCase().includes(needle))).slice(0, 8);
}, [people, subject.id, needle]);
const from = pick ? people.find((x) => x.id === pick.managerId) : undefined;
return (
<div>
{reports.length === 0
? <div className="tc-people-pad"><Empty pad={1}>Nobody reports to {subject.first || "them"} yet.</Empty></div>
: (
<div className="tc-people-list">
{reports.map((x) => (
<div key={x.id} className="tc-people-item">
<Link href={`/app/staff/${x.id}`} className="link-name" style={{ fontWeight: 600 }}>{fullName(x)}</Link>{x.id === subject.id ? " (themselves)" : ""}
<span className="grow tc-meta-line">{[x.num, x.dept].filter(Boolean).join(" · ") || ""}</span>
{isAdmin && (
<button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Take ${fullName(x)} off ${subject.first}s list, they will have no approver`}
onClick={() => { if (confirm(`Take ${fullName(x)} off ${subject.first}'s list? They'll have no manager, so they can't raise requests until one is set.`)) act("staff.patch", { id: x.id, managerId: "" }); }}>
Remove from list
</button>
)}
</div>
))}
</div>
)}
{isAdmin && (
<div className="tc-people-pad" style={{ borderTop: reports.length ? "1px solid #cfcccb" : undefined }}>
{subject.inactive ? (
<div className="tc-meta-line">Reactivate {subject.first} to give them reports.</div>
) : pick ? (
<div className="tc-people-confirm" style={{ marginTop: 0 }}>
Send <b>{fullName(pick)}</b>s requests to {fullName(subject)}{from ? <> instead of {fullName(from)}</> : null}?
<div style={{ display: "flex", gap: 10, marginTop: 10, flexWrap: "wrap" }}>
<button type="button" className="btn btn-secondary" onClick={async () => { if (await act("staff.patch", { id: pick.id, managerId: subject.id })) { setPick(null); setQ(""); } }}>Change {pick.first}s record</button>
<button type="button" className="btn btn-ghost" onClick={() => setPick(null)}>Cancel</button>
</div>
</div>
) : (
<>
<Field label="Add somebody who reports to them">
{(c) => <input {...c} className="input" value={q} autoComplete="off" placeholder="Name or staff number" onChange={(e) => setQ(e.target.value)} />}
</Field>
{needle !== "" && (
<div role="group" aria-label="Matching staff" className="tc-people-pick">
{matches.map((x) => (
<button key={x.id} type="button" onClick={() => setPick(x)} aria-label={`Send ${fullName(x)}s requests to ${fullName(subject)}`}>
<b>{fullName(x)}</b>
<span style={{ color: "#57534f", marginLeft: 8 }}>{[x.num, x.dept].filter(Boolean).join(" · ")}</span>
</button>
))}
{matches.length === 0 && <div className="tc-meta-line">Nobody else on the register matches that.</div>}
</div>
)}
</>
)}
</div>
)}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { ErrorLine } from "@/components/ui";
import { Seg } from "@/components/portal";
import RequestList, { requestCounts, type RequestsPayload } from "@/components/requests/RequestList";
import type { StaffRec } from "@/lib/compute";
export type RequestsHook = { data: RequestsPayload | null; error: string; loading: boolean; reload: () => Promise<void> };
export default function RequestsTab({ st, req }: { st: StaffRec; req: RequestsHook }) {
const [which, setWhich] = useState<"open" | "all">("open");
const counts = req.data ? requestCounts(req.data, { staffId: st.id }) : null;
return (
<div>
<div className="tc-people-filters">
<Seg label="Which requests" opts={["open", "all"] as const} value={which} onChange={setWhich}
labels={{ open: "Open", all: "All" }} counts={counts ? { open: counts.open, all: counts.all } : undefined} />
<div className="tc-people-right">
<Link href={`/app/requests?staff=${encodeURIComponent(st.id)}`} className="btn btn-secondary">Open in the queue</Link>
</div>
</div>
<ErrorLine msg={req.error} />
<div className="tc-pp">
<RequestList staffId={st.id} filter={which} hidePerson title={null} data={req.data} reload={req.reload} />
</div>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { ErrorLine } from "@/components/ui";
import { printAccessSlip } from "@/components/dialogs";
import { SLIP_DAYS, daysBetween, facilityDate, facilityToday, slipLive, type StaffRec } from "@/lib/compute";
import type { Act, Mutate } from "./shared";
/* Staff app activation. The code comes back once, in the op's response, and is never in the snapshot. */
export default function SelfService({ st, act, mutate, isAdmin, facility, tz }: { st: StaffRec; act: Act; mutate: Mutate; isAdmin: boolean; facility: string; tz: string }) {
const [code, setCode] = useState<string | null>(null);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const today = facilityToday(tz);
const live = slipLive(st.selfCodeAt, today, tz);
const printed = facilityDate(st.selfCodeAt ?? "", tz);
const age = printed ? daysBetween(printed, today) : null;
const left = age === null ? null : SLIP_DAYS - age;
const when = age === 0 ? "today" : age === 1 ? "yesterday" : `${age} days ago`;
if (code) {
return (
<div className="tc-people-confirm">
<div className="tc-lbl">Code for {st.first}</div>
<div className="tc-people-code">{code}</div>
<div className="tc-people-hint">Shown once. Print or copy it now.</div>
<div style={{ display: "flex", gap: 10, marginTop: 10, flexWrap: "wrap" }}>
<button type="button" className="btn btn-primary" onClick={() => printAccessSlip({ settings: { facility } }, st, code)}>Print the slip</button>
<button type="button" className="btn btn-secondary" onClick={() => navigator.clipboard?.writeText(code).catch(() => {})}>Copy</button>
<button type="button" className="btn btn-ghost" onClick={() => setCode(null)}>Done</button>
</div>
</div>
);
}
return (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 13 }}>
{st.selfEmail
? <>Signed up as <b>{st.selfEmail}</b>.</>
: st.selfCode
? age === null
? <>Code outstanding but <b>won&apos;t work</b> (no print date).</>
: live
? <>Code printed {when}, unused; expires {left === 1 ? "tomorrow" : `in ${left} days`}.</>
: <>Code printed {when} has <b>expired</b>.</>
: <>No staff-app login yet.</>}
</div>
<ErrorLine msg={err} />
{isAdmin && (
<div style={{ display: "flex", gap: 14, marginTop: 10, flexWrap: "wrap", alignItems: "center" }}>
{!st.selfEmail && (
<button type="button" className="btn btn-secondary" disabled={busy} onClick={async () => {
setBusy(true); setErr("");
const r = await mutate<{ code: string }>("staff.selfCode", { id: st.id });
setBusy(false);
if (!r.ok) { setErr(r.error); return; }
setCode(r.result.code);
}}>{busy ? "Generating…" : st.selfCode ? "New code" : "Generate a code"}</button>
)}
{st.selfCode && !st.selfEmail && <button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => act("staff.selfClear", { id: st.id })}>Cancel the code</button>}
{st.selfEmail && (
<button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => {
if (confirm(`Remove ${st.first}'s access? They'll be signed out and need a new code to get back in.`)) act("staff.selfUnlink", { id: st.id });
}}>Remove access</button>
)}
</div>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { useState } from "react";
import type { IssueRec } from "@/lib/compute";
import type { Act } from "./shared";
/** The receipt tick for one issue line (issue.receipt). Issuers may use it. */
export default function SignedToggle({ issue, what, act }: { issue: IssueRec; what: string; act: Act }) {
const [busy, setBusy] = useState(false);
return (
<button type="button" className={"tc-people-signed" + (issue.receipt ? "" : " no")} aria-pressed={issue.receipt} disabled={busy}
aria-label={`Receipt signed for ${what}`}
onClick={async () => { setBusy(true); await act("issue.receipt", { id: issue.id, receipt: !issue.receipt }); setBusy(false); }}>
{issue.receipt ? "signed" : "not signed"}
</button>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import Link from "next/link";
import { Fragment } from "react";
import { useSnap } from "@/lib/client";
import { Meter, Tag } from "@/components/portal";
import { allowanceRouteOf, capCheck, ccOf, fmtDate, type StaffRec } from "@/lib/compute";
import { fullName, pl } from "./shared";
const dot = (parts: React.ReactNode[]) => parts.map((p, i) => <Fragment key={i}>{i > 0 && " · "}{p}</Fragment>);
export default function Summary({ st, editHref }: { st: StaffRec; editHref: string }) {
const { s, isAdmin } = useSnap();
/* The counter's own question, so the meters and the counter never quote two figures. */
const held = capCheck(s, st);
const owed = held.owed.tops + held.owed.pants + held.owed.other;
const route = allowanceRouteOf(s, st);
const cc = ccOf(s, st);
const details: React.ReactNode[] = [];
if (st.group) details.push(st.group);
if (st.dept) details.push(st.dept);
if (cc) details.push(<span className="tc-mono">{cc}</span>);
if (st.uniformStyle) details.push(`${st.uniformStyle.replace("'", "")} cut`);
if (st.top || st.pants) details.push(<><span className="tc-mono">{st.top || ""}</span> / <span className="tc-mono">{st.pants || ""}</span></>);
if (route === "fte" && st.fte) details.push(<>FTE <span className="tc-mono">{st.fte}</span></>);
if (st.start) details.push(`started ${fmtDate(st.start)}`);
const reports = s.staff.filter((x) => x.managerId === st.id && !x.inactive);
const mgr = st.managerId ? s.staff.find((x) => x.id === st.managerId) : undefined;
const people: React.ReactNode[] = [];
if (reports.length) {
people.push(<>Approves requests for {pl(reports.length, "person", "people")}{st.dept && <> on <Link href={`/app/requests?ward=${encodeURIComponent(st.dept)}`}>{st.dept}</Link></>}</>);
}
if (mgr) {
people.push(<>Manager {fullName(mgr)}{mgr.id === st.id && <> <Tag tone="accent">Self-approved</Tag></>}{mgr.inactive && <> <Tag>Inactive</Tag></>}</>);
}
if (st.phone) people.push(<span className="tc-mono">{st.phone}</span>);
return (
<div className="tc-pp tc-people-summary">
<div style={{ minWidth: 0 }}>
<div className="tc-people-name">
<p className="nm">{fullName(st)}</p>
<span className="tc-mono" style={{ fontSize: 12, color: "#57534f" }}>{st.num}</span>
{st.selfEmail && <Tag>Staff app</Tag>}
{st.wardDesk && <Tag>{st.dept ? `Ward desk · ${st.dept}` : "Ward desk"}</Tag>}
{st.inactive && <Tag tone="accent">Inactive</Tag>}
</div>
{details.length > 0 && <div className="tc-people-sumline">{dot(details)}</div>}
{people.length > 0 && <div className="tc-people-sumline2"><span>{dot(people)}</span></div>}
</div>
<div className="tc-people-meters">
<Meter label="Tops" value={held.tops} of={held.cap} />
<Meter label="Pants" value={held.pants} of={held.cap} />
{held.other > 0 && <Meter label="Other" value={held.other} of={held.otherCap} />}
{owed > 0 && <div className="tc-meta-line">{owed} still on order or waiting</div>}
</div>
<div className="tc-people-actions">
{!st.inactive && <Link href={`/app/counter?staff=${encodeURIComponent(st.id)}`} className="btn btn-primary">Open at the counter</Link>}
{isAdmin && <Link href={editHref} scroll={false} className="btn btn-ghost tc-people-ghost">Edit details</Link>}
</div>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Empty, Field } from "@/components/ui";
import { Panel, QueueRow, Tag } from "@/components/portal";
import { PhotoButton, ReturnDialog } from "@/components/dialogs";
import { viewPhoto } from "@/lib/photo";
import { PICKUP_LATE_DAYS } from "@/lib/portalcounts";
import { CASUAL_ALLOWED, FTE_CASUAL, allowanceRouteOf, approvalDeparture, capCheck, daysBetween, fmtDate, initialGarments, initialSets, initialUsed, label, openApprovals, setsForFte, type ApprovalRec, type IssueRec, type StaffRec } from "@/lib/compute";
import SignedToggle from "./SignedToggle";
import { dayMonth, fullName, objectPronoun, type Act } from "./shared";
const ROUTE_LABEL = { fte: "FTE table", kit: "Starting kit", approval: "Manager approval" } as const;
export default function UniformTab({ st, act, setErr }: { st: StaffRec; act: Act; setErr: (e: string) => void }) {
const { s, isAdmin } = useSnap();
const { byId } = useDerived();
const [ret, setRet] = useState<IssueRec | null>(null);
const held = capCheck(s, st);
const holding = s.issues.filter((i) => i.staffId === st.id && !i.returned && !i.handedIn)
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : b.createdAt.localeCompare(a.createdAt)));
const garments = holding.reduce((t, i) => t + i.qty, 0);
const pickups = s.pickups.filter((p) => p.staffId === st.id && !p.pickedUp)
.map((p) => ({ p, days: p.received ? daysBetween(p.received, s.today) : 0 }))
.sort((a, b) => b.days - a.days);
return (
<div className="tc-people-uniform">
<div className="tc-people-stack">
<Panel title="Holding" aside={`${garments} ${garments === 1 ? "garment" : "garments"} · ${held.sets} of ${held.cap} sets`}>
{holding.length === 0 ? <div className="tc-people-pad"><Empty pad={1}>Holding nothing.</Empty></div> : (
<div className="table-wrap">
<table className="tc-table">
<thead><tr><th>Garment</th><th>Size</th><th className="num">Qty</th><th>Issued</th><th>Source</th><th><span className="sr-only">Action</span></th></tr></thead>
<tbody>
{holding.map((i) => {
const it = byId[i.itemId];
const size = it?.sizes[i.si] ?? "";
return (
<tr key={i.id}>
<td>{label(it)}</td>
<td className="tc-mono">{size}</td>
<td className="num">{i.qty}</td>
<td className="tc-mono" style={{ fontSize: 12, color: "#57534f", whiteSpace: "nowrap" }}>{fmtDate(i.date)}</td>
<td style={{ fontSize: 12, color: "#57534f" }}>
<span className="tc-people-tags">
<span>{i.preloved ? "Pre-loved" : i.direct ? "Collected" : "Shelf"} ·</span>
<SignedToggle issue={i} what={`${label(it)} ${size}`} act={act} />
{i.override && <Tag tone="low">Override</Tag>}
</span>
</td>
<td style={{ textAlign: "right" }}>
<button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Return ${label(it)} ${size}`} onClick={() => setRet(i)}>Return</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Panel>
{pickups.length > 0 && (
<Panel title={`Waiting for ${objectPronoun(st)}`} aside={`${pickups.length} at the counter`}>
{pickups.map(({ p, days }) => (
<QueueRow key={p.id} age={`${days}d`} ageLabel="waiting" urgent={days >= PICKUP_LATE_DAYS}
title={p.lines.map((l, n) => <span key={n}>{n > 0 && ", "}{label(byId[l.itemId])} · <span className="tc-mono">{l.size}</span> ×{l.qty}</span>)}
meta={<><Link href={`/app/orders/${p.orderId}`} className="tc-mono">{p.orderCode}</Link>{p.received ? ` · arrived ${dayMonth(p.received)}` : ""}{p.contacted ? " · called" : ""}</>}
actions={<>
{!p.contacted && <button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => act("pickup.contacted", { id: p.id, contacted: true })}>Mark called</button>}
<button type="button" className="btn btn-secondary" onClick={() => act("pickup.pickedUp", { id: p.id })}>Picked up</button>
</>} />
))}
</Panel>
)}
</div>
<div className="tc-people-stack">
<ApprovalPanel st={st} act={act} setErr={setErr} />
<NotePanel st={st} isAdmin={isAdmin} setErr={setErr} />
</div>
{ret && <ReturnDialog issue={ret} onClose={() => setRet(null)} />}
</div>
);
}
function ApprovalPanel({ st, act, setErr }: { st: StaffRec; act: Act; setErr: (e: string) => void }) {
const { s } = useSnap();
const [open, setOpen] = useState(false);
const [ap, setAp] = useState({ sets: "", fte: "", date: s.today, note: "" });
const [photo, setPhoto] = useState<string | null>(null);
const noGroup = !(st.group || "").trim();
const route = allowanceRouteOf(s, st);
const fte = st.fte || "";
const casual = fte.trim().toLowerCase() === FTE_CASUAL.toLowerCase();
const proposed = setsForFte(fte);
const kitSets = initialSets(s, st), kitOf = initialGarments(s, st), kitUsed = initialUsed(s, st.id);
const all: ApprovalRec[] = s.approvals.filter((a) => a.staffId === st.id)
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
const openAps = openApprovals(s, st.id);
const latestOpen = openAps[openAps.length - 1];
const used = openAps.reduce((t, a) => t + a.used, 0);
const left = openAps.reduce((t, a) => t + a.sets - a.used, 0);
const formPhoto = (latestOpen ?? all[0])?.photoId ?? null;
/* The form is recorded as signed by the manager on the register: one box, one signer. */
const mgr = st.managerId ? s.staff.find((x) => x.id === st.managerId) : undefined;
const apMgr = mgr && !mgr.inactive ? mgr : undefined;
const apBy = apMgr ? fullName(apMgr) : "";
const apFte = ap.fte.trim() || fte;
const apProposed = setsForFte(apFte);
const apOver = approvalDeparture({ sets: parseInt(ap.sets, 10) || 0, fte: apFte, by: apBy || "the manager" });
const apFuture = ap.date > s.today;
const invalid = !apMgr || !(parseInt(ap.sets, 10) > 0) || apFuture;
async function record() {
if (!apMgr) return;
const ok = await act("approval.add", { staffId: st.id, by: apBy, byStaffId: apMgr.id, sets: parseInt(ap.sets, 10), fte: ap.fte, date: ap.date, notes: ap.note.trim(), photoId: photo });
if (ok) { setAp({ sets: "", fte: "", date: s.today, note: "" }); setPhoto(null); setOpen(false); }
}
return (
<Panel title="Approval" aside={noGroup ? "No staff group" : ROUTE_LABEL[route]}>
<div>
{route === "fte" && !noGroup && (
<div className="tc-people-kv">
<span className="k">Proposed by FTE {fte && <span className="tc-mono">{fte}</span>}</span>
<span className="v tc-mono">{!fte ? "no FTE recorded" : casual ? `${CASUAL_ALLOWED} sets` : proposed !== null ? `${proposed} sets` : ""}</span>
</div>
)}
{route === "kit" && !noGroup && (
<div className="tc-people-kv">
<span className="k">Starting kit</span>
<span className="v tc-mono">{kitSets ?? 0} sets · {kitUsed} of {kitOf ?? 0} issued</span>
</div>
)}
<div className="tc-people-kv">
<span className="k">Signed by the manager</span>
<span className="v tc-mono">{latestOpen ? `${latestOpen.sets} sets · ${dayMonth(latestOpen.date)}` : "nothing signed"}</span>
</div>
<div className="tc-people-kv">
<span className="k">Drawn</span>
<span className="v tc-mono">{used} · {left} left</span>
</div>
<div className="tc-people-kv">
{formPhoto && <button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => viewPhoto(formPhoto)}>View the form</button>}
<button type="button" className="btn btn-ghost tc-people-ghost" style={{ marginLeft: "auto" }}
onClick={() => window.open(`/print/order-form?staff=${encodeURIComponent(st.id)}`, "_blank", "noopener")}>Print a new one</button>
</div>
<div className="tc-people-kv">
<button type="button" className="btn btn-ghost tc-people-ghost" aria-expanded={open} onClick={() => setOpen((o) => !o)}>{open ? "Close the form" : "Record a signed form"}</button>
</div>
</div>
{open && (
<div className="tc-people-form">
<div className="row">
<Field label="Sets" style={{ width: 70 }}>{(c) => <input {...c} className="input" inputMode="numeric" value={ap.sets} onChange={(e) => setAp({ ...ap, sets: e.target.value.replace(/[^0-9]/g, "") })} />}</Field>
<Field label="FTE" style={{ width: 80 }}>{(c) => <input {...c} className="input" value={ap.fte} placeholder={fte || "1.0"} onChange={(e) => setAp({ ...ap, fte: e.target.value })} />}</Field>
<Field label="Date signed" style={{ width: 170 }} error={apFuture ? "After today: check the year." : undefined}>
{(c) => <input {...c} className="input" type="date" max={s.today} value={ap.date} onChange={(e) => setAp({ ...ap, date: e.target.value })} />}
</Field>
</div>
<Field label="Note" style={{ marginTop: 8 }} hint="What the manager wrote beside the number.">
{(c) => <textarea {...c} className="input" rows={2} maxLength={400} value={ap.note} onChange={(e) => setAp({ ...ap, note: e.target.value })} />}
</Field>
<div className="row" style={{ marginTop: 10, alignItems: "center" }}>
<PhotoButton kind="approval" label="Photo the signed form" attached="Form photo attached" value={photo} onChange={setPhoto} onError={setErr} />
<button type="button" className="btn btn-secondary" disabled={invalid} onClick={record}>Record approval</button>
</div>
{!apMgr && <div className="tc-people-hint">{mgr?.inactive ? "Their manager is inactive: set a new one first." : "Set their manager first."}</div>}
{route === "fte" && apProposed !== null && <div className="tc-people-hint">The table proposes {apProposed} {apProposed === 1 ? "set" : "sets"} at FTE {apFte}.</div>}
{apOver && <div className="tc-people-hint"><b>Recorded as:</b> {apOver}</div>}
</div>
)}
</Panel>
);
}
/* Notes save 600ms after typing stops, and a pending save is flushed (not dropped) when the panel
goes away: another tab, another record, or off the page. */
function NotePanel({ st, isAdmin, setErr }: { st: StaffRec; isAdmin: boolean; setErr: (e: string) => void }) {
const { mutate, refresh } = useSnap();
const [value, setValue] = useState<string | null>(null);
const [state, setState] = useState<"" | "saving" | "saved">("");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pending = useRef<string | null>(null);
const id = st.id;
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current);
if (savedTimer.current) clearTimeout(savedTimer.current);
const v = pending.current;
if (v === null) return;
pending.current = null;
fetch("/api/mutate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "staff.patch", payload: { id, notes: v } }), keepalive: true })
.then(() => refresh()).catch(() => {});
}, [id, refresh]);
function change(v: string) {
setValue(v);
pending.current = v;
setState("saving");
if (timer.current) clearTimeout(timer.current);
if (savedTimer.current) clearTimeout(savedTimer.current);
timer.current = setTimeout(async () => {
pending.current = null;
const r = await mutate("staff.patch", { id, notes: v });
if (pending.current !== null) return;
if (!r.ok) { setState(""); setErr(r.error); return; }
setState("saved");
savedTimer.current = setTimeout(() => setState(""), 3000);
}, 600);
}
const text = value ?? st.notes;
return (
<Panel title="Note" aside={state === "saving" ? "Saving…" : state === "saved" ? "Saved" : undefined}>
<div className="tc-people-pad">
{isAdmin
? <textarea className="input" rows={3} style={{ width: "100%", boxSizing: "border-box" }} aria-label={`Notes about ${fullName(st)}`} value={text} onChange={(e) => change(e.target.value)} />
: text.trim() ? <div className="tc-people-noted" aria-label={`Notes about ${fullName(st)}`}>{text}</div> : <span className="tc-meta-line">No note.</span>}
</div>
</Panel>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
/* Helpers and the few layout rules the People screens need beyond components/portal.tsx.
* The rules are scoped to class names prefixed tc-people- and hoisted once by React (href + precedence). */
import type { StaffRec } from "@/lib/compute";
export type Act = (op: string, payload: unknown) => Promise<boolean>;
export type Mutate = <T = unknown>(op: string, payload?: unknown) => Promise<{ ok: true; result: T } | { ok: false; error: string }>;
/** "3 Feb" from an ISO date. */
export function dayMonth(iso: string | null | undefined): string {
if (!iso || iso.length < 10) return iso || "";
const d = new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("en-AU", { day: "numeric", month: "short" });
}
/** Same pronoun rule the counter uses: Women's → her, Men's → him, otherwise them. */
export function objectPronoun(st: StaffRec): "her" | "him" | "them" {
return st.uniformStyle === "Women's" ? "her" : st.uniformStyle === "Men's" ? "him" : "them";
}
export const fullName = (st: StaffRec) => `${st.first} ${st.last}`.trim();
export const pl = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
/* Most rules live in app/globals.css under the portal redesign. These tighten the register so its
* eleven columns fit a 1440px screen (with a scrollbar) without scrolling sideways: narrower cell
* padding, a Holding column sized by its meters rather than a fixed floor, and capped Group/Ward. */
const PEOPLE_CSS = `@media screen and (min-width: 781px) {
table.tc-table.tc-people-reg th, table.tc-table.tc-people-reg td { padding-left: 8px; padding-right: 8px; }
table.tc-table.tc-people-reg th:first-child, table.tc-table.tc-people-reg td:first-child { padding-left: 14px; }
table.tc-table.tc-people-reg th:last-child, table.tc-table.tc-people-reg td:last-child { padding-right: 14px; }
table.tc-table.tc-people-reg td.hold { min-width: 0; }
table.tc-table.tc-people-reg .tc-meter { gap: 6px; }
table.tc-table.tc-people-reg .tc-meter-label { width: 40px; }
table.tc-table.tc-people-reg td.wrapcap { max-width: 120px; }
table.tc-table.tc-people-reg td.open a { white-space: nowrap; }
}`;
export function PeopleStyles() {
return <style href="tc-people-register" precedence="default">{PEOPLE_CSS}</style>;
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "@/components/m/handback/HandBackTab";
export type { PersonTabProps } from "@/components/m/handback/HandBackTab";
+292
View File
@@ -0,0 +1,292 @@
"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<IconName, React.ReactNode> = {
today: <><rect x="3" y="4" width="18" height="17" /><path d="M3 9h18M8 2v4M16 2v4M8 14l2.5 2.5L16 12" /></>,
counter: <path d="M8 3l-5 3 2 5 3-1v11h8V10l3 1 2-5-5-3a4 4 0 0 1-8 0z" />,
stock: <><path d="M3 7l9-4 9 4v10l-9 4-9-4z" /><path d="M3 7l9 4 9-4M12 11v10" /></>,
orders: <><path d="M2 6h12v10H2zM14 9h4l3 3v4h-7" /><circle cx="6" cy="18" r="2" /><circle cx="17" cy="18" r="2" /></>,
people: <><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c.8-3.6 3.3-5.5 6.5-5.5s5.7 1.9 6.5 5.5" /><path d="M16 4.5a3.5 3.5 0 0 1 0 7M18.5 14.8c1.6.8 2.6 2.5 3 5.2" /></>,
reports: <path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />,
settings: <><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0" /><circle cx="16" cy="6" r="2" /><circle cx="10" cy="12" r="2" /><circle cx="18" cy="18" r="2" /></>,
phone: <path d="M5 3h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A18 18 0 0 1 3 5a2 2 0 0 1 2-2z" />,
truck: <><path d="M2 6h12v10H2zM14 9h4l3 3v4h-7" /><circle cx="6" cy="18" r="2" /><circle cx="17" cy="18" r="2" /></>,
bag: <><path d="M5 8h14l-1 13H6z" /><path d="M9 8V6a3 3 0 0 1 6 0v2" /></>,
doc: <><path d="M6 3h9l4 4v14H6z" /><path d="M9 11h7M9 15h7M9 7h3" /></>,
search: <><circle cx="11" cy="11" r="6.5" /><path d="M16 16l5 5" /></>,
scan: <path d="M3 8V4h4M17 4h4v4M21 16v4h-4M7 20H3v-4M7 8v8M10 8v8M13 8v8M16 8v8" />,
print: <><path d="M7 9V3h10v6" /><path d="M5 17H3V9h18v8h-2" /><path d="M7 14h10v7H7z" /></>,
mail: <><path d="M3 5h18v14H3z" /><path d="M3 5l9 7 9-7" /></>,
check: <path d="M4 12l5 5L20 6" />,
plus: <path d="M12 4v16M4 12h16" />,
drag: <path d="M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01" />,
chevronLeft: <path d="M15 5l-7 7 7 7" />,
chevronDown: <path d="M5 9l7 7 7-7" />,
alert: <><path d="M12 3l10 18H2z" /><path d="M12 10v5M12 18v.01" /></>,
camera: <><path d="M3 7h4l2-3h6l2 3h4v13H3z" /><circle cx="12" cy="13" r="4" /></>,
};
export function Icon({ name, size = 18, title }: { name: IconName; size?: 16 | 18; title?: string }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth={name === "drag" ? 3 : 1.8} strokeLinecap="square" strokeLinejoin="miter"
aria-hidden={title ? undefined : true} role={title ? "img" : undefined} aria-label={title} focusable="false" style={{ flex: "none" }}>
{title && <title>{title}</title>}
{GLYPHS[name]}
</svg>
);
}
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 <Tag className="tc-mono" style={{ fontWeight: weight, color, fontSize: size }}>{children}</Tag>;
}
export function Kbd({ children, onAccent }: { children: React.ReactNode; onAccent?: boolean }) {
return <kbd className={"tc-kbd" + (onAccent ? " tc-kbd-onaccent" : "")}>{children}</kbd>;
}
export function Tag({ children, tone = "outline", mono, title }: { children: React.ReactNode; tone?: "outline" | "ink" | "accent" | "low" | "quiet"; mono?: boolean; title?: string }) {
return <span className={`tag tag-${tone}${mono ? " tc-mono tc-tag-mono" : ""}`} title={title}>{children}</span>;
}
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 (
<div className={`tc-meter tc-meter-${size}${over ? " tc-meter-over" : ""}`} role="img" aria-label={`${label}: ${value} of ${of}`}>
{showLabel && <span className="tc-lbl tc-meter-label" aria-hidden="true">{label}</span>}
<span className="tc-meter-squares" aria-hidden="true">
{Array.from({ length: drawn }, (_, i) => <span key={i} className={"tc-meter-sq" + (over || i < filled ? " on" : "")} />)}
</span>
<span className="tc-mono tc-meter-text" aria-hidden="true">{value} of {of}</span>
</div>
);
}
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 (
<div className="tc-sizestrip">
{cells.map((c) => {
const cls = `tc-sizecell tc-sizecell-${c.state}${selectedSi === c.si ? " selected" : ""}`;
const inner = <><span className="tc-sizecell-size">{c.size}</span><span className="tc-sizecell-count">{c.state === "none" || c.count === null ? "" : c.count}</span></>;
const aria = c.state === "none" ? `${itemLabel} ${c.size}, not stocked` : `${itemLabel} ${c.size}: ${c.count ?? 0} on hand${note(c)}`;
return onCell
? <button key={c.si} type="button" className={cls} title={c.title} aria-label={action ? `${action} ${aria}` : aria} aria-haspopup={opensDialog ? "dialog" : undefined} aria-pressed={selectedSi === undefined ? undefined : selectedSi === c.si} onClick={() => onCell(c.si)}>{inner}</button>
: <span key={c.si} className={cls} title={c.title} role="img" aria-label={aria}>{inner}</span>;
})}
</div>
);
}
export function Seg<T extends string>({ 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<Record<T, React.ReactNode>>;
counts?: Partial<Record<T, number | string>>;
hrefs?: Partial<Record<T, string>>;
tone?: "paper" | "ink";
size?: "md" | "sm";
disabled?: boolean;
style?: React.CSSProperties;
}) {
const body = (o: T) => (
<>
{labels?.[o] ?? o}
{counts?.[o] !== undefined && counts[o] !== "" && <span className="tc-seg-count tc-mono">{counts[o]}</span>}
</>
);
return (
<div className={`seg tc-seg tc-seg-${tone} tc-seg-${size}`} style={style} role={label ? "group" : undefined} aria-label={label}>
{opts.map((o) => {
const on = value === o;
const cls = "seg-opt tc-seg-opt" + (on ? " on" : "");
const href = hrefs?.[o];
if (href && !disabled) return <Link key={o} href={href} className={cls} aria-current={on ? "page" : undefined} onClick={() => onChange(o)}>{body(o)}</Link>;
return <button key={o} type="button" className={cls} aria-pressed={on} disabled={disabled} onClick={() => onChange(o)}>{body(o)}</button>;
})}
</div>
);
}
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 (
<div className="tc-tabs" role="tablist" aria-label={label}>
{tabs.map((t, i) => {
const on = t.id === value;
return (
<Link key={t.id} ref={(el) => { 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 && <Tag tone="ink" mono>{t.count}</Tag>}
</Link>
);
})}
</div>
);
}
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 (
<section id={id} className={"tc-pp" + (flag ? " tc-flag" : "")} aria-labelledby={hid}>
<div className="tc-pp-head">
<span className="tc-pp-title">
{icon && <Icon name={icon} size={16} />}
<H id={hid} className="tc-pp-h">{title}</H>
{count !== undefined && <span className="tag tag-ink tc-mono tc-pp-count">{count}</span>}
</span>
{aside && <span className="tc-pp-aside">{aside}</span>}
</div>
<div className="tc-pp-body">{children}</div>
{foot && <div className="tc-pp-foot">{foot}</div>}
</section>
);
}
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 <Panel id={id} icon={icon} title={title} count={count} aside={aside}>{children}</Panel>;
}
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 (
<div className={"tc-qrow" + (urgent ? " urgent" : "")}>
<div className="tc-qrow-age">
<div className="tc-mono tc-qrow-agefig">{age}</div>
<div className="tc-qrow-agelbl">{ageLabel}</div>
</div>
<div className="tc-qrow-main">
<div className="tc-qrow-title">{title}{titleMeta && <> <span className="tc-mono tc-qrow-titlemeta">{titleMeta}</span></>}</div>
<div className="tc-qrow-meta">{meta}</div>
</div>
{actions && <div className="tc-qrow-actions">{actions}</div>}
</div>
);
}
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 (
<span className={`tc-qty tc-qty-${size}`}>
<button type="button" className="tc-qty-btn" aria-label={`One fewer ${label}`} disabled={atMin} onClick={() => onChange(Math.max(min, value - 1))}></button>
<output className="tc-qty-val tc-mono" aria-live="polite">{value}</output>
<button type="button" className="tc-qty-btn" aria-label={`One more ${label}`} disabled={atMax} onClick={() => onChange(max === undefined ? value + 1 : Math.min(max, value + 1))}>+</button>
</span>
);
}
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 (
<span className="tc-selectbtn btn btn-secondary">
<span aria-hidden="true">{label}: {shown}</span>
<Icon name="chevronDown" size={16} />
<select aria-label={label} value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</span>
);
}
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<HTMLDivElement>(null);
const btn = useRef<HTMLButtonElement>(null);
const menuId = useId();
const shown = items.filter((i) => !i.hidden);
const focusItem = (i: number) => {
const els = wrap.current?.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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 (
<div className="tc-more" ref={wrap} onKeyDown={open ? onKey : undefined}>
<button ref={btn} type="button" className={"btn " + (tone === "ink" ? "btn-onink" : "btn-secondary")} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? menuId : undefined}
onClick={() => setOpen((o) => !o)}>
{label}<Icon name="chevronDown" size={16} />
</button>
{open && (
<div id={menuId} className="tc-more-menu" role="menu" aria-label={label}>
{shown.map((it) => {
const cls = "tc-more-item" + (it.danger ? " danger" : "");
if (it.href && !it.disabled) {
return <Link key={it.label} role="menuitem" tabIndex={-1} className={cls} href={it.href} target={it.newTab ? "_blank" : undefined} rel={it.newTab ? "noopener" : undefined}
onClick={() => { it.onSelect?.(); setOpen(false); }}>{it.label}</Link>;
}
return <button key={it.label} type="button" role="menuitem" tabIndex={-1} className={cls} aria-disabled={it.disabled || undefined}
onClick={() => { if (it.disabled) return; done(); it.onSelect?.(); }}>{it.label}</button>;
})}
</div>
)}
</div>
);
}
export function Figures({ items }: { items: { value: React.ReactNode; label: string; note?: React.ReactNode; flag?: boolean }[] }) {
return (
<div className="tc-figs" style={{ gridTemplateColumns: `repeat(${Math.max(1, items.length)}, minmax(0, 1fr))` }}>
{items.map((it) => (
<div key={it.label} className={"tc-fig" + (it.flag ? " flag" : "")}>
<div className="tc-mono tc-fig-value">{it.flag && <span className="tc-mark" aria-hidden="true" />}{it.value}</div>
<div className="tc-lbl">{it.label}</div>
{it.note && <div className="tc-meta-line">{it.note}</div>}
</div>
))}
</div>
);
}
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 <div className="tc-bar" role="img" aria-label={label}><span style={{ width: pct + "%" }} /></div>;
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
/* The four month-end steps across the top of Reports. Every state comes from monthEnd() in
* lib/portalcounts.ts, so Today's checklist and this strip agree. */
import Link from "next/link";
import { useDerived, useSnap } from "@/lib/client";
import { monthEnd } from "@/lib/portalcounts";
import { Icon } from "@/components/portal";
import { shortDate } from "./bits";
const cell: React.CSSProperties = { background: "var(--color-bg)", padding: "14px 18px", minWidth: 0 };
const state: React.CSSProperties = { marginTop: 6, fontWeight: 600, display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" };
const inline: React.CSSProperties = { minHeight: 0, padding: 0 };
export default function MonthEndStrip({ month, onJournal, onPrint }: { month: string; onJournal: () => void; onPrint: () => void }) {
const { s } = useSnap();
const { L, byId, staffById } = useDerived();
const me = monthEnd(s, L, byId, staffById, month);
return (
<section className="tc-rep-strip" aria-label="Month-end steps">
<div style={cell}>
<div className="tc-lbl">1 · Deliveries booked in</div>
{me.deliveriesOverdue > 0
? <div style={{ ...state, color: "var(--color-accent-700)" }}><Icon name="alert" size={16} /><Link href="/app/orders/all" style={{ color: "inherit" }}>{me.deliveriesOverdue} overdue</Link></div>
: <div style={state}><Icon name="check" size={16} />All in</div>}
</div>
<div style={cell}>
<div className="tc-lbl">2 · Stock take filed</div>
{me.stocktakeFiled
? <div style={state}>Filed <span className="tc-mono">{shortDate(me.stocktakeFiled.date)}</span></div>
: <div style={state}><span>Not yet ·</span><Link href="/app/stock?tab=count" className="btn btn-ghost" style={inline}>Start</Link></div>}
</div>
<div style={cell}>
<div className="tc-lbl">3 · Journal</div>
{!me.stocktakeFiled
? <div style={{ ...state, color: "#6c6764" }}>Ready after the count</div>
: me.unallocated > 0
? <div style={state}><button type="button" className="btn btn-ghost" style={{ ...inline, color: "var(--color-accent-700)" }} onClick={onJournal}><span className="tc-mono">{me.unallocated}</span>&nbsp;unallocated</button></div>
: <div style={state}><Icon name="check" size={16} />Ready</div>}
</div>
<button type="button" className="tc-rep-pack" aria-label="Print the month-end pack" onClick={onPrint}
style={{ ...cell, background: "#201e1d", color: "#f3f2f2", border: 0, textAlign: "left", font: "inherit", cursor: "pointer" }}>
<div className="tc-lbl" style={{ color: "#b5b1af" }}>4 · Month-end pack</div>
<div style={state}>PDF + journal CSV + valuation</div>
</button>
</section>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { LiveRegion } from "@/components/ui";
import { Panel } from "@/components/portal";
import { useSnap } from "@/lib/client";
import { fmtDate, money } from "@/lib/compute";
import { HeadActions, PanelEmpty, TableWrap, TOTAL, plural } from "./bits";
import type { ReportData } from "./useReportData";
function SubHead({ children }: { children: React.ReactNode }) {
return <h3 className="tc-lbl" style={{ margin: 0, padding: "12px 16px 4px", borderTop: "2px solid #201e1d" }}>{children}</h3>;
}
/* The yearly figure is a reporting number, not a limit, so it lives here and nowhere else. */
function YearlyFigure() {
const { s, isAdmin, busy, mutate } = useSnap();
const [editing, setEditing] = useState(false);
const [val, setVal] = useState("");
const [msg, setMsg] = useState<{ text: string; err: boolean } | null>(null);
const current = s.settings.defaultEntitlement;
async function save() {
const n = Number(val);
if (!Number.isInteger(n) || n < 0) { setMsg({ text: "Enter a whole number, 0 or more.", err: true }); return; }
const r = await mutate("settings.update", { defaultEntitlement: n });
if (!r.ok) { setMsg({ text: r.error, err: true }); return; }
setEditing(false);
setMsg({ text: "Saved.", err: false });
}
return (
<section className="tc-pp" aria-label="Yearly figure for reports">
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", padding: "11px 16px" }}>
{!editing ? (
<>
<span>Yearly figure for reports · <span className="tc-mono" style={{ fontWeight: 600 }}>{current}</span> garments per person</span>
{isAdmin && <button type="button" className="btn btn-ghost" style={{ minHeight: 0, padding: 0, marginLeft: "auto" }} onClick={() => { setVal(String(current)); setMsg(null); setEditing(true); }}>Change</button>}
</>
) : (
<form style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }} onSubmit={(e) => { e.preventDefault(); void save(); }}>
<label htmlFor="tc-rep-yearly">Yearly figure for reports</label>
<input id="tc-rep-yearly" className="input tc-mono" type="number" min={0} step={1} inputMode="numeric" value={val} onChange={(e) => setVal(e.target.value)} style={{ width: 90 }} autoFocus />
<span>garments per person</span>
<button type="submit" className="btn btn-primary" disabled={busy}>Save</button>
<button type="button" className="btn btn-ghost" onClick={() => { setEditing(false); setMsg(null); }}>Cancel</button>
</form>
)}
</div>
<LiveRegion tone={msg?.err ? "alert" : "status"} msg={msg?.text}
style={{ padding: "0 16px 10px", fontSize: 12, fontWeight: 600, color: msg?.err ? "var(--color-accent-700)" : "var(--color-neutral-700)" }} />
</section>
);
}
export default function PeopleTab({ d }: { d: ReportData }) {
const { R, mLbl, csv, print } = d;
return (
<div className="tc-rep-stack">
<Panel title="Exceptions" flag={R.excRows.length > 0}
aside={<HeadActions name="staff exceptions" aside={<>threshold <span className="tc-mono">{R.excThreshold}</span> items/month</>} onCsv={csv.exceptions} onPrint={print.exceptions} />}>
{R.excRows.length === 0 ? <PanelEmpty>No exceptions in {mLbl}.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Staff</th><th>Group</th><th>Cost centre</th><th className="num">Items (month)</th><th className="num">Items (FY)</th><th>Flag</th></tr></thead>
<tbody>{R.excRows.map((r, i) => <tr key={i}><td>{r.who}</td><td>{r.group}</td><td className="tc-mono">{r.cc || "—"}</td><td className="num">{r.mQty}</td><td className="num">{r.fyQty}</td><td><span style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>{r.flags.map((f, j) => <span key={j} className="tag tag-flag">{f}</span>)}</span></td></tr>)}</tbody>
</TableWrap>
)}
</Panel>
<Panel title="Approvals outstanding" flag={R.apprTot > 0}
aside={<HeadActions name="approvals outstanding" aside={R.apprTot > 0 ? `${plural(R.apprTot, "set")} outstanding` : undefined} onCsv={csv.approvals} onPrint={print.approvals} />}>
{R.apprRows.length === 0 ? <PanelEmpty>No uncollected approvals.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Staff</th><th>Ward</th><th>Approved by</th><th>Date</th><th className="num">Sets approved</th><th className="num">Collected</th><th className="num">Remaining</th></tr></thead>
<tbody>
{R.apprRows.map((r, i) => <tr key={i}><td>{r.who}</td><td>{r.dept}</td><td>{r.by}</td><td className="tc-mono">{fmtDate(r.date)}</td><td className="num">{r.sets}</td><td className="num">{r.used}</td><td className="num" style={{ fontWeight: 600, color: "var(--color-accent-700)" }}>{r.rem}</td></tr>)}
<tr><td style={TOTAL}>TOTAL OUTSTANDING</td><td></td><td></td><td></td><td></td><td></td><td className="num" style={TOTAL}>{R.apprTot}</td></tr>
</tbody>
</TableWrap>
)}
</Panel>
<Panel title="Pre-loved" aside={<HeadActions name="pre-loved issues, hand-ins and pool" aside={<>saved <span className="tc-mono">{money(R.plSaved)}</span></>} onCsv={csv.preloved} onPrint={print.preloved} />}>
<h3 className="tc-lbl" style={{ margin: 0, padding: "12px 16px 4px" }}>Issued free · <span className="tc-mono">{R.plQty}</span></h3>
{R.plIssueRows.length === 0 ? <PanelEmpty>Nothing issued from the pool in {mLbl}.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Date</th><th>Staff</th><th>Item</th><th>Size</th><th className="num">Qty</th><th className="num">Value saved</th></tr></thead>
<tbody>{R.plIssueRows.map((r, i) => <tr key={i}><td className="tc-mono">{fmtDate(r.date)}</td><td>{r.who}</td><td>{r.item}</td><td className="tc-mono">{r.size}</td><td className="num">{r.qty}</td><td className="num">{money(r.saved)}</td></tr>)}</tbody>
</TableWrap>
)}
<SubHead>Hand-ins · <span className="tc-mono">{R.ragMonth}</span> to rag</SubHead>
{R.hiRows.length === 0 ? <PanelEmpty>No hand-ins in {mLbl}.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Date</th><th>Staff</th><th>Received by</th><th className="num">Good</th><th className="num">Rag</th><th>Allowance</th></tr></thead>
<tbody>{R.hiRows.map((r, i) => <tr key={i}><td className="tc-mono">{fmtDate(r.date)}</td><td>{r.who}</td><td>{r.by}</td><td className="num">{r.good}</td><td className="num">{r.rag}</td><td>{r.credit}</td></tr>)}</tbody>
</TableWrap>
)}
<SubHead>Pool today · <span className="tc-mono">{R.plPoolTotal}</span> at $0</SubHead>
{R.plPoolRows.length === 0 ? <PanelEmpty>The pool is empty.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Item</th><th>Sizes on hand</th><th className="num">Total</th></tr></thead>
<tbody>{R.plPoolRows.map((r, i) => <tr key={i}><td>{r.item}</td><td className="tc-mono">{r.sizes}</td><td className="num" style={{ fontWeight: 600 }}>{r.total}</td></tr>)}</tbody>
</TableWrap>
)}
</Panel>
<YearlyFigure />
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useMemo, useState } from "react";
import { Dialog } from "@/components/ui";
import { Bar, Figures, Panel } from "@/components/portal";
import { csvEsc, csvOf, fmtDate, money, monthLabel, prevMonth } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
import { HeadActions, PanelEmpty, TableWrap, TOTAL, plural } from "./bits";
import type { ReportData } from "./useReportData";
export const JOURNAL_ID = "tc-rep-journal";
export default function SpendTab({ d, onMonth }: { d: ReportData; onMonth: (m: string) => void }) {
const { R, month, mLbl, csv, print, jnTotItems, jnTot } = d;
// Only the keys are held: the lines are recounted from the snapshot on every render, so the
// dialog still agrees with the row that opened it if stock moves while it is open.
const [drill, setDrill] = useState<{ cc: string; dept: string; keys: string[] } | null>(null);
const drillRows = useMemo(() => (drill ? drill.keys.flatMap((k) => R.ccLines[k] || []).sort((a, b) => a.date.localeCompare(b.date) || a.who.localeCompare(b.who) || a.item.localeCompare(b.item)) : []), [drill, R]);
const drillQty = drillRows.reduce((t, r) => t + r.qty, 0), drillAmt = drillRows.reduce((t, r) => t + r.amt, 0);
const csvDrill = () => drill && downloadCsv(`threadcount-cost-centre-${drill.cc.replace(/[^A-Za-z0-9]+/g, "-").toLowerCase()}-${month}.csv`, `Issues behind cost centre,${csvEsc(drill.cc)},${month}\n\n` + csvOf(["Date", "Staff", "Item", "Size", "Qty", "Unit cost", "Value"], [...drillRows.map((r) => [r.date, r.who, r.item, r.size, r.qty, r.unit.toFixed(2), r.amt.toFixed(2)] as (string | number)[]), ["TOTAL", "", "", "", drillQty, "", drillAmt.toFixed(2)]]));
const open = (cc: string, dept: string, keys: string[]) => setDrill({ cc: cc === "—" ? "UNALLOCATED" : cc, dept, keys });
const drillBtn = (cc: string, dept: string, keys: string[], items: number, amt: number) => {
const name = cc === "—" ? "UNALLOCATED" : cc;
return (
<button type="button" className="tc-rep-drill tc-mono" aria-haspopup="dialog" aria-label={`Show the ${plural(items, "item")} issued behind ${name}, ${money(amt)} in ${mLbl}`}
onClick={(e) => { e.stopPropagation(); open(cc, dept, keys); }}>{cc}</button>
);
};
const pm = prevMonth(month);
const pct = R.totPrev > 0 ? Math.round(((R.totAmt - R.totPrev) / R.totPrev) * 100) : null;
const pctText = pct === null ? "—" : (pct > 0 ? "+" : pct < 0 ? "" : "") + Math.abs(pct) + "%";
const ccShown = R.ccRows.filter((r) => r.items > 0 || r.amt > 0);
const ccMax = Math.max(0, ...ccShown.map((r) => r.amt));
const monthOnly = monthLabel(month, { month: "long" });
return (
<div className="tc-rep-stack">
<Figures items={[
{ value: money(R.totAmt), label: "Issued value", note: <>vs {monthLabel(pm, { month: "long" })} <span className="tc-mono">{pctText}</span></> },
{ value: R.totItems + R.plQty, label: "Garments issued", note: <><span className="tc-mono">{R.plQty}</span> of them pre-loved</> },
{ value: money(R.ordSpend), label: "Ordered from suppliers", note: <><span className="tc-mono">{R.ordCount}</span> order{R.ordCount === 1 ? "" : "s"}</> },
]} />
<div className="tc-grid tc-rep-grid">
<Panel title="By cost centre" aside={<HeadActions name="issued value by cost centre" aside="click a row for the lines" onCsv={csv.costCentres} onPrint={print.costCentres} />}>
{ccShown.length === 0 ? <PanelEmpty>No issues recorded in {mLbl}.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Cost centre</th><th>Ward</th><th style={{ width: "34%" }}><span className="sr-only">Share of the largest</span></th><th className="num">Items</th><th className="num">Value</th></tr></thead>
<tbody>
{ccShown.map((r) => (
<tr key={r.key} onClick={() => open(r.cc, r.dept, [r.key])} style={{ cursor: "pointer" }}>
<td>{drillBtn(r.cc, r.dept, [r.key], r.items, r.amt)}</td>
<td>{r.dept}</td>
<td><Bar value={r.amt} max={ccMax} label={`${money(r.amt)} of ${money(ccMax)}`} /></td>
<td className="num">{r.items}</td>
<td className="num" style={{ fontWeight: 600 }}>{money(r.amt)}</td>
</tr>
))}
<tr><td style={TOTAL}>TOTAL</td><td></td><td></td><td className="num" style={TOTAL}>{R.totItems}</td><td className="num" style={TOTAL}>{money(R.totAmt)}</td></tr>
</tbody>
</TableWrap>
)}
</Panel>
<Panel title="By staff group" aside={monthOnly}>
{R.groupRows.length === 0 ? <PanelEmpty>Nothing issued in {mLbl}.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Group</th><th className="num">People</th><th className="num">Value</th></tr></thead>
<tbody>{R.groupRows.map((g) => <tr key={g.g}><td>{g.g}</td><td className="num">{g.people}</td><td className="num">{money(g.amt)}</td></tr>)}</tbody>
</TableWrap>
)}
</Panel>
</div>
<div className="tc-grid tc-rep-grid">
<Panel title="By staff member" aside={<HeadActions name="issued value by staff member" onCsv={csv.staff} onPrint={print.staff} />}>
{R.staffRows.length === 0 ? <PanelEmpty>Nothing issued in {mLbl}.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Staff</th><th>Cost centre</th><th className="num">Items</th><th className="num">Value</th></tr></thead>
<tbody>{R.staffRows.map((r, i) => <tr key={i}><td>{r.who}</td><td className="tc-mono">{r.cc || "—"}</td><td className="num">{r.items}</td><td className="num" style={{ fontWeight: 600 }}>{money(r.amt)}</td></tr>)}</tbody>
</TableWrap>
)}
</Panel>
<Panel title="Issued value · last 6 months">
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, height: 140, padding: "12px 16px" }}>
{R.trend.map((b) => (
<button type="button" key={b.m} className="tc-rep-trend" aria-label={`Show ${monthLabel(b.m)}, ${money(b.amt)} issued`} aria-pressed={b.sel} onClick={() => onMonth(b.m)}>
<span className="tc-mono" style={{ fontSize: 10, color: "var(--color-neutral-700)", whiteSpace: "nowrap", overflow: "hidden" }}>{b.amt ? money(b.amt) : ""}</span>
<span aria-hidden="true" style={{ display: "block", width: "100%", height: b.h, background: b.sel ? "var(--color-accent-600)" : "#201e1d" }} />
<span className="tc-lbl" style={{ fontSize: 10 }}>{b.label}</span>
</button>
))}
</div>
</Panel>
</div>
<Panel id={JOURNAL_ID} title="Journal" flag={R.jnUnallocated}
aside={<HeadActions name="the journal" aside={<>GL <span className="tc-mono">{R.glAcct}</span></>} onCsv={csv.journal} csvText="Export journal CSV" csvSecondary onPrint={print.journal} />}
foot={R.jnUnallocated ? <span className="tc-meta-line" style={{ color: "var(--color-accent-700)", fontWeight: 600 }}><span className="tc-mark" aria-hidden="true" />UNALLOCATED = no cost centre</span> : undefined}>
{R.jnRows.length === 0 ? <PanelEmpty>No issues to journal in {mLbl}.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Cost centre</th><th>Department</th><th>GL account</th><th>Description</th><th className="num">Items</th><th className="num">Debit</th></tr></thead>
<tbody>
{R.jnRows.map((r) => <tr key={r.cc + r.dept}><td>{drillBtn(r.cc, r.dept, r.keys, r.items, r.debit)}</td><td>{r.dept}</td><td className="tc-mono">{r.gl}</td><td>{r.desc}</td><td className="num">{r.items}</td><td className="num" style={{ fontWeight: 600 }}>{money(r.debit)}</td></tr>)}
<tr><td style={TOTAL}>TOTAL</td><td></td><td></td><td></td><td className="num" style={TOTAL}>{jnTotItems}</td><td className="num" style={TOTAL}>{money(jnTot)}</td></tr>
</tbody>
</TableWrap>
)}
</Panel>
<Panel title="Financial year" aside={<HeadActions name="the financial year summary" aside={`to the end of ${mLbl}`} onCsv={csv.fy} onPrint={print.fy} />}>
<TableWrap>
<thead><tr><th>Month</th><th className="num">Items issued</th><th className="num">Issued value</th><th className="num">Orders placed</th></tr></thead>
<tbody>
{R.fyRows.map((m) => <tr key={m.m}><td>{m.label}</td><td className="num">{m.items}</td><td className="num" style={{ fontWeight: 600 }}>{money(m.issued)}</td><td className="num">{money(m.orders)}</td></tr>)}
<tr><td style={TOTAL}>FY TOTAL</td><td className="num" style={TOTAL}>{R.fyTot.items}</td><td className="num" style={TOTAL}>{money(R.fyTot.issued)}</td><td className="num" style={TOTAL}>{money(R.fyTot.orders)}</td></tr>
</tbody>
</TableWrap>
</Panel>
{drill && (
<Dialog title={`Issues behind ${drill.cc}${mLbl}`} width={820} onClose={() => setDrill(null)}
sub={`${drill.dept} · ${plural(drillQty, "item")} · ${money(drillAmt)}`}
foot={<>
{drillRows.length > 0 && <button type="button" className="btn btn-secondary" style={{ marginRight: "auto" }} onClick={csvDrill}>Export CSV</button>}
<button type="button" className="btn btn-ghost" onClick={() => setDrill(null)}>Close</button>
</>}>
{drillRows.length === 0 ? <PanelEmpty>Nothing was issued against this cost centre in {mLbl}.</PanelEmpty> : (
<div style={{ marginTop: 12 }}>
<TableWrap>
<thead><tr><th>Date</th><th>Staff</th><th>Item</th><th>Size</th><th className="num">Qty</th><th className="num">Unit cost</th><th className="num">Value</th></tr></thead>
<tbody>
{drillRows.map((r, i) => <tr key={i}><td className="tc-mono">{fmtDate(r.date)}</td><td>{r.who}</td><td>{r.item}</td><td className="tc-mono">{r.size}</td><td className="num">{r.qty}</td><td className="num">{money(r.unit)}</td><td className="num" style={{ fontWeight: 600 }}>{money(r.amt)}</td></tr>)}
<tr><td style={TOTAL}>TOTAL</td><td></td><td></td><td></td><td className="num" style={TOTAL}>{drillQty}</td><td></td><td className="num" style={TOTAL}>{money(drillAmt)}</td></tr>
</tbody>
</TableWrap>
</div>
)}
</Dialog>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { Figures, Panel } from "@/components/portal";
import { fmtDate, money, signedInt, signedMoney } from "@/lib/compute";
import { HeadActions, PanelEmpty, TableWrap, TOTAL, plural } from "./bits";
import type { ReportData } from "./useReportData";
export default function StockTab({ d }: { d: ReportData }) {
const { R, mLbl, csv, print } = d;
return (
<div className="tc-rep-stack">
<Panel title="Valuation" flag={R.negSizes > 0}
aside={<HeadActions name="the stock valuation" aside={R.negSizes > 0 ? `${plural(R.negSizes, "size")} negative on hand` : "today, at catalogue cost"} onCsv={csv.valuation} onPrint={print.valuation} />}
foot={R.negSizes > 0 ? <span className="tc-meta-line" style={{ color: "var(--color-accent-700)", fontWeight: 600 }}><span className="tc-mark" aria-hidden="true" />Negative sizes count as 0</span> : undefined}>
{R.valRows.length === 0 ? <PanelEmpty>Nothing on hand.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Item</th><th>SKU</th><th>Supplier</th><th className="num">Units on hand</th><th className="num">Unit cost</th><th className="num">Value</th></tr></thead>
<tbody>
{R.valRows.map((r, i) => <tr key={i}><td>{r.item}</td><td className="tc-mono">{r.sku}</td><td>{r.supplier}</td><td className="num">{r.units}</td><td className="num">{money(r.cost)}</td><td className="num" style={{ fontWeight: 600 }}>{money(r.val)}</td></tr>)}
<tr><td style={TOTAL}>TOTAL</td><td></td><td></td><td className="num" style={TOTAL}>{R.valTotUnits}</td><td></td><td className="num" style={TOTAL}>{money(R.valTot)}</td></tr>
</tbody>
</TableWrap>
)}
</Panel>
<Panel title="Shrinkage" aside={<HeadActions name="shrinkage" aside={`FY to the end of ${mLbl}`} onCsv={csv.shrinkage} onPrint={print.shrinkage} />}>
<div style={{ padding: 16 }}>
<Figures items={[
{ value: R.shRows.length, label: "Stocktakes this FY" },
{ value: signedInt(R.shU), label: "Net variance, units", flag: R.shV < 0 },
{ value: signedMoney(R.shV), label: "Net value", flag: R.shV < 0 },
]} />
</div>
{R.shRows.length === 0 ? <PanelEmpty>No stocktakes filed this financial year.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Date</th><th>Counted by</th><th className="num">Lines counted</th><th className="num">Variances</th><th className="num">Net units</th><th className="num">Net value</th></tr></thead>
<tbody>{R.shRows.map((r, i) => <tr key={i}><td className="tc-mono">{fmtDate(r.date)}</td><td>{r.by}</td><td className="num">{r.counted}</td><td className="num">{r.variances}</td><td className="num">{signedInt(r.net)}</td><td className="num" style={{ fontWeight: 600 }}>{signedMoney(r.netVal)}</td></tr>)}</tbody>
</TableWrap>
)}
</Panel>
<Panel title="Top stock" aside={<HeadActions name="top stock" aside={mLbl} onCsv={csv.topStock} onPrint={print.topStock} />}>
{R.topRows.length === 0 ? <PanelEmpty>Nothing issued in {mLbl}.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>#</th><th>Item</th><th>Supplier</th><th className="num">Qty (month)</th><th className="num">Value (month)</th><th className="num">Share</th><th className="num">Qty (FY)</th></tr></thead>
<tbody>{R.topRows.map((r) => <tr key={r.n}><td className="tc-mono" style={{ color: "var(--color-neutral-700)" }}>{r.n}</td><td>{r.item}</td><td>{r.supplier}</td><td className="num" style={{ fontWeight: 600 }}>{r.qty}</td><td className="num">{money(r.val)}</td><td className="num">{r.share}</td><td className="num">{r.fyQty}</td></tr>)}</tbody>
</TableWrap>
)}
</Panel>
<Panel title="Supplier spend" aside={<HeadActions name="supplier spend" aside={mLbl} onCsv={csv.suppliers} onPrint={print.suppliers} />}>
{R.supRows.length === 0 ? <PanelEmpty>No supplier orders placed in {mLbl}.</PanelEmpty> : (
<TableWrap>
<thead><tr><th>Supplier</th><th className="num">Orders</th><th className="num">Value</th><th>Invoices</th></tr></thead>
<tbody>{R.supRows.map((r) => <tr key={r.name}><td>{r.name}</td><td className="num">{r.n}</td><td className="num" style={{ fontWeight: 600 }}>{money(r.amt)}</td><td className="tc-mono">{r.invoices}</td></tr>)}</tbody>
</TableWrap>
)}
</Panel>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
/* Small pieces every Reports tab shares: the CSV / Print pair on a panel head, a table frame, and
* an empty line that sits inside a panel's padding. */
import { Empty } from "@/components/ui";
const mini: React.CSSProperties = { minHeight: 0, padding: 0 };
export function HeadActions({ name, aside, onCsv, onPrint, csvText = "CSV", csvSecondary }: {
name: string; aside?: React.ReactNode; onCsv?: () => void; onPrint?: () => void; csvText?: string; csvSecondary?: boolean;
}) {
return (
<span className="tc-rep-headacts">
{aside && <span>{aside}</span>}
{onCsv && (csvSecondary
? <button type="button" className="btn btn-secondary" onClick={onCsv}>{csvText}</button>
: <button type="button" className="btn btn-ghost" style={mini} onClick={onCsv} aria-label={`Download ${name} as CSV`}>{csvText}</button>)}
{onPrint && <button type="button" className="btn btn-ghost" style={mini} onClick={onPrint} aria-label={`Print ${name}`}>Print</button>}
</span>
);
}
export function TableWrap({ children }: { children: React.ReactNode }) {
return <div className="table-wrap"><table className="tc-table">{children}</table></div>;
}
export function PanelEmpty({ children }: { children: React.ReactNode }) {
return <div style={{ padding: "0 16px" }}><Empty pad={4}>{children}</Empty></div>;
}
export const plural = (n: number, one: string, many = one + "s") => `${n} ${n === 1 ? one : many}`;
export function shortDate(iso: string) {
if (!iso || iso.length < 10) return iso || "—";
return new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10)).toLocaleDateString("en-AU", { day: "numeric", month: "short" });
}
export const TOTAL: React.CSSProperties = { fontWeight: 800 };
+242
View File
@@ -0,0 +1,242 @@
"use client";
/* Every figure on Reports, computed once per snapshot and month. The computation is today's report
* moved here unchanged; the redesign only regroups where each table is shown. Two additions sit at
* the end of R: the number of placed orders (Spend figures) and the distinct people per staff
* group (By staff group). */
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { ccOf, countsAsIssued, csvEsc, csvOf, fmtDate, fyStart, issueCost, label, longLabel, money, monthLabel, onhand, orderTotal, prevMonth, setsCap, shiftMonth, signedInt, signedMoney, staffName } from "@/lib/compute";
import { downloadCsv, printDoc, tbl, type Col } from "@/lib/print";
export function useReportData(month: string) {
const { s } = useSnap();
const { L, byId, staffById } = useDerived();
const R = useMemo(() => {
const months = new Set([s.today.slice(0, 7)]);
s.issues.forEach((i) => months.add(i.date.slice(0, 7))); s.orders.forEach((o) => o.date && months.add(o.date.slice(0, 7)));
for (let i = 5; i >= 0; i--) months.add(shiftMonth(month, -i)); // trend bars are clickable, so they must be selectable
const repMonths = [...months].sort().reverse();
const cost = (itemId: string) => byId[itemId]?.cost || 0; // catalogue cost (stocktake lines, valuation)
const mIssues = s.issues.filter((i) => i.date.slice(0, 7) === month && countsAsIssued(i) && !i.preloved);
const mPl = s.issues.filter((i) => i.date.slice(0, 7) === month && countsAsIssued(i) && i.preloved);
const pm = prevMonth(month);
const pIssues = s.issues.filter((i) => i.date.slice(0, 7) === pm && countsAsIssued(i) && !i.preloved);
// Pre-loved: free reissues (value saved at catalogue cost), hand-ins, and the pool at $0.
const plIssueRows = mPl.map((i) => { const it = byId[i.itemId]; return { date: i.date, who: staffName(staffById[i.staffId], "—"), item: label(it), size: it ? String(it.sizes[i.si]) : "?", qty: i.qty, saved: i.qty * (it?.cost || 0) }; });
const plSaved = plIssueRows.reduce((t, r) => t + r.saved, 0), plQty = mPl.reduce((t, i) => t + i.qty, 0);
const mHi = s.handins.filter((h) => h.date.slice(0, 7) === month);
const hiRows = mHi.map((h) => ({ date: h.date, who: staffName(staffById[h.staffId], "—"), by: h.by, good: h.lines.filter((l) => l.cond === "Good").reduce((t, l) => t + l.qty, 0), rag: h.lines.filter((l) => l.cond === "Rag").reduce((t, l) => t + l.qty, 0), credit: h.credit ? "Credited" : "—" }));
const ragMonth = hiRows.reduce((t, r) => t + r.rag, 0);
const plByItem: Record<string, string[]> = {};
for (const k in s.stock) { const n = s.stock[k].preloved; if (!(n > 0)) continue; const itemId = k.slice(0, k.lastIndexOf(":")), si = +k.slice(k.lastIndexOf(":") + 1); const it = byId[itemId]; if (!it) continue; (plByItem[itemId] = plByItem[itemId] || []).push(`${it.sizes[si]} ×${n}`); }
const plPoolRows = Object.keys(plByItem).map((itemId) => ({ item: label(byId[itemId]), sizes: plByItem[itemId].join(", "), total: plByItem[itemId].reduce((t, x) => t + parseInt(x.split("×")[1], 10), 0) }));
const plPoolTotal = plPoolRows.reduce((t, r) => t + r.total, 0);
type Agg = { items: number; amt: number };
const sumBy = (arr: typeof mIssues, keyFn: (i: (typeof arr)[number]) => string) => { const m: Record<string, Agg> = {}; for (const i of arr) { const k = keyFn(i); if (!m[k]) m[k] = { items: 0, amt: 0 }; m[k].items += i.qty; m[k].amt += i.qty * issueCost(i, byId); } return m; };
const ccKey = (i: (typeof mIssues)[number]) => { const st = staffById[i.staffId]; return st ? (ccOf(s, st) || "—") + "|" + (st.dept || "Unknown") : "—|Unknown"; };
const byCC = sumBy(mIssues, ccKey), byCCPrev = sumBy(pIssues, ccKey);
// Union of this month's and last month's cost centres so the Prev column reconciles to the previous-month total.
const ccKeys = [...new Set([...Object.keys(byCC), ...Object.keys(byCCPrev)])];
const ccRows = ccKeys.map((k) => { const v = byCC[k] || { items: 0, amt: 0 }; const [cc, dept] = k.split("|"); const prev = byCCPrev[k]?.amt || 0; return { key: k, cc, dept, items: v.items, amt: v.amt, prev, delta: v.amt - prev }; }).sort((a, b) => b.amt - a.amt || b.prev - a.prev);
// What each cost-centre figure is actually made of, filed under the same key the total was
// grouped on. Grouping the detail the same way as the total is what stops a drill-down from
// disagreeing with the row that opened it — a ward manager checking their number would rather
// have no drill-down than one that doesn't add up.
const ccLines: Record<string, { date: string; who: string; item: string; size: string; qty: number; unit: number; amt: number }[]> = {};
for (const i of mIssues) { const k = ccKey(i); const it = byId[i.itemId]; const unit = issueCost(i, byId); (ccLines[k] = ccLines[k] || []).push({ date: i.date, who: staffName(staffById[i.staffId], "—"), item: label(it), size: it ? String(it.sizes[i.si]) : "?", qty: i.qty, unit, amt: i.qty * unit }); }
const totAmt = mIssues.reduce((t, i) => t + i.qty * issueCost(i, byId), 0), totPrev = pIssues.reduce((t, i) => t + i.qty * issueCost(i, byId), 0), totItems = mIssues.reduce((t, i) => t + i.qty, 0);
// "Placed" = sent to the supplier; drafts (incl. auto-replenishment) are not spend yet.
// Back orders carry the parent's short lines, so they're excluded from spend to avoid counting those lines twice.
const placed = (o: (typeof s.orders)[number]) => o.status !== "Cancelled" && o.status !== "Draft" && !o.parentId;
const mOrders = s.orders.filter((o) => o.date.slice(0, 7) === month && placed(o));
const ordSpend = mOrders.reduce((t, o) => t + orderTotal(o, byId), 0);
const byG = sumBy(mIssues, (i) => staffById[i.staffId]?.group || "Unknown");
const groupRows = Object.entries(byG).sort((a, b) => b[1].amt - a[1].amt).map(([g, v]) => ({ g, ...v }));
const supAgg: Record<string, { n: number; amt: number; inv: string[] }> = {};
for (const o of mOrders) { const v = orderTotal(o, byId); if (!supAgg[o.supplier]) supAgg[o.supplier] = { n: 0, amt: 0, inv: [] }; supAgg[o.supplier].n++; supAgg[o.supplier].amt += v; if (o.invoice && !supAgg[o.supplier].inv.includes(o.invoice)) supAgg[o.supplier].inv.push(o.invoice); for (const rc of o.receipts) if (rc.invoice && !supAgg[o.supplier].inv.includes(rc.invoice)) supAgg[o.supplier].inv.push(rc.invoice); }
const supRows = Object.entries(supAgg).sort((a, b) => b[1].amt - a[1].amt).map(([name, v]) => ({ name, n: v.n, amt: v.amt, invoices: v.inv.join(", ") || "—" }));
const issueAgg = (m: string) => { const a = s.issues.filter((i) => i.date.slice(0, 7) === m && countsAsIssued(i) && !i.preloved); return { items: a.reduce((t, i) => t + i.qty, 0), amt: a.reduce((t, i) => t + i.qty * issueCost(i, byId), 0) }; };
const orderAgg = (m: string) => s.orders.filter((o) => o.date.slice(0, 7) === m && placed(o)).reduce((t, o) => t + orderTotal(o, byId), 0);
const fyMonths: string[] = []; { let cur = fyStart(month + "-15").slice(0, 7); let g = 0; while (cur <= month && g++ < 13) { fyMonths.push(cur); cur = shiftMonth(cur, 1); } }
let fti = 0, fta = 0, fto = 0;
const fyRows = fyMonths.map((m) => { const ia = issueAgg(m); const ov = orderAgg(m); fti += ia.items; fta += ia.amt; fto += ov; return { m, label: monthLabel(m, { month: "short", year: "2-digit" }), items: ia.items, issued: ia.amt, orders: ov }; });
const trendM: string[] = []; for (let i = 5; i >= 0; i--) trendM.push(shiftMonth(month, -i));
const tv = trendM.map((m) => issueAgg(m).amt); const tmax = Math.max(...tv, 1);
const trend = trendM.map((m, i) => ({ m, label: monthLabel(m, { month: "short" }), amt: tv[i], h: tv[i] ? Math.max(Math.round((tv[i] / tmax) * 70), 4) : 2, sel: m === month }));
const byS = sumBy(mIssues, (i) => i.staffId);
const staffRows = Object.entries(byS).sort((a, b) => b[1].amt - a[1].amt).map(([sid, v]) => { const st = staffById[sid]; return { who: staffName(st, "—"), cc: ccOf(s, st), ...v }; });
// Journal
const glAcct = s.settings.glAccount || "—";
const jnDesc = `${s.settings.journalDesc || "Uniform issues"} ${monthLabel(month)}`;
// One debit per cost centre: departments that share a CC (or a ccOverride pointing at another dept's code) fold together.
const jnAgg: Record<string, { cc: string; depts: string[]; keys: string[]; items: number; debit: number }> = {};
for (const r of ccRows) { if (r.items <= 0) continue; const cc = r.cc === "—" ? "UNALLOCATED" : r.cc; const a = jnAgg[cc] || (jnAgg[cc] = { cc, depts: [], keys: [], items: 0, debit: 0 }); if (!a.depts.includes(r.dept)) a.depts.push(r.dept); a.keys.push(r.key); a.items += r.items; a.debit += r.amt; }
const jnRows = Object.values(jnAgg).sort((a, b) => b.debit - a.debit).map((a) => ({ cc: a.cc, dept: a.depts.join(" / "), keys: a.keys, gl: glAcct, desc: jnDesc, items: a.items, debit: a.debit }));
const jnUnallocated = jnRows.some((r) => r.cc === "UNALLOCATED");
// Top stock
const byItem: Record<string, Agg> = {}; const fyByItem: Record<string, number> = {};
const fy = fyStart(month + "-15"); // financial year of the selected month
// Every FY figure on this page — Top stock's, Exceptions' and Shrinkage's — stops at the end of
// the selected month. Without the upper bound, reprinting June's pack in September counts three
// months that hadn't happened when June closed, so the reprint no longer agrees with the pack
// finance was already given.
const fyCutoff = shiftMonth(month, 1) + "-01"; // exclusive: dates in `month` sort before it
for (const i of mIssues) { if (!byItem[i.itemId]) byItem[i.itemId] = { items: 0, amt: 0 }; byItem[i.itemId].items += i.qty; byItem[i.itemId].amt += i.qty * issueCost(i, byId); }
for (const i of s.issues) if (countsAsIssued(i) && !i.preloved && i.date >= fy && i.date < fyCutoff) fyByItem[i.itemId] = (fyByItem[i.itemId] || 0) + i.qty;
const mTotQty = Object.values(byItem).reduce((t, v) => t + v.items, 0);
const topRows = Object.entries(byItem).sort((a, b) => b[1].items - a[1].items).slice(0, 15).map(([id, v], n) => ({ n: n + 1, item: label(byId[id]), supplier: byId[id]?.supplier || "—", qty: v.items, val: v.amt, share: Math.round((v.items / Math.max(mTotQty, 1)) * 100) + "%", fyQty: fyByItem[id] || 0 }));
// Valuation
let negSizes = 0;
const valRows = s.catalog.map((it) => { const units = it.sizes.reduce((t, _sz, si) => { const oh = onhand(s, L, `${it.id}:${si}`); if (oh < 0) negSizes++; return t + Math.max(0, oh); }, 0); return { item: longLabel(it), sku: it.sku || "—", supplier: it.supplier || "—", units, cost: it.cost, val: units * it.cost }; }).filter((x) => x.units > 0).sort((a, b) => b.val - a.val);
const valTotUnits = valRows.reduce((t, x) => t + x.units, 0), valTot = valRows.reduce((t, x) => t + x.val, 0);
// Shrinkage
// Bounded at fyCutoff like the other FY figures: a count filed in July must not change the
// shrinkage figure on June's pack after finance has it. Pool counts are at $0, not shrinkage.
const fyTakes = s.stocktakes.filter((h) => h.date >= fy && h.date < fyCutoff && h.mode !== "preloved");
let shU = 0, shV = 0;
const shRows = fyTakes.map((h) => { const nu = h.lines.reduce((t, l) => t + (l.counted - l.sys), 0); const nv = h.lines.reduce((t, l) => t + (l.counted - l.sys) * cost(l.itemId), 0); shU += nu; shV += nv; return { date: h.date, by: h.by, counted: h.counted, variances: h.variances, net: nu, netVal: nv }; });
// Exceptions
const excThreshold = s.settings.exceptionHigh || 10;
const mByStaff: Record<string, number> = {}; for (const i of mIssues) mByStaff[i.staffId] = (mByStaff[i.staffId] || 0) + i.qty;
const cap = setsCap(s.settings.capSets);
// Garments handed over this month past the six sets one person holds, on a coordinator's
// override. That is the one exception the ceiling itself produces, and the counter stamps it on
// the issue for this tab to find. It is read from that stamp rather than from anybody's locker
// today, because today's locker is not June's: a June pack reprinted in September would name
// whoever happens to be past the ceiling now, and saying who that is belongs to the staff
// register and the dashboard. Every stamped row in the month counts, pre-loved and since-returned
// included, because the decision was made at the counter on the day. A partial hand-in splits a
// row without changing its date, so the halves still add up to what went over.
const ovByStaff: Record<string, number> = {};
for (const i of s.issues) if (i.override && i.date.slice(0, 7) === month) ovByStaff[i.staffId] = (ovByStaff[i.staffId] || 0) + i.qty;
// Garments handed over this month outside the person's staff group, on the same tick but stamped
// apart (offGroup), so they are counted and named apart. Same month rule as above. A garment can
// be both, and then it is on both lines, because two rules were bent.
const ogByStaff: Record<string, Record<string, number>> = {};
for (const i of s.issues) if (i.offGroup && i.date.slice(0, 7) === month) { const m = (ogByStaff[i.staffId] = ogByStaff[i.staffId] || {}); const n = label(byId[i.itemId]); m[n] = (m[n] || 0) + i.qty; }
// Garments handed over this month in a cut the person isn't offered, on the same tick and stamped
// apart again (offStyle). Same month rule, and the same reason for counting it apart: the ceiling,
// the staff group and the cut are three different decisions a coordinator made, and a row that
// named them all as "an override" tells whoever reads the pack nothing about which was bent.
const osByStaff: Record<string, Record<string, number>> = {};
for (const i of s.issues) if (i.offStyle && i.date.slice(0, 7) === month) { const m = (osByStaff[i.staffId] = osByStaff[i.staffId] || {}); const n = label(byId[i.itemId]); m[n] = (m[n] || 0) + i.qty; }
// What each person has drawn this financial year, to the end of the selected month. It is a
// tally printed beside the month's figure, and nobody is flagged on it: what anybody may have is
// six sets held at any time, with no year in it, and a report calling somebody over on a yearly
// count sends a coordinator after a new starter the counter has kitted out quite properly.
// Counted here rather than with entUsed(), which always measures the year containing today, so a
// closed month reprints with the figures it was first printed with. fyCutoff is the one Top
// stock and Shrinkage count to, so no two tabs quote a different window for the same month. Same
// rules as entUsed(): pre-loved is free and not counted, a garment returned in good condition
// never counted, and a credited hand-in takes the good garments back off.
const fyByStaff: Record<string, number> = {};
for (const i of s.issues) if (!i.preloved && countsAsIssued(i) && i.date >= fy && i.date < fyCutoff) fyByStaff[i.staffId] = (fyByStaff[i.staffId] || 0) + i.qty;
for (const h of s.handins) if (h.credit && h.date >= fy && h.date < fyCutoff) for (const l of h.lines) fyByStaff[h.staffId] = (fyByStaff[h.staffId] || 0) - l.credited;
const excRows: { who: string; group: string; cc: string; mQty: number; fyQty: number; ovQty: number; ogQty: number; osQty: number; flags: string[]; flag: string }[] = [];
for (const st of s.staff) {
const fyQ = Math.max(0, fyByStaff[st.id] || 0); const mQ = mByStaff[st.id] || 0; const ov = ovByStaff[st.id] || 0;
const og = Object.entries(ogByStaff[st.id] || {}); const ogQ = og.reduce((t, [, n]) => t + n, 0);
const os = Object.entries(osByStaff[st.id] || {}); const osQ = os.reduce((t, [, n]) => t + n, 0);
const flags: string[] = [];
if (ov) flags.push(`Past ${cap} sets on an override — ${ov} garment${ov === 1 ? "" : "s"}`);
if (ogQ) flags.push(`Outside their staff group on an override — ${og.map(([n, q]) => `${n} ×${q}`).join(", ")}`);
if (osQ) flags.push(`Not their uniform style on an override — ${os.map(([n, q]) => `${n} ×${q}`).join(", ")}`);
if (mQ >= excThreshold) flags.push(`${mQ} items this month (threshold ${excThreshold})`);
if (flags.length) excRows.push({ who: staffName(st), group: st.group, cc: ccOf(s, st), mQty: mQ, fyQty: fyQ, ovQty: ov, ogQty: ogQ, osQty: osQ, flags, flag: flags.join(" · ") });
}
// Overrides first, of any kind. Each one is a decision somebody made at the counter, and it is
// the row a coordinator gets asked about. Volume on its own comes after, busiest first.
excRows.sort((a, b) => Number(b.ovQty + b.ogQty + b.osQty > 0) - Number(a.ovQty + a.ogQty + a.osQty > 0) || b.mQty - a.mQty);
// Approvals
const apprRows = s.approvals.filter((a) => a.sets - a.used > 0).map((a) => { const st = staffById[a.staffId]; return { who: staffName(st, "—"), dept: st?.dept || "—", by: a.by, date: a.date, sets: a.sets, used: a.used, rem: a.sets - a.used }; });
const apprTot = apprRows.reduce((t, a) => t + a.rem, 0);
// Redesign additions: orders placed this month, and distinct people issued per staff group.
const ordCount = mOrders.length;
const groupPeopleSets: Record<string, Set<string>> = {};
for (const i of mIssues) { const g = staffById[i.staffId]?.group || "Unknown"; (groupPeopleSets[g] = groupPeopleSets[g] || new Set()).add(i.staffId); }
const groupRowsP = groupRows.map((g) => ({ ...g, people: groupPeopleSets[g.g]?.size || 0 }));
return { repMonths, ccRows, ccLines, totAmt, totPrev, totItems, ordSpend, ordCount, groupRows: groupRowsP, supRows, fyRows, fyTot: { items: fti, issued: fta, orders: fto }, trend, staffRows, glAcct, jnDesc, jnRows, jnUnallocated, topRows, valRows, valTotUnits, valTot, negSizes, shRows, shU, shV, cap, excThreshold, excRows, apprRows, apprTot, plIssueRows, plSaved, plQty, hiRows, ragMonth, plPoolRows, plPoolTotal };
}, [s, L, byId, staffById, month]);
const mLbl = monthLabel(month);
const meta = `${s.settings.facility} · ${s.settings.location} · prepared ${fmtDate(s.today)}${s.settings.coordinator ? " by " + s.settings.coordinator : ""}`;
const jnTotItems = R.jnRows.reduce((t, r) => t + r.items, 0), jnTot = R.jnRows.reduce((t, r) => t + r.debit, 0);
const C = (t: string, r = false): Col => ({ t, r });
const ccTable = () => tbl([C("CC"), C("Department"), C("Items", true), C("This period", true), C("Prev", true), C("Δ", true)], [...R.ccRows.map((r) => [r.cc, r.dept, r.items, money(r.amt), money(r.prev), signedMoney(r.delta)] as (string | number)[]), ["TOTAL", "", R.totItems, money(R.totAmt), money(R.totPrev), ""]]);
const staffTable = () => tbl([C("Staff"), C("CC"), C("Items", true), C("Value", true)], R.staffRows.map((r) => [r.who, r.cc, r.items, money(r.amt)]));
const fyTable = () => tbl([C("Month"), C("Items", true), C("Issued", true), C("Orders", true)], [...R.fyRows.map((m) => [m.label, m.items, money(m.issued), money(m.orders)] as (string | number)[]), ["FY TOTAL", R.fyTot.items, money(R.fyTot.issued), money(R.fyTot.orders)]]);
const ccCsvRows = () => csvOf(["Cost Centre", "Department", "Items", "Amount", "Previous Month"], [...R.ccRows.map((r) => [r.cc, r.dept, r.items, r.amt.toFixed(2), r.prev.toFixed(2)] as (string | number)[]), ["TOTAL", "", R.totItems, R.totAmt.toFixed(2), R.totPrev.toFixed(2)]]);
const staffCsvRows = () => csvOf(["Staff", "Cost Centre", "Items", "Amount"], R.staffRows.map((r) => [r.who, r.cc, r.items, r.amt.toFixed(2)]));
const fyCsvRows = () => csvOf(["FY Month", "Items Issued", "Issued Value", "Orders Placed"], [...R.fyRows.map((m) => [m.label, m.items, m.issued.toFixed(2), m.orders.toFixed(2)] as (string | number)[]), ["FY TOTAL", R.fyTot.items, R.fyTot.issued.toFixed(2), R.fyTot.orders.toFixed(2)]]);
const csv = {
/* Today's Overview CSV, whole: the Spend head's Export CSV. */
overview: () => {
let out = `ThreadCount monthly report,${month},${csvEsc(s.settings.facility)}\n\n` + ccCsvRows();
out += "\n" + csvOf(["Staff Group", "Items", "Amount"], R.groupRows.map((g) => [g.g, g.items, g.amt.toFixed(2)]));
out += "\n" + staffCsvRows();
out += "\n" + csvOf(["Supplier", "Orders", "Amount"], R.supRows.map((r) => [r.name, r.n, r.amt.toFixed(2)]));
out += "\n" + fyCsvRows();
downloadCsv(`threadcount-report-${month}.csv`, out);
},
costCentres: () => downloadCsv(`threadcount-cost-centres-${month}.csv`, `Issued value by cost centre,${month},${csvEsc(s.settings.facility)}\n\n` + ccCsvRows()),
staff: () => downloadCsv(`threadcount-staff-${month}.csv`, `Issued value by staff member,${month},${csvEsc(s.settings.facility)}\n\n` + staffCsvRows()),
fy: () => downloadCsv(`threadcount-financial-year-${month}.csv`, `Financial year to the end of,${month},${csvEsc(s.settings.facility)}\n\n` + fyCsvRows()),
journal: () => downloadCsv(`threadcount-journal-${month}.csv`, csvOf(["Cost Centre", "Department", "GL Account", "Description", "Items", "Debit"], [...R.jnRows.map((r) => [r.cc, r.dept, r.gl, r.desc, r.items, r.debit.toFixed(2)] as (string | number)[]), ["TOTAL", "", "", "", jnTotItems, jnTot.toFixed(2)]])),
valuation: () => downloadCsv(`threadcount-valuation-${s.today}.csv`, `Stock valuation as at,${s.today}\n` + csvOf(["Item", "SKU", "Supplier", "Units", "Unit cost", "Value"], R.valRows.map((x) => [x.item, x.sku, x.supplier, x.units, x.cost, x.val.toFixed(2)]))),
topStock: () => downloadCsv(`threadcount-top-stock-${month}.csv`, csvOf(["Rank", "Item", "Supplier", "Qty (month)", "Value (month)", "Share", "Qty (FY)"], R.topRows.map((r) => [r.n, r.item, r.supplier, r.qty, r.val.toFixed(2), r.share, r.fyQty]))),
shrinkage: () => downloadCsv(`threadcount-shrinkage-${month}.csv`, csvOf(["Date", "Counted by", "Lines counted", "Variances", "Net units", "Net value"], R.shRows.map((r) => [r.date, r.by, r.counted, r.variances, r.net, r.netVal.toFixed(2)]))),
exceptions: () => downloadCsv(`threadcount-exceptions-${month}.csv`, csvOf(["Staff", "Group", "Cost centre", "Items (month)", "Items (FY)", "Flag"], R.excRows.map((r) => [r.who, r.group, r.cc, r.mQty, r.fyQty, r.flag]))),
suppliers: () => downloadCsv(`threadcount-supplier-spend-${month}.csv`, csvOf(["Supplier", "Orders", "Value", "Invoices"], R.supRows.map((r) => [r.name, r.n, r.amt.toFixed(2), r.invoices]))),
approvals: () => downloadCsv(`threadcount-approvals-outstanding-${s.today}.csv`, csvOf(["Staff", "Ward", "Approved by", "Date", "Sets approved", "Collected", "Remaining"], R.apprRows.map((r) => [r.who, r.dept, r.by, r.date, r.sets, r.used, r.rem]))),
preloved: () => downloadCsv(`threadcount-preloved-${month}.csv`, `Pre-loved issues ${month}\n` + csvOf(["Date", "Staff", "Item", "Size", "Qty", "Value saved"], R.plIssueRows.map((r) => [r.date, r.who, r.item, r.size, r.qty, r.saved.toFixed(2)])) + "\nHand-ins\n" + csvOf(["Date", "Staff", "Received by", "Good", "Rag", "Credit"], R.hiRows.map((r) => [r.date, r.who, r.by, r.good, r.rag, r.credit])) + "\nPool snapshot\n" + csvOf(["Item", "Sizes", "Total"], R.plPoolRows.map((r) => [r.item, r.sizes, r.total]))),
};
const print = {
overview: () => printDoc(`Cost centre report — ${mLbl}`, meta, [
{ h: "Summary", html: tbl([C(""), C("", true)], [["Issued value (period)", money(R.totAmt)], ["Items issued", R.totItems], ["Supplier orders placed", money(R.ordSpend)], ["vs previous month", money(R.totPrev)]]) },
{ h: "Issued value by cost centre", html: ccTable() },
{ h: "By staff group", html: tbl([C("Group"), C("Items", true), C("Value", true)], R.groupRows.map((g) => [g.g, g.items, money(g.amt)])) },
{ h: "By staff member", html: staffTable() },
{ h: "Financial year", html: fyTable() },
]),
costCentres: () => printDoc(`Cost centre report — ${mLbl}`, meta, [{ h: "Issued value by cost centre", html: ccTable() }]),
staff: () => printDoc(`Issued value by staff member — ${mLbl}`, meta, [{ h: "By staff member", html: staffTable() }]),
fy: () => printDoc(`Financial year — to the end of ${mLbl}`, meta, [{ h: "Financial year", html: fyTable() }]),
journal: () => printDoc(`End-of-month journal — ${mLbl}`, meta, [{ h: `One debit per cost centre — GL ${R.glAcct}`, html: tbl([C("CC"), C("Department"), C("GL"), C("Description"), C("Items", true), C("Debit", true)], [...R.jnRows.map((r) => [r.cc, r.dept, r.gl, r.desc, r.items, money(r.debit)] as (string | number)[]), ["TOTAL", "", "", "", jnTotItems, money(jnTot)]]) }]),
topStock: () => printDoc(`Top stock — ${mLbl}`, meta, [{ h: "Most issued items", html: tbl([C("#"), C("Item"), C("Supplier"), C("Qty", true), C("Value", true), C("Share", true), C("Qty FY", true)], R.topRows.map((r) => [r.n, r.item, r.supplier, r.qty, money(r.val), r.share, r.fyQty])) }]),
valuation: () => printDoc(`Stock valuation — as at ${fmtDate(s.today)}`, meta, [{ h: "On-hand value by item", html: tbl([C("Item"), C("SKU"), C("Supplier"), C("Units", true), C("Unit cost", true), C("Value", true)], [...R.valRows.map((r) => [r.item, r.sku, r.supplier, r.units, money(r.cost), money(r.val)] as (string | number)[]), ["TOTAL", "", "", R.valTotUnits, "", money(R.valTot)]]) }]),
shrinkage: () => printDoc(`Stocktake variance / shrinkage — FY to end of ${mLbl}`, meta, [{ h: `${R.shRows.length} stocktakes · net ${signedInt(R.shU)} units · ${signedMoney(R.shV)}`, html: tbl([C("Date"), C("Counted by"), C("Lines", true), C("Variances", true), C("Net units", true), C("Net value", true)], R.shRows.map((r) => [fmtDate(r.date), r.by, r.counted, r.variances, signedInt(r.net), signedMoney(r.netVal)])) }]),
exceptions: () => printDoc(`Staff exceptions — ${mLbl}`, meta, [{ h: `Past ${R.cap} sets, outside their staff group or not their uniform style on an override, or ≥ ${R.excThreshold} items this month`, html: tbl([C("Staff"), C("Group"), C("CC"), C("Month", true), C("FY", true), C("Flag")], R.excRows.map((r) => [r.who, r.group, r.cc, r.mQty, r.fyQty, r.flag])) }]),
suppliers: () => printDoc(`Supplier spend — ${mLbl}`, meta, [{ h: "Orders placed this period", html: tbl([C("Supplier"), C("Orders", true), C("Value", true), C("Invoices")], R.supRows.map((r) => [r.name, r.n, money(r.amt), r.invoices])) }]),
approvals: () => printDoc(`Uncollected manager's approvals — as at ${fmtDate(s.today)}`, meta, [{ h: `${R.apprTot} sets outstanding`, html: tbl([C("Staff"), C("Ward"), C("Approved by"), C("Date"), C("Sets", true), C("Collected", true), C("Remaining", true)], R.apprRows.map((r) => [r.who, r.dept, r.by, fmtDate(r.date), r.sets, r.used, r.rem])) }]),
preloved: () => printDoc(`Pre-loved uniforms — ${mLbl}`, meta, [
{ h: `Issued free this period — saved ${money(R.plSaved)}`, html: tbl([C("Date"), C("Staff"), C("Item"), C("Size"), C("Qty", true), C("Value saved", true)], R.plIssueRows.map((r) => [fmtDate(r.date), r.who, r.item, r.size, r.qty, money(r.saved)])) },
{ h: `Hand-ins this period · ${R.ragMonth} to rag disposal`, html: tbl([C("Date"), C("Staff"), C("Received by"), C("Good", true), C("Rag", true), C("Credit")], R.hiRows.map((r) => [fmtDate(r.date), r.who, r.by, r.good, r.rag, r.credit])) },
{ h: `Pool snapshot — ${R.plPoolTotal} items at $0 book value`, html: tbl([C("Item"), C("Sizes"), C("Total", true)], R.plPoolRows.map((r) => [r.item, r.sizes, r.total])) },
]),
};
function printEomPack() {
const sections = [
{ h: "Summary", html: tbl([C(""), C("", true), C(""), C("", true)], [["Issued value", money(R.totAmt), "Items issued", R.totItems], ["Supplier orders placed", money(R.ordSpend), "Stock on hand value", money(R.valTot)], ["Shrinkage (FY to end of month)", signedMoney(R.shV), "Stocktakes counted (FY)", R.shRows.length]]) },
{ h: "Cost centre summary", html: tbl([C("CC"), C("Department"), C("Items", true), C("Value", true)], [...R.jnRows.map((r) => [r.cc, r.dept, r.items, money(r.debit)] as (string | number)[]), ["TOTAL", "", jnTotItems, money(jnTot)]]) },
{ h: `Journal — one debit per cost centre (GL ${R.glAcct})`, html: tbl([C("CC"), C("Description"), C("Debit", true)], R.jnRows.map((r) => [r.cc, r.desc, money(r.debit)])) },
{ h: "Top stock", html: tbl([C("Item"), C("Qty", true), C("Value", true)], R.topRows.slice(0, 10).map((r) => [r.item, r.qty, money(r.val)])) },
];
// Finance is promised shrinkage in this pack, and the net figure is in the summary above every
// month. The count-by-count table only turns up when counts were actually filed, the same rule
// the exceptions and approvals sections below follow — a heading over an empty table tells
// finance nothing and costs them a page.
if (R.shRows.length) sections.push({ h: `Shrinkage — stocktake variance, FY to end of ${mLbl} · net ${signedInt(R.shU)} units · ${signedMoney(R.shV)}`, html: tbl([C("Date"), C("Counted by"), C("Variances", true), C("Net units", true), C("Net value", true)], R.shRows.map((r) => [fmtDate(r.date), r.by, r.variances, signedInt(r.net), signedMoney(r.netVal)])) });
if (R.excRows.length) sections.push({ h: "Staff exceptions", html: tbl([C("Staff"), C("Cost centre"), C("Flag")], R.excRows.map((r) => [r.who, r.cc, r.flag])) });
if (R.apprRows.length) sections.push({ h: "Uncollected manager's approvals", html: tbl([C("Staff"), C("Approved by"), C("Remaining sets", true)], R.apprRows.map((r) => [r.who, r.by, r.rem])) });
printDoc(`Month-end pack — ${mLbl}`, meta, sections);
}
return { R, month, mLbl, meta, jnTotItems, jnTot, csv, print, printEomPack };
}
export type ReportData = ReturnType<typeof useReportData>;
+47
View File
@@ -0,0 +1,47 @@
"use client";
/* Damage reported from the staff app that has not come back to the counter yet. */
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { Empty } from "@/components/ui";
import { MonoNum, Panel, Tag } from "@/components/portal";
import { formatInZone } from "@/lib/compute";
import type { DamageRow } from "./RequestList";
export default function Damage({ rows, act }: { rows: DamageRow[]; act: (op: "damage.handedIn", payload: Record<string, unknown>) => Promise<boolean> }) {
const { s } = useSnap();
return (
<Panel title="Damage" count={rows.length}>
{rows.length === 0 ? (
<div style={{ padding: "0 16px" }}><Empty pad={3}>Nothing reported damaged that hasn&apos;t come back yet.</Empty></div>
) : rows.map((d) => (
<div key={d.id} className="tc-req-row">
<div className="tc-req-head" style={{ alignItems: "flex-start" }}>
{d.photoId && (
<a href={`/api/photo/${d.photoId}`} target="_blank" rel="noopener" style={{ flex: "none" }} aria-label={`Photo of the damage to ${d.staffName}s ${d.item || "garment"}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`/api/photo/${d.photoId}`} alt="" width={56} height={56} style={{ width: 56, height: 56, objectFit: "cover", border: "2px solid var(--color-text)", display: "block" }} />
</a>
)}
<div style={{ flex: 1, minWidth: 180 }}>
<div>
<b>{d.item ? d.item : "Garment no longer on file"}</b>
{d.item && d.size && <> <MonoNum>{d.size}</MonoNum></>}
<span style={{ color: "var(--color-neutral-700)" }}> · {d.staffName}{d.ward ? ` (${d.ward})` : ""}</span>
{" "}<Tag tone="accent">{d.kind}</Tag>
</div>
<div className="tc-meta-line" style={{ marginTop: 3 }}>
{formatInZone(d.at, s.tz)}
{" · "}{d.requestCode ? <>replacement <MonoNum size={12}>{d.requestCode}</MonoNum></> : "no replacement asked for"}
</div>
{d.note && <p style={{ fontSize: 13.5, lineHeight: 1.5, margin: "6px 0 0", maxWidth: "70ch" }}>&ldquo;{d.note}&rdquo;</p>}
</div>
<div className="tc-req-actions">
<button type="button" className="btn btn-secondary" onClick={() => void act("damage.handedIn", { id: d.id })}>Handed in at the counter</button>
<Link className="btn btn-ghost" href={`/app/staff/${d.staffId}`}>Open record</Link>
</div>
</div>
</div>
))}
</Panel>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
/* Kit check (a round asking everyone to confirm what they hold), its shortfalls, and the size
* waitlist. Starting and closing a round is admin only on the server, so it is hidden here too. */
import { useState } from "react";
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { Empty, Field } from "@/components/ui";
import { MonoNum, Panel, Tag } from "@/components/portal";
import { fmtDate, formatInZone } from "@/lib/compute";
import { WAITLIST_HOLD_HOURS, holdEndsAt, holdExpired } from "@/lib/staffreq";
import type { CycleRow, ShortfallRow, WaitingRow } from "./RequestList";
type Op = "kitcheck.open" | "kitcheck.close" | "waitlist.offer";
export default function KitCheck({ cycle, shortfalls, waiting, act }: {
cycle: CycleRow | null; shortfalls: ShortfallRow[]; waiting: WaitingRow[];
act: (op: Op, payload: Record<string, unknown>) => Promise<boolean>;
}) {
const { s, isAdmin } = useSnap();
const [dueBy, setDueBy] = useState("");
return (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<Panel title="Kit check" aside={cycle ? "Running" : undefined}>
<div style={{ padding: "12px 16px" }}>
{cycle ? (
<div className="tc-req-actions" style={{ justifyContent: "space-between" }}>
<span style={{ fontSize: 13.5 }}>
Due by <b>{fmtDate(cycle.dueBy)}</b>
<span className="tc-meta-line"> · <MonoNum size={12}>{cycle.answers}</MonoNum> answer{cycle.answers === 1 ? "" : "s"} in · opened by {cycle.openedBy || "—"}</span>
</span>
{isAdmin && <button type="button" className="btn btn-secondary" onClick={() => void act("kitcheck.close", { id: cycle.id })}>Close the round</button>}
</div>
) : isAdmin ? (
<div className="tc-req-actions" style={{ alignItems: "flex-end" }}>
<Field label="Due by" style={{ width: 200 }}>{(c) => <input {...c} className="input" type="date" value={dueBy} onChange={(e) => setDueBy(e.target.value)} />}</Field>
<button type="button" className="btn btn-primary" onClick={async () => { if (await act("kitcheck.open", { dueBy })) setDueBy(""); }}>Start a kit check</button>
</div>
) : (
<span className="tc-meta-line">No kit check running.</span>
)}
</div>
</Panel>
<Panel title="Couldnt account for" count={cycle ? shortfalls.length : undefined}>
{!cycle ? (
<div style={{ padding: "0 16px" }}><Empty pad={3}>No kit check is running.</Empty></div>
) : shortfalls.length === 0 ? (
<div style={{ padding: "0 16px" }}>
<Empty pad={3}>{cycle.answers === 0 ? "Nobody has answered yet." : "Every answer so far matched the record."}</Empty>
</div>
) : (
<div className="tc-req-scroll">
<table className="tc-table">
<thead><tr>
<th>Who</th><th>Garment</th><th>Size</th>
<th className="num">On record</th><th className="num">Confirmed</th><th className="num">Short</th>
<th>Answered</th><th><span className="sr-only">Record</span></th>
</tr></thead>
<tbody>
{shortfalls.map((f) => (
<tr key={f.id}>
<td>{f.staffName} <MonoNum size={12} tone="muted">{f.staffNum}</MonoNum>{f.ward && <span className="tc-meta-line"> · {f.ward}</span>}</td>
<td>{f.item}</td>
<td className="tc-mono">{f.size}</td>
<td className="num">{f.onRecord}</td>
<td className="num">{f.confirmed}</td>
<td className="num" style={{ color: "var(--color-accent-700)", fontWeight: 600, whiteSpace: "nowrap" }}><span className="tc-mark" aria-hidden="true" />{f.short}</td>
<td className="tc-mono" style={{ whiteSpace: "nowrap", fontSize: 12 }}>{formatInZone(f.at, s.tz)}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}><Link href={`/app/staff/${f.staffId}`}>Open record</Link></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Panel>
<Panel title="Waiting for a size" count={waiting.length}>
{waiting.length === 0 ? (
<div style={{ padding: "0 16px" }}><Empty pad={3}>Nobody is waiting on a size.</Empty></div>
) : waiting.map((w) => {
const ends = holdEndsAt(w.offeredAt);
return (
<div key={w.id} className="tc-req-row">
<div className="tc-req-head">
<span style={{ flex: 1, minWidth: 180 }}>
<b>{w.item}</b> <MonoNum>{w.size}</MonoNum>
<span style={{ color: "var(--color-neutral-700)" }}> · {w.staffName}{w.ward ? ` (${w.ward})` : ""}</span>
<span className="tc-meta-line"> · since {formatInZone(w.since, s.tz)}</span>
</span>
{!w.offeredAt
? <button type="button" className="btn btn-secondary" title={`Tells them and holds it for ${WAITLIST_HOLD_HOURS} hours`} onClick={() => void act("waitlist.offer", { id: w.id })}>Its in offer it</button>
: holdExpired(w.offeredAt)
? <Tag tone="quiet">Hold lapsed offer to the next person</Tag>
: <Tag tone="accent">Held until {ends ? formatInZone(ends, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" }) : "—"}</Tag>}
</div>
</div>
);
})}
</Panel>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
/* Record queries: somebody says their staff record is wrong. */
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { Empty } from "@/components/ui";
import { MonoNum, Panel } from "@/components/portal";
import { formatInZone } from "@/lib/compute";
import type { DisputeRow } from "./RequestList";
export default function Queries({ rows, act }: { rows: DisputeRow[]; act: (op: "dispute.resolve", payload: Record<string, unknown>) => Promise<boolean> }) {
const { s } = useSnap();
// A query carries the staff number, not the record id, so the link is found on the register.
const idByNum = new Map(s.staff.map((st) => [st.num, st.id]));
return (
<Panel title="Record queries" count={rows.length}>
{rows.length === 0 ? (
<div style={{ padding: "0 16px" }}><Empty pad={3}>Nobody has queried their record.</Empty></div>
) : rows.map((d) => {
const sid = idByNum.get(d.staffNum);
return (
<div key={d.id} className="tc-req-row">
<div className="tc-req-head">
<span style={{ flex: 1, minWidth: 180 }}>
<b>{d.staffName}</b> <MonoNum size={12} tone="muted">{d.staffNum}</MonoNum>
<span className="tc-meta-line">{d.ward ? ` · ${d.ward}` : ""} · {formatInZone(d.at, s.tz)}</span>
</span>
<div className="tc-req-actions">
<button type="button" className="btn btn-secondary" onClick={() => void act("dispute.resolve", { id: d.id })}>Mark sorted</button>
{sid && <Link className="btn btn-ghost" href={`/app/staff/${sid}`}>Open record</Link>}
</div>
</div>
<p style={{ fontSize: 14, lineHeight: 1.5, margin: "6px 0 0", maxWidth: "70ch" }}>{d.body}</p>
</div>
);
})}
</Panel>
);
}
+521
View File
@@ -0,0 +1,521 @@
"use client";
/* The request queue, as one reusable list.
*
* Imported by /app/requests (the full queue), the staff record's Requests tab and Today's Pick
* group; the command panel reads useRequests(). `lines` is what was asked, `bag` is what is
* picked: the pick, the slip and the collection code are always built from `bag`, so a garment
* the ward declined never goes in somebody's hands. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { openSlip } from "@/components/dialogs";
import { Empty, ErrorLine } from "@/components/ui";
import { Panel, Tag } from "@/components/portal";
import { formatInZone, genderLabel, key, onhand, slipLive, type Ledger, type Snapshot } from "@/lib/compute";
import type { ReqLine } from "@/lib/staffdata";
import { NEEDS_STAFF, OPEN_REQUEST, statusText } from "@/lib/staffreq";
export type RequestMessage = { id: string; fromStaff: boolean; authorName: string; body: string; at: string };
export type RequestEvent = { id: string; label: string; meta: string; actorName: string; at: string };
/** One request exactly as GET /api/requests returns it (the API does not change). */
export type RequestRow = {
id: string; code: string; status: string; staffId: string; staffName: string; staffNum: string; ward: string;
lines: ReqLine[]; bag: ReqLine[];
summary: string; garments: number; lineCount: number; decision: string | null;
reason: string; note: string;
managerName: string; managerId: string | null;
declineReason: string | null; route: string | null;
collectCode: string | null; holdUntil: string; signerName: string | null; signerRole: string | null;
signedAt: string | null; claimedAt: string | null;
raisedById: string | null; raisedByName: string;
createdAt: string; decidedAt: string | null;
messages: RequestMessage[]; events: RequestEvent[];
};
export type DisputeRow = { id: string; body: string; staffName: string; staffNum: string; ward: string; at: string };
export type CycleRow = { id: string; dueBy: string; openedBy: string; openedAt: string; answers: number };
export type WaitingRow = { id: string; staffName: string; staffNum: string; ward: string; item: string; size: string; since: string; offeredAt: string | null };
export type DamageRow = { id: string; kind: string; note: string; photoId: string | null; staffId: string; staffName: string; staffNum: string; ward: string; item: string; size: string; requestCode: string; at: string };
export type ShortfallRow = { id: string; staffId: string; staffName: string; staffNum: string; ward: string; item: string; size: string; onRecord: number; confirmed: number; short: number; at: string };
export type RequestsPayload = {
requests: RequestRow[]; requestLimit: number; moreRequests: boolean;
/** Absent when fetched with ?staff= */
disputes?: DisputeRow[]; cycle?: CycleRow | null; shortfalls?: ShortfallRow[]; waiting?: WaitingRow[]; damage?: DamageRow[];
};
export const REQUEST_FILTERS = ["todo", "noapprover", "open", "all", "queries", "damage", "cycles"] as const;
export type RequestFilter = (typeof REQUEST_FILTERS)[number];
export type RequestRowFilter = Extract<RequestFilter, "todo" | "noapprover" | "open" | "all">;
export const REQUEST_FILTER_LABEL: Record<RequestFilter, string> = {
todo: "To do", noapprover: "Needs an approver", open: "Open", all: "All",
queries: "Record queries", damage: "Damage", cycles: "Kit check & waitlist",
};
export const isRowFilter = (f: RequestFilter): f is RequestRowFilter => f === "todo" || f === "noapprover" || f === "open" || f === "all";
/* ---------- status predicates: the only definitions screens may use ---------- */
const TODO = new Set(["accepted", "picking", "ready", "round"]);
export function isTodo(r: RequestRow): boolean { return TODO.has(r.status); }
/** Nobody was ever asked to approve it, so nobody on the ward can move it. */
export function isStranded(r: RequestRow): boolean { return r.status === "awaiting" && !r.managerName; }
export function isOpenRequest(r: RequestRow): boolean { return OPEN_REQUEST.has(r.status as never); }
export function isPickable(r: RequestRow): boolean { return r.status === "accepted"; }
type Scope = { staffId?: string; ward?: string };
const inScope = (r: { staffId?: string; ward: string }, scope?: Scope) =>
(!scope?.staffId || r.staffId === scope.staffId) && (!scope?.ward || r.ward === scope.ward);
export function requestsFor(rows: readonly RequestRow[], f: RequestRowFilter, scope?: Scope): RequestRow[] {
const pick = f === "todo" ? isTodo : f === "noapprover" ? isStranded : f === "open" ? isOpenRequest : () => true;
return rows.filter((r) => inScope(r, scope) && pick(r));
}
/** The payload narrowed to one person or one ward. Record queries and the waitlist carry no staff
* id, so a person is matched on their staff number there: pass it when you have the snapshot,
* otherwise it is read off that person's own requests, damage or shortfall rows. */
export function scopePayload(p: RequestsPayload, scope?: Scope, staffNum?: string): RequestsPayload {
if (!scope?.staffId && !scope?.ward) return p;
const num = scope.staffId
? staffNum ?? [...p.requests, ...(p.damage ?? []), ...(p.shortfalls ?? [])].find((r) => r.staffId === scope.staffId)?.staffNum
: undefined;
const byNum = (r: { staffNum: string; ward: string }) =>
(!scope.staffId || (!!num && r.staffNum === num)) && (!scope.ward || r.ward === scope.ward);
return {
...p,
requests: p.requests.filter((r) => inScope(r, scope)),
disputes: p.disputes?.filter(byNum),
waiting: p.waiting?.filter(byNum),
damage: p.damage?.filter((r) => inScope(r, scope)),
shortfalls: p.shortfalls?.filter((r) => inScope(r, scope)),
};
}
export function requestCounts(p: RequestsPayload, scope?: Scope): Record<RequestFilter, number> {
const q = scopePayload(p, scope);
return {
todo: q.requests.filter(isTodo).length,
noapprover: q.requests.filter(isStranded).length,
open: q.requests.filter(isOpenRequest).length,
all: q.requests.length,
queries: q.disputes?.length ?? 0,
damage: q.damage?.length ?? 0,
cycles: q.waiting?.length ?? 0,
};
}
/** Shelf check of a request's bag: inStock when every bag line has onhand(itemId:si) >= qty. */
export function bagStock(s: Snapshot, L: Ledger, r: RequestRow): { inStock: boolean; shortLines: ReqLine[] } {
// Two lines for the same garment and size draw on the same shelf, so they are summed first.
const want: Record<string, number> = {};
for (const l of r.bag) want[key(l.itemId, l.si)] = (want[key(l.itemId, l.si)] || 0) + l.qty;
const shortLines = r.bag.filter((l) => onhand(s, L, key(l.itemId, l.si)) < want[key(l.itemId, l.si)]);
return { inStock: shortLines.length === 0, shortLines };
}
const lineText = (l: ReqLine) => `${l.qty} × ${l.item}${l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""}${l.size}`;
/** The slip payload for openSlip() (bag lines only, collection code, cut). */
export function requestSlip(s: Snapshot, r: RequestRow): Record<string, string | number> {
return {
staffName: r.staffName, dept: r.ward, deliverTo: r.ward,
sets: r.garments, po: r.code, code: r.collectCode || "",
lines: r.bag.map(lineText).join("\n"),
dateReceived: s.today, requestedBy: r.staffNum,
deliveredBy: s.settings.coordinator, dateTime: s.today,
};
}
/* ---------- fetching: one module-level cache per key, shared by every screen ---------- */
type Entry = { at: number; data: RequestsPayload | null; error: string; loading: boolean; inflight: Promise<void> | null };
const TTL = 60_000;
const cache = new Map<string, Entry>();
const listeners = new Map<string, Set<() => void>>();
const entryOf = (k: string): Entry => {
let e = cache.get(k);
if (!e) { e = { at: 0, data: null, error: "", loading: false, inflight: null }; cache.set(k, e); }
return e;
};
const notify = (k: string) => listeners.get(k)?.forEach((fn) => fn());
function load(k: string, force: boolean): Promise<void> {
const e = entryOf(k);
if (e.inflight) return e.inflight;
if (!force && e.data && Date.now() - e.at < TTL) return Promise.resolve();
e.loading = true;
notify(k);
e.inflight = (async () => {
try {
const r = await fetch(k ? `/api/requests?staff=${encodeURIComponent(k)}` : "/api/requests");
if (!r.ok) {
const j = await r.json().catch(() => ({}));
e.error = (j && j.error) || "Couldnt load the requests.";
} else {
e.data = await r.json();
e.error = "";
e.at = Date.now();
}
} catch {
e.error = "Couldnt reach the server — requests arent loaded.";
} finally {
e.loading = false;
e.inflight = null;
notify(k);
}
})();
return e.inflight;
}
/** Fetch hook. Unscoped = the whole queue plus disputes/cycle/waiting/damage; staffId = GET /api/requests?staff=<id>.
* Results are cached per key at module level for 60s; reload() refetches. Errors are returned, never thrown. */
export function useRequests(opts?: { staffId?: string; enabled?: boolean }): { data: RequestsPayload | null; error: string; loading: boolean; reload: () => Promise<void> } {
const k = opts?.staffId || "";
const enabled = opts?.enabled !== false;
const [, bump] = useState(0);
useEffect(() => {
if (!enabled) return;
let set = listeners.get(k);
if (!set) { set = new Set(); listeners.set(k, set); }
const subs = set;
const fn = () => bump((n) => n + 1);
subs.add(fn);
void load(k, false);
return () => { subs.delete(fn); };
}, [k, enabled]);
const reload = useCallback(() => load(k, true), [k]);
if (!enabled) return { data: null, error: "", loading: false, reload };
const e = entryOf(k);
return { data: e.data, error: e.error, loading: e.loading || (!e.data && !e.error), reload };
}
type QueueOp = "request.pick" | "request.hold" | "request.round" | "request.collected" | "request.reply" | "request.reassign" | "request.withdraw" | "damage.handedIn" | "dispute.resolve" | "kitcheck.open" | "kitcheck.close" | "waitlist.offer";
/** Runs a request-queue op through mutate(), then reload(). Returns ok. */
export function useRequestActions(reload: () => Promise<void>): { act: (op: QueueOp, payload: Record<string, unknown>) => Promise<boolean>; error: string; clearError: () => void } {
const { mutate } = useSnap();
const [error, setError] = useState("");
const act = useCallback(async (op: QueueOp, payload: Record<string, unknown>) => {
setError("");
const r = await mutate(op, payload);
if (!r.ok) { setError(r.error); return false; }
// Every screen sharing this cache key sees the change, not just the one that made it.
await reload();
return true;
}, [mutate, reload]);
const clearError = useCallback(() => setError(""), []);
return { act, error, clearError };
}
/* ---------- the list ---------- */
export type RequestListProps = {
/** Scope. staffId fetches ?staff=<id> unless `data` is passed; ward filters client-side on r.ward. */
staffId?: string;
ward?: string;
/** Which rows. Default "open". */
filter?: RequestRowFilter;
/** Row expanded on first render (from ?open=). */
openId?: string | null;
/** Hide the person/ward text on each row (every row is the same person). */
hidePerson?: boolean;
/** Panel title; null renders rows without panel chrome. Default REQUEST_FILTER_LABEL[filter]. */
title?: React.ReactNode | null;
/** One-sentence empty state. Default per filter. */
emptyText?: string;
/** Called whenever data loads or changes. */
onCounts?: (counts: Record<RequestFilter, number>) => void;
/** Use an already-loaded payload (the /app/requests page shares one hook between its segment and the list). */
data?: RequestsPayload | null;
reload?: () => Promise<void>;
};
const EMPTY: Record<RequestRowFilter, string> = {
todo: "Nothing approved and waiting.",
noapprover: "Every waiting request has somebody to approve it.",
open: "No open requests.",
all: "No requests yet.",
};
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
/* Row layout rules. Kept with the component (and hoisted once by React) because only the list
* wears them; the mobile rule stretches an expanded row's actions to full width. */
/** The row styles, rendered once however many lists are on screen. */
export function RequestStyles() {
return null; // the rules are in app/globals.css under portal redesign
}
export default function RequestList({ staffId, ward, filter = "open", openId, hidePerson, title, emptyText, onCounts, data: given, reload: givenReload }: RequestListProps) {
const external = given !== undefined;
const own = useRequests({ staffId, enabled: !external });
const data = external ? given : own.data;
const reload = external ? givenReload ?? own.reload : own.reload;
const { act, error } = useRequestActions(reload);
const [openRow, setOpenRow] = useState<string | null>(openId ?? null);
useEffect(() => { if (openId) setOpenRow(openId); }, [openId]);
// Bring a deep-linked row into view once it is on screen.
const scrolled = useRef<string | null>(null);
useEffect(() => {
if (!openId || scrolled.current === openId || !data) return;
const el = document.getElementById(`req-${openId}`);
if (el) { scrolled.current = openId; el.scrollIntoView({ block: "center" }); }
}, [openId, data, filter]);
const scope = useMemo(() => ({ staffId, ward }), [staffId, ward]);
const counts = useMemo(() => (data ? requestCounts(data, scope) : null), [data, scope]);
const countsRef = useRef(onCounts);
useEffect(() => { countsRef.current = onCounts; }, [onCounts]);
useEffect(() => { if (counts) countsRef.current?.(counts); }, [counts]);
const rows = useMemo(() => (data ? requestsFor(data.requests, filter, scope) : []), [data, filter, scope]);
const pad = title === null ? 0 : "0 16px";
let body: React.ReactNode;
if (!data) {
const loadErr = external ? "" : own.error;
body = loadErr ? (
<div style={{ padding: title === null ? 0 : "0 16px 12px" }}>
<ErrorLine msg={loadErr} />
<div style={{ marginTop: 12 }}><button type="button" className="btn btn-secondary" onClick={() => void own.reload()}>Try again</button></div>
</div>
) : <div style={{ padding: "0 16px" }}><Empty pad={3}>Loading</Empty></div>;
} else if (rows.length === 0) {
// The loading and empty lines always keep side padding: embedded with no title (the staff
// record's Requests tab) the list sits directly inside a bordered panel, and a sentence flush
// against that border reads as broken. Request rows lay themselves out and keep `pad`.
body = <div style={{ padding: "0 16px" }}><Empty pad={3}>{emptyText ?? EMPTY[filter]}</Empty></div>;
} else {
body = rows.map((r) => (
<RequestRowView key={r.id} r={r} open={openRow === r.id} hidePerson={hidePerson}
onToggle={() => setOpenRow((o) => (o === r.id ? null : r.id))} act={act} />
));
}
return (
<div>
<RequestStyles />
<ErrorLine msg={error} />
{title === null
? <div>{body}</div>
: (
<div style={{ marginTop: error ? 12 : 0 }}>
<Panel title={title ?? REQUEST_FILTER_LABEL[filter]} count={data ? rows.length : undefined}>{body}</Panel>
</div>
)}
{data?.moreRequests && (
<p className="tc-meta-line" style={{ margin: "12px 0 0" }}>Only the latest {data.requestLimit} are loaded older ones are on the staff record.</p>
)}
</div>
);
}
/* Who a waiting request can be handed to. The wearer may approve their own (marked as a
* self-approval); the person who raised it never can, and the counter refuses that on the id.
* A manager with no staff-app account cannot be asked, so they are grouped apart. */
type ApproverChoice = { id: string; reachable: boolean; label: string };
function approverChoices(s: Snapshot, r: RequestRow): ApproverChoice[] {
return s.staff
.filter((x) => !x.inactive && x.first && x.id !== r.raisedById && (x.id !== r.staffId || x.managerId === x.id))
.map((x) => ({
id: x.id,
reachable: !!x.selfEmail,
label: `${`${x.first} ${x.last}`.trim()}${x.dept ? ` · ${x.dept}` : ""}`
+ (x.id === r.staffId ? " · this request is theirs — self-approval" : "")
+ (x.selfEmail ? "" : x.selfCode && slipLive(x.selfCodeAt, s.today, s.tz) ? " · code printed, not used yet" : " · no staff-app account"),
}));
}
function RequestRowView({ r, open, hidePerson, onToggle, act }: {
r: RequestRow; open: boolean; hidePerson?: boolean; onToggle: () => void;
act: (op: QueueOp, payload: Record<string, unknown>) => Promise<boolean>;
}) {
const { s } = useSnap();
const [reply, setReply] = useState("");
const [hold, setHold] = useState("");
const [reassign, setReassign] = useState("");
const st = statusText(r, { mine: false, first: r.staffName.split(" ")[0] });
const awaiting = r.status === "awaiting";
const orphan = isStranded(r);
const onRound = r.status === "round" || r.status === "delivered";
const wearerApproves = !!r.managerId && r.managerId === r.staffId;
const approval = awaiting
? (r.managerName ? `with ${r.managerName}${wearerApproves ? " · self-approval" : ""}` : "nobody asked yet")
: r.status === "declined"
? (r.decision || "declined")
: `${r.decision || "Approved"} by ${r.managerName}${wearerApproves ? " · self-approved" : ""}`;
const choices = useMemo(() => (open && awaiting ? approverChoices(s, r) : []), [open, awaiting, s, r]);
const reachable = choices.filter((c) => c.reachable);
const unreachable = choices.filter((c) => !c.reachable);
const picked = open && reassign ? s.staff.find((x) => x.id === reassign) ?? null : null;
const refused = r.lines.filter((l) => l.status === "declined").length;
function toggle() {
setReply(""); setHold(""); setReassign("");
onToggle();
}
async function send() {
if (reply.trim() && await act("request.reply", { id: r.id, body: reply })) setReply("");
}
const orderForm = (
<button type="button" className="btn btn-secondary" aria-label={`Print order form for ${r.code}`}
title={awaiting ? "Everything asked for, managers block blank" : "The approved lines only"}
onClick={() => window.open(`/print/order-form?request=${encodeURIComponent(r.id)}`, "_blank", "noopener")}>
Print order form
</button>
);
return (
<div id={`req-${r.id}`} className={"tc-req-row" + (orphan ? " tc-flag" : "")} style={{ opacity: awaiting && !orphan && !open ? 0.6 : 1 }}>
<div className="tc-req-head">
<span className="tc-mono" style={{ fontSize: 12, fontWeight: 500, width: 74, flex: "none" }}>{r.code}</span>
<span style={{ flex: 1, minWidth: 160 }}>
<b>{r.summary}</b>
{!hidePerson && <span style={{ color: "var(--color-neutral-700)" }}> · {r.staffName}{r.ward ? ` (${r.ward})` : ""}</span>}
</span>
{orphan && <Tag tone="accent">No approver</Tag>}
<Tag tone={NEEDS_STAFF.has(r.status as never) ? "accent" : "quiet"}>{st.label}</Tag>
<button type="button" className="btn btn-ghost" style={{ minHeight: 30, padding: "2px 8px" }} aria-expanded={open} onClick={toggle}
aria-label={open ? `Close ${r.code}` : `Open ${r.code}`}>
{open ? "Close" : `Open${r.lineCount > 1 ? ` · ${r.lineCount} lines` : ""}`}
</button>
</div>
<div className="tc-meta-line tc-req-meta">
{[r.reason, approval, r.raisedByName ? `raised by ${r.raisedByName}` : "", formatInZone(r.createdAt, s.tz)].filter(Boolean).join(" · ")}
</div>
{open && (
<div className="tc-req-body">
<div>
{r.lines.map((l) => {
const off = l.status === "declined";
return (
<div key={l.id} className="tc-req-item">
<span style={{ flex: 1, minWidth: 160, fontWeight: off ? 400 : 600, textDecoration: off ? "line-through" : "none", color: off ? "var(--color-neutral-700)" : undefined }}>
{l.qty} × {l.item}{l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""} <span className="tc-mono">{l.size}</span>
</span>
<Tag tone={l.status === "approved" ? "quiet" : off ? "outline" : "low"}>{l.statusLabel}</Tag>
{off && l.declineReason && <span className="tc-meta-line">{l.declineReason}</span>}
</div>
);
})}
<div className="tc-meta-line" style={{ marginTop: 4 }}>
{r.status === "declined" ? "Nothing to pick"
: awaiting ? `${plural(r.garments, "garment", "garments")} asked for`
: `In the bag: ${plural(r.garments, "garment", "garments")}${refused ? ` · ${plural(refused, "declined line", "declined lines")} not picked` : ""}`}
</div>
</div>
{r.note && <p style={{ fontSize: 13.5, lineHeight: 1.5, margin: "10px 0 0", maxWidth: "70ch" }}>&ldquo;{r.note}&rdquo;</p>}
<div className="tc-req-actions" style={{ marginTop: 12 }}>
{awaiting && (
<>
<select className="input" style={{ width: 280, maxWidth: "100%" }} value={reassign} onChange={(e) => setReassign(e.target.value)}
aria-label={orphan ? `Choose who approves ${r.code}` : `Send ${r.code} to a different approver`}>
<option value="">{orphan ? "Choose an approver…" : "Send it to somebody else…"}</option>
{unreachable.length === 0
? reachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)
: (
<>
{reachable.length > 0 && (
<optgroup label="Can decide it today">
{reachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</optgroup>
)}
<optgroup label="Cant be asked — no staff-app account">
{unreachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</optgroup>
</>
)}
</select>
<button type="button" className="btn btn-primary"
onClick={async () => { if (reassign && await act("request.reassign", { id: r.id, managerId: reassign })) setReassign(""); }}>
{orphan ? "Ask them" : "Re-address"}
</button>
<button type="button" className="btn btn-secondary"
onClick={() => { if (confirm(`Withdraw ${r.code}? ${r.staffName} is told it was declined by the linen room.`)) void act("request.withdraw", { id: r.id, reason: "Withdrawn — no approver available" }); }}>
Withdraw it
</button>
{orderForm}
</>
)}
{r.status === "accepted" && <button type="button" className="btn btn-primary" onClick={() => void act("request.pick", { id: r.id })}>Start picking</button>}
{r.status === "picking" && (
<>
<input className="input" style={{ width: 200 }} aria-label={`Held until, for ${r.code}`} placeholder="Held until — e.g. Fri 6pm" value={hold} onChange={(e) => setHold(e.target.value)} />
<button type="button" className="btn btn-primary" onClick={async () => { if (await act("request.hold", { id: r.id, holdUntil: hold })) setHold(""); }}>Hold at the counter</button>
<button type="button" className="btn btn-secondary" onClick={() => void act("request.round", { id: r.id })}>Send on the ward round</button>
</>
)}
{r.status === "ready" && (
<>
<span style={{ fontSize: 13 }}>
Code <span className="tc-mono" style={{ fontSize: 15, fontWeight: 600 }}>{r.collectCode}</span> · {plural(r.garments, "garment", "garments")}{r.holdUntil ? ` · until ${r.holdUntil}` : ""}
</span>
<button type="button" className="btn btn-primary" onClick={() => void act("request.collected", { id: r.id })}>Collected</button>
</>
)}
{r.status === "round" && <span className="tc-meta-line" style={{ fontSize: 13 }}>On the round to {r.ward || "the ward"} · {plural(r.garments, "garment", "garments")}</span>}
{r.status === "delivered" && <span className="tc-meta-line" style={{ fontSize: 13 }}>Signed by {r.signerName}{r.signerRole ? `, ${r.signerRole}` : ""}{r.claimedAt ? " · collected" : " · not yet collected from the ward"}</span>}
{r.status === "collected" && <span className="tc-meta-line" style={{ fontSize: 13 }}>Handed over at the counter</span>}
{r.status === "declined" && <span className="tc-meta-line" style={{ fontSize: 13 }}>Declined {r.declineReason || "no reason recorded"}</span>}
{!awaiting && r.status !== "declined" && (
<>
<button type="button" className="btn btn-secondary" onClick={() => openSlip(onRound ? "delivery" : "collection", requestSlip(s, r))}>
{onRound ? "Delivery slip" : "Collection slip"}
</button>
{orderForm}
</>
)}
</div>
{awaiting && choices.length === 0 && (
<p className="tc-meta-line" style={{ margin: "8px 0 0" }}>Nobody on the register can approve this one <Link href="/app/staff">add a manager</Link> or withdraw it.</p>
)}
{picked && picked.id === r.staffId && (
<p className="tc-meta-line" style={{ margin: "8px 0 0" }}>{picked.first}&apos;s own request sending it to them is a self-approval.</p>
)}
{picked && !picked.selfEmail && (
<p className="tc-meta-line" style={{ margin: "8px 0 0" }}>
{picked.first} can&apos;t be asked:{" "}
{picked.selfCode && slipLive(picked.selfCodeAt, s.today, s.tz)
? <>the code on <Link href={`/app/staff/${picked.id}`}>their record</Link> isn&apos;t used yet.</>
: picked.selfCode
? <>the code on <Link href={`/app/staff/${picked.id}`}>their record</Link> has expired.</>
: <>no staff-app account <Link href={`/app/staff/${picked.id}`}>give them a code</Link>.</>}
</p>
)}
<h3 className="tc-lbl tc-req-sub">Messages</h3>
{r.messages.length === 0 && <div className="tc-meta-line" style={{ padding: "4px 0" }}>No messages.</div>}
{r.messages.map((m) => (
<div key={m.id} className="tc-req-item" style={{ display: "block" }}>
<b>{m.fromStaff ? m.authorName : `${m.authorName} (linen room)`}</b>
<span className="tc-meta-line" style={{ marginLeft: 8 }}>{formatInZone(m.at, s.tz)}</span>
<div style={{ marginTop: 3, lineHeight: 1.5 }}>{m.body}</div>
</div>
))}
<div className="tc-req-actions" style={{ marginTop: 8 }}>
<input className="input" style={{ flex: 1, minWidth: 200 }} aria-label={`Reply about ${r.code}`} placeholder="Reply to this order" value={reply}
onChange={(e) => setReply(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void send(); }} />
<button type="button" className="btn btn-secondary" onClick={() => void send()}>Send</button>
</div>
<h3 className="tc-lbl tc-req-sub">History</h3>
{r.events.map((e) => (
<div key={e.id} className="tc-req-item">
<span style={{ flex: 1, minWidth: 160 }}>{e.label}{e.meta ? `${e.meta}` : ""}</span>
<span className="tc-meta-line">{e.actorName} · {formatInZone(e.at, s.tz)}</span>
</div>
))}
</div>
)}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
/* The request queue's CSV exports, one file per filter, exactly the files the queue wrote before
* the redesign. Times are full dates in the facility's zone with the zone named in the preamble,
* because a spreadsheet is re-sorted the moment it lands. Request rows are one per garment line,
* so a declined fleece is one filter away. */
import { csvEsc, csvOf, facilityDate, formatInZone, genderLabel, type Snapshot } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
import type { ReqLine } from "@/lib/staffdata";
import { holdEndsAt, holdExpired, statusText } from "@/lib/staffreq";
import { REQUEST_FILTER_LABEL, requestsFor, type RequestFilter, type RequestsPayload } from "./RequestList";
/** How many rows the export for this filter writes (what "{n} shown" counts). */
export function exportCount(f: RequestFilter, p: RequestsPayload): number {
if (f === "queries") return p.disputes?.length ?? 0;
if (f === "damage") return p.damage?.length ?? 0;
if (f === "cycles") return (p.shortfalls?.length ?? 0) + (p.waiting?.length ?? 0);
return requestsFor(p.requests, f).length;
}
/** Writes the file for filter `f` from an already-scoped payload. `showing` names the scope. */
export function exportRequestsCsv(s: Snapshot, f: RequestFilter, p: RequestsPayload, showing?: string) {
const when = (iso: string | null) =>
iso ? `${facilityDate(iso, s.tz)} ${formatInZone(iso, s.tz, { hour: "2-digit", minute: "2-digit", hour12: false, hourCycle: "h23" })}` : "";
const preamble = (facts: [string, string | number][]) =>
[...facts, ...(showing ? ([["Showing", showing]] as [string, string][]) : [])]
.map(([k, v]) => `${csvEsc(k)},${typeof v === "number" ? v : csvEsc(v)}`).join("\n") + "\n\n";
if (f === "queries") {
const rows = p.disputes ?? [];
downloadCsv(`threadcount-record-queries-${s.today}.csv`,
preamble([["Record queries", "Raised against a staff record, not yet sorted"], ["Exported", s.today], ["Times shown in", s.tz], ["Queries in this file", rows.length]])
+ csvOf(["Raised", "Staff no.", "Staff member", "Ward", "What they say is wrong"],
rows.map((d) => [when(d.at), d.staffNum, d.staffName, d.ward, d.body])));
return;
}
if (f === "damage") {
const rows = p.damage ?? [];
downloadCsv(`threadcount-damage-${s.today}.csv`,
preamble([["Damage reported", "Not yet handed in at the counter"], ["Exported", s.today], ["Times shown in", s.tz], ["Reports in this file", rows.length]])
+ csvOf(["Reported", "Staff no.", "Staff member", "Ward", "Garment", "Size", "Damage", "What they said", "Replacement requested", "Photo"],
rows.map((d) => [when(d.at), d.staffNum, d.staffName, d.ward, d.item || "Garment no longer on file", d.size, d.kind, d.note, d.requestCode, d.photoId ? "Yes" : "No"])));
return;
}
if (f === "cycles") {
const c = p.cycle ?? null;
downloadCsv(`threadcount-kit-check-${s.today}.csv`,
preamble([
["Kit check and waitlist", c ? `Running — due by ${c.dueBy}` : "No kit check running"],
["Opened by", c ? c.openedBy || "—" : ""],
["Answers in", c ? c.answers : 0],
["Exported", s.today],
["Times shown in", s.tz],
])
+ "What people couldn't account for\n"
+ csvOf(["Staff no.", "Staff member", "Ward", "Garment", "Size", "On record", "Confirmed", "Short", "Answered"],
(p.shortfalls ?? []).map((x) => [x.staffNum, x.staffName, x.ward, x.item, x.size, x.onRecord, x.confirmed, x.short, when(x.at)]))
+ "\nWaiting for a size\n"
+ csvOf(["Staff no.", "Staff member", "Ward", "Garment", "Size", "Waiting since", "Offered", "Held until", "Hold"],
(p.waiting ?? []).map((w) => {
const ends = holdEndsAt(w.offeredAt);
return [w.staffNum, w.staffName, w.ward, w.item, w.size, when(w.since), when(w.offeredAt),
ends ? when(ends.toISOString()) : "",
!w.offeredAt ? "Not offered yet" : holdExpired(w.offeredAt) ? "Lapsed — offer to the next person" : "Held"];
})));
return;
}
const rows = requestsFor(p.requests, f);
downloadCsv(`threadcount-requests-${f === "noapprover" ? "needs-an-approver" : f}-${s.today}.csv`,
preamble([
["Ward requests", REQUEST_FILTER_LABEL[f]],
["Exported", s.today],
["Times shown in", s.tz],
["Requests in this file", rows.length],
...(p.moreRequests
? ([["Older requests not in this file", `The screen holds the most recent ${p.requestLimit} requests and there are older ones than those`]] as [string, string][])
: []),
["Rows", "One per line on the request — a request for a tunic and two pairs of trousers is two rows, and the pairs are a Qty of 2 on the second"],
])
+ csvOf(["Request", "Raised", "Staff no.", "Staff member", "Ward", "Raised by", "Reason", "Note", "Request status", "Approver", "Approver is the wearer", "Decision summary", "Decided", "Request decline reason", "Collection code", "Garment", "Cut", "Size", "Qty", "Line decision", "Line decline reason"],
rows.flatMap((r) => {
const req: (string | number)[] = [
r.code, when(r.createdAt), r.staffNum, r.staffName, r.ward, r.raisedByName, r.reason, r.note,
statusText(r).label, r.managerName, r.managerId && r.managerId === r.staffId ? "Yes" : "",
r.decision ?? "", when(r.decidedAt), r.declineReason ?? "", r.collectCode ?? "",
];
// A request with no lines still appears: it is sitting in somebody's queue.
const lines: (ReqLine | null)[] = r.lines.length ? r.lines : [null];
return lines.map((l) => [...req,
l ? l.item : "", l ? genderLabel(l.gender) : "", l ? l.size : "", l ? l.qty : "",
l ? l.statusLabel : "", l ? l.declineReason ?? "" : ""]);
})));
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
/* 1J Account.
*
* What a wearer can do to their own sign-in, and one thing they deliberately cannot.
*
* Changing the password is the only revocation they have: a staff token carries a fingerprint of
* the password hash, so setting a new one ends every session signed against the old one at once
* a phone left on a ward, a cookie copied off it, a password read over somebody's shoulder. The
* copy says so plainly, because the consequence is the feature and somebody who does not know it
* happens will not reach for this when they most need it.
*
* Deleting the account is not here, and is not an oversight. Access is the linen room's to grant
* and theirs to remove: a wearer who could delete their own account would take the record of what
* they were issued with it.
*/
import { DELETE_ACCOUNT_URL, PRIVACY_EMAIL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
import { useState } from "react";
import { MBar, MBody, MError, MRow, MRule, MSection, MTop } from "@/components/m";
import { INK, N600, N700 } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { forgetPush } from "@/lib/staffpush";
import NotificationSettings, { type NotifyPrefs } from "@/components/screens/NotificationSettings";
const field: React.CSSProperties = {
width: "100%", minHeight: 52, padding: "0 14px", border: "2px solid var(--color-divider)",
borderRadius: 0, font: "inherit", fontSize: 16, background: "#fff", color: "var(--color-text)",
};
const label: React.CSSProperties = {
display: "block", fontSize: 12.5, fontWeight: 800, letterSpacing: "0.06em",
textTransform: "uppercase", color: N600,
};
export default function AccountScreen({ email, prefs, pushReady }: {
email: string; prefs: NotifyPrefs; pushReady: boolean;
}) {
const { me, mutate, busy } = useStaff();
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [err, setErr] = useState("");
const [done, setDone] = useState(false);
const [leaving, setLeaving] = useState(false);
// The server enforces the same floor; checking it here only saves a round trip and a refusal.
const ready = current.length > 0 && next.length >= 8;
/* Signing out never waits on push.forget succeeding, and never fails because it didn't.
* Somebody on a ward with no signal still has to be able to leave a phone they are handing on.
* An orphaned token is reclaimed three other ways the next registration re-points it, FCM
* reports it gone, and a password change clears the lot. */
async function signOut() {
setLeaving(true);
const token = forgetPush();
if (token) void mutate("push.forget", { token });
await fetch("/api/staff/logout", {
method: "POST", headers: { "content-type": "application/json" }, body: "{}",
}).catch(() => {});
// A full navigation: the cookie has just been cleared and every screen behind it is
// server-rendered.
window.location.replace("/my/signin");
}
return (
<>
<MTop title="Account" back backHref="/my" />
<MRule />
<MBody>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ padding: "20px 16px 18px", borderBottom: `2px solid ${INK}`, background: "var(--color-bg)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 26, letterSpacing: "-0.02em", lineHeight: 1.05 }}>
{me.name}
</div>
<div style={{ fontSize: 13, color: N600, marginTop: 4 }}>{email}</div>
</div>
<div style={{ padding: "0 16px" }}>
<MSection label="Notifications" />
<NotificationSettings prefs={prefs} configured={pushReady} />
<MSection label="Sign-in" />
{done ? (
<div style={{ background: "#fff", borderLeft: `6px solid ${INK}`, padding: "14px 16px", marginTop: 12 }}>
<div style={{ fontSize: 16, fontWeight: 800 }}>Password changed</div>
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "8px 0 0" }}>
Every other device signed in as you has been signed out, and any phone of yours set
up for notifications has been unregistered. This one stays signed in.
</p>
</div>
) : (
<>
{/* A disclosure, not a link: the form is on this screen, so the row says so in words
a screen reader is given rather than only by what appears underneath it. */}
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
style={{
display: "flex", alignItems: "center", gap: 12, width: "100%", minHeight: 60,
padding: "9px 0", background: "none", border: 0,
borderBottom: "1px solid var(--color-divider)", font: "inherit", color: "inherit",
textAlign: "left", cursor: "pointer",
}}
>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 15, fontWeight: 700 }}>Change your password</span>
<span style={{ display: "block", fontSize: 13, color: N600, marginTop: 1 }}>Signs you out on other devices</span>
</span>
<span aria-hidden="true" style={{ color: N600, fontSize: 18 }}>{open ? "" : ""}</span>
</button>
{open && (
<div style={{ padding: "16px 0 4px" }}>
{/* The words wrap the box rather than sitting beside it: on the one screen where
typing in the wrong one of two password fields is silent, both must announce
which they are. */}
<label style={{ display: "block" }}>
<span style={label}>Current password</span>
<input
type="password" autoComplete="current-password" value={current}
onChange={(e) => { setCurrent(e.target.value); setErr(""); }}
style={{ ...field, marginTop: 8 }}
/>
</label>
<label style={{ display: "block", marginTop: 16 }}>
<span style={label}>New password</span>
<input
type="password" autoComplete="new-password" value={next}
onChange={(e) => { setNext(e.target.value); setErr(""); }}
style={{ ...field, marginTop: 8 }}
/>
</label>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
At least 8 characters. Changing it signs you out everywhere else straight away.
This device stays signed in.
</p>
</div>
)}
</>
)}
<MSection label="Privacy" />
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "12px 0 4px" }}>
ThreadCount holds your sign-in and the linen room&rsquo;s record of what you have been
issued. You can&rsquo;t delete this account from here ask your uniform coordinator and
they can remove it{PRIVACY_EMAIL ? <>, or write to {PRIVACY_EMAIL}</> : null}.
</p>
</div>
{PRIVACY_URL && <MRow href={PRIVACY_URL} external mark="ink" title="Privacy policy" sub="What ThreadCount stores, and what it never does" />}
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external mark="ink" title="Deleting your account" sub="How it is done, and what goes with it" />}
{TERMS_URL && <MRow href={TERMS_URL} external mark="ink" title="Terms of use" sub="What you and ThreadCount each agree to" />}
{/* The one thing a wearer can do to a phone they no longer have, so it is findable without
asking and at the foot, because it is the last thing anybody comes here to do. */}
<div style={{ padding: "22px 16px 0" }}>
<button
type="button"
onClick={() => void signOut()}
style={{
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",
cursor: leaving ? "wait" : "pointer",
}}
>{leaving ? "Signing out…" : "Sign out"}</button>
</div>
<div style={{ height: 24 }} />
</MBody>
{open && !done && (
<MBar
label={busy ? "Saving…" : "Change my password"}
glyph="check"
disabled={!ready || busy}
onClick={async () => {
const r = await mutate("account.password", { current, next });
if (!r.ok) { setErr(r.error); return; }
setCurrent(""); setNext(""); setOpen(false); setDone(true);
}}
/>
)}
</>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
/* Team Approvals the queue, and the decision, on one screen.
*
* Oldest first, deliberately. The queue's job is to surface the person who has been waiting
* longest, and a newest-first list quietly buries them which is the failure mode this whole
* flow exists to fix.
*
* Approving now happens in the list: the commonest decision by far is "yes, all of it", and making
* somebody open a screen to say so cost a tap on every request and taught them to open and approve
* without reading either. Open is still there for the decision that needs looking at a single
* garment knocked back, which only the review screen can do.
*
* A request the manager is the wearer of can reach this queue. Whoever a request names is who
* decides it, and the server has been allowed to let that be the wearer themselves in the one case
* the owner named. The server owns that decision and this screen never re-tests it. What the screen
* owns is the honesty: an approval somebody gives themselves is set apart from the ones they give
* on other people's behalf, and says what the record will call it afterwards.
*
* This queue also reaches people who manage nobody the linen room re-addresses a request that
* arrived without an approver, or somebody's last report moves away while their own request is
* still waiting. They have a queue, so they have a Team tab (lib/staffreq.ts teamTabs).
*/
import { useState } from "react";
import { MEmpty, MError } from "@/components/m";
import { EdgeRow, N600, N700 } from "@/components/staffui";
import Team, { Band, ChipAction, ChipRow } from "./Team";
import { useStaff } from "@/lib/staffclient";
import { daysBetween, facilityDate, facilityToday } from "@/lib/compute";
import type { QueueRow } from "@/lib/managerdata";
/* Calendar days on the ward, not elapsed 24-hour blocks.
*
* Dividing the milliseconds understated every overnight wait: a request raised at 18:00 on Monday
* still read "today" at 09:00 on Tuesday and only became "since yesterday" that evening, by which
* point it had spanned two working days. This queue exists to surface the person who has been
* waiting longest, so the label counts the way they do, in the facility's own zone. */
function waited(iso: string, tz: string) {
const raised = facilityDate(iso, tz);
if (!raised) return ""; // the row's meta line filters empties out
const days = daysBetween(raised, facilityToday(tz));
if (days <= 0) return "today";
if (days === 1) return "since yesterday";
return `waiting ${days} days`;
}
export default function ApprovalsScreen({ rows, ownIds = [] }: { rows: QueueRow[]; ownIds?: string[] }) {
const { me, mutate, busy } = useStaff();
const [err, setErr] = useState("");
/* What just happened, for the person who pressed the button. The row itself leaves the list on
* the refresh, and a list that silently gets shorter is not an answer. There is no toast in this
* app components/m.tsx's useToast is a documented no-op outside the counter's provider so it
* is said here, in a live region. */
const [done, setDone] = useState("");
/* Which of these are the manager's own is settled on the server, from the request's own subject.
Working it out here by matching a name would start calling a stranger's request yours the day
two people on the register share one and the thing being labelled is an audit fact. */
const own = new Set(ownIds);
const mine = rows.filter((r) => own.has(r.id));
const theirs = rows.filter((r) => !own.has(r.id));
async function approveAll(r: QueueRow) {
setErr("");
setDone("");
// The op decides garment by garment and wants a call for every line by id — approving from the
// queue is approving all of them, said explicitly rather than by omission.
const res = await mutate("request.approve", {
id: r.id,
lines: r.lines.map((l) => ({ id: l.id, decision: "approved", reason: "" })),
});
if (!res.ok) { setErr(res.error); return; }
setDone(`Approved · ${r.subjectName.split(" ")[0]} has been told`);
}
function Row({ r, own: isOwn }: { r: QueueRow; own: boolean }) {
return (
/* An ink edge rather than the accent one every other row carries. Accent means somebody else
is waiting on you; your own uniform is not that, and it must not be able to pass for it at
a glance on a phone held in one hand halfway down a ward. */
<EdgeRow tone={isOwn ? "ink" : "accent"}>
{/* The person, and nothing beside them. The code belongs to the request rather than to the
decision, and it is on the review screen's own title where somebody who has come from
the approval e-mail will look for it. */}
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{r.subjectName}</div>
{/* The whole ask in one line "3 garments · Tunic, Trousers". The garments themselves are
on the review screen, which is where a line is refused; a queue that listed every one
would bury the person who has been waiting longest under somebody else's four. */}
<div style={{ fontSize: 14.5, color: N600, marginTop: 5, lineHeight: 1.4 }}>{r.summary}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 3, lineHeight: 1.4 }}>
{[r.reason, r.subjectGroup, waited(r.createdAt, me.tz)].filter(Boolean).join(" · ")}
{r.raisedByName ? ` · raised by ${r.raisedByName}` : ""}
</div>
<ChipRow>
<ChipAction
label={`Approve ${r.garments}`}
disabled={busy}
onClick={() => void approveAll(r)}
/>
<ChipAction label="Open" href={`/my/approvals/${r.id}`} />
</ChipRow>
</EdgeRow>
);
}
return (
<Team active="/my/approvals">
<div role="status" aria-live="polite">
{done && (
<div style={{ padding: "12px 16px", background: "#fff", borderBottom: "1px solid var(--color-divider)", fontSize: 14, fontWeight: 800 }}>
{done}
</div>
)}
</div>
<MError msg={err} onDismiss={() => setErr("")} />
{rows.length === 0 ? (
<div style={{ padding: "0 16px" }}>
<MEmpty
title="Nothing waiting on you"
sub="Requests from your team arrive by notification and email, and land here."
/>
</div>
) : (
<>
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{theirs.map((r) => <Row key={r.id} r={r} own={false} />)}
</div>
{/* The manager's own go last, whatever date they were raised. Oldest first is a promise to
the colleague who has been waiting longest, and a request for your own uniform does not
step in front of her and a group at the foot, under its own heading, is not somewhere
a thumb arrives by accident on the way down the list of your team's. */}
{mine.length > 0 && (
<>
<Band tone="attention" label={mine.length === 1 ? "Your own request" : "Your own requests"} />
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "12px 16px 0", margin: 0 }}>
Approving {mine.length === 1 ? "it" : "them"} is recorded as your own approval.
</p>
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{mine.map((r) => <Row key={r.id} r={r} own />)}
</div>
</>
)}
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
Nothing reaches the linen room until you approve it.
</p>
</>
)}
<div style={{ height: 12 }} />
</Team>
);
}
+196
View File
@@ -0,0 +1,196 @@
"use client";
/* Approving or declining straight from the email, on whatever device opened it.
*
* This is the centred page shell rather than the phone column: a manager reaches it from a mail
* client, as often on a desktop as on a ward phone, and dropping them into an app chrome they
* never signed into would be a strange thing to meet.
*
* Nothing has happened when this page loads. That is the point of the design: the link renders,
* the button decides.
*/
import { useState } from "react";
import { Err, MyShell, h1, kicker, lead, primary } from "@/components/my";
import { DECLINE_REASONS } from "@/lib/staffreq";
import type { ReqLine } from "@/lib/staffdata";
type Data = {
code: string; subjectName: string; subjectMeta: string;
/** The whole ask, in the order it was entered. */
lines: ReqLine[];
/** linesSummary() — the one-liner every other screen leads with. */
summary: string;
reason: string; note: string;
raisedByName: string; facility: string;
};
const INK = "#201e1d";
const N600 = "var(--color-neutral-600)";
const N700 = "var(--color-neutral-700)";
const ACCENT_700 = "var(--color-accent-700)";
/** The garments, one row each. Deliberately drawn here rather than borrowed from the staff app's
* own list: this page is the centred desktop shell, not the phone column, and its type sizes and
* rules are a size up from everything in components/staffui. What it must match is the *content*
* the same order, the same strike-through on a refusal, the same reason against it. */
function Lines({ lines }: { lines: readonly ReqLine[] }) {
return (
<div style={{ display: "grid", gap: 1, background: "var(--color-divider)", marginTop: 16 }}>
{lines.map((l) => {
const off = l.status === "declined";
return (
<div key={l.id} style={{ background: "#fff", padding: "14px 16px" }}>
<div style={{
fontSize: 17, fontWeight: 800, lineHeight: 1.3,
textDecoration: off ? "line-through" : "none", color: off ? N600 : INK,
}}>{l.qty} × {l.item} {l.size}</div>
{off && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_700, marginTop: 6 }}>
Declined{l.declineReason ? `${l.declineReason}` : ""}
</div>
)}
</div>
);
})}
</div>
);
}
export default function ApproveByLink({ token, data, decided, status, declineReason }: {
token: string; data: Data; decided: boolean; status: string; declineReason: string | null;
}) {
const [done, setDone] = useState<null | { approved: boolean; reason?: string; notified: boolean }>(null);
const [declining, setDeclining] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
async function decide(action: "approve" | "decline", reason?: string) {
setBusy(true); setErr("");
const r = await fetch("/api/staff/decide", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ token, action, reason }),
}).catch(() => null);
const j = await r?.json().catch(() => ({}));
setBusy(false);
// Already decided — the other link in the email was used, or this one twice. The route says
// so with `already`, and the server render of this page shows the decision that stands, so
// reload into that rather than sit on a red error with two live buttons under it.
if (j?.already) { window.location.reload(); return; }
if (!r || !r.ok) { setErr(j?.error || "That didnt work."); return; }
setDone({ approved: action === "approve", reason, notified: !!j?.notified });
}
if (done) {
return (
<MyShell>
<div style={kicker}>ThreadCount</div>
<h1 style={h1}>{done.approved ? "Approved." : "Declined."}</h1>
<p style={lead}>
{/* "Has been told" only when an email actually left a wearer with no account or a site
with no mail hears nothing from this, and saying otherwise is how they wait a fortnight. */}
{done.approved
? `${done.notified ? `${data.subjectName.split(" ")[0]} has been told, and` : `${data.subjectName.split(" ")[0]} hasnt been emailed — its on their record in the app — and`} ${data.lines.length > 1 ? "all of it is" : "its"} with the linen room now.`
: `${done.notified ? `${data.subjectName.split(" ")[0]} has been told` : `${data.subjectName.split(" ")[0]} hasnt been emailed, so mention it to them`}${(done.reason || "").toLowerCase()}.`}
</p>
<p style={{ ...lead, fontSize: 13.5, color: N600 }}>
You can close this. Nothing else is waiting on you here.
</p>
</MyShell>
);
}
if (decided) {
return (
<MyShell>
<div style={kicker}>{data.facility}</div>
<h1 style={h1}>Already decided.</h1>
<p style={lead}>
{status === "declined"
? `This request was declined${declineReason ? `${declineReason.toLowerCase()}` : ""}.`
: "This request has already been approved and is with the linen room."}
</p>
{/* Which garments went and which didn't. A manager coming back to a request they settled
on their phone deserves the same answer here as the wearer gets on their order the
alternative is a page that says "approved" over an ask where a third of it was refused. */}
<Lines lines={data.lines} />
<p style={{ ...lead, fontSize: 13.5, color: N600 }}>
Approval links work once. Open the app if you need to look at it again.
</p>
</MyShell>
);
}
return (
<MyShell>
<div style={kicker}>{data.facility} · {data.code}</div>
<h1 style={h1}>{data.subjectName} needs your approval.</h1>
{data.subjectMeta && <p style={{ ...lead, marginTop: 8, fontSize: 13.5, color: N600 }}>{data.subjectMeta}</p>}
<div style={{ border: `2px solid ${INK}`, padding: 18, marginTop: 24, background: "#fff" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "-0.01em", lineHeight: 1.2 }}>
{data.summary}
</div>
{data.reason && <div style={{ fontSize: 14, color: N700, marginTop: 8 }}>{data.reason}</div>}
{data.note && <p style={{ fontSize: 14, lineHeight: 1.55, color: N700, margin: "10px 0 0" }}>{data.note}</p>}
{data.raisedByName && (
<p style={{ fontSize: 13, color: N600, margin: "10px 0 0" }}>Raised for them by {data.raisedByName}.</p>
)}
</div>
{/* The summary above is a count and three names; a manager about to approve four garments
needs to see the four. One garment needs no list the heading already is one. */}
{data.lines.length > 1 && <Lines lines={data.lines} />}
{err && <div style={{ marginTop: 16 }}><Err>{err}</Err></div>}
{declining ? (
<div style={{ marginTop: 24 }}>
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_700 }}>
Why are you declining?
</div>
<p style={{ ...lead, marginTop: 8, fontSize: 13.5 }}>
{data.lines.length > 1
? `This turns down all ${data.lines.length} garments. ${data.subjectName.split(" ")[0]} is told which reason you picked.`
: `${data.subjectName.split(" ")[0]} is told which one you picked.`}
</p>
<div style={{ display: "grid", gap: 2, marginTop: 14 }}>
{DECLINE_REASONS.map((r) => (
<button key={r} disabled={busy} onClick={() => decide("decline", r)} style={{
minHeight: 56, background: "#fff", color: INK, border: `2px solid ${INK}`, borderRadius: 0,
textAlign: "left", padding: "0 16px", font: "inherit", fontSize: 15.5, fontWeight: 800,
cursor: busy ? "wait" : "pointer", opacity: busy ? 0.6 : 1,
}}>{r}</button>
))}
</div>
<button onClick={() => { setDeclining(false); setErr(""); }} style={{
marginTop: 16, minHeight: 48, padding: "0 20px", background: "transparent", color: INK,
border: `2px solid ${INK}`, borderRadius: 0, font: "inherit", fontWeight: 800, fontSize: 13,
letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
}}>Back</button>
</div>
) : (
<div style={{ display: "grid", gap: 2, marginTop: 24 }}>
<button disabled={busy} onClick={() => decide("approve")} style={primary(busy)}>
{busy ? "Working…" : "Approve"}
</button>
<button disabled={busy} onClick={() => setDeclining(true)} style={{
minHeight: 56, background: "transparent", color: INK, border: `2px solid ${INK}`, borderRadius: 0,
font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14,
letterSpacing: "0.08em", textTransform: "uppercase", textAlign: "left", padding: "0 20px",
cursor: busy ? "wait" : "pointer",
}}>Decline</button>
</div>
)}
{/* The one thing this page cannot do. Signed in, a manager approves the tunic and turns down
the fleece on the same request; from an email link there is no signed-in person to check
a per-garment decision against, so it is deliberately the whole ask either way. A manager
who wants part of it has to be told where that lives rather than left approving three
garments to get one of them through. */}
<p style={{ ...lead, fontSize: 13, color: N600, marginTop: 24 }}>
Nothing has been decided yet this page just shows you the request. The link works once,
and it settles {data.lines.length > 1 ? "the whole request" : "it"} one way or the other.
{data.lines.length > 1 && " To approve some garments and not others, open the app."}
</p>
</MyShell>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
/* 1G the collection code, full screen and nothing else.
*
* This is held up across a counter, often at arm's length, so everything the order screen carries
* is something to read past: no app bar, no accent rule, no tab bar. The wearer's name and what is
* in the bag sit under the digits, because a clerk hands a bag to a person, not to a number.
*
* Done is a real link back to the order, never history.back() the 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 somebody out of the app holding an unread code.
* FullCode takes the href for exactly that reason.
*/
import { MStyles } from "@/components/m";
import { FullCode, lineText } from "@/components/staffui";
import { useKeepAwake } from "@/lib/wakelock";
export default function CodeFullScreen({ id, code, name, lines }: {
id: string; code: string; name: string; lines: { item: string; size: string; qty: number }[];
}) {
/* Keep the screen alight while the code is up.
*
* There is no brightness control on this platform neither the WebView nor the Capacitor shell
* offers one so the honest version of "make it readable across a counter" is the Screen Wake
* Lock API: it is what stops Android dimming and then locking the phone in the time it takes to
* reach the front of the queue. A no-op where it isn't supported, and the digits are already
* drawn as large as the screen allows.
*/
useKeepAwake(true);
return (
<>
{/* The furniture styles are normally injected by MTop, which this screen deliberately does
not have; without them the Done link loses its focus ring. */}
<MStyles />
<FullCode
code={code}
name={name}
lines={lines.map(lineText)}
backHref={`/my/orders/${id}`}
/>
</>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
/* Report damage. Two jobs on one screen: tell the linen room, and start the replacement.
*
* The two are deliberately separate acts. Reporting damage does not issue anything and does not
* silently remove the garment it comes off the record when it is handed in at the counter. A
* screen that wrote off a garment on somebody's say-so would be a screen the linen room stops
* trusting, and a screen that quietly issued a replacement would route around the manager.
*
* `Contaminated` is not in the list. Clinically it is a different pathway red bag, no return to
* the counter and telling someone to carry a contaminated garment to the linen room would be
* worse than saying nothing. Wards use the route they already have.
*/
import { useState } from "react";
import { MBar, MBody, MChipRow, MError, MRule, MSwitchRow, MTop } from "@/components/m";
import { DarkCard, N600, N700, NumberedField, OptionList } from "@/components/staffui";
import Sent from "@/components/screens/Sent";
import { useStaff } from "@/lib/staffclient";
import { DAMAGE_KINDS } from "@/lib/staffreq";
type Holding = {
issueId: string; itemId: string; item: string; size: string; si: number; qty: number;
labelId: string; issued: string; replacement: string;
};
export default function DamageScreen({ holdings, managerName, notifyWays }: {
holdings: Holding[]; managerName: string; notifyWays: { email: boolean; push: boolean };
}) {
const { mutate, busy } = useStaff();
const [issueId, setIssueId] = useState<string | null>(null);
const [kind, setKind] = useState<string | null>(null);
const [note, setNote] = useState("");
/* The switch defaults on, because asking for a replacement is what almost everybody reporting a
* torn tunic actually wants but only when there is somebody to ask. With no manager recorded
* the request half cannot be raised at all, so defaulting it on left people tapping "Report and
* request" and getting an error they could do nothing about. */
const canRequest = !!managerName;
const [replace, setReplace] = useState(canRequest);
const [err, setErr] = useState("");
/* The server's sentence for a report it saved without a replacement behind it almost always a
* garment whose range has been withdrawn, which cannot be ordered but is still on somebody's
* back. Nothing failed, so it is not an error; but the screen used to go straight to the kit
* list on a plain success and the nurse walked away expecting a replacement nobody had ordered. */
const [noReplacement, setNoReplacement] = useState("");
/* Where a finished report lands. A replacement was raised, so this is the request screen's own
* Sent same approver, same "what happens next" or, with no replacement asked for, the same
* screen saying the one thing that is still true: it comes off the record at the counter. */
const [done, setDone] = useState<{ what: string; order: { id: string; code: string; manager: string; notified: boolean } | null } | null>(null);
const held = holdings.find((h) => h.issueId === issueId) || null;
const ready = !!held && !!kind;
if (noReplacement) {
return (
<>
<MTop title="Reported" />
<MRule />
<MBody>
<div style={{ padding: 16 }}>
<DarkCard kicker="Reported" title="No replacement has been ordered" meta={noReplacement} />
</div>
</MBody>
<MBar label="Back to your kit" href="/my/kit" />
</>
);
}
if (done) {
const o = done.order;
const who = o?.manager || managerName || "your manager";
return o ? (
<Sent
headline={`Sent to ${who}`}
sub={`${done.what} · ${o.code}`}
next={
notifyWays.push
? "You get a notification when it is approved, and again when it is ready."
: o.notified
? `${who} has been emailed.`
: `It is waiting with ${who}.`
}
actions={[
{ label: "Open the order", href: `/my/orders/${o.id}` },
{ label: "Back to home", href: "/my" },
]}
bar={{ label: "Back to home", href: "/my" }}
/>
) : (
<Sent
title="Reported"
headline="Reported to the linen room"
sub={done.what}
next="It comes off your record when you hand it in at the counter."
actions={[{ label: "Back to your kit", href: "/my/kit" }]}
bar={{ label: "Back to home", href: "/my" }}
/>
);
}
return (
<>
<MTop title="Report damage" back backHref="/my/kit" />
<MRule />
<MBody>
<NumberedField n={1} label="Which item" first>
{holdings.length === 0 ? (
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>
Nothing on your record to report.
</p>
) : (
<OptionList
value={issueId}
onPick={(k) => { setIssueId(k); setErr(""); }}
options={holdings.map((h) => ({
key: h.issueId,
label: `${h.item}${h.size}`,
// The label id is what the linen room reads off the garment in their hand, so a
// row here can be matched to a physical thing.
meta: `${h.labelId} · issued ${h.issued}`,
}))}
/>
)}
</NumberedField>
{/* Both steps stay on screen from the start, as the mockup draws them. Revealing "what
happened" only after a garment is chosen hid half the job from somebody deciding whether
this screen was the one they wanted. */}
<NumberedField n={2} label="What happened">
<MChipRow
label="What happened"
value={kind}
onPick={(k) => { setKind(k); setErr(""); }}
options={DAMAGE_KINDS.map((d) => ({ value: d, label: d }))}
/>
<textarea
value={note} onChange={(e) => setNote(e.target.value)} rows={3}
aria-label="Anything the linen room should know (optional)"
placeholder="Anything the linen room should know (optional)"
style={{ width: "100%", minHeight: 84, marginTop: 14, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 16, resize: "none", background: "#fff", color: "var(--color-text)" }}
/>
</NumberedField>
<div style={{ padding: "16px 16px 0" }}>
<MSwitchRow
title="Ask for a replacement too"
sub={canRequest
? `Goes to ${managerName} with the report`
: "Nobody is recorded as your approver yet — ask the linen room to set your manager"}
on={replace && canRequest}
onToggle={() => setReplace((v) => !v)}
disabled={!canRequest}
/>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
The damaged item comes off your record when you hand it in at the counter.
</p>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ height: 12 }} />
</MBody>
<MBar
label={busy ? "Sending…" : replace && canRequest ? "Report and request" : "Report it"}
disabled={!ready || busy}
offReason="Pick the item and what happened"
onClick={async () => {
if (!held || !kind) return;
/* `replacement` is the whole request.create result at run time id, code, manager and
* whether an email actually left the server because damage.report raises the
* replacement through that op and hands back what it returned. Every field past the id
* is optional here: the app in somebody's pocket can be older or newer than the server
* it is talking to, and a missing one only costs a line of the confirmation. */
const r = await mutate<{
replacement: { id: string; code?: string; manager?: string; notified?: boolean } | null;
replacementNote?: string;
}>("damage.report", { issueId: held.issueId, kind, note, replace: replace && canRequest });
if (!r.ok) { setErr(r.error); return; }
const why = (r.result.replacementNote || "").trim();
if (!r.result.replacement && why) { setNoReplacement(why); return; }
const rep = r.result.replacement;
setDone({
what: `${held.item}${held.size}`,
order: rep ? {
id: rep.id, code: rep.code || "", manager: rep.manager || managerName, notified: !!rep.notified,
} : null,
});
}}
/>
</>
);
}
+340
View File
@@ -0,0 +1,340 @@
"use client";
/* 2C Raise for someone you manage.
*
* A manager standing next to somebody who will not type it in themselves: they pick the person,
* build the list, and the record says both names. The ward desk once had a screen of its own for
* the same job, on the grounds that half a ward would never install anything; it is gone, because
* the manager is already the person that ward would have asked.
*
* The rule that shapes it: **nobody approves their own raise.** The approver is the subject's
* manager which here is the person typing so the server sends it a level up, and this screen
* says so by name before the button is pressed. A manager who could approve a request they typed
* themselves would be no approval at all.
*/
import { useMemo, useRef, useState } from "react";
import { MBar, MBody, MError, MRule, MTop, inputStyle } from "@/components/m";
import {
ACCENT_300, DarkCard, DraftLineList, EdgeRow, GarmentPicker, GROUND, INK, N500, N600,
NumberedField, OptionList, type DraftLine,
} from "@/components/staffui";
import Team, { Band } from "./Team";
import { useStaff } from "@/lib/staffclient";
import { REQUEST_REASONS, statusText } from "@/lib/staffreq";
import type { ReqRow } from "@/lib/staffdata";
type Person = {
id: string; name: string; num: string; group: string; hasApp: boolean;
recordedTop: string; recordedPants: string;
held: Record<string, number>; lastSizes: Record<string, string>;
};
type Size = { size: string; si: number; word: string; countedOn: string };
type Item = { id: string; item: string; type: string; gender: string; sizes: Size[]; recorded: string; isTop: boolean; isPant: boolean };
export default function DeskScreen({ people, items, raised, maxLines, maxQty }: {
/** The people who report to whoever is raising, which is exactly the set the server accepts. */
people: Person[]; items: Item[];
/** What they have raised for other people and not yet seen the end of. */
raised: ReqRow[];
maxLines: number; maxQty: number;
}) {
const { mutate, busy } = useStaff();
const [q, setQ] = useState("");
const [personId, setPersonId] = useState<string | null>(null);
const [lines, setLines] = useState<DraftLine[]>([]);
const [adding, setAdding] = useState(false);
const [reason, setReason] = useState<string | null>(null);
const [note, setNote] = useState("");
const [err, setErr] = useState("");
const [sent, setSent] = useState<{ id: string; code: string; manager: string; escalated: boolean } | null>(null);
const person = people.find((p) => p.id === personId) || null;
const garments = lines.reduce((n, l) => n + l.qty, 0);
const full = lines.length >= maxLines;
const picking = adding || lines.length === 0;
const matches = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return people.slice(0, 8);
return people.filter((p) => p.name.toLowerCase().includes(needle) || p.num.toLowerCase().includes(needle)).slice(0, 12);
}, [people, q]);
function pick(id: string) {
setPersonId(id);
setErr("");
setLines([]);
setAdding(false);
}
/* The result list is a radio group, so it has to answer the arrow keys.
*
* A screen reader in forms mode announces it as "radio group, 1 of 8", and a manager standing
* next to the nurse they are raising for presses Down to reach her having been told it is a
* radio group, they have no reason to try Tab. Without this they sit on the first name and give
* up. Two halves make it work: one tab stop into the group (the roving tabIndex below), and the
* arrows moving inside it. Moving also selects, the way a radio group does everywhere else,
* which clears any draft lines exactly as clicking a different name always has. */
const radios = useRef<(HTMLButtonElement | null)[]>([]);
const activeIdx = Math.max(0, matches.findIndex((p) => p.id === personId));
function moveTo(i: number) {
const next = matches[i];
if (!next) return;
pick(next.id);
radios.current[i]?.focus();
}
function onResultKey(e: React.KeyboardEvent) {
if (!matches.length) return;
const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
const back = e.key === "ArrowUp" || e.key === "ArrowLeft";
if (!fwd && !back && e.key !== "Home" && e.key !== "End") return;
e.preventDefault(); // otherwise the arrows scroll the list out from under the focused name
if (e.key === "Home") return moveTo(0);
if (e.key === "End") return moveTo(matches.length - 1);
// Nothing chosen yet — or the search has moved on past whoever was — so the first press lands
// on the first match rather than skipping it.
const at = matches.findIndex((p) => p.id === personId);
if (at < 0) return moveTo(0);
moveTo((at + (fwd ? 1 : -1) + matches.length) % matches.length);
}
/* Their recorded size, not the clerk's guess. Tops and trousers carry one on the register; for
* everything else a fleece, a vest, a dress the size of the last one they were issued is all
* the record knows, and it is a far better opening bid than an empty grid. */
function sizeFor(it: Item): string {
if (!person) return "";
if (it.isPant) return person.recordedPants;
if (it.isTop) return person.recordedTop;
return person.lastSizes[it.id] || "";
}
function add(l: { itemId: string; si: number; item: string; size: string; qty: number }) {
setErr("");
setLines((cur) => {
// The server sums duplicate lines before it writes them, so two identical rows on the screen
// would be showing something that cannot be saved.
const at = cur.findIndex((x) => x.itemId === l.itemId && x.si === l.si);
if (at >= 0) {
const next = [...cur];
next[at] = { ...next[at], qty: Math.min(maxQty, next[at].qty + l.qty) };
return next;
}
return [...cur, { ...l, key: `${l.itemId}:${l.si}:${cur.length}` }];
});
setAdding(false);
}
const ready = !!person && lines.length > 0;
/* Where it actually went.
*
* A manager raising for their own report is approving nothing: the server sends it up a level,
* or leaves it for the linen room to address when there is nobody above them. Either way the
* person who typed it needs to be told, by name, rather than dropped on an order screen that
* says "awaiting approval" and leaves them to work out whose. */
if (sent) {
return (
<>
<MTop title="Raised" />
<MRule />
<MBody>
<div style={{ padding: 16 }}>
<DarkCard
kicker={sent.code}
title={sent.manager ? `With ${sent.manager}` : "Nobody approves this yet"}
meta={sent.manager
? (sent.escalated
? "You approve their requests, so this went up a level — nobody approves their own raise."
: "They have been told, and it stays on your list until it is done.")
: "There is nobody above you on the register, so the linen room will address it to an approver."}
>
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, lineHeight: 1.55, color: N500 }}>
You&rsquo;ll see the outcome here and under Raised in your orders.
</div>
</DarkCard>
</div>
</MBody>
<MBar label="See the order" glyph="arrow" href={`/my/orders/${sent.id}`} />
</>
);
}
/* The bar lives below the scrolling body, so the shell is handed it rather than it being drawn
inside the list, where it would scroll away with the garments it is about to send. */
const sendBar = (
<MBar
label={busy ? "Sending…" : "Send for approval"}
sub={person && lines.length ? `${garments} garment${garments === 1 ? "" : "s"} for ${person.name.split(" ")[0]}` : undefined}
disabled={!ready || busy}
onClick={async () => {
if (!person || !lines.length) return;
const r = await mutate<{ id: string; code: string; manager: string; escalated: boolean }>("request.create", {
subjectId: person.id,
lines: lines.map((l) => ({ itemId: l.itemId, si: l.si, qty: l.qty })),
reason: reason || "", note,
});
if (!r.ok) { setErr(r.error); return; }
// A raise that went somewhere other than the obvious place — up a level, or to nobody at
// all — is worth a screen of its own. Anything ordinary goes straight to the order, which
// is where the person who typed it will come looking for it.
if (r.result.escalated || !r.result.manager) {
setSent({ id: r.result.id, code: r.result.code, manager: r.result.manager, escalated: r.result.escalated });
return;
}
window.location.assign(`/my/orders/${r.result.id}`);
}}
/>
);
return (
<Team active="/my/raise" foot={sendBar}>
<div style={{ padding: "16px 16px 0" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "-0.01em", lineHeight: 1.2 }}>
Raise for your team
</div>
{/* The one rule that shapes this screen: a manager who could approve what they typed
themselves would be no approval at all. */}
<p style={{ fontSize: 14, lineHeight: 1.5, color: N600, margin: "4px 0 0" }}>
Goes above you, not to you your own manager approves it.
</p>
</div>
<NumberedField n={1} label="Who is it for" first>
{/* The NumberedField heading names the step, not this box, so the box says what it is
itself a placeholder disappears the moment anyone types into it. */}
<input
value={q} onChange={(e) => { setQ(e.target.value); setErr(""); }}
aria-label="Search by name or staff number"
placeholder="Name or staff number" autoComplete="off"
style={{ ...inputStyle, width: "100%" }}
/>
<div role="radiogroup" aria-label="Search results" onKeyDown={onResultKey} style={{ display: "grid", gap: 2, marginTop: 12 }}>
{matches.map((p, i) => {
const on = p.id === personId;
return (
<button key={p.id} role="radio" aria-checked={on}
ref={(el) => { radios.current[i] = el; }}
// One tab stop for the whole group: Tab reaches the chosen name (or the first
// one), and the arrows move between them from there.
tabIndex={i === activeIdx ? 0 : -1}
onClick={() => pick(p.id)}
style={{
textAlign: "left", padding: "12px 14px", border: 0, borderRadius: 0, font: "inherit",
background: on ? INK : "#fff", color: on ? GROUND : INK, cursor: "pointer", minHeight: 48,
}}>
<div style={{ fontSize: 16, fontWeight: 800 }}>{p.name}</div>
<div style={{ fontSize: 12.5, marginTop: 3, opacity: 0.85 }}>{[p.num, p.group].filter(Boolean).join(" · ")}</div>
{on && !p.hasApp && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300, marginTop: 6 }}>
No app you&rsquo;ll have to pass the outcome on
</div>
)}
</button>
);
})}
{matches.length === 0 && (
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>Nobody on your team matches that.</p>
)}
</div>
</NumberedField>
{person && (
<NumberedField n={2} label={`What ${person.name.split(" ")[0]} needs`}>
{lines.length > 0 && (
<div style={{ marginBottom: picking ? 14 : 0 }}>
<DraftLineList
lines={lines}
maxQty={maxQty}
onQty={(k, q2) => setLines((cur) => cur.map((l) => (l.key === k ? { ...l, qty: q2 } : l)))}
onRemove={(k) => setLines((cur) => cur.filter((l) => l.key !== k))}
/>
</div>
)}
{picking ? (
<GarmentPicker
items={items}
maxQty={maxQty}
addLabel={lines.length ? "Add it" : "Add to the request"}
onCancel={lines.length ? () => setAdding(false) : undefined}
defaultSi={(it) => {
const want = sizeFor(it);
return it.sizes.find((s) => String(s.size) === String(want))?.si ?? null;
}}
note={(it) => {
const rec = sizeFor(it);
const holds = person.held[it.id] || 0;
return [
rec ? `Recorded size ${rec}` : "No size on record for this one",
holds ? `holds ${holds}` : "",
].filter(Boolean).join(" · ");
}}
onAdd={add}
/>
) : full ? (
<p style={{ fontSize: 13, color: N600, lineHeight: 1.55, margin: "12px 0 0" }}>
That is {maxLines} lines as much as one request carries. Send this one and raise
another for anything else.
</p>
) : (
<button
onClick={() => { setAdding(true); setErr(""); }}
style={{
width: "100%", minHeight: 52, marginTop: 2, border: `2px solid ${INK}`, borderRadius: 0,
background: "transparent", color: INK, font: "inherit", fontWeight: 800, fontSize: 13,
letterSpacing: "0.06em", textTransform: "uppercase", textAlign: "left", padding: "0 16px", cursor: "pointer",
}}
>Add another garment</button>
)}
{lines.length > 0 && !picking && (
<div style={{ marginTop: 14 }}>
<OptionList
label="Why"
columns={2}
value={reason}
onPick={setReason}
options={REQUEST_REASONS.map((r) => ({ key: r, label: r }))}
/>
<textarea
value={note} onChange={(e) => setNote(e.target.value)} rows={2}
aria-label="Anything the linen room should know (optional)"
placeholder="Anything the linen room should know (optional)"
style={{ width: "100%", minHeight: 68, marginTop: 14, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 15, resize: "none", background: "#fff", color: "var(--color-text)" }}
/>
</div>
)}
</NumberedField>
)}
{/* What they have already raised for other people. Without this the manager who was told
"you'll see the outcome" had nowhere to see it: every list in this app starts from the
wearer, and a request raised for somebody else belongs to none of them. */}
{raised.length > 0 && (
<>
<Band label="Raised by you · still open" />
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{raised.map((r) => {
const st = statusText(r, { mine: false, first: r.subjectName?.split(" ")[0] });
return (
<EdgeRow key={r.id} tone={st.ink === "attention" ? "accent" : "divider"} href={`/my/orders/${r.id}`}>
<div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
<span style={{ flex: 1, fontSize: 16, fontWeight: 800 }}>{r.subjectName}</span>
<span style={{ fontSize: 12, color: N600 }}>{r.code}</span>
</div>
<div style={{ fontSize: 14.5, fontWeight: 600, marginTop: 5, lineHeight: 1.35 }}>{r.summary}</div>
<div style={{ fontSize: 12.5, color: N600, marginTop: 4 }}>
{[st.label, st.note].filter(Boolean).join(" · ")}
</div>
</EdgeRow>
);
})}
</div>
</>
)}
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ height: 12 }} />
</Team>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
/* 1A Home.
*
* Two questions, in this order: what is on the way, and what you hold. Everything else is a
* shortcut one row lower. There is no list of orders here on purpose that is the Orders tab, and
* a home screen that tried to be both would be neither.
*
* The one banner at the top is the only place on this screen where somebody else is blocked until
* this person acts. At most one is ever drawn: a screen with two things shouting at once has
* nothing at the top.
*/
import { MBody, MRow, MSection, MTopAction, MTopBrand } from "@/components/m";
import {
AlertBar, DarkButton, DarkCard, DarkRow, EdgeRow, IdentityBlock, Kicker, N600, Notice, QuickGrid,
} from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import type { ReqRow } from "@/lib/staffdata";
type Data = {
name: string; num: string; ward: string; group: string; facility: string;
hasManager: boolean; wardDesk: boolean;
holding: number; live: ReqRow | null; openCount: number; notice: string;
/** Open requests this person raised for somebody else — never their own. */
raisedOpen: ReqRow[];
lastRequest: { itemId: string; si: number; reason: string; code: string } | null;
};
export type HoldRow = { item: string; size: string; qty: number; last: string };
const plural = (n: number, w: string) => `${n} ${n === 1 ? w : w + "s"}`;
export default function HomeScreen({ data, held, lastItem, managerName, kitCheckOpen }: {
data: Data;
/** Up to three, most recently issued first. The total is the section's own note. */
held: HoldRow[];
/** The garment on the last request, for the "Same again" tile. Null when they never asked. */
lastItem: string | null;
managerName: string;
kitCheckOpen: boolean;
}) {
const { me, counts } = useStaff();
const live = data.live;
const st = live ? statusText(live) : null;
/* At most one banner, approvals first.
*
* Driven by the count of requests ADDRESSED to this person, never by "is a manager": the linen
* room can re-address a request to somebody who manages nobody, and a manager's last report can
* move away while their request is still waiting. Gating on the role would take this banner away
* from the one person who has to act on it. A desk that is also an approver keeps its bags on
* the Team tab's badge. */
const banner = counts.approvals > 0
? { title: `${plural(counts.approvals, "request")} waiting on you`, href: "/my/approvals" }
: me.wardDesk && counts.round > 0 && data.ward
? { title: `${plural(counts.round, "bag")} to sign on ${data.ward}`, href: "/my/round" }
: null;
return (
<>
{/* Sign out has left this screen. It lives on Account, with the password where people look
for it, and where the one thing a wearer can do to a phone they no longer have belongs. */}
<MTopBrand facility={data.facility} right={<MTopAction label="Account" href="/my/account" />} />
<MBody>
{banner && <AlertBar title={banner.title} href={banner.href} />}
<IdentityBlock ward={data.ward} num={data.num} name={data.name} group={data.group} />
<div style={{ padding: 16 }}>
<MSection label="On the way" />
{live && st ? (
/* The bag furthest along ready beats out on the round beats waiting on a manager.
* One request covers as many garments as the person needed, so the card leads with the
* summary and the list itself is a tap away on the order. */
<div style={{ marginTop: 12 }}>
<DarkCard kicker={st.label} title={live.summary} meta={[st.note, live.code].filter(Boolean).join(" · ")}>
{live.status === "ready" && live.collectCode ? (
<>
<DarkRow label="Collection code" value={live.collectCode} />
<DarkButton label="Show at the counter" href={`/my/orders/${live.id}/code`} />
</>
) : (
<DarkButton label="Open this order" href={`/my/orders/${live.id}`} />
)}
</DarkCard>
</div>
) : (
<div style={{ padding: "26px 0" }}>
<div style={{ fontSize: 18, fontWeight: 800 }}>Nothing on the way</div>
<div style={{ fontSize: 14, color: N600, marginTop: 2, lineHeight: 1.45 }}>
{data.hasManager && managerName
? `Ask for something and it goes to ${managerName} first.`
: "Your manager isnt recorded yet — the linen room has to set who approves your requests."}
</div>
</div>
)}
<MSection label="You hold" right={plural(data.holding, "garment")} />
{held.length > 0 ? held.map((h) => (
<MRow
key={`${h.item}:${h.size}`}
title={`${h.item}${h.size}`}
sub={`Last issued ${h.last}`}
right={`×${h.qty}`}
/>
)) : (
<div style={{ padding: "18px 0", fontSize: 14, color: N600 }}>Nothing on your record yet.</div>
)}
{/* 2×2. "Same again" is drawn only when there is a last request to repeat: a shortcut
that opens an empty screen is worse than no shortcut. Swapping a size has moved to
My kit, which is where a wearer reads the size that is wrong. */}
<div style={{ marginTop: 12 }}>
<QuickGrid
items={[
{ label: "Request an item", caption: "Ask for", href: "/my/request" },
...(data.lastRequest && lastItem
? [{ label: "Same again", caption: `Last: ${lastItem}`, href: "/my/request?again=1" }]
: []),
{ label: "Report damage", caption: "Torn, stained, worn", href: "/my/damage" },
{ label: "What is on the shelf", caption: "Words, not counts", href: "/my/shelf" },
]}
/>
</div>
{/* A chore with a deadline, not an alert: dressing it as one would devalue the banner.
The title is word for word the heading on the screen it opens. */}
{kitCheckOpen && (
<div style={{ marginTop: 12 }}>
<EdgeRow tone="accent" href="/my/kitcheck">
<div style={{ fontSize: 15, fontWeight: 800 }}>Kit check is open</div>
<div style={{ fontSize: 13, color: N600, marginTop: 2 }}>Have you still got everything on your record?</div>
</EdgeRow>
</div>
)}
{/* What they raised for other people, as one row rather than a list. Until Orders grew a
Raised tab a request typed in for a colleague vanished the moment it was sent. */}
{data.raisedOpen.length > 0 && (
<div style={{ marginTop: 12 }}>
<EdgeRow tone="ink" href="/my/orders?tab=raised">
<Kicker>Raised by you</Kicker>
<div style={{ fontSize: 16, fontWeight: 800, marginTop: 6, lineHeight: 1.3 }}>
{data.raisedOpen.length === 1
? `One request open for ${data.raisedOpen[0].subjectName}`
: `${data.raisedOpen.length} requests open for other people`}
</div>
<div style={{ fontSize: 13, color: N600, marginTop: 2 }}>Not yours to collect this is where they got to.</div>
</EdgeRow>
</div>
)}
{/* Somebody who will not type a request themselves asks the person who approves it. The
server sends anything a manager raises up a level, which is why the row can say so
plainly approving your own raise is the one thing this must never allow. */}
{me.isManager && (
<div style={{ marginTop: 12 }}>
<EdgeRow tone="ink" href="/my/raise">
<div style={{ fontSize: 15, fontWeight: 800 }}>Raise for someone you manage</div>
<div style={{ fontSize: 13, color: N600, marginTop: 2 }}>Goes to your own manager, not to you.</div>
</EdgeRow>
</div>
)}
{/* The round is listed by ward, so a clerk whose ward was never filled in has no round to
open. Saying so beats a banner that never appears and a tap that 404s. */}
{data.wardDesk && !data.ward && (
<div style={{ marginTop: 12 }}>
<DarkCard kicker="Ward desk" title="No ward on your record" meta="Ask the linen room to record which ward you are on." />
</div>
)}
{data.notice && <Notice>{data.notice}</Notice>}
</div>
<div style={{ height: 20 }} />
</MBody>
<StaffNav />
</>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
/* 1B My kit.
*
* The linen room's record of what this person holds, shown to the person it is about, and a way
* to say it is wrong. "This isn't right" is deliberately blunt and deliberately last: the record
* is usually correct, and a dispute button placed first would invite one before anybody had read
* the list.
*
* The bar is a screen-level one, drawn on ALL THREE segments rather than only on Holding. A wrong
* recorded *size* is one of the commonest things anybody queries, and that is read on My sizes
* where the screen literally says "tell them below", a promise only kept while the bar is there.
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import { MBody, MEmpty, MError, MRow, MRule, MTop } from "@/components/m";
import { INK, N600, SecondaryBar, Segments, SlipCard } from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useDraft, useStaff } from "@/lib/staffclient";
import { fmtDate } from "@/lib/compute";
type Held = { itemId: string; item: string; size: string; si: number; qty: number; last: string };
/* One row per garment and size with its quantity the slip as it was signed, not one repeated row
* per garment. The grouping is done in kitData(); the screen only draws it. */
type Slip = { id: string; date: string; lines: { item: string; size: string; qty: number }[]; signed: boolean };
type Data = { held: Held[]; total: number; handedBackThisYear: number; fyFrom: string; sizes: { top: string; pants: string }; slips?: Slip[] };
export default function KitScreen({ data }: { data: Data }) {
const { mutate, busy } = useStaff();
const router = useRouter();
const [tab, setTab] = useState<"holding" | "sizes" | "slips">("holding");
const [disputing, setDisputing] = useState(false);
// Kept across a dropped send and a screen change: ward wifi drops mid-sentence, and the one thing
// worse than a complaint that did not send is a complaint that did not send and is gone.
const draft = useDraft("dispute");
const [err, setErr] = useState("");
const [sent, setSent] = useState(false);
const slips = data.slips || [];
return (
<>
<MTop title="My kit" />
<MRule />
<MBody>
<Segments
label="What to show"
value={tab}
onPick={setTab}
options={[{ key: "holding" as const, label: "Holding" }, { key: "sizes" as const, label: "My sizes" }, { key: "slips" as const, label: "Slips" }]}
/>
{tab === "holding" && (
<div style={{ padding: "0 16px" }}>
{data.held.length === 0 ? (
<MEmpty title="Nothing on your record" sub="Anything the linen room issues you shows up here." />
) : (
data.held.map((h) => (
<MRow
key={`${h.itemId}:${h.si}`}
title={`${h.item}${h.size}`}
sub={`Last issued ${fmtDate(h.last)}`}
right={`×${h.qty}`}
/>
))
)}
{/* A row, not the paragraph this used to be: it is one figure about their own record,
and it reads as one line beside the garments it belongs with. */}
<MRow title="Handed back this year" right={String(data.handedBackThisYear)} />
</div>
)}
{tab === "sizes" && (
<div style={{ padding: "0 16px" }}>
<MRow title="Top" right={data.sizes.top || "not recorded"} />
<MRow title="Trouser" right={data.sizes.pants || "not recorded"} />
<div style={{ padding: "18px 0 6px", fontSize: 14, color: N600, lineHeight: 1.5 }}>
The linen room records these. Tell them below if they are wrong.
</div>
{/* Swapping a size is a request like any other, so it goes to the request screen rather
than living as its own flow. This is the door it is reached by. */}
<MRow title="Swap a size" sub="Hand one back, ask for another" href="/my/request?swap=1" chev />
</div>
)}
{tab === "slips" && (
slips.length === 0 ? (
<div style={{ padding: "0 16px" }}>
<MEmpty title="No signed slips" sub="A slip arrives when you sign for a hand-over at the counter." />
</div>
) : (
slips.map((sl) => (
<div key={sl.id}>
<SlipCard
date={fmtDate(sl.date)}
lines={sl.lines}
sigSrc={sl.signed ? `/api/staff/slip/${encodeURIComponent(sl.id)}/sig` : undefined}
/>
{/* A slip with no signature stored is not a broken screen, and saying so is kinder
than an empty space where a signature obviously belongs. */}
{!sl.signed && (
<div style={{ padding: "0 16px 14px", background: "#fff", fontSize: 12.5, color: N600 }}>
Handed over at the counter · no signature on file
</div>
)}
</div>
))
)
)}
{sent && (
<div style={{ margin: 16, background: INK, color: "var(--color-bg)", padding: 16, fontSize: 14, lineHeight: 1.5 }}>
Sent. The linen room will look at your record and come back to you.
</div>
)}
{disputing && !sent && (
<div style={{ padding: 16, borderTop: "2px solid " + INK }}>
<label htmlFor="tc-dispute" style={{ display: "block", fontSize: 13, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase" }}>What doesnt look right?</label>
<textarea
id="tc-dispute"
value={draft.value} onChange={(e) => { draft.set(e.target.value); setErr(""); }} rows={4}
placeholder="e.g. I handed two tunics back in August but theyre still on here"
style={{ width: "100%", minHeight: 84, marginTop: 12, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 15, resize: "none", background: "#fff", color: INK }}
/>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ display: "flex", gap: 10, marginTop: 12, flexWrap: "wrap" }}>
<button
disabled={busy || !draft.value.trim()}
onClick={async () => {
const r = await mutate("dispute.raise", { body: draft.value });
if (!r.ok) { setErr(r.error); return; }
setSent(true); setDisputing(false); draft.clear(); router.refresh();
}}
style={{ minHeight: 48, padding: "0 20px", background: "var(--color-accent)", color: "#fff", border: 0, borderRadius: 0, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: busy ? "wait" : "pointer", opacity: busy || !draft.value.trim() ? 0.45 : 1 }}
>{busy ? "Sending…" : "Send to the linen room"}</button>
<button onClick={() => { setDisputing(false); setErr(""); }}
style={{ minHeight: 48, padding: "0 20px", background: "transparent", color: INK, border: "2px solid " + INK, borderRadius: 0, font: "inherit", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Cancel</button>
</div>
</div>
)}
<div style={{ height: 12 }} />
</MBody>
{!disputing && !sent && <SecondaryBar label="This isnt right" onClick={() => setDisputing(true)} />}
<StaffNav />
</>
);
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
/* 2A Kit check. Every few months, reconcile the record with reality, item by item.
*
* "Nothing here is chargeable" is not reassurance for its own sake a check that felt like an
* audit would be answered with whatever number keeps somebody out of trouble, and the resulting
* data would be worse than no data.
*
* Writing off and re-requesting stay separate acts. Saying a garment is missing does not quietly
* order another one: that would turn an honest answer into a request somebody's manager has to
* decline, which is exactly how you teach a ward to stop answering honestly.
*
* Between rounds this is a STATE, not a refusal. A refusal explains nothing by design, and "no kit
* check is open" is not a secret it is the answer somebody who tapped a notification from last
* autumn needs, with the one way onward under it.
*/
import { useState } from "react";
import { MBar, MBody, MEmpty, MError, MONO, MRule, MTop } from "@/components/m";
import { ACCENT_300, DIVIDER, GROUND, INK, N300, N600, N700 } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { fmtDate } from "@/lib/compute";
type Row = { itemId: string; item: string; size: string; si: number; onRecord: number; answered: number | null };
export default function KitCheckScreen({ closed, dueBy = "", lastConfirmed = "", rows = [] }: {
/** No cycle open. The screen still exists; it just has one thing to say. */
closed?: boolean;
dueBy?: string; lastConfirmed?: string; rows?: Row[];
}) {
const { mutate, busy } = useStaff();
const [answers, setAnswers] = useState<Record<string, number>>(
Object.fromEntries(rows.filter((r) => r.answered !== null).map((r) => [`${r.itemId}:${r.si}`, r.answered as number])),
);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [err, setErr] = useState("");
const [done, setDone] = useState(false);
const answeredCount = rows.filter((r) => answers[`${r.itemId}:${r.si}`] !== undefined).length;
async function answer(r: Row, confirmed: number) {
const k = `${r.itemId}:${r.si}`;
setAnswers((a) => ({ ...a, [k]: confirmed }));
setErr("");
const res = await mutate("kit.answer", { itemId: r.itemId, si: r.si, onRecord: r.onRecord, confirmed });
if (!res.ok) {
setErr(res.error);
setAnswers((a) => { const n = { ...a }; delete n[k]; return n; });
}
}
// aria-pressed, because the only other thing separating the chosen answer from the unchosen one
// is an ink fill: nothing a screen reader can hear, and nothing at all in high contrast.
const pick = (on: boolean, label: string, onClick: () => void): React.ReactNode => (
<button onClick={onClick} aria-pressed={on} style={{
flex: 1, minWidth: 0, minHeight: 44, borderRadius: 0, font: "inherit",
border: `2px solid ${on ? INK : DIVIDER}`,
background: on ? INK : "#fff", color: on ? GROUND : N700,
fontWeight: 700, fontSize: 14, cursor: "pointer",
}}>{label}</button>
);
if (closed) {
return (
<>
<MTop title="Kit check" back backHref="/my" />
<MRule />
<MBody>
<div style={{ padding: "0 16px" }}>
<MEmpty title="No kit check is open" sub="The linen room opens one every few months. You get a notification." />
</div>
</MBody>
<MBar label="Back to home" href="/my" />
</>
);
}
if (done) {
return (
<>
<MTop title="Kit check" back backHref="/my" />
<MRule />
<MBody>
<div style={{ background: INK, color: GROUND, padding: 16 }}>
<div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: ACCENT_300 }}>Thanks</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 22, lineHeight: 1.15, marginTop: 4 }}>
Thats your record confirmed.
</div>
<div style={{ fontSize: 13, lineHeight: 1.5, color: N300, marginTop: 4 }}>
The linen room will square anything short. Nothing is charged.
</div>
</div>
</MBody>
<MBar label="Back to home" href="/my" />
</>
);
}
return (
<>
<MTop title="Kit check" back backHref="/my" right={dueBy ? `Due ${fmtDate(dueBy)}` : undefined} />
<MRule />
<MBody>
{/* The ink header carries the question and the one rule that decides how honestly it gets
answered. Nobody keeps their uniform at work it is at home, in the wash, in a bag in
the boot so the ask is to count what they still have, wherever it is. */}
<div style={{ background: INK, color: GROUND, padding: "14px 16px 16px" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 22, letterSpacing: "-0.015em", lineHeight: 1.05 }}>
Have you still got everything on your record?
</div>
<div style={{ fontSize: 13, color: "#d6d3d2", marginTop: 4, lineHeight: 1.45 }}>
Count everything you still have, wherever it is. Nothing here is chargeable.
{lastConfirmed ? ` Last confirmed ${fmtDate(lastConfirmed)}.` : ""}
</div>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
{rows.length === 0 ? (
<div style={{ padding: "0 16px" }}>
<MEmpty title="Nothing on your record to check." sub="Anything the linen room issues you shows up here." />
</div>
) : (
<div style={{ padding: "0 16px" }}>
{rows.map((r) => {
const k = `${r.itemId}:${r.si}`;
const a = answers[k];
const short = a !== undefined && a < r.onRecord;
const open = expanded[k];
return (
<div key={k} style={{
borderBottom: `1px solid ${DIVIDER}`, padding: "10px 0 12px",
...(short ? { borderLeft: "6px solid var(--color-accent)", paddingLeft: 12, marginLeft: -12 } : null),
}}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "baseline" }}>
<span style={{ fontSize: 15, fontWeight: 700 }}>{r.item} {r.size}</span>
<span style={{ fontFamily: MONO, fontSize: 14 }} aria-label={`${r.onRecord} on record`}>×{r.onRecord}</span>
</div>
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
{/* "All 3 here" was an answer only somebody standing in front of the garments
could give, and nobody is: they are at home, half of them in the wash. */}
{pick(a === r.onRecord, `Got all ${r.onRecord}`, () => { setExpanded((e) => ({ ...e, [k]: false })); void answer(r, r.onRecord); })}
{pick(short || !!open, short ? (a === 0 ? "None left" : `Only ${a}`) : "Fewer", () => setExpanded((e) => ({ ...e, [k]: !e[k] })))}
</div>
{open && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
{Array.from({ length: r.onRecord }, (_, i) => i).map((n) => (
<button key={n} aria-pressed={a === n} onClick={() => { void answer(r, n); setExpanded((e) => ({ ...e, [k]: false })); }} style={{
minHeight: 46, minWidth: 50, padding: "0 12px", borderRadius: 0, font: "inherit",
border: `2px solid ${a === n ? INK : DIVIDER}`,
background: a === n ? INK : "#fff", color: a === n ? GROUND : N700,
fontWeight: 700, fontSize: 14, cursor: "pointer",
}}>{n === 0 ? "None left" : `Only ${n}`}</button>
))}
</div>
)}
{short && (
/* This used to say the missing ones "come off your record". They don't the
answer is written down and that is all it does; only the linen room can
change the record. Telling somebody it has already been corrected, when it
hasn't, is how they stop believing the next thing this screen says. */
<div style={{ fontSize: 13, lineHeight: 1.5, color: N700, marginTop: 8 }}>
{r.onRecord - (a as number)} unaccounted for. The linen room will square your record.
</div>
)}
</div>
);
})}
</div>
)}
<div style={{ height: 12 }} />
</MBody>
{rows.length === 0 ? (
<MBar label="Back to home" href="/my" />
) : (
<MBar
label={busy ? "Saving…" : "Confirm"}
small={`${answeredCount} of ${rows.length} answered`}
disabled={busy || answeredCount < rows.length}
onClick={() => setDone(true)}
/>
)}
</>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
/* 1F Messages. Every conversation with the linen room, newest word first.
*
* There is no inbox and no direct messaging in this product: a message always hangs off the request
* it is about, which is what stops it becoming a chat app nobody staffs. So this screen is a list of
* requests that have been talked about not a mailbox and under it the orders that could be
* talked about but haven't been yet.
*
* The tab that opens this used to be a button that pushed /my/orders: a control among links, absent
* from any list of the app's navigation, and a tab that lied about where it went. It is a link now
* (components/staffnav.tsx) and this is the screen behind it.
*/
import { MBody, MRow, MRule, MSection, MTop } from "@/components/m";
import { EdgeRow, N600 } from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import { addDays, facilityDate, facilityToday, formatInZone } from "@/lib/compute";
import type { ReqRow, ThreadRow } from "@/lib/staffdata";
/* How long a line of somebody else's message is allowed to be before the row stops being a row.
* The mockup's 54 characters, kept as a number so the truncation and the ellipsis agree. */
const PREVIEW = 54;
const preview = (body: string) => {
const one = body.replace(/\s+/g, " ").trim();
return one.length > PREVIEW ? one.slice(0, PREVIEW) + "…" : one;
};
/* The right-hand stamp: a time today, a weekday this week, a date before that.
*
* Formatted in the facility's zone on both sides of hydration for the same reason the thread's own
* separators are this screen is server-rendered and then hydrated, and a stamp read off the
* server's ambient zone is a different string in the browser, which React logs and a nurse sees
* flicker. */
function stamp(iso: string, tz: string): string {
const day = facilityDate(iso, tz);
const today = facilityToday(tz);
if (!day) return "";
if (day === today) return formatInZone(iso, tz, { hour: "2-digit", minute: "2-digit", hour12: false });
if (day >= addDays(today, -6)) return formatInZone(iso, tz, { weekday: "short" });
return formatInZone(iso, tz, { day: "numeric", month: "short" });
}
export default function MessagesScreen({ threads, startable }: { threads: ThreadRow[]; startable: ReqRow[] }) {
const { me } = useStaff();
return (
<>
<MTop title="Messages" />
<MRule />
<MBody>
{threads.length === 0 ? (
/* Nothing to show, not a refusal: there is a way out of it and the screen says what it
is. A dead end with no explanation is reserved for a record that isn't yours. */
<div style={{ padding: "26px 16px" }}>
<div style={{ fontSize: 18, fontWeight: 800 }}>No messages yet</div>
<div style={{ fontSize: 14, color: N600, marginTop: 2 }}>Ask about any order and the thread starts here.</div>
</div>
) : (
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{threads.map((t) => {
const st = statusText({ status: t.status }, { mine: t.mine, first: t.subjectName.split(" ")[0] });
return (
<EdgeRow
key={t.id}
/* Accent only where the linen room has said something nobody here has opened.
Everything else keeps the ink edge, so the colour means one thing. */
tone={t.unread ? "accent" : "ink"}
href={`/my/orders/${t.id}/messages`}
>
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 15, fontWeight: 800 }}>
{/* Whose uniform it is only needs saying when it isn't yours a manager or
a clerk reading a thread on a request they typed in for somebody else. */}
{t.mine ? "Linen room" : `${t.subjectName} · you raised this`}
</span>
<span style={{ display: "block", fontSize: 13, color: N600, marginTop: 2, lineHeight: 1.45 }}>
{preview(t.last.body)}
</span>
<span style={{ display: "block", fontSize: 13, color: N600, marginTop: 2, lineHeight: 1.45 }}>
{t.code} · {st.label}
</span>
</span>
<span style={{ flex: "0 0 auto", fontFamily: "var(--font-plex-mono), 'IBM Plex Mono', ui-monospace, monospace", fontSize: 13, color: N600, whiteSpace: "nowrap" }}>
{stamp(t.last.at, me.tz)}
</span>
</div>
</EdgeRow>
);
})}
</div>
)}
{/* Open orders only. A question about a bag collected in March is a question for the
counter, and listing every order this person has ever had would bury the three that are
actually moving. */}
{startable.length > 0 && (
<div style={{ padding: "0 16px" }}>
<MSection label="Start one" />
{startable.map((r) => (
<MRow key={r.id} title={r.code} sub={r.summary} chev href={`/my/orders/${r.id}/messages`} />
))}
</div>
)}
<div style={{ height: 12 }} />
</MBody>
<StaffNav />
</>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
/* The Notifications section of Account: one switch per kind of thing this person can be told about.
*
* Two rules shape the whole thing.
*
* The phone is asked for permission when somebody turns the FIRST switch on, never at launch
* Android 13+ policy, and plain manners. A wearer who never wants to be told is never asked.
*
* "Something waiting on you" is offered to anybody a request can be addressed to, which is not the
* same as "managers": the linen room can re-address one to somebody who manages nobody, and that
* person most needs telling and, having been told, is entitled to silence it.
*/
import { useEffect, useState } from "react";
import { MError, MSwitchRow } from "@/components/m";
import { N600 } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { askForPush, pushShell, rememberedToken, type PushShell } from "@/lib/staffpush";
export type NotifyPrefs = { approved: boolean; ready: boolean; round: boolean; kitcheck: boolean; waiting: boolean };
type Key = keyof NotifyPrefs;
const KINDS: { key: Key; title: string }[] = [
{ key: "approved", title: "Approved or declined" },
{ key: "ready", title: "Ready to collect" },
{ key: "round", title: "On the ward round" },
{ key: "kitcheck", title: "Kit check opens" },
];
export default function NotificationSettings({ prefs, configured }: { prefs: NotifyPrefs; configured: boolean }) {
const { me, counts, mutate } = useStaff();
const [on, setOn] = useState<NotifyPrefs>(prefs);
const [err, setErr] = useState("");
const [busy, setBusy] = useState<Key | null>(null);
/* Settled after mount: the server cannot know whether this is the app or a browser, and a line
* about where notifications arrive that changed on hydration would be worse than one that waits
* a beat. "old" is a shell from before the notification bridge existed see lib/staffpush.ts. */
const [shell, setShell] = useState<PushShell>("none");
useEffect(() => { setShell(pushShell()); }, []);
const bridge = shell === "ready";
/* The waiting switch belongs to whoever a request can be ADDRESSED to, which is what the queue,
* the badge and the Team tab all key on. `counts.team` is the wrong number: it adds the bags on
* a ward desk, so a clerk who manages nobody was offered a switch for a notification the sender
* only ever writes to `r.managerId` it could never fire, and it vanished off their Account
* screen the moment they signed for the bags. */
const rows = me.isManager || counts.approvals > 0
? [...KINDS, { key: "waiting" as Key, title: "Something waiting on you" }]
: KINDS;
async function toggle(k: Key) {
if (busy) return;
const before = on;
const next = { ...on, [k]: !on[k] };
setOn(next);
setErr("");
setBusy(k);
const r = await mutate("notify.prefs", { [k]: next[k] });
if (!r.ok) {
setOn(before);
setErr(r.error);
setBusy(null);
return;
}
/* Ask the phone after the preference is saved, not before: somebody who says no to Android
* still meant to turn the switch on, and their answer is kept. In a browser there is no bridge
* and nothing is asked the preference is the server's either way, and it governs the phone
* whenever they next open the app.
*
* The question is "is this phone registered", not "is this the first switch". The old test
* nothing on before this tap could never be true: every switch defaults to ON, so a fresh
* account arrives here with all of them set, and for a wearer `waiting` is on with no row to
* turn it off. Android was therefore never asked, no device row was ever written, and every
* send found nobody, on a server with a key installed and every switch saying yes. A remembered
* token is the honest answer to whether this phone has ever handed one back. */
if (next[k] && bridge && configured && !rememberedToken()) {
const ask = await askForPush();
if (ask.ok) await mutate("push.register", { token: ask.token, platform: "android" });
}
setBusy(null);
}
return (
<>
<MError msg={err} onDismiss={() => setErr("")} />
{rows.map((r) => (
<MSwitchRow
key={r.key}
title={r.title}
on={on[r.key]}
disabled={!configured || busy !== null}
onToggle={() => void toggle(r.key)}
/>
))}
{!configured ? (
<div style={{ fontSize: 13, color: N600, padding: "12px 0 0", lineHeight: 1.5 }}>
Notifications arent set up on this server.
</div>
) : shell === "old" ? (
// In the app, on a build from before notifications existed. Saying "they arrive in the app"
// to somebody who is standing in it would be a line that explains nothing.
<div style={{ fontSize: 13, color: N600, padding: "12px 0 0", lineHeight: 1.5 }}>
Update the ThreadCount Staff app to turn these on.
</div>
) : !bridge ? (
<div style={{ fontSize: 13, color: N600, padding: "12px 0 0", lineHeight: 1.5 }}>
Notifications come to the ThreadCount Staff app on your phone.
</div>
) : null}
</>
);
}
+239
View File
@@ -0,0 +1,239 @@
"use client";
/* 1D Order detail. The tracking screen, and the screen someone holds up at the counter.
*
* It leads with what to do next. The old order of this page was a history lesson with the action
* buried under it: the code somebody was walking to the counter to show sat below four timeline
* rows they had already read. So the top of the screen is now the status word and the bag, then
* the one thing there is to do show the code, tell the desk you have it, read why it was
* refused and the history follows underneath, where it belongs.
*
* The timeline still always shows the step that hasn't happened yet, as an outlined dot. Half the
* point of a progress list is what is still to come: one that only listed what had already
* happened left "so when do I get it?" unanswered, which is the question that sends people to the
* counter.
*/
import { useState } from "react";
import { CodeBlock, Kicker, LineList, N600, N700, OutlineButton, SecondaryBar, type Step, Timeline } from "@/components/staffui";
import { MBar, MBody, MError, MRule, MSection, MTop } from "@/components/m";
import { useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import type { ReqStatus } from "@/lib/staffreq";
import type { ReqLine } from "@/lib/staffdata";
import { formatInZone } from "@/lib/compute";
type Ev = { id: string; label: string; meta: string; actorName: string; at: string };
type Data = {
id: string; code: string; status: string;
lines: ReqLine[]; summary: string; garments: number; lineCount: number; decision: string | null;
reason: string; note: string; managerName: string; declineReason: string | null;
collectCode: string | null; holdUntil: string; route: string | null;
signerName: string | null; signerRole: string | null; ward: string;
subjectName: string; raisedByName: string; mine: boolean; claimedAt: string | null;
createdAt: string;
events: Ev[];
};
/* The zone is the facility's, not the device's and not the server's.
*
* This screen is server-rendered and then hydrated, so a timeline stamp built without a zone was
* printed in whatever zone the host sits in and then quietly replaced with the phone's a step
* taken at 08:00 in the linen room read "7 Sep, 22:00" until React caught up. */
function stamp(iso: string, tz: string) {
return formatInZone(iso, tz, { day: "numeric", month: "short" }) + ", " +
formatInZone(iso, tz, { hour: "2-digit", minute: "2-digit", hour12: false });
}
/** The step after the last one that happened, so the person can see where it goes next. */
function nextStep(status: string, holdUntil: string, route: string | null): { label: string; meta?: string } | null {
switch (status as ReqStatus) {
case "awaiting": return { label: "Approved", meta: "Then it goes to the linen room" };
case "accepted": return { label: "Picked from the shelf" };
case "picking": return { label: route === "ward_round" ? "Out on the ward round" : "Ready at the counter" };
case "ready": return { label: "Collected", meta: holdUntil ? `Held until ${holdUntil}` : undefined };
case "round": return { label: "Signed for on the ward" };
default: return null; // declined, collected and delivered are endings
}
}
export default function OrderScreen({ data }: { data: Data }) {
const { mutate, busy, me } = useStaff();
const [err, setErr] = useState("");
// The wearer's first name, for the third-person reading a manager or the desk gets of this page.
const first = (data.subjectName || "").split(" ")[0];
const st = statusText(data, { mine: data.mine, first });
// A request the linen room withdrew was never decided by the manager named on it; the timeline
// row it writes is the only record of that, so this is where the page finds out.
const withdrawn = data.status === "declined" && data.events.some((e) => e.label === "Withdrawn by the linen room");
/* The one thing only the requester can settle: whether the bag actually reached them.
*
* A ward clerk signs for the round, which is where the linen room's job ends but the bag then
* sits on the desk, and until somebody says it was picked up the desk's unclaimed list only
* grows. This is that confirmation, and it is why round.claim exists; without a caller it never
* ran and the list never emptied.
*
* The mockup offers it while the bag is still `round`. The op does not, and should not: a bag
* still on the trolley has not been signed for by anybody, and claiming it would clear a desk
* list that has nothing on it yet. `delivered` signed for on the ward, not yet picked up off
* the desk is the state this question belongs to. */
const canClaim = data.status === "delivered" && data.mine && !data.claimedAt;
/* The code is the wearer's alone.
*
* Four people can open this order the wearer, the manager it was addressed to, whoever raised
* it and the ward desk holding the bag and only one of them collects the bag. Without the
* `mine` test an approver read the four digits off their own approvals history and could walk to
* the counter with them, and was offered a "Show at the counter" button that /my/orders/[id]/code
* then refused. reqRow() no longer hands them the code at all; this is the same rule said on the
* screen, so neither end can drift. */
const showCode = data.mine && data.status === "ready" && !!data.collectCode;
const steps: Step[] = data.events.map((e, i) => ({
label: e.label,
meta: [e.actorName, e.meta, stamp(e.at, me.tz)].filter(Boolean).join(" · "),
state: i === data.events.length - 1 ? "current" : "done",
}));
const next = nextStep(data.status, data.holdUntil, data.route);
if (next) steps.push({ label: next.label, meta: next.meta, state: "future" });
return (
<>
{/* A real destination behind the chevron: this screen is what a tapped notification opens, and
on a cold start there is no history to pop, so a bare router.back() is a dead control. */}
<MTop title={data.code} back backHref="/my/orders" right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{data.mine ? "" : data.subjectName}</span>} />
<MRule />
<MBody>
<MError msg={err} onDismiss={() => setErr("")} />
{/* The headline: which order this is, then the one word that says where it has got to. */}
<div style={{ padding: "16px 16px 0", background: "var(--color-bg)" }}>
<Kicker tone={st.ink === "attention" ? "attention" : "quiet"}>
{data.code} · raised {formatInZone(data.createdAt, me.tz)}
</Kicker>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 24, letterSpacing: "-0.02em", lineHeight: 1.1, marginTop: 4 }}>
{st.label}
</div>
</div>
{/* The bag and why it was asked for, in one bordered row under the headline. */}
<div style={{ margin: "12px 16px 0", padding: "10px 0 12px", borderBottom: "2px solid var(--color-text)" }}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.35 }}>{data.summary}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 3 }}>
{/* `decision` is only there once the manager has been through it, and it is the honest
line when they didn't approve everything: "2 of 3 approved" above a list where the
fleece is struck out. */}
{[data.decision, data.reason].filter(Boolean).join(" · ")}
</div>
{!data.mine && data.subjectName && (
<div style={{ fontSize: 13, color: N600, marginTop: 6 }}>{`For ${data.subjectName}${data.raisedByName ? ` · raised by ${data.raisedByName}` : ""}`}</div>
)}
{data.mine && data.raisedByName && (
<div style={{ fontSize: 13, color: N600, marginTop: 6 }}>{`Raised for you by ${data.raisedByName}`}</div>
)}
</div>
{/* The whole ask, declines included. A request where the fleece was refused has to read
honestly on the wearer's own screen the alternative is somebody collecting a bag,
counting two garments where they asked for three, and coming to the counter to find out
why. One line needs no list: the row above already is one. */}
{data.lineCount > 1 && (
<div style={{ padding: "16px 16px 0" }}>
<Kicker>{data.mine ? "What you asked for" : `What ${first || "they"} asked for`}</Kicker>
<div style={{ marginTop: 10 }}><LineList lines={data.lines} /></div>
</div>
)}
{/* ---- what to do next, before the history ---- */}
{showCode && (
<div style={{ padding: "16px 16px 0" }}>
<CodeBlock code={data.collectCode as string} kicker="Collection code" />
<div style={{ marginTop: 12 }}>
{/* A box in the flow of the screen, not the foot of it: the foot belongs to "Ask
about this order", and two flush-left bars with arrows one above the other read
as the same control twice. */}
<OutlineButton label="Show at the counter" href={`/my/orders/${data.id}/code`} />
</div>
{data.garments > 1 && (
<p style={{ fontSize: 13, color: N600, marginTop: 10, lineHeight: 1.55 }}>
All {data.garments} garments are in one bag under this code.
</p>
)}
{/* This used to say the hold lapses on its own and the garment goes back on the shelf.
Nothing does that: the hold is a note the linen room typed, there is no job that
reads it, and the only way out of `ready` is somebody collecting. So the copy says
what is true it keeps waiting, and a late collection is a conversation rather than
a lost request. */}
{data.holdUntil && (
<p style={{ fontSize: 13, color: N600, marginTop: 10, lineHeight: 1.55 }}>
Held until {data.holdUntil}. It stays on the counter until you collect it if you
can&rsquo;t get there by then, say so on this order and the linen room will sort it out.
</p>
)}
</div>
)}
{canClaim && (
// In the flow rather than docked at the foot: it is the next thing to do, and the next
// thing to do lives at the top of this screen now. The screen updates in place when it
// lands — mutate() refreshes the route; nothing here reloads the app.
<div style={{ marginTop: 16 }}>
<MBar
label={busy ? "Working…" : "Ive got it"}
sub="Tells the desk the bag has been picked up"
glyph="check"
disabled={busy}
onClick={async () => {
const r = await mutate("round.claim", { id: data.id });
if (!r.ok) setErr(r.error);
}}
/>
</div>
)}
{data.status === "declined" && data.declineReason && (
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: "14px 16px", margin: "16px 16px 0" }}>
<Kicker tone="attention">Why</Kicker>
<div style={{ fontSize: 15, fontWeight: 800, marginTop: 6 }}>{data.declineReason}</div>
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: N700, margin: "8px 0 0" }}>
{withdrawn
? "The linen room withdrew this. Ask at the counter if it needs another look."
: data.managerName
? `${data.managerName} decided this. Talk to them if it needs another look.`
: "Ask the linen room if it needs another look."}
</p>
</div>
)}
{data.status === "delivered" && data.signerName && (
<div style={{ margin: "16px 16px 0", background: "#fff", borderLeft: "6px solid var(--color-text)", padding: "14px 16px" }}>
<Kicker>Signed for</Kicker>
<div style={{ fontSize: 15, fontWeight: 800, marginTop: 6 }}>{data.signerName}{data.signerRole ? `, ${data.signerRole}` : ""}</div>
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: N700, margin: "8px 0 0" }}>
{data.claimedAt
? `Picked up from the desk on ${data.ward || "your ward"}.`
: `Ask at the desk on ${data.ward || "your ward"} — whoever signed has it.`}
</p>
</div>
)}
{/* ---- then the history ---- */}
<div style={{ padding: "0 16px" }}><MSection label="Progress" /></div>
<Timeline steps={steps} />
{data.note && (
<div style={{ padding: "0 16px 16px" }}>
<Kicker>{data.mine ? "Your note" : `${first || "Their"}s note`}</Kicker>
<p style={{ fontSize: 14, lineHeight: 1.55, margin: "8px 0 0" }}>{data.note}</p>
</div>
)}
<div style={{ height: 12 }} />
</MBody>
{/* Docked at the foot, as the mockup has it, rather than sitting at the end of the scroll:
the one question somebody has about an order they are tracking is "can I ask about this?",
and on a long timeline that control was below everything they had already read. */}
<SecondaryBar label="Ask about this order" href={`/my/orders/${data.id}/messages`} />
</>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
/* 1C Orders. Every request, newest first.
*
* The status *word* leads each row and the left border only reinforces it. Nothing here is
* distinguishable by colour alone, which matters on a ward phone in bad light as much as it does
* for anyone who can't tell the red from the grey.
*
* Three lists, not two. What somebody raised for another person a manager for one of their team,
* plus anything still open from the desk route that used to exist is deliberately kept out of
* Open and Done: what a wearer does with their own order (collect it, chase it, say they picked it
* up) is not what the person who typed it in does with it, and one mixed list is how somebody walks
* off with a bag that isn't theirs. Before this tab existed those requests appeared on no screen
* the raiser could reach at all.
*/
import { useState } from "react";
import { MBar, MBody, MEmpty, MRule, MTop, NOT_DOCKED } from "@/components/m";
import { DIVIDER, EdgeRow, INK, N600, Tabs } from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useStaff } from "@/lib/staffclient";
import { NEEDS_STAFF, statusText } from "@/lib/staffreq";
import { formatInZone } from "@/lib/compute";
import type { ReqRow } from "@/lib/staffdata";
type Tab = "open" | "done" | "raised";
export default function OrdersScreen({ open, done, raised, initialTab }: {
open: ReqRow[]; done: ReqRow[]; raised: { open: ReqRow[]; done: ReqRow[] }; initialTab: Tab;
}) {
const { me } = useStaff();
// Open first with the ones still moving, then whatever has finished, so the tab reads top-down
// like the two it sits beside.
const forOthers = [...raised.open, ...raised.done];
const [tab, setTab] = useState<Tab>(initialTab === "raised" && !forOthers.length ? "open" : initialTab);
const rows = tab === "open" ? open : tab === "done" ? done : forOthers;
const tabs = [
{ key: "open" as const, label: "Open", count: open.length },
{ key: "done" as const, label: "Done", count: done.length },
...(forOthers.length ? [{ key: "raised" as const, label: "Raised", count: forOthers.length }] : []),
];
const empty = tab === "open"
? {
title: "Nothing open",
sub: forOthers.length
? "Anything you raise for somebody else is under Raised."
: "Anything you ask for shows here until you have it.",
}
: tab === "done"
? { title: "Nothing finished yet", sub: "Collected and declined orders stay here." }
: { title: "Nothing you raised for somebody else", sub: "What you type in for somebody else shows here." };
return (
<>
<MTop title="Orders" />
<MRule />
<MBody>
<Tabs label="Which orders" value={tab} onPick={setTab} options={tabs} />
{rows.length === 0 ? (
<div style={{ padding: "0 16px" }}><MEmpty title={empty.title} sub={empty.sub} /></div>
) : (
// The 1px rules between rows are the gap, not a border on each row: the last row then has
// no rule hanging under it, and every left edge still starts at the edge of the screen.
<div style={{ display: "grid", gap: 1, background: DIVIDER, borderBottom: `1px solid ${DIVIDER}` }}>
{rows.map((r) => {
const st = statusText(r, { mine: r.mine, first: r.subjectName.split(" ")[0] });
// The accent edge marks what is waiting on the wearer. An order somebody raised for
// a colleague is never waiting on the reader — they typed it in, they don't collect
// it — so it keeps the quiet edge whatever its status.
const attention = !r.mine ? false : NEEDS_STAFF.has(r.status as never);
return (
<EdgeRow key={r.id} tone={attention ? "accent" : "divider"} href={`/my/orders/${r.id}`}>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{st.label}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 2, lineHeight: 1.4 }}>{r.summary}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 1, lineHeight: 1.4 }}>
{/* createdAt is a UTC instant; slicing its first ten characters dated every
request raised before 10:00 to the previous day. */}
{[
r.mine ? "" : `for ${r.subjectName}`,
// Only worth saying when the manager didn't approve the lot — otherwise
// the status word above has already said it.
r.decision && r.lineCount > 1 ? r.decision : "",
`${r.code} · raised ${formatInZone(r.createdAt, me.tz)}`,
].filter(Boolean).join(" · ")}
</div>
</div>
<span aria-hidden="true" style={{ fontSize: 18, color: N600, flex: "0 0 auto" }}></span>
</div>
</EdgeRow>
);
})}
</div>
)}
<div style={{ height: 12 }} />
</MBody>
{/* The nav below is what the phone's gesture handle sits on, and it pads itself for it. The
bar is not at the foot of anything, so it says so: otherwise it reserves the home-indicator
band a second time and "New request" floats above an accent gap mid-screen. */}
<div style={{ ...NOT_DOCKED, borderTop: "2px solid " + INK }}>
<MBar label="New request" href="/my/request" />
</div>
<StaffNav />
</>
);
}

Some files were not shown because too many files have changed in this diff Show More