Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
/* 1J — Account.
|
||||
*
|
||||
* What a wearer can do to their own sign-in, and one thing they deliberately cannot.
|
||||
*
|
||||
* Changing the password is the only revocation they have: a staff token carries a fingerprint of
|
||||
* the password hash, so setting a new one ends every session signed against the old one at once —
|
||||
* a phone left on a ward, a cookie copied off it, a password read over somebody's shoulder. The
|
||||
* copy says so plainly, because the consequence is the feature and somebody who does not know it
|
||||
* happens will not reach for this when they most need it.
|
||||
*
|
||||
* Deleting the account is not here, and is not an oversight. Access is the linen room's to grant
|
||||
* and theirs to remove: a wearer who could delete their own account would take the record of what
|
||||
* they were issued with it.
|
||||
*/
|
||||
import { DELETE_ACCOUNT_URL, PRIVACY_EMAIL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
|
||||
import { useState } from "react";
|
||||
import { MBar, MBody, MError, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
import { INK, N600, N700 } from "@/components/staffui";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { forgetPush } from "@/lib/staffpush";
|
||||
import NotificationSettings, { type NotifyPrefs } from "@/components/screens/NotificationSettings";
|
||||
|
||||
const field: React.CSSProperties = {
|
||||
width: "100%", minHeight: 52, padding: "0 14px", border: "2px solid var(--color-divider)",
|
||||
borderRadius: 0, font: "inherit", fontSize: 16, background: "#fff", color: "var(--color-text)",
|
||||
};
|
||||
|
||||
const label: React.CSSProperties = {
|
||||
display: "block", fontSize: 12.5, fontWeight: 800, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", color: N600,
|
||||
};
|
||||
|
||||
export default function AccountScreen({ email, prefs, pushReady }: {
|
||||
email: string; prefs: NotifyPrefs; pushReady: boolean;
|
||||
}) {
|
||||
const { me, mutate, busy } = useStaff();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
|
||||
// The server enforces the same floor; checking it here only saves a round trip and a refusal.
|
||||
const ready = current.length > 0 && next.length >= 8;
|
||||
|
||||
/* ⛔ Signing out never waits on push.forget succeeding, and never fails because it didn't.
|
||||
* Somebody on a ward with no signal still has to be able to leave a phone they are handing on.
|
||||
* An orphaned token is reclaimed three other ways — the next registration re-points it, FCM
|
||||
* reports it gone, and a password change clears the lot. */
|
||||
async function signOut() {
|
||||
setLeaving(true);
|
||||
const token = forgetPush();
|
||||
if (token) void mutate("push.forget", { token });
|
||||
await fetch("/api/staff/logout", {
|
||||
method: "POST", headers: { "content-type": "application/json" }, body: "{}",
|
||||
}).catch(() => {});
|
||||
// A full navigation: the cookie has just been cleared and every screen behind it is
|
||||
// server-rendered.
|
||||
window.location.replace("/my/signin");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Account" back backHref="/my" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
<div style={{ padding: "20px 16px 18px", borderBottom: `2px solid ${INK}`, background: "var(--color-bg)" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 26, letterSpacing: "-0.02em", lineHeight: 1.05 }}>
|
||||
{me.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 4 }}>{email}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<MSection label="Notifications" />
|
||||
<NotificationSettings prefs={prefs} configured={pushReady} />
|
||||
|
||||
<MSection label="Sign-in" />
|
||||
{done ? (
|
||||
<div style={{ background: "#fff", borderLeft: `6px solid ${INK}`, padding: "14px 16px", marginTop: 12 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800 }}>Password changed</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "8px 0 0" }}>
|
||||
Every other device signed in as you has been signed out, and any phone of yours set
|
||||
up for notifications has been unregistered. This one stays signed in.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* A disclosure, not a link: the form is on this screen, so the row says so in words
|
||||
a screen reader is given rather than only by what appears underneath it. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 12, width: "100%", minHeight: 60,
|
||||
padding: "9px 0", background: "none", border: 0,
|
||||
borderBottom: "1px solid var(--color-divider)", font: "inherit", color: "inherit",
|
||||
textAlign: "left", cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: "block", fontSize: 15, fontWeight: 700 }}>Change your password</span>
|
||||
<span style={{ display: "block", fontSize: 13, color: N600, marginTop: 1 }}>Signs you out on other devices</span>
|
||||
</span>
|
||||
<span aria-hidden="true" style={{ color: N600, fontSize: 18 }}>{open ? "–" : "›"}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{ padding: "16px 0 4px" }}>
|
||||
{/* The words wrap the box rather than sitting beside it: on the one screen where
|
||||
typing in the wrong one of two password fields is silent, both must announce
|
||||
which they are. */}
|
||||
<label style={{ display: "block" }}>
|
||||
<span style={label}>Current password</span>
|
||||
<input
|
||||
type="password" autoComplete="current-password" value={current}
|
||||
onChange={(e) => { setCurrent(e.target.value); setErr(""); }}
|
||||
style={{ ...field, marginTop: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "block", marginTop: 16 }}>
|
||||
<span style={label}>New password</span>
|
||||
<input
|
||||
type="password" autoComplete="new-password" value={next}
|
||||
onChange={(e) => { setNext(e.target.value); setErr(""); }}
|
||||
style={{ ...field, marginTop: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
|
||||
At least 8 characters. Changing it signs you out everywhere else straight away.
|
||||
This device stays signed in.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MSection label="Privacy" />
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "12px 0 4px" }}>
|
||||
ThreadCount holds your sign-in and the linen room’s record of what you have been
|
||||
issued. You can’t delete this account from here — ask your uniform coordinator and
|
||||
they can remove it{PRIVACY_EMAIL ? <>, or write to {PRIVACY_EMAIL}</> : null}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{PRIVACY_URL && <MRow href={PRIVACY_URL} external mark="ink" title="Privacy policy" sub="What ThreadCount stores, and what it never does" />}
|
||||
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external mark="ink" title="Deleting your account" sub="How it is done, and what goes with it" />}
|
||||
{TERMS_URL && <MRow href={TERMS_URL} external mark="ink" title="Terms of use" sub="What you and ThreadCount each agree to" />}
|
||||
|
||||
{/* The one thing a wearer can do to a phone they no longer have, so it is findable without
|
||||
asking — and at the foot, because it is the last thing anybody comes here to do. */}
|
||||
<div style={{ padding: "22px 16px 0" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
style={{
|
||||
minHeight: 52, width: "100%", border: `2px solid ${INK}`, borderRadius: 0,
|
||||
background: "transparent", color: INK, font: "inherit",
|
||||
fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14,
|
||||
letterSpacing: "0.05em", textTransform: "uppercase", display: "flex",
|
||||
alignItems: "center", justifyContent: "center", padding: "0 14px",
|
||||
cursor: leaving ? "wait" : "pointer",
|
||||
}}
|
||||
>{leaving ? "Signing out…" : "Sign out"}</button>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 24 }} />
|
||||
</MBody>
|
||||
|
||||
{open && !done && (
|
||||
<MBar
|
||||
label={busy ? "Saving…" : "Change my password"}
|
||||
glyph="check"
|
||||
disabled={!ready || busy}
|
||||
onClick={async () => {
|
||||
const r = await mutate("account.password", { current, next });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setCurrent(""); setNext(""); setOpen(false); setDone(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
/* Team ▸ Approvals — the queue, and the decision, on one screen.
|
||||
*
|
||||
* Oldest first, deliberately. The queue's job is to surface the person who has been waiting
|
||||
* longest, and a newest-first list quietly buries them — which is the failure mode this whole
|
||||
* flow exists to fix.
|
||||
*
|
||||
* Approving now happens in the list: the commonest decision by far is "yes, all of it", and making
|
||||
* somebody open a screen to say so cost a tap on every request and taught them to open and approve
|
||||
* without reading either. Open is still there for the decision that needs looking at — a single
|
||||
* garment knocked back, which only the review screen can do.
|
||||
*
|
||||
* A request the manager is the wearer of can reach this queue. Whoever a request names is who
|
||||
* decides it, and the server has been allowed to let that be the wearer themselves in the one case
|
||||
* the owner named. The server owns that decision and this screen never re-tests it. What the screen
|
||||
* owns is the honesty: an approval somebody gives themselves is set apart from the ones they give
|
||||
* on other people's behalf, and says what the record will call it afterwards.
|
||||
*
|
||||
* This queue also reaches people who manage nobody — the linen room re-addresses a request that
|
||||
* arrived without an approver, or somebody's last report moves away while their own request is
|
||||
* still waiting. They have a queue, so they have a Team tab (lib/staffreq.ts teamTabs).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MEmpty, MError } from "@/components/m";
|
||||
import { EdgeRow, N600, N700 } from "@/components/staffui";
|
||||
import Team, { Band, ChipAction, ChipRow } from "./Team";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { daysBetween, facilityDate, facilityToday } from "@/lib/compute";
|
||||
import type { QueueRow } from "@/lib/managerdata";
|
||||
|
||||
/* Calendar days on the ward, not elapsed 24-hour blocks.
|
||||
*
|
||||
* Dividing the milliseconds understated every overnight wait: a request raised at 18:00 on Monday
|
||||
* still read "today" at 09:00 on Tuesday and only became "since yesterday" that evening, by which
|
||||
* point it had spanned two working days. This queue exists to surface the person who has been
|
||||
* waiting longest, so the label counts the way they do, in the facility's own zone. */
|
||||
function waited(iso: string, tz: string) {
|
||||
const raised = facilityDate(iso, tz);
|
||||
if (!raised) return ""; // the row's meta line filters empties out
|
||||
const days = daysBetween(raised, facilityToday(tz));
|
||||
if (days <= 0) return "today";
|
||||
if (days === 1) return "since yesterday";
|
||||
return `waiting ${days} days`;
|
||||
}
|
||||
|
||||
export default function ApprovalsScreen({ rows, ownIds = [] }: { rows: QueueRow[]; ownIds?: string[] }) {
|
||||
const { me, mutate, busy } = useStaff();
|
||||
const [err, setErr] = useState("");
|
||||
/* What just happened, for the person who pressed the button. The row itself leaves the list on
|
||||
* the refresh, and a list that silently gets shorter is not an answer. There is no toast in this
|
||||
* app — components/m.tsx's useToast is a documented no-op outside the counter's provider — so it
|
||||
* is said here, in a live region. */
|
||||
const [done, setDone] = useState("");
|
||||
|
||||
/* Which of these are the manager's own is settled on the server, from the request's own subject.
|
||||
Working it out here by matching a name would start calling a stranger's request yours the day
|
||||
two people on the register share one — and the thing being labelled is an audit fact. */
|
||||
const own = new Set(ownIds);
|
||||
const mine = rows.filter((r) => own.has(r.id));
|
||||
const theirs = rows.filter((r) => !own.has(r.id));
|
||||
|
||||
async function approveAll(r: QueueRow) {
|
||||
setErr("");
|
||||
setDone("");
|
||||
// The op decides garment by garment and wants a call for every line by id — approving from the
|
||||
// queue is approving all of them, said explicitly rather than by omission.
|
||||
const res = await mutate("request.approve", {
|
||||
id: r.id,
|
||||
lines: r.lines.map((l) => ({ id: l.id, decision: "approved", reason: "" })),
|
||||
});
|
||||
if (!res.ok) { setErr(res.error); return; }
|
||||
setDone(`Approved · ${r.subjectName.split(" ")[0]} has been told`);
|
||||
}
|
||||
|
||||
function Row({ r, own: isOwn }: { r: QueueRow; own: boolean }) {
|
||||
return (
|
||||
/* An ink edge rather than the accent one every other row carries. Accent means somebody else
|
||||
is waiting on you; your own uniform is not that, and it must not be able to pass for it at
|
||||
a glance on a phone held in one hand halfway down a ward. */
|
||||
<EdgeRow tone={isOwn ? "ink" : "accent"}>
|
||||
{/* The person, and nothing beside them. The code belongs to the request rather than to the
|
||||
decision, and it is on the review screen's own title where somebody who has come from
|
||||
the approval e-mail will look for it. */}
|
||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{r.subjectName}</div>
|
||||
{/* The whole ask in one line — "3 garments · Tunic, Trousers". The garments themselves are
|
||||
on the review screen, which is where a line is refused; a queue that listed every one
|
||||
would bury the person who has been waiting longest under somebody else's four. */}
|
||||
<div style={{ fontSize: 14.5, color: N600, marginTop: 5, lineHeight: 1.4 }}>{r.summary}</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 3, lineHeight: 1.4 }}>
|
||||
{[r.reason, r.subjectGroup, waited(r.createdAt, me.tz)].filter(Boolean).join(" · ")}
|
||||
{r.raisedByName ? ` · raised by ${r.raisedByName}` : ""}
|
||||
</div>
|
||||
<ChipRow>
|
||||
<ChipAction
|
||||
label={`Approve ${r.garments}`}
|
||||
disabled={busy}
|
||||
onClick={() => void approveAll(r)}
|
||||
/>
|
||||
<ChipAction label="Open" href={`/my/approvals/${r.id}`} />
|
||||
</ChipRow>
|
||||
</EdgeRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Team active="/my/approvals">
|
||||
<div role="status" aria-live="polite">
|
||||
{done && (
|
||||
<div style={{ padding: "12px 16px", background: "#fff", borderBottom: "1px solid var(--color-divider)", fontSize: 14, fontWeight: 800 }}>
|
||||
{done}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<MEmpty
|
||||
title="Nothing waiting on you"
|
||||
sub="Requests from your team arrive by notification and email, and land here."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
|
||||
{theirs.map((r) => <Row key={r.id} r={r} own={false} />)}
|
||||
</div>
|
||||
|
||||
{/* The manager's own go last, whatever date they were raised. Oldest first is a promise to
|
||||
the colleague who has been waiting longest, and a request for your own uniform does not
|
||||
step in front of her — and a group at the foot, under its own heading, is not somewhere
|
||||
a thumb arrives by accident on the way down the list of your team's. */}
|
||||
{mine.length > 0 && (
|
||||
<>
|
||||
<Band tone="attention" label={mine.length === 1 ? "Your own request" : "Your own requests"} />
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "12px 16px 0", margin: 0 }}>
|
||||
Approving {mine.length === 1 ? "it" : "them"} is recorded as your own approval.
|
||||
</p>
|
||||
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
|
||||
{mine.map((r) => <Row key={r.id} r={r} own />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
|
||||
Nothing reaches the linen room until you approve it.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div style={{ height: 12 }} />
|
||||
</Team>
|
||||
);
|
||||
}
|
||||
@@ -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,43 @@
|
||||
"use client";
|
||||
/* 1G — the collection code, full screen and nothing else.
|
||||
*
|
||||
* This is held up across a counter, often at arm's length, so everything the order screen carries
|
||||
* is something to read past: no app bar, no accent rule, no tab bar. The wearer's name and what is
|
||||
* in the bag sit under the digits, because a clerk hands a bag to a person, not to a number.
|
||||
*
|
||||
* Done is a real link back to the order, never history.back() — the screen is opened cold at least
|
||||
* three ways (a notification tap, a refresh on a ward phone, an app link) and in each of them there
|
||||
* is no history to pop, so a back-button Done walks somebody out of the app holding an unread code.
|
||||
* FullCode takes the href for exactly that reason.
|
||||
*/
|
||||
import { MStyles } from "@/components/m";
|
||||
import { FullCode, lineText } from "@/components/staffui";
|
||||
import { useKeepAwake } from "@/lib/wakelock";
|
||||
|
||||
export default function CodeFullScreen({ id, code, name, lines }: {
|
||||
id: string; code: string; name: string; lines: { item: string; size: string; qty: number }[];
|
||||
}) {
|
||||
/* Keep the screen alight while the code is up.
|
||||
*
|
||||
* There is no brightness control on this platform — neither the WebView nor the Capacitor shell
|
||||
* offers one — so the honest version of "make it readable across a counter" is the Screen Wake
|
||||
* Lock API: it is what stops Android dimming and then locking the phone in the time it takes to
|
||||
* reach the front of the queue. A no-op where it isn't supported, and the digits are already
|
||||
* drawn as large as the screen allows.
|
||||
*/
|
||||
useKeepAwake(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The furniture styles are normally injected by MTop, which this screen deliberately does
|
||||
not have; without them the Done link loses its focus ring. */}
|
||||
<MStyles />
|
||||
<FullCode
|
||||
code={code}
|
||||
name={name}
|
||||
lines={lines.map(lineText)}
|
||||
backHref={`/my/orders/${id}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
/* Report damage. Two jobs on one screen: tell the linen room, and start the replacement.
|
||||
*
|
||||
* The two are deliberately separate acts. Reporting damage does not issue anything and does not
|
||||
* silently remove the garment — it comes off the record when it is handed in at the counter. A
|
||||
* screen that wrote off a garment on somebody's say-so would be a screen the linen room stops
|
||||
* trusting, and a screen that quietly issued a replacement would route around the manager.
|
||||
*
|
||||
* `Contaminated` is not in the list. Clinically it is a different pathway — red bag, no return to
|
||||
* the counter — and telling someone to carry a contaminated garment to the linen room would be
|
||||
* worse than saying nothing. Wards use the route they already have.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MBar, MBody, MChipRow, MError, MRule, MSwitchRow, MTop } from "@/components/m";
|
||||
import { DarkCard, N600, N700, NumberedField, OptionList } from "@/components/staffui";
|
||||
import Sent from "@/components/screens/Sent";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { DAMAGE_KINDS } from "@/lib/staffreq";
|
||||
|
||||
type Holding = {
|
||||
issueId: string; itemId: string; item: string; size: string; si: number; qty: number;
|
||||
labelId: string; issued: string; replacement: string;
|
||||
};
|
||||
|
||||
export default function DamageScreen({ holdings, managerName, notifyWays }: {
|
||||
holdings: Holding[]; managerName: string; notifyWays: { email: boolean; push: boolean };
|
||||
}) {
|
||||
const { mutate, busy } = useStaff();
|
||||
const [issueId, setIssueId] = useState<string | null>(null);
|
||||
const [kind, setKind] = useState<string | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
/* The switch defaults on, because asking for a replacement is what almost everybody reporting a
|
||||
* torn tunic actually wants — but only when there is somebody to ask. With no manager recorded
|
||||
* the request half cannot be raised at all, so defaulting it on left people tapping "Report and
|
||||
* request" and getting an error they could do nothing about. */
|
||||
const canRequest = !!managerName;
|
||||
const [replace, setReplace] = useState(canRequest);
|
||||
const [err, setErr] = useState("");
|
||||
/* The server's sentence for a report it saved without a replacement behind it — almost always a
|
||||
* garment whose range has been withdrawn, which cannot be ordered but is still on somebody's
|
||||
* back. Nothing failed, so it is not an error; but the screen used to go straight to the kit
|
||||
* list on a plain success and the nurse walked away expecting a replacement nobody had ordered. */
|
||||
const [noReplacement, setNoReplacement] = useState("");
|
||||
/* Where a finished report lands. A replacement was raised, so this is the request screen's own
|
||||
* Sent — same approver, same "what happens next" — or, with no replacement asked for, the same
|
||||
* screen saying the one thing that is still true: it comes off the record at the counter. */
|
||||
const [done, setDone] = useState<{ what: string; order: { id: string; code: string; manager: string; notified: boolean } | null } | null>(null);
|
||||
|
||||
const held = holdings.find((h) => h.issueId === issueId) || null;
|
||||
const ready = !!held && !!kind;
|
||||
|
||||
if (noReplacement) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Reported" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: 16 }}>
|
||||
<DarkCard kicker="Reported" title="No replacement has been ordered" meta={noReplacement} />
|
||||
</div>
|
||||
</MBody>
|
||||
<MBar label="Back to your kit" href="/my/kit" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
const o = done.order;
|
||||
const who = o?.manager || managerName || "your manager";
|
||||
return o ? (
|
||||
<Sent
|
||||
headline={`Sent to ${who}`}
|
||||
sub={`${done.what} · ${o.code}`}
|
||||
next={
|
||||
notifyWays.push
|
||||
? "You get a notification when it is approved, and again when it is ready."
|
||||
: o.notified
|
||||
? `${who} has been emailed.`
|
||||
: `It is waiting with ${who}.`
|
||||
}
|
||||
actions={[
|
||||
{ label: "Open the order", href: `/my/orders/${o.id}` },
|
||||
{ label: "Back to home", href: "/my" },
|
||||
]}
|
||||
bar={{ label: "Back to home", href: "/my" }}
|
||||
/>
|
||||
) : (
|
||||
<Sent
|
||||
title="Reported"
|
||||
headline="Reported to the linen room"
|
||||
sub={done.what}
|
||||
next="It comes off your record when you hand it in at the counter."
|
||||
actions={[{ label: "Back to your kit", href: "/my/kit" }]}
|
||||
bar={{ label: "Back to home", href: "/my" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Report damage" back backHref="/my/kit" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<NumberedField n={1} label="Which item" first>
|
||||
{holdings.length === 0 ? (
|
||||
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>
|
||||
Nothing on your record to report.
|
||||
</p>
|
||||
) : (
|
||||
<OptionList
|
||||
value={issueId}
|
||||
onPick={(k) => { setIssueId(k); setErr(""); }}
|
||||
options={holdings.map((h) => ({
|
||||
key: h.issueId,
|
||||
label: `${h.item} — ${h.size}`,
|
||||
// The label id is what the linen room reads off the garment in their hand, so a
|
||||
// row here can be matched to a physical thing.
|
||||
meta: `${h.labelId} · issued ${h.issued}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</NumberedField>
|
||||
|
||||
{/* Both steps stay on screen from the start, as the mockup draws them. Revealing "what
|
||||
happened" only after a garment is chosen hid half the job from somebody deciding whether
|
||||
this screen was the one they wanted. */}
|
||||
<NumberedField n={2} label="What happened">
|
||||
<MChipRow
|
||||
label="What happened"
|
||||
value={kind}
|
||||
onPick={(k) => { setKind(k); setErr(""); }}
|
||||
options={DAMAGE_KINDS.map((d) => ({ value: d, label: d }))}
|
||||
/>
|
||||
<textarea
|
||||
value={note} onChange={(e) => setNote(e.target.value)} rows={3}
|
||||
aria-label="Anything the linen room should know (optional)"
|
||||
placeholder="Anything the linen room should know (optional)"
|
||||
style={{ width: "100%", minHeight: 84, marginTop: 14, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 16, resize: "none", background: "#fff", color: "var(--color-text)" }}
|
||||
/>
|
||||
</NumberedField>
|
||||
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<MSwitchRow
|
||||
title="Ask for a replacement too"
|
||||
sub={canRequest
|
||||
? `Goes to ${managerName} with the report`
|
||||
: "Nobody is recorded as your approver yet — ask the linen room to set your manager"}
|
||||
on={replace && canRequest}
|
||||
onToggle={() => setReplace((v) => !v)}
|
||||
disabled={!canRequest}
|
||||
/>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
|
||||
The damaged item comes off your record when you hand it in at the counter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
<MBar
|
||||
label={busy ? "Sending…" : replace && canRequest ? "Report and request" : "Report it"}
|
||||
disabled={!ready || busy}
|
||||
offReason="Pick the item and what happened"
|
||||
onClick={async () => {
|
||||
if (!held || !kind) return;
|
||||
/* `replacement` is the whole request.create result at run time — id, code, manager and
|
||||
* whether an email actually left the server — because damage.report raises the
|
||||
* replacement through that op and hands back what it returned. Every field past the id
|
||||
* is optional here: the app in somebody's pocket can be older or newer than the server
|
||||
* it is talking to, and a missing one only costs a line of the confirmation. */
|
||||
const r = await mutate<{
|
||||
replacement: { id: string; code?: string; manager?: string; notified?: boolean } | null;
|
||||
replacementNote?: string;
|
||||
}>("damage.report", { issueId: held.issueId, kind, note, replace: replace && canRequest });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
const why = (r.result.replacementNote || "").trim();
|
||||
if (!r.result.replacement && why) { setNoReplacement(why); return; }
|
||||
const rep = r.result.replacement;
|
||||
setDone({
|
||||
what: `${held.item} — ${held.size}`,
|
||||
order: rep ? {
|
||||
id: rep.id, code: rep.code || "", manager: rep.manager || managerName, notified: !!rep.notified,
|
||||
} : null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
/* 2C — Raise for someone you manage.
|
||||
*
|
||||
* A manager standing next to somebody who will not type it in themselves: they pick the person,
|
||||
* build the list, and the record says both names. The ward desk once had a screen of its own for
|
||||
* the same job, on the grounds that half a ward would never install anything; it is gone, because
|
||||
* the manager is already the person that ward would have asked.
|
||||
*
|
||||
* The rule that shapes it: **nobody approves their own raise.** The approver is the subject's
|
||||
* manager — which here is the person typing — so the server sends it a level up, and this screen
|
||||
* says so by name before the button is pressed. A manager who could approve a request they typed
|
||||
* themselves would be no approval at all.
|
||||
*/
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { MBar, MBody, MError, MRule, MTop, inputStyle } from "@/components/m";
|
||||
import {
|
||||
ACCENT_300, DarkCard, DraftLineList, EdgeRow, GarmentPicker, GROUND, INK, N500, N600,
|
||||
NumberedField, OptionList, type DraftLine,
|
||||
} from "@/components/staffui";
|
||||
import Team, { Band } from "./Team";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { REQUEST_REASONS, statusText } from "@/lib/staffreq";
|
||||
import type { ReqRow } from "@/lib/staffdata";
|
||||
|
||||
type Person = {
|
||||
id: string; name: string; num: string; group: string; hasApp: boolean;
|
||||
recordedTop: string; recordedPants: string;
|
||||
held: Record<string, number>; lastSizes: Record<string, string>;
|
||||
};
|
||||
type Size = { size: string; si: number; word: string; countedOn: string };
|
||||
type Item = { id: string; item: string; type: string; gender: string; sizes: Size[]; recorded: string; isTop: boolean; isPant: boolean };
|
||||
|
||||
export default function DeskScreen({ people, items, raised, maxLines, maxQty }: {
|
||||
/** The people who report to whoever is raising, which is exactly the set the server accepts. */
|
||||
people: Person[]; items: Item[];
|
||||
/** What they have raised for other people and not yet seen the end of. */
|
||||
raised: ReqRow[];
|
||||
maxLines: number; maxQty: number;
|
||||
}) {
|
||||
const { mutate, busy } = useStaff();
|
||||
const [q, setQ] = useState("");
|
||||
const [personId, setPersonId] = useState<string | null>(null);
|
||||
const [lines, setLines] = useState<DraftLine[]>([]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [reason, setReason] = useState<string | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [sent, setSent] = useState<{ id: string; code: string; manager: string; escalated: boolean } | null>(null);
|
||||
|
||||
const person = people.find((p) => p.id === personId) || null;
|
||||
const garments = lines.reduce((n, l) => n + l.qty, 0);
|
||||
const full = lines.length >= maxLines;
|
||||
const picking = adding || lines.length === 0;
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
if (!needle) return people.slice(0, 8);
|
||||
return people.filter((p) => p.name.toLowerCase().includes(needle) || p.num.toLowerCase().includes(needle)).slice(0, 12);
|
||||
}, [people, q]);
|
||||
|
||||
function pick(id: string) {
|
||||
setPersonId(id);
|
||||
setErr("");
|
||||
setLines([]);
|
||||
setAdding(false);
|
||||
}
|
||||
|
||||
/* The result list is a radio group, so it has to answer the arrow keys.
|
||||
*
|
||||
* A screen reader in forms mode announces it as "radio group, 1 of 8", and a manager standing
|
||||
* next to the nurse they are raising for presses Down to reach her — having been told it is a
|
||||
* radio group, they have no reason to try Tab. Without this they sit on the first name and give
|
||||
* up. Two halves make it work: one tab stop into the group (the roving tabIndex below), and the
|
||||
* arrows moving inside it. Moving also selects, the way a radio group does everywhere else,
|
||||
* which clears any draft lines exactly as clicking a different name always has. */
|
||||
const radios = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const activeIdx = Math.max(0, matches.findIndex((p) => p.id === personId));
|
||||
|
||||
function moveTo(i: number) {
|
||||
const next = matches[i];
|
||||
if (!next) return;
|
||||
pick(next.id);
|
||||
radios.current[i]?.focus();
|
||||
}
|
||||
|
||||
function onResultKey(e: React.KeyboardEvent) {
|
||||
if (!matches.length) return;
|
||||
const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
|
||||
const back = e.key === "ArrowUp" || e.key === "ArrowLeft";
|
||||
if (!fwd && !back && e.key !== "Home" && e.key !== "End") return;
|
||||
e.preventDefault(); // otherwise the arrows scroll the list out from under the focused name
|
||||
if (e.key === "Home") return moveTo(0);
|
||||
if (e.key === "End") return moveTo(matches.length - 1);
|
||||
// Nothing chosen yet — or the search has moved on past whoever was — so the first press lands
|
||||
// on the first match rather than skipping it.
|
||||
const at = matches.findIndex((p) => p.id === personId);
|
||||
if (at < 0) return moveTo(0);
|
||||
moveTo((at + (fwd ? 1 : -1) + matches.length) % matches.length);
|
||||
}
|
||||
|
||||
/* Their recorded size, not the clerk's guess. Tops and trousers carry one on the register; for
|
||||
* everything else — a fleece, a vest, a dress — the size of the last one they were issued is all
|
||||
* the record knows, and it is a far better opening bid than an empty grid. */
|
||||
function sizeFor(it: Item): string {
|
||||
if (!person) return "";
|
||||
if (it.isPant) return person.recordedPants;
|
||||
if (it.isTop) return person.recordedTop;
|
||||
return person.lastSizes[it.id] || "";
|
||||
}
|
||||
|
||||
function add(l: { itemId: string; si: number; item: string; size: string; qty: number }) {
|
||||
setErr("");
|
||||
setLines((cur) => {
|
||||
// The server sums duplicate lines before it writes them, so two identical rows on the screen
|
||||
// would be showing something that cannot be saved.
|
||||
const at = cur.findIndex((x) => x.itemId === l.itemId && x.si === l.si);
|
||||
if (at >= 0) {
|
||||
const next = [...cur];
|
||||
next[at] = { ...next[at], qty: Math.min(maxQty, next[at].qty + l.qty) };
|
||||
return next;
|
||||
}
|
||||
return [...cur, { ...l, key: `${l.itemId}:${l.si}:${cur.length}` }];
|
||||
});
|
||||
setAdding(false);
|
||||
}
|
||||
|
||||
const ready = !!person && lines.length > 0;
|
||||
|
||||
/* Where it actually went.
|
||||
*
|
||||
* A manager raising for their own report is approving nothing: the server sends it up a level,
|
||||
* or leaves it for the linen room to address when there is nobody above them. Either way the
|
||||
* person who typed it needs to be told, by name, rather than dropped on an order screen that
|
||||
* says "awaiting approval" and leaves them to work out whose. */
|
||||
if (sent) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Raised" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: 16 }}>
|
||||
<DarkCard
|
||||
kicker={sent.code}
|
||||
title={sent.manager ? `With ${sent.manager}` : "Nobody approves this yet"}
|
||||
meta={sent.manager
|
||||
? (sent.escalated
|
||||
? "You approve their requests, so this went up a level — nobody approves their own raise."
|
||||
: "They have been told, and it stays on your list until it is done.")
|
||||
: "There is nobody above you on the register, so the linen room will address it to an approver."}
|
||||
>
|
||||
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, lineHeight: 1.55, color: N500 }}>
|
||||
You’ll see the outcome here and under Raised in your orders.
|
||||
</div>
|
||||
</DarkCard>
|
||||
</div>
|
||||
</MBody>
|
||||
<MBar label="See the order" glyph="arrow" href={`/my/orders/${sent.id}`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* The bar lives below the scrolling body, so the shell is handed it rather than it being drawn
|
||||
inside the list, where it would scroll away with the garments it is about to send. */
|
||||
const sendBar = (
|
||||
<MBar
|
||||
label={busy ? "Sending…" : "Send for approval"}
|
||||
sub={person && lines.length ? `${garments} garment${garments === 1 ? "" : "s"} for ${person.name.split(" ")[0]}` : undefined}
|
||||
disabled={!ready || busy}
|
||||
onClick={async () => {
|
||||
if (!person || !lines.length) return;
|
||||
const r = await mutate<{ id: string; code: string; manager: string; escalated: boolean }>("request.create", {
|
||||
subjectId: person.id,
|
||||
lines: lines.map((l) => ({ itemId: l.itemId, si: l.si, qty: l.qty })),
|
||||
reason: reason || "", note,
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
// A raise that went somewhere other than the obvious place — up a level, or to nobody at
|
||||
// all — is worth a screen of its own. Anything ordinary goes straight to the order, which
|
||||
// is where the person who typed it will come looking for it.
|
||||
if (r.result.escalated || !r.result.manager) {
|
||||
setSent({ id: r.result.id, code: r.result.code, manager: r.result.manager, escalated: r.result.escalated });
|
||||
return;
|
||||
}
|
||||
window.location.assign(`/my/orders/${r.result.id}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Team active="/my/raise" foot={sendBar}>
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "-0.01em", lineHeight: 1.2 }}>
|
||||
Raise for your team
|
||||
</div>
|
||||
{/* The one rule that shapes this screen: a manager who could approve what they typed
|
||||
themselves would be no approval at all. */}
|
||||
<p style={{ fontSize: 14, lineHeight: 1.5, color: N600, margin: "4px 0 0" }}>
|
||||
Goes above you, not to you — your own manager approves it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NumberedField n={1} label="Who is it for" first>
|
||||
{/* The NumberedField heading names the step, not this box, so the box says what it is
|
||||
itself — a placeholder disappears the moment anyone types into it. */}
|
||||
<input
|
||||
value={q} onChange={(e) => { setQ(e.target.value); setErr(""); }}
|
||||
aria-label="Search by name or staff number"
|
||||
placeholder="Name or staff number" autoComplete="off"
|
||||
style={{ ...inputStyle, width: "100%" }}
|
||||
/>
|
||||
<div role="radiogroup" aria-label="Search results" onKeyDown={onResultKey} style={{ display: "grid", gap: 2, marginTop: 12 }}>
|
||||
{matches.map((p, i) => {
|
||||
const on = p.id === personId;
|
||||
return (
|
||||
<button key={p.id} role="radio" aria-checked={on}
|
||||
ref={(el) => { radios.current[i] = el; }}
|
||||
// One tab stop for the whole group: Tab reaches the chosen name (or the first
|
||||
// one), and the arrows move between them from there.
|
||||
tabIndex={i === activeIdx ? 0 : -1}
|
||||
onClick={() => pick(p.id)}
|
||||
style={{
|
||||
textAlign: "left", padding: "12px 14px", border: 0, borderRadius: 0, font: "inherit",
|
||||
background: on ? INK : "#fff", color: on ? GROUND : INK, cursor: "pointer", minHeight: 48,
|
||||
}}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800 }}>{p.name}</div>
|
||||
<div style={{ fontSize: 12.5, marginTop: 3, opacity: 0.85 }}>{[p.num, p.group].filter(Boolean).join(" · ")}</div>
|
||||
{on && !p.hasApp && (
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300, marginTop: 6 }}>
|
||||
No app — you’ll have to pass the outcome on
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{matches.length === 0 && (
|
||||
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>Nobody on your team matches that.</p>
|
||||
)}
|
||||
</div>
|
||||
</NumberedField>
|
||||
|
||||
{person && (
|
||||
<NumberedField n={2} label={`What ${person.name.split(" ")[0]} needs`}>
|
||||
{lines.length > 0 && (
|
||||
<div style={{ marginBottom: picking ? 14 : 0 }}>
|
||||
<DraftLineList
|
||||
lines={lines}
|
||||
maxQty={maxQty}
|
||||
onQty={(k, q2) => setLines((cur) => cur.map((l) => (l.key === k ? { ...l, qty: q2 } : l)))}
|
||||
onRemove={(k) => setLines((cur) => cur.filter((l) => l.key !== k))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{picking ? (
|
||||
<GarmentPicker
|
||||
items={items}
|
||||
maxQty={maxQty}
|
||||
addLabel={lines.length ? "Add it" : "Add to the request"}
|
||||
onCancel={lines.length ? () => setAdding(false) : undefined}
|
||||
defaultSi={(it) => {
|
||||
const want = sizeFor(it);
|
||||
return it.sizes.find((s) => String(s.size) === String(want))?.si ?? null;
|
||||
}}
|
||||
note={(it) => {
|
||||
const rec = sizeFor(it);
|
||||
const holds = person.held[it.id] || 0;
|
||||
return [
|
||||
rec ? `Recorded size ${rec}` : "No size on record for this one",
|
||||
holds ? `holds ${holds}` : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
}}
|
||||
onAdd={add}
|
||||
/>
|
||||
) : full ? (
|
||||
<p style={{ fontSize: 13, color: N600, lineHeight: 1.55, margin: "12px 0 0" }}>
|
||||
That is {maxLines} lines — as much as one request carries. Send this one and raise
|
||||
another for anything else.
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setAdding(true); setErr(""); }}
|
||||
style={{
|
||||
width: "100%", minHeight: 52, marginTop: 2, border: `2px solid ${INK}`, borderRadius: 0,
|
||||
background: "transparent", color: INK, font: "inherit", fontWeight: 800, fontSize: 13,
|
||||
letterSpacing: "0.06em", textTransform: "uppercase", textAlign: "left", padding: "0 16px", cursor: "pointer",
|
||||
}}
|
||||
>Add another garment</button>
|
||||
)}
|
||||
|
||||
{lines.length > 0 && !picking && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<OptionList
|
||||
label="Why"
|
||||
columns={2}
|
||||
value={reason}
|
||||
onPick={setReason}
|
||||
options={REQUEST_REASONS.map((r) => ({ key: r, label: r }))}
|
||||
/>
|
||||
<textarea
|
||||
value={note} onChange={(e) => setNote(e.target.value)} rows={2}
|
||||
aria-label="Anything the linen room should know (optional)"
|
||||
placeholder="Anything the linen room should know (optional)"
|
||||
style={{ width: "100%", minHeight: 68, marginTop: 14, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 15, resize: "none", background: "#fff", color: "var(--color-text)" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</NumberedField>
|
||||
)}
|
||||
|
||||
{/* What they have already raised for other people. Without this the manager who was told
|
||||
"you'll see the outcome" had nowhere to see it: every list in this app starts from the
|
||||
wearer, and a request raised for somebody else belongs to none of them. */}
|
||||
{raised.length > 0 && (
|
||||
<>
|
||||
<Band label="Raised by you · still open" />
|
||||
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
|
||||
{raised.map((r) => {
|
||||
const st = statusText(r, { mine: false, first: r.subjectName?.split(" ")[0] });
|
||||
return (
|
||||
<EdgeRow key={r.id} tone={st.ink === "attention" ? "accent" : "divider"} href={`/my/orders/${r.id}`}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
|
||||
<span style={{ flex: 1, fontSize: 16, fontWeight: 800 }}>{r.subjectName}</span>
|
||||
<span style={{ fontSize: 12, color: N600 }}>{r.code}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 14.5, fontWeight: 600, marginTop: 5, lineHeight: 1.35 }}>{r.summary}</div>
|
||||
<div style={{ fontSize: 12.5, color: N600, marginTop: 4 }}>
|
||||
{[st.label, st.note].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</EdgeRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<div style={{ height: 12 }} />
|
||||
</Team>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
/* 1A — Home.
|
||||
*
|
||||
* Two questions, in this order: what is on the way, and what you hold. Everything else is a
|
||||
* shortcut one row lower. There is no list of orders here on purpose — that is the Orders tab, and
|
||||
* a home screen that tried to be both would be neither.
|
||||
*
|
||||
* The one banner at the top is the only place on this screen where somebody else is blocked until
|
||||
* this person acts. At most one is ever drawn: a screen with two things shouting at once has
|
||||
* nothing at the top.
|
||||
*/
|
||||
import { MBody, MRow, MSection, MTopAction, MTopBrand } from "@/components/m";
|
||||
import {
|
||||
AlertBar, DarkButton, DarkCard, DarkRow, EdgeRow, IdentityBlock, Kicker, N600, Notice, QuickGrid,
|
||||
} from "@/components/staffui";
|
||||
import StaffNav from "@/components/staffnav";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { statusText } from "@/lib/staffreq";
|
||||
import type { ReqRow } from "@/lib/staffdata";
|
||||
|
||||
type Data = {
|
||||
name: string; num: string; ward: string; group: string; facility: string;
|
||||
hasManager: boolean; wardDesk: boolean;
|
||||
holding: number; live: ReqRow | null; openCount: number; notice: string;
|
||||
/** Open requests this person raised for somebody else — never their own. */
|
||||
raisedOpen: ReqRow[];
|
||||
lastRequest: { itemId: string; si: number; reason: string; code: string } | null;
|
||||
};
|
||||
|
||||
export type HoldRow = { item: string; size: string; qty: number; last: string };
|
||||
|
||||
const plural = (n: number, w: string) => `${n} ${n === 1 ? w : w + "s"}`;
|
||||
|
||||
export default function HomeScreen({ data, held, lastItem, managerName, kitCheckOpen }: {
|
||||
data: Data;
|
||||
/** Up to three, most recently issued first. The total is the section's own note. */
|
||||
held: HoldRow[];
|
||||
/** The garment on the last request, for the "Same again" tile. Null when they never asked. */
|
||||
lastItem: string | null;
|
||||
managerName: string;
|
||||
kitCheckOpen: boolean;
|
||||
}) {
|
||||
const { me, counts } = useStaff();
|
||||
const live = data.live;
|
||||
const st = live ? statusText(live) : null;
|
||||
|
||||
/* At most one banner, approvals first.
|
||||
*
|
||||
* Driven by the count of requests ADDRESSED to this person, never by "is a manager": the linen
|
||||
* room can re-address a request to somebody who manages nobody, and a manager's last report can
|
||||
* move away while their request is still waiting. Gating on the role would take this banner away
|
||||
* from the one person who has to act on it. A desk that is also an approver keeps its bags on
|
||||
* the Team tab's badge. */
|
||||
const banner = counts.approvals > 0
|
||||
? { title: `${plural(counts.approvals, "request")} waiting on you`, href: "/my/approvals" }
|
||||
: me.wardDesk && counts.round > 0 && data.ward
|
||||
? { title: `${plural(counts.round, "bag")} to sign on ${data.ward}`, href: "/my/round" }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sign out has left this screen. It lives on Account, with the password — where people look
|
||||
for it, and where the one thing a wearer can do to a phone they no longer have belongs. */}
|
||||
<MTopBrand facility={data.facility} right={<MTopAction label="Account" href="/my/account" />} />
|
||||
<MBody>
|
||||
{banner && <AlertBar title={banner.title} href={banner.href} />}
|
||||
|
||||
<IdentityBlock ward={data.ward} num={data.num} name={data.name} group={data.group} />
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
<MSection label="On the way" />
|
||||
{live && st ? (
|
||||
/* The bag furthest along — ready beats out on the round beats waiting on a manager.
|
||||
* One request covers as many garments as the person needed, so the card leads with the
|
||||
* summary and the list itself is a tap away on the order. */
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<DarkCard kicker={st.label} title={live.summary} meta={[st.note, live.code].filter(Boolean).join(" · ")}>
|
||||
{live.status === "ready" && live.collectCode ? (
|
||||
<>
|
||||
<DarkRow label="Collection code" value={live.collectCode} />
|
||||
<DarkButton label="Show at the counter" href={`/my/orders/${live.id}/code`} />
|
||||
</>
|
||||
) : (
|
||||
<DarkButton label="Open this order" href={`/my/orders/${live.id}`} />
|
||||
)}
|
||||
</DarkCard>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "26px 0" }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 800 }}>Nothing on the way</div>
|
||||
<div style={{ fontSize: 14, color: N600, marginTop: 2, lineHeight: 1.45 }}>
|
||||
{data.hasManager && managerName
|
||||
? `Ask for something and it goes to ${managerName} first.`
|
||||
: "Your manager isn’t recorded yet — the linen room has to set who approves your requests."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MSection label="You hold" right={plural(data.holding, "garment")} />
|
||||
{held.length > 0 ? held.map((h) => (
|
||||
<MRow
|
||||
key={`${h.item}:${h.size}`}
|
||||
title={`${h.item} — ${h.size}`}
|
||||
sub={`Last issued ${h.last}`}
|
||||
right={`×${h.qty}`}
|
||||
/>
|
||||
)) : (
|
||||
<div style={{ padding: "18px 0", fontSize: 14, color: N600 }}>Nothing on your record yet.</div>
|
||||
)}
|
||||
|
||||
{/* 2×2. "Same again" is drawn only when there is a last request to repeat: a shortcut
|
||||
that opens an empty screen is worse than no shortcut. Swapping a size has moved to
|
||||
My kit, which is where a wearer reads the size that is wrong. */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<QuickGrid
|
||||
items={[
|
||||
{ label: "Request an item", caption: "Ask for", href: "/my/request" },
|
||||
...(data.lastRequest && lastItem
|
||||
? [{ label: "Same again", caption: `Last: ${lastItem}`, href: "/my/request?again=1" }]
|
||||
: []),
|
||||
{ label: "Report damage", caption: "Torn, stained, worn", href: "/my/damage" },
|
||||
{ label: "What is on the shelf", caption: "Words, not counts", href: "/my/shelf" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* A chore with a deadline, not an alert: dressing it as one would devalue the banner.
|
||||
The title is word for word the heading on the screen it opens. */}
|
||||
{kitCheckOpen && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<EdgeRow tone="accent" href="/my/kitcheck">
|
||||
<div style={{ fontSize: 15, fontWeight: 800 }}>Kit check is open</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 2 }}>Have you still got everything on your record?</div>
|
||||
</EdgeRow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What they raised for other people, as one row rather than a list. Until Orders grew a
|
||||
Raised tab a request typed in for a colleague vanished the moment it was sent. */}
|
||||
{data.raisedOpen.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<EdgeRow tone="ink" href="/my/orders?tab=raised">
|
||||
<Kicker>Raised by you</Kicker>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, marginTop: 6, lineHeight: 1.3 }}>
|
||||
{data.raisedOpen.length === 1
|
||||
? `One request open for ${data.raisedOpen[0].subjectName}`
|
||||
: `${data.raisedOpen.length} requests open for other people`}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 2 }}>Not yours to collect — this is where they got to.</div>
|
||||
</EdgeRow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Somebody who will not type a request themselves asks the person who approves it. The
|
||||
server sends anything a manager raises up a level, which is why the row can say so
|
||||
plainly — approving your own raise is the one thing this must never allow. */}
|
||||
{me.isManager && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<EdgeRow tone="ink" href="/my/raise">
|
||||
<div style={{ fontSize: 15, fontWeight: 800 }}>Raise for someone you manage</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 2 }}>Goes to your own manager, not to you.</div>
|
||||
</EdgeRow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The round is listed by ward, so a clerk whose ward was never filled in has no round to
|
||||
open. Saying so beats a banner that never appears and a tap that 404s. */}
|
||||
{data.wardDesk && !data.ward && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<DarkCard kicker="Ward desk" title="No ward on your record" meta="Ask the linen room to record which ward you are on." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.notice && <Notice>{data.notice}</Notice>}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 20 }} />
|
||||
</MBody>
|
||||
<StaffNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
/* 1B — My kit.
|
||||
*
|
||||
* The linen room's record of what this person holds, shown to the person it is about, and a way
|
||||
* to say it is wrong. "This isn't right" is deliberately blunt and deliberately last: the record
|
||||
* is usually correct, and a dispute button placed first would invite one before anybody had read
|
||||
* the list.
|
||||
*
|
||||
* The bar is a screen-level one, drawn on ALL THREE segments rather than only on Holding. A wrong
|
||||
* recorded *size* is one of the commonest things anybody queries, and that is read on My sizes —
|
||||
* where the screen literally says "tell them below", a promise only kept while the bar is there.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MBody, MEmpty, MError, MRow, MRule, MTop } from "@/components/m";
|
||||
import { INK, N600, SecondaryBar, Segments, SlipCard } from "@/components/staffui";
|
||||
import StaffNav from "@/components/staffnav";
|
||||
import { useDraft, useStaff } from "@/lib/staffclient";
|
||||
import { fmtDate } from "@/lib/compute";
|
||||
|
||||
type Held = { itemId: string; item: string; size: string; si: number; qty: number; last: string };
|
||||
/* One row per garment and size with its quantity — the slip as it was signed, not one repeated row
|
||||
* per garment. The grouping is done in kitData(); the screen only draws it. */
|
||||
type Slip = { id: string; date: string; lines: { item: string; size: string; qty: number }[]; signed: boolean };
|
||||
type Data = { held: Held[]; total: number; handedBackThisYear: number; fyFrom: string; sizes: { top: string; pants: string }; slips?: Slip[] };
|
||||
|
||||
export default function KitScreen({ data }: { data: Data }) {
|
||||
const { mutate, busy } = useStaff();
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<"holding" | "sizes" | "slips">("holding");
|
||||
const [disputing, setDisputing] = useState(false);
|
||||
// Kept across a dropped send and a screen change: ward wifi drops mid-sentence, and the one thing
|
||||
// worse than a complaint that did not send is a complaint that did not send and is gone.
|
||||
const draft = useDraft("dispute");
|
||||
const [err, setErr] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const slips = data.slips || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="My kit" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<Segments
|
||||
label="What to show"
|
||||
value={tab}
|
||||
onPick={setTab}
|
||||
options={[{ key: "holding" as const, label: "Holding" }, { key: "sizes" as const, label: "My sizes" }, { key: "slips" as const, label: "Slips" }]}
|
||||
/>
|
||||
|
||||
{tab === "holding" && (
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
{data.held.length === 0 ? (
|
||||
<MEmpty title="Nothing on your record" sub="Anything the linen room issues you shows up here." />
|
||||
) : (
|
||||
data.held.map((h) => (
|
||||
<MRow
|
||||
key={`${h.itemId}:${h.si}`}
|
||||
title={`${h.item} — ${h.size}`}
|
||||
sub={`Last issued ${fmtDate(h.last)}`}
|
||||
right={`×${h.qty}`}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{/* A row, not the paragraph this used to be: it is one figure about their own record,
|
||||
and it reads as one line beside the garments it belongs with. */}
|
||||
<MRow title="Handed back this year" right={String(data.handedBackThisYear)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "sizes" && (
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<MRow title="Top" right={data.sizes.top || "not recorded"} />
|
||||
<MRow title="Trouser" right={data.sizes.pants || "not recorded"} />
|
||||
<div style={{ padding: "18px 0 6px", fontSize: 14, color: N600, lineHeight: 1.5 }}>
|
||||
The linen room records these. Tell them below if they are wrong.
|
||||
</div>
|
||||
{/* Swapping a size is a request like any other, so it goes to the request screen rather
|
||||
than living as its own flow. This is the door it is reached by. */}
|
||||
<MRow title="Swap a size" sub="Hand one back, ask for another" href="/my/request?swap=1" chev />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "slips" && (
|
||||
slips.length === 0 ? (
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<MEmpty title="No signed slips" sub="A slip arrives when you sign for a hand-over at the counter." />
|
||||
</div>
|
||||
) : (
|
||||
slips.map((sl) => (
|
||||
<div key={sl.id}>
|
||||
<SlipCard
|
||||
date={fmtDate(sl.date)}
|
||||
lines={sl.lines}
|
||||
sigSrc={sl.signed ? `/api/staff/slip/${encodeURIComponent(sl.id)}/sig` : undefined}
|
||||
/>
|
||||
{/* A slip with no signature stored is not a broken screen, and saying so is kinder
|
||||
than an empty space where a signature obviously belongs. */}
|
||||
{!sl.signed && (
|
||||
<div style={{ padding: "0 16px 14px", background: "#fff", fontSize: 12.5, color: N600 }}>
|
||||
Handed over at the counter · no signature on file
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)
|
||||
)}
|
||||
|
||||
{sent && (
|
||||
<div style={{ margin: 16, background: INK, color: "var(--color-bg)", padding: 16, fontSize: 14, lineHeight: 1.5 }}>
|
||||
Sent. The linen room will look at your record and come back to you.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{disputing && !sent && (
|
||||
<div style={{ padding: 16, borderTop: "2px solid " + INK }}>
|
||||
<label htmlFor="tc-dispute" style={{ display: "block", fontSize: 13, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase" }}>What doesn’t look right?</label>
|
||||
<textarea
|
||||
id="tc-dispute"
|
||||
value={draft.value} onChange={(e) => { draft.set(e.target.value); setErr(""); }} rows={4}
|
||||
placeholder="e.g. I handed two tunics back in August but 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 || !draft.value.trim()}
|
||||
onClick={async () => {
|
||||
const r = await mutate("dispute.raise", { body: draft.value });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setSent(true); setDisputing(false); draft.clear(); router.refresh();
|
||||
}}
|
||||
style={{ minHeight: 48, padding: "0 20px", background: "var(--color-accent)", color: "#fff", border: 0, borderRadius: 0, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: busy ? "wait" : "pointer", opacity: busy || !draft.value.trim() ? 0.45 : 1 }}
|
||||
>{busy ? "Sending…" : "Send to the linen room"}</button>
|
||||
<button onClick={() => { setDisputing(false); setErr(""); }}
|
||||
style={{ minHeight: 48, padding: "0 20px", background: "transparent", color: INK, border: "2px solid " + INK, borderRadius: 0, font: "inherit", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
{!disputing && !sent && <SecondaryBar label="This isn’t right" onClick={() => setDisputing(true)} />}
|
||||
<StaffNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
/* 2A — Kit check. Every few months, reconcile the record with reality, item by item.
|
||||
*
|
||||
* "Nothing here is chargeable" is not reassurance for its own sake — a check that felt like an
|
||||
* audit would be answered with whatever number keeps somebody out of trouble, and the resulting
|
||||
* data would be worse than no data.
|
||||
*
|
||||
* Writing off and re-requesting stay separate acts. Saying a garment is missing does not quietly
|
||||
* order another one: that would turn an honest answer into a request somebody's manager has to
|
||||
* decline, which is exactly how you teach a ward to stop answering honestly.
|
||||
*
|
||||
* Between rounds this is a STATE, not a refusal. A refusal explains nothing by design, and "no kit
|
||||
* check is open" is not a secret — it is the answer somebody who tapped a notification from last
|
||||
* autumn needs, with the one way onward under it.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MBar, MBody, MEmpty, MError, MONO, MRule, MTop } from "@/components/m";
|
||||
import { ACCENT_300, DIVIDER, GROUND, INK, N300, N600, N700 } from "@/components/staffui";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { fmtDate } from "@/lib/compute";
|
||||
|
||||
type Row = { itemId: string; item: string; size: string; si: number; onRecord: number; answered: number | null };
|
||||
|
||||
export default function KitCheckScreen({ closed, dueBy = "", lastConfirmed = "", rows = [] }: {
|
||||
/** No cycle open. The screen still exists; it just has one thing to say. */
|
||||
closed?: boolean;
|
||||
dueBy?: string; lastConfirmed?: string; rows?: Row[];
|
||||
}) {
|
||||
const { mutate, busy } = useStaff();
|
||||
const [answers, setAnswers] = useState<Record<string, number>>(
|
||||
Object.fromEntries(rows.filter((r) => r.answered !== null).map((r) => [`${r.itemId}:${r.si}`, r.answered as number])),
|
||||
);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [err, setErr] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const answeredCount = rows.filter((r) => answers[`${r.itemId}:${r.si}`] !== undefined).length;
|
||||
|
||||
async function answer(r: Row, confirmed: number) {
|
||||
const k = `${r.itemId}:${r.si}`;
|
||||
setAnswers((a) => ({ ...a, [k]: confirmed }));
|
||||
setErr("");
|
||||
const res = await mutate("kit.answer", { itemId: r.itemId, si: r.si, onRecord: r.onRecord, confirmed });
|
||||
if (!res.ok) {
|
||||
setErr(res.error);
|
||||
setAnswers((a) => { const n = { ...a }; delete n[k]; return n; });
|
||||
}
|
||||
}
|
||||
|
||||
// aria-pressed, because the only other thing separating the chosen answer from the unchosen one
|
||||
// is an ink fill: nothing a screen reader can hear, and nothing at all in high contrast.
|
||||
const pick = (on: boolean, label: string, onClick: () => void): React.ReactNode => (
|
||||
<button onClick={onClick} aria-pressed={on} style={{
|
||||
flex: 1, minWidth: 0, minHeight: 44, borderRadius: 0, font: "inherit",
|
||||
border: `2px solid ${on ? INK : DIVIDER}`,
|
||||
background: on ? INK : "#fff", color: on ? GROUND : N700,
|
||||
fontWeight: 700, fontSize: 14, cursor: "pointer",
|
||||
}}>{label}</button>
|
||||
);
|
||||
|
||||
if (closed) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Kit check" back backHref="/my" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<MEmpty title="No kit check is open" sub="The linen room opens one every few months. You get a notification." />
|
||||
</div>
|
||||
</MBody>
|
||||
<MBar label="Back to home" href="/my" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Kit check" back backHref="/my" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ background: INK, color: GROUND, padding: 16 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: ACCENT_300 }}>Thanks</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 22, lineHeight: 1.15, marginTop: 4 }}>
|
||||
That’s your record confirmed.
|
||||
</div>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.5, color: N300, marginTop: 4 }}>
|
||||
The linen room will square anything short. Nothing is charged.
|
||||
</div>
|
||||
</div>
|
||||
</MBody>
|
||||
<MBar label="Back to home" href="/my" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Kit check" back backHref="/my" right={dueBy ? `Due ${fmtDate(dueBy)}` : undefined} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
{/* The ink header carries the question and the one rule that decides how honestly it gets
|
||||
answered. Nobody keeps their uniform at work — it is at home, in the wash, in a bag in
|
||||
the boot — so the ask is to count what they still have, wherever it is. */}
|
||||
<div style={{ background: INK, color: GROUND, padding: "14px 16px 16px" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 22, letterSpacing: "-0.015em", lineHeight: 1.05 }}>
|
||||
Have you still got everything on your record?
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#d6d3d2", marginTop: 4, lineHeight: 1.45 }}>
|
||||
Count everything you still have, wherever it is. Nothing here is chargeable.
|
||||
{lastConfirmed ? ` Last confirmed ${fmtDate(lastConfirmed)}.` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<MEmpty title="Nothing on your record to check." sub="Anything the linen room issues you shows up here." />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
{rows.map((r) => {
|
||||
const k = `${r.itemId}:${r.si}`;
|
||||
const a = answers[k];
|
||||
const short = a !== undefined && a < r.onRecord;
|
||||
const open = expanded[k];
|
||||
return (
|
||||
<div key={k} style={{
|
||||
borderBottom: `1px solid ${DIVIDER}`, padding: "10px 0 12px",
|
||||
...(short ? { borderLeft: "6px solid var(--color-accent)", paddingLeft: 12, marginLeft: -12 } : null),
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, alignItems: "baseline" }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700 }}>{r.item} — {r.size}</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: 14 }} aria-label={`${r.onRecord} on record`}>×{r.onRecord}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
||||
{/* "All 3 here" was an answer only somebody standing in front of the garments
|
||||
could give, and nobody is: they are at home, half of them in the wash. */}
|
||||
{pick(a === r.onRecord, `Got all ${r.onRecord}`, () => { setExpanded((e) => ({ ...e, [k]: false })); void answer(r, r.onRecord); })}
|
||||
{pick(short || !!open, short ? (a === 0 ? "None left" : `Only ${a}`) : "Fewer", () => setExpanded((e) => ({ ...e, [k]: !e[k] })))}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
|
||||
{Array.from({ length: r.onRecord }, (_, i) => i).map((n) => (
|
||||
<button key={n} aria-pressed={a === n} onClick={() => { void answer(r, n); setExpanded((e) => ({ ...e, [k]: false })); }} style={{
|
||||
minHeight: 46, minWidth: 50, padding: "0 12px", borderRadius: 0, font: "inherit",
|
||||
border: `2px solid ${a === n ? INK : DIVIDER}`,
|
||||
background: a === n ? INK : "#fff", color: a === n ? GROUND : N700,
|
||||
fontWeight: 700, fontSize: 14, cursor: "pointer",
|
||||
}}>{n === 0 ? "None left" : `Only ${n}`}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{short && (
|
||||
/* This used to say the missing ones "come off your record". They don't — the
|
||||
answer is written down and that is all it does; only the linen room can
|
||||
change the record. Telling somebody it has already been corrected, when it
|
||||
hasn't, is how they stop believing the next thing this screen says. */
|
||||
<div style={{ fontSize: 13, lineHeight: 1.5, color: N700, marginTop: 8 }}>
|
||||
{r.onRecord - (a as number)} unaccounted for. The linen room will square your record.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
{rows.length === 0 ? (
|
||||
<MBar label="Back to home" href="/my" />
|
||||
) : (
|
||||
<MBar
|
||||
label={busy ? "Saving…" : "Confirm"}
|
||||
small={`${answeredCount} of ${rows.length} answered`}
|
||||
disabled={busy || answeredCount < rows.length}
|
||||
onClick={() => setDone(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
/* 1F — Messages. Every conversation with the linen room, newest word first.
|
||||
*
|
||||
* There is no inbox and no direct messaging in this product: a message always hangs off the request
|
||||
* it is about, which is what stops it becoming a chat app nobody staffs. So this screen is a list of
|
||||
* requests that have been talked about — not a mailbox — and under it the orders that could be
|
||||
* talked about but haven't been yet.
|
||||
*
|
||||
* The tab that opens this used to be a button that pushed /my/orders: a control among links, absent
|
||||
* from any list of the app's navigation, and a tab that lied about where it went. It is a link now
|
||||
* (components/staffnav.tsx) and this is the screen behind it.
|
||||
*/
|
||||
import { MBody, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
import { EdgeRow, N600 } from "@/components/staffui";
|
||||
import StaffNav from "@/components/staffnav";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { statusText } from "@/lib/staffreq";
|
||||
import { addDays, facilityDate, facilityToday, formatInZone } from "@/lib/compute";
|
||||
import type { ReqRow, ThreadRow } from "@/lib/staffdata";
|
||||
|
||||
/* How long a line of somebody else's message is allowed to be before the row stops being a row.
|
||||
* The mockup's 54 characters, kept as a number so the truncation and the ellipsis agree. */
|
||||
const PREVIEW = 54;
|
||||
const preview = (body: string) => {
|
||||
const one = body.replace(/\s+/g, " ").trim();
|
||||
return one.length > PREVIEW ? one.slice(0, PREVIEW) + "…" : one;
|
||||
};
|
||||
|
||||
/* The right-hand stamp: a time today, a weekday this week, a date before that.
|
||||
*
|
||||
* Formatted in the facility's zone on both sides of hydration for the same reason the thread's own
|
||||
* separators are — this screen is server-rendered and then hydrated, and a stamp read off the
|
||||
* server's ambient zone is a different string in the browser, which React logs and a nurse sees
|
||||
* flicker. */
|
||||
function stamp(iso: string, tz: string): string {
|
||||
const day = facilityDate(iso, tz);
|
||||
const today = facilityToday(tz);
|
||||
if (!day) return "";
|
||||
if (day === today) return formatInZone(iso, tz, { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
if (day >= addDays(today, -6)) return formatInZone(iso, tz, { weekday: "short" });
|
||||
return formatInZone(iso, tz, { day: "numeric", month: "short" });
|
||||
}
|
||||
|
||||
export default function MessagesScreen({ threads, startable }: { threads: ThreadRow[]; startable: ReqRow[] }) {
|
||||
const { me } = useStaff();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Messages" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
{threads.length === 0 ? (
|
||||
/* Nothing to show, not a refusal: there is a way out of it and the screen says what it
|
||||
is. A dead end with no explanation is reserved for a record that isn't yours. */
|
||||
<div style={{ padding: "26px 16px" }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 800 }}>No messages yet</div>
|
||||
<div style={{ fontSize: 14, color: N600, marginTop: 2 }}>Ask about any order and the thread starts here.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
|
||||
{threads.map((t) => {
|
||||
const st = statusText({ status: t.status }, { mine: t.mine, first: t.subjectName.split(" ")[0] });
|
||||
return (
|
||||
<EdgeRow
|
||||
key={t.id}
|
||||
/* Accent only where the linen room has said something nobody here has opened.
|
||||
Everything else keeps the ink edge, so the colour means one thing. */
|
||||
tone={t.unread ? "accent" : "ink"}
|
||||
href={`/my/orders/${t.id}/messages`}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: "block", fontSize: 15, fontWeight: 800 }}>
|
||||
{/* Whose uniform it is only needs saying when it isn't yours — a manager or
|
||||
a clerk reading a thread on a request they typed in for somebody else. */}
|
||||
{t.mine ? "Linen room" : `${t.subjectName} · you raised this`}
|
||||
</span>
|
||||
<span style={{ display: "block", fontSize: 13, color: N600, marginTop: 2, lineHeight: 1.45 }}>
|
||||
{preview(t.last.body)}
|
||||
</span>
|
||||
<span style={{ display: "block", fontSize: 13, color: N600, marginTop: 2, lineHeight: 1.45 }}>
|
||||
{t.code} · {st.label}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ flex: "0 0 auto", fontFamily: "var(--font-plex-mono), 'IBM Plex Mono', ui-monospace, monospace", fontSize: 13, color: N600, whiteSpace: "nowrap" }}>
|
||||
{stamp(t.last.at, me.tz)}
|
||||
</span>
|
||||
</div>
|
||||
</EdgeRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open orders only. A question about a bag collected in March is a question for the
|
||||
counter, and listing every order this person has ever had would bury the three that are
|
||||
actually moving. */}
|
||||
{startable.length > 0 && (
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<MSection label="Start one" />
|
||||
{startable.map((r) => (
|
||||
<MRow key={r.id} title={r.code} sub={r.summary} chev href={`/my/orders/${r.id}/messages`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
<StaffNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
/* The Notifications section of Account: one switch per kind of thing this person can be told about.
|
||||
*
|
||||
* Two rules shape the whole thing.
|
||||
*
|
||||
* The phone is asked for permission when somebody turns the FIRST switch on, never at launch —
|
||||
* Android 13+ policy, and plain manners. A wearer who never wants to be told is never asked.
|
||||
*
|
||||
* "Something waiting on you" is offered to anybody a request can be addressed to, which is not the
|
||||
* same as "managers": the linen room can re-address one to somebody who manages nobody, and that
|
||||
* person most needs telling — and, having been told, is entitled to silence it.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { MError, MSwitchRow } from "@/components/m";
|
||||
import { N600 } from "@/components/staffui";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { askForPush, pushShell, rememberedToken, type PushShell } from "@/lib/staffpush";
|
||||
|
||||
export type NotifyPrefs = { approved: boolean; ready: boolean; round: boolean; kitcheck: boolean; waiting: boolean };
|
||||
type Key = keyof NotifyPrefs;
|
||||
|
||||
const KINDS: { key: Key; title: string }[] = [
|
||||
{ key: "approved", title: "Approved or declined" },
|
||||
{ key: "ready", title: "Ready to collect" },
|
||||
{ key: "round", title: "On the ward round" },
|
||||
{ key: "kitcheck", title: "Kit check opens" },
|
||||
];
|
||||
|
||||
export default function NotificationSettings({ prefs, configured }: { prefs: NotifyPrefs; configured: boolean }) {
|
||||
const { me, counts, mutate } = useStaff();
|
||||
const [on, setOn] = useState<NotifyPrefs>(prefs);
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState<Key | null>(null);
|
||||
/* Settled after mount: the server cannot know whether this is the app or a browser, and a line
|
||||
* about where notifications arrive that changed on hydration would be worse than one that waits
|
||||
* a beat. "old" is a shell from before the notification bridge existed — see lib/staffpush.ts. */
|
||||
const [shell, setShell] = useState<PushShell>("none");
|
||||
useEffect(() => { setShell(pushShell()); }, []);
|
||||
const bridge = shell === "ready";
|
||||
|
||||
/* The waiting switch belongs to whoever a request can be ADDRESSED to, which is what the queue,
|
||||
* the badge and the Team tab all key on. `counts.team` is the wrong number: it adds the bags on
|
||||
* a ward desk, so a clerk who manages nobody was offered a switch for a notification the sender
|
||||
* only ever writes to `r.managerId` — it could never fire, and it vanished off their Account
|
||||
* screen the moment they signed for the bags. */
|
||||
const rows = me.isManager || counts.approvals > 0
|
||||
? [...KINDS, { key: "waiting" as Key, title: "Something waiting on you" }]
|
||||
: KINDS;
|
||||
|
||||
async function toggle(k: Key) {
|
||||
if (busy) return;
|
||||
const before = on;
|
||||
const next = { ...on, [k]: !on[k] };
|
||||
setOn(next);
|
||||
setErr("");
|
||||
setBusy(k);
|
||||
|
||||
const r = await mutate("notify.prefs", { [k]: next[k] });
|
||||
if (!r.ok) {
|
||||
setOn(before);
|
||||
setErr(r.error);
|
||||
setBusy(null);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Ask the phone after the preference is saved, not before: somebody who says no to Android
|
||||
* still meant to turn the switch on, and their answer is kept. In a browser there is no bridge
|
||||
* and nothing is asked — the preference is the server's either way, and it governs the phone
|
||||
* whenever they next open the app.
|
||||
*
|
||||
* The question is "is this phone registered", not "is this the first switch". The old test —
|
||||
* nothing on before this tap — could never be true: every switch defaults to ON, so a fresh
|
||||
* account arrives here with all of them set, and for a wearer `waiting` is on with no row to
|
||||
* turn it off. Android was therefore never asked, no device row was ever written, and every
|
||||
* send found nobody, on a server with a key installed and every switch saying yes. A remembered
|
||||
* token is the honest answer to whether this phone has ever handed one back. */
|
||||
if (next[k] && bridge && configured && !rememberedToken()) {
|
||||
const ask = await askForPush();
|
||||
if (ask.ok) await mutate("push.register", { token: ask.token, platform: "android" });
|
||||
}
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
{rows.map((r) => (
|
||||
<MSwitchRow
|
||||
key={r.key}
|
||||
title={r.title}
|
||||
on={on[r.key]}
|
||||
disabled={!configured || busy !== null}
|
||||
onToggle={() => void toggle(r.key)}
|
||||
/>
|
||||
))}
|
||||
{!configured ? (
|
||||
<div style={{ fontSize: 13, color: N600, padding: "12px 0 0", lineHeight: 1.5 }}>
|
||||
Notifications aren’t set up on this server.
|
||||
</div>
|
||||
) : shell === "old" ? (
|
||||
// In the app, on a build from before notifications existed. Saying "they arrive in the app"
|
||||
// to somebody who is standing in it would be a line that explains nothing.
|
||||
<div style={{ fontSize: 13, color: N600, padding: "12px 0 0", lineHeight: 1.5 }}>
|
||||
Update the ThreadCount Staff app to turn these on.
|
||||
</div>
|
||||
) : !bridge ? (
|
||||
<div style={{ fontSize: 13, color: N600, padding: "12px 0 0", lineHeight: 1.5 }}>
|
||||
Notifications come to the ThreadCount Staff app on your phone.
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
/* 1D — Order detail. The tracking screen, and the screen someone holds up at the counter.
|
||||
*
|
||||
* It leads with what to do next. The old order of this page was a history lesson with the action
|
||||
* buried under it: the code somebody was walking to the counter to show sat below four timeline
|
||||
* rows they had already read. So the top of the screen is now the status word and the bag, then
|
||||
* the one thing there is to do — show the code, tell the desk you have it, read why it was
|
||||
* refused — and the history follows underneath, where it belongs.
|
||||
*
|
||||
* The timeline still always shows the step that hasn't happened yet, as an outlined dot. Half the
|
||||
* point of a progress list is what is still to come: one that only listed what had already
|
||||
* happened left "so when do I get it?" unanswered, which is the question that sends people to the
|
||||
* counter.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { CodeBlock, Kicker, LineList, N600, N700, OutlineButton, SecondaryBar, type Step, Timeline } from "@/components/staffui";
|
||||
import { MBar, MBody, MError, MRule, MSection, MTop } from "@/components/m";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { statusText } from "@/lib/staffreq";
|
||||
import type { ReqStatus } from "@/lib/staffreq";
|
||||
import type { ReqLine } from "@/lib/staffdata";
|
||||
import { formatInZone } from "@/lib/compute";
|
||||
|
||||
type Ev = { id: string; label: string; meta: string; actorName: string; at: string };
|
||||
type Data = {
|
||||
id: string; code: string; status: string;
|
||||
lines: ReqLine[]; summary: string; garments: number; lineCount: number; decision: string | null;
|
||||
reason: string; note: string; managerName: string; declineReason: string | null;
|
||||
collectCode: string | null; holdUntil: string; route: string | null;
|
||||
signerName: string | null; signerRole: string | null; ward: string;
|
||||
subjectName: string; raisedByName: string; mine: boolean; claimedAt: string | null;
|
||||
createdAt: string;
|
||||
events: Ev[];
|
||||
};
|
||||
|
||||
/* The zone is the facility's, not the device's and not the server's.
|
||||
*
|
||||
* This screen is server-rendered and then hydrated, so a timeline stamp built without a zone was
|
||||
* printed in whatever zone the host sits in and then quietly replaced with the phone's — a step
|
||||
* taken at 08:00 in the linen room read "7 Sep, 22:00" until React caught up. */
|
||||
function stamp(iso: string, tz: string) {
|
||||
return formatInZone(iso, tz, { day: "numeric", month: "short" }) + ", " +
|
||||
formatInZone(iso, tz, { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
|
||||
/** The step after the last one that happened, so the person can see where it goes next. */
|
||||
function nextStep(status: string, holdUntil: string, route: string | null): { label: string; meta?: string } | null {
|
||||
switch (status as ReqStatus) {
|
||||
case "awaiting": return { label: "Approved", meta: "Then it goes to the linen room" };
|
||||
case "accepted": return { label: "Picked from the shelf" };
|
||||
case "picking": return { label: route === "ward_round" ? "Out on the ward round" : "Ready at the counter" };
|
||||
case "ready": return { label: "Collected", meta: holdUntil ? `Held until ${holdUntil}` : undefined };
|
||||
case "round": return { label: "Signed for on the ward" };
|
||||
default: return null; // declined, collected and delivered are endings
|
||||
}
|
||||
}
|
||||
|
||||
export default function OrderScreen({ data }: { data: Data }) {
|
||||
const { mutate, busy, me } = useStaff();
|
||||
const [err, setErr] = useState("");
|
||||
// The wearer's first name, for the third-person reading a manager or the desk gets of this page.
|
||||
const first = (data.subjectName || "").split(" ")[0];
|
||||
const st = statusText(data, { mine: data.mine, first });
|
||||
// A request the linen room withdrew was never decided by the manager named on it; the timeline
|
||||
// row it writes is the only record of that, so this is where the page finds out.
|
||||
const withdrawn = data.status === "declined" && data.events.some((e) => e.label === "Withdrawn by the linen room");
|
||||
|
||||
/* The one thing only the requester can settle: whether the bag actually reached them.
|
||||
*
|
||||
* A ward clerk signs for the round, which is where the linen room's job ends — but the bag then
|
||||
* sits on the desk, and until somebody says it was picked up the desk's unclaimed list only
|
||||
* grows. This is that confirmation, and it is why round.claim exists; without a caller it never
|
||||
* ran and the list never emptied.
|
||||
*
|
||||
* The mockup offers it while the bag is still `round`. The op does not, and should not: a bag
|
||||
* still on the trolley has not been signed for by anybody, and claiming it would clear a desk
|
||||
* list that has nothing on it yet. `delivered` — signed for on the ward, not yet picked up off
|
||||
* the desk — is the state this question belongs to. */
|
||||
const canClaim = data.status === "delivered" && data.mine && !data.claimedAt;
|
||||
/* ⛔ The code is the wearer's alone.
|
||||
*
|
||||
* Four people can open this order — the wearer, the manager it was addressed to, whoever raised
|
||||
* it and the ward desk holding the bag — and only one of them collects the bag. Without the
|
||||
* `mine` test an approver read the four digits off their own approvals history and could walk to
|
||||
* the counter with them, and was offered a "Show at the counter" button that /my/orders/[id]/code
|
||||
* then refused. reqRow() no longer hands them the code at all; this is the same rule said on the
|
||||
* screen, so neither end can drift. */
|
||||
const showCode = data.mine && data.status === "ready" && !!data.collectCode;
|
||||
|
||||
const steps: Step[] = data.events.map((e, i) => ({
|
||||
label: e.label,
|
||||
meta: [e.actorName, e.meta, stamp(e.at, me.tz)].filter(Boolean).join(" · "),
|
||||
state: i === data.events.length - 1 ? "current" : "done",
|
||||
}));
|
||||
const next = nextStep(data.status, data.holdUntil, data.route);
|
||||
if (next) steps.push({ label: next.label, meta: next.meta, state: "future" });
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* A real destination behind the chevron: this screen is what a tapped notification opens, and
|
||||
on a cold start there is no history to pop, so a bare router.back() is a dead control. */}
|
||||
<MTop title={data.code} back backHref="/my/orders" right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{data.mine ? "" : data.subjectName}</span>} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{/* The headline: which order this is, then the one word that says where it has got to. */}
|
||||
<div style={{ padding: "16px 16px 0", background: "var(--color-bg)" }}>
|
||||
<Kicker tone={st.ink === "attention" ? "attention" : "quiet"}>
|
||||
{data.code} · raised {formatInZone(data.createdAt, me.tz)}
|
||||
</Kicker>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 24, letterSpacing: "-0.02em", lineHeight: 1.1, marginTop: 4 }}>
|
||||
{st.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The bag and why it was asked for, in one bordered row under the headline. */}
|
||||
<div style={{ margin: "12px 16px 0", padding: "10px 0 12px", borderBottom: "2px solid var(--color-text)" }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.35 }}>{data.summary}</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 3 }}>
|
||||
{/* `decision` is only there once the manager has been through it, and it is the honest
|
||||
line when they didn't approve everything: "2 of 3 approved" above a list where the
|
||||
fleece is struck out. */}
|
||||
{[data.decision, data.reason].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{!data.mine && data.subjectName && (
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 6 }}>{`For ${data.subjectName}${data.raisedByName ? ` · raised by ${data.raisedByName}` : ""}`}</div>
|
||||
)}
|
||||
{data.mine && data.raisedByName && (
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 6 }}>{`Raised for you by ${data.raisedByName}`}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The whole ask, declines included. A request where the fleece was refused has to read
|
||||
honestly on the wearer's own screen — the alternative is somebody collecting a bag,
|
||||
counting two garments where they asked for three, and coming to the counter to find out
|
||||
why. One line needs no list: the row above already is one. */}
|
||||
{data.lineCount > 1 && (
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<Kicker>{data.mine ? "What you asked for" : `What ${first || "they"} asked for`}</Kicker>
|
||||
<div style={{ marginTop: 10 }}><LineList lines={data.lines} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- what to do next, before the history ---- */}
|
||||
|
||||
{showCode && (
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<CodeBlock code={data.collectCode as string} kicker="Collection code" />
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{/* A box in the flow of the screen, not the foot of it: the foot belongs to "Ask
|
||||
about this order", and two flush-left bars with arrows one above the other read
|
||||
as the same control twice. */}
|
||||
<OutlineButton label="Show at the counter" href={`/my/orders/${data.id}/code`} />
|
||||
</div>
|
||||
{data.garments > 1 && (
|
||||
<p style={{ fontSize: 13, color: N600, marginTop: 10, lineHeight: 1.55 }}>
|
||||
All {data.garments} garments are in one bag under this code.
|
||||
</p>
|
||||
)}
|
||||
{/* This used to say the hold lapses on its own and the garment goes back on the shelf.
|
||||
Nothing does that: the hold is a note the linen room typed, there is no job that
|
||||
reads it, and the only way out of `ready` is somebody collecting. So the copy says
|
||||
what is true — it keeps waiting, and a late collection is a conversation rather than
|
||||
a lost request. */}
|
||||
{data.holdUntil && (
|
||||
<p style={{ fontSize: 13, color: N600, marginTop: 10, lineHeight: 1.55 }}>
|
||||
Held until {data.holdUntil}. It stays on the counter until you collect it — if you
|
||||
can’t get there by then, say so on this order and the linen room will sort it out.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canClaim && (
|
||||
// In the flow rather than docked at the foot: it is the next thing to do, and the next
|
||||
// thing to do lives at the top of this screen now. The screen updates in place when it
|
||||
// lands — mutate() refreshes the route; nothing here reloads the app.
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<MBar
|
||||
label={busy ? "Working…" : "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);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.status === "declined" && data.declineReason && (
|
||||
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: "14px 16px", margin: "16px 16px 0" }}>
|
||||
<Kicker tone="attention">Why</Kicker>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, marginTop: 6 }}>{data.declineReason}</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: N700, margin: "8px 0 0" }}>
|
||||
{withdrawn
|
||||
? "The linen room withdrew this. Ask at the counter if it needs another look."
|
||||
: data.managerName
|
||||
? `${data.managerName} decided this. Talk to them if it needs another look.`
|
||||
: "Ask the linen room if it needs another look."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.status === "delivered" && data.signerName && (
|
||||
<div style={{ margin: "16px 16px 0", background: "#fff", borderLeft: "6px solid var(--color-text)", padding: "14px 16px" }}>
|
||||
<Kicker>Signed for</Kicker>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, marginTop: 6 }}>{data.signerName}{data.signerRole ? `, ${data.signerRole}` : ""}</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: N700, margin: "8px 0 0" }}>
|
||||
{data.claimedAt
|
||||
? `Picked up from the desk on ${data.ward || "your ward"}.`
|
||||
: `Ask at the desk on ${data.ward || "your ward"} — whoever signed has it.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- then the history ---- */}
|
||||
|
||||
<div style={{ padding: "0 16px" }}><MSection label="Progress" /></div>
|
||||
<Timeline steps={steps} />
|
||||
|
||||
{data.note && (
|
||||
<div style={{ padding: "0 16px 16px" }}>
|
||||
<Kicker>{data.mine ? "Your note" : `${first || "Their"}’s note`}</Kicker>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.55, margin: "8px 0 0" }}>{data.note}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
{/* Docked at the foot, as the mockup has it, rather than sitting at the end of the scroll:
|
||||
the one question somebody has about an order they are tracking is "can I ask about this?",
|
||||
and on a long timeline that control was below everything they had already read. */}
|
||||
<SecondaryBar label="Ask about this order" href={`/my/orders/${data.id}/messages`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
/* 1C — Orders. Every request, newest first.
|
||||
*
|
||||
* The status *word* leads each row and the left border only reinforces it. Nothing here is
|
||||
* distinguishable by colour alone, which matters on a ward phone in bad light as much as it does
|
||||
* for anyone who can't tell the red from the grey.
|
||||
*
|
||||
* Three lists, not two. What somebody raised for another person — a manager for one of their team,
|
||||
* plus anything still open from the desk route that used to exist — is deliberately kept out of
|
||||
* Open and Done: what a wearer does with their own order (collect it, chase it, say they picked it
|
||||
* up) is not what the person who typed it in does with it, and one mixed list is how somebody walks
|
||||
* off with a bag that isn't theirs. Before this tab existed those requests appeared on no screen
|
||||
* the raiser could reach at all.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MBar, MBody, MEmpty, MRule, MTop, NOT_DOCKED } from "@/components/m";
|
||||
import { DIVIDER, EdgeRow, INK, N600, Tabs } from "@/components/staffui";
|
||||
import StaffNav from "@/components/staffnav";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { NEEDS_STAFF, statusText } from "@/lib/staffreq";
|
||||
import { formatInZone } from "@/lib/compute";
|
||||
import type { ReqRow } from "@/lib/staffdata";
|
||||
|
||||
type Tab = "open" | "done" | "raised";
|
||||
|
||||
export default function OrdersScreen({ open, done, raised, initialTab }: {
|
||||
open: ReqRow[]; done: ReqRow[]; raised: { open: ReqRow[]; done: ReqRow[] }; initialTab: Tab;
|
||||
}) {
|
||||
const { me } = useStaff();
|
||||
// Open first with the ones still moving, then whatever has finished, so the tab reads top-down
|
||||
// like the two it sits beside.
|
||||
const forOthers = [...raised.open, ...raised.done];
|
||||
const [tab, setTab] = useState<Tab>(initialTab === "raised" && !forOthers.length ? "open" : initialTab);
|
||||
const rows = tab === "open" ? open : tab === "done" ? done : forOthers;
|
||||
|
||||
const tabs = [
|
||||
{ key: "open" as const, label: "Open", count: open.length },
|
||||
{ key: "done" as const, label: "Done", count: done.length },
|
||||
...(forOthers.length ? [{ key: "raised" as const, label: "Raised", count: forOthers.length }] : []),
|
||||
];
|
||||
|
||||
const empty = tab === "open"
|
||||
? {
|
||||
title: "Nothing open",
|
||||
sub: forOthers.length
|
||||
? "Anything you raise for somebody else is under Raised."
|
||||
: "Anything you ask for shows here until you have it.",
|
||||
}
|
||||
: tab === "done"
|
||||
? { title: "Nothing finished yet", sub: "Collected and declined orders stay here." }
|
||||
: { title: "Nothing you raised for somebody else", sub: "What you type in for somebody else shows here." };
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Orders" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<Tabs label="Which orders" value={tab} onPick={setTab} options={tabs} />
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ padding: "0 16px" }}><MEmpty title={empty.title} sub={empty.sub} /></div>
|
||||
) : (
|
||||
// The 1px rules between rows are the gap, not a border on each row: the last row then has
|
||||
// no rule hanging under it, and every left edge still starts at the edge of the screen.
|
||||
<div style={{ display: "grid", gap: 1, background: DIVIDER, borderBottom: `1px solid ${DIVIDER}` }}>
|
||||
{rows.map((r) => {
|
||||
const st = statusText(r, { mine: r.mine, first: r.subjectName.split(" ")[0] });
|
||||
// The accent edge marks what is waiting on the wearer. An order somebody raised for
|
||||
// a colleague is never waiting on the reader — they typed it in, they don't collect
|
||||
// it — so it keeps the quiet edge whatever its status.
|
||||
const attention = !r.mine ? false : NEEDS_STAFF.has(r.status as never);
|
||||
return (
|
||||
<EdgeRow key={r.id} tone={attention ? "accent" : "divider"} href={`/my/orders/${r.id}`}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{st.label}</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 2, lineHeight: 1.4 }}>{r.summary}</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 1, lineHeight: 1.4 }}>
|
||||
{/* createdAt is a UTC instant; slicing its first ten characters dated every
|
||||
request raised before 10:00 to the previous day. */}
|
||||
{[
|
||||
r.mine ? "" : `for ${r.subjectName}`,
|
||||
// Only worth saying when the manager didn't approve the lot — otherwise
|
||||
// the status word above has already said it.
|
||||
r.decision && r.lineCount > 1 ? r.decision : "",
|
||||
`${r.code} · raised ${formatInZone(r.createdAt, me.tz)}`,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<span aria-hidden="true" style={{ fontSize: 18, color: N600, flex: "0 0 auto" }}>›</span>
|
||||
</div>
|
||||
</EdgeRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
{/* The nav below is what the phone's gesture handle sits on, and it pads itself for it. The
|
||||
bar is not at the foot of anything, so it says so: otherwise it reserves the home-indicator
|
||||
band a second time and "New request" floats above an accent gap mid-screen. */}
|
||||
<div style={{ ...NOT_DOCKED, borderTop: "2px solid " + INK }}>
|
||||
<MBar label="New request" href="/my/request" />
|
||||
</div>
|
||||
<StaffNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
/* Asking for a uniform, in three taps.
|
||||
*
|
||||
* 01 what you need — the things already on your record first, in the size the record has for them
|
||||
* 02 the size, each one carrying the word the ward is allowed to see, and how many
|
||||
* 03 why
|
||||
*
|
||||
* The old screen put a picker dialog between the person and all three, which is six or seven taps
|
||||
* for the commonest ask in the product: another of the top I already wear. The picker is gone; the
|
||||
* garments they hold are at the top of the list, choosing one fills in their size, and the bar
|
||||
* names the person it is going to — "Send to" and the approver's own name, not "Submit" —
|
||||
* because who has it is the single most asked question about a request.
|
||||
*
|
||||
* Two things the mockup's three taps do not cover are kept, because the product rests on them.
|
||||
* One request may carry up to REQUEST_MAX_LINES garments — the manager's per-line decline, the one
|
||||
* collection code and one bag all depend on it — so *Add another garment* appears once a line is
|
||||
* complete and the chosen lines are listed above. And the allowance the manager will measure this
|
||||
* against is on screen while they are still choosing, in one line, rather than arriving inside a
|
||||
* refusal against a number they were never shown.
|
||||
*
|
||||
* No prices, no payment, no basket, and no count of what is on the shelf: a size carries a word.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { MBar, MBody, MChipRow, MError, MONO, MRule, MStepper, MTop, useToast } from "@/components/m";
|
||||
import {
|
||||
ACCENT, DraftLineList, INK, N200, N500, N600, N700, NumberedField, OptionList, SecondaryBar,
|
||||
type DraftLine,
|
||||
} from "@/components/staffui";
|
||||
import Sent from "@/components/screens/Sent";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { REQUEST_REASONS, stockLabel, type StockWord } from "@/lib/staffreq";
|
||||
import { genderLabel } from "@/lib/compute";
|
||||
|
||||
type Size = { size: string; si: number; word: StockWord | 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 };
|
||||
|
||||
/** The word under a size chip. The short lowercase forms are the mockup's; `stockLabel()` is what
|
||||
* a screen reader is given, so the vocabulary the rest of the app uses is the one announced. */
|
||||
function chipWord(w: string): string {
|
||||
return w === "in_stock" ? "in stock" : w === "low" ? "low" : "none";
|
||||
}
|
||||
|
||||
function lineOf(l: { qty: number; item: string; size: string }): string {
|
||||
return l.qty > 1 ? `${l.qty} × ${l.item} — ${l.size}` : `${l.item} — ${l.size}`;
|
||||
}
|
||||
|
||||
export default function RequestScreen({
|
||||
items, managerName, swap, heldItemIds, preItemId, preSi, preReason, filledFrom,
|
||||
holding, allowance, notifyWays, maxLines, maxQty,
|
||||
}: {
|
||||
items: Item[]; managerName: string; swap: boolean; heldItemIds: string[];
|
||||
preItemId: string | null; preSi: number | null; preReason: string | null;
|
||||
/** The code of the request *Same again* copied, for the one line that says so. */
|
||||
filledFrom: string | null;
|
||||
holding: { total: number; sets: number }; allowance: Allowance;
|
||||
notifyWays: { email: boolean; push: boolean };
|
||||
maxLines: number; maxQty: number;
|
||||
}) {
|
||||
const { mutate, busy } = useStaff();
|
||||
const toast = useToast();
|
||||
|
||||
// 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 base = swap && heldItemIds.length ? items.filter((i) => heldItemIds.includes(i.id)) : items;
|
||||
/* The things on your record first. That is the whole point of the redesign: the commonest ask is
|
||||
* another of something you already wear, and it used to be somewhere down an alphabetical list
|
||||
* of the entire catalogue. Within each half the linen room's own order is kept. */
|
||||
const list = [...base.filter((i) => i.held > 0), ...base.filter((i) => i.held === 0)];
|
||||
// Names that more than one garment on this list answers to (see the option label below).
|
||||
const sharedNames = new Set(list.filter((i, _n, all) => all.some((o) => o.id !== i.id && o.item === i.item)).map((i) => i.item));
|
||||
|
||||
const [lines, setLines] = useState<DraftLine[]>([]);
|
||||
/* A pre-selection only counts if it is on the list this screen is offering. `?swap=1` narrows the
|
||||
* list to what they hold, and an `?item=` arriving from the shelf or a waitlist alternative can
|
||||
* fall outside it — leaving a chosen garment nothing could resolve, and steps 02 and 03 never
|
||||
* drawn. */
|
||||
const [itemId, setItemId] = useState<string | null>(() => (preItemId && list.some((i) => i.id === preItemId) ? preItemId : null));
|
||||
const [si, setSi] = useState<number | null>(preSi);
|
||||
const [qty, setQty] = useState(1);
|
||||
const [reason, setReason] = useState<string | null>(preReason);
|
||||
const [err, setErr] = useState("");
|
||||
const [sent, setSent] = useState<{ id: string; code: string; manager: string; notified: boolean; lines: string[] } | null>(null);
|
||||
|
||||
const item = list.find((i) => i.id === itemId) || null;
|
||||
const size = item && si !== null ? item.sizes.find((s) => s.si === si) || null : null;
|
||||
const current = item && size ? { itemId: item.id, si: size.si, item: item.item, size: String(size.size), qty } : null;
|
||||
const all = current ? [...lines, current] : lines;
|
||||
const garments = all.reduce((n, l) => n + l.qty, 0);
|
||||
const full = all.length >= maxLines;
|
||||
const ready = all.length > 0 && !!reason && !!managerName;
|
||||
/* Which step the bar is still waiting for.
|
||||
*
|
||||
* The mockup draws "pick an item" under a disabled bar, which is right on an empty screen and
|
||||
* wrong a moment later: with a garment and a size already chosen and only the reason outstanding,
|
||||
* it tells somebody to do the one thing they have just done. So the note names what is actually
|
||||
* missing, in the same order the screen asks for it. */
|
||||
const missing = !itemId && all.length === 0
|
||||
? { short: "pick an item", full: "Pick an item, a size and a reason" }
|
||||
: itemId && si === null
|
||||
? { short: "pick a size", full: "Pick a size" }
|
||||
: !reason
|
||||
? { short: "pick a reason", full: "Pick a reason" }
|
||||
: { short: "pick an item", full: "Pick an item, a size and a reason" };
|
||||
|
||||
/** Choosing a garment opens on the size the record already has for it — the difference between
|
||||
* three taps and five. Not when that size is the one that isn't there: pre-selecting a size the
|
||||
* linen room cannot pick is worse than asking. */
|
||||
function pickItem(id: string) {
|
||||
setErr("");
|
||||
setItemId(id);
|
||||
setQty(1);
|
||||
const it = list.find((i) => i.id === id);
|
||||
const rec = it ? it.sizes.find((s) => String(s.size) === String(it.recorded)) : null;
|
||||
setSi(rec && rec.word !== "none" ? rec.si : null);
|
||||
}
|
||||
|
||||
/** Put the garment being chosen on the list and start the next one. */
|
||||
function addAnother() {
|
||||
if (!current) return;
|
||||
setLines((cur) => {
|
||||
// The same garment in the same size twice is one line of two: the server sums duplicates
|
||||
// before it writes them, so two identical rows would be showing something that cannot save.
|
||||
const at = cur.findIndex((x) => x.itemId === current.itemId && x.si === current.si);
|
||||
if (at >= 0) {
|
||||
const next = [...cur];
|
||||
next[at] = { ...next[at], qty: Math.min(maxQty, next[at].qty + current.qty) };
|
||||
return next;
|
||||
}
|
||||
return [...cur, { ...current, key: `${current.itemId}:${current.si}:${cur.length}` }];
|
||||
});
|
||||
setItemId(null);
|
||||
setSi(null);
|
||||
setQty(1);
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
/* What happens next, answered by what this server can actually do rather than by what is
|
||||
* usually true: a registered phone, an email that really went out, or neither. */
|
||||
const who = sent.manager || "your manager";
|
||||
return (
|
||||
<Sent
|
||||
headline={`Sent to ${who}`}
|
||||
sub={`${sent.lines.join(", ")} · ${sent.code}`}
|
||||
next={
|
||||
notifyWays.push
|
||||
? "You get a notification when it is approved, and again when it is ready."
|
||||
: sent.notified
|
||||
? `${who} has been emailed.`
|
||||
: `It is waiting with ${who}.`
|
||||
}
|
||||
actions={[
|
||||
{ label: "Open the order", href: `/my/orders/${sent.id}` },
|
||||
{ label: "Back to home", href: "/my" },
|
||||
]}
|
||||
bar={{ label: "Back to home", href: "/my" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={swap ? "Swap a size" : "Request an item"} back backHref="/my" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
{/* The mockup toasts "Filled in from R-0042". Kept as a line on the screen rather than a
|
||||
toast: the toast host is mounted, but this screen is server-rendered and arrives with
|
||||
the garment already chosen, so a 2.2-second toast is gone — or never announced — before
|
||||
somebody has looked up from the list. A line a screen reader is told about survives. */}
|
||||
{filledFrom && (
|
||||
<div role="status" style={{
|
||||
background: INK, color: "var(--color-bg)", borderLeft: `6px solid ${ACCENT}`,
|
||||
padding: "12px 14px", fontSize: 14, fontWeight: 800,
|
||||
}}>Filled in from {filledFrom}</div>
|
||||
)}
|
||||
|
||||
{lines.length > 0 && (
|
||||
<div style={{ padding: "16px 16px 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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<OptionList
|
||||
value={itemId}
|
||||
onPick={pickItem}
|
||||
options={list.map((i) => {
|
||||
const held = i.sizes.find((s) => s.held > 0);
|
||||
return {
|
||||
key: i.id,
|
||||
/* Two garments can carry the same name in different cuts — a women's and a
|
||||
unisex scrub top are two rows on the register, and somebody set to "either"
|
||||
is offered both. Named identically they read as the list repeating itself, so
|
||||
the cut is added to whichever names are shared, and to nothing else. */
|
||||
label: sharedNames.has(i.item) ? `${i.item} · ${genderLabel(i.gender)}` : i.item,
|
||||
meta: i.held > 0 && held
|
||||
? `You hold ${i.held === 1 ? "one" : i.held} in ${held.size}`
|
||||
: "Not on your record",
|
||||
};
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</NumberedField>
|
||||
|
||||
{item && (
|
||||
<>
|
||||
<NumberedField n={2} label="Size">
|
||||
{/* Every size is shown, including the ones that aren't there: "it isn't there" is
|
||||
information somebody came for. A none size is a link to the queue for it —
|
||||
⛔ never a `disabled` button, which takes no tap and no focus, so the one route
|
||||
that answers their problem would be unreachable by touch and by keyboard alike. */}
|
||||
<div role="group" aria-label="Size" style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{item.sizes.map((s) => {
|
||||
const on = si === s.si;
|
||||
const none = s.word === "none";
|
||||
const st: React.CSSProperties = {
|
||||
minHeight: 46, minWidth: 50, padding: "0 12px", borderRadius: 0,
|
||||
border: `2px solid ${none ? "var(--color-divider)" : INK}`,
|
||||
background: none ? "transparent" : on ? INK : "#fff",
|
||||
color: none ? N500 : on ? "var(--color-bg)" : INK,
|
||||
font: "inherit", fontSize: 14, fontWeight: 700, textDecoration: "none",
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6,
|
||||
cursor: "pointer",
|
||||
};
|
||||
const inner = (
|
||||
<>
|
||||
{s.size}
|
||||
<small style={{ fontFamily: MONO, fontSize: 11, fontWeight: 500, color: on ? N200 : N600 }}>
|
||||
{chipWord(String(s.word))}
|
||||
</small>
|
||||
</>
|
||||
);
|
||||
return none ? (
|
||||
<Link
|
||||
key={s.si}
|
||||
href={`/my/waitlist?item=${encodeURIComponent(item.id)}&si=${s.si}`}
|
||||
aria-disabled="true"
|
||||
aria-label={`${s.size}, ${stockLabel("none")} — join the waitlist`}
|
||||
style={st}
|
||||
>{inner}</Link>
|
||||
) : (
|
||||
<button
|
||||
key={s.si}
|
||||
type="button"
|
||||
onClick={() => { setSi(s.si); setErr(""); }}
|
||||
aria-pressed={on}
|
||||
aria-label={`${s.size}, ${stockLabel(s.word as StockWord)}`}
|
||||
style={st}
|
||||
>{inner}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{size && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 12, minHeight: 60, marginTop: 8,
|
||||
borderTop: "1px solid var(--color-divider)", paddingTop: 10,
|
||||
}}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontWeight: 700 }}>How many</span>
|
||||
<MStepper n={qty} onChange={setQty} min={1} max={maxQty} label="garments" />
|
||||
</div>
|
||||
)}
|
||||
</NumberedField>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* One reason covers the whole request, so it stays put once anything is on the list.
|
||||
Gated on the garment being chosen, *Add another garment* took the only control that sets
|
||||
a reason off the screen — and the bar then asked for one that could no longer be picked. */}
|
||||
{(item || lines.length > 0) && (
|
||||
<NumberedField n={3} label="Why">
|
||||
{/* Chips, as the mockup draws them. Four short words do not need four 58px boxes with a
|
||||
chevron each: the reason is the last of the three taps, and at option-list size it
|
||||
pushed the bar that sends the request off the bottom of the screen. */}
|
||||
<MChipRow
|
||||
label="Why"
|
||||
value={reason}
|
||||
onPick={(r) => { setReason(r); setErr(""); }}
|
||||
options={REQUEST_REASONS.map((r) => ({ value: r as string, label: r }))}
|
||||
/>
|
||||
</NumberedField>
|
||||
)}
|
||||
|
||||
{/* What they hold and what they are measured against, in one line, while they are still
|
||||
choosing — not inside the decline. Same sum the manager's review screen uses. */}
|
||||
<div style={{
|
||||
margin: "0 16px", background: "#fff", padding: "14px 16px",
|
||||
borderLeft: `6px solid ${allowance.over ? ACCENT : INK}`,
|
||||
fontSize: 14, lineHeight: 1.5, color: N700,
|
||||
}}>
|
||||
{holding.total === 0 ? "Nothing on your record yet" : `${holding.total} garment${holding.total === 1 ? "" : "s"} on your record`}
|
||||
{" · "}{allowance.label}
|
||||
</div>
|
||||
|
||||
{current && !full && (
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<SecondaryBar label="Add another garment" onClick={addAnother} />
|
||||
</div>
|
||||
)}
|
||||
{full && (
|
||||
<p style={{ fontSize: 13, color: N600, lineHeight: 1.55, margin: "12px 16px 0" }}>
|
||||
That is {maxLines} garments — as much as one request carries.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* What the bar is still waiting for. A single "pick an item" was what the mockup drew on an
|
||||
empty screen, but it stayed there with a garment and a size already chosen and only the
|
||||
reason outstanding — telling somebody to do the one thing they have just done. */}
|
||||
<MBar
|
||||
label={busy ? "Sending…" : managerName ? `Send to ${managerName}` : "Send for approval"}
|
||||
small={ready ? `${garments} garment${garments === 1 ? "" : "s"}` : managerName ? missing.short : "no approver"}
|
||||
disabled={!ready || busy}
|
||||
offReason={managerName ? missing.full : "Nobody is recorded as your approver yet"}
|
||||
onClick={async () => {
|
||||
if (!ready) return;
|
||||
const r = await mutate<{ id: string; code: string; manager: string; notified: boolean }>("request.create", {
|
||||
lines: all.map((l) => ({ itemId: l.itemId, si: l.si, qty: l.qty })),
|
||||
reason: reason || "",
|
||||
// The one thing the swap route has always told the linen room, kept now that the free
|
||||
// note has gone: this is an exchange, not another garment on top.
|
||||
note: swap ? "Swapping a size." : "",
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
toast("Sent");
|
||||
setSent({
|
||||
id: r.result.id, code: r.result.code, manager: r.result.manager || managerName,
|
||||
notified: r.result.notified, lines: all.map(lineOf),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"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 reasons, in the list, where the garment is.
|
||||
* Everything not knocked back is approved when the bar is pressed, and the bar says how many that
|
||||
* is, because "Approve" over a list of three 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 lets the wearer
|
||||
* settle their own. The queue sets those apart; so does the line above the bar here, because
|
||||
* having been told on a list you scrolled past is not the same as being told as you sign.
|
||||
*
|
||||
* Not inside the Team shell: this is a detail screen with a back chevron, like the order and the
|
||||
* thread. A tab row on a decision screen invites somebody off it mid-decision.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MBar, MBody, MError, MRule, MTop } from "@/components/m";
|
||||
import { ACCENT_700, IdentityBlock, INK, Kicker, LineList, N600, N700, lineText } from "@/components/staffui";
|
||||
import { Band, ChipAction } from "./Team";
|
||||
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 };
|
||||
|
||||
/** The shelf word, in a box beside the garment. Words, never a count. */
|
||||
function StockBox({ word, struck }: { word: string; struck: boolean }) {
|
||||
const none = word.startsWith("none");
|
||||
return (
|
||||
<span style={{
|
||||
flex: "0 0 auto", fontSize: 12, fontWeight: 800, letterSpacing: "0.05em", textTransform: "uppercase",
|
||||
background: none ? "var(--color-accent)" : "var(--color-divider)", color: none ? "#fff" : INK,
|
||||
padding: "5px 8px", whiteSpace: "nowrap", textDecoration: struck ? "line-through" : "none",
|
||||
}}>{word}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReviewScreen({ data }: { data: Data }) {
|
||||
const router = useRouter();
|
||||
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: "" }])),
|
||||
);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const decided = data.status !== "awaiting";
|
||||
/* Whether the person deciding this is the person it is for. Matched on the id, which the register
|
||||
* keeps unique; 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;
|
||||
const yes = data.lines.filter((l) => calls[l.id]?.decision === "approved").length;
|
||||
const total = data.lines.length;
|
||||
|
||||
function decline(lineId: string, reason: string) {
|
||||
setErr("");
|
||||
setCalls((c) => ({ ...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; }
|
||||
router.push("/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 (
|
||||
<>
|
||||
{/* A real destination, not history.back(): this screen opens cold from the approval email. */}
|
||||
<MTop title={`Review ${data.code}`} back onBack={() => router.push("/my/approvals")} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<IdentityBlock
|
||||
ward={data.subject.ward}
|
||||
num={data.subject.num}
|
||||
name={data.subject.name}
|
||||
group={data.subject.group}
|
||||
/>
|
||||
|
||||
{/* No count at the right. Every garment is listed under this band with its own quantity,
|
||||
and a total beside the heading only invites the manager to decide against the number
|
||||
rather than against the list. */}
|
||||
<Band label="Asking for" />
|
||||
|
||||
{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}. ` : ""}Nothing here is waiting on you.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: 2, margin: "12px 0 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" }}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<div style={{
|
||||
flex: 1, minWidth: 0, fontSize: 16, fontWeight: 800, lineHeight: 1.3,
|
||||
textDecoration: off ? "line-through" : "none", color: off ? N600 : INK,
|
||||
}}>{lineText(l)}</div>
|
||||
<StockBox word={l.stock} struck={off} />
|
||||
</div>
|
||||
|
||||
{off ? (
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center", marginTop: 10 }}>
|
||||
<span style={{
|
||||
flex: 1, minWidth: 0, fontSize: 13, fontWeight: 800, color: ACCENT_700,
|
||||
textDecoration: "line-through",
|
||||
}}>{call.reason}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalls((c) => ({ ...c, [l.id]: { decision: "approved", reason: "" } }))}
|
||||
style={{
|
||||
minHeight: 44, padding: "0 14px", border: `2px solid ${INK}`, borderRadius: 0,
|
||||
background: "#fff", color: ACCENT_700, font: "inherit", fontSize: 14, fontWeight: 800,
|
||||
cursor: "pointer", flex: "0 0 auto",
|
||||
}}
|
||||
>Undo</button>
|
||||
</div>
|
||||
) : (
|
||||
/* The reasons are the control. A Decline button that opens them was a tap that
|
||||
told nobody anything, and the three of them fit on the line they belong to. */
|
||||
<div role="group" aria-label={`Decline ${lineText(l)}`} style={{ display: "flex", gap: 6, marginTop: 10 }}>
|
||||
{DECLINE_REASONS.map((r) => (
|
||||
<ChipAction key={r} small label={r} onClick={() => decline(l.id, r)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(data.reason || data.note || data.raisedByName) && (
|
||||
<div style={{ margin: "12px 16px 0", background: "#fff", border: `2px solid ${INK}`, padding: "12px 14px" }}>
|
||||
<Kicker>Why they asked</Kicker>
|
||||
<div style={{ fontSize: 15, lineHeight: 1.5, marginTop: 6 }}>
|
||||
{[data.reason, data.note].filter(Boolean).join(" — ")}
|
||||
</div>
|
||||
{data.raisedByName && (
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 8 }}>Raised for them by {data.raisedByName}.</div>
|
||||
)}
|
||||
</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: "6px 0 0" }}>{data.allowance.note}</p>
|
||||
</div>
|
||||
|
||||
{mine && !decided && (
|
||||
/* Last thing above the bar, because the bar is what does it. The reader finds out here
|
||||
rather than from an auditor months later. */
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "12px 16px 0", margin: 0 }}>
|
||||
Approving it is recorded as your own approval.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
|
||||
{!decided && (
|
||||
/* `small`, not `sub`: the count of what is about to be refused is mono at the right of the
|
||||
bar, reading against the outcome rather than as a subtitle underneath it. */
|
||||
<MBar
|
||||
label={barLabel}
|
||||
small={yes < total ? `${total - yes} declined` : undefined}
|
||||
glyph={yes > 0 ? "check" : "arrow"}
|
||||
tone={yes > 0 ? "accent" : "ink"}
|
||||
disabled={busy}
|
||||
onClick={send}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
/* Team ▸ Round — 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.
|
||||
*
|
||||
* Signing for the lot now reports itself. It used to loop silently: on a ward where the fourth bag
|
||||
* was refused, the clerk saw an unchanged list and no idea which three had gone through, so the
|
||||
* honest thing — a count that climbs, and the name of whatever stopped it — is on the screen.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { MEmpty, MError } from "@/components/m";
|
||||
import {
|
||||
ACCENT_300, DoneRow, EdgeRow, GROUND, INK, Kicker, N300, N600, Progress, SecondaryBar, lineText,
|
||||
} from "@/components/staffui";
|
||||
import Team, { Band, ChipAction, ChipRow } from "./Team";
|
||||
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. 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("");
|
||||
const [prog, setProg] = useState<{ done: number; total: number } | null>(null);
|
||||
|
||||
async function sign(id: string) {
|
||||
setErr("");
|
||||
const r = await mutate("round.sign", { id });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}
|
||||
|
||||
async function claim(id: string) {
|
||||
setErr("");
|
||||
const r = await mutate("round.claim", { id });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}
|
||||
|
||||
/* One call per bag, because that is what signing is — a signature against one hand-over — and the
|
||||
* count climbs after each. It stops at the first refusal and says which bag it stopped on: the
|
||||
* ones already signed are done and must not be sent again (round.sign is not idempotent), and
|
||||
* the rest are left exactly as they were for the clerk to deal with. */
|
||||
async function signAll() {
|
||||
const list = toSign;
|
||||
if (!list.length) return;
|
||||
setErr("");
|
||||
setProg({ done: 0, total: list.length });
|
||||
let done = 0;
|
||||
for (const b of list) {
|
||||
const r = await mutate("round.sign", { id: b.id });
|
||||
if (!r.ok) {
|
||||
setErr(`Signed ${done} of ${list.length} — ${b.subjectName}: ${r.error}`);
|
||||
setProg(null);
|
||||
return;
|
||||
}
|
||||
done += 1;
|
||||
setProg({ done, total: list.length });
|
||||
}
|
||||
setProg(null);
|
||||
}
|
||||
|
||||
const nothingAtAll = toSign.length === 0 && signedToday.length === 0 && unclaimed.length === 0;
|
||||
|
||||
return (
|
||||
<Team
|
||||
active="/my/round"
|
||||
foot={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={prog ? "Signing…" : `Sign for all ${toSign.length} remaining`}
|
||||
disabled={busy || !!prog}
|
||||
onClick={() => void signAll()}
|
||||
/>
|
||||
</div>
|
||||
) : undefined}
|
||||
>
|
||||
<div style={{ padding: "4px 16px 0" }}><Kicker>Ward round</Kicker></div>
|
||||
<div style={{ background: INK, color: GROUND, padding: 18, margin: "8px 0 0", 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>
|
||||
|
||||
{prog && <Progress label="Signing" done={prog.done} total={prog.total} unit="signed" />}
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{unclaimed.length > 0 && (
|
||||
<>
|
||||
<Band tone="attention" label="Unclaimed from earlier rounds" />
|
||||
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
|
||||
{unclaimed.map((b) => (
|
||||
<EdgeRow key={b.id} tone="accent">
|
||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 4, lineHeight: 1.4 }}>
|
||||
{[b.lines.map(lineText).join(", "), b.signedAt ? `since ${formatInZone(b.signedAt, tz, { weekday: "long" })}` : ""].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
<ChipRow>
|
||||
{/* 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, which is what Collected is for. */}
|
||||
<ChipAction label="Nudge" href={`/my/orders/${b.id}/messages`} />
|
||||
<ChipAction label="Collected" disabled={busy} onClick={() => void claim(b.id)} />
|
||||
</ChipRow>
|
||||
</EdgeRow>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* The count stays on the band when it reaches nought: a heading that drops its note the
|
||||
moment the work is done reads as a screen that has lost its place. */}
|
||||
<Band label="Arriving today" right={`${toSign.length} to sign`} />
|
||||
|
||||
{nothingAtAll ? (
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<MEmpty title={`Nothing on the round for ${ward || "your ward"} right now.`} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{toSign.map((b) => (
|
||||
<EdgeRow key={b.id} tone="ink">
|
||||
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
|
||||
{/* Signing is a signature: whoever puts their name to a bag of four garments should be
|
||||
able to read the four before they do, rather than a count of them and a code. That
|
||||
is why the garments themselves are the row's second line. Nothing declined is
|
||||
listed — a knocked-back garment never reaches the trolley. */}
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 4, lineHeight: 1.4 }}>
|
||||
{b.lines.map(lineText).join(", ")}
|
||||
</div>
|
||||
<ChipRow>
|
||||
<ChipAction label="Sign" disabled={busy || !!prog} onClick={() => void sign(b.id)} />
|
||||
</ChipRow>
|
||||
</EdgeRow>
|
||||
))}
|
||||
{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: 13, marginTop: 3 }}>
|
||||
Signed {b.signedAt ? formatInZone(b.signedAt, tz, { hour: "2-digit", minute: "2-digit", hour12: false }) : ""} by {b.signerName}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 800, letterSpacing: "0.05em", textTransform: "uppercase",
|
||||
background: "var(--color-divider)", color: INK, padding: "5px 8px", whiteSpace: "nowrap",
|
||||
}}>Done</span>
|
||||
</div>
|
||||
</DoneRow>
|
||||
))}
|
||||
{/* Under the signed rows rather than instead of them: the desk wants this morning's work
|
||||
still on the screen AND to be told it is finished. Drawn whenever nothing is left to
|
||||
sign, which is what the mockup does once the last bag has a name against it. */}
|
||||
{toSign.length === 0 && (
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<MEmpty title="Every bag is signed" sub="Whoever signs appears on the requester’s order." />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<>
|
||||
<Band label="Raised by you · still open" />
|
||||
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
|
||||
{raised.map((r) => {
|
||||
const st = statusText(r, { mine: false, first: r.subjectName?.split(" ")[0] });
|
||||
return (
|
||||
<EdgeRow key={r.id} tone="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: 13, color: N600, marginTop: 4 }}>
|
||||
{[st.label, st.note].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</EdgeRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ height: 12 }} />
|
||||
</Team>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
/* The screen you land on when something has gone to your approver.
|
||||
*
|
||||
* It exists because the old flow jumped straight to the order, which opens on "Awaiting approval"
|
||||
* and nothing else: the one question somebody has the second after tapping Send — has anyone
|
||||
* actually been told? — was answered by a status word that says only that nobody has decided yet.
|
||||
*
|
||||
* So the sentence under "What happens next" is computed from what this server can really do
|
||||
* (`notifyWays()` — a registered phone, a working mail server, or neither), not from what is
|
||||
* usually true elsewhere. A screen that promises a notification nobody configured is how a request
|
||||
* sits for three weeks.
|
||||
*
|
||||
* No back chevron. Behind this screen is a half-filled form for a request that has already been
|
||||
* raised, and the one thing worse than losing a draft is sending the same ask twice.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { MBar, MBody, MRule, MTop } from "@/components/m";
|
||||
import { INK, N600 } from "@/components/staffui";
|
||||
|
||||
export type SentAction = { label: string; href: string };
|
||||
|
||||
export default function Sent({ title = "Sent", headline, sub, next, actions, bar }: {
|
||||
/** App-bar title. */
|
||||
title?: string;
|
||||
/** The 26/900 line: "Sent to" and whoever the approver is. */
|
||||
headline: string;
|
||||
/** What was asked for, and the code it was given. */
|
||||
sub?: string;
|
||||
/** One line under "What happens next" — never a paragraph. */
|
||||
next?: string;
|
||||
/** The outlined buttons, in order. */
|
||||
actions?: SentAction[];
|
||||
/** The 64px bar at the foot. */
|
||||
bar: SentAction;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<MTop title={title} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "26px 16px 0" }}>
|
||||
<h2 style={{
|
||||
fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 26, letterSpacing: "-0.015em",
|
||||
lineHeight: 1.1, margin: 0,
|
||||
}}>{headline}</h2>
|
||||
{sub && <div style={{ fontSize: 14, color: N600, marginTop: 6, lineHeight: 1.5 }}>{sub}</div>}
|
||||
|
||||
{next && (
|
||||
/* The mockup's `.notice`: a full 2px ink border rather than staffui's accent edge,
|
||||
which is the linen room's voice and this is not from them. */
|
||||
<div style={{ border: `2px solid ${INK}`, background: "#fff", padding: "12px 14px", marginTop: 18 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: N600 }}>
|
||||
What happens next
|
||||
</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.5, marginTop: 6 }}>{next}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions && actions.length > 0 && (
|
||||
<div style={{ display: "grid", gap: 10, marginTop: 14 }}>
|
||||
{actions.map((a) => (
|
||||
<Link
|
||||
key={a.href + a.label}
|
||||
href={a.href}
|
||||
className="tcx-bar"
|
||||
style={{
|
||||
minHeight: 52, width: "100%", border: `2px solid ${INK}`, background: "transparent",
|
||||
color: INK, textDecoration: "none", font: "inherit", fontFamily: "var(--font-heading)",
|
||||
fontWeight: 800, fontSize: 14, letterSpacing: "0.05em", textTransform: "uppercase",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "0 14px",
|
||||
}}
|
||||
>{a.label}</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MBody>
|
||||
<MBar label={bar.label} href={bar.href} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"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 word here is as fresh as the last
|
||||
* stocktake of that garment, which is what the one closing line says and why no row implies a live
|
||||
* figure.
|
||||
*
|
||||
* Every row is a way on. A size that is not there leads to the waitlist; a size that is leads to
|
||||
* the request screen with the garment and size already chosen. A row that did neither left somebody
|
||||
* who had just found their size with nothing to tap.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { MBody, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
import { DIVIDER, INK, Kicker, N600, N700, StockTag } from "@/components/staffui";
|
||||
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]);
|
||||
|
||||
// The most recent stocktake anywhere in what is on screen: one date, in one line, at the foot.
|
||||
const counted = shown.flatMap((i) => i.sizes).map((s) => s.countedOn).filter(Boolean).sort().reverse()[0] || "";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* A detail screen: a chevron, and no tab bar. The bar belongs to the five tab roots, and on
|
||||
this screen it marked none of them as current — five unselected tabs and no aria-current
|
||||
tell a screen reader the app is on no tab at all, and eat 66px of a shelf list. */}
|
||||
<MTop title="On the shelf" back backHref="/my" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<Kicker>Wards see words, not counts</Kicker>
|
||||
{/* 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%", marginTop: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
{shown.length === 0 && (
|
||||
<div style={{ padding: "26px 0", fontSize: 14, color: N600, lineHeight: 1.6 }}>
|
||||
Nothing matches “{q}”.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shown.map((it) => (
|
||||
<div key={it.id}>
|
||||
<MSection label={`${it.item}${it.gender && it.gender !== "Unisex" ? ` · ${it.gender}` : ""}`} />
|
||||
{it.sizes.map((s) => {
|
||||
const mine = !!it.recorded && String(it.recorded) === String(s.size);
|
||||
const none = s.word === "none";
|
||||
const href = none
|
||||
? `/my/waitlist?item=${encodeURIComponent(it.id)}&si=${s.si}`
|
||||
: `/my/request?item=${encodeURIComponent(it.id)}&si=${s.si}`;
|
||||
return (
|
||||
<Link
|
||||
key={s.si}
|
||||
href={href}
|
||||
className="tcx-bar"
|
||||
aria-label={`${it.item}, size ${s.size}${mine ? ", your size" : ""}. ${none ? "None on shelf — join the waitlist" : "Ask for one"}`}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 12, minHeight: 60, padding: "9px 0",
|
||||
width: "100%", borderBottom: `1px solid ${DIVIDER}`, color: INK,
|
||||
textDecoration: "none", font: "inherit", background: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 15, fontWeight: 700 }}>
|
||||
{s.size}{mine ? " · your size" : ""}
|
||||
</span>
|
||||
<StockTag word={s.word} />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "16px 0 0", margin: 0 }}>
|
||||
Availability comes from the last linen-room stocktake{counted ? ` — most recently ${fmtDate(counted)}` : ""}.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
/* The Team shell: one app bar, one tab row, one nav, four screens inside it.
|
||||
*
|
||||
* Approvals, Round, Ward and Raise are four views of the same job, and before this they were four
|
||||
* separate screens with a back chevron and a two-item nav of their own — a manager with an empty
|
||||
* queue could not reach any of them without typing the address. The tabs are real links to the
|
||||
* routes that already existed, so the back button, a screen reader and the emailed approval link
|
||||
* all keep working.
|
||||
*
|
||||
* Which tabs a reader gets comes from teamTabs() in lib/staffreq.ts, off the same two counts the
|
||||
* tab badge is built from. That is the point of it living there: a tab drawn from one fact and a
|
||||
* route fenced on another is a tab that 404s, which is exactly what this shell is here to stop.
|
||||
*
|
||||
* Review is deliberately NOT in here. It is a detail screen — it gets a back chevron, like the
|
||||
* order, the thread and the request — because a decision screen with a tab row invites somebody to
|
||||
* wander off it mid-decision.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { MBody, MONO, MRule, MTop } from "@/components/m";
|
||||
import { INK, Kicker, SegmentLinks } from "@/components/staffui";
|
||||
import StaffNav from "@/components/staffnav";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { teamTabs, teamTitle } from "@/lib/staffreq";
|
||||
|
||||
/** A section band: the heading a list hangs off, with an optional mono note at the right.
|
||||
*
|
||||
* The four screens in this shell each had their own copy of this div. One of them is enough, and
|
||||
* it is exported because the bands are the shell's own furniture as much as the tab row is. */
|
||||
export function Band({ label, right, tone = "quiet" }: {
|
||||
label: string; right?: React.ReactNode; tone?: "quiet" | "attention";
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "baseline", gap: 10,
|
||||
padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: "var(--color-bg)",
|
||||
}}>
|
||||
<span style={{ flex: 1, minWidth: 0 }}><Kicker tone={tone}>{label}</Kicker></span>
|
||||
{right !== undefined && right !== null && (
|
||||
<span style={{ fontFamily: MONO, fontSize: 13, fontWeight: 500, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>
|
||||
{right}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The decision buttons that sit inside a row — Approve 3 / Open, Nudge / Collected, Sign.
|
||||
*
|
||||
* Not CompactAction: these share the width of the row between them (the mockup's `.decide .chip`,
|
||||
* flex:1), where CompactAction is a fixed-width control sitting beside a block of text. Same 2px
|
||||
* ink border, same uppercase label, 46px rather than the 44px floor. The small variant is the
|
||||
* decline reasons — three across a phone, directly under the garment line they decline — and it
|
||||
* sat at 42 until somebody measured it. Nothing anyone taps in this app goes under 44. */
|
||||
export function ChipAction({ label, onClick, href, disabled, small }: {
|
||||
label: string; onClick?: () => void; href?: string; disabled?: boolean;
|
||||
/** Three across rather than two — the decline reasons, which are phrases rather than words. */
|
||||
small?: boolean;
|
||||
}) {
|
||||
const st: React.CSSProperties = {
|
||||
flex: 1, minWidth: 0, minHeight: small ? 44 : 46, padding: "0 10px", border: `2px solid ${INK}`, borderRadius: 0,
|
||||
background: "#fff", color: INK, font: "inherit", fontSize: small ? 13 : 15, fontWeight: 800,
|
||||
lineHeight: 1.25,
|
||||
display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center",
|
||||
textDecoration: "none", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1,
|
||||
};
|
||||
// A Link, not an anchor: a full page load on "Open" or "Nudge" throws away the list the reader
|
||||
// is standing in and re-fetches the whole screen on ward wifi.
|
||||
if (href && !disabled) return <Link href={href} style={st}>{label}</Link>;
|
||||
return <button type="button" onClick={onClick} disabled={disabled} style={st}>{label}</button>;
|
||||
}
|
||||
|
||||
/** The row of decision buttons. */
|
||||
export function ChipRow({ children }: { children: React.ReactNode }) {
|
||||
return <div style={{ display: "flex", gap: 8, marginTop: 10 }}>{children}</div>;
|
||||
}
|
||||
|
||||
export default function Team({ active, children, foot }: {
|
||||
/** The href of the tab being shown, so the segment row can mark it. */
|
||||
active: string;
|
||||
children: React.ReactNode;
|
||||
/** Anything that belongs below the scrolling body and above the nav — Round's "sign for all",
|
||||
* Raise's send bar. Bars cannot live inside MBody or they scroll away with the list. */
|
||||
foot?: React.ReactNode;
|
||||
}) {
|
||||
const { me, counts } = useStaff();
|
||||
const tabs = teamTabs(me, counts);
|
||||
const single = tabs.length === 1 ? tabs[0] : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={teamTitle(me, counts)} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
{/* One tab is not a choice, and a single inverted button that does nothing when pressed is
|
||||
a control that lies. A clerk who only signs for the trolley gets the band instead — and
|
||||
no tabs at all draws nothing, rather than an empty <nav>: an approver who has just
|
||||
cleared the last request in their queue still stands on this screen. */}
|
||||
{tabs.length === 0
|
||||
? null
|
||||
: single
|
||||
? <Band label={single.label} right={single.count ? String(single.count) : undefined} />
|
||||
: <SegmentLinks label="Team" options={tabs} active={active} />}
|
||||
{children}
|
||||
</MBody>
|
||||
{foot}
|
||||
<StaffNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"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, N700 } from "@/components/staffui";
|
||||
import { MBody, MError, MRule, MTop } from "@/components/m";
|
||||
import { useDraft, 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[];
|
||||
/** The linen room has said something on this thread that nobody here has opened. */
|
||||
unreadRoom?: boolean;
|
||||
};
|
||||
|
||||
/* 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();
|
||||
/* The box keeps what was typed, per order, through a dropped send and a walk to another screen.
|
||||
*
|
||||
* Ward wifi drops mid-sentence, and the one thing worse than a message that did not send is a
|
||||
* message that did not send and is gone. sessionStorage, so it is the tab's unfinished business
|
||||
* and not a record. */
|
||||
const draft = useDraft(`msg.${data.id}`);
|
||||
const [err, setErr] = useState("");
|
||||
const [sent, setSent] = useState<Msg[]>([]);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* Opening the thread is what marks it read — once, and only when there is something to mark.
|
||||
*
|
||||
* `mutate` refreshes this screen, so the flag comes back false and the effect does not fire
|
||||
* again. A screen that called this on every mount would refresh the page every time anybody
|
||||
* glanced at a thread. */
|
||||
const marked = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!data.unreadRoom || marked.current) return;
|
||||
marked.current = true;
|
||||
void mutate("request.read", { id: data.id });
|
||||
}, [data.unreadRoom, data.id, mutate]);
|
||||
|
||||
/* 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={`Ask about ${data.code}`} back backHref={`/my/orders/${data.id}`} />
|
||||
<MRule />
|
||||
<ContextStrip>
|
||||
{/* Which order this thread belongs to: the code and the status word, then the summary
|
||||
rather than the lines — a request for four garments would push the first message off
|
||||
the screen, and whoever needs the detail is one tap away on the order itself. */}
|
||||
<span style={{ display: "block", fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: N700 }}>
|
||||
{data.code} · {st.label}
|
||||
</span>
|
||||
<span style={{ display: "block", fontSize: 14, color: "var(--color-text)", marginTop: 3 }}>
|
||||
{data.summary}
|
||||
</span>
|
||||
</ContextStrip>
|
||||
<MBody>
|
||||
{all.length === 0 && (
|
||||
<div style={{ padding: "26px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
|
||||
Nothing yet. Ask anything about this order.
|
||||
</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={draft.value}
|
||||
onChange={(v) => { draft.set(v); setErr(""); }}
|
||||
busy={busy}
|
||||
placeholder="Ask about this order"
|
||||
onSend={async () => {
|
||||
const text = draft.value.trim();
|
||||
if (!text) return;
|
||||
// Cleared optimistically so the box is empty the moment it is sent, and put back below if
|
||||
// the send never left the building — which on ward wifi is the whole reason for the draft.
|
||||
draft.clear();
|
||||
const r = await mutate<{ id: string; at: string }>("request.message", { id: data.id, body: text });
|
||||
if (!r.ok) { setErr(r.error); draft.set(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,189 @@
|
||||
"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.
|
||||
*
|
||||
* Joining and leaving redraw this screen in place (router.refresh) rather than reloading it: the
|
||||
* whole point of the bar is that it answers immediately, and a full reload on ward wifi is a
|
||||
* second or two of white while somebody wonders whether the tap landed.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MBar, MBody, MError, MRule, MSection, MTop } from "@/components/m";
|
||||
import { DIVIDER, INK, Kicker, N600, N700, StockTag } from "@/components/staffui";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { fmtDate, formatInZone } from "@/lib/compute";
|
||||
|
||||
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 router = useRouter();
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
/* Optimistic, then corrected by the refresh that follows it. The server's answer always wins:
|
||||
* without the effect a stale local flag would outlive the redraw and offer "Leave the list" to
|
||||
* somebody who had already left. */
|
||||
const [joined, setJoined] = useState(data.joined);
|
||||
useEffect(() => { setJoined(data.joined); }, [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" })
|
||||
: "";
|
||||
|
||||
const headline = accepted ? "It’s with your manager"
|
||||
: heldForMe ? "Held for you"
|
||||
: lapsed ? "The hold has run out"
|
||||
: joined ? "You are on the list"
|
||||
: "None on the shelf";
|
||||
|
||||
const noticeKicker = accepted ? "Accepted" : heldForMe ? "It’s in" : lapsed ? "Your place" : joined ? "Your place" : "If you join";
|
||||
const noticeLine = accepted
|
||||
? `Accepted ${formatInZone(acceptedAt || "", me.tz)}. It goes to your manager for approval like any other request.`
|
||||
: heldForMe
|
||||
? `${heldUntil ? `Held for you until ${heldUntil}.` : "Held for you."} Accepting sends it to your manager like any other request.`
|
||||
: lapsed
|
||||
? `Nobody took it before the hold ran out, so you are ${ordinal(data.position)} in queue again and the linen room can offer it a second time.`
|
||||
: joined
|
||||
? `${ordinal(data.position)} in queue. The linen room tells you when one is held for you.`
|
||||
: `You would be ${ordinal(data.position)} in queue. ${data.ahead === 0 ? "Nobody is waiting yet." : data.ahead === 1 ? "One person is already waiting." : `${data.ahead} people are already waiting.`}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Waitlist" back backHref="/my/shelf" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<Kicker>{data.item} · {data.size}</Kicker>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 24, letterSpacing: "-0.015em", lineHeight: 1.1, marginTop: 4 }}>
|
||||
{headline}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<div style={{ border: `2px solid ${INK}`, background: "#fff", padding: "12px 14px", marginTop: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: N600 }}>{noticeKicker}</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.5, marginTop: 4 }}>{noticeLine}</div>
|
||||
</div>
|
||||
|
||||
{accepted && (
|
||||
<div style={{ fontSize: 13, lineHeight: 1.6, color: N700, marginTop: 12 }}>
|
||||
Follow it on <Link href="/my/orders" style={{ color: INK, fontWeight: 800 }}>your orders</Link>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!accepted && !heldForMe && data.lastRestocked && (
|
||||
<div style={{ fontSize: 13, lineHeight: 1.6, color: N700, marginTop: 12 }}>
|
||||
Last counted {fmtDate(data.lastRestocked)}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Most people would rather have a size that fits approximately today, so the stocked
|
||||
sizes nearest theirs are the row above the wait, not a footnote under it. Tapping one
|
||||
opens the request screen with that garment and size already chosen. */}
|
||||
{data.alternatives.length > 0 && !heldForMe && !accepted && (
|
||||
<>
|
||||
<MSection label="Or take a stocked size" />
|
||||
{data.alternatives.map((a) => (
|
||||
<Link
|
||||
key={a.si}
|
||||
href={`/my/request?item=${encodeURIComponent(data.itemId)}&si=${a.si}`}
|
||||
className="tcx-bar"
|
||||
aria-label={`Ask for ${data.item}, size ${a.size}`}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 12, minHeight: 60, padding: "9px 0",
|
||||
width: "100%", borderBottom: `1px solid ${DIVIDER}`, color: INK,
|
||||
textDecoration: "none", font: "inherit", background: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 15, fontWeight: 700 }}>{a.size}</span>
|
||||
<StockTag word={a.word} />
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "16px 0 0", margin: 0 }}>
|
||||
Waiting doesn’t need approval. Your manager only sees it if the item comes in and you
|
||||
accept it.
|
||||
</p>
|
||||
</div>
|
||||
<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; }
|
||||
router.push(`/my/orders/${r.result.request.id}`);
|
||||
}}
|
||||
/>
|
||||
) : joined ? (
|
||||
<MBar
|
||||
label={busy ? "Working…" : "Leave the list"}
|
||||
tone="ink"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
const r = await mutate("waitlist.leave", { id: data.entryId });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setJoined(false);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MBar
|
||||
label={busy ? "Joining…" : "Join the list"}
|
||||
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);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,38 @@
|
||||
"use client";
|
||||
/* Team ▸ Ward — who on this manager's 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 one can. Nothing does — the rule is six held at any
|
||||
* time, reached without handing anything in — so the line is there only when somebody is capped.
|
||||
*/
|
||||
import { MRow } from "@/components/m";
|
||||
import { N700 } from "@/components/staffui";
|
||||
import Team, { Band } from "./Team";
|
||||
|
||||
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 (
|
||||
<Team active="/my/ward">
|
||||
{/* The head-count and the column the numbers on the right belong to, on one line. */}
|
||||
<Band label={ward || "Your team"} right={`${rows.length} · Items held`} />
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
{rows.map((r) => (
|
||||
<MRow
|
||||
key={r.id}
|
||||
title={r.name}
|
||||
sub={[r.group, r.capped ? r.setsLabel : ""].filter(Boolean).join(" · ")}
|
||||
right={`${r.held} item${r.held === 1 ? "" : "s"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{anyCapped && (
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
|
||||
Nothing has to be handed back before the next set is issued.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ height: 12 }} />
|
||||
</Team>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user