ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
"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 } from "@/lib/links";
|
||||
|
||||
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 next rather than what went wrong inside the broker. */
|
||||
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 didn’t complete. Try again, or use your password if you have one.",
|
||||
sso_unavailable: "Single sign-on isn’t available for that address.",
|
||||
};
|
||||
|
||||
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 }) {
|
||||
// Which hosted plan a new facility starts on, asked only once plans are live (lib/plans-live.ts).
|
||||
const [plan, setPlan] = useState<SignupPlan>("hosted_small");
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<"login" | "signup">(initialMode);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [li, setLi] = useState({ email: "", pw: "", err: SSO_ERRORS[ssoError] || "" });
|
||||
/* Whether the typed address belongs to a facility that signs in with single sign-on, asked once
|
||||
* the address is complete. "required" means the password box is beside the point for most people
|
||||
* there, so the SSO button leads and the password stays as the fire escape. */
|
||||
const [sso, setSso] = useState<{ email: string; on: boolean; required: boolean; facility: string } | null>(null);
|
||||
async function lookupSso(email: string) {
|
||||
if (!ssoOffered) return;
|
||||
const e = email.trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) { setSso(null); return; }
|
||||
if (sso && sso.email === e) return;
|
||||
try {
|
||||
const r = await fetch("/api/auth/sso/lookup", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: e }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
setSso({ email: e, on: !!j.sso, required: !!j.required, facility: String(j.facility || "") });
|
||||
} catch { setSso(null); }
|
||||
}
|
||||
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(li.email.trim().toLowerCase()));
|
||||
}
|
||||
const [su, setSu] = useState({ first: "", last: "", facility: "", email: "", pw: "", pw2: "", err: "" });
|
||||
const [cfToken, setCfToken] = 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 [sentReset, setSentReset] = useState(false);
|
||||
const [ticket, setTicket] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
/** The security check was asked for and never answered — see cfNow. */
|
||||
const [cfSlow, setCfSlow] = useState(false);
|
||||
|
||||
/* The Turnstile token, waited for at submit time rather than demanded before the button works.
|
||||
*
|
||||
* The button used to be disabled until a token arrived, reading "Checking…" with nothing else on
|
||||
* screen. On a hospital network that filters or TLS-inspects challenges.cloudflare.com the token
|
||||
* never comes and the error callback publishes an empty string, so that was a permanent lockout
|
||||
* with no message and no way through — on the only door into the product. /m/login already does
|
||||
* it this way: wait up to eight seconds, then go anyway and let the server give an honest
|
||||
* refusal, with a line on screen saying what didn't finish. */
|
||||
async function cfNow(): Promise<string> {
|
||||
if (!turnstileOn()) return "";
|
||||
const t = cfToken || await awaitTurnstile();
|
||||
setCfSlow(!t);
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Always reports the same thing, so the reply can’t be used to test whether an address exists. */
|
||||
async function sendReset() {
|
||||
if (!li.email.trim()) { setLi({ ...li, err: "Put your work email in the box first." }); return; }
|
||||
// Said before the request goes, not after it comes back: 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 on a network that is struggling with it.
|
||||
setSentReset(true);
|
||||
const token = await cfNow();
|
||||
await fetch("/api/auth/forgot", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: li.email, cfToken: token }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/* Shown under the widget when the check didn't answer. Not an error — the request is still sent,
|
||||
* and the server has the last word — but the person deserves to know which part is struggling. */
|
||||
const cfNote = cfSlow
|
||||
? "The security check didn’t finish. If this keeps happening, your network may be blocking challenges.cloudflare.com — try again, or ask IT."
|
||||
: "";
|
||||
|
||||
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(su.email);
|
||||
const pwLen = su.pw.length >= 8;
|
||||
const pwMatch = pwLen && su.pw === su.pw2;
|
||||
const named = !!(su.first.trim() && su.last.trim() && su.facility.trim());
|
||||
|
||||
/* 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 on a spinner for good — the only fix
|
||||
* being to close the tab, or on the phone to kill the app. 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.email.trim() || !li.pw) { setLi({ ...li, err: "Enter your email and 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: li.email, password: li.pw, cfToken: token }) });
|
||||
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) {
|
||||
// The kind of refusal only — never the server's words. Same buckets as the phone's sign-in.
|
||||
track("signin_failed", { screen: "desktop", reason: r.status === 429 ? "throttled" : r.status === 400 ? "security_check" : "credentials" });
|
||||
setLi({ ...li, err: j.error || "Email or password doesn’t 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: "" }); 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
|
||||
* — server-rendered, in its own shell, and it already looks like the app at any width, so
|
||||
* there is nothing here that needs to know what kind of device this is.
|
||||
*
|
||||
* A full navigation rather than a router push: the App Router can serve a prefetched copy of
|
||||
* the destination that was fetched before the cookie existed, which lands a freshly signed-in
|
||||
* person on the signed-out screen. */
|
||||
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: "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 }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
track("signin_failed", { screen: "desktop", reason: "second_factor" });
|
||||
setLi({ ...li, err: j.error || "That code isn’t right." });
|
||||
if (r.status === 400) { setTicket(""); setCode(""); } // expired — back to the start
|
||||
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);
|
||||
}
|
||||
}
|
||||
async function doSignup() {
|
||||
if (!(named && emailOk && pwMatch)) 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, 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 || "Couldn’t create the account." }); setCfToken(""); resetTurnstile(); return;
|
||||
}
|
||||
// A facility exists from here — the conversion every public page points at.
|
||||
track("signup_completed", { screen: "desktop" });
|
||||
/* Show the address back before going anywhere.
|
||||
*
|
||||
* The account is made and signed in either way — nothing here gates on the answer. 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 the address
|
||||
* is put in front of the person once, with what actually happened to it: a note sent, or no
|
||||
* mail configured at all, which is a different thing from a bad address.
|
||||
*/
|
||||
setMade({ email: String(j.email || su.email), mailed: !!j.mailed, mail: j.mail !== false });
|
||||
} catch {
|
||||
/* The account may or may not exist now: the POST could have committed before the connection
|
||||
* went. Signing up twice with the same address is refused by the server, so the safe advice
|
||||
* is to try again and read what it says. */
|
||||
setSu({ ...su, err: "No connection — try again. If the address is already taken, the account was created." });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const checks = [
|
||||
{ label: "Valid work email", ok: emailOk },
|
||||
{ label: "Password at least 8 characters", ok: pwLen },
|
||||
{ label: "Passwords match", ok: pwMatch },
|
||||
];
|
||||
// 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 }} />
|
||||
);
|
||||
// The same panel, quieter: this one is a warning about the check, not a refusal of the sign-in.
|
||||
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, marginTop: 24, letterSpacing: "-0.01em" };
|
||||
|
||||
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)" }}>
|
||||
<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>
|
||||
<div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 42, lineHeight: 1.05, letterSpacing: "-0.02em", textWrap: "balance" as never }}>Stock that adds up, from day one.</div>
|
||||
<div style={{ marginTop: 20, display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }}>
|
||||
{POINTS.map((pt) => (
|
||||
<div key={pt} style={{ display: "flex", gap: 12, alignItems: "baseline", fontSize: 14, 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>
|
||||
<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>
|
||||
</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" }}>Uniform management</span>
|
||||
</div>
|
||||
{signupsOpen && (
|
||||
<div className="seg" role="group" aria-label="Log in or create an account" style={{ display: "flex" }}>
|
||||
{/* Which pane you are on is otherwise carried by a red fill alone. */}
|
||||
<button className={"seg-opt" + (tab === "login" ? " btn-primary" : "")} aria-pressed={tab === "login"} style={{ flex: 1 }} onClick={() => setTab("login")}>Log in</button>
|
||||
<button className={"seg-opt" + (tab === "signup" ? " btn-primary" : "")} aria-pressed={tab === "signup"} style={{ flex: 1 }} onClick={() => setTab("signup")}>Create account</button>
|
||||
</div>
|
||||
)}
|
||||
{tab === "login" ? (
|
||||
ticket ? (
|
||||
<form onSubmit={(e) => { e.preventDefault(); doCode(); }}>
|
||||
<h1 style={title}>Two-factor</h1>
|
||||
<p style={{ fontSize: 13.5, color: "var(--color-neutral-800)", marginTop: 8, lineHeight: 1.6 }}>
|
||||
Your password was right. Enter the six-digit code from your authenticator app — or a
|
||||
recovery code if you no longer have the phone.
|
||||
</p>
|
||||
<Field label="Code" style={{ marginTop: 16 }}>
|
||||
{(c) => (
|
||||
<input {...c} className="input" inputMode="numeric" autoComplete="one-time-code" autoFocus
|
||||
value={code} onChange={(e) => { setCode(e.target.value); setLi({ ...li, err: "" }); }}
|
||||
placeholder="000000" />
|
||||
)}
|
||||
</Field>
|
||||
{errBox(li.err)}
|
||||
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: 16, width: "100%" }}>{busy ? "Checking…" : "Verify"}</button>
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 12 }}>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); setTicket(""); setCode(""); setLi({ ...li, err: "" }); }}>Start again</a>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={(e) => { e.preventDefault(); doLogin(); }}>
|
||||
{/* The page's only heading. It was a styled div, so /auth had no heading of any level
|
||||
and a screen-reader user landing here had nothing to orient by. */}
|
||||
<h1 style={title}>Log in</h1>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 16 }}>
|
||||
<Field label="Work email">{(c) => <input {...c} className="input" type="email" inputMode="email" autoCapitalize="none" autoCorrect="off" spellCheck={false} enterKeyHint="next" autoComplete="email" value={li.email} onChange={(e) => setLi({ ...li, email: e.target.value, err: "" })} onBlur={(e) => void lookupSso(e.target.value)} placeholder="you@yourfacility.org" />}</Field>
|
||||
{sso?.on && sso.email === li.email.trim().toLowerCase() && (
|
||||
<div style={{ border: "2px solid var(--color-text)", padding: "10px 12px", display: "grid", gap: 8 }}>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.5 }}>{sso.required ? <><b>{sso.facility}</b> signs in with single sign-on.</> : <><b>{sso.facility}</b> offers single sign-on.</>}</div>
|
||||
<button type="button" className="btn btn-primary" onClick={goSso} style={{ width: "100%" }}>Continue with single sign-on</button>
|
||||
{sso.required && <div style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>The password below is for the facility’s fire-escape admin only.</div>}
|
||||
</div>
|
||||
)}
|
||||
<Field label="Password">{(c) => <input {...c} className="input" type="password" enterKeyHint="go" autoComplete="current-password" value={li.pw} onChange={(e) => setLi({ ...li, pw: e.target.value, err: "" })} onKeyDown={(e) => { if (e.key === "Enter") doLogin(); }} placeholder="••••••••" />}</Field>
|
||||
</div>
|
||||
<Turnstile action="login" onToken={setCfToken} />
|
||||
{cfBox}
|
||||
{errBox(li.err)}
|
||||
{/* Disabled while the request is in flight, and at no other time. */}
|
||||
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: 16, width: "100%" }}>{busy ? "Logging in…" : "Log in"}</button>
|
||||
{/* "Ask an admin" was a dead end for the admin themselves — and deleting the last
|
||||
admin deletes the facility, so there was no way back in at all. */}
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 12, lineHeight: 1.6 }}>
|
||||
{sentReset
|
||||
? <span>If that address has an account, a reset link is on its way. It works once and expires in an hour.</span>
|
||||
: <>Forgot your password? <a href="#" onClick={(e) => { e.preventDefault(); void sendReset(); }} style={{ fontWeight: 700 }}>Email me a reset link</a>.</>}
|
||||
{signupsOpen && <> New here? <a href="#" onClick={(e) => { e.preventDefault(); setTab("signup"); }}>Create your facility's account</a>.</>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
) : made ? (
|
||||
<div>
|
||||
<h1 style={title}>Facility created</h1>
|
||||
<p style={{ fontSize: 13.5, color: "var(--color-neutral-800)", marginTop: 8, lineHeight: 1.6 }}>
|
||||
You’re signed in as <b>{made.email}</b>.
|
||||
</p>
|
||||
<p style={{ fontSize: 13.5, color: "var(--color-neutral-800)", marginTop: 10, lineHeight: 1.6 }}>
|
||||
{!made.mail
|
||||
? "No mail is configured on this server, so that address hasn’t been checked. Make sure it is right — it is where a password reset would go."
|
||||
: made.mailed
|
||||
? "We’ve sent a note there — that is the address a password reset goes to. If it doesn’t arrive, the address is wrong: add a second admin under Settings → Users while you’re still signed in."
|
||||
: "We couldn’t send a note to that address. Check it is right, and add a second admin under Settings → Users while you’re still signed in — otherwise a forgotten password locks the facility out."}
|
||||
</p>
|
||||
<button className="btn btn-primary" style={{ marginTop: 18, width: "100%" }}
|
||||
onClick={() => { router.push("/app?welcome=1"); router.refresh(); }}>Open ThreadCount</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={(e) => { e.preventDefault(); doSignup(); }}>
|
||||
<h1 style={title}>Create account</h1>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>One account per facility. You'll be its first admin.</div>
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginTop: 16 }}>
|
||||
<Field label="First name">{(c) => <input {...c} className="input" autoComplete="given-name" autoCapitalize="words" 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="Facility name" style={{ gridColumn: "1/-1" }}>{(c) => <input {...c} className="input" autoComplete="organization" autoCapitalize="words" value={su.facility} onChange={(e) => setSu({ ...su, facility: e.target.value, err: "" })} placeholder="e.g. St Vincent’s Private" />}</Field>
|
||||
<Field label="Work email" style={{ gridColumn: "1/-1" }}>{(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>
|
||||
<Field label="Password">{(c) => <input {...c} className="input" type="password" autoComplete="new-password" value={su.pw} onChange={(e) => setSu({ ...su, pw: e.target.value, err: "" })} />}</Field>
|
||||
<Field label="Confirm">{(c) => <input {...c} className="input" type="password" autoComplete="new-password" value={su.pw2} onChange={(e) => setSu({ ...su, pw2: e.target.value, err: "" })} />}</Field>
|
||||
</div>
|
||||
{plansLive && (
|
||||
<fieldset style={{ border: 0, padding: 0, margin: "14px 0 0" }}>
|
||||
<legend style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 8 }}>Plan</legend>
|
||||
<PlanChoice value={plan} onChange={setPlan} />
|
||||
</fieldset>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginTop: 10 }}>
|
||||
{checks.map((c) => (
|
||||
<div key={c.label} style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: c.ok ? "var(--color-text)" : "var(--color-neutral-600)" }}><b style={{ width: 14 }}>{c.ok ? "✓" : "·"}</b>{c.label}</div>
|
||||
))}
|
||||
</div>
|
||||
<Turnstile action="signup" onToken={setCfToken} />
|
||||
{cfBox}
|
||||
{errBox(su.err)}
|
||||
<button type="submit" className="btn btn-primary" disabled={busy || !(named && emailOk && pwMatch)} style={{ marginTop: 16, width: "100%" }}>{busy ? "Creating…" : "Create account"}</button>
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 12 }}>Already set up? <a href="#" onClick={(e) => { e.preventDefault(); setTab("login"); }}>Log in</a>.</div>
|
||||
</form>
|
||||
)}
|
||||
<div className="tc-authfoot" style={{ display: "none", justifyContent: "space-between", gap: 12, marginTop: 28, paddingTop: 14, borderTop: "1px solid var(--color-divider)", fontSize: 12 }}>
|
||||
{HAS_SITE && <Link href="/" style={{ fontWeight: 700 }}>← threadcount.tech</Link>}
|
||||
{HAS_SITE && <Link href="/demo" style={{ fontWeight: 700 }}>Try the working demo</Link>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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'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>;
|
||||
}
|
||||
@@ -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 supplier’s 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 garment’s. 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 isn’t 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Community edition: there is no shared demo facility. */
|
||||
export default function DemoBanner() { return null; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ThreadCount’s.
|
||||
*
|
||||
* 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 };
|
||||
@@ -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 ? `Manager’s approval — ${sets} set${sets === 1 ? "" : "s"} still approved` : "No manager’s 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]);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
/* The app's barcode camera. Two modes:
|
||||
- "single": read one code and hand it back (issue, return, search, reprint).
|
||||
- "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, which is what the mobile web already uses. */
|
||||
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 { scanTick } from "@/lib/feedback";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { useKeepAwake } from "@/lib/wakelock";
|
||||
|
||||
type Detector = { detect: (src: HTMLVideoElement) => Promise<{ rawValue?: string }[]> };
|
||||
/* The same set the native scanner is held to — see lib/nativescan.ts for why ITF and the 2D
|
||||
formats are not on it. */
|
||||
const FORMATS = ["ean_13", "ean_8", "upc_a", "upc_e", "code_128", "code_39", "code_93"];
|
||||
|
||||
export default function MScan({ onHit, onClose, live = false, title, figure, log = [], running, onToggle, debounceMs = 900 }: {
|
||||
onHit: (raw: string) => void;
|
||||
onClose: () => void;
|
||||
live?: boolean;
|
||||
title: string;
|
||||
figure?: React.ReactNode;
|
||||
log?: string[];
|
||||
running?: boolean;
|
||||
onToggle?: () => void;
|
||||
debounceMs?: number;
|
||||
}) {
|
||||
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
|
||||
let stream: MediaStream | null = null, timer: ReturnType<typeof setInterval> | null = null, stopped = false;
|
||||
let lastRaw = "", lastT = 0;
|
||||
const w = window as unknown as { BarcodeDetector?: new (o?: { formats?: string[] }) => Detector };
|
||||
if (!navigator.mediaDevices?.getUserMedia) { setStatus("This browser has no camera access — type the code instead."); setDenied(true); 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) { setStatus("Live reading isn’t supported here — type the code instead."); return; }
|
||||
let bd: Detector;
|
||||
try { bd = new w.BarcodeDetector({ formats: FORMATS }); } catch { bd = new w.BarcodeDetector(); }
|
||||
setStatus(live ? "Hold each garment up to the camera" : "Point the camera at the barcode");
|
||||
timer = setInterval(() => {
|
||||
if (!on.current) return;
|
||||
const v = vid.current; if (!v || v.readyState < 2) return;
|
||||
bd.detect(v).then((codes) => {
|
||||
if (!codes?.length) return;
|
||||
const raw = String(codes[0].rawValue || "").trim();
|
||||
if (!raw) return;
|
||||
// Same code again inside the debounce = the same garment still in frame, not a second one.
|
||||
if (raw === lastRaw && Date.now() - lastT < debounceMs) return;
|
||||
lastRaw = raw; lastT = Date.now();
|
||||
scanTick();
|
||||
hit.current(raw);
|
||||
}).catch(() => {});
|
||||
}, 300);
|
||||
}).catch((e: Error) => {
|
||||
setDenied(true);
|
||||
setStatus(/denied|permission/i.test(e.message) ? "ThreadCount needs the camera to scan. Allow it in your browser or device settings, then try again." : "The camera isn’t available — " + e.message);
|
||||
});
|
||||
return () => { stopped = true; if (timer) clearInterval(timer); if (stream) stream.getTracks().forEach((t) => t.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 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>Last scans</div>
|
||||
<div style={{ marginTop: 6, minHeight: 74 }}>
|
||||
{log.length === 0 && <div style={{ fontSize: 13, color: ON_DARK }}>Nothing scanned yet.</div>}
|
||||
{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 style={{ fontSize: 13.5, marginTop: live ? 8 : 0, color: denied ? "var(--color-accent-300)" : ON_DARK }}>{status}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{figure}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"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);
|
||||
const [agree, setAgree] = useState(false);
|
||||
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 doesn’t 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("Couldn’t reach the server, so we can’t say whether the account was made. Try again — if it was, you’ll 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 || "Couldn’t 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’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 hasn’t been checked. Make sure it is right — it is where a password reset would go."
|
||||
: made.mailed
|
||||
? "We’ve sent a note there — that is the address a password reset goes to. If it doesn’t arrive, the address is wrong: add a second admin under Settings → Users while you’re still signed in."
|
||||
: "We couldn’t send a note to that address. Check it is right, and add a second admin under Settings → Users while you’re 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>
|
||||
|
||||
<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 <a href={TERMS_URL} target="_blank" rel="noopener" style={authLink}>terms of use</a> and the{" "}
|
||||
<a href={PRIVACY_URL} target="_blank" rel="noopener" style={authLink}>privacy notice</a>.
|
||||
</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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Community edition: no plans, so no plan banner. */
|
||||
export default function PlanBanner() { return null; }
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Community edition: no plan, no ceiling, nothing to pay. */
|
||||
export default function PlanTab() { return null; }
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { fmtDate } from "@/lib/compute";
|
||||
import DemoBanner from "@/components/DemoBanner";
|
||||
import PlanBanner from "@/components/PlanBanner";
|
||||
import { LiveRegion } from "@/components/ui";
|
||||
|
||||
/* The rail's icons are drawn here rather than pulled off a CDN: the content policy allows very
|
||||
few hosts, and every icon set worth loading draws with rounded caps, which would sit badly
|
||||
beside buttons and panels made of 2px square corners. So: 2px strokes, square caps, mitred
|
||||
joins, one glyph per screen, each one different enough to tell apart at 18px on a collapsed
|
||||
rail. The <svg> is decoration — the label beside it is what gets read out. */
|
||||
const ico = (...d: string[]) => (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true" focusable="false">
|
||||
{d.map((p) => <path key={p} d={p} />)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* The screens, in the order the linen room already knows them, and Help last. Stock Take was only ever
|
||||
reachable from the Inventory tabs and the phone's bottom bar; with icons ordering the menu
|
||||
there is room to name it, and a count is not a sub-page of the shelf list. */
|
||||
const SCREENS: { href: string; label: string; also: string[]; icon: React.ReactNode }[] = [
|
||||
{ href: "/app", label: "Dashboard", also: [], icon: ico("M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z") },
|
||||
{ href: "/app/stock", label: "Inventory", also: [], icon: ico("M3 4h18v16H3z", "M3 9.5h18M3 15h18") },
|
||||
{ href: "/app/stocktake", label: "Stock Take", also: [], icon: ico("M6 4h12v16H6z", "M9 2.5h6v3H9z", "M9 13l2 2 4-4") },
|
||||
{ href: "/app/issue", label: "Issue Stock", also: [], icon: ico("M9 4L3 7l2 4 3-1.5V20h8V9.5l3 1.5 2-4-6-3z", "M9 4l3 3 3-3") },
|
||||
{ href: "/app/rounds", label: "Delivery Rounds", also: [], icon: ico("M2 4h3l2.5 11H19", "M7 7h13l-1.5 5H8", "M8 17h2v2H8zM16 17h2v2h-2z") },
|
||||
{ href: "/app/orders", label: "Ordering", also: [], icon: ico("M4 13v7h16v-7", "M12 3v9", "M8 8l4 4 4-4") },
|
||||
{ href: "/app/report", label: "Reports", also: [], icon: ico("M4 20h16", "M6 20v-5h3v5zM11 20V8h3v12zM16 20v-8h3v8z") },
|
||||
{ href: "/app/staff", label: "Staff Register", also: [], icon: ico("M9 3h6v6H9z", "M4 21v-4l3-2h10l3 2v4") },
|
||||
{ href: "/app/requests", label: "Ward Requests", also: [], icon: ico("M3 5h18v14H3z", "M3 5l9 7 9-7") },
|
||||
{ href: "/app/settings", label: "Settings", also: [], icon: ico("M3 8h18M3 16h18", "M7 5h3v6H7zM14 13h3v6h-3z") },
|
||||
{ href: "/app/activity", label: "Activity", also: [], icon: ico("M2 12h4l3-7 4 14 3-7h6") },
|
||||
{ href: "/app/help", label: "Help", also: [], icon: ico("M4 4h16v16H4z", "M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1 .8-1 1.5V14", "M12 17v.5") },
|
||||
];
|
||||
const ICON_BADGE = ico("M5 3h14v18H5z", "M9 7h6v5H9z", "M8 16h8");
|
||||
const ICON_OUT = ico("M10 4H4v16h6", "M13 12h8", "M18 9l3 3-3 3");
|
||||
// Mobile: four slots (Dashboard · Issue · Stocktake · More); the rest live in the More sheet.
|
||||
const MOB_MAIN: [string, string, string[]][] = [["/app", "Dashboard", []], ["/app/issue", "Issue", []], ["/app/stocktake", "Stocktake", []]];
|
||||
const MOB_MORE: [string, string, string[]][] = [["/app/stock", "Inventory", []], ["/app/rounds", "Delivery Rounds", []], ["/app/orders", "Ordering", []], ["/app/report", "Reports", []], ["/app/staff", "Staff Register", []], ["/app/requests", "Ward Requests", []], ["/app/settings", "Settings", []], ["/app/activity", "Activity", []], ["/m", "Counter app", []]];
|
||||
|
||||
export default function Shell({ children }: { children: React.ReactNode }) {
|
||||
const { s } = useSnap();
|
||||
const path = usePathname();
|
||||
const router = useRouter();
|
||||
const active = (href: string, also: string[]) => (href === "/app" ? path === "/app" : path.startsWith(href) || also.some((a) => path.startsWith(a)));
|
||||
// The rail matches on whole path segments where the bottom bar matches on a bare prefix, because
|
||||
// "/app/stocktake" starts with "/app/stock": with both in the menu a prefix test lights Inventory
|
||||
// and Stock Take at once, and there is then nothing on screen saying which count you are in.
|
||||
const railActive = (href: string, also: string[]) =>
|
||||
href === "/app" ? path === "/app" : [href, ...also].some((h) => path === h || path.startsWith(h + "/"));
|
||||
const [more, setMore] = useState(false);
|
||||
useEffect(() => { setMore(false); }, [path]);
|
||||
const moreActive = MOB_MORE.some(([h]) => active(h, []));
|
||||
// Collapsing the rail is a habit of the machine, not of the account: the linen-room PC is shared
|
||||
// and its screen is short, while a manager's laptop is not. So it lives in localStorage. Read
|
||||
// after mount — reading it while rendering would make the server's HTML and the browser's first
|
||||
// paint disagree — and every touch of storage is wrapped, because a locked-down browser profile
|
||||
// throws on the first read rather than returning null.
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
try { setNarrow(localStorage.getItem("tc.rail") === "narrow"); } catch { /* storage is off: the rail just starts open */ }
|
||||
}, []);
|
||||
function toggleRail() {
|
||||
setNarrow((n) => {
|
||||
const next = !n;
|
||||
try { localStorage.setItem("tc.rail", next ? "narrow" : "wide"); } catch { /* nothing to remember it with; the toggle still works for this visit */ }
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// Floating SCAN button: on Issue / Stock take the page opens its own camera; elsewhere it's a garment lookup on Inventory.
|
||||
function fabScan() {
|
||||
setMore(false);
|
||||
if (path.startsWith("/app/issue") || path.startsWith("/app/stocktake")) window.dispatchEvent(new CustomEvent("tc-scan"));
|
||||
else router.push("/app/stock?scan=1");
|
||||
}
|
||||
const mobBtn = (on: boolean): React.CSSProperties => ({ border: "none", background: on ? "var(--color-text)" : "var(--color-bg)", color: on ? "var(--color-bg)" : "var(--color-text)", fontFamily: "var(--font-body)", fontWeight: on ? 700 : 500, fontSize: 12, padding: "14px 4px 16px", borderRight: "1px solid var(--color-divider)", cursor: "pointer", minHeight: 52, textDecoration: "none", textAlign: "center" });
|
||||
// The session cookie is httpOnly, so only the server can end a session. If the request never
|
||||
// lands there is nothing the browser can do about it, and sending the person to /auth anyway
|
||||
// would bounce them straight back here (an authenticated visit to /auth redirects into the app).
|
||||
// So say what happened and leave the button to try again, rather than swallowing the failure and
|
||||
// leaving someone on a shared ward machine believing they have signed out.
|
||||
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();
|
||||
}
|
||||
return (
|
||||
<div className="tc-shell">
|
||||
{/* The first thing the keyboard reaches on every screen. Without it a coordinator tabbing in
|
||||
walks the collapse toggle, the eleven nav items, their profile link and Sign out before
|
||||
touching the page. */}
|
||||
<a className="skip-link" href="#content">Skip to content</a>
|
||||
<aside id="tc-side" className={narrow ? "tc-rail-narrow" : undefined}>
|
||||
<div className="tc-rail-brand">
|
||||
<span className="tc-rail-mark" aria-hidden="true" />
|
||||
<span className="tc-rail-word">ThreadCount</span>
|
||||
{/* aria-label rather than a visible word: collapsed there is no room for one, and a
|
||||
button whose whole content is an arrow has no name at all without it. */}
|
||||
<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"}>
|
||||
{narrow ? ico("M8 6l6 6-6 6", "M15 6l6 6-6 6") : ico("M16 6l-6 6 6 6", "M9 6l-6 6 6 6")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="tc-rail-facility" title={s.settings.facility}>{s.settings.facility}</div>
|
||||
<nav id="tc-rail-nav" className="tc-rail-nav" aria-label="Screens">
|
||||
{SCREENS.map((sc) => {
|
||||
const on = railActive(sc.href, sc.also);
|
||||
return (
|
||||
<Link key={sc.href} href={sc.href} className={"tc-rail-item" + (on ? " active" : "")} aria-current={on ? "page" : undefined}>
|
||||
<span className="tc-rail-icon">{sc.icon}</span>
|
||||
<span className="tc-rail-label">{sc.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="tc-rail-foot">
|
||||
{/* Where you are and what day it is. Collapsed this goes: it is context, not navigation,
|
||||
and the same two facts are on the screen behind the rail. */}
|
||||
<div className="tc-rail-meta">
|
||||
<div>{s.settings.location}</div>
|
||||
<div>{fmtDate(s.today)}</div>
|
||||
</div>
|
||||
<Link href="/app/settings?tab=account" className="tc-rail-item" title="Edit your profile">
|
||||
<span className="tc-rail-icon">{ICON_BADGE}</span>
|
||||
<span className="tc-rail-label">{s.session.name}<span className="tc-rail-role">{s.session.role}</span></span>
|
||||
</Link>
|
||||
<button type="button" className="tc-rail-item" onClick={signOut} disabled={out === "busy"}>
|
||||
<span className="tc-rail-icon">{ICON_OUT}</span>
|
||||
<span className="tc-rail-label">{out === "busy" ? "Signing out…" : "Sign out"}</span>
|
||||
</button>
|
||||
<LiveRegion tone="alert" className="tc-rail-live" msg={out === "err" ? "Network error — you are still signed in." : ""} />
|
||||
</div>
|
||||
</aside>
|
||||
<nav id="tc-mobilebar" style={{ position: "fixed", bottom: 0, left: 0, right: 0, zIndex: 60, background: "var(--color-bg)", borderTop: "2px solid var(--color-text)", gridTemplateColumns: "repeat(4, 1fr)" }}>
|
||||
{MOB_MAIN.map(([href, label, also]) => <Link key={href} href={href} style={mobBtn(active(href, also))}>{label}</Link>)}
|
||||
<button style={mobBtn(moreActive || more)} onClick={() => setMore(!more)} aria-expanded={more}>More</button>
|
||||
</nav>
|
||||
<button id="tc-scanfab" onClick={fabScan} title="Scan a barcode" style={{ position: "fixed", right: 16, bottom: 76, zIndex: 61, width: 60, height: 60, border: "2px solid var(--color-text)", background: "var(--color-accent-600)", color: "#fff", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 11, letterSpacing: "0.06em", cursor: "pointer", alignItems: "center", justifyContent: "center", boxShadow: "var(--shadow-md)" }}>SCAN</button>
|
||||
{more && (
|
||||
<div onClick={() => setMore(false)} style={{ position: "fixed", inset: 0, background: "color-mix(in srgb, #201e1d 45%, transparent)", zIndex: 62, display: "flex", alignItems: "flex-end" }}>
|
||||
<div style={{ background: "var(--color-bg)", borderTop: "2px solid var(--color-text)", width: "100%", padding: "var(--space-3) var(--space-3) var(--space-6)" }}>
|
||||
{MOB_MORE.map(([href, label, also]) => <Link key={href} href={href} style={{ display: "block", width: "100%", textAlign: "left", border: "none", borderBottom: "1px solid var(--color-divider)", background: active(href, also) ? "var(--color-text)" : "var(--color-bg)", color: active(href, also) ? "var(--color-bg)" : "var(--color-text)", fontWeight: active(href, also) ? 700 : 500, fontSize: 15, padding: "15px 12px", textDecoration: "none" }}>{label}</Link>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* tabIndex −1 so the skip link actually moves focus here: following a fragment scrolls the
|
||||
page but leaves the keyboard where it was unless the target can hold focus. */}
|
||||
<main id="content" tabIndex={-1} className="tc-main"><DemoBanner /><PlanBanner />{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */ } }
|
||||
@@ -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 || "Couldn’t check whether two-factor is on."); return; }
|
||||
setSt(await r.json());
|
||||
} catch {
|
||||
setLoadErr("Couldn’t reach the server, so we can’t 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 didn’t 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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
/* The phone app's shared furniture, built once from the handoff's "structure common to every screen".
|
||||
Every screen is a top bar, an accent rule, a scrolling body and (usually) one primary action bar.
|
||||
Sizes here are the handoff's dp figures used straight as px — the app runs at device width. */
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
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
|
||||
|
||||
// ---------- icons (Lucide shapes, stroke 2.2, square caps)
|
||||
const ic = (d: React.ReactNode, size: number) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} 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 7V3h4" /><path d="M17 3h4v4" /><path d="M21 17v4h-4" /><path d="M7 21H3v-4" /><path d="M7 8v8M11 8v8M15 8v8" /></>, size);
|
||||
export const IconSearch = ({ size = 22 }: { size?: number }) => ic(<><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>, 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);
|
||||
|
||||
// ---------- top bar
|
||||
export function MTop({ title, right, back, onBack, dark = true }: { title: string; right?: React.ReactNode; back?: boolean; onBack?: () => void; dark?: boolean }) {
|
||||
const router = useRouter();
|
||||
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", paddingLeft: 16, paddingRight: 16, 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" }}>
|
||||
{back && (
|
||||
// −12px pulls the 44px hit area back so the glyph itself lands on the 16px gutter.
|
||||
<button onClick={() => (onBack ? onBack() : router.back())} aria-label="Back"
|
||||
style={{ width: 44, height: 44, marginLeft: -12, marginRight: 0, border: 0, background: "none", color: "inherit", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", flex: "0 0 44px" }}>
|
||||
<IconLeft />
|
||||
</button>
|
||||
)}
|
||||
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase", margin: 0, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</h1>
|
||||
{right !== undefined && <div style={{ fontSize: 12, color: dark ? ON_DARK : "var(--color-neutral-600)", marginLeft: 12, whiteSpace: "nowrap" }}>{right}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/* The home screen's bar: wordmark and facility rather than a screen title.
|
||||
*
|
||||
* It exists as a component for one reason — it used to be hand-rolled inline on the home page,
|
||||
* which meant it missed `tcx-topbar` and therefore the safe-area padding. The wordmark and the
|
||||
* facility name were drawn underneath the phone's status bar, colliding with the clock and the
|
||||
* battery, while every MTop screen sat correctly below it. Sharing the class here is what stops
|
||||
* that drifting apart again. */
|
||||
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,
|
||||
// Same hairline as MTop, pinned to the bottom of the status bar area.
|
||||
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", background: pct === null ? ACCENT : "var(--color-divider)" }}>
|
||||
{pct !== null && <div style={{ height: "100%", width: `${pct * 100}%`, background: ACCENT, transition: "width 140ms linear" }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Android 15 draws the app edge to edge whether it asks or not, so a bar docked at the foot of the
|
||||
column has the gesture handle — or the three-button bar — sitting on its bottom edge. The inset
|
||||
goes inside the bar, the way .tcx-topbar takes the status bar at the other end: the accent still
|
||||
runs to the bottom of the screen, but "Commit count" and its tick stay above the system's own
|
||||
furniture rather than being drawn under it with their lower half untappable.
|
||||
|
||||
It reads through a custom property, and MBody and MSplit set that property to zero, because the
|
||||
same bars are also used away from the foot of the window — the "Done" bar mid-list on a person's
|
||||
record, Scan / Undo above the lines on the counting screen. There the inset would open a band of
|
||||
dead colour in the middle of the screen. Inheritance does the sorting: anything that scrolls or
|
||||
shares a split is by definition not the thing the gesture bar is sitting on.
|
||||
|
||||
A screen that parks a bar above its own tab bar has the same problem and no ancestor to say so,
|
||||
which is why NOT_DOCKED is exported for that wrapper to carry: the nav underneath already takes
|
||||
the inset, and a bar that takes it as well leaves a strip of accent nothing sits on, halfway up
|
||||
the screen, with the tap target ending above it. */
|
||||
const SAFE_BOTTOM = "var(--tcx-safe-bottom, env(safe-area-inset-bottom, 0px))";
|
||||
export const NOT_DOCKED = { "--tcx-safe-bottom": "0px" } as React.CSSProperties;
|
||||
|
||||
export function MBody({ children, pad = false }: { children?: React.ReactNode; pad?: boolean }) {
|
||||
return <div style={{ ...NOT_DOCKED, flex: 1, overflowY: "auto", WebkitOverflowScrolling: "touch", background: GROUND, padding: pad ? 16 : 0 }}>{children}</div>;
|
||||
}
|
||||
|
||||
/** Full-bleed 64px primary action. Label flush left at 20px, glyph at the right edge — brand rule. */
|
||||
export function MBar({ label, onClick, href, glyph = "arrow", disabled, tone = "accent", sub }: {
|
||||
label: string; onClick?: () => void; href?: string; glyph?: "arrow" | "check" | "printer" | "scan" | "none"; disabled?: boolean; tone?: "accent" | "ink"; sub?: string;
|
||||
}) {
|
||||
const G = glyph === "check" ? IconCheck : glyph === "printer" ? IconPrinter : glyph === "scan" ? IconScan : IconRight;
|
||||
const bg = 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: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>{label}</span>
|
||||
{sub && <span style={{ fontSize: 11, fontWeight: 600, opacity: 0.85, textTransform: "none", letterSpacing: 0 }}>{sub}</span>}
|
||||
</span>
|
||||
{glyph !== "none" && <G />}
|
||||
</>
|
||||
);
|
||||
const st: React.CSSProperties = {
|
||||
height: `calc(64px + ${SAFE_BOTTOM})`, flex: `0 0 calc(64px + ${SAFE_BOTTOM})`, width: "100%", background: bg, color: "#fff", border: 0, borderRadius: 0,
|
||||
display: "flex", alignItems: "center", gap: 12, padding: `0 20px ${SAFE_BOTTOM}`, cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.5 : 1, textDecoration: "none", textAlign: "left",
|
||||
};
|
||||
if (href && !disabled) return <Link href={href} style={st} className="tcx-bar">{inner}</Link>;
|
||||
return <button onClick={onClick} disabled={disabled} style={st} className="tcx-bar">{inner}</button>;
|
||||
}
|
||||
|
||||
/** Two actions sharing the 64px bar, e.g. Scan / Undo 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 }: { label: string; onClick?: () => void; disabled?: boolean; flex?: number; tone?: "accent" | "grey" | "ink"; glyph?: "scan" | "plus" | "none" }) {
|
||||
const bg = tone === "accent" ? ACCENT : tone === "ink" ? INK : "var(--color-neutral-200)";
|
||||
const fg = tone === "grey" ? INK : "#fff";
|
||||
return (
|
||||
<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: glyph ? "flex-start" : "center", gap: 10, padding: `0 20px ${SAFE_BOTTOM}`, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
{glyph === "scan" && <IconScan />}
|
||||
{glyph === "plus" && <IconPlus />}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- lists
|
||||
export function MSection({ label, right }: { label: string; right?: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 12, padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
|
||||
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>{label}</span>
|
||||
{right !== undefined && <span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>{right}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Can a tap on a link to the marketing site actually get out of here?
|
||||
*
|
||||
* In any browser, yes — it has real tabs. Inside the Android shell only a browser plugin can do it,
|
||||
* and asking the plugin registry is the only honest way to find out: nothing is imported, so this
|
||||
* stays out of the web bundle, and a shell built without one simply says no. Neither shipped app
|
||||
* has one today, so today the answer on a phone is no, and the rows below have to behave and read
|
||||
* accordingly rather than promising a trip to Chrome that never happens. */
|
||||
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"; // the server can't know; see MExternal
|
||||
const cap = (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
|
||||
if (!cap?.isNativePlatform?.()) return "tab";
|
||||
return browserPlugin()?.open ? "browser" : "inline";
|
||||
}
|
||||
|
||||
/** Hands a URL to the phone's own browser, and says whether it got there. */
|
||||
function handedToTheBrowser(url: string): boolean {
|
||||
const browser = browserPlugin();
|
||||
if (!browser?.open) return false;
|
||||
// If the plugin refuses, load it where the tap would have gone anyway rather than leaving the row
|
||||
// looking broken — the hardware back button gets a counter out of that.
|
||||
browser.open({ url }).catch(() => { window.location.href = url; });
|
||||
return true;
|
||||
}
|
||||
|
||||
export type Mark = "ink" | "accent" | "mute" | "none";
|
||||
|
||||
/* One row's contents, shared so the external row can add a line about where the tap goes without a
|
||||
second copy of the markup drifting away from this one. */
|
||||
function rowBody(bar: React.ReactNode, title: React.ReactNode, sub: React.ReactNode, right: React.ReactNode, note?: string) {
|
||||
return (
|
||||
<>
|
||||
{bar}
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: "block", fontSize: 16, fontWeight: 600, letterSpacing: "-0.01em", overflow: "hidden", textOverflow: "ellipsis" }}>{title}</span>
|
||||
{sub !== undefined && sub !== "" && <span style={{ display: "block", fontSize: 13, color: "var(--color-neutral-600)", marginTop: 2 }}>{sub}</span>}
|
||||
{note && <span style={{ display: "block", fontSize: 12, fontWeight: 600, color: "var(--color-neutral-700)", marginTop: 4 }}>{note}</span>}
|
||||
</span>
|
||||
{right !== undefined && <span style={{ flex: "0 0 auto", textAlign: "right" }}>{right}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
/** A list row. `mark` is the 4×34 status bar at the left; `attention` lifts the row to white. */
|
||||
export function MRow({ title, sub, right, mark = "none", attention, onClick, href, external, disabled }: {
|
||||
title: React.ReactNode; sub?: React.ReactNode; right?: React.ReactNode; mark?: Mark; attention?: boolean; onClick?: () => void; href?: string; external?: boolean; disabled?: boolean;
|
||||
}) {
|
||||
const bar = mark === "none" ? null : (
|
||||
<span aria-hidden="true" style={{ width: 4, height: 34, flex: "0 0 4px", background: mark === "accent" ? ACCENT : mark === "mute" ? "var(--color-neutral-400)" : INK }} />
|
||||
);
|
||||
const body = rowBody(bar, title, sub, right);
|
||||
const st: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 12, width: "100%", minHeight: 62, padding: "12px 16px",
|
||||
background: attention ? "#fff" : GROUND, borderBottom: "1px solid var(--color-divider)", border: "none",
|
||||
borderBottomWidth: 1, borderBottomStyle: "solid", borderBottomColor: "var(--color-divider)",
|
||||
color: INK, 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 onClick={onClick} disabled={disabled} style={st}>{body}</button>;
|
||||
return <div style={st}>{body}</div>;
|
||||
}
|
||||
|
||||
/* A row pointing at the marketing site — the privacy policy, the terms, how to delete an account.
|
||||
*
|
||||
* On the web it is a plain anchor in a new tab, never a Link: those are the site's pages, not a
|
||||
* counter screen. Inside the Android shell target="_blank" does nothing of the sort. The site and
|
||||
* the app are the same host, that host is the one host server.allowNavigation lets the WebView
|
||||
* load, and the WebView is given no second window, so the page lands over the top of the counter
|
||||
* with the site's own nav and no tab bar to leave by. A browser plugin is the only route to Chrome
|
||||
* from in there, and neither shipped app has one in its capacitor.plugins.json.
|
||||
*
|
||||
* So one check settles both what the row does and what it says. Where a tap can leave, it leaves
|
||||
* and says where it is going. Where it cannot, the row stops claiming it can: it still opens — the
|
||||
* deletion instructions have to be reachable from inside the app, and a dead row is worse — but it
|
||||
* says the page opens here and how to come back, which is the whole difference between a page
|
||||
* somebody chose and a page somebody is stuck on. Both are settled after mount, not during render:
|
||||
* the server has no idea which shell this is, and the shell's globals only exist once it has
|
||||
* booted, so the first paint is the web row and the phone corrects it. */
|
||||
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 — "I agree to the Terms of use" — with the same
|
||||
* three behaviours as the row above: a new tab in a browser, Chrome through the Browser plugin
|
||||
* in the shell, and the page itself (back button returns) in a shell without one. */
|
||||
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 — the active line on a count, the person on an issue. */
|
||||
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: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>{kicker}</span>
|
||||
{kickerRight}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A link inside an ink panel — underlined in accent, per the Hands-free treatment. */
|
||||
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 onClick={onClick} style={st}>{label}</button>;
|
||||
}
|
||||
|
||||
/** Counted / expected / delta — the three-part figure row inside the ink panel. */
|
||||
export function MFigures({ counted, expected, unit = "COUNTED" }: { counted: number; expected: number; unit?: string }) {
|
||||
const d = counted - expected;
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 20, marginTop: 14 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: ON_DARK }}>{unit}</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 64, lineHeight: 0.9, letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums" }}>{counted}</div>
|
||||
</div>
|
||||
<div style={{ paddingBottom: 4 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: ON_DARK }}>Expected</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 32, lineHeight: 1, letterSpacing: "-0.02em", color: "var(--color-neutral-300)", fontVariantNumeric: "tabular-nums" }}>{expected}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, textAlign: "right", paddingBottom: 6, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", color: d === 0 ? "var(--color-neutral-300)" : "var(--color-accent-300)", fontVariantNumeric: "tabular-nums" }}>
|
||||
{d === 0 ? "Match" : d > 0 ? `+${d}` : `−${-d}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- bottom nav
|
||||
const NAV: [string, string][] = [["/m", "Home"], ["/m/count", "Count"], ["/m/stock", "Stock"], ["/m/search", "Search"]];
|
||||
export function MNav() {
|
||||
const path = usePathname();
|
||||
const on = (href: string) => (href === "/m" ? path === "/m" : path.startsWith(href));
|
||||
return (
|
||||
<nav style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", flex: "0 0 auto", borderTop: "2px solid " + INK, background: GROUND }}>
|
||||
{NAV.map(([href, label]) => (
|
||||
<Link key={href} href={href} aria-current={on(href) ? "page" : undefined}
|
||||
style={{ minHeight: 52, display: "flex", alignItems: "center", justifyContent: "center", padding: "14px 4px calc(14px + env(safe-area-inset-bottom, 0px))", fontSize: 10, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", textDecoration: "none", background: on(href) ? INK : GROUND, color: on(href) ? GROUND : "var(--color-neutral-600)" }}>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- odds and ends
|
||||
export function MEmpty({ title, sub, action }: { title: string; sub?: string; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ padding: "48px 24px", textAlign: "center" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.02em" }}>{title}</div>
|
||||
{sub && <p style={{ fontSize: 14, color: "var(--color-neutral-600)", marginTop: 8, lineHeight: 1.55 }}>{sub}</p>}
|
||||
{action && <div style={{ marginTop: 18, display: "flex", justifyContent: "center" }}>{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 and with a way out. */
|
||||
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: 13.5, lineHeight: 1.5, fontWeight: 600 }}>
|
||||
<span style={{ flex: 1 }}>{msg}</span>
|
||||
{/* Negative margins give the × a 44px hit area without moving the glyph or growing the
|
||||
banner: an 18px icon with no padding is a target you miss with gloves on. */}
|
||||
{onDismiss && <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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 52px size chips; the selected one inverts. */
|
||||
export function MChips({ sizes, value, onPick, disabled }: { sizes: string[]; value: number; onPick: (i: number) => void; disabled?: (i: number) => boolean }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
|
||||
{sizes.map((sz, i) => {
|
||||
const off = disabled?.(i);
|
||||
const on = i === value;
|
||||
return (
|
||||
<button key={i} onClick={() => onPick(i)} disabled={off} aria-pressed={on}
|
||||
style={{ minWidth: 52, height: 52, padding: "0 10px", border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? GROUND : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, cursor: off ? "not-allowed" : "pointer", opacity: off ? 0.35 : 1 }}>
|
||||
{sz}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MStepper({ n, onChange, min = 0, max = 999 }: { n: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||
const b: React.CSSProperties = { width: 44, height: 44, border: "2px solid " + INK, background: "transparent", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 18, lineHeight: 1, cursor: "pointer" };
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 0 }}>
|
||||
<button style={b} onClick={() => onChange(Math.max(min, n - 1))} disabled={n <= min} aria-label="One fewer">−</button>
|
||||
<span style={{ width: 46, height: 44, display: "flex", alignItems: "center", justifyContent: "center", background: INK, color: GROUND, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, fontVariantNumeric: "tabular-nums" }}>{n}</span>
|
||||
<button style={b} onClick={() => onChange(Math.min(max, n + 1))} disabled={n >= max} aria-label="One more">+</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label style={{ display: "block", padding: "14px 16px", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<span style={{ display: "block", fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 6 }}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export const inputStyle: React.CSSProperties = {
|
||||
width: "100%", minHeight: 48, padding: "10px 12px", border: "2px solid " + INK, background: "#fff",
|
||||
fontSize: 16, fontWeight: 600, borderRadius: 0, // 16px keeps iOS/Android from zooming the field on focus
|
||||
};
|
||||
|
||||
/** Numerals that line up in a column: counted/expected, on hand, par. */
|
||||
export function MNum({ a, b: bb, tone }: { a: number | string; b?: number | string; tone?: "accent" | "mute" }) {
|
||||
return (
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.02em", fontVariantNumeric: "tabular-nums", color: tone === "accent" ? "var(--color-accent-700)" : tone === "mute" ? "var(--color-neutral-500)" : INK }}>
|
||||
{/* The second figure is data — what you are counting towards — so it takes a shade that can
|
||||
be read on paper and on white: neutral-400 is 1.9:1 there and effectively invisible. */}
|
||||
{a}{bb !== undefined && <span style={{ color: "var(--color-neutral-700)" }}>/{bb}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
/* 1J — Your sign-in. One thing on it, and one thing deliberately not on it.
|
||||
*
|
||||
* Changing the password is the only revocation a wearer has. 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. That is
|
||||
* why the copy says so plainly: the consequence is the feature, and someone who does not know it
|
||||
* happened will not use this when they most need to.
|
||||
*
|
||||
* 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, MTop } from "@/components/m";
|
||||
import { N600, N700, NumberedField } from "@/components/staffui";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
|
||||
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)",
|
||||
};
|
||||
|
||||
export default function AccountScreen({ email }: { email: string }) {
|
||||
const { mutate, busy } = useStaff();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [done, setDone] = 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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Your sign-in" back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<div style={{ padding: "20px 16px 18px", borderBottom: "2px solid var(--color-text)", background: "var(--color-bg)" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", lineHeight: 1.15 }}>
|
||||
{email}
|
||||
</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "10px 0 0" }}>
|
||||
This is the address you sign in with. The linen room sets who you are on the register —
|
||||
your name, ward and sizes come from them, and only they can change them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{done ? (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-text)", padding: "14px 16px" }}>
|
||||
<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. This one stays signed in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NumberedField n={1} label="Change your password" first>
|
||||
{/* Wrapping the box rather than sitting beside it: the two used to be siblings, so
|
||||
nothing tied the words to the field and both announced as an unnamed password box —
|
||||
on the one screen where typing in the wrong one of two is silent. */}
|
||||
<label style={{ display: "block" }}>
|
||||
<span style={{ display: "block", fontSize: 12.5, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", color: N600 }}>
|
||||
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={{ display: "block", fontSize: 12.5, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", color: N600 }}>
|
||||
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 — every other phone or
|
||||
browser signed in as you stops working straight away. This device stays signed in.
|
||||
</p>
|
||||
</NumberedField>
|
||||
)}
|
||||
|
||||
{/* Privacy, and the honest answer about deletion.
|
||||
*
|
||||
* Kyle's rule stands — a wearer cannot delete their own account, because the issue history
|
||||
* it hangs off is the linen room's record and not theirs to take away — but "you can't"
|
||||
* still has to be said somewhere the person can find it, together with who can. This is
|
||||
* also what Play looks for: a policy and a data-deletion route reachable from inside the
|
||||
* app, not only from the store listing.
|
||||
*
|
||||
* They are the shared external rows rather than words in a line, because this shell
|
||||
* registers no plugins at all: target="_blank" opens nothing in there, and no URL can be
|
||||
* handed to Chrome, so what used to look like three links was three taps that either did
|
||||
* nothing or dropped a wearer onto a marketing page with no way home. The row loads the
|
||||
* page here, says before the tap that it will, and the phone's back button returns to the
|
||||
* app. */}
|
||||
<div style={{ borderTop: "2px solid var(--color-text)", padding: "18px 16px" }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
Privacy and your data
|
||||
</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "10px 0 0" }}>
|
||||
ThreadCount holds your sign-in and the linen room’s record of what you have been
|
||||
issued. You can’t delete this account from here — access is the linen room’s
|
||||
to give and theirs to take away, so ask your uniform coordinator and they can remove it
|
||||
straight away.{PRIVACY_EMAIL ? <> If you would rather not ask them, 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" />}
|
||||
|
||||
<div style={{ height: 20 }} />
|
||||
</MBody>
|
||||
|
||||
{!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(""); setDone(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
/* The approvals queue.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* A request the manager is the wearer of can reach this queue now. 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 are
|
||||
* giving on other people's behalf, says whose uniform it is, and says what the record will call it
|
||||
* afterwards — so it is never something that happens to a manager mid-scroll and has to be
|
||||
* explained to an auditor months later.
|
||||
*
|
||||
* 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 is moved away while their own request is
|
||||
* still waiting. The server takes their self-approval too (lib/staffops.ts decideRequest dropped
|
||||
* the reports test), so the copy below no longer tells them the bar will refuse: for a while it
|
||||
* did, and the bar approved. The only difference `me.isManager` makes here is the wording.
|
||||
*/
|
||||
import { MBody, MRule, MTop } from "@/components/m";
|
||||
import { EdgeRow, GROUND, INK, Kicker, N600, N700, SecondaryBar } from "@/components/staffui";
|
||||
import ManagerNav from "./ManagerNav";
|
||||
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`;
|
||||
}
|
||||
|
||||
/** The heading over a group, in the ward-round style: a band the list hangs off. */
|
||||
function Band({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ r, tz, mine, mayApproveOwn }: { r: QueueRow; tz: string; mine: boolean; mayApproveOwn: 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={mine ? "ink" : "accent"} href={`/my/approvals/${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>
|
||||
{/* The group heading above says this too, but it scrolls away and the row is the thing that
|
||||
gets tapped — so the row carries the fact on its own. */}
|
||||
{mine && <div style={{ marginTop: 6 }}><Kicker tone="attention">Your own uniform</Kicker></div>}
|
||||
{/* The summary is the whole ask in one line — "5 garments · Tunic, Trousers,
|
||||
Fleece". The garments themselves are on the review screen, which is where the
|
||||
decision is made; a queue that listed every line would bury the person who has
|
||||
been waiting longest under somebody else's four-garment request. */}
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginTop: 6, lineHeight: 1.35 }}>
|
||||
{r.summary}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: N600, marginTop: 5, lineHeight: 1.45 }}>
|
||||
{[r.reason, r.subjectGroup, waited(r.createdAt, tz)].filter(Boolean).join(" · ")}
|
||||
{r.raisedByName ? ` · raised by ${r.raisedByName}` : ""}
|
||||
</div>
|
||||
{mine && (
|
||||
<div style={{ fontSize: 12.5, color: N700, marginTop: 5, lineHeight: 1.45 }}>
|
||||
{mayApproveOwn
|
||||
? "Approving it is recorded as your own approval."
|
||||
: "Not yours to approve — it needs another manager."}
|
||||
</div>
|
||||
)}
|
||||
</EdgeRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApprovalsScreen({ rows, ownIds = [] }: { rows: QueueRow[]; ownIds?: string[] }) {
|
||||
const { me } = useStaff();
|
||||
/* 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));
|
||||
return (
|
||||
<>
|
||||
<MTop
|
||||
title="Approvals"
|
||||
back
|
||||
right={rows.length ? <span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{rows.length} waiting</span> : undefined}
|
||||
/>
|
||||
<MRule />
|
||||
<MBody>
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ padding: "28px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
|
||||
Nothing waiting on you. Requests from your team arrive by email, and land here too.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{theirs.length > 0 && (
|
||||
<>
|
||||
{/* Headed only when there is something to tell it apart from. With nothing of the
|
||||
manager's own waiting, this is simply the queue, and a band over the whole of
|
||||
it would be furniture. */}
|
||||
{mine.length > 0 && <Band><Kicker>Everyone else</Kicker></Band>}
|
||||
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
|
||||
{theirs.map((r) => <Row key={r.id} r={r} tz={me.tz} mine={false} mayApproveOwn />)}
|
||||
</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 of the screen, 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>
|
||||
<Kicker tone="attention">{mine.length === 1 ? "Your own request" : "Your own requests"}</Kicker>
|
||||
</Band>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "12px 16px 0", margin: 0 }}>
|
||||
{me.isManager ? (
|
||||
<>
|
||||
{mine.length === 1
|
||||
? "These garments are for you. You may approve them yourself, and it is recorded as a manager’s approval you gave yourself"
|
||||
: "These are for you. You may approve them yourself, and each one is recorded as a manager’s approval you gave yourself"}
|
||||
{" — the request says so on its own history, and so does the record anybody reads afterwards."}
|
||||
</>
|
||||
) : (
|
||||
/* The server takes a self-approval from anybody the request is addressed to — the
|
||||
reports test was dropped (lib/staffops.ts decideRequest) — so this can't go on
|
||||
promising a refusal that never comes. */
|
||||
<>
|
||||
{mine.length === 1
|
||||
? "These garments are for you. Nobody reports to you at the moment, but this one was addressed to you, so it is yours to decide — and it is recorded as an approval you gave yourself"
|
||||
: "These are for you. Nobody reports to you at the moment, but they were addressed to you, so they are yours to decide — and each one is recorded as an approval you gave yourself"}
|
||||
{" — the request says so on its own history, and so does the record anybody reads afterwards."}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
|
||||
{mine.map((r) => <Row key={r.id} r={r} tz={me.tz} mine mayApproveOwn />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* The only route left for putting a request in somebody else's name: a manager, for the
|
||||
people who report to them. The server sends a raise of theirs up a level, to their own
|
||||
manager, so nobody ever decides what they typed themselves.
|
||||
|
||||
Offered only to somebody who actually has a team. This queue also reaches people who
|
||||
manage nobody — the linen room re-addresses a request that arrived without an approver,
|
||||
or a manager's last report is moved away while their request is still waiting — and
|
||||
/my/raise turns exactly those people away with a 404. Inviting them to a screen that
|
||||
refuses them is worse than not mentioning it, so the invitation and the sentence
|
||||
explaining it appear together or not at all. */}
|
||||
{me.isManager && (
|
||||
<>
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<SecondaryBar label="Raise for someone on your team" href="/my/raise" />
|
||||
</div>
|
||||
|
||||
{/* The second sentence would read as a bug to the one person it is wrong for: a manager
|
||||
looking straight at a request of her own on a screen telling her such a thing always
|
||||
goes somewhere else. So when one is on the screen it says what is actually there. */}
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
|
||||
Nothing reaches the linen room until you approve it.{" "}
|
||||
{mine.length === 0
|
||||
? "Anything you raise yourself goes to your own manager instead."
|
||||
: mine.length === 1
|
||||
? "One of these is your own, and it is yours to decide — the record will show you were the one who approved it."
|
||||
: "Some of these are your own, and they are yours to decide — the record will show you were the one who approved them."}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{!me.isManager && (
|
||||
<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 }} />
|
||||
</MBody>
|
||||
<ManagerNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 didn’t 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]} hasn’t been emailed — it’s on their record in the app — and`} ${data.lines.length > 1 ? "all of it is" : "it’s"} with the linen room now.`
|
||||
: `${done.notified ? `${data.subjectName.split(" ")[0]} has been told` : `${data.subjectName.split(" ")[0]} hasn’t 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
/* 1G — Report damage. Two jobs in one screen: take the garment off my record, 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, MError, MRule, MTop } from "@/components/m";
|
||||
import { DarkCard, N600, N700, NumberedField, OptionList, StockTag, Toggle } from "@/components/staffui";
|
||||
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 }: { holdings: Holding[]; managerName: string }) {
|
||||
const { mutate, busy } = useStaff();
|
||||
const [issueId, setIssueId] = useState<string | null>(null);
|
||||
const [kind, setKind] = useState<string | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
/* The toggle 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("");
|
||||
|
||||
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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Report damage" back />
|
||||
<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>
|
||||
|
||||
{held && (
|
||||
<NumberedField n={2} label="What happened">
|
||||
<OptionList
|
||||
columns={2}
|
||||
value={kind}
|
||||
onPick={(k) => { setKind(k); setErr(""); }}
|
||||
options={DAMAGE_KINDS.map((d) => ({ key: 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: 15, resize: "none", background: "#fff", color: "var(--color-text)" }}
|
||||
/>
|
||||
</NumberedField>
|
||||
)}
|
||||
|
||||
{held && kind && (
|
||||
<NumberedField n={3} label="Replacement">
|
||||
<div style={{ display: "flex", gap: 14, alignItems: "center", background: "#fff", padding: "14px 16px" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800 }}>Request a replacement</div>
|
||||
<div style={{ fontSize: 12, color: N600, marginTop: 4 }}>
|
||||
{held.item} — {held.size} · <StockTag word={held.replacement} />
|
||||
</div>
|
||||
</div>
|
||||
<Toggle on={replace && canRequest} onChange={setReplace} disabled={!canRequest} label="Request a replacement" />
|
||||
</div>
|
||||
<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.
|
||||
{!canRequest
|
||||
? " Nobody is recorded as your approver yet, so a replacement can’t be asked for here — report it, and ask the linen room to set your manager on your staff record."
|
||||
: replace ? ` The replacement goes to ${managerName} for approval first.` : ""}
|
||||
</p>
|
||||
</NumberedField>
|
||||
)}
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
<MBar
|
||||
label={busy ? "Sending…" : replace && canRequest ? "Report and request" : "Report it"}
|
||||
disabled={!ready || busy}
|
||||
onClick={async () => {
|
||||
if (!held || !kind) return;
|
||||
const r = await mutate<{ replacement: { id: string } | null; replacementNote?: string }>("damage.report", {
|
||||
issueId: held.issueId, kind, note, replace: replace && canRequest,
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
// Optional on the type because the app in somebody's pocket can be older or newer than
|
||||
// the server it is talking to; an absent note just means there was nothing to explain.
|
||||
const why = (r.result.replacementNote || "").trim();
|
||||
if (!r.result.replacement && why) { setNoReplacement(why); return; }
|
||||
window.location.assign(r.result.replacement ? `/my/orders/${r.result.replacement.id}` : "/my/kit");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
"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, Kicker, N500, N600, N700,
|
||||
NumberedField, OptionList, type DraftLine,
|
||||
} from "@/components/staffui";
|
||||
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’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}`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop
|
||||
title="Raise for your team"
|
||||
back
|
||||
right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>Manager</span>}
|
||||
/>
|
||||
<MRule />
|
||||
<MBody>
|
||||
<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’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>
|
||||
)}
|
||||
|
||||
{person && lines.length > 0 && (
|
||||
<NumberedField n={3} label="Approval and collection">
|
||||
<div style={{ background: "#fff", borderLeft: `6px solid ${INK}`, padding: "14px 16px" }}>
|
||||
<Kicker>Goes above you, not to you</Kicker>
|
||||
<div style={{ fontSize: 17, fontWeight: 800, marginTop: 6 }}>Your own manager</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: N700, margin: "8px 0 0" }}>
|
||||
You approve {person.name.split(" ")[0]}’s requests, so this one goes up a
|
||||
level — nobody approves their own raise. If nobody is above you on the register,
|
||||
the linen room addresses it.
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
|
||||
Raised by you, recorded against {person.name.split(" ")[0]}. Both names sit on the
|
||||
order, and {garments === 1 ? "the garment goes" : `all ${garments} garments go`} in one bag
|
||||
with one collection code.
|
||||
</p>
|
||||
</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 && (
|
||||
<>
|
||||
<div style={{ padding: "18px 16px 8px", borderTop: "2px solid " + INK, borderBottom: "2px solid " + INK, background: GROUND, marginTop: 18 }}>
|
||||
<Kicker>Raised by you · still open</Kicker>
|
||||
</div>
|
||||
<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 }} />
|
||||
</MBody>
|
||||
<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}`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
/* 1A — Home.
|
||||
*
|
||||
* Answer "is anything waiting for me?" in one glance, then get out of the way. One live thing at
|
||||
* the top — the request furthest along — and everything else is a shortcut. There is no list of
|
||||
* orders here on purpose: that is what the Orders tab is for, and a home screen that tried to be
|
||||
* both would be neither.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MBody, MTopBrand } from "@/components/m";
|
||||
import {
|
||||
Banner, DarkCard, DarkRow, EdgeRow, IdentityBlock, Kicker, N600, Notice, QuickGrid, SecondaryBar,
|
||||
} from "@/components/staffui";
|
||||
import StaffNav from "@/components/staffnav";
|
||||
import { statusText } from "@/lib/staffreq";
|
||||
import type { ReqRow } from "@/lib/staffdata";
|
||||
|
||||
type Data = {
|
||||
name: string; num: string; ward: 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[];
|
||||
};
|
||||
|
||||
const ic = (d: React.ReactNode) => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="square" aria-hidden>{d}</svg>
|
||||
);
|
||||
|
||||
export default function HomeScreen({ data, approvals, roundBags, kitCheckDue, canRaiseForTeam }: {
|
||||
data: Data; approvals: number; roundBags: number; kitCheckDue: string | null;
|
||||
/** Does anybody name this person as their manager? If so they may raise for them. */
|
||||
canRaiseForTeam: boolean;
|
||||
}) {
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const live = data.live;
|
||||
const st = live ? statusText(live) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTopBrand
|
||||
facility={data.facility}
|
||||
right={
|
||||
<button
|
||||
onClick={async () => {
|
||||
setLeaving(true);
|
||||
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");
|
||||
}}
|
||||
style={{
|
||||
background: "none", border: "1px solid rgba(243,242,242,0.4)", color: "var(--color-neutral-400)",
|
||||
font: "inherit", fontSize: 11, fontWeight: 800, letterSpacing: "0.08em",
|
||||
textTransform: "uppercase", padding: "6px 10px", borderRadius: 0,
|
||||
cursor: leaving ? "wait" : "pointer", flex: "0 0 auto", minHeight: 30,
|
||||
}}
|
||||
>{leaving ? "…" : "Sign out"}</button>
|
||||
}
|
||||
/>
|
||||
<MBody>
|
||||
{/* A manager with people waiting on them sees it before anything of their own. The queue
|
||||
is the one thing in this app where somebody else is blocked until they act. */}
|
||||
{approvals > 0 && (
|
||||
<Banner
|
||||
title={`${approvals} request${approvals === 1 ? "" : "s"} waiting on you`}
|
||||
body={approvals === 1 ? "Someone on your team is waiting to be approved." : "People on your team are waiting to be approved."}
|
||||
onOpen={() => { window.location.href = "/my/approvals"; }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IdentityBlock ward={data.ward} num={data.num} name={data.name} />
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
{live && st ? (
|
||||
/* One request covers as many garments as the person needed, so the card leads with the
|
||||
* summary — "5 garments · Tunic, Trousers, Fleece" — and the list itself is a tap away
|
||||
* on the order. This screen answers "is anything waiting for me?" and then gets out of
|
||||
* the way; a home screen that unpacked every request would be the Orders tab.
|
||||
*
|
||||
* The decision rides alongside the status because on a split one the status word is a
|
||||
* half-truth: "Approved — with linen room" over an ask where the fleece was knocked
|
||||
* back has somebody expecting three garments in a bag that holds two. */
|
||||
<DarkCard
|
||||
href={`/my/orders/${live.id}`}
|
||||
kicker={st.label}
|
||||
title={live.summary}
|
||||
meta={[live.decision && live.lineCount > 1 ? live.decision : "", st.note].filter(Boolean).join(" · ")}
|
||||
>
|
||||
{live.collectCode && <DarkRow label="Collection code" value={live.collectCode.split("").join(" ")} />}
|
||||
</DarkCard>
|
||||
) : (
|
||||
<DarkCard
|
||||
kicker={data.holding > 0 ? "Nothing on the way" : "Nothing yet"}
|
||||
title={data.holding > 0 ? `${data.holding} garment${data.holding === 1 ? "" : "s"} with you` : "No uniform on your record"}
|
||||
meta={
|
||||
data.hasManager
|
||||
? "Ask for something and it goes to your manager first."
|
||||
: "Your manager isn’t recorded yet — the linen room has to set who approves your requests before you can ask for anything."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "0 16px 8px" }}><Kicker>Quick actions</Kicker></div>
|
||||
<QuickGrid
|
||||
items={[
|
||||
{ label: "Request an item", href: "/my/request", icon: ic(<><path d="M12 5v14" /><path d="M5 12h14" /></>) },
|
||||
{ label: "Swap a size", href: "/my/request?swap=1", icon: ic(<><path d="M3 7V3h4" /><path d="M17 3h4v4" /><path d="M21 17v4h-4" /><path d="M7 21H3v-4" /><path d="M8 12h8" /></>) },
|
||||
{ label: "Report damage", href: "/my/damage", icon: ic(<><path d="M12 3 2 20h20z" /><path d="M12 10v4" /><path d="M12 17h.01" /></>) },
|
||||
{ label: "What’s on the shelf", href: "/my/shelf", icon: ic(<><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* The desk’s own work, only for the person on it: signing the trolley in.
|
||||
|
||||
The round is listed by ward, so a clerk whose ward was never filled in has no round to
|
||||
open — /my/round refuses a blank ward, and signing is fenced the same way on the
|
||||
server. The card still appears, because the desk work is real and hiding it would tell
|
||||
her nothing; it just stops being a link and names the missing ward. Reading "nothing on
|
||||
the round right now" and tapping through to a not-found page was the worst of both:
|
||||
indistinguishable from a quiet day, and no clue the linen room had to fix anything. */}
|
||||
{data.wardDesk && (
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
{data.ward ? (
|
||||
<DarkCard
|
||||
href="/my/round"
|
||||
kicker="Ward desk"
|
||||
title={roundBags > 0 ? `${roundBags} bag${roundBags === 1 ? "" : "s"} to sign` : "Ward round"}
|
||||
meta={roundBags > 0 ? "Arriving on your ward today." : "Nothing on the round for your ward right now."}
|
||||
/>
|
||||
) : (
|
||||
<DarkCard
|
||||
kicker="Ward desk"
|
||||
title="No ward on your record"
|
||||
meta="The round is listed by ward, so there is nothing to show you until the linen room records which ward you are on. Ask them to set it."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Somebody who will not type a request themselves asks the person who approves it, which
|
||||
is the one door left for raising on another person's behalf. The server sends anything
|
||||
a manager raises up a level, which is why the card can say so plainly — approving your
|
||||
own raise is the one thing this must never let happen. */}
|
||||
{canRaiseForTeam && (
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<DarkCard
|
||||
href="/my/raise"
|
||||
kicker="Your team"
|
||||
title="Raise for someone you manage"
|
||||
meta="Goes to your own manager for approval, not to you."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What they have raised for other people, as one row rather than a list.
|
||||
Every list in this app starts from the wearer, so until Orders grew a Raised tab a
|
||||
request somebody typed in for a colleague vanished the moment it was sent — and the
|
||||
raise screen promises they will see the outcome. Home is not the place for the list
|
||||
itself (that rule is the whole shape of this screen), but it is the place to say the
|
||||
requests exist and where they went. */}
|
||||
{data.raisedOpen.length > 0 && (
|
||||
<div style={{ padding: "16px 0 0" }}>
|
||||
<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: 4, lineHeight: 1.45 }}>
|
||||
Not yours to collect — this is where they got to.
|
||||
</div>
|
||||
</EdgeRow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* An open cycle they haven’t finished. Not a banner — this isn’t urgent, it’s a chore
|
||||
with a deadline, and dressing it as an alert would devalue the ones that are.
|
||||
|
||||
The title is word for word the heading on the screen it opens, and it no longer asks
|
||||
about a locker. Wearers take their uniform home and wash it themselves; there is no
|
||||
locker to stand in front of, so the only answerable question is what they still have,
|
||||
wherever it happens to be that day. */}
|
||||
{kitCheckDue && (
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<DarkCard
|
||||
href="/my/kitcheck"
|
||||
kicker="Kit check"
|
||||
title="Have you still got everything on your record?"
|
||||
meta={`Due by ${kitCheckDue}. Count what’s in the wash too. Nothing here is chargeable.`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.notice && <div style={{ padding: "0 16px" }}><Notice>{data.notice}</Notice></div>}
|
||||
|
||||
{/* Sitting under everything else because it is the least often needed thing here — but it
|
||||
is the only way a wearer can end a session on a phone they no longer have, so it has to
|
||||
be somewhere they can find without asking. The label names privacy too: the policy and
|
||||
the answer about deleting an account live behind this row, and somebody looking for
|
||||
either would never guess that "your sign-in" was where they were kept. */}
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<SecondaryBar label="Your sign-in and privacy" href="/my/account" />
|
||||
</div>
|
||||
|
||||
<div style={{ height: 20 }} />
|
||||
</MBody>
|
||||
<StaffNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"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.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MBody, MError, MRule, MTop } from "@/components/m";
|
||||
import { INK, N600, N700, SecondaryBar, Segments } from "@/components/staffui";
|
||||
import StaffNav from "@/components/staffnav";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { fmtDate } from "@/lib/compute";
|
||||
|
||||
type Held = { itemId: string; item: string; size: string; si: number; qty: number; last: string };
|
||||
type Data = { held: Held[]; total: number; handedBackThisYear: number; fyFrom: string; sizes: { top: string; pants: string } };
|
||||
|
||||
export default function KitScreen({ data }: { data: Data }) {
|
||||
const { mutate, busy } = useStaff();
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<"holding" | "sizes">("holding");
|
||||
const [disputing, setDisputing] = useState(false);
|
||||
const [body, setBody] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="My kit" right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{data.total} item{data.total === 1 ? "" : "s"}</span>} />
|
||||
<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" }]}
|
||||
/>
|
||||
|
||||
{tab === "holding" ? (
|
||||
<>
|
||||
<div style={{ display: "flex", padding: "8px 16px", borderBottom: "2px solid " + INK, background: "var(--color-bg)" }}>
|
||||
<span style={{ flex: 1, fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>Uniform</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>Issued</span>
|
||||
</div>
|
||||
{data.held.length === 0 ? (
|
||||
<div style={{ padding: "22px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
|
||||
Nothing on your record. Anything the linen room issues you shows up here.
|
||||
</div>
|
||||
) : (
|
||||
data.held.map((h) => (
|
||||
<div key={`${h.itemId}:${h.si}`} style={{ display: "flex", gap: 12, alignItems: "center", padding: "14px 16px", background: "#fff", borderTop: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{h.item} — {h.size}</div>
|
||||
<div style={{ fontSize: 12, color: N600, marginTop: 3 }}>
|
||||
{h.qty} held · last issued {fmtDate(h.last)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20, fontVariantNumeric: "tabular-nums" }}>{h.qty}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: "2px solid " + INK, padding: "16px", background: "var(--color-bg)" }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>Handed back this year</div>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.55, margin: "8px 0 0" }}>
|
||||
{data.handedBackThisYear === 0
|
||||
? `Nothing handed back since ${fmtDate(data.fyFrom)}.`
|
||||
: `${data.handedBackThisYear} garment${data.handedBackThisYear === 1 ? "" : "s"} handed back since ${fmtDate(data.fyFrom)}.`}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{[["Top", data.sizes.top], ["Trouser", data.sizes.pants]].map(([label, value]) => (
|
||||
<div key={label} style={{ display: "flex", gap: 12, alignItems: "baseline", padding: "16px", background: "#fff", borderTop: "1px solid var(--color-divider)" }}>
|
||||
<span style={{ flex: 1, fontSize: 16, fontWeight: 800 }}>{label}</span>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20 }}>{value || "not recorded"}</span>
|
||||
</div>
|
||||
))}
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "16px" }}>
|
||||
These are the sizes the linen room has on file, and what a new request starts from.
|
||||
If one is wrong, tell them — this page can’t be edited from your side.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sent && (
|
||||
<div style={{ margin: 16, background: INK, color: "var(--color-bg)", padding: 16, fontSize: 14, lineHeight: 1.55 }}>
|
||||
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 doesn’t look right?</label>
|
||||
<textarea
|
||||
id="tc-dispute"
|
||||
value={body} onChange={(e) => { setBody(e.target.value); setErr(""); }} rows={4}
|
||||
placeholder="e.g. I handed two tunics back in August but they’re 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 || !body.trim()}
|
||||
onClick={async () => {
|
||||
const r = await mutate("dispute.raise", { body });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setSent(true); setDisputing(false); setBody(""); 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 || !body.trim() ? 0.45 : 1 }}
|
||||
>{busy ? "Sending…" : "Send to the linen room"}</button>
|
||||
<button onClick={() => { setDisputing(false); setBody(""); 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>
|
||||
)}
|
||||
|
||||
{!disputing && !sent && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<SecondaryBar label="This isn’t right" onClick={() => setDisputing(true)} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
<StaffNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
/* 2A — Kit check. Twice a year, reconcile the record with reality, item by item.
|
||||
*
|
||||
* The copy does most of the work here. "Nothing here is chargeable" and "the linen room uses these
|
||||
* answers to set par levels, not to chase people" are not reassurance for its own sake — a check
|
||||
* that felt like an audit would be answered with whatever number keeps someone 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.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MBar, MBody, MError, 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({ dueBy, lastConfirmed, rows }: {
|
||||
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, minHeight: 44, border: 0, borderRadius: 0, font: "inherit",
|
||||
background: on ? INK : "var(--color-neutral-200)", color: on ? GROUND : N700,
|
||||
fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
|
||||
}}>{label}</button>
|
||||
);
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Kit check" back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ background: INK, color: GROUND, padding: 20 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>Thanks</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, lineHeight: 1.25, marginTop: 10 }}>
|
||||
That’s your record confirmed.
|
||||
</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N300, margin: "12px 0 0" }}>
|
||||
Your answers have gone to the linen room, and they’ll square anything that
|
||||
didn’t match. Nothing is charged, and you don’t need to do anything else.
|
||||
</p>
|
||||
</div>
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Kit check" back right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{dueBy ? fmtDate(dueBy).split(" ").slice(1).join(" ") : ""}</span>} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ background: INK, color: GROUND, padding: 20 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>
|
||||
Due by {fmtDate(dueBy)}
|
||||
</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, lineHeight: 1.25, marginTop: 10 }}>
|
||||
Have you still got everything on your record?
|
||||
</div>
|
||||
{/* Nobody keeps their uniform at work. It goes home, it gets washed, and on any given
|
||||
day a good part of it is on the line or in a bag in the boot. The question that used
|
||||
to be asked here — whether this matched what was in your locker — could only be
|
||||
answered by somebody standing in front of a locker they do not have, so it either
|
||||
got answered wrongly or not at all. Counting from memory, wherever the garments
|
||||
are, is the honest ask. */}
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N300, margin: "12px 0 0" }}>
|
||||
Count everything you still have, wherever it is — what’s in the wash and on
|
||||
the line counts too.{" "}
|
||||
{lastConfirmed ? `Last confirmed ${fmtDate(lastConfirmed)}. ` : ""}Nothing here is chargeable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
<div style={{ padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>
|
||||
{rows.length} item{rows.length === 1 ? "" : "s"} on your record
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 && (
|
||||
<div style={{ padding: "22px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
|
||||
Nothing on your record to check.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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={{
|
||||
background: "#fff", borderTop: `1px solid ${DIVIDER}`,
|
||||
borderLeft: short ? "6px solid var(--color-accent)" : "6px solid transparent",
|
||||
padding: "14px 16px",
|
||||
}}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{r.item} — {r.size}</div>
|
||||
<div style={{ fontSize: 12.5, color: N600, marginTop: 4 }}>{r.onRecord} on record</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 2, marginTop: 12 }}>
|
||||
{/* "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, r.onRecord === 1 ? "Got it" : `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: "grid", gridTemplateColumns: `repeat(${Math.min(r.onRecord, 4)}, 1fr)`, gap: 2, marginTop: 2 }}>
|
||||
{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: 44, border: 0, borderRadius: 0, font: "inherit",
|
||||
background: a === n ? INK : "var(--color-neutral-200)", color: a === n ? GROUND : N700,
|
||||
fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
|
||||
}}>{n === 0 ? "None left" : `Only ${n}`}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{short && (
|
||||
<div style={{ borderTop: `1px solid ${DIVIDER}`, marginTop: 12, paddingTop: 10 }}>
|
||||
{/* 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; the record still says what it
|
||||
said, and only the linen room can change it. Telling somebody their record has
|
||||
already been corrected, when it hasn't, is how they stop believing the next
|
||||
thing this screen says. */}
|
||||
<p style={{ fontSize: 13, lineHeight: 1.55, color: N700, margin: 0 }}>
|
||||
{r.onRecord - (a as number) === 1 ? "One" : `${r.onRecord - (a as number)}`} unaccounted
|
||||
for. The linen room will square your record — ask for a replacement separately if
|
||||
you need one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
|
||||
Anything you can’t account for is noted for the linen room to look at — nothing is
|
||||
charged, and nothing changes on your record until they do. They use these answers to set
|
||||
par levels, not to chase people.
|
||||
</p>
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
<MBar
|
||||
label={busy ? "Saving…" : `Confirm — ${answeredCount} of ${rows.length} answered`}
|
||||
glyph="check"
|
||||
disabled={busy || answeredCount < rows.length || rows.length === 0}
|
||||
onClick={() => setDone(true)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { GROUND, INK } from "@/components/m";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
|
||||
/* The two-item nav from the design, shown only inside the manager area.
|
||||
*
|
||||
* A ward manager is a staff member who also approves — they wear the uniform too — so this does
|
||||
* not replace the app's own four-item nav. It appears while they are in Approvals or Ward, and
|
||||
* the app bar's back chevron takes them home. The prototype's role-switching chips were a
|
||||
* prototyping device; building a separate manager shell around them would give one person two
|
||||
* apps to remember.
|
||||
*/
|
||||
const ITEMS: [string, string, React.ReactNode][] = [
|
||||
["/my/approvals", "Approvals", <path key="a" d="m4 12 5 5L20 6" />],
|
||||
["/my/ward", "Ward", <g key="w"><path d="M5 20V10" /><path d="M12 20V4" /><path d="M19 20v-7" /></g>],
|
||||
];
|
||||
|
||||
export default function ManagerNav() {
|
||||
const path = usePathname();
|
||||
const { me } = useStaff();
|
||||
// Approvals admits somebody with no reports (a request re-addressed to them); /my/ward does
|
||||
// not, so offering it to them was a tab that 404'd. One item, full width, for that reader.
|
||||
const items = ITEMS.filter(([href]) => href !== "/my/ward" || me.isManager);
|
||||
return (
|
||||
<nav style={{ display: "grid", gridTemplateColumns: `repeat(${items.length}, 1fr)`, flex: "0 0 auto", borderTop: "2px solid " + INK, background: GROUND }}>
|
||||
{items.map(([href, label, icon]) => {
|
||||
const active = path.startsWith(href);
|
||||
return (
|
||||
<Link key={href} href={href} aria-current={active ? "page" : undefined} style={{
|
||||
minHeight: 52, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
||||
gap: 5, padding: "10px 4px calc(12px + env(safe-area-inset-bottom, 0px))",
|
||||
fontSize: 10, fontWeight: 800, letterSpacing: "0.1em", textTransform: "uppercase", textDecoration: "none",
|
||||
background: active ? INK : GROUND, color: active ? GROUND : "var(--color-neutral-600)",
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="square" aria-hidden>{icon}</svg>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
/* 1D — Order detail. The tracking screen, and the screen someone holds up at the counter.
|
||||
*
|
||||
* The timeline always shows the step that hasn't happened yet, as an outlined dot. Half the point
|
||||
* of this screen is what is still to come — an order that only listed what had already happened
|
||||
* would leave "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, SecondaryBar, type Step, Timeline } from "@/components/staffui";
|
||||
import { MBar, MBody, MError, MRule, 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;
|
||||
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. */
|
||||
const canClaim = data.status === "delivered" && data.mine && !data.claimedAt;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<MTop title={data.code} back right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{data.mine ? "" : data.subjectName}</span>} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<div style={{ padding: "20px 16px 18px", borderBottom: "2px solid var(--color-text)", background: "var(--color-bg)" }}>
|
||||
<Kicker tone={st.ink === "attention" ? "attention" : "quiet"}>{st.label}</Kicker>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.02em", lineHeight: 1.15, marginTop: 8 }}>
|
||||
{data.summary}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: N700, marginTop: 8 }}>
|
||||
{/* `decision` is only there once the manager has been through it, and it is the honest
|
||||
headline when they didn't approve everything: "2 of 3 approved" above a list where
|
||||
the fleece is struck out. */}
|
||||
{[data.decision, data.reason.toLowerCase()].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{!data.mine && data.subjectName && (
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 8 }}>{`For ${data.subjectName}${data.raisedByName ? ` · raised by ${data.raisedByName}` : ""}`}</div>
|
||||
)}
|
||||
{data.mine && data.raisedByName && (
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 8 }}>{`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 heading 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>
|
||||
)}
|
||||
|
||||
{data.status === "declined" && data.declineReason && (
|
||||
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: "14px 16px", margin: 16 }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div style={{ padding: "16px 16px 0" }}><Kicker>Progress</Kicker></div>
|
||||
<Timeline steps={steps} />
|
||||
|
||||
{data.collectCode && data.status === "ready" && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<CodeBlock code={data.collectCode} />
|
||||
{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’t get there by then, say so on this order and the linen room will sort it out.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.status === "delivered" && data.signerName && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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={{ padding: 16 }}>
|
||||
<SecondaryBar label="Ask about this order" href={`/my/orders/${data.id}/messages`} />
|
||||
</div>
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
|
||||
{canClaim && (
|
||||
<MBar
|
||||
label={busy ? "Working…" : "I’ve 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); return; }
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
/* 1C — Orders. Every request, newest first.
|
||||
*
|
||||
* The status *word* is the signal; 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, MRule, MTop, NOT_DOCKED } from "@/components/m";
|
||||
import { EdgeRow, INK, N600, N700, 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 }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Orders" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<Tabs label="Which orders" value={tab} onPick={setTab} options={tabs} />
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ padding: "28px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
|
||||
{tab === "open"
|
||||
? `Nothing open. Requests for you appear here with their progress${forOthers.length ? " — anything you raise for somebody else is under Raised." : "."}`
|
||||
: tab === "done" ? "Nothing closed yet."
|
||||
: "Nothing you raised for somebody else."}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
|
||||
{rows.map((r) => {
|
||||
const st = statusText(r, { mine: r.mine, first: r.subjectName.split(" ")[0] });
|
||||
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: "baseline" }}>
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", color: st.ink === "attention" ? "var(--color-accent-700)" : N700 }}>
|
||||
{st.label}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: N600 }}>{r.code}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 800, letterSpacing: "-0.01em", marginTop: 6, lineHeight: 1.25 }}>
|
||||
{r.summary}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 5, lineHeight: 1.45 }}>
|
||||
{/* 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 : "",
|
||||
st.note,
|
||||
formatInZone(r.createdAt, me.tz),
|
||||
].filter(Boolean).join(" · ")}
|
||||
</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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
/* 1E — New request. One ask, however many garments it takes.
|
||||
*
|
||||
* A nurse who needs a tunic, trousers and a fleece used to raise three requests: three codes,
|
||||
* three emails to the same manager on the same morning, three bags to collect. So this screen is
|
||||
* built around a list the person is filling rather than a single garment — add a line, add
|
||||
* another, one reason and one note over the lot, one approval at the end.
|
||||
*
|
||||
* Two things carried over from the old screen because they do most of the work. Availability is
|
||||
* shown **before** the request is sent, so nobody asks for a size that isn't there and then waits
|
||||
* a week to find out. And the button names the actual approver — "Send to D. Adeyemi", not
|
||||
* "Submit" — because the single most common question about a request is who has it.
|
||||
*
|
||||
* What is new is that the screen also shows what the person already holds and what they are
|
||||
* allowed, from the same sum the manager's review screen uses. Being declined "Over allowance"
|
||||
* against a number you were never shown is the sort of refusal that ends in a phone call.
|
||||
*
|
||||
* No prices, no payment, no basket. A request covering four garments is not a basket; approval is
|
||||
* still the only control.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MBar, MBody, MError, MRule, MTop } from "@/components/m";
|
||||
import {
|
||||
DarkCard, DraftLineList, GarmentPicker, Kicker, N500, N600, N700, NumberedField, OptionList,
|
||||
StockTag, type DraftLine,
|
||||
} from "@/components/staffui";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { REQUEST_REASONS } from "@/lib/staffreq";
|
||||
import { fmtDate } from "@/lib/compute";
|
||||
|
||||
type Size = { size: string; si: number; word: "in_stock" | "low" | "none" | string; countedOn: string; held: number };
|
||||
type Item = {
|
||||
id: string; item: string; type: string; gender: string; sizes: Size[];
|
||||
recorded: string; recordedSource: "record" | "issued" | ""; held: number;
|
||||
};
|
||||
type Allowance = { capped: boolean; label: string; note: string; over: boolean };
|
||||
|
||||
export default function RequestScreen({
|
||||
items, managerName, swap, heldItemIds, preItemId, preSi, holding, allowance, maxLines, maxQty,
|
||||
}: {
|
||||
items: Item[]; managerName: string; swap: boolean; heldItemIds: string[];
|
||||
preItemId: string | null; preSi: number | null;
|
||||
holding: { total: number; sets: number }; allowance: Allowance;
|
||||
maxLines: number; maxQty: number;
|
||||
}) {
|
||||
const { mutate, busy } = useStaff();
|
||||
// Swapping a size is a request against something you already hold, so the list is the shorter
|
||||
// one. Everything else about the screen is identical — a swap is not a different kind of ask.
|
||||
const list = swap && heldItemIds.length ? items.filter((i) => heldItemIds.includes(i.id)) : items;
|
||||
|
||||
/* Arriving from the waitlist's "or take a stocked size", the garment and size are already
|
||||
* decided — the person picked them on the previous screen and should not have to again. So the
|
||||
* list starts with that line already on it rather than with an empty picker. */
|
||||
const [lines, setLines] = useState<DraftLine[]>(() => {
|
||||
const it = preItemId ? list.find((i) => i.id === preItemId) : null;
|
||||
const s = it && preSi !== null ? it.sizes.find((x) => x.si === preSi) : null;
|
||||
return it && s ? [{ key: "pre", itemId: it.id, si: s.si, item: it.item, size: String(s.size), qty: 1 }] : [];
|
||||
});
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [reason, setReason] = useState<string | null>(null);
|
||||
const [note, setNote] = useState(swap ? "Swapping a size." : "");
|
||||
const [err, setErr] = useState("");
|
||||
const [sent, setSent] = useState<{ id: string; manager: string } | null>(null);
|
||||
|
||||
const garments = lines.reduce((n, l) => n + l.qty, 0);
|
||||
const full = lines.length >= maxLines;
|
||||
// With nothing on the list there is nothing to show but the picker, so it opens itself.
|
||||
const picking = adding || lines.length === 0;
|
||||
|
||||
/* The same garment in the same size, added twice, is one line of two rather than two lines of
|
||||
* one. The server sums duplicates anyway before it writes them, so a screen that showed two
|
||||
* identical rows would be showing something that cannot be saved. */
|
||||
function add(l: { itemId: string; si: number; item: string; size: string; qty: number }) {
|
||||
setErr("");
|
||||
setLines((cur) => {
|
||||
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);
|
||||
}
|
||||
|
||||
/* Raised, but nobody has been emailed.
|
||||
*
|
||||
* Not an error — the request is on the manager's list either way — but the order screen it would
|
||||
* otherwise jump straight to says "Awaiting approval" and nothing else, and somebody who believes
|
||||
* there is a message sitting in their manager's inbox will wait a fortnight before chasing it.
|
||||
* So the one case where no email left the server is said plainly, once, with the thing to do. */
|
||||
if (sent) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Sent" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: 16 }}>
|
||||
<DarkCard
|
||||
kicker="Raised"
|
||||
title={sent.manager ? `${sent.manager} hasn’t been emailed` : "Your manager hasn’t been emailed"}
|
||||
meta="It is on their list in the app and nothing has been lost — but no message went out, so they will not hear about it unless somebody tells them."
|
||||
>
|
||||
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, lineHeight: 1.55, color: N500 }}>
|
||||
Ask the linen room to give {sent.manager || "your manager"} a code for the staff app,
|
||||
or mention it to them yourself.
|
||||
</div>
|
||||
</DarkCard>
|
||||
</div>
|
||||
</MBody>
|
||||
<MBar label="See the order" glyph="arrow" href={`/my/orders/${sent.id}`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={swap ? "Swap a size" : "New request"} back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<NumberedField n={1} label={swap ? "What you’re swapping" : "What you need"} first>
|
||||
{list.length === 0 ? (
|
||||
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>
|
||||
{swap
|
||||
? "Nothing on your record to swap. Ask for an item instead."
|
||||
: "The linen room hasn’t listed any garments yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{lines.length > 0 && (
|
||||
<div style={{ marginBottom: picking ? 14 : 0 }}>
|
||||
<DraftLineList
|
||||
lines={lines}
|
||||
maxQty={maxQty}
|
||||
onQty={(k, q) => setLines((cur) => cur.map((l) => (l.key === k ? { ...l, qty: q } : l)))}
|
||||
onRemove={(k) => setLines((cur) => cur.filter((l) => l.key !== k))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{picking ? (
|
||||
<GarmentPicker
|
||||
items={list}
|
||||
maxQty={maxQty}
|
||||
addLabel={lines.length ? "Add it" : "Add to the request"}
|
||||
onCancel={lines.length ? () => setAdding(false) : undefined}
|
||||
// The size the record already knows — their recorded top or trouser size, or the
|
||||
// size of the last one they were issued for everything else.
|
||||
defaultSi={(it) => it.sizes.find((s) => String(s.size) === String(it.recorded))?.si ?? null}
|
||||
note={(it, s) => (
|
||||
<>
|
||||
{it.recordedSource === "record" ? `Your recorded size is ${it.recorded}. ` : ""}
|
||||
{it.recordedSource === "issued" ? `Last issued in ${it.recorded}. ` : ""}
|
||||
{it.held > 0 ? `You hold ${it.held}. ` : ""}
|
||||
{s ? (
|
||||
<>
|
||||
<StockTag word={s.word} />
|
||||
{s.word === "none" && " — the linen room will order it in"}
|
||||
{s.countedOn ? ` · counted ${fmtDate(s.countedOn)}` : ""}
|
||||
</>
|
||||
) : "Pick a size."}
|
||||
</>
|
||||
)}
|
||||
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 var(--color-text)",
|
||||
borderRadius: 0, background: "transparent", color: "var(--color-text)", font: "inherit",
|
||||
fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase",
|
||||
textAlign: "left", padding: "0 16px", cursor: "pointer",
|
||||
}}
|
||||
>Add another garment</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NumberedField>
|
||||
|
||||
{/* What they hold and what they are entitled to, in the manager's own words. Shown while
|
||||
they are still choosing rather than in the decline. */}
|
||||
<div style={{ margin: "0 16px", background: "#fff", borderLeft: `6px solid ${allowance.over ? "var(--color-accent)" : "var(--color-text)"}`, padding: "14px 16px" }}>
|
||||
<Kicker tone={allowance.over ? "attention" : "quiet"}>What you hold</Kicker>
|
||||
<div style={{ fontSize: 15, lineHeight: 1.5, marginTop: 6 }}>
|
||||
{holding.total === 0
|
||||
? "Nothing on your record yet."
|
||||
: `${holding.total} garment${holding.total === 1 ? "" : "s"}${holding.sets ? ` · ${holding.sets} set${holding.sets === 1 ? "" : "s"}` : ""}`}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.5, marginTop: 4, color: N700 }}>{allowance.label}</div>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.55, color: N600, margin: "8px 0 0" }}>{allowance.note}</p>
|
||||
</div>
|
||||
|
||||
{lines.length > 0 && (
|
||||
<NumberedField n={2} label="Why">
|
||||
<OptionList
|
||||
columns={2}
|
||||
value={reason}
|
||||
onPick={(k) => { setReason(k); setErr(""); }}
|
||||
options={REQUEST_REASONS.map((r) => ({ key: r, label: r }))}
|
||||
/>
|
||||
<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: 15, resize: "none", background: "#fff", color: "var(--color-text)" }}
|
||||
/>
|
||||
<p style={{ fontSize: 13, color: N600, lineHeight: 1.55, margin: "12px 0 0" }}>
|
||||
One reason covers the whole request. {managerName || "Your manager"} can approve some
|
||||
garments and knock others back.
|
||||
</p>
|
||||
</NumberedField>
|
||||
)}
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{!managerName && (
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, padding: "16px 16px 0", margin: 0 }}>
|
||||
Nobody is recorded as your approver yet, so this can’t be sent. Ask the linen room
|
||||
to set your manager on your staff record.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
<MBar
|
||||
label={busy ? "Sending…" : managerName ? `Send to ${managerName}` : "Send for approval"}
|
||||
sub={lines.length ? `${garments} garment${garments === 1 ? "" : "s"} on ${lines.length} line${lines.length === 1 ? "" : "s"}` : undefined}
|
||||
disabled={!lines.length || busy || !managerName}
|
||||
onClick={async () => {
|
||||
if (!lines.length) return;
|
||||
const r = await mutate<{ id: string; notified: boolean }>("request.create", {
|
||||
lines: lines.map((l) => ({ itemId: l.itemId, si: l.si, qty: l.qty })),
|
||||
reason: reason || "", note,
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
// `notified` means an email actually left the server, not that the manager has an account.
|
||||
// Nothing was lost either way — the request is raised and waiting for them — but somebody
|
||||
// who thinks their manager has been told will wait a fortnight before asking, so the one
|
||||
// case where nobody has been told says so before the screen changes.
|
||||
if (!r.result.notified) {
|
||||
setSent({ id: r.result.id, manager: managerName });
|
||||
return;
|
||||
}
|
||||
window.location.assign(`/my/orders/${r.result.id}`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
/* Reviewing one request: approve it, or knock back the garments that shouldn't go.
|
||||
*
|
||||
* One request covers everything the person asked for, so this screen shows the whole ask and
|
||||
* settles it in one action. A manager can still refuse part of it — the tunic and the trousers
|
||||
* yes, the fleece no — and each line carries its own control for that. Everything not knocked back
|
||||
* is approved when the bar at the bottom is pressed, and the bar says how many that is, because
|
||||
* "Approve" over a list of three garments with one struck out has to be unambiguous.
|
||||
*
|
||||
* The decline reason is compulsory and comes from a fixed list of three, and it is always shown to
|
||||
* the staff member — per garment now, rather than for the request as a whole. That is the point:
|
||||
* the thing this replaces is a request that goes quiet, and a refusal nobody can explain is the
|
||||
* same failure with an extra step.
|
||||
*
|
||||
* A manager can be the person the request is for. Two ward managers commonly name each other as
|
||||
* approver — that is how the top of the tree gets one at all — and the server now lets the wearer
|
||||
* settle their own, provided somebody actually reports to them. The queue sets those apart; so does
|
||||
* this screen, next to the button, because having been told on a list you scrolled past is not the
|
||||
* same as being told at the moment you sign.
|
||||
*
|
||||
* The allowance line tells the manager what the cap is *and* that releasing the second allocation
|
||||
* is not theirs to do. Operational Officers are the only capped role; for everyone else the
|
||||
* manager's judgement is the number, and the screen says so rather than showing a limit that
|
||||
* doesn't exist.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MBar, MBody, MError, MRule, MTop } from "@/components/m";
|
||||
import { ACCENT_700, CompactAction, INK, Kicker, LineList, N600, N700, lineText } from "@/components/staffui";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { DECLINE_REASONS } from "@/lib/staffreq";
|
||||
import type { ReviewLine } from "@/lib/managerdata";
|
||||
|
||||
type Data = {
|
||||
id: string; code: string; status: string;
|
||||
subject: { id: string; name: string; num: string; group: string; ward: string; held: number; sets: number; approvedThisYear: number };
|
||||
lines: ReviewLine[]; summary: string; garments: number; lineCount: number; decision: string | null;
|
||||
reason: string; note: string; raisedByName: string;
|
||||
allowance: { capped: boolean; label: string; note: string; over: boolean };
|
||||
};
|
||||
|
||||
/** What the manager has pencilled against each line before they press the bar. */
|
||||
type Call = { decision: "approved" | "declined"; reason: string };
|
||||
|
||||
export default function ReviewScreen({ data }: { data: Data }) {
|
||||
const { me, mutate, busy } = useStaff();
|
||||
/* Everything starts approved. That is not a default in the lazy sense — it is what the button at
|
||||
* the bottom will do, spelled out on every line before it is pressed, so the manager is choosing
|
||||
* what to refuse rather than ticking off what to allow. */
|
||||
const [calls, setCalls] = useState<Record<string, Call>>(
|
||||
() => Object.fromEntries(data.lines.map((l) => [l.id, { decision: "approved" as const, reason: "" }])),
|
||||
);
|
||||
/* Which line's reason list is open — or "*" for the one that settles the whole request. */
|
||||
const [asking, setAsking] = useState<string | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const decided = data.status !== "awaiting";
|
||||
/* Whether the person deciding this is the person it is for. Matched on the staff number, which
|
||||
* the register keeps unique within a facility; a name would start calling a stranger's request
|
||||
* yours the day two people on the ward share one, and what is being marked here is an audit
|
||||
* fact. */
|
||||
const mine = data.subject.id === me.staffId;
|
||||
/* The server takes a self-approval from anybody a request is addressed to (lib/staffops.ts
|
||||
* decideRequest — the reports test was dropped, by Kyle's decision), so the screen no longer has
|
||||
* a "not yours to settle" reading: it said the bar would come back refused, and the bar approved.
|
||||
* What it does say, every time, is that a self-approval is written down as one. */
|
||||
const mayApproveOwn = true;
|
||||
const yes = data.lines.filter((l) => calls[l.id]?.decision === "approved").length;
|
||||
const total = data.lines.length;
|
||||
|
||||
function decline(lineId: string, reason: string) {
|
||||
setAsking(null);
|
||||
setErr("");
|
||||
setCalls((c) => (lineId === "*"
|
||||
// Declining the lot: one reason against every line, which is also what makes the request's
|
||||
// own decline reason true rather than invented.
|
||||
? Object.fromEntries(data.lines.map((l) => [l.id, { decision: "declined" as const, reason }]))
|
||||
: { ...c, [lineId]: { decision: "declined", reason } }));
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const lines = data.lines.map((l) => ({
|
||||
id: l.id,
|
||||
decision: calls[l.id]?.decision ?? "approved",
|
||||
reason: calls[l.id]?.reason ?? "",
|
||||
}));
|
||||
const every = lines.every((l) => l.decision === "declined");
|
||||
// The op name matches the outcome the manager can see on the button; `lines` is what actually
|
||||
// decides, garment by garment, and it has to name every one of them exactly once.
|
||||
const r = await mutate(every ? "request.decline" : "request.approve", {
|
||||
id: data.id,
|
||||
lines,
|
||||
// When the whole request went, and every line went for the same reason, that reason is the
|
||||
// request's reason too — it is what the wearer's order and the decision email lead with.
|
||||
reason: every && new Set(lines.map((l) => l.reason)).size === 1 ? lines[0].reason : undefined,
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
window.location.assign("/my/approvals");
|
||||
}
|
||||
|
||||
const barLabel = busy ? "Working…"
|
||||
: yes === 0 ? (total === 1 ? "Decline" : `Decline all ${total}`)
|
||||
: yes === total ? (total === 1 ? "Approve" : `Approve all ${total}`)
|
||||
: `Approve ${yes} of ${total}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Review request" back right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{data.code}</span>} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 18px", borderBottom: "2px solid " + INK, background: "var(--color-bg)" }}>
|
||||
<Kicker>{[data.subject.ward, data.subject.num, data.subject.group].filter(Boolean).join(" · ")}</Kicker>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 28, letterSpacing: "-0.02em", lineHeight: 1.15, marginTop: 8 }}>
|
||||
{data.subject.name}
|
||||
</div>
|
||||
{mine && <div style={{ marginTop: 10 }}><Kicker tone="attention">Your own uniform</Kicker></div>}
|
||||
<div style={{ fontSize: 14, color: N700, marginTop: 8 }}>
|
||||
Holds {data.subject.held} item{data.subject.held === 1 ? "" : "s"}
|
||||
{data.subject.approvedThisYear > 0 && ` · ${data.subject.approvedThisYear} request${data.subject.approvedThisYear === 1 ? "" : "s"} approved`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "16px 16px 0", display: "flex", alignItems: "baseline", gap: 12 }}>
|
||||
<span style={{ flex: 1 }}><Kicker>Asking for</Kicker></span>
|
||||
<span style={{ fontSize: 12, color: N600 }}>
|
||||
{data.garments} garment{data.garments === 1 ? "" : "s"}{total > 1 ? ` · ${total} lines` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{decided ? (
|
||||
<>
|
||||
<div style={{ margin: "12px 16px 0" }}><LineList lines={data.lines} /></div>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, color: N700, padding: "12px 16px 0", margin: 0 }}>
|
||||
{data.decision ? `${data.decision}. ` : ""}This one has already been settled — it is with
|
||||
the linen room or closed. Nothing here is waiting on you.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: 2, margin: "12px 16px 0" }}>
|
||||
{data.lines.map((l) => {
|
||||
const call = calls[l.id] ?? { decision: "approved" as const, reason: "" };
|
||||
const off = call.decision === "declined";
|
||||
return (
|
||||
<div key={l.id} style={{ background: "#fff", padding: "14px 16px", borderLeft: `6px solid ${off ? ACCENT_700 : "transparent"}` }}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.01em",
|
||||
lineHeight: 1.25, textDecoration: off ? "line-through" : "none", color: off ? N600 : INK,
|
||||
}}>{lineText(l)}</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 5, lineHeight: 1.45 }}>
|
||||
{[
|
||||
l.stock,
|
||||
l.held ? `holds ${l.held}` : "holds none",
|
||||
l.heldThisSize ? `${l.heldThisSize} in this size` : "",
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
{off
|
||||
? <CompactAction label="Undo" onClick={() => { setCalls((c) => ({ ...c, [l.id]: { decision: "approved", reason: "" } })); setAsking(null); }} />
|
||||
: <CompactAction label="Decline" onClick={() => setAsking(asking === l.id ? null : l.id)} />}
|
||||
</div>
|
||||
|
||||
{off && (
|
||||
<div style={{ fontSize: 12.5, fontWeight: 800, color: ACCENT_700, marginTop: 8, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
{call.reason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{asking === l.id && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Kicker tone="attention">Why not this one?</Kicker>
|
||||
<div style={{ display: "grid", gap: 2, marginTop: 10 }}>
|
||||
{DECLINE_REASONS.map((r) => (
|
||||
<button key={r} onClick={() => decline(l.id, r)} style={{
|
||||
minHeight: 52, background: "var(--color-neutral-200)", color: INK, border: 0, borderRadius: 0,
|
||||
textAlign: "left", padding: "0 14px", font: "inherit", fontSize: 15, fontWeight: 800, cursor: "pointer",
|
||||
}}>{r}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(data.reason || data.note || data.raisedByName) && (
|
||||
<div style={{ margin: "12px 16px 0", background: "#fff", padding: "14px 16px" }}>
|
||||
{data.reason && <div style={{ fontSize: 14, fontWeight: 800 }}>{data.reason}</div>}
|
||||
{data.note && <p style={{ fontSize: 14, lineHeight: 1.55, color: N700, margin: data.reason ? "8px 0 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>
|
||||
)}
|
||||
|
||||
<div style={{ margin: "12px 16px 0", background: "#fff", borderLeft: `6px solid ${data.allowance.over ? "var(--color-accent)" : INK}`, padding: "14px 16px" }}>
|
||||
<Kicker tone={data.allowance.over ? "attention" : "quiet"}>Allowance</Kicker>
|
||||
<div style={{ fontSize: 15, lineHeight: 1.5, marginTop: 6 }}>{data.allowance.label}</div>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.55, color: N600, margin: "8px 0 0" }}>{data.allowance.note}</p>
|
||||
</div>
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{!decided && (
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
{asking === "*" ? (
|
||||
<>
|
||||
<Kicker tone="attention">Why are you declining all of it?</Kicker>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.55, color: N600, margin: "8px 0 12px" }}>
|
||||
{data.subject.name.split(" ")[0]} is told which one you picked.
|
||||
</p>
|
||||
<div style={{ display: "grid", gap: 2 }}>
|
||||
{DECLINE_REASONS.map((r) => (
|
||||
<button key={r} onClick={() => decline("*", r)} style={{
|
||||
minHeight: 56, background: "#fff", color: INK, border: 0, borderRadius: 0, textAlign: "left",
|
||||
padding: "0 16px", font: "inherit", fontSize: 15.5, fontWeight: 800, cursor: "pointer",
|
||||
}}>{r}</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}><CompactAction label="Back" onClick={() => setAsking(null)} /></div>
|
||||
</>
|
||||
) : yes > 0 ? (
|
||||
<CompactAction label={total === 1 ? "Decline it instead" : "Decline the whole request"} onClick={() => setAsking("*")} />
|
||||
) : (
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: 0 }}>
|
||||
Nothing on this request will be picked. {data.subject.name.split(" ")[0]} is told the
|
||||
reason against each garment.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mine && !decided && (
|
||||
/* Last thing above the bar, because the bar is what does it. Whichever half of the rule
|
||||
the reader is in, they find out here rather than from a refusal or from an auditor. */
|
||||
<div style={{ margin: "16px 16px 0", background: "#fff", borderLeft: `6px solid ${mayApproveOwn ? INK : ACCENT_700}`, padding: "14px 16px" }}>
|
||||
<Kicker tone="attention">{mayApproveOwn ? "You are signing for yourself" : "Not yours to settle"}</Kicker>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "8px 0 0" }}>
|
||||
{mayApproveOwn
|
||||
? "These garments are for you, and this one is yours to decide. The request's own history will say, in words, that you approved your own uniform, and so will the record anybody reads afterwards."
|
||||
: "These garments are for you, and only a manager with somebody reporting to them can decide their own. Nobody reports to you at the moment, so this will come back refused whichever way you send it — ask the linen room to hand it to another manager."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
|
||||
Only what you approve reaches the linen room, and it goes in one bag with one collection code.
|
||||
</p>
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
|
||||
{!decided && (
|
||||
<MBar
|
||||
label={barLabel}
|
||||
sub={yes > 0 && yes < total ? `${total - yes} declined` : undefined}
|
||||
glyph={yes > 0 ? "check" : "arrow"}
|
||||
tone={yes > 0 ? "accent" : "ink"}
|
||||
disabled={busy || asking !== null}
|
||||
onClick={send}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
/* 2D — Ward round manifest. What the desk sees when the trolley arrives.
|
||||
*
|
||||
* Unclaimed bags from previous rounds sit **above** today's work. They are the linen room's
|
||||
* biggest waste — a bag signed for on the ward and never collected is a garment out of stock and
|
||||
* off the record — so the screen refuses to bury them under whatever arrived this morning.
|
||||
*
|
||||
* Anyone on the ward can sign, and whoever does is named on the requester's order. That is the
|
||||
* whole audit story: a missing bag has a name against it.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MBody, MError, MRule, MTop } from "@/components/m";
|
||||
import { ACCENT_300, CompactAction, DoneRow, EdgeRow, GROUND, INK, Kicker, LineList, N300, N400, N600, N700, SecondaryBar } from "@/components/staffui";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { statusText } from "@/lib/staffreq";
|
||||
import type { ReqLine, ReqRow } from "@/lib/staffdata";
|
||||
import { formatInZone } from "@/lib/compute";
|
||||
|
||||
type Bag = {
|
||||
id: string; code: string; subjectName: string;
|
||||
/** What is actually in the bag: the approved lines, and nothing the manager knocked back. */
|
||||
lines: ReqLine[]; summary: string; garments: number; lineCount: number;
|
||||
status: string; signerName: string | null; signedAt: string | null; claimedAt: string | null;
|
||||
since: string;
|
||||
};
|
||||
|
||||
export default function RoundScreen({ ward, toSign, unclaimed, signedToday, raised }: {
|
||||
ward: string; toSign: Bag[]; unclaimed: Bag[]; signedToday: Bag[];
|
||||
/** Open requests this person raised for someone else, however they came to raise them. */
|
||||
raised: ReqRow[];
|
||||
}) {
|
||||
const { mutate, busy, me } = useStaff();
|
||||
/* `signedAt` arrives as a UTC instant, and both of the places it is shown used to read it as
|
||||
* local text — slicing the first ten characters for the date, and formatting with no zone for the
|
||||
* time. The ward round happens in the morning, which is exactly when the UTC date is still
|
||||
* yesterday's, so the desk was routinely told a bag it signed for an hour ago went out the day
|
||||
* before. The facility's zone answers both. */
|
||||
const tz = me.tz;
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
async function sign(id: string) {
|
||||
const r = await mutate("round.sign", { id });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}
|
||||
|
||||
async function claim(id: string) {
|
||||
const r = await mutate("round.claim", { id });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Ward round" back right={<span style={{ fontSize: 12, color: N400 }}>{ward}</span>} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ background: INK, color: GROUND, padding: 18, display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 34, letterSpacing: "-0.03em", lineHeight: 1 }}>
|
||||
{toSign.length}
|
||||
</div>
|
||||
<div style={{ flex: 1, fontSize: 14, lineHeight: 1.4, color: N300 }}>
|
||||
<div>{toSign.length === 1 ? "bag to sign" : "bags to sign"}</div>
|
||||
<div>{toSign.length === 0 ? "nothing waiting" : "arriving today"}</div>
|
||||
</div>
|
||||
{signedToday.length > 0 && (
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>
|
||||
{signedToday.length} signed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{unclaimed.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
|
||||
<Kicker tone="attention">Unclaimed from earlier rounds</Kicker>
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
|
||||
{unclaimed.map((b) => (
|
||||
<EdgeRow key={b.id} tone="accent">
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
|
||||
<div style={{ fontSize: 12.5, color: N600, marginTop: 4, lineHeight: 1.4 }}>
|
||||
{b.summary} · {b.code}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: N600, marginTop: 2 }}>
|
||||
signed by {b.signerName === me.name ? "you" : b.signerName}
|
||||
{b.signedAt ? ` ${formatInZone(b.signedAt, tz)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<CompactAction label="Nudge" onClick={() => { window.location.assign(`/my/orders/${b.id}/messages`); }} />
|
||||
{/* The desk’s own way out of this list. Nudging only works if the requester
|
||||
eventually opens the app; often the bag went days ago and the person who
|
||||
knows that is the clerk standing where it used to be. */}
|
||||
<CompactAction label="Collected" tone="accent" disabled={busy} onClick={() => claim(b.id)} />
|
||||
</div>
|
||||
</EdgeRow>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
|
||||
<Kicker>Arriving today</Kicker>
|
||||
</div>
|
||||
|
||||
{toSign.length === 0 && signedToday.length === 0 ? (
|
||||
<div style={{ padding: "22px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
|
||||
Nothing on the round for {ward || "your ward"} right now.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{toSign.map((b) => (
|
||||
<div key={b.id} style={{ padding: "14px 16px", background: "#fff", borderTop: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
|
||||
<div style={{ fontSize: 12.5, color: N600, marginTop: 4 }}>{b.summary} · {b.code}</div>
|
||||
</div>
|
||||
<CompactAction label="Sign" tone="accent" disabled={busy} onClick={() => sign(b.id)} />
|
||||
</div>
|
||||
{/* Signing is a signature: whoever puts their name to a bag of four garments should
|
||||
be able to see the four before they do, not a count. Nothing declined is listed
|
||||
— a knocked-back garment never reaches the trolley. */}
|
||||
{b.lineCount > 1 && (
|
||||
<div style={{ marginTop: 10 }}><LineList lines={b.lines} /></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{signedToday.map((b) => (
|
||||
<DoneRow key={b.id}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
|
||||
<div style={{ fontSize: 12.5, marginTop: 4 }}>
|
||||
signed {b.signedAt ? formatInZone(b.signedAt, tz, { hour: "2-digit", minute: "2-digit", hour12: false }) : ""} by {b.signerName}
|
||||
</div>
|
||||
</div>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="square" aria-hidden><path d="m4 12 5 5L20 6" /></svg>
|
||||
</div>
|
||||
</DoneRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* What this person raised in somebody else's name, still on its way. The bags above are
|
||||
only the ones arriving today; a request typed in on Tuesday and approved on Thursday is
|
||||
invisible there until it turns up on a trolley, so without this the only way to find out
|
||||
where it had got to was to ring the linen room. */}
|
||||
{raised.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: "18px 16px 8px", borderTop: "2px solid " + INK, borderBottom: "2px solid " + INK, background: GROUND }}>
|
||||
<Kicker>Raised by you · still open</Kicker>
|
||||
</div>
|
||||
<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="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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
|
||||
Anyone on the ward can sign. Whoever does appears on the requester’s order, so a
|
||||
missing bag has a name against it.
|
||||
</p>
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
|
||||
{toSign.length > 1 && (
|
||||
<div style={{ borderTop: "2px solid " + INK }}>
|
||||
{/* Bulk signing is allowed but not encouraged — a secondary action, never the red one. */}
|
||||
<SecondaryBar
|
||||
label={busy ? "Signing…" : `Sign for all ${toSign.length} remaining`}
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
for (const b of toSign) {
|
||||
const r = await mutate("round.sign", { id: b.id });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
/* 1H — Shelf check. Kill the "have you got any size 12s?" message before it is sent.
|
||||
*
|
||||
* Words, never counts. Wards see In stock / Low / None on shelf; the number stays in the linen
|
||||
* room. That is partly the product rule and partly honesty — the count here is as fresh as the
|
||||
* last stocktake of that garment, which is why each row says when it was counted rather than
|
||||
* implying a live figure.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { MBody, MRule, MTop, inputStyle } from "@/components/m";
|
||||
import { INK, N600, N700, StockTag } from "@/components/staffui";
|
||||
import StaffNav from "@/components/staffnav";
|
||||
import { fmtDate } from "@/lib/compute";
|
||||
|
||||
type Size = { size: string; si: number; word: string; countedOn: string };
|
||||
type Item = { id: string; item: string; type: string; gender: string; sizes: Size[]; recorded: string };
|
||||
|
||||
export default function ShelfScreen({ items }: { items: Item[] }) {
|
||||
const [q, setQ] = useState("");
|
||||
const shown = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
if (!needle) return items;
|
||||
return items.filter((i) =>
|
||||
i.item.toLowerCase().includes(needle) ||
|
||||
i.type.toLowerCase().includes(needle) ||
|
||||
i.sizes.some((s) => String(s.size).toLowerCase() === needle));
|
||||
}, [items, q]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="On the shelf" back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ background: "#fff", padding: 16, borderBottom: "2px solid " + INK }}>
|
||||
{/* Named, not just placeheld: the placeholder is gone as soon as anyone types, and a
|
||||
box that then announces itself as "edit, blank" is a box nobody can come back to. */}
|
||||
<input
|
||||
value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search garments or a size"
|
||||
aria-label="Search garments or a size"
|
||||
autoComplete="off" style={{ ...inputStyle, width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{shown.length === 0 && (
|
||||
<div style={{ padding: "28px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
|
||||
Nothing matches “{q}”.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shown.map((it) => (
|
||||
<div key={it.id}>
|
||||
<div style={{ padding: "18px 16px 8px", background: "var(--color-bg)", borderBottom: "2px solid " + INK }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>
|
||||
{it.item}{it.gender && it.gender !== "Unisex" ? ` · ${it.gender}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{it.sizes.map((s) => {
|
||||
const row = (
|
||||
<>
|
||||
<span style={{ flex: 1, fontSize: 16, fontWeight: 800, textAlign: "left" }}>
|
||||
{s.size}
|
||||
{it.recorded && String(it.recorded) === String(s.size) && (
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: N600, marginLeft: 8 }}>your size</span>
|
||||
)}
|
||||
</span>
|
||||
<StockTag word={s.word} />
|
||||
</>
|
||||
);
|
||||
const style: React.CSSProperties = {
|
||||
display: "flex", gap: 12, alignItems: "center", padding: "14px 16px", background: "#fff",
|
||||
borderTop: "1px solid var(--color-divider)", borderRight: 0, borderBottom: 0, width: "100%",
|
||||
borderLeft: s.word === "none" ? "6px solid var(--color-accent)" : "6px solid transparent",
|
||||
font: "inherit", color: INK, borderRadius: 0, textDecoration: "none",
|
||||
};
|
||||
// A size that isn't there is the one row worth tapping: it leads to the waitlist
|
||||
// rather than a dead end, which is the whole point of 2B.
|
||||
return s.word === "none" ? (
|
||||
<Link key={s.si} href={`/my/waitlist?item=${it.id}&si=${s.si}`} style={style} className="tcx-bar">{row}</Link>
|
||||
) : (
|
||||
<div key={s.si} style={style}>{row}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
|
||||
Availability comes from the last linen-room stocktake
|
||||
{shown[0]?.sizes.find((s) => s.countedOn) ? ` — most recently ${fmtDate(shown.flatMap((i) => i.sizes).map((s) => s.countedOn).filter(Boolean).sort().reverse()[0] || "")}` : ""}.
|
||||
Wards see words, not counts.
|
||||
</p>
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
<StaffNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
/* 1F — Order messages. Ask about *this* order.
|
||||
*
|
||||
* One thread per order, and no general inbox. That is the rule that keeps this from becoming a
|
||||
* chat app nobody staffs: every message arrives attached to the thing it is about, so whoever
|
||||
* picks it up in the linen room already knows what is being asked.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bubble, Composer, ContextStrip, DateSeparator, N600 } from "@/components/staffui";
|
||||
import { MBody, MError, MRule, MTop } from "@/components/m";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { statusText } from "@/lib/staffreq";
|
||||
import { addDays, facilityDate, facilityToday, formatInZone } from "@/lib/compute";
|
||||
|
||||
type Msg = { id: string; fromStaff: boolean; authorName: string; body: string; at: string };
|
||||
type Data = {
|
||||
id: string; code: string; status: string; summary: string;
|
||||
managerName: string; declineReason: string | null; holdUntil: string; ward: string;
|
||||
signerName: string | null; signerRole: string | null;
|
||||
messages: Msg[];
|
||||
};
|
||||
|
||||
/* Both of these run twice — once on the server rendering this screen, once in the browser hydrating
|
||||
* it — so neither may read the ambient zone. `toDateString()` did exactly that: on a UTC host a
|
||||
* message sent at 08:00 Brisbane was separated under "Yesterday" and stamped 22:30, then flipped to
|
||||
* "Today" and 08:30 when React took over. Comparing calendar dates in the facility's zone gives the
|
||||
* same answer in both places, and it is the ward's answer. */
|
||||
const dayLabel = (iso: string, tz: string) => {
|
||||
const day = facilityDate(iso, tz);
|
||||
if (!day) return "";
|
||||
const today = facilityToday(tz);
|
||||
if (day === today) return "Today";
|
||||
if (day === addDays(today, -1)) return "Yesterday";
|
||||
return formatInZone(iso, tz, { day: "numeric", month: "long" });
|
||||
};
|
||||
const timeLabel = (iso: string, tz: string) => formatInZone(iso, tz, { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
|
||||
export default function ThreadScreen({ data }: { data: Data }) {
|
||||
const { mutate, busy, me } = useStaff();
|
||||
const [body, setBody] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [sent, setSent] = useState<Msg[]>([]);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* Anything the server has already told us about wins: `mutate` refreshes this screen, so a message
|
||||
* we optimistically appended comes back in `data.messages` under the same id a moment later. Without
|
||||
* this the nurse sees her own question twice, once from each list, for as long as she stays on the
|
||||
* thread — and the clerk reading the same order sees the doubled conversation too. */
|
||||
const confirmed = new Set(data.messages.map((m) => m.id));
|
||||
const all = [...data.messages, ...sent.filter((m) => !confirmed.has(m.id))];
|
||||
useEffect(() => { endRef.current?.scrollIntoView({ block: "end" }); }, [all.length]);
|
||||
|
||||
const st = statusText(data);
|
||||
let lastDay = "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={data.code} back right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>Messages</span>} />
|
||||
<MRule />
|
||||
<ContextStrip>
|
||||
{/* The summary rather than the lines: this strip is here to say which order the thread
|
||||
belongs to, and a request for four garments would push the first message off the
|
||||
screen. Whoever needs the detail is one tap away on the order itself. */}
|
||||
{data.summary} · {st.label.toLowerCase()}
|
||||
</ContextStrip>
|
||||
<MBody>
|
||||
{all.length === 0 && (
|
||||
<div style={{ padding: "28px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
|
||||
Nothing here yet. Ask the linen room about this order and they’ll see it against
|
||||
the request.
|
||||
</div>
|
||||
)}
|
||||
{all.map((m) => {
|
||||
const day = dayLabel(m.at, me.tz);
|
||||
const sep = day !== lastDay;
|
||||
lastDay = day;
|
||||
return (
|
||||
<div key={m.id}>
|
||||
{sep && <DateSeparator>{day}</DateSeparator>}
|
||||
<Bubble
|
||||
mine={m.fromStaff && m.authorName === me.name}
|
||||
author={m.fromStaff ? m.authorName : "Linen room"}
|
||||
body={m.body}
|
||||
stamp={timeLabel(m.at, me.tz)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<div ref={endRef} style={{ height: 8 }} />
|
||||
</MBody>
|
||||
<Composer
|
||||
value={body}
|
||||
onChange={(v) => { setBody(v); setErr(""); }}
|
||||
busy={busy}
|
||||
placeholder="Ask about this order"
|
||||
onSend={async () => {
|
||||
const text = body.trim();
|
||||
if (!text) return;
|
||||
setBody("");
|
||||
const r = await mutate<{ id: string; at: string }>("request.message", { id: data.id, body: text });
|
||||
if (!r.ok) { setErr(r.error); setBody(text); return; }
|
||||
// Shown immediately with the server's own stamp, so the thread doesn't jump when the
|
||||
// page refreshes underneath it.
|
||||
setSent((s) => [...s, { id: r.result.id, fromStaff: true, authorName: me.name, body: text, at: r.result.at }]);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
/* 2B — Waitlist. What "none on the shelf" leads to instead of a dead end.
|
||||
*
|
||||
* Position is shown **before** joining, because "you are fourth" and "you are fortieth" are
|
||||
* different decisions and only one of them is worth waiting for. The nearest stocked sizes sit
|
||||
* right underneath for the same reason: most people would rather have something that fits
|
||||
* approximately today than exactly in three weeks, and the screen should let them say so.
|
||||
*
|
||||
* Joining needs no approval — a queue is not a request. Approval happens if and when the item
|
||||
* lands and they accept it.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MBar, MBody, MError, MRule, MTop } from "@/components/m";
|
||||
import { ACCENT_300, CompactAction, DarkCard, DIVIDER, GROUND, INK, Kicker, N300, N600, N700, StockTag } from "@/components/staffui";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { fmtDate, formatInZone } from "@/lib/compute";
|
||||
import { WAITLIST_HOLD_HOURS } from "@/lib/staffreq";
|
||||
|
||||
type Alt = { si: number; size: string; word: string };
|
||||
type Data = {
|
||||
itemId: string; item: string; size: string; si: number;
|
||||
lastRestocked: string; position: number; ahead: number;
|
||||
joined: boolean; entryId: string | null; offeredAt: string | null;
|
||||
holdUntil: string | null; offerExpired: boolean; acceptedAt: string | null;
|
||||
alternatives: Alt[];
|
||||
};
|
||||
|
||||
export default function WaitlistScreen({ data }: { data: Data }) {
|
||||
const { mutate, busy, me } = useStaff();
|
||||
const [err, setErr] = useState("");
|
||||
const [joined, setJoined] = useState(data.joined);
|
||||
|
||||
/* Four states, and the screen used to know about two of them.
|
||||
*
|
||||
* An offer is not a standing invitation: it has been accepted, or it is live, or the 48-hour
|
||||
* hold has run out. Keying only off `offeredAt` meant somebody who had already accepted was
|
||||
* shown "Accept it" again — every tap answered "Nothing to accept" — and somebody whose hold
|
||||
* had lapsed was shown a bar the server now refuses, with no word about why. */
|
||||
const acceptedAt = data.acceptedAt;
|
||||
const accepted = !!acceptedAt;
|
||||
const heldForMe = !accepted && !!data.offeredAt && !data.offerExpired;
|
||||
const lapsed = !accepted && !!data.offeredAt && data.offerExpired;
|
||||
|
||||
/* The deadline, with the time on it, in the facility's zone.
|
||||
*
|
||||
* `holdUntil` is an instant 48 hours after the offer, and it was being shown by slicing the first
|
||||
* ten characters of the UTC string — so an offer made at 09:00 Brisbane printed the previous day's
|
||||
* date, and a nurse reading "held until the 9th" had until the 10th. The hour matters as much as
|
||||
* the date here: a hold that runs out mid-afternoon is not the same as one that runs to midnight. */
|
||||
const heldUntil = data.holdUntil
|
||||
? formatInZone(data.holdUntil, me.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Waitlist" back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 18px", borderBottom: "2px solid " + INK, background: GROUND }}>
|
||||
<Kicker tone="attention">None on the shelf</Kicker>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.02em", lineHeight: 1.1, marginTop: 8 }}>
|
||||
{data.item} — {data.size}
|
||||
</div>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.55, color: N700, margin: "10px 0 0" }}>
|
||||
{data.lastRestocked ? `Last counted ${fmtDate(data.lastRestocked)}. ` : ""}
|
||||
The linen room orders these in when the shelf runs down.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
{accepted ? (
|
||||
<DarkCard kicker="Accepted" title="It’s with your manager" meta="You accepted this one, and it has gone to your manager for approval like any other request.">
|
||||
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, color: N300 }}>
|
||||
Accepted {formatInZone(acceptedAt || "", me.tz)}. Follow it on <a href="/my/orders" style={{ color: GROUND }}>your orders</a>.
|
||||
</div>
|
||||
</DarkCard>
|
||||
) : heldForMe ? (
|
||||
<DarkCard kicker="It’s in" title="Held for you" meta="The linen room has one for you. Accept it and it goes to your manager for approval like any other request.">
|
||||
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, color: N300 }}>
|
||||
{heldUntil ? `Held until ${heldUntil}.` : "Held for you."}
|
||||
</div>
|
||||
</DarkCard>
|
||||
) : (
|
||||
<div style={{ background: INK, color: GROUND, padding: 18 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>
|
||||
{joined ? "You’re on the list" : "If you join"}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, marginTop: 12 }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 44, letterSpacing: "-0.03em", lineHeight: 1 }}>
|
||||
{ordinal(data.position)}
|
||||
</div>
|
||||
<div style={{ flex: 1, fontSize: 14, lineHeight: 1.4, color: N300 }}>
|
||||
<div>in queue</div>
|
||||
<div>{data.ahead === 0 ? "nobody ahead of you" : `${data.ahead} ${data.ahead === 1 ? "person" : "people"} already waiting`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, lineHeight: 1.55, color: N300 }}>
|
||||
{lapsed
|
||||
? `One came in and was held for you until ${heldUntil || "the hold ran out"}. Nobody took it, so the hold has run out — you’re still on the list, and the linen room can offer it again.`
|
||||
: `You’ll get a message the day it lands, and the item is held for you for ${WAITLIST_HOLD_HOURS} hours.`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.alternatives.length > 0 && !heldForMe && !accepted && (
|
||||
<>
|
||||
<div style={{ padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
|
||||
<Kicker>Or take a stocked size</Kicker>
|
||||
</div>
|
||||
{data.alternatives.map((a) => (
|
||||
<div key={a.si} style={{ display: "flex", gap: 12, alignItems: "center", padding: "14px 16px", background: "#fff", borderTop: `1px solid ${DIVIDER}` }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800 }}>{data.item} — {a.size}</div>
|
||||
<div style={{ fontSize: 12.5, marginTop: 4 }}><StockTag word={a.word} /></div>
|
||||
</div>
|
||||
<CompactAction label="Request" onClick={() => { window.location.assign(`/my/request?item=${data.itemId}&si=${a.si}`); }} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
|
||||
Waiting doesn’t need approval. Your manager only sees it if the item comes in and you
|
||||
accept it.
|
||||
</p>
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
|
||||
{accepted ? null : heldForMe ? (
|
||||
<MBar
|
||||
label={busy ? "Working…" : "Accept it"}
|
||||
glyph="check"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
const r = await mutate<{ request: { id: string } }>("waitlist.accept", { id: data.entryId });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
window.location.assign(`/my/orders/${r.result.request.id}`);
|
||||
}}
|
||||
/>
|
||||
) : joined ? (
|
||||
<MBar
|
||||
label={busy ? "Working…" : "Leave the list"}
|
||||
glyph="none"
|
||||
tone="ink"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
const r = await mutate("waitlist.leave", { id: data.entryId });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setJoined(false);
|
||||
window.location.assign("/my/shelf");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MBar
|
||||
label={busy ? "Joining…" : "Join the waitlist"}
|
||||
glyph="none"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
const r = await mutate("waitlist.join", { itemId: data.itemId, si: data.si });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setJoined(true);
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ordinal(n: number) {
|
||||
const s = ["th", "st", "nd", "rd"], v = n % 100;
|
||||
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
/* The manager's ward view: who on their team holds what.
|
||||
*
|
||||
* A list, not a dashboard. The only computed thing on it is each person's set count, and the
|
||||
* footnote exists because the first question a manager asks about "3 of 6 sets" is whether
|
||||
* something has to come back before the next set can. Nothing does — the owner's rule is six held
|
||||
* at any time, reached without handing anything in.
|
||||
*/
|
||||
import { MBody, MRule, MTop } from "@/components/m";
|
||||
import { INK, N600, N700 } from "@/components/staffui";
|
||||
import ManagerNav from "./ManagerNav";
|
||||
import { fmtDate } from "@/lib/compute";
|
||||
|
||||
type Row = { id: string; name: string; group: string; held: number; lastIssued: string; capped: boolean; setsLabel: string; isNewStarter: boolean };
|
||||
|
||||
export default function WardScreen({ ward, rows, anyCapped }: { ward: string; rows: Row[]; anyCapped: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<MTop
|
||||
title={ward || "Your team"}
|
||||
back
|
||||
right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{rows.length} staff</span>}
|
||||
/>
|
||||
<MRule />
|
||||
<MBody>
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ padding: "28px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
|
||||
Nobody is recorded as reporting to you. The linen room sets who approves each staff
|
||||
member on their record.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", padding: "12px 16px", borderBottom: "2px solid " + INK, background: "var(--color-bg)" }}>
|
||||
<span style={{ flex: 1, fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>{ward || "Your team"}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>Items held</span>
|
||||
</div>
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} style={{ display: "flex", gap: 12, alignItems: "center", padding: "14px 16px", background: "#fff", borderTop: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{r.name}</div>
|
||||
<div style={{ fontSize: 12.5, color: N600, marginTop: 4, lineHeight: 1.4 }}>
|
||||
{[
|
||||
r.group,
|
||||
r.capped ? r.setsLabel : "",
|
||||
r.isNewStarter && r.lastIssued ? `set issued ${fmtDate(r.lastIssued)}` : "",
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20, fontVariantNumeric: "tabular-nums" }}>{r.held}</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{anyCapped && (
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, color: N700, padding: 16, margin: 0, background: "var(--color-bg)" }}>
|
||||
The figure against each person is how many sets they hold, out of the most anyone may
|
||||
hold at once. Nothing has to be handed back before the next set is issued.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
<ManagerNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { GROUND, INK } from "./m";
|
||||
|
||||
/* Four items, as designed. Not five.
|
||||
*
|
||||
* A manager is a staff member who also approves — they wear the uniform too — so the approvals
|
||||
* queue is a card on Home rather than a fifth tab. The prototype showed a manager with two nav
|
||||
* items because it switched roles with chips above the phone; that switcher is a prototyping
|
||||
* device, and building a separate manager shell around it would give a ward manager two apps to
|
||||
* remember instead of one.
|
||||
*/
|
||||
const NAV: [string, string, React.ReactNode][] = [
|
||||
["/my", "Home", <path key="h" d="M4 11 12 4l8 7v9H4z" />],
|
||||
["/my/kit", "Kit", <g key="k"><path d="M4 7h16" /><path d="M4 12h16" /><path d="M4 17h16" /></g>],
|
||||
["/my/orders", "Orders", <g key="o"><path d="M9 6h11" /><path d="M9 12h11" /><path d="M9 18h11" /><path d="M4 6h1" /><path d="M4 12h1" /><path d="M4 18h1" /></g>],
|
||||
["/my/messages", "Messages", <path key="m" d="M4 5h16v11H9l-5 4z" />],
|
||||
];
|
||||
|
||||
export default function StaffNav() {
|
||||
const path = usePathname();
|
||||
const router = useRouter();
|
||||
const on = (href: string) => (href === "/my" ? path === "/my" : path.startsWith(href));
|
||||
|
||||
return (
|
||||
<nav style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", flex: "0 0 auto", borderTop: "2px solid " + INK, background: GROUND }}>
|
||||
{NAV.map(([href, label, icon]) => {
|
||||
const active = on(href);
|
||||
const style: React.CSSProperties = {
|
||||
minHeight: 52, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
||||
gap: 5, padding: "10px 4px calc(12px + env(safe-area-inset-bottom, 0px))",
|
||||
fontSize: 10, fontWeight: 800, letterSpacing: "0.1em", textTransform: "uppercase",
|
||||
textDecoration: "none", background: active ? INK : GROUND,
|
||||
color: active ? GROUND : "var(--color-neutral-600)", border: 0, font: "inherit", cursor: "pointer",
|
||||
};
|
||||
const inner = (
|
||||
<>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="square" aria-hidden>{icon}</svg>
|
||||
<span style={{ fontSize: 10, fontWeight: 800, letterSpacing: "0.1em" }}>{label}</span>
|
||||
</>
|
||||
);
|
||||
// Messages has no general inbox to land in, so with nothing open it goes to Orders. The
|
||||
// alternative is a screen that exists only to say there is nothing here.
|
||||
if (href === "/my/messages") {
|
||||
return (
|
||||
<button key={href} onClick={() => router.push("/my/orders?tab=open")} style={{ ...style, fontSize: 10 }} aria-current={active ? "page" : undefined}>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link key={href} href={href} aria-current={active ? "page" : undefined} style={style}>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
"use client";
|
||||
/* The primitives the staff app needs that the counter app never did.
|
||||
*
|
||||
* components/m.tsx already carries the shared half of the system — app bar, 64px bars, rows,
|
||||
* sections, dark panels, chips, steppers — and this file adds the rest of the recipes from the
|
||||
* handoff rather than restating them. Anything that exists in m.tsx is imported, not re-drawn:
|
||||
* two implementations of a 64px flush-left button is how a design system stops being one.
|
||||
*
|
||||
* House rules that every component here obeys, because they are the system:
|
||||
* · radius 0, always. No shadows. Hierarchy comes from rules and fills.
|
||||
* · 2px rules between sections, 1px between rows, 4px accent bar under the app bar.
|
||||
* · 44px is the floor for anything you tap.
|
||||
* · gaps between sibling options are 2px — never 0, never 8.
|
||||
* · button labels are flush left with the icon pushed right. Nothing is centred except the
|
||||
* date separators in a message thread.
|
||||
* · status is carried by a word. Colour only ever reinforces it.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { createContext, useContext, useId, useRef, useState } from "react";
|
||||
import { GARMENT_CATEGORIES, garmentCategory, type GarmentCategory } from "@/lib/compute";
|
||||
import { ACCENT, GROUND, INK, IconRight, MStepper } from "./m";
|
||||
|
||||
const DIVIDER = "var(--color-divider)";
|
||||
const ACCENT_300 = "var(--color-accent-300)";
|
||||
const ACCENT_700 = "var(--color-accent-700)";
|
||||
const N200 = "var(--color-neutral-200)";
|
||||
const N300 = "var(--color-neutral-300)";
|
||||
const N400 = "var(--color-neutral-400)";
|
||||
const N500 = "var(--color-neutral-500)";
|
||||
const N600 = "var(--color-neutral-600)";
|
||||
const N700 = "var(--color-neutral-700)";
|
||||
const SURFACE = "var(--color-surface)";
|
||||
|
||||
/* ---------------------------------------------------------------- text ---- */
|
||||
|
||||
export const Kicker = ({ children, tone = "quiet" }: { children: React.ReactNode; tone?: "quiet" | "attention" | "dark" }) => (
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase",
|
||||
color: tone === "attention" ? ACCENT_700 : tone === "dark" ? ACCENT_300 : N600,
|
||||
}}>{children}</div>
|
||||
);
|
||||
|
||||
/* ---------------------------------------------------------------- identity ---- */
|
||||
|
||||
/** 1A’s identity block: who you are, then the sizes the linen room has on file. */
|
||||
/** Who this is: the ward and staff number over the name, at the top of Home.
|
||||
*
|
||||
* It carried the recorded top and trouser sizes too, and they have gone. Home answers one
|
||||
* question — is anything waiting for me — and a size is not something anybody acts on from here.
|
||||
* They still sit on Kit, which is where a wearer goes to see their own record. */
|
||||
export function IdentityBlock({ ward, num, name }: { ward: string; num: string; name: string }) {
|
||||
return (
|
||||
<div style={{ padding: "20px 16px 18px", borderBottom: "2px solid " + INK, background: GROUND }}>
|
||||
<Kicker>{[ward, num].filter(Boolean).join(" · ") || "Staff"}</Kicker>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 28, letterSpacing: "-0.02em", lineHeight: 1.15, marginTop: 8 }}>{name}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- dark card ---- */
|
||||
|
||||
/** The ink card: the live order on Home, the collection code, the waitlist position. */
|
||||
export function DarkCard({ kicker, title, meta, children, onClick, href }: {
|
||||
kicker?: string; title?: React.ReactNode; meta?: React.ReactNode;
|
||||
children?: React.ReactNode; onClick?: () => void; href?: string;
|
||||
}) {
|
||||
const inner = (
|
||||
<>
|
||||
{kicker && <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>{kicker}</div>}
|
||||
{title && <div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "-0.01em", lineHeight: 1.2, marginTop: 8 }}>{title}</div>}
|
||||
{meta && <div style={{ fontSize: 13, color: N300, marginTop: 8, lineHeight: 1.5 }}>{meta}</div>}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
const st: React.CSSProperties = {
|
||||
background: INK, color: GROUND, padding: 18, display: "block", width: "100%",
|
||||
border: 0, borderRadius: 0, textAlign: "left", font: "inherit",
|
||||
cursor: onClick || href ? "pointer" : "default",
|
||||
};
|
||||
if (href) return <Link href={href} style={{ ...st, textDecoration: "none" }} className="tcx-bar">{inner}</Link>;
|
||||
if (onClick) return <button onClick={onClick} style={st} className="tcx-bar">{inner}</button>;
|
||||
return <div style={st}>{inner}</div>;
|
||||
}
|
||||
|
||||
/** A divided row inside a dark card — "COLLECTION CODE 4 8 2 6". */
|
||||
export function DarkRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N400 }}>{label}</span>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "0.16em" }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The code someone holds up at the counter. Big, because it is read across a desk. */
|
||||
export function CodeBlock({ code, kicker = "Show at the counter" }: { code: string; kicker?: string }) {
|
||||
return (
|
||||
<div style={{ background: INK, color: GROUND, padding: 18 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>{kicker}</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 38, letterSpacing: "0.2em", marginTop: 10 }}>{code}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- segments & tabs ---- */
|
||||
|
||||
/** Two or three mutually exclusive views. 2px gaps; the selected one inverts.
|
||||
*
|
||||
* The inversion is the only thing that says which view you are on, and an ink fill is not
|
||||
* something a screen reader can see — so `aria-pressed` says it in words, and the row announces
|
||||
* itself as one group rather than as two unrelated buttons. */
|
||||
export function Segments<T extends string>({ options, value, onPick, label = "View" }: {
|
||||
options: { key: T; label: string }[]; value: T; onPick: (k: T) => void; label?: string;
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} style={{ display: "flex", gap: 2, padding: "12px 16px", background: GROUND }}>
|
||||
{options.map((o) => {
|
||||
const on = o.key === value;
|
||||
return (
|
||||
<button key={o.key} onClick={() => onPick(o.key)} aria-pressed={on} style={{
|
||||
flex: 1, minHeight: 44, border: 0, background: on ? INK : "#fff", color: on ? GROUND : N700,
|
||||
font: "inherit", fontWeight: on ? 800 : 600, fontSize: 13, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", cursor: "pointer", borderRadius: 0,
|
||||
}}>{o.label}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tabs with counts — OPEN 3 / DONE 11.
|
||||
*
|
||||
* Pressed rather than the full tablist/tabpanel machinery: these swap the list underneath rather
|
||||
* than switching between labelled panels, and claiming a role the markup doesn't keep would be
|
||||
* worse than the honest one. */
|
||||
export function Tabs<T extends string>({ options, value, onPick, label = "Filter" }: {
|
||||
options: { key: T; label: string; count?: number }[]; value: T; onPick: (k: T) => void; label?: string;
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} style={{ display: "flex", gap: 2, background: GROUND, borderBottom: "2px solid " + INK }}>
|
||||
{options.map((o) => {
|
||||
const on = o.key === value;
|
||||
return (
|
||||
<button key={o.key} onClick={() => onPick(o.key)} aria-pressed={on} style={{
|
||||
flex: 1, minHeight: 48, border: 0, background: on ? INK : "transparent", color: on ? GROUND : N600,
|
||||
font: "inherit", fontWeight: 800, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase",
|
||||
cursor: "pointer", borderRadius: 0,
|
||||
}}>
|
||||
{o.label}{o.count === undefined ? "" : ` ${o.count}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- quick actions ---- */
|
||||
|
||||
export function QuickGrid({ items }: { items: { label: string; href?: string; onClick?: () => void; icon: React.ReactNode }[] }) {
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, background: DIVIDER }}>
|
||||
{items.map((it) => {
|
||||
const inner = (
|
||||
<>
|
||||
<span style={{ color: ACCENT, display: "block" }}>{it.icon}</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 800, lineHeight: 1.3, marginTop: 12, display: "block" }}>{it.label}</span>
|
||||
</>
|
||||
);
|
||||
const st: React.CSSProperties = {
|
||||
background: "#fff", minHeight: 96, padding: "16px 14px", border: 0, borderRadius: 0,
|
||||
font: "inherit", color: INK, textAlign: "left", cursor: "pointer", textDecoration: "none", display: "block",
|
||||
};
|
||||
return it.href
|
||||
? <Link key={it.label} href={it.href} style={st} className="tcx-bar">{inner}</Link>
|
||||
: <button key={it.label} onClick={it.onClick} style={st} className="tcx-bar">{inner}</button>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- notice & banner ---- */
|
||||
|
||||
/** A broadcast from the linen room. Not a message — nobody replies to it. */
|
||||
export function Notice({ kicker = "From the linen room", children }: { kicker?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ background: "#fff", borderLeft: "6px solid " + ACCENT, padding: "14px 16px", margin: "16px 0 0" }}>
|
||||
<Kicker>{kicker}</Kicker>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.5, marginTop: 6 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An in-app notification, directly under the accent bar. Tapping it goes there and clears it. */
|
||||
export function Banner({ title, body, onOpen, onDismiss }: {
|
||||
title: string; body: string; onOpen?: () => void; onDismiss?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ background: INK, color: GROUND, borderLeft: "6px solid " + ACCENT, padding: "12px 14px", display: "flex", gap: 12, alignItems: "flex-start" }}>
|
||||
<button onClick={onOpen} style={{ flex: 1, minWidth: 0, background: "none", border: 0, padding: 0, font: "inherit", color: "inherit", textAlign: "left", cursor: "pointer" }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>ThreadCount · now</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, marginTop: 5 }}>{title}</div>
|
||||
<div style={{ fontSize: 13, color: N300, marginTop: 4, lineHeight: 1.45 }}>{body}</div>
|
||||
</button>
|
||||
{onDismiss && (
|
||||
<button onClick={onDismiss} aria-label="Dismiss" style={{ background: "none", border: 0, color: N400, padding: 4, cursor: "pointer", flex: "0 0 auto", font: "inherit", fontSize: 18, lineHeight: 1 }}>×</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- timeline ---- */
|
||||
|
||||
export type Step = { label: string; meta?: string; state: "done" | "current" | "future" };
|
||||
|
||||
/** The order's progress. A step that hasn't happened is always shown, as an outlined dot — the
|
||||
* point of the screen is what is still to come as much as what has happened. */
|
||||
export function Timeline({ steps }: { steps: Step[] }) {
|
||||
return (
|
||||
<div style={{ padding: "16px 16px 4px" }}>
|
||||
{steps.map((s, i) => {
|
||||
const last = i === steps.length - 1;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
position: "relative", marginLeft: 5, paddingLeft: 20, paddingBottom: last ? 4 : 18,
|
||||
borderLeft: last ? "2px solid transparent" : `2px solid ${s.state === "current" ? ACCENT : INK}`,
|
||||
}}>
|
||||
<span style={{
|
||||
position: "absolute", left: -7, top: 2, width: 12, height: 12,
|
||||
background: s.state === "future" ? "transparent" : s.state === "current" ? ACCENT : INK,
|
||||
border: s.state === "future" ? `2px solid ${N500}` : "none",
|
||||
}} />
|
||||
<div style={{
|
||||
fontSize: 15, fontWeight: 800, lineHeight: 1.3,
|
||||
color: s.state === "current" ? ACCENT_700 : s.state === "future" ? N600 : INK,
|
||||
}}>{s.label}</div>
|
||||
{s.meta && <div style={{ fontSize: 12, color: N600, marginTop: 3, lineHeight: 1.45 }}>{s.meta}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- messages ---- */
|
||||
|
||||
export function DateSeparator({ children }: { children: React.ReactNode }) {
|
||||
// The only centred text in the system.
|
||||
return (
|
||||
<div style={{ textAlign: "center", fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600, padding: "16px 0 8px" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Bubble({ mine, author, body, stamp }: { mine: boolean; author?: string; body: string; stamp: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: mine ? "flex-end" : "flex-start", padding: "4px 16px" }}>
|
||||
<div style={{
|
||||
maxWidth: 300, padding: 14, background: mine ? INK : "#fff", color: mine ? GROUND : INK,
|
||||
borderLeft: mine ? undefined : "6px solid " + ACCENT,
|
||||
}}>
|
||||
{!mine && author && <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600, marginBottom: 6 }}>{author}</div>}
|
||||
<div style={{ fontSize: 14.5, lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{body}</div>
|
||||
<div style={{ fontSize: 12, marginTop: 8, color: mine ? N400 : N600 }}>{stamp}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The strip under the app bar saying which order this thread belongs to. */
|
||||
export function ContextStrip({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ background: N200, padding: "10px 16px", fontSize: 12, borderBottom: "2px solid " + INK, color: N700 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Composer({ value, onChange, onSend, busy, placeholder = "Write a message" }: {
|
||||
value: string; onChange: (v: string) => void; onSend: () => void; busy?: boolean; placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); if (value.trim() && !busy) onSend(); }}
|
||||
style={{ display: "flex", flex: "0 0 60px", height: 60, borderTop: "2px solid " + INK, background: "#fff" }}
|
||||
>
|
||||
<input
|
||||
value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}
|
||||
// The placeholder is the only thing naming this box, and it disappears the moment anybody
|
||||
// types — so the same words are given as the label.
|
||||
aria-label={placeholder}
|
||||
style={{ flex: 1, minWidth: 0, border: 0, padding: "0 16px", font: "inherit", fontSize: 15, background: "transparent", color: INK, borderRadius: 0 }}
|
||||
/>
|
||||
<button type="submit" disabled={busy || !value.trim()} style={{
|
||||
flex: "0 0 auto", padding: "0 20px", background: ACCENT, color: "#fff", border: 0, borderRadius: 0,
|
||||
fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em",
|
||||
textTransform: "uppercase", cursor: busy || !value.trim() ? "not-allowed" : "pointer",
|
||||
opacity: busy || !value.trim() ? 0.45 : 1,
|
||||
}}>Send</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- forms ---- */
|
||||
|
||||
/* The id of the heading a NumberedField drew, handed down to whatever grouped control it wraps.
|
||||
*
|
||||
* A group of options needs a name, and the name is already on the screen — "02 SIZE AND QUANTITY".
|
||||
* Passing the id through context rather than as a prop keeps every call site unchanged: the field
|
||||
* knows what it wrote, the control inside it points at that, and nobody has to invent an id at each
|
||||
* of the dozen places these are used. */
|
||||
const FieldLabelId = createContext<string | undefined>(undefined);
|
||||
|
||||
/** `01` in accent, then the label, then the control. Carried over from ThreadCount onboarding,
|
||||
* where the numbering reinforces the counting identity. */
|
||||
export function NumberedField({ n, label, children, first }: { n: number; label: string; children: React.ReactNode; first?: boolean }) {
|
||||
const labelId = useId();
|
||||
return (
|
||||
<div style={{ padding: "18px 16px", borderTop: first ? "none" : "2px solid " + INK }}>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "baseline" }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.1em", color: ACCENT }}>{String(n).padStart(2, "0")}</span>
|
||||
<span id={labelId} style={{ fontSize: 13, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase" }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<FieldLabelId.Provider value={labelId}>{children}</FieldLabelId.Provider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A list of mutually exclusive options at 2px gaps. Unavailable options are shown, greyed —
|
||||
* never hidden, because "it isn't there" is information the person came for.
|
||||
*
|
||||
* Announced as a radio group, because that is what it is: exactly one answer, and the answer was
|
||||
* previously carried by an ink fill and a heavier weight — nothing a screen reader could report,
|
||||
* so every option sounded identical before and after it was chosen. `label` names the group when
|
||||
* a NumberedField holds more than one of these; otherwise the field's own heading names it.
|
||||
* Arrow keys move between options the way a radio group is expected to, and the buttons stay
|
||||
* buttons, so tapping and the Enter key behave exactly as they did. */
|
||||
export function OptionList<T extends string>({ options, value, onPick, columns = 1, label }: {
|
||||
options: { key: T; label: string; meta?: string; disabled?: boolean }[];
|
||||
value: T | null; onPick: (k: T) => void; columns?: number; label?: string;
|
||||
}) {
|
||||
const fieldLabelId = useContext(FieldLabelId);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
||||
const step = e.key === "ArrowDown" || e.key === "ArrowRight" ? 1 : e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 0;
|
||||
if (!step) return;
|
||||
const live = options.filter((o) => !o.disabled);
|
||||
if (live.length < 2) return;
|
||||
e.preventDefault();
|
||||
const at = live.findIndex((o) => o.key === value);
|
||||
// Nothing picked yet: an arrow starts at whichever end it is heading away from.
|
||||
const next = at < 0 ? (step > 0 ? live[0] : live[live.length - 1]) : live[(at + step + live.length) % live.length];
|
||||
onPick(next.key);
|
||||
box.current?.querySelector<HTMLButtonElement>(`[data-opt="${CSS.escape(next.key)}"]`)?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={box}
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
aria-labelledby={label ? undefined : fieldLabelId}
|
||||
onKeyDown={onKeyDown}
|
||||
style={{ display: "grid", gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 2 }}
|
||||
>
|
||||
{options.map((o) => {
|
||||
const on = o.key === value;
|
||||
return (
|
||||
<button
|
||||
key={o.key} data-opt={o.key} role="radio" aria-checked={on}
|
||||
aria-disabled={o.disabled || undefined} disabled={o.disabled}
|
||||
onClick={() => onPick(o.key)}
|
||||
style={{
|
||||
minHeight: 48, padding: "10px 14px", border: 0, borderRadius: 0, textAlign: "left", font: "inherit",
|
||||
background: o.disabled ? N200 : on ? INK : "#fff",
|
||||
color: o.disabled ? N500 : on ? GROUND : N700,
|
||||
fontWeight: on ? 800 : 600, fontSize: 14.5,
|
||||
cursor: o.disabled ? "not-allowed" : "pointer",
|
||||
}}>
|
||||
<span style={{ display: "block" }}>{o.label}</span>
|
||||
{o.meta && <span style={{ display: "block", fontSize: 12, marginTop: 3, opacity: 0.85 }}>{o.meta}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Square, like everything else. */
|
||||
export function Toggle({ on, onChange, label, disabled }: { on: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) {
|
||||
return (
|
||||
<button role="switch" aria-checked={on} aria-label={label} disabled={disabled} onClick={() => onChange(!on)} style={{
|
||||
width: 52, height: 30, padding: 3, border: `2px solid ${INK}`, borderRadius: 0, background: on ? ACCENT : "#fff",
|
||||
display: "flex", justifyContent: on ? "flex-end" : "flex-start", alignItems: "center",
|
||||
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1, flex: "0 0 auto",
|
||||
}}>
|
||||
<span style={{ width: 24, height: 24, background: on ? "#fff" : INK, display: "block" }} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The optional photo. Dashed, because it is the one thing on the screen that isn’t required. */
|
||||
export function PhotoWell({ has, onPick, hint }: { has: boolean; onPick: () => void; hint?: string }) {
|
||||
return (
|
||||
<button onClick={onPick} style={{
|
||||
width: "100%", minHeight: 96, border: `2px dashed ${has ? INK : DIVIDER}`, borderRadius: 0, background: "#fff",
|
||||
display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "center",
|
||||
padding: "16px 14px", cursor: "pointer", font: "inherit", color: INK, gap: 6,
|
||||
}}>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke={ACCENT} strokeWidth="2.2" strokeLinecap="square" aria-hidden>
|
||||
<path d="M3 8h4l2-3h6l2 3h4v11H3z" /><circle cx="12" cy="13" r="3.5" />
|
||||
</svg>
|
||||
<span style={{ fontSize: 15, fontWeight: 800 }}>{has ? "Photo attached" : "Add a photo"}</span>
|
||||
{hint && <span style={{ fontSize: 12.5, color: N600 }}>{hint}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- rows ---- */
|
||||
|
||||
/** A row with an emphasis border. accent = needs attention, divider = neutral, ink = informational. */
|
||||
export function EdgeRow({ tone = "divider", onClick, href, children }: {
|
||||
tone?: "accent" | "divider" | "ink"; onClick?: () => void; href?: string; children: React.ReactNode;
|
||||
}) {
|
||||
const edge = tone === "accent" ? ACCENT : tone === "ink" ? INK : DIVIDER;
|
||||
const st: React.CSSProperties = {
|
||||
display: "block", width: "100%", textAlign: "left", font: "inherit", color: INK,
|
||||
background: "#fff", border: 0, borderLeft: `6px solid ${edge}`, borderRadius: 0,
|
||||
padding: "14px 16px", cursor: onClick || href ? "pointer" : "default", textDecoration: "none",
|
||||
};
|
||||
if (href) return <Link href={href} style={st} className="tcx-bar">{children}</Link>;
|
||||
if (onClick) return <button onClick={onClick} style={st} className="tcx-bar">{children}</button>;
|
||||
return <div style={st}>{children}</div>;
|
||||
}
|
||||
|
||||
/** A muted row — a job already done. */
|
||||
export function DoneRow({ children }: { children: React.ReactNode }) {
|
||||
return <div style={{ background: SURFACE, padding: "14px 16px", color: N600, borderTop: `1px solid ${DIVIDER}` }}>{children}</div>;
|
||||
}
|
||||
|
||||
/** 44px is the floor for anything you tap. */
|
||||
export function CompactAction({ label, onClick, tone = "outline", disabled }: {
|
||||
label: string; onClick?: () => void; tone?: "outline" | "accent"; disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} style={{
|
||||
minHeight: 44, padding: "0 14px", borderRadius: 0, font: "inherit",
|
||||
border: tone === "accent" ? 0 : `2px solid ${INK}`,
|
||||
background: tone === "accent" ? ACCENT : "transparent",
|
||||
color: tone === "accent" ? "#fff" : INK,
|
||||
fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase",
|
||||
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1, flex: "0 0 auto",
|
||||
}}>{label}</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full-width, transparent, 2px ink border. The action you are allowed but not encouraged to take. */
|
||||
export function SecondaryBar({ label, onClick, href, disabled }: { label: string; onClick?: () => void; href?: string; disabled?: boolean }) {
|
||||
const st: React.CSSProperties = {
|
||||
minHeight: 52, width: "100%", border: `2px solid ${INK}`, borderRadius: 0, background: "transparent",
|
||||
color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14,
|
||||
letterSpacing: "0.08em", textTransform: "uppercase", display: "flex", alignItems: "center",
|
||||
padding: "0 20px", gap: 12, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1,
|
||||
textDecoration: "none",
|
||||
};
|
||||
const inner = <><span style={{ flex: 1, textAlign: "left" }}>{label}</span><IconRight /></>;
|
||||
if (href && !disabled) return <Link href={href} style={st} className="tcx-bar">{inner}</Link>;
|
||||
return <button onClick={onClick} disabled={disabled} style={st} className="tcx-bar">{inner}</button>;
|
||||
}
|
||||
|
||||
/** The word a ward is allowed to see. Never a number, and never colour on its own. */
|
||||
export function StockTag({ word }: { word: "in_stock" | "low" | "none" | string }) {
|
||||
const label = word === "in_stock" ? "In stock" : word === "low" ? "Low" : "None on shelf";
|
||||
return (
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase",
|
||||
color: word === "none" ? ACCENT_700 : N700, whiteSpace: "nowrap",
|
||||
}}>{label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- request lines ---- */
|
||||
|
||||
/* One request covers as many garments as the person needed, so six screens draw a list where they
|
||||
* used to print a single bold line. All of it lives here rather than in each screen, for the same
|
||||
* reason the status words live in lib/staffreq: the manager deciding, the wearer reading the
|
||||
* outcome, the desk signing for the bag and the linen room picking it have to describe the same
|
||||
* garments the same way. A declined fleece struck through on one screen and silently missing on
|
||||
* the next is exactly the confusion this flow exists to end.
|
||||
*/
|
||||
|
||||
/** A garment line, however it reached the screen — a saved RequestLine or one being drafted. The
|
||||
* decision fields are optional because nothing has been decided while somebody is still typing. */
|
||||
export type LineLike = {
|
||||
id: string; item: string; size: string; qty: number;
|
||||
status?: string; declineReason?: string | null;
|
||||
};
|
||||
|
||||
/** How a line reads everywhere, in one place. */
|
||||
export function lineText(l: { qty: number; item: string; size: string }): string {
|
||||
return `${l.qty} × ${l.item} — ${l.size}`;
|
||||
}
|
||||
|
||||
/** The lines of a request, as a record. Declines are struck through and carry their reason: a
|
||||
* wearer whose fleece was refused should be able to see that on the order rather than count the
|
||||
* bag and wonder. The approved word only appears on a split decision — where everything was
|
||||
* approved the request's own status has already said so. */
|
||||
export function LineList({ lines }: { lines: readonly LineLike[] }) {
|
||||
const mixed = lines.some((l) => l.status === "declined") && lines.some((l) => l.status === "approved");
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 1, background: DIVIDER }}>
|
||||
{lines.map((l) => {
|
||||
const off = l.status === "declined";
|
||||
return (
|
||||
<div key={l.id} style={{ background: "#fff", padding: "13px 14px" }}>
|
||||
<div style={{
|
||||
fontSize: 15.5, fontWeight: 800, lineHeight: 1.35,
|
||||
textDecoration: off ? "line-through" : "none", color: off ? N600 : INK,
|
||||
}}>{lineText(l)}</div>
|
||||
{off && (
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_700, marginTop: 5 }}>
|
||||
Declined{l.declineReason ? ` — ${l.declineReason}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{!off && mixed && (
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600, marginTop: 5 }}>
|
||||
Approved
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A line on a request nobody has sent yet. `key` is the row's identity while it is being edited —
|
||||
* the same garment in two sizes is two rows, and neither has an id until the server writes one. */
|
||||
export type DraftLine = { key: string; itemId: string; si: number; item: string; size: string; qty: number };
|
||||
|
||||
/** The list somebody is building. Every row can be counted up and down or taken out again, which is
|
||||
* the whole difference between this and the old one-garment form: getting a line wrong costs a tap
|
||||
* rather than a second request and a second approval. */
|
||||
export function DraftLineList({ lines, maxQty, onQty, onRemove }: {
|
||||
lines: readonly DraftLine[]; maxQty: number;
|
||||
onQty: (key: string, qty: number) => void; onRemove: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 2 }}>
|
||||
{lines.map((l) => (
|
||||
<div key={l.key} style={{ background: "#fff", padding: "12px 14px" }}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>
|
||||
{l.item} — {l.size}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRemove(l.key)}
|
||||
aria-label={`Take ${l.item} — ${l.size} off this request`}
|
||||
style={{
|
||||
flex: "0 0 auto", minWidth: 44, minHeight: 44, border: 0, background: "transparent",
|
||||
color: N600, font: "inherit", fontSize: 20, lineHeight: 1, cursor: "pointer", borderRadius: 0,
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<MStepper n={l.qty} onChange={(v) => onQty(l.key, v)} min={1} max={maxQty} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type PickerSize = { size: string; si: number; word: string; countedOn?: string; held?: number };
|
||||
export type PickerItem = { id: string; item: string; type: string; gender?: string; sizes: PickerSize[] };
|
||||
|
||||
/** Choosing one garment to add to a request. Shared by the wearer's own request screen and the one
|
||||
* a manager raises on somebody else's behalf, because the only thing that differs between them is
|
||||
* whose sizes and holdings are being shown — and that arrives as `defaultSi` and `note` rather
|
||||
* than as a second copy of this.
|
||||
*
|
||||
* The garment list is narrowed by a row of category chips — Tops, Bottoms, Maternity, Outerwear,
|
||||
* Everything else. On a real catalogue the flat list is the longest scroll in the flow and it is
|
||||
* walked once per garment, on a request that often runs to three or four.
|
||||
*
|
||||
* Chips rather than a heading over each group, for two reasons. A heading labels a long scroll;
|
||||
* only a filter shortens it, and the length is the complaint. And OptionList's arrow keys walk one
|
||||
* radiogroup, so a separate list per category would trap the arrows in whichever section they
|
||||
* started in — one filtered list keeps the keyboard walking the whole picker.
|
||||
*
|
||||
* The categories come from garmentCategory(), which reads the type already on the garment and
|
||||
* falls back to its name, so this needs no new prop and no data entry: both screens that render
|
||||
* the picker get the grouping without knowing it exists. */
|
||||
export function GarmentPicker<I extends PickerItem>({ items, defaultSi, note, maxQty, addLabel = "Add to the request", onAdd, onCancel }: {
|
||||
items: readonly I[];
|
||||
defaultSi: (item: I) => number | null;
|
||||
note?: (item: I, size: PickerSize | null) => React.ReactNode;
|
||||
maxQty: number;
|
||||
addLabel?: string;
|
||||
onAdd: (line: { itemId: string; si: number; item: string; size: string; qty: number }) => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [itemId, setItemId] = useState<string | null>(null);
|
||||
const [si, setSi] = useState<number | null>(null);
|
||||
const [qty, setQty] = useState(1);
|
||||
const [cat, setCat] = useState<GarmentCategory | "all">("all");
|
||||
|
||||
const item = items.find((i) => i.id === itemId) || null;
|
||||
const size = item && si !== null ? item.sizes.find((s) => s.si === si) || null : null;
|
||||
|
||||
const catOf: Record<string, GarmentCategory> = {};
|
||||
const counts = new Map<GarmentCategory, number>();
|
||||
for (const i of items) {
|
||||
const c = garmentCategory(i);
|
||||
catOf[i.id] = c;
|
||||
counts.set(c, (counts.get(c) || 0) + 1);
|
||||
}
|
||||
// Only the categories something actually falls in: a facility that stocks no maternity wear must
|
||||
// never be shown the word, and an empty chip is a promise of garments that aren't there.
|
||||
const chips = GARMENT_CATEGORIES.filter((c) => counts.has(c.key));
|
||||
/* Below about a screenful there is nothing to shorten, and a filter row over a list you can
|
||||
* already see whole is one more thing to read before you can start. Eight 48px options is
|
||||
* roughly where the list stops fitting on a phone. One category is nothing to filter either. */
|
||||
const filtering = chips.length > 1 && items.length > 8;
|
||||
const shown = filtering && cat !== "all" ? items.filter((i) => catOf[i.id] === cat) : items;
|
||||
|
||||
function pickCat(k: GarmentCategory | "all") {
|
||||
setCat(k);
|
||||
/* A garment half-chosen under the old filter can fall outside the new one, and leaving its
|
||||
* sizes, note and count on screen under a filter that hides the garment itself is the one
|
||||
* thing a filter must not do: the next tap would add something nobody can see. */
|
||||
if (itemId && k !== "all" && catOf[itemId] !== k) { setItemId(null); setSi(null); setQty(1); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 14 }}>
|
||||
{filtering && (
|
||||
<div role="group" aria-label="Show one kind of garment" style={{ display: "flex", flexWrap: "wrap", gap: 2 }}>
|
||||
{[{ key: "all" as const, label: "All", n: items.length }, ...chips.map((c) => ({ key: c.key, label: c.label, n: counts.get(c.key) || 0 }))].map((c) => {
|
||||
const on = c.key === cat;
|
||||
return (
|
||||
<button
|
||||
key={c.key} onClick={() => pickCat(c.key)} aria-pressed={on}
|
||||
style={{
|
||||
minHeight: 44, padding: "0 14px", border: 0, borderRadius: 0, cursor: "pointer",
|
||||
background: on ? INK : "#fff", color: on ? GROUND : N600, font: "inherit",
|
||||
fontWeight: 800, fontSize: 12, letterSpacing: "0.08em", textTransform: "uppercase",
|
||||
}}
|
||||
>{c.label} {c.n}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OptionList
|
||||
label={cat === "all" || !filtering ? "Garment" : `Garment — ${chips.find((c) => c.key === cat)?.label}`}
|
||||
value={itemId}
|
||||
onPick={(id) => {
|
||||
setItemId(id);
|
||||
setQty(1);
|
||||
// Opening on the size the record already knows is the difference between three taps and
|
||||
// one, and the wrong size is what generates the exchange this app exists to stop.
|
||||
const it = items.find((i) => i.id === id);
|
||||
setSi(it ? defaultSi(it) : null);
|
||||
}}
|
||||
options={shown.map((i) => ({
|
||||
key: i.id,
|
||||
label: i.item,
|
||||
meta: [i.type, i.gender && i.gender !== "Unisex" ? i.gender : ""].filter(Boolean).join(" · "),
|
||||
}))}
|
||||
/>
|
||||
|
||||
{item && (
|
||||
<>
|
||||
<OptionList
|
||||
label="Size"
|
||||
columns={3}
|
||||
value={si === null ? null : String(si)}
|
||||
onPick={(k) => setSi(Number(k))}
|
||||
// Unavailable sizes are shown greyed, never hidden: "it isn't there" is the information
|
||||
// the person came for.
|
||||
options={item.sizes.map((s) => ({
|
||||
key: String(s.si),
|
||||
label: String(s.size),
|
||||
meta: [s.word === "none" ? "none" : s.word === "low" ? "low" : "", s.held ? `${s.held} held` : ""].filter(Boolean).join(" · "),
|
||||
}))}
|
||||
/>
|
||||
{note && <div style={{ fontSize: 12.5, color: N600, lineHeight: 1.5 }}>{note(item, size)}</div>}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
|
||||
<MStepper n={qty} onChange={setQty} min={1} max={maxQty} />
|
||||
<span style={{ fontSize: 12.5, color: N600, flex: 1, minWidth: 120, lineHeight: 1.45 }}>
|
||||
{qty === 1 ? "One garment" : `${qty} garments`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 2 }}>
|
||||
<CompactAction
|
||||
label={addLabel}
|
||||
tone="accent"
|
||||
disabled={!item || si === null}
|
||||
onClick={() => {
|
||||
if (!item || !size) return;
|
||||
onAdd({ itemId: item.id, si: size.si, item: item.item, size: String(size.size), qty });
|
||||
// The chosen garment clears for the next one; the category filter deliberately does
|
||||
// not. Somebody adding two tops is still looking at tops.
|
||||
setItemId(null); setSi(null); setQty(1);
|
||||
}}
|
||||
/>
|
||||
{onCancel && <CompactAction label="Cancel" onClick={onCancel} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ACCENT, ACCENT_300, ACCENT_700, DIVIDER, GROUND, INK, N200, N300, N400, N500, N600, N700, SURFACE };
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { label, longLabel, type Item, type Snapshot } from "@/lib/compute";
|
||||
|
||||
/* The menu used to be numbered 01–11 and every page eyebrow repeated its number back — "01 —
|
||||
Overview". The rail's icons carry that ordering now, so the digits were only a second thing to
|
||||
read on a screen already being read from across a linen room. The word stays.
|
||||
Every screen now passes the bare word, so this is a floor rather than the mechanism: one page
|
||||
that came back with its number would be the only page in the app wearing one. */
|
||||
const EYEBROW_NUMBER = /^\s*\d{1,2}\s*[—–-]\s*/;
|
||||
|
||||
export function PageHead({ eyebrow, title, sub, children, below }: { eyebrow: string; title: React.ReactNode; sub?: React.ReactNode; children?: React.ReactNode; below?: React.ReactNode }) {
|
||||
return (
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<div className="eyebrow">{eyebrow.replace(EYEBROW_NUMBER, "")}</div>
|
||||
<h1 className="h1">{title}</h1>
|
||||
{sub && <div style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: "var(--space-1)" }}>{sub}</div>}
|
||||
{below}
|
||||
</div>
|
||||
{children && <div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", flexWrap: "wrap", justifyContent: "flex-end" }}>{children}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sec({ children, right, style }: { children: React.ReactNode; right?: React.ReactNode; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div className="sec" style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 8, ...style }}>
|
||||
<div>{children}</div>
|
||||
{right && <div style={{ fontSize: 12, fontWeight: 400, letterSpacing: 0, textTransform: "none", color: "var(--color-neutral-700)" }}>{right}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The props a Field hands its control. Spread them onto the input/select/textarea. */
|
||||
export type FieldControl = { id: string; "aria-describedby": string | undefined; "aria-invalid": true | undefined };
|
||||
|
||||
/* A labelled form control.
|
||||
*
|
||||
* The pattern this replaces — `<div className="field"><label>Sets</label><input className="input" …/></div>`
|
||||
* — draws a label and leaves it a sibling of the box it names, so nothing connects the two: a
|
||||
* screen reader reaching the input announces "edit text, blank", and clicking the label does not
|
||||
* put the cursor in the field. Field generates one id per instance and wires it as the label's
|
||||
* htmlFor and the control's id, so every consumer gets the association for free rather than having
|
||||
* to invent an id at each of the ninety-odd fields in the product.
|
||||
*
|
||||
* The control comes in as a function because only the consumer knows which element is the one the
|
||||
* label names — some fields draw a button or a hint alongside the input. Grouped controls (a Seg,
|
||||
* a set of radios) are not Fields: a single label cannot name several controls, and they want a
|
||||
* fieldset or role="group" instead.
|
||||
*
|
||||
* The markup is the same div.field the stylesheet already targets, so the visual result is
|
||||
* unchanged; `hint` and `error` only appear when a consumer asks for them, and both are wired into
|
||||
* aria-describedby so they are read out as part of the field rather than as loose text. */
|
||||
export function Field({ label, hint, error, className, style, children }: {
|
||||
label: React.ReactNode; hint?: React.ReactNode; error?: string; className?: string; style?: React.CSSProperties;
|
||||
children: (control: FieldControl) => React.ReactNode;
|
||||
}) {
|
||||
const base = useId();
|
||||
const id = base + "c";
|
||||
const hintId = hint ? base + "h" : undefined;
|
||||
const errId = error ? base + "e" : undefined;
|
||||
const describedBy = [hintId, errId].filter(Boolean).join(" ") || undefined;
|
||||
return (
|
||||
<div className={"field" + (className ? " " + className : "")} style={style}>
|
||||
<label htmlFor={id}>{label}</label>
|
||||
{children({ id, "aria-describedby": describedBy, "aria-invalid": error ? true : undefined })}
|
||||
{hint && <div id={hintId} style={{ fontSize: 11, color: "var(--color-neutral-700)" }}>{hint}</div>}
|
||||
{/* Red on its own says nothing here — the accent is already the primary button an inch below
|
||||
this line. The mark is what carries at a glance; the words are what carry the meaning, so
|
||||
the mark is decoration and stays out of the reading. */}
|
||||
<LiveRegion id={errId} tone="alert" style={{ fontSize: 12, fontWeight: 700, color: "var(--color-accent-700)" }}
|
||||
msg={error ? <><span className="tc-mark" aria-hidden="true" />{error}</> : undefined} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Everything the keyboard can reach inside a dialog, in tab order. getClientRects() is the
|
||||
visibility test rather than offsetParent because a fixed-position control inside the dialog has
|
||||
no offset parent and would otherwise drop out of the cycle. */
|
||||
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
function focusables(root: HTMLElement) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => el.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
/* Drawn here rather than pulled off a CDN, and drawn the way the rail's icons are: 2px strokes,
|
||||
square caps, mitred joins. A rounded × would be the only soft corner in an app built out of 2px
|
||||
square borders. */
|
||||
const CLOSE_ICON = (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square" strokeLinejoin="miter" aria-hidden="true" focusable="false">
|
||||
<path d="M5 5l14 14M19 5L5 19" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* A dialog is chrome, so it wears the chrome's ink: a dark head with the accent rule under it, the
|
||||
same band the rail and the page header carry. What the dialog is *about* — the garment rows, the
|
||||
counted figures, the invoice costs — stays on paper below it, for the same reason the page
|
||||
content did: those numbers get read at arm's length under ward lighting.
|
||||
*
|
||||
* `foot` is the row of buttons. It is a prop rather than the last thing in `children` so the head
|
||||
* and the buttons stay put and only the middle scrolls: Receive delivery on a fourteen-line order
|
||||
* used to push Receive off the bottom of the screen, and someone had to scroll a dialog they had
|
||||
* just finished filling in to find out where the button went. */
|
||||
export function Dialog({ title, width = 560, onClose, children, sub, foot }: { title: React.ReactNode; width?: number; onClose: () => void; children: React.ReactNode; sub?: React.ReactNode; foot?: React.ReactNode }) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
const subId = useId();
|
||||
// Whoever had focus when the dialog was opened gets it back when it closes. Read during render,
|
||||
// not in the effect: by the time an effect runs the dialog is in the page and a field with
|
||||
// autoFocus may already have taken focus off the button that opened it.
|
||||
const opener = useRef<Element | null>(null);
|
||||
if (opener.current === null && typeof document !== "undefined") opener.current = document.activeElement;
|
||||
|
||||
useEffect(() => {
|
||||
const node = box.current;
|
||||
if (!node) return;
|
||||
const opened = opener.current;
|
||||
// "Modal" has to mean something to the keyboard and the screen reader, not just to the eye.
|
||||
// Marking every ancestor's other children `inert` takes the page behind the overlay out of the
|
||||
// tab order and out of the accessibility tree, which is what the dim layer only implies. Doing
|
||||
// it by walking the ancestors keeps the dialog where it is rendered — moving it to a portal
|
||||
// would change which React tree its events bubble through.
|
||||
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); }
|
||||
}
|
||||
}
|
||||
// Focus the dialog itself rather than its first control: the name and the contents get read
|
||||
// out, and nothing is armed by accident. The two dialogs that autoFocus a field have already
|
||||
// moved focus inside by now, so leave those alone.
|
||||
if (!node.contains(document.activeElement)) node.focus();
|
||||
return () => {
|
||||
for (const el of off) el.inert = false;
|
||||
if (opened instanceof HTMLElement && opened.isConnected) opened.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") { onClose(); return; }
|
||||
if (e.key !== "Tab") return;
|
||||
const node = box.current;
|
||||
if (!node) return;
|
||||
// Wrap the tab cycle inside the dialog. `inert` already stops the page behind it from taking
|
||||
// focus, but on a browser without inert this is what keeps Tab from walking out, and it is
|
||||
// what returns Tab at the last control to the first rather than to the browser chrome.
|
||||
const f = focusables(node);
|
||||
if (f.length === 0) { e.preventDefault(); node.focus(); return; }
|
||||
const at = document.activeElement;
|
||||
if (e.shiftKey && (at === f[0] || at === node)) { e.preventDefault(); f[f.length - 1].focus(); }
|
||||
else if (!e.shiftKey && at === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
{/* The stylesheet's .dialog is a padded box that scrolls as a whole. Overridden here to a
|
||||
column that clips, so the three bands below can decide for themselves what scrolls —
|
||||
88vh and the 2px frame still come off the class. */}
|
||||
<div ref={box} className="dialog" style={{ maxWidth: width, padding: 0, display: "flex", flexDirection: "column", overflow: "hidden" }} role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={sub ? subId : undefined} tabIndex={-1}>
|
||||
{/* --tc-* are literal values that no scope remaps, unlike the --color-* tokens the page
|
||||
head reassigns — a dialog can be rendered inside one, and this band has to stay ink
|
||||
either way. */}
|
||||
<div style={{ flex: "0 0 auto", display: "flex", alignItems: "flex-start", gap: "var(--space-3)", background: "var(--tc-ink)", borderBottom: "4px solid var(--color-accent)", padding: "var(--space-4) var(--space-6)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="dialog-title" id={titleId} style={{ color: "var(--tc-on-ink)" }}>{title}</div>
|
||||
{sub && <div id={subId} style={{ fontSize: 12, color: "var(--tc-ink-muted)", marginTop: "var(--space-1)" }}>{sub}</div>}
|
||||
</div>
|
||||
{/* The rail's collapse button wears this class: it is the product's one piece of
|
||||
ink-on-ink chrome, and a dialog head is the same material. Escape and a click outside
|
||||
already close, but neither is discoverable on a shared linen-room PC. */}
|
||||
<button type="button" className="tc-rail-toggle" onClick={onClose} aria-label="Close" title="Close">{CLOSE_ICON}</button>
|
||||
</div>
|
||||
{/* Light on top: every dialog's first element already brings its own top margin — they had
|
||||
to, sitting directly under a title in the old box — and a full gutter here on top of
|
||||
that opens a hole under the accent rule. Enough that a future dialog without one is not
|
||||
printed against the band. */}
|
||||
<div style={{ flex: "1 1 auto", minHeight: 0, overflow: "auto", padding: "var(--space-2) var(--space-6) var(--space-5)" }}>{children}</div>
|
||||
{foot && <div style={{ flex: "0 0 auto", display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "var(--space-2)", flexWrap: "wrap", padding: "var(--space-3) var(--space-6)", borderTop: "2px solid var(--color-text)" }}>{foot}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ children, pad = 6 }: { children: React.ReactNode; pad?: number }) {
|
||||
return <div style={{ padding: `var(--space-${pad}) 0`, fontSize: 13, color: "var(--color-neutral-700)" }}>{children}</div>;
|
||||
}
|
||||
|
||||
/* Something the app has to say back: a save that failed, a save that went through.
|
||||
*
|
||||
* A message that is only painted is silent — nothing about a div that turns up mid-page reaches
|
||||
* anyone who is not looking at it, which is why a failed login used to leave a screen reader user
|
||||
* with an apparently unchanged page. `tone` chooses how much it interrupts: "alert" cuts into
|
||||
* whatever is being read (a failure that has to be acted on), "status" waits for a gap (a result).
|
||||
*
|
||||
* The element is rendered only when there is something to say. A live region that is inserted
|
||||
* along with its text is announced by current screen readers, and keeping an empty one mounted
|
||||
* would count as a flex item wherever one of these sits in a column and open a gap in the layout. */
|
||||
export function LiveRegion({ msg, tone = "status", id, className, style }: { msg?: React.ReactNode; tone?: "status" | "alert"; id?: string; className?: string; style?: React.CSSProperties }) {
|
||||
if (!msg) return null;
|
||||
return <div id={id} className={className} style={style} role={tone} aria-live={tone === "alert" ? "assertive" : "polite"} aria-atomic="true">{msg}</div>;
|
||||
}
|
||||
|
||||
/* A save that would not go through. Marked three ways over — the rule down the left edge, the
|
||||
heavier type, and the mark — because this sits under a form whose primary button is already the
|
||||
same red, and two reds a metre apart across a linen room is a guess rather than a signal. */
|
||||
export function ErrorLine({ msg }: { msg: string }) {
|
||||
if (!msg) return null;
|
||||
return (
|
||||
<LiveRegion tone="alert" className="tc-flag" style={{ marginTop: "var(--space-3)", padding: "var(--space-2) var(--space-3)", fontSize: 13, color: "var(--color-accent-700)", fontWeight: 700 }}
|
||||
msg={<><span className="tc-mark" aria-hidden="true" />{msg}</>} />
|
||||
);
|
||||
}
|
||||
|
||||
export function Notice({ msg }: { msg: string }) {
|
||||
return <LiveRegion msg={msg} className="notice" />;
|
||||
}
|
||||
|
||||
/** Segmented control.
|
||||
*
|
||||
* Which option is chosen was carried by the btn-primary class alone — a fill and a colour, and
|
||||
* nothing at all in the accessibility tree. Read aloud, every segment was an ordinary button and
|
||||
* the one already in force was indistinguishable from the four that would change the screen.
|
||||
* aria-pressed is the honest role for a control that stays down: these swap what the list below
|
||||
* shows rather than navigating anywhere, which is a toggle, not a tab. Every caller already wraps
|
||||
* the row in a labelled role="group". */
|
||||
export function Seg<T extends string>({ opts, value, onChange, style }: { opts: readonly T[]; value: T; onChange: (v: T) => void; style?: React.CSSProperties }) {
|
||||
return <div className="seg" style={style}>{opts.map((o) => <button key={o} aria-pressed={value === o} className={"seg-opt" + (value === o ? " btn-primary" : "")} onClick={() => onChange(o)}>{o}</button>)}</div>;
|
||||
}
|
||||
|
||||
/** Inventory / Stock take sub-tabs shown under the Inventory heading. */
|
||||
export function InvTabs({ active }: { active: "stock" | "take" }) {
|
||||
return (
|
||||
<div className="seg" style={{ marginTop: "var(--space-3)" }}>
|
||||
{/* Links, not buttons — so the one you are on is aria-current, not aria-pressed. Same defect
|
||||
as Seg's: without it the current tab was a fill and nothing more. */}
|
||||
<Link href="/app/stock" aria-current={active === "stock" ? "page" : undefined} className={"seg-opt" + (active === "stock" ? " btn-primary" : "")} style={{ textDecoration: "none", display: "inline-flex", alignItems: "center" }}>Stock on hand</Link>
|
||||
<Link href="/app/stocktake" aria-current={active === "take" ? "page" : undefined} className={"seg-opt" + (active === "take" ? " btn-primary" : "")} style={{ textDecoration: "none", display: "inline-flex", alignItems: "center" }}>Stock take</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* `flag` is how a screen says a figure needs attention — sizes out of stock, a gap that has to be
|
||||
explained before the count can be filed. It never means "paint it red": a flagged tile gets the
|
||||
rule down its edge, the heavier accent-700 figure and the mark, all three off .tc-flag, because
|
||||
the accent is already the primary button on the same screen.
|
||||
A caller that reaches for the accent through `color` instead meant the same thing, so it lands in
|
||||
the same place rather than as the red-only treatment this design is trying to get rid of. */
|
||||
const flagged = (color?: string, flag?: boolean) => flag ?? /accent/.test(color || "");
|
||||
|
||||
/** The figures under a page head. Grid is auto-fit, so three tiles and five both fill the row. */
|
||||
export function KpiStrip({ items }: { items: { val: React.ReactNode; label: string; color?: string; note?: string; flag?: boolean }[] }) {
|
||||
return (
|
||||
<div className="tc-tiles" style={{ marginTop: "var(--space-4)" }}>
|
||||
{items.map((k) => {
|
||||
const on = flagged(k.color, k.flag);
|
||||
return (
|
||||
<div key={k.label} className={"tc-tile" + (on ? " tc-flag" : "")}>
|
||||
<div className="tc-figure" style={on ? undefined : { color: k.color || "var(--color-text)" }}>{on && <span className="tc-mark" aria-hidden="true" />}{k.val}</div>
|
||||
<div className="tc-tile-label">{k.label}</div>
|
||||
{k.note && <div className="tc-tile-note">{k.note}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single figure under a top rule, for a row of them that is not a bordered tile strip. Flagged,
|
||||
* it gains a left rule as well — an L around the figure, which reads as a mark on the page rather
|
||||
* than as a border. */
|
||||
export function Stat({ label, val, color, flag }: { label: string; val: React.ReactNode; color?: string; flag?: boolean }) {
|
||||
const on = flagged(color, flag);
|
||||
return (
|
||||
<div className={on ? "tc-flag" : undefined} style={{ borderTop: "2px solid var(--color-text)", paddingTop: "var(--space-2)", paddingLeft: on ? "var(--space-2)" : undefined }}>
|
||||
<div className="tc-meta">{label}</div>
|
||||
<div className="tc-figure" style={on ? { fontSize: 26 } : { fontSize: 26, color: color || "var(--color-text)" }}>{on && <span className="tc-mark" aria-hidden="true" />}{val}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function itemOptions(s: Snapshot) {
|
||||
return s.catalog.filter((i) => !i.archived).map((it) => ({ v: it.id, label: longLabel(it) + (it.sku ? " · " + it.sku : "") }));
|
||||
}
|
||||
|
||||
/** Item select + one button per size. */
|
||||
/* The sizes default to outlined boxes rather than underlined text. They sit immediately beside the
|
||||
2px select they belong to, they are the thing being aimed at, and two of the four callers had
|
||||
already overridden the underline away — this makes the odd two out the same as the rest. */
|
||||
export function ItemSizePicker({ s, itemId, onItem, onSize, placeholder = "Choose an item…", btnClass = "btn btn-secondary", maxWidth = 320 }: {
|
||||
s: Snapshot; itemId: string; onItem: (id: string) => void; onSize: (it: Item, si: number) => void; placeholder?: string; btnClass?: string; maxWidth?: number;
|
||||
}) {
|
||||
const it = s.catalog.find((x) => x.id === itemId);
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<select className="input" style={{ maxWidth }} value={itemId} onChange={(e) => onItem(e.target.value)}>
|
||||
<option value="">{placeholder}</option>
|
||||
{itemOptions(s).map((o) => <option key={o.v} value={o.v}>{o.label}</option>)}
|
||||
</select>
|
||||
{it && it.sizes.map((sz, si) => (
|
||||
<button key={si} className={btnClass} style={{ minHeight: 26, padding: "2px 8px", justifyContent: "center" }} onClick={() => onSize(it, si)} title={label(it) + " " + sz}>{sz}</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Two square boxes with the count between them. They were ghost buttons, which means .btn-ghost's
|
||||
underline ran under the − and the + and flush-left alignment pushed both glyphs off centre in
|
||||
their own boxes. Outlined at 2px, they match the quantity inputs they sit in a row with. */
|
||||
export function Stepper({ value, onDec, onInc, width = 24 }: { value: number; onDec: () => void; onInc: () => void; width?: number }) {
|
||||
const btn: React.CSSProperties = { padding: "0 9px", minHeight: 26, justifyContent: "center" };
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-1)" }}>
|
||||
<button className="btn btn-secondary" style={btn} onClick={onDec} aria-label="decrease">−</button>
|
||||
<span style={{ width, textAlign: "center", fontFamily: "var(--font-heading)", fontWeight: 800 }}>{value}</span>
|
||||
<button className="btn btn-secondary" style={btn} onClick={onInc} aria-label="increase">+</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const th = (t: string, right = false, extra: React.CSSProperties = {}) => <th key={t} style={{ textAlign: right ? "right" : "left", ...extra }}>{t}</th>;
|
||||
Reference in New Issue
Block a user