ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 22:54:09 +10:00
commit a6f1059ddf
424 changed files with 53535 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
"use client";
/* 1J — Your sign-in. One thing on it, and one thing deliberately not on it.
*
* Changing the password is the only revocation a wearer has. A staff token carries a fingerprint of
* the password hash, so setting a new one ends every session signed against the old one at once —
* a phone left on a ward, a cookie copied off it, a password read over somebody's shoulder. That is
* why the copy says so plainly: the consequence is the feature, and someone who does not know it
* happened will not use this when they most need to.
*
* Deleting the account is not here, and is not an oversight. Access is the linen room's to grant
* and theirs to remove: a wearer who could delete their own account would take the record of what
* they were issued with it.
*/
import { DELETE_ACCOUNT_URL, PRIVACY_EMAIL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
import { useState } from "react";
import { MBar, MBody, MError, MRow, MRule, MTop } from "@/components/m";
import { N600, N700, NumberedField } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
const field: React.CSSProperties = {
width: "100%", minHeight: 52, padding: "0 14px", border: "2px solid var(--color-divider)",
borderRadius: 0, font: "inherit", fontSize: 16, background: "#fff", color: "var(--color-text)",
};
export default function AccountScreen({ email }: { email: string }) {
const { mutate, busy } = useStaff();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [err, setErr] = useState("");
const [done, setDone] = useState(false);
// The server enforces the same floor; checking it here only saves a round trip and a refusal.
const ready = current.length > 0 && next.length >= 8;
return (
<>
<MTop title="Your sign-in" back />
<MRule />
<MBody>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ padding: "20px 16px 18px", borderBottom: "2px solid var(--color-text)", background: "var(--color-bg)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", lineHeight: 1.15 }}>
{email}
</div>
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "10px 0 0" }}>
This is the address you sign in with. The linen room sets who you are on the register
your name, ward and sizes come from them, and only they can change them.
</p>
</div>
{done ? (
<div style={{ padding: 16 }}>
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-text)", padding: "14px 16px" }}>
<div style={{ fontSize: 16, fontWeight: 800 }}>Password changed</div>
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "8px 0 0" }}>
Every other device signed in as you has been signed out. This one stays signed in.
</p>
</div>
</div>
) : (
<NumberedField n={1} label="Change your password" first>
{/* Wrapping the box rather than sitting beside it: the two used to be siblings, so
nothing tied the words to the field and both announced as an unnamed password box —
on the one screen where typing in the wrong one of two is silent. */}
<label style={{ display: "block" }}>
<span style={{ display: "block", fontSize: 12.5, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", color: N600 }}>
Current password
</span>
<input
type="password" autoComplete="current-password" value={current}
onChange={(e) => { setCurrent(e.target.value); setErr(""); }}
style={{ ...field, marginTop: 8 }}
/>
</label>
<label style={{ display: "block", marginTop: 16 }}>
<span style={{ display: "block", fontSize: 12.5, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", color: N600 }}>
New password
</span>
<input
type="password" autoComplete="new-password" value={next}
onChange={(e) => { setNext(e.target.value); setErr(""); }}
style={{ ...field, marginTop: 8 }}
/>
</label>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
At least 8 characters. Changing it signs you out everywhere else every other phone or
browser signed in as you stops working straight away. This device stays signed in.
</p>
</NumberedField>
)}
{/* Privacy, and the honest answer about deletion.
*
* Kyle's rule stands — a wearer cannot delete their own account, because the issue history
* it hangs off is the linen room's record and not theirs to take away — but "you can't"
* still has to be said somewhere the person can find it, together with who can. This is
* also what Play looks for: a policy and a data-deletion route reachable from inside the
* app, not only from the store listing.
*
* They are the shared external rows rather than words in a line, because this shell
* registers no plugins at all: target="_blank" opens nothing in there, and no URL can be
* handed to Chrome, so what used to look like three links was three taps that either did
* nothing or dropped a wearer onto a marketing page with no way home. The row loads the
* page here, says before the tap that it will, and the phone's back button returns to the
* app. */}
<div style={{ borderTop: "2px solid var(--color-text)", padding: "18px 16px" }}>
<div style={{ fontSize: 13, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase" }}>
Privacy and your data
</div>
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "10px 0 0" }}>
ThreadCount holds your sign-in and the linen room&rsquo;s record of what you have been
issued. You can&rsquo;t delete this account from here access is the linen room&rsquo;s
to give and theirs to take away, so ask your uniform coordinator and they can remove it
straight away.{PRIVACY_EMAIL ? <> If you would rather not ask them, write to {PRIVACY_EMAIL}.</> : null}
</p>
</div>
{PRIVACY_URL && <MRow href={PRIVACY_URL} external mark="ink" title="Privacy policy" sub="What ThreadCount stores, and what it never does" />}
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external mark="ink" title="Deleting your account" sub="How it is done, and what goes with it" />}
{TERMS_URL && <MRow href={TERMS_URL} external mark="ink" title="Terms of use" sub="What you and ThreadCount each agree to" />}
<div style={{ height: 20 }} />
</MBody>
{!done && (
<MBar
label={busy ? "Saving…" : "Change my password"}
glyph="check"
disabled={!ready || busy}
onClick={async () => {
const r = await mutate("account.password", { current, next });
if (!r.ok) { setErr(r.error); return; }
setCurrent(""); setNext(""); setDone(true);
}}
/>
)}
</>
);
}
+199
View File
@@ -0,0 +1,199 @@
"use client";
/* The approvals queue.
*
* Oldest first, deliberately. The queue's job is to surface the person who has been waiting
* longest, and a newest-first list quietly buries them — which is the failure mode this whole
* flow exists to fix.
*
* A request the manager is the wearer of can reach this queue now. Whoever a request names is who
* decides it, and the server has been allowed to let that be the wearer themselves in the one case
* the owner named. The server owns that decision and this screen never re-tests it. What the screen
* owns is the honesty: an approval somebody gives themselves is set apart from the ones they are
* giving on other people's behalf, says whose uniform it is, and says what the record will call it
* afterwards — so it is never something that happens to a manager mid-scroll and has to be
* explained to an auditor months later.
*
* This queue also reaches people who manage nobody — the linen room re-addresses a request that
* arrived without an approver, or somebody's last report is moved away while their own request is
* still waiting. The server takes their self-approval too (lib/staffops.ts decideRequest dropped
* the reports test), so the copy below no longer tells them the bar will refuse: for a while it
* did, and the bar approved. The only difference `me.isManager` makes here is the wording.
*/
import { MBody, MRule, MTop } from "@/components/m";
import { EdgeRow, GROUND, INK, Kicker, N600, N700, SecondaryBar } from "@/components/staffui";
import ManagerNav from "./ManagerNav";
import { useStaff } from "@/lib/staffclient";
import { daysBetween, facilityDate, facilityToday } from "@/lib/compute";
import type { QueueRow } from "@/lib/managerdata";
/* Calendar days on the ward, not elapsed 24-hour blocks.
*
* Dividing the milliseconds understated every overnight wait: a request raised at 18:00 on Monday
* still read "today" at 09:00 on Tuesday and only became "since yesterday" that evening, by which
* point it had spanned two working days. This queue exists to surface the person who has been
* waiting longest, so the label counts the way they do, in the facility's own zone. */
function waited(iso: string, tz: string) {
const raised = facilityDate(iso, tz);
if (!raised) return ""; // the row's meta line filters empties out
const days = daysBetween(raised, facilityToday(tz));
if (days <= 0) return "today";
if (days === 1) return "since yesterday";
return `waiting ${days} days`;
}
/** The heading over a group, in the ward-round style: a band the list hangs off. */
function Band({ children }: { children: React.ReactNode }) {
return (
<div style={{ padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
{children}
</div>
);
}
function Row({ r, tz, mine, mayApproveOwn }: { r: QueueRow; tz: string; mine: boolean; mayApproveOwn: boolean }) {
return (
/* An ink edge rather than the accent one every other row carries. Accent means somebody else is
waiting on you; your own uniform is not that, and it must not be able to pass for it at a
glance on a phone held in one hand halfway down a ward. */
<EdgeRow tone={mine ? "ink" : "accent"} href={`/my/approvals/${r.id}`}>
<div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
<span style={{ flex: 1, fontSize: 16, fontWeight: 800 }}>{r.subjectName}</span>
<span style={{ fontSize: 12, color: N600 }}>{r.code}</span>
</div>
{/* The group heading above says this too, but it scrolls away and the row is the thing that
gets tapped — so the row carries the fact on its own. */}
{mine && <div style={{ marginTop: 6 }}><Kicker tone="attention">Your own uniform</Kicker></div>}
{/* The summary is the whole ask in one line — "5 garments · Tunic, Trousers,
Fleece". The garments themselves are on the review screen, which is where the
decision is made; a queue that listed every line would bury the person who has
been waiting longest under somebody else's four-garment request. */}
<div style={{ fontSize: 15, fontWeight: 600, marginTop: 6, lineHeight: 1.35 }}>
{r.summary}
</div>
<div style={{ fontSize: 12.5, color: N600, marginTop: 5, lineHeight: 1.45 }}>
{[r.reason, r.subjectGroup, waited(r.createdAt, tz)].filter(Boolean).join(" · ")}
{r.raisedByName ? ` · raised by ${r.raisedByName}` : ""}
</div>
{mine && (
<div style={{ fontSize: 12.5, color: N700, marginTop: 5, lineHeight: 1.45 }}>
{mayApproveOwn
? "Approving it is recorded as your own approval."
: "Not yours to approve — it needs another manager."}
</div>
)}
</EdgeRow>
);
}
export default function ApprovalsScreen({ rows, ownIds = [] }: { rows: QueueRow[]; ownIds?: string[] }) {
const { me } = useStaff();
/* Which of these are the manager's own is settled on the server, from the request's own subject.
Working it out here by matching a name would start calling a stranger's request yours the day
two people on the register share one — and the thing being labelled is an audit fact. */
const own = new Set(ownIds);
const mine = rows.filter((r) => own.has(r.id));
const theirs = rows.filter((r) => !own.has(r.id));
return (
<>
<MTop
title="Approvals"
back
right={rows.length ? <span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{rows.length} waiting</span> : undefined}
/>
<MRule />
<MBody>
{rows.length === 0 ? (
<div style={{ padding: "28px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
Nothing waiting on you. Requests from your team arrive by email, and land here too.
</div>
) : (
<>
{theirs.length > 0 && (
<>
{/* Headed only when there is something to tell it apart from. With nothing of the
manager's own waiting, this is simply the queue, and a band over the whole of
it would be furniture. */}
{mine.length > 0 && <Band><Kicker>Everyone else</Kicker></Band>}
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{theirs.map((r) => <Row key={r.id} r={r} tz={me.tz} mine={false} mayApproveOwn />)}
</div>
</>
)}
{/* The manager's own go last, whatever date they were raised. Oldest first is a promise
to the colleague who has been waiting longest, and a request for your own uniform
does not step in front of her — and a group at the foot of the screen, under its own
heading, is not somewhere a thumb arrives by accident on the way down the list of
your team's. */}
{mine.length > 0 && (
<>
<Band>
<Kicker tone="attention">{mine.length === 1 ? "Your own request" : "Your own requests"}</Kicker>
</Band>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "12px 16px 0", margin: 0 }}>
{me.isManager ? (
<>
{mine.length === 1
? "These garments are for you. You may approve them yourself, and it is recorded as a managers approval you gave yourself"
: "These are for you. You may approve them yourself, and each one is recorded as a managers approval you gave yourself"}
{" — the request says so on its own history, and so does the record anybody reads afterwards."}
</>
) : (
/* The server takes a self-approval from anybody the request is addressed to — the
reports test was dropped (lib/staffops.ts decideRequest) — so this can't go on
promising a refusal that never comes. */
<>
{mine.length === 1
? "These garments are for you. Nobody reports to you at the moment, but this one was addressed to you, so it is yours to decide — and it is recorded as an approval you gave yourself"
: "These are for you. Nobody reports to you at the moment, but they were addressed to you, so they are yours to decide — and each one is recorded as an approval you gave yourself"}
{" — the request says so on its own history, and so does the record anybody reads afterwards."}
</>
)}
</p>
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{mine.map((r) => <Row key={r.id} r={r} tz={me.tz} mine mayApproveOwn />)}
</div>
</>
)}
</>
)}
{/* The only route left for putting a request in somebody else's name: a manager, for the
people who report to them. The server sends a raise of theirs up a level, to their own
manager, so nobody ever decides what they typed themselves.
Offered only to somebody who actually has a team. This queue also reaches people who
manage nobody — the linen room re-addresses a request that arrived without an approver,
or a manager's last report is moved away while their request is still waiting — and
/my/raise turns exactly those people away with a 404. Inviting them to a screen that
refuses them is worse than not mentioning it, so the invitation and the sentence
explaining it appear together or not at all. */}
{me.isManager && (
<>
<div style={{ padding: "16px 16px 0" }}>
<SecondaryBar label="Raise for someone on your team" href="/my/raise" />
</div>
{/* The second sentence would read as a bug to the one person it is wrong for: a manager
looking straight at a request of her own on a screen telling her such a thing always
goes somewhere else. So when one is on the screen it says what is actually there. */}
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
Nothing reaches the linen room until you approve it.{" "}
{mine.length === 0
? "Anything you raise yourself goes to your own manager instead."
: mine.length === 1
? "One of these is your own, and it is yours to decide — the record will show you were the one who approved it."
: "Some of these are your own, and they are yours to decide — the record will show you were the one who approved them."}
</p>
</>
)}
{!me.isManager && (
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
Nothing reaches the linen room until you approve it.
</p>
)}
<div style={{ height: 12 }} />
</MBody>
<ManagerNav />
</>
);
}
+196
View File
@@ -0,0 +1,196 @@
"use client";
/* Approving or declining straight from the email, on whatever device opened it.
*
* This is the centred page shell rather than the phone column: a manager reaches it from a mail
* client, as often on a desktop as on a ward phone, and dropping them into an app chrome they
* never signed into would be a strange thing to meet.
*
* Nothing has happened when this page loads. That is the point of the design: the link renders,
* the button decides.
*/
import { useState } from "react";
import { Err, MyShell, h1, kicker, lead, primary } from "@/components/my";
import { DECLINE_REASONS } from "@/lib/staffreq";
import type { ReqLine } from "@/lib/staffdata";
type Data = {
code: string; subjectName: string; subjectMeta: string;
/** The whole ask, in the order it was entered. */
lines: ReqLine[];
/** linesSummary() — the one-liner every other screen leads with. */
summary: string;
reason: string; note: string;
raisedByName: string; facility: string;
};
const INK = "#201e1d";
const N600 = "var(--color-neutral-600)";
const N700 = "var(--color-neutral-700)";
const ACCENT_700 = "var(--color-accent-700)";
/** The garments, one row each. Deliberately drawn here rather than borrowed from the staff app's
* own list: this page is the centred desktop shell, not the phone column, and its type sizes and
* rules are a size up from everything in components/staffui. What it must match is the *content* —
* the same order, the same strike-through on a refusal, the same reason against it. */
function Lines({ lines }: { lines: readonly ReqLine[] }) {
return (
<div style={{ display: "grid", gap: 1, background: "var(--color-divider)", marginTop: 16 }}>
{lines.map((l) => {
const off = l.status === "declined";
return (
<div key={l.id} style={{ background: "#fff", padding: "14px 16px" }}>
<div style={{
fontSize: 17, fontWeight: 800, lineHeight: 1.3,
textDecoration: off ? "line-through" : "none", color: off ? N600 : INK,
}}>{l.qty} × {l.item} {l.size}</div>
{off && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_700, marginTop: 6 }}>
Declined{l.declineReason ? `${l.declineReason}` : ""}
</div>
)}
</div>
);
})}
</div>
);
}
export default function ApproveByLink({ token, data, decided, status, declineReason }: {
token: string; data: Data; decided: boolean; status: string; declineReason: string | null;
}) {
const [done, setDone] = useState<null | { approved: boolean; reason?: string; notified: boolean }>(null);
const [declining, setDeclining] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
async function decide(action: "approve" | "decline", reason?: string) {
setBusy(true); setErr("");
const r = await fetch("/api/staff/decide", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ token, action, reason }),
}).catch(() => null);
const j = await r?.json().catch(() => ({}));
setBusy(false);
// Already decided — the other link in the email was used, or this one twice. The route says
// so with `already`, and the server render of this page shows the decision that stands, so
// reload into that rather than sit on a red error with two live buttons under it.
if (j?.already) { window.location.reload(); return; }
if (!r || !r.ok) { setErr(j?.error || "That didnt work."); return; }
setDone({ approved: action === "approve", reason, notified: !!j?.notified });
}
if (done) {
return (
<MyShell>
<div style={kicker}>ThreadCount</div>
<h1 style={h1}>{done.approved ? "Approved." : "Declined."}</h1>
<p style={lead}>
{/* "Has been told" only when an email actually left — a wearer with no account or a site
with no mail hears nothing from this, and saying otherwise is how they wait a fortnight. */}
{done.approved
? `${done.notified ? `${data.subjectName.split(" ")[0]} has been told, and` : `${data.subjectName.split(" ")[0]} hasnt been emailed — its on their record in the app — and`} ${data.lines.length > 1 ? "all of it is" : "its"} with the linen room now.`
: `${done.notified ? `${data.subjectName.split(" ")[0]} has been told` : `${data.subjectName.split(" ")[0]} hasnt been emailed, so mention it to them`}${(done.reason || "").toLowerCase()}.`}
</p>
<p style={{ ...lead, fontSize: 13.5, color: N600 }}>
You can close this. Nothing else is waiting on you here.
</p>
</MyShell>
);
}
if (decided) {
return (
<MyShell>
<div style={kicker}>{data.facility}</div>
<h1 style={h1}>Already decided.</h1>
<p style={lead}>
{status === "declined"
? `This request was declined${declineReason ? `${declineReason.toLowerCase()}` : ""}.`
: "This request has already been approved and is with the linen room."}
</p>
{/* Which garments went and which didn't. A manager coming back to a request they settled
on their phone deserves the same answer here as the wearer gets on their order — the
alternative is a page that says "approved" over an ask where a third of it was refused. */}
<Lines lines={data.lines} />
<p style={{ ...lead, fontSize: 13.5, color: N600 }}>
Approval links work once. Open the app if you need to look at it again.
</p>
</MyShell>
);
}
return (
<MyShell>
<div style={kicker}>{data.facility} · {data.code}</div>
<h1 style={h1}>{data.subjectName} needs your approval.</h1>
{data.subjectMeta && <p style={{ ...lead, marginTop: 8, fontSize: 13.5, color: N600 }}>{data.subjectMeta}</p>}
<div style={{ border: `2px solid ${INK}`, padding: 18, marginTop: 24, background: "#fff" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "-0.01em", lineHeight: 1.2 }}>
{data.summary}
</div>
{data.reason && <div style={{ fontSize: 14, color: N700, marginTop: 8 }}>{data.reason}</div>}
{data.note && <p style={{ fontSize: 14, lineHeight: 1.55, color: N700, margin: "10px 0 0" }}>{data.note}</p>}
{data.raisedByName && (
<p style={{ fontSize: 13, color: N600, margin: "10px 0 0" }}>Raised for them by {data.raisedByName}.</p>
)}
</div>
{/* The summary above is a count and three names; a manager about to approve four garments
needs to see the four. One garment needs no list — the heading already is one. */}
{data.lines.length > 1 && <Lines lines={data.lines} />}
{err && <div style={{ marginTop: 16 }}><Err>{err}</Err></div>}
{declining ? (
<div style={{ marginTop: 24 }}>
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_700 }}>
Why are you declining?
</div>
<p style={{ ...lead, marginTop: 8, fontSize: 13.5 }}>
{data.lines.length > 1
? `This turns down all ${data.lines.length} garments. ${data.subjectName.split(" ")[0]} is told which reason you picked.`
: `${data.subjectName.split(" ")[0]} is told which one you picked.`}
</p>
<div style={{ display: "grid", gap: 2, marginTop: 14 }}>
{DECLINE_REASONS.map((r) => (
<button key={r} disabled={busy} onClick={() => decide("decline", r)} style={{
minHeight: 56, background: "#fff", color: INK, border: `2px solid ${INK}`, borderRadius: 0,
textAlign: "left", padding: "0 16px", font: "inherit", fontSize: 15.5, fontWeight: 800,
cursor: busy ? "wait" : "pointer", opacity: busy ? 0.6 : 1,
}}>{r}</button>
))}
</div>
<button onClick={() => { setDeclining(false); setErr(""); }} style={{
marginTop: 16, minHeight: 48, padding: "0 20px", background: "transparent", color: INK,
border: `2px solid ${INK}`, borderRadius: 0, font: "inherit", fontWeight: 800, fontSize: 13,
letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
}}>Back</button>
</div>
) : (
<div style={{ display: "grid", gap: 2, marginTop: 24 }}>
<button disabled={busy} onClick={() => decide("approve")} style={primary(busy)}>
{busy ? "Working…" : "Approve"}
</button>
<button disabled={busy} onClick={() => setDeclining(true)} style={{
minHeight: 56, background: "transparent", color: INK, border: `2px solid ${INK}`, borderRadius: 0,
font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14,
letterSpacing: "0.08em", textTransform: "uppercase", textAlign: "left", padding: "0 20px",
cursor: busy ? "wait" : "pointer",
}}>Decline</button>
</div>
)}
{/* The one thing this page cannot do. Signed in, a manager approves the tunic and turns down
the fleece on the same request; from an email link there is no signed-in person to check
a per-garment decision against, so it is deliberately the whole ask either way. A manager
who wants part of it has to be told where that lives rather than left approving three
garments to get one of them through. */}
<p style={{ ...lead, fontSize: 13, color: N600, marginTop: 24 }}>
Nothing has been decided yet this page just shows you the request. The link works once,
and it settles {data.lines.length > 1 ? "the whole request" : "it"} one way or the other.
{data.lines.length > 1 && " To approve some garments and not others, open the app."}
</p>
</MyShell>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
/* 1G — Report damage. Two jobs in one screen: take the garment off my record, and start the
* replacement.
*
* The two are deliberately separate acts. Reporting damage does not issue anything and does not
* silently remove the garment — it comes off the record when it is handed in at the counter. A
* screen that wrote off a garment on somebody's say-so would be a screen the linen room stops
* trusting, and a screen that quietly issued a replacement would route around the manager.
*
* `Contaminated` is not in the list. Clinically it is a different pathway — red bag, no return to
* the counter — and telling someone to carry a contaminated garment to the linen room would be
* worse than saying nothing. Wards use the route they already have.
*/
import { useState } from "react";
import { MBar, MBody, MError, MRule, MTop } from "@/components/m";
import { DarkCard, N600, N700, NumberedField, OptionList, StockTag, Toggle } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { DAMAGE_KINDS } from "@/lib/staffreq";
type Holding = {
issueId: string; itemId: string; item: string; size: string; si: number; qty: number;
labelId: string; issued: string; replacement: string;
};
export default function DamageScreen({ holdings, managerName }: { holdings: Holding[]; managerName: string }) {
const { mutate, busy } = useStaff();
const [issueId, setIssueId] = useState<string | null>(null);
const [kind, setKind] = useState<string | null>(null);
const [note, setNote] = useState("");
/* The toggle defaults on, because asking for a replacement is what almost everybody reporting a
* torn tunic actually wants — but only when there is somebody to ask. With no manager recorded
* the request half cannot be raised at all, so defaulting it on left people tapping "Report and
* request" and getting an error they could do nothing about. */
const canRequest = !!managerName;
const [replace, setReplace] = useState(canRequest);
const [err, setErr] = useState("");
/* The server's sentence for a report it saved without a replacement behind it — almost always a
* garment whose range has been withdrawn, which cannot be ordered but is still on somebody's
* back. Nothing failed, so it is not an error; but the screen used to go straight to the kit
* list on a plain success and the nurse walked away expecting a replacement nobody had ordered. */
const [noReplacement, setNoReplacement] = useState("");
const held = holdings.find((h) => h.issueId === issueId) || null;
const ready = !!held && !!kind;
if (noReplacement) {
return (
<>
<MTop title="Reported" />
<MRule />
<MBody>
<div style={{ padding: 16 }}>
<DarkCard kicker="Reported" title="No replacement has been ordered" meta={noReplacement} />
</div>
</MBody>
<MBar label="Back to your kit" href="/my/kit" />
</>
);
}
return (
<>
<MTop title="Report damage" back />
<MRule />
<MBody>
<NumberedField n={1} label="Which item" first>
{holdings.length === 0 ? (
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>
Nothing on your record to report.
</p>
) : (
<OptionList
value={issueId}
onPick={(k) => { setIssueId(k); setErr(""); }}
options={holdings.map((h) => ({
key: h.issueId,
label: `${h.item}${h.size}`,
// The label id is what the linen room reads off the garment in their hand, so a
// row here can be matched to a physical thing.
meta: `${h.labelId} · issued ${h.issued}`,
}))}
/>
)}
</NumberedField>
{held && (
<NumberedField n={2} label="What happened">
<OptionList
columns={2}
value={kind}
onPick={(k) => { setKind(k); setErr(""); }}
options={DAMAGE_KINDS.map((d) => ({ key: d, label: d }))}
/>
<textarea
value={note} onChange={(e) => setNote(e.target.value)} rows={3}
aria-label="Anything the linen room should know (optional)"
placeholder="Anything the linen room should know (optional)"
style={{ width: "100%", minHeight: 84, marginTop: 14, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 15, resize: "none", background: "#fff", color: "var(--color-text)" }}
/>
</NumberedField>
)}
{held && kind && (
<NumberedField n={3} label="Replacement">
<div style={{ display: "flex", gap: 14, alignItems: "center", background: "#fff", padding: "14px 16px" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800 }}>Request a replacement</div>
<div style={{ fontSize: 12, color: N600, marginTop: 4 }}>
{held.item} {held.size} · <StockTag word={held.replacement} />
</div>
</div>
<Toggle on={replace && canRequest} onChange={setReplace} disabled={!canRequest} label="Request a replacement" />
</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
The damaged item comes off your record when you hand it in at the counter.
{!canRequest
? " Nobody is recorded as your approver yet, so a replacement cant be asked for here — report it, and ask the linen room to set your manager on your staff record."
: replace ? ` The replacement goes to ${managerName} for approval first.` : ""}
</p>
</NumberedField>
)}
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ height: 12 }} />
</MBody>
<MBar
label={busy ? "Sending…" : replace && canRequest ? "Report and request" : "Report it"}
disabled={!ready || busy}
onClick={async () => {
if (!held || !kind) return;
const r = await mutate<{ replacement: { id: string } | null; replacementNote?: string }>("damage.report", {
issueId: held.issueId, kind, note, replace: replace && canRequest,
});
if (!r.ok) { setErr(r.error); return; }
// Optional on the type because the app in somebody's pocket can be older or newer than
// the server it is talking to; an absent note just means there was nothing to explain.
const why = (r.result.replacementNote || "").trim();
if (!r.result.replacement && why) { setNoReplacement(why); return; }
window.location.assign(r.result.replacement ? `/my/orders/${r.result.replacement.id}` : "/my/kit");
}}
/>
</>
);
}
+352
View File
@@ -0,0 +1,352 @@
"use client";
/* 2C — Raise for someone you manage.
*
* A manager standing next to somebody who will not type it in themselves: they pick the person,
* build the list, and the record says both names. The ward desk once had a screen of its own for
* the same job, on the grounds that half a ward would never install anything; it is gone, because
* the manager is already the person that ward would have asked.
*
* The rule that shapes it: **nobody approves their own raise.** The approver is the subject's
* manager — which here is the person typing — so the server sends it a level up, and this screen
* says so by name before the button is pressed. A manager who could approve a request they typed
* themselves would be no approval at all.
*/
import { useMemo, useRef, useState } from "react";
import { MBar, MBody, MError, MRule, MTop, inputStyle } from "@/components/m";
import {
ACCENT_300, DarkCard, DraftLineList, EdgeRow, GarmentPicker, GROUND, INK, Kicker, N500, N600, N700,
NumberedField, OptionList, type DraftLine,
} from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { REQUEST_REASONS, statusText } from "@/lib/staffreq";
import type { ReqRow } from "@/lib/staffdata";
type Person = {
id: string; name: string; num: string; group: string; hasApp: boolean;
recordedTop: string; recordedPants: string;
held: Record<string, number>; lastSizes: Record<string, string>;
};
type Size = { size: string; si: number; word: string; countedOn: string };
type Item = { id: string; item: string; type: string; gender: string; sizes: Size[]; recorded: string; isTop: boolean; isPant: boolean };
export default function DeskScreen({ people, items, raised, maxLines, maxQty }: {
/** The people who report to whoever is raising, which is exactly the set the server accepts. */
people: Person[]; items: Item[];
/** What they have raised for other people and not yet seen the end of. */
raised: ReqRow[];
maxLines: number; maxQty: number;
}) {
const { mutate, busy } = useStaff();
const [q, setQ] = useState("");
const [personId, setPersonId] = useState<string | null>(null);
const [lines, setLines] = useState<DraftLine[]>([]);
const [adding, setAdding] = useState(false);
const [reason, setReason] = useState<string | null>(null);
const [note, setNote] = useState("");
const [err, setErr] = useState("");
const [sent, setSent] = useState<{ id: string; code: string; manager: string; escalated: boolean } | null>(null);
const person = people.find((p) => p.id === personId) || null;
const garments = lines.reduce((n, l) => n + l.qty, 0);
const full = lines.length >= maxLines;
const picking = adding || lines.length === 0;
const matches = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return people.slice(0, 8);
return people.filter((p) => p.name.toLowerCase().includes(needle) || p.num.toLowerCase().includes(needle)).slice(0, 12);
}, [people, q]);
function pick(id: string) {
setPersonId(id);
setErr("");
setLines([]);
setAdding(false);
}
/* The result list is a radio group, so it has to answer the arrow keys.
*
* A screen reader in forms mode announces it as "radio group, 1 of 8", and a manager standing
* next to the nurse they are raising for presses Down to reach her — having been told it is a
* radio group, they have no reason to try Tab. Without this they sit on the first name and give
* up. Two halves make it work: one tab stop into the group (the roving tabIndex below), and the
* arrows moving inside it. Moving also selects, the way a radio group does everywhere else,
* which clears any draft lines exactly as clicking a different name always has. */
const radios = useRef<(HTMLButtonElement | null)[]>([]);
const activeIdx = Math.max(0, matches.findIndex((p) => p.id === personId));
function moveTo(i: number) {
const next = matches[i];
if (!next) return;
pick(next.id);
radios.current[i]?.focus();
}
function onResultKey(e: React.KeyboardEvent) {
if (!matches.length) return;
const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
const back = e.key === "ArrowUp" || e.key === "ArrowLeft";
if (!fwd && !back && e.key !== "Home" && e.key !== "End") return;
e.preventDefault(); // otherwise the arrows scroll the list out from under the focused name
if (e.key === "Home") return moveTo(0);
if (e.key === "End") return moveTo(matches.length - 1);
// Nothing chosen yet — or the search has moved on past whoever was — so the first press lands
// on the first match rather than skipping it.
const at = matches.findIndex((p) => p.id === personId);
if (at < 0) return moveTo(0);
moveTo((at + (fwd ? 1 : -1) + matches.length) % matches.length);
}
/* Their recorded size, not the clerk's guess. Tops and trousers carry one on the register; for
* everything else — a fleece, a vest, a dress — the size of the last one they were issued is all
* the record knows, and it is a far better opening bid than an empty grid. */
function sizeFor(it: Item): string {
if (!person) return "";
if (it.isPant) return person.recordedPants;
if (it.isTop) return person.recordedTop;
return person.lastSizes[it.id] || "";
}
function add(l: { itemId: string; si: number; item: string; size: string; qty: number }) {
setErr("");
setLines((cur) => {
// The server sums duplicate lines before it writes them, so two identical rows on the screen
// would be showing something that cannot be saved.
const at = cur.findIndex((x) => x.itemId === l.itemId && x.si === l.si);
if (at >= 0) {
const next = [...cur];
next[at] = { ...next[at], qty: Math.min(maxQty, next[at].qty + l.qty) };
return next;
}
return [...cur, { ...l, key: `${l.itemId}:${l.si}:${cur.length}` }];
});
setAdding(false);
}
const ready = !!person && lines.length > 0;
/* Where it actually went.
*
* A manager raising for their own report is approving nothing: the server sends it up a level,
* or leaves it for the linen room to address when there is nobody above them. Either way the
* person who typed it needs to be told, by name, rather than dropped on an order screen that
* says "awaiting approval" and leaves them to work out whose. */
if (sent) {
return (
<>
<MTop title="Raised" />
<MRule />
<MBody>
<div style={{ padding: 16 }}>
<DarkCard
kicker={sent.code}
title={sent.manager ? `With ${sent.manager}` : "Nobody approves this yet"}
meta={sent.manager
? (sent.escalated
? "You approve their requests, so this went up a level — nobody approves their own raise."
: "They have been told, and it stays on your list until it is done.")
: "There is nobody above you on the register, so the linen room will address it to an approver."}
>
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, lineHeight: 1.55, color: N500 }}>
You&rsquo;ll see the outcome here and under Raised in your orders.
</div>
</DarkCard>
</div>
</MBody>
<MBar label="See the order" glyph="arrow" href={`/my/orders/${sent.id}`} />
</>
);
}
return (
<>
<MTop
title="Raise for your team"
back
right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>Manager</span>}
/>
<MRule />
<MBody>
<NumberedField n={1} label="Who is it for" first>
{/* The NumberedField heading names the step, not this box, so the box says what it is
itself — a placeholder disappears the moment anyone types into it. */}
<input
value={q} onChange={(e) => { setQ(e.target.value); setErr(""); }}
aria-label="Search by name or staff number"
placeholder="Name or staff number" autoComplete="off"
style={{ ...inputStyle, width: "100%" }}
/>
<div role="radiogroup" aria-label="Search results" onKeyDown={onResultKey} style={{ display: "grid", gap: 2, marginTop: 12 }}>
{matches.map((p, i) => {
const on = p.id === personId;
return (
<button key={p.id} role="radio" aria-checked={on}
ref={(el) => { radios.current[i] = el; }}
// One tab stop for the whole group: Tab reaches the chosen name (or the first
// one), and the arrows move between them from there.
tabIndex={i === activeIdx ? 0 : -1}
onClick={() => pick(p.id)}
style={{
textAlign: "left", padding: "12px 14px", border: 0, borderRadius: 0, font: "inherit",
background: on ? INK : "#fff", color: on ? GROUND : INK, cursor: "pointer", minHeight: 48,
}}>
<div style={{ fontSize: 16, fontWeight: 800 }}>{p.name}</div>
<div style={{ fontSize: 12.5, marginTop: 3, opacity: 0.85 }}>{[p.num, p.group].filter(Boolean).join(" · ")}</div>
{on && !p.hasApp && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300, marginTop: 6 }}>
No app you&rsquo;ll have to pass the outcome on
</div>
)}
</button>
);
})}
{matches.length === 0 && (
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>Nobody on your team matches that.</p>
)}
</div>
</NumberedField>
{person && (
<NumberedField n={2} label={`What ${person.name.split(" ")[0]} needs`}>
{lines.length > 0 && (
<div style={{ marginBottom: picking ? 14 : 0 }}>
<DraftLineList
lines={lines}
maxQty={maxQty}
onQty={(k, q2) => setLines((cur) => cur.map((l) => (l.key === k ? { ...l, qty: q2 } : l)))}
onRemove={(k) => setLines((cur) => cur.filter((l) => l.key !== k))}
/>
</div>
)}
{picking ? (
<GarmentPicker
items={items}
maxQty={maxQty}
addLabel={lines.length ? "Add it" : "Add to the request"}
onCancel={lines.length ? () => setAdding(false) : undefined}
defaultSi={(it) => {
const want = sizeFor(it);
return it.sizes.find((s) => String(s.size) === String(want))?.si ?? null;
}}
note={(it) => {
const rec = sizeFor(it);
const holds = person.held[it.id] || 0;
return [
rec ? `Recorded size ${rec}` : "No size on record for this one",
holds ? `holds ${holds}` : "",
].filter(Boolean).join(" · ");
}}
onAdd={add}
/>
) : full ? (
<p style={{ fontSize: 13, color: N600, lineHeight: 1.55, margin: "12px 0 0" }}>
That is {maxLines} lines as much as one request carries. Send this one and raise
another for anything else.
</p>
) : (
<button
onClick={() => { setAdding(true); setErr(""); }}
style={{
width: "100%", minHeight: 52, marginTop: 2, border: `2px solid ${INK}`, borderRadius: 0,
background: "transparent", color: INK, font: "inherit", fontWeight: 800, fontSize: 13,
letterSpacing: "0.06em", textTransform: "uppercase", textAlign: "left", padding: "0 16px", cursor: "pointer",
}}
>Add another garment</button>
)}
{lines.length > 0 && !picking && (
<div style={{ marginTop: 14 }}>
<OptionList
label="Why"
columns={2}
value={reason}
onPick={setReason}
options={REQUEST_REASONS.map((r) => ({ key: r, label: r }))}
/>
<textarea
value={note} onChange={(e) => setNote(e.target.value)} rows={2}
aria-label="Anything the linen room should know (optional)"
placeholder="Anything the linen room should know (optional)"
style={{ width: "100%", minHeight: 68, marginTop: 14, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 15, resize: "none", background: "#fff", color: "var(--color-text)" }}
/>
</div>
)}
</NumberedField>
)}
{person && lines.length > 0 && (
<NumberedField n={3} label="Approval and collection">
<div style={{ background: "#fff", borderLeft: `6px solid ${INK}`, padding: "14px 16px" }}>
<Kicker>Goes above you, not to you</Kicker>
<div style={{ fontSize: 17, fontWeight: 800, marginTop: 6 }}>Your own manager</div>
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: N700, margin: "8px 0 0" }}>
You approve {person.name.split(" ")[0]}&rsquo;s requests, so this one goes up a
level nobody approves their own raise. If nobody is above you on the register,
the linen room addresses it.
</p>
</div>
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
Raised by you, recorded against {person.name.split(" ")[0]}. Both names sit on the
order, and {garments === 1 ? "the garment goes" : `all ${garments} garments go`} in one bag
with one collection code.
</p>
</NumberedField>
)}
{/* What they have already raised for other people. Without this the manager who was told
"you'll see the outcome" had nowhere to see it: every list in this app starts from the
wearer, and a request raised for somebody else belongs to none of them. */}
{raised.length > 0 && (
<>
<div style={{ padding: "18px 16px 8px", borderTop: "2px solid " + INK, borderBottom: "2px solid " + INK, background: GROUND, marginTop: 18 }}>
<Kicker>Raised by you · still open</Kicker>
</div>
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{raised.map((r) => {
const st = statusText(r, { mine: false, first: r.subjectName?.split(" ")[0] });
return (
<EdgeRow key={r.id} tone={st.ink === "attention" ? "accent" : "divider"} href={`/my/orders/${r.id}`}>
<div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
<span style={{ flex: 1, fontSize: 16, fontWeight: 800 }}>{r.subjectName}</span>
<span style={{ fontSize: 12, color: N600 }}>{r.code}</span>
</div>
<div style={{ fontSize: 14.5, fontWeight: 600, marginTop: 5, lineHeight: 1.35 }}>{r.summary}</div>
<div style={{ fontSize: 12.5, color: N600, marginTop: 4 }}>
{[st.label, st.note].filter(Boolean).join(" · ")}
</div>
</EdgeRow>
);
})}
</div>
</>
)}
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ height: 12 }} />
</MBody>
<MBar
label={busy ? "Sending…" : "Send for approval"}
sub={person && lines.length ? `${garments} garment${garments === 1 ? "" : "s"} for ${person.name.split(" ")[0]}` : undefined}
disabled={!ready || busy}
onClick={async () => {
if (!person || !lines.length) return;
const r = await mutate<{ id: string; code: string; manager: string; escalated: boolean }>("request.create", {
subjectId: person.id,
lines: lines.map((l) => ({ itemId: l.itemId, si: l.si, qty: l.qty })),
reason: reason || "", note,
});
if (!r.ok) { setErr(r.error); return; }
// A raise that went somewhere other than the obvious place — up a level, or to nobody at
// all — is worth a screen of its own. Anything ordinary goes straight to the order, which
// is where the person who typed it will come looking for it.
if (r.result.escalated || !r.result.manager) {
setSent({ id: r.result.id, code: r.result.code, manager: r.result.manager, escalated: r.result.escalated });
return;
}
window.location.assign(`/my/orders/${r.result.id}`);
}}
/>
</>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
/* 1A — Home.
*
* Answer "is anything waiting for me?" in one glance, then get out of the way. One live thing at
* the top — the request furthest along — and everything else is a shortcut. There is no list of
* orders here on purpose: that is what the Orders tab is for, and a home screen that tried to be
* both would be neither.
*/
import { useState } from "react";
import { MBody, MTopBrand } from "@/components/m";
import {
Banner, DarkCard, DarkRow, EdgeRow, IdentityBlock, Kicker, N600, Notice, QuickGrid, SecondaryBar,
} from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { statusText } from "@/lib/staffreq";
import type { ReqRow } from "@/lib/staffdata";
type Data = {
name: string; num: string; ward: string; facility: string;
hasManager: boolean; wardDesk: boolean;
holding: number; live: ReqRow | null; openCount: number; notice: string;
/** Open requests this person raised for somebody else — never their own. */
raisedOpen: ReqRow[];
};
const ic = (d: React.ReactNode) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="square" aria-hidden>{d}</svg>
);
export default function HomeScreen({ data, approvals, roundBags, kitCheckDue, canRaiseForTeam }: {
data: Data; approvals: number; roundBags: number; kitCheckDue: string | null;
/** Does anybody name this person as their manager? If so they may raise for them. */
canRaiseForTeam: boolean;
}) {
const [leaving, setLeaving] = useState(false);
const live = data.live;
const st = live ? statusText(live) : null;
return (
<>
<MTopBrand
facility={data.facility}
right={
<button
onClick={async () => {
setLeaving(true);
await fetch("/api/staff/logout", {
method: "POST", headers: { "content-type": "application/json" }, body: "{}",
}).catch(() => {});
// A full navigation: the cookie has just been cleared and every screen behind it is
// server-rendered.
window.location.replace("/my/signin");
}}
style={{
background: "none", border: "1px solid rgba(243,242,242,0.4)", color: "var(--color-neutral-400)",
font: "inherit", fontSize: 11, fontWeight: 800, letterSpacing: "0.08em",
textTransform: "uppercase", padding: "6px 10px", borderRadius: 0,
cursor: leaving ? "wait" : "pointer", flex: "0 0 auto", minHeight: 30,
}}
>{leaving ? "…" : "Sign out"}</button>
}
/>
<MBody>
{/* A manager with people waiting on them sees it before anything of their own. The queue
is the one thing in this app where somebody else is blocked until they act. */}
{approvals > 0 && (
<Banner
title={`${approvals} request${approvals === 1 ? "" : "s"} waiting on you`}
body={approvals === 1 ? "Someone on your team is waiting to be approved." : "People on your team are waiting to be approved."}
onOpen={() => { window.location.href = "/my/approvals"; }}
/>
)}
<IdentityBlock ward={data.ward} num={data.num} name={data.name} />
<div style={{ padding: 16 }}>
{live && st ? (
/* One request covers as many garments as the person needed, so the card leads with the
* summary — "5 garments · Tunic, Trousers, Fleece" — and the list itself is a tap away
* on the order. This screen answers "is anything waiting for me?" and then gets out of
* the way; a home screen that unpacked every request would be the Orders tab.
*
* The decision rides alongside the status because on a split one the status word is a
* half-truth: "Approved — with linen room" over an ask where the fleece was knocked
* back has somebody expecting three garments in a bag that holds two. */
<DarkCard
href={`/my/orders/${live.id}`}
kicker={st.label}
title={live.summary}
meta={[live.decision && live.lineCount > 1 ? live.decision : "", st.note].filter(Boolean).join(" · ")}
>
{live.collectCode && <DarkRow label="Collection code" value={live.collectCode.split("").join(" ")} />}
</DarkCard>
) : (
<DarkCard
kicker={data.holding > 0 ? "Nothing on the way" : "Nothing yet"}
title={data.holding > 0 ? `${data.holding} garment${data.holding === 1 ? "" : "s"} with you` : "No uniform on your record"}
meta={
data.hasManager
? "Ask for something and it goes to your manager first."
: "Your manager isnt recorded yet — the linen room has to set who approves your requests before you can ask for anything."
}
/>
)}
</div>
<div style={{ padding: "0 16px 8px" }}><Kicker>Quick actions</Kicker></div>
<QuickGrid
items={[
{ label: "Request an item", href: "/my/request", icon: ic(<><path d="M12 5v14" /><path d="M5 12h14" /></>) },
{ label: "Swap a size", href: "/my/request?swap=1", icon: ic(<><path d="M3 7V3h4" /><path d="M17 3h4v4" /><path d="M21 17v4h-4" /><path d="M7 21H3v-4" /><path d="M8 12h8" /></>) },
{ label: "Report damage", href: "/my/damage", icon: ic(<><path d="M12 3 2 20h20z" /><path d="M12 10v4" /><path d="M12 17h.01" /></>) },
{ label: "Whats on the shelf", href: "/my/shelf", icon: ic(<><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>) },
]}
/>
{/* The desks own work, only for the person on it: signing the trolley in.
The round is listed by ward, so a clerk whose ward was never filled in has no round to
open — /my/round refuses a blank ward, and signing is fenced the same way on the
server. The card still appears, because the desk work is real and hiding it would tell
her nothing; it just stops being a link and names the missing ward. Reading "nothing on
the round right now" and tapping through to a not-found page was the worst of both:
indistinguishable from a quiet day, and no clue the linen room had to fix anything. */}
{data.wardDesk && (
<div style={{ padding: "16px 16px 0" }}>
{data.ward ? (
<DarkCard
href="/my/round"
kicker="Ward desk"
title={roundBags > 0 ? `${roundBags} bag${roundBags === 1 ? "" : "s"} to sign` : "Ward round"}
meta={roundBags > 0 ? "Arriving on your ward today." : "Nothing on the round for your ward right now."}
/>
) : (
<DarkCard
kicker="Ward desk"
title="No ward on your record"
meta="The round is listed by ward, so there is nothing to show you until the linen room records which ward you are on. Ask them to set it."
/>
)}
</div>
)}
{/* Somebody who will not type a request themselves asks the person who approves it, which
is the one door left for raising on another person's behalf. The server sends anything
a manager raises up a level, which is why the card can say so plainly — approving your
own raise is the one thing this must never let happen. */}
{canRaiseForTeam && (
<div style={{ padding: "16px 16px 0" }}>
<DarkCard
href="/my/raise"
kicker="Your team"
title="Raise for someone you manage"
meta="Goes to your own manager for approval, not to you."
/>
</div>
)}
{/* What they have raised for other people, as one row rather than a list.
Every list in this app starts from the wearer, so until Orders grew a Raised tab a
request somebody typed in for a colleague vanished the moment it was sent — and the
raise screen promises they will see the outcome. Home is not the place for the list
itself (that rule is the whole shape of this screen), but it is the place to say the
requests exist and where they went. */}
{data.raisedOpen.length > 0 && (
<div style={{ padding: "16px 0 0" }}>
<EdgeRow tone="ink" href="/my/orders?tab=raised">
<Kicker>Raised by you</Kicker>
<div style={{ fontSize: 16, fontWeight: 800, marginTop: 6, lineHeight: 1.3 }}>
{data.raisedOpen.length === 1
? `One request open for ${data.raisedOpen[0].subjectName}`
: `${data.raisedOpen.length} requests open for other people`}
</div>
<div style={{ fontSize: 13, color: N600, marginTop: 4, lineHeight: 1.45 }}>
Not yours to collect this is where they got to.
</div>
</EdgeRow>
</div>
)}
{/* An open cycle they havent finished. Not a banner — this isnt urgent, its a chore
with a deadline, and dressing it as an alert would devalue the ones that are.
The title is word for word the heading on the screen it opens, and it no longer asks
about a locker. Wearers take their uniform home and wash it themselves; there is no
locker to stand in front of, so the only answerable question is what they still have,
wherever it happens to be that day. */}
{kitCheckDue && (
<div style={{ padding: "16px 16px 0" }}>
<DarkCard
href="/my/kitcheck"
kicker="Kit check"
title="Have you still got everything on your record?"
meta={`Due by ${kitCheckDue}. Count whats in the wash too. Nothing here is chargeable.`}
/>
</div>
)}
{data.notice && <div style={{ padding: "0 16px" }}><Notice>{data.notice}</Notice></div>}
{/* Sitting under everything else because it is the least often needed thing here — but it
is the only way a wearer can end a session on a phone they no longer have, so it has to
be somewhere they can find without asking. The label names privacy too: the policy and
the answer about deleting an account live behind this row, and somebody looking for
either would never guess that "your sign-in" was where they were kept. */}
<div style={{ padding: "16px 16px 0" }}>
<SecondaryBar label="Your sign-in and privacy" href="/my/account" />
</div>
<div style={{ height: 20 }} />
</MBody>
<StaffNav />
</>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
/* 1B — My kit.
*
* The linen room's record of what this person holds, shown to the person it is about, and a way
* to say it is wrong. "This isn't right" is deliberately blunt and deliberately last: the record
* is usually correct, and a dispute button placed first would invite one before anybody had read
* the list.
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import { MBody, MError, MRule, MTop } from "@/components/m";
import { INK, N600, N700, SecondaryBar, Segments } from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useStaff } from "@/lib/staffclient";
import { fmtDate } from "@/lib/compute";
type Held = { itemId: string; item: string; size: string; si: number; qty: number; last: string };
type Data = { held: Held[]; total: number; handedBackThisYear: number; fyFrom: string; sizes: { top: string; pants: string } };
export default function KitScreen({ data }: { data: Data }) {
const { mutate, busy } = useStaff();
const router = useRouter();
const [tab, setTab] = useState<"holding" | "sizes">("holding");
const [disputing, setDisputing] = useState(false);
const [body, setBody] = useState("");
const [err, setErr] = useState("");
const [sent, setSent] = useState(false);
return (
<>
<MTop title="My kit" right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{data.total} item{data.total === 1 ? "" : "s"}</span>} />
<MRule />
<MBody>
<Segments
label="What to show"
value={tab}
onPick={setTab}
options={[{ key: "holding" as const, label: "Holding" }, { key: "sizes" as const, label: "My sizes" }]}
/>
{tab === "holding" ? (
<>
<div style={{ display: "flex", padding: "8px 16px", borderBottom: "2px solid " + INK, background: "var(--color-bg)" }}>
<span style={{ flex: 1, fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>Uniform</span>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>Issued</span>
</div>
{data.held.length === 0 ? (
<div style={{ padding: "22px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
Nothing on your record. Anything the linen room issues you shows up here.
</div>
) : (
data.held.map((h) => (
<div key={`${h.itemId}:${h.si}`} style={{ display: "flex", gap: 12, alignItems: "center", padding: "14px 16px", background: "#fff", borderTop: "1px solid var(--color-divider)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{h.item} {h.size}</div>
<div style={{ fontSize: 12, color: N600, marginTop: 3 }}>
{h.qty} held · last issued {fmtDate(h.last)}
</div>
</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20, fontVariantNumeric: "tabular-nums" }}>{h.qty}</div>
</div>
))
)}
<div style={{ borderTop: "2px solid " + INK, padding: "16px", background: "var(--color-bg)" }}>
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>Handed back this year</div>
<p style={{ fontSize: 14, lineHeight: 1.55, margin: "8px 0 0" }}>
{data.handedBackThisYear === 0
? `Nothing handed back since ${fmtDate(data.fyFrom)}.`
: `${data.handedBackThisYear} garment${data.handedBackThisYear === 1 ? "" : "s"} handed back since ${fmtDate(data.fyFrom)}.`}
</p>
</div>
</>
) : (
<>
{[["Top", data.sizes.top], ["Trouser", data.sizes.pants]].map(([label, value]) => (
<div key={label} style={{ display: "flex", gap: 12, alignItems: "baseline", padding: "16px", background: "#fff", borderTop: "1px solid var(--color-divider)" }}>
<span style={{ flex: 1, fontSize: 16, fontWeight: 800 }}>{label}</span>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20 }}>{value || "not recorded"}</span>
</div>
))}
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: "16px" }}>
These are the sizes the linen room has on file, and what a new request starts from.
If one is wrong, tell them this page can&rsquo;t be edited from your side.
</p>
</>
)}
{sent && (
<div style={{ margin: 16, background: INK, color: "var(--color-bg)", padding: 16, fontSize: 14, lineHeight: 1.55 }}>
Sent. The linen room will look at your record and come back to you.
</div>
)}
{disputing && !sent && (
<div style={{ padding: 16, borderTop: "2px solid " + INK }}>
<label htmlFor="tc-dispute" style={{ display: "block", fontSize: 13, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase" }}>What doesn&rsquo;t look right?</label>
<textarea
id="tc-dispute"
value={body} onChange={(e) => { setBody(e.target.value); setErr(""); }} rows={4}
placeholder="e.g. I handed two tunics back in August but theyre still on here"
style={{ width: "100%", minHeight: 84, marginTop: 12, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 15, resize: "none", background: "#fff", color: INK }}
/>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ display: "flex", gap: 10, marginTop: 12, flexWrap: "wrap" }}>
<button
disabled={busy || !body.trim()}
onClick={async () => {
const r = await mutate("dispute.raise", { body });
if (!r.ok) { setErr(r.error); return; }
setSent(true); setDisputing(false); setBody(""); router.refresh();
}}
style={{ minHeight: 48, padding: "0 20px", background: "var(--color-accent)", color: "#fff", border: 0, borderRadius: 0, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: busy ? "wait" : "pointer", opacity: busy || !body.trim() ? 0.45 : 1 }}
>{busy ? "Sending…" : "Send to the linen room"}</button>
<button onClick={() => { setDisputing(false); setBody(""); setErr(""); }}
style={{ minHeight: 48, padding: "0 20px", background: "transparent", color: INK, border: "2px solid " + INK, borderRadius: 0, font: "inherit", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Cancel</button>
</div>
</div>
)}
{!disputing && !sent && (
<div style={{ padding: 16 }}>
<SecondaryBar label="This isnt right" onClick={() => setDisputing(true)} />
</div>
)}
<div style={{ height: 12 }} />
</MBody>
<StaffNav />
</>
);
}
+181
View File
@@ -0,0 +1,181 @@
"use client";
/* 2A — Kit check. Twice a year, reconcile the record with reality, item by item.
*
* The copy does most of the work here. "Nothing here is chargeable" and "the linen room uses these
* answers to set par levels, not to chase people" are not reassurance for its own sake — a check
* that felt like an audit would be answered with whatever number keeps someone out of trouble, and
* the resulting data would be worse than no data.
*
* Writing off and re-requesting stay separate acts. Saying a garment is missing does not quietly
* order another one: that would turn an honest answer into a request somebody's manager has to
* decline, which is exactly how you teach a ward to stop answering honestly.
*/
import { useState } from "react";
import { MBar, MBody, MError, MRule, MTop } from "@/components/m";
import { ACCENT_300, DIVIDER, GROUND, INK, N300, N600, N700 } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { fmtDate } from "@/lib/compute";
type Row = { itemId: string; item: string; size: string; si: number; onRecord: number; answered: number | null };
export default function KitCheckScreen({ dueBy, lastConfirmed, rows }: {
dueBy: string; lastConfirmed: string; rows: Row[];
}) {
const { mutate, busy } = useStaff();
const [answers, setAnswers] = useState<Record<string, number>>(
Object.fromEntries(rows.filter((r) => r.answered !== null).map((r) => [`${r.itemId}:${r.si}`, r.answered as number])),
);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [err, setErr] = useState("");
const [done, setDone] = useState(false);
const answeredCount = rows.filter((r) => answers[`${r.itemId}:${r.si}`] !== undefined).length;
async function answer(r: Row, confirmed: number) {
const k = `${r.itemId}:${r.si}`;
setAnswers((a) => ({ ...a, [k]: confirmed }));
setErr("");
const res = await mutate("kit.answer", { itemId: r.itemId, si: r.si, onRecord: r.onRecord, confirmed });
if (!res.ok) {
setErr(res.error);
setAnswers((a) => { const n = { ...a }; delete n[k]; return n; });
}
}
// aria-pressed, because the only other thing separating the chosen answer from the unchosen one
// is an ink fill: nothing a screen reader can hear, and nothing at all in high contrast.
const pick = (on: boolean, label: string, onClick: () => void): React.ReactNode => (
<button onClick={onClick} aria-pressed={on} style={{
flex: 1, minHeight: 44, border: 0, borderRadius: 0, font: "inherit",
background: on ? INK : "var(--color-neutral-200)", color: on ? GROUND : N700,
fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
}}>{label}</button>
);
if (done) {
return (
<>
<MTop title="Kit check" back />
<MRule />
<MBody>
<div style={{ background: INK, color: GROUND, padding: 20 }}>
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>Thanks</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, lineHeight: 1.25, marginTop: 10 }}>
That&rsquo;s your record confirmed.
</div>
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N300, margin: "12px 0 0" }}>
Your answers have gone to the linen room, and they&rsquo;ll square anything that
didn&rsquo;t match. Nothing is charged, and you don&rsquo;t need to do anything else.
</p>
</div>
</MBody>
</>
);
}
return (
<>
<MTop title="Kit check" back right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{dueBy ? fmtDate(dueBy).split(" ").slice(1).join(" ") : ""}</span>} />
<MRule />
<MBody>
<div style={{ background: INK, color: GROUND, padding: 20 }}>
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>
Due by {fmtDate(dueBy)}
</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, lineHeight: 1.25, marginTop: 10 }}>
Have you still got everything on your record?
</div>
{/* Nobody keeps their uniform at work. It goes home, it gets washed, and on any given
day a good part of it is on the line or in a bag in the boot. The question that used
to be asked here — whether this matched what was in your locker — could only be
answered by somebody standing in front of a locker they do not have, so it either
got answered wrongly or not at all. Counting from memory, wherever the garments
are, is the honest ask. */}
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N300, margin: "12px 0 0" }}>
Count everything you still have, wherever it is &mdash; what&rsquo;s in the wash and on
the line counts too.{" "}
{lastConfirmed ? `Last confirmed ${fmtDate(lastConfirmed)}. ` : ""}Nothing here is chargeable.
</p>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>
{rows.length} item{rows.length === 1 ? "" : "s"} on your record
</span>
</div>
{rows.length === 0 && (
<div style={{ padding: "22px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
Nothing on your record to check.
</div>
)}
{rows.map((r) => {
const k = `${r.itemId}:${r.si}`;
const a = answers[k];
const short = a !== undefined && a < r.onRecord;
const open = expanded[k];
return (
<div key={k} style={{
background: "#fff", borderTop: `1px solid ${DIVIDER}`,
borderLeft: short ? "6px solid var(--color-accent)" : "6px solid transparent",
padding: "14px 16px",
}}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{r.item} {r.size}</div>
<div style={{ fontSize: 12.5, color: N600, marginTop: 4 }}>{r.onRecord} on record</div>
<div style={{ display: "flex", gap: 2, marginTop: 12 }}>
{/* "All 3 here" was an answer only somebody standing in front of the garments
could give, and nobody is: they are at home, half of them in the wash. */}
{pick(a === r.onRecord, r.onRecord === 1 ? "Got it" : `Got all ${r.onRecord}`, () => { setExpanded((e) => ({ ...e, [k]: false })); void answer(r, r.onRecord); })}
{pick(short || !!open, short ? (a === 0 ? "None left" : `Only ${a}`) : "Fewer", () => setExpanded((e) => ({ ...e, [k]: !e[k] })))}
</div>
{open && (
<div style={{ display: "grid", gridTemplateColumns: `repeat(${Math.min(r.onRecord, 4)}, 1fr)`, gap: 2, marginTop: 2 }}>
{Array.from({ length: r.onRecord }, (_, i) => i).map((n) => (
<button key={n} aria-pressed={a === n} onClick={() => { void answer(r, n); setExpanded((e) => ({ ...e, [k]: false })); }} style={{
minHeight: 44, border: 0, borderRadius: 0, font: "inherit",
background: a === n ? INK : "var(--color-neutral-200)", color: a === n ? GROUND : N700,
fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
}}>{n === 0 ? "None left" : `Only ${n}`}</button>
))}
</div>
)}
{short && (
<div style={{ borderTop: `1px solid ${DIVIDER}`, marginTop: 12, paddingTop: 10 }}>
{/* This used to say the missing ones "come off your record". They don't — the
answer is written down and that is all it does; the record still says what it
said, and only the linen room can change it. Telling somebody their record has
already been corrected, when it hasn't, is how they stop believing the next
thing this screen says. */}
<p style={{ fontSize: 13, lineHeight: 1.55, color: N700, margin: 0 }}>
{r.onRecord - (a as number) === 1 ? "One" : `${r.onRecord - (a as number)}`} unaccounted
for. The linen room will square your record ask for a replacement separately if
you need one.
</p>
</div>
)}
</div>
);
})}
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
Anything you can&rsquo;t account for is noted for the linen room to look at nothing is
charged, and nothing changes on your record until they do. They use these answers to set
par levels, not to chase people.
</p>
<div style={{ height: 12 }} />
</MBody>
<MBar
label={busy ? "Saving…" : `Confirm — ${answeredCount} of ${rows.length} answered`}
glyph="check"
disabled={busy || answeredCount < rows.length || rows.length === 0}
onClick={() => setDone(true)}
/>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { GROUND, INK } from "@/components/m";
import { useStaff } from "@/lib/staffclient";
/* The two-item nav from the design, shown only inside the manager area.
*
* A ward manager is a staff member who also approves — they wear the uniform too — so this does
* not replace the app's own four-item nav. It appears while they are in Approvals or Ward, and
* the app bar's back chevron takes them home. The prototype's role-switching chips were a
* prototyping device; building a separate manager shell around them would give one person two
* apps to remember.
*/
const ITEMS: [string, string, React.ReactNode][] = [
["/my/approvals", "Approvals", <path key="a" d="m4 12 5 5L20 6" />],
["/my/ward", "Ward", <g key="w"><path d="M5 20V10" /><path d="M12 20V4" /><path d="M19 20v-7" /></g>],
];
export default function ManagerNav() {
const path = usePathname();
const { me } = useStaff();
// Approvals admits somebody with no reports (a request re-addressed to them); /my/ward does
// not, so offering it to them was a tab that 404'd. One item, full width, for that reader.
const items = ITEMS.filter(([href]) => href !== "/my/ward" || me.isManager);
return (
<nav style={{ display: "grid", gridTemplateColumns: `repeat(${items.length}, 1fr)`, flex: "0 0 auto", borderTop: "2px solid " + INK, background: GROUND }}>
{items.map(([href, label, icon]) => {
const active = path.startsWith(href);
return (
<Link key={href} href={href} aria-current={active ? "page" : undefined} style={{
minHeight: 52, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: 5, padding: "10px 4px calc(12px + env(safe-area-inset-bottom, 0px))",
fontSize: 10, fontWeight: 800, letterSpacing: "0.1em", textTransform: "uppercase", textDecoration: "none",
background: active ? INK : GROUND, color: active ? GROUND : "var(--color-neutral-600)",
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="square" aria-hidden>{icon}</svg>
{label}
</Link>
);
})}
</nav>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use client";
/* 1D — Order detail. The tracking screen, and the screen someone holds up at the counter.
*
* The timeline always shows the step that hasn't happened yet, as an outlined dot. Half the point
* of this screen is what is still to come — an order that only listed what had already happened
* would leave "so when do I get it?" unanswered, which is the question that sends people to the
* counter.
*/
import { useState } from "react";
import { CodeBlock, Kicker, LineList, N600, N700, SecondaryBar, type Step, Timeline } from "@/components/staffui";
import { MBar, MBody, MError, MRule, MTop } from "@/components/m";
import { useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import type { ReqStatus } from "@/lib/staffreq";
import type { ReqLine } from "@/lib/staffdata";
import { formatInZone } from "@/lib/compute";
type Ev = { id: string; label: string; meta: string; actorName: string; at: string };
type Data = {
id: string; code: string; status: string;
lines: ReqLine[]; summary: string; garments: number; lineCount: number; decision: string | null;
reason: string; note: string; managerName: string; declineReason: string | null;
collectCode: string | null; holdUntil: string; route: string | null;
signerName: string | null; signerRole: string | null; ward: string;
subjectName: string; raisedByName: string; mine: boolean; claimedAt: string | null;
events: Ev[];
};
/* The zone is the facility's, not the device's and not the server's.
*
* This screen is server-rendered and then hydrated, so a timeline stamp built without a zone was
* printed in whatever zone the host sits in and then quietly replaced with the phone's — a step
* taken at 08:00 in the linen room read "7 Sep, 22:00" until React caught up. */
function stamp(iso: string, tz: string) {
return formatInZone(iso, tz, { day: "numeric", month: "short" }) + ", " +
formatInZone(iso, tz, { hour: "2-digit", minute: "2-digit", hour12: false });
}
/** The step after the last one that happened, so the person can see where it goes next. */
function nextStep(status: string, holdUntil: string, route: string | null): { label: string; meta?: string } | null {
switch (status as ReqStatus) {
case "awaiting": return { label: "Approved", meta: "Then it goes to the linen room" };
case "accepted": return { label: "Picked from the shelf" };
case "picking": return { label: route === "ward_round" ? "Out on the ward round" : "Ready at the counter" };
case "ready": return { label: "Collected", meta: holdUntil ? `Held until ${holdUntil}` : undefined };
case "round": return { label: "Signed for on the ward" };
default: return null; // declined, collected and delivered are endings
}
}
export default function OrderScreen({ data }: { data: Data }) {
const { mutate, busy, me } = useStaff();
const [err, setErr] = useState("");
// The wearer's first name, for the third-person reading a manager or the desk gets of this page.
const first = (data.subjectName || "").split(" ")[0];
const st = statusText(data, { mine: data.mine, first });
// A request the linen room withdrew was never decided by the manager named on it; the timeline
// row it writes is the only record of that, so this is where the page finds out.
const withdrawn = data.status === "declined" && data.events.some((e) => e.label === "Withdrawn by the linen room");
/* The one thing only the requester can settle: whether the bag actually reached them.
*
* A ward clerk signs for the round, which is where the linen room's job ends — but the bag then
* sits on the desk, and until somebody says it was picked up the desk's unclaimed list only
* grows. This is that confirmation, and it is why round.claim exists; without a caller it never
* ran and the list never emptied. */
const canClaim = data.status === "delivered" && data.mine && !data.claimedAt;
const steps: Step[] = data.events.map((e, i) => ({
label: e.label,
meta: [e.actorName, e.meta, stamp(e.at, me.tz)].filter(Boolean).join(" · "),
state: i === data.events.length - 1 ? "current" : "done",
}));
const next = nextStep(data.status, data.holdUntil, data.route);
if (next) steps.push({ label: next.label, meta: next.meta, state: "future" });
return (
<>
<MTop title={data.code} back right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{data.mine ? "" : data.subjectName}</span>} />
<MRule />
<MBody>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ padding: "20px 16px 18px", borderBottom: "2px solid var(--color-text)", background: "var(--color-bg)" }}>
<Kicker tone={st.ink === "attention" ? "attention" : "quiet"}>{st.label}</Kicker>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.02em", lineHeight: 1.15, marginTop: 8 }}>
{data.summary}
</div>
<div style={{ fontSize: 14, color: N700, marginTop: 8 }}>
{/* `decision` is only there once the manager has been through it, and it is the honest
headline when they didn't approve everything: "2 of 3 approved" above a list where
the fleece is struck out. */}
{[data.decision, data.reason.toLowerCase()].filter(Boolean).join(" · ")}
</div>
{!data.mine && data.subjectName && (
<div style={{ fontSize: 13, color: N600, marginTop: 8 }}>{`For ${data.subjectName}${data.raisedByName ? ` · raised by ${data.raisedByName}` : ""}`}</div>
)}
{data.mine && data.raisedByName && (
<div style={{ fontSize: 13, color: N600, marginTop: 8 }}>{`Raised for you by ${data.raisedByName}`}</div>
)}
</div>
{/* The whole ask, declines included. A request where the fleece was refused has to read
honestly on the wearer's own screen — the alternative is somebody collecting a bag,
counting two garments where they asked for three, and coming to the counter to find out
why. One line needs no list: the heading above already is one. */}
{data.lineCount > 1 && (
<div style={{ padding: "16px 16px 0" }}>
<Kicker>{data.mine ? "What you asked for" : `What ${first || "they"} asked for`}</Kicker>
<div style={{ marginTop: 10 }}><LineList lines={data.lines} /></div>
</div>
)}
{data.status === "declined" && data.declineReason && (
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: "14px 16px", margin: 16 }}>
<Kicker tone="attention">Why</Kicker>
<div style={{ fontSize: 15, fontWeight: 800, marginTop: 6 }}>{data.declineReason}</div>
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: N700, margin: "8px 0 0" }}>
{withdrawn
? "The linen room withdrew this. Ask at the counter if it needs another look."
: data.managerName
? `${data.managerName} decided this. Talk to them if it needs another look.`
: "Ask the linen room if it needs another look."}
</p>
</div>
)}
<div style={{ padding: "16px 16px 0" }}><Kicker>Progress</Kicker></div>
<Timeline steps={steps} />
{data.collectCode && data.status === "ready" && (
<div style={{ padding: 16 }}>
<CodeBlock code={data.collectCode} />
{data.garments > 1 && (
<p style={{ fontSize: 13, color: N600, marginTop: 10, lineHeight: 1.55 }}>
All {data.garments} garments are in one bag under this code.
</p>
)}
{/* This used to say the hold lapses on its own and the garment goes back on the shelf.
Nothing does that: the hold is a note the linen room typed, there is no job that
reads it, and the only way out of `ready` is somebody collecting. So the copy says
what is true — it keeps waiting, and a late collection is a conversation rather than
a lost request. */}
{data.holdUntil && (
<p style={{ fontSize: 13, color: N600, marginTop: 10, lineHeight: 1.55 }}>
Held until {data.holdUntil}. It stays on the counter until you collect it if you
can&rsquo;t get there by then, say so on this order and the linen room will sort it out.
</p>
)}
</div>
)}
{data.status === "delivered" && data.signerName && (
<div style={{ padding: 16 }}>
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-text)", padding: "14px 16px" }}>
<Kicker>Signed for</Kicker>
<div style={{ fontSize: 15, fontWeight: 800, marginTop: 6 }}>{data.signerName}{data.signerRole ? `, ${data.signerRole}` : ""}</div>
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: N700, margin: "8px 0 0" }}>
{data.claimedAt
? `Picked up from the desk on ${data.ward || "your ward"}.`
: `Ask at the desk on ${data.ward || "your ward"} — whoever signed has it.`}
</p>
</div>
</div>
)}
{data.note && (
<div style={{ padding: "0 16px 16px" }}>
<Kicker>{data.mine ? "Your note" : `${first || "Their"}s note`}</Kicker>
<p style={{ fontSize: 14, lineHeight: 1.55, margin: "8px 0 0" }}>{data.note}</p>
</div>
)}
<div style={{ padding: 16 }}>
<SecondaryBar label="Ask about this order" href={`/my/orders/${data.id}/messages`} />
</div>
<div style={{ height: 12 }} />
</MBody>
{canClaim && (
<MBar
label={busy ? "Working…" : "Ive got it"}
sub="Tells the desk the bag has been picked up"
glyph="check"
disabled={busy}
onClick={async () => {
const r = await mutate("round.claim", { id: data.id });
if (!r.ok) { setErr(r.error); return; }
window.location.reload();
}}
/>
)}
</>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
/* 1C — Orders. Every request, newest first.
*
* The status *word* is the signal; the left border only reinforces it. Nothing here is
* distinguishable by colour alone, which matters on a ward phone in bad light as much as it does
* for anyone who can't tell the red from the grey.
*
* Three lists, not two. What somebody raised for another person — a manager for one of their team,
* plus anything still open from the desk route that used to exist — is deliberately kept out of
* Open and Done: what a wearer does with their own order (collect it, chase it, say they picked it
* up) is not what the person who typed it in does with it, and one mixed list is how somebody walks
* off with a bag that isn't theirs. Before this tab existed those requests appeared on no screen
* the raiser could reach at all.
*/
import { useState } from "react";
import { MBar, MBody, MRule, MTop, NOT_DOCKED } from "@/components/m";
import { EdgeRow, INK, N600, N700, Tabs } from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { useStaff } from "@/lib/staffclient";
import { NEEDS_STAFF, statusText } from "@/lib/staffreq";
import { formatInZone } from "@/lib/compute";
import type { ReqRow } from "@/lib/staffdata";
type Tab = "open" | "done" | "raised";
export default function OrdersScreen({ open, done, raised, initialTab }: {
open: ReqRow[]; done: ReqRow[]; raised: { open: ReqRow[]; done: ReqRow[] }; initialTab: Tab;
}) {
const { me } = useStaff();
// Open first with the ones still moving, then whatever has finished, so the tab reads top-down
// like the two it sits beside.
const forOthers = [...raised.open, ...raised.done];
const [tab, setTab] = useState<Tab>(initialTab === "raised" && !forOthers.length ? "open" : initialTab);
const rows = tab === "open" ? open : tab === "done" ? done : forOthers;
const tabs = [
{ key: "open" as const, label: "Open", count: open.length },
{ key: "done" as const, label: "Done", count: done.length },
...(forOthers.length ? [{ key: "raised" as const, label: "Raised", count: forOthers.length }] : []),
];
return (
<>
<MTop title="Orders" />
<MRule />
<MBody>
<Tabs label="Which orders" value={tab} onPick={setTab} options={tabs} />
{rows.length === 0 ? (
<div style={{ padding: "28px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
{tab === "open"
? `Nothing open. Requests for you appear here with their progress${forOthers.length ? " — anything you raise for somebody else is under Raised." : "."}`
: tab === "done" ? "Nothing closed yet."
: "Nothing you raised for somebody else."}
</div>
) : (
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{rows.map((r) => {
const st = statusText(r, { mine: r.mine, first: r.subjectName.split(" ")[0] });
const attention = !r.mine ? false : NEEDS_STAFF.has(r.status as never);
return (
<EdgeRow key={r.id} tone={attention ? "accent" : "divider"} href={`/my/orders/${r.id}`}>
<div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
<span style={{ flex: 1, fontSize: 12, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", color: st.ink === "attention" ? "var(--color-accent-700)" : N700 }}>
{st.label}
</span>
<span style={{ fontSize: 12, color: N600 }}>{r.code}</span>
</div>
<div style={{ fontSize: 18, fontWeight: 800, letterSpacing: "-0.01em", marginTop: 6, lineHeight: 1.25 }}>
{r.summary}
</div>
<div style={{ fontSize: 13, color: N600, marginTop: 5, lineHeight: 1.45 }}>
{/* createdAt is a UTC instant; slicing its first ten characters dated every
request raised before 10:00 to the previous day. */}
{[
r.mine ? "" : `for ${r.subjectName}`,
// Only worth saying when the manager didn't approve the lot — otherwise the
// status word above has already said it.
r.decision && r.lineCount > 1 ? r.decision : "",
st.note,
formatInZone(r.createdAt, me.tz),
].filter(Boolean).join(" · ")}
</div>
</EdgeRow>
);
})}
</div>
)}
<div style={{ height: 12 }} />
</MBody>
{/* The nav below is what the phone's gesture handle sits on, and it pads itself for it. The
bar is not at the foot of anything, so it says so: otherwise it reserves the home-indicator
band a second time and "New request" floats above an accent gap mid-screen. */}
<div style={{ ...NOT_DOCKED, borderTop: "2px solid " + INK }}>
<MBar label="New request" href="/my/request" />
</div>
<StaffNav />
</>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
/* 1E — New request. One ask, however many garments it takes.
*
* A nurse who needs a tunic, trousers and a fleece used to raise three requests: three codes,
* three emails to the same manager on the same morning, three bags to collect. So this screen is
* built around a list the person is filling rather than a single garment — add a line, add
* another, one reason and one note over the lot, one approval at the end.
*
* Two things carried over from the old screen because they do most of the work. Availability is
* shown **before** the request is sent, so nobody asks for a size that isn't there and then waits
* a week to find out. And the button names the actual approver — "Send to D. Adeyemi", not
* "Submit" — because the single most common question about a request is who has it.
*
* What is new is that the screen also shows what the person already holds and what they are
* allowed, from the same sum the manager's review screen uses. Being declined "Over allowance"
* against a number you were never shown is the sort of refusal that ends in a phone call.
*
* No prices, no payment, no basket. A request covering four garments is not a basket; approval is
* still the only control.
*/
import { useState } from "react";
import { MBar, MBody, MError, MRule, MTop } from "@/components/m";
import {
DarkCard, DraftLineList, GarmentPicker, Kicker, N500, N600, N700, NumberedField, OptionList,
StockTag, type DraftLine,
} from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { REQUEST_REASONS } from "@/lib/staffreq";
import { fmtDate } from "@/lib/compute";
type Size = { size: string; si: number; word: "in_stock" | "low" | "none" | string; countedOn: string; held: number };
type Item = {
id: string; item: string; type: string; gender: string; sizes: Size[];
recorded: string; recordedSource: "record" | "issued" | ""; held: number;
};
type Allowance = { capped: boolean; label: string; note: string; over: boolean };
export default function RequestScreen({
items, managerName, swap, heldItemIds, preItemId, preSi, holding, allowance, maxLines, maxQty,
}: {
items: Item[]; managerName: string; swap: boolean; heldItemIds: string[];
preItemId: string | null; preSi: number | null;
holding: { total: number; sets: number }; allowance: Allowance;
maxLines: number; maxQty: number;
}) {
const { mutate, busy } = useStaff();
// Swapping a size is a request against something you already hold, so the list is the shorter
// one. Everything else about the screen is identical — a swap is not a different kind of ask.
const list = swap && heldItemIds.length ? items.filter((i) => heldItemIds.includes(i.id)) : items;
/* Arriving from the waitlist's "or take a stocked size", the garment and size are already
* decided — the person picked them on the previous screen and should not have to again. So the
* list starts with that line already on it rather than with an empty picker. */
const [lines, setLines] = useState<DraftLine[]>(() => {
const it = preItemId ? list.find((i) => i.id === preItemId) : null;
const s = it && preSi !== null ? it.sizes.find((x) => x.si === preSi) : null;
return it && s ? [{ key: "pre", itemId: it.id, si: s.si, item: it.item, size: String(s.size), qty: 1 }] : [];
});
const [adding, setAdding] = useState(false);
const [reason, setReason] = useState<string | null>(null);
const [note, setNote] = useState(swap ? "Swapping a size." : "");
const [err, setErr] = useState("");
const [sent, setSent] = useState<{ id: string; manager: string } | null>(null);
const garments = lines.reduce((n, l) => n + l.qty, 0);
const full = lines.length >= maxLines;
// With nothing on the list there is nothing to show but the picker, so it opens itself.
const picking = adding || lines.length === 0;
/* The same garment in the same size, added twice, is one line of two rather than two lines of
* one. The server sums duplicates anyway before it writes them, so a screen that showed two
* identical rows would be showing something that cannot be saved. */
function add(l: { itemId: string; si: number; item: string; size: string; qty: number }) {
setErr("");
setLines((cur) => {
const at = cur.findIndex((x) => x.itemId === l.itemId && x.si === l.si);
if (at >= 0) {
const next = [...cur];
next[at] = { ...next[at], qty: Math.min(maxQty, next[at].qty + l.qty) };
return next;
}
return [...cur, { ...l, key: `${l.itemId}:${l.si}:${cur.length}` }];
});
setAdding(false);
}
/* Raised, but nobody has been emailed.
*
* Not an error — the request is on the manager's list either way — but the order screen it would
* otherwise jump straight to says "Awaiting approval" and nothing else, and somebody who believes
* there is a message sitting in their manager's inbox will wait a fortnight before chasing it.
* So the one case where no email left the server is said plainly, once, with the thing to do. */
if (sent) {
return (
<>
<MTop title="Sent" />
<MRule />
<MBody>
<div style={{ padding: 16 }}>
<DarkCard
kicker="Raised"
title={sent.manager ? `${sent.manager} hasnt been emailed` : "Your manager hasnt been emailed"}
meta="It is on their list in the app and nothing has been lost — but no message went out, so they will not hear about it unless somebody tells them."
>
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, lineHeight: 1.55, color: N500 }}>
Ask the linen room to give {sent.manager || "your manager"} a code for the staff app,
or mention it to them yourself.
</div>
</DarkCard>
</div>
</MBody>
<MBar label="See the order" glyph="arrow" href={`/my/orders/${sent.id}`} />
</>
);
}
return (
<>
<MTop title={swap ? "Swap a size" : "New request"} back />
<MRule />
<MBody>
<NumberedField n={1} label={swap ? "What youre 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 hasnt listed any garments yet."}
</p>
) : (
<>
{lines.length > 0 && (
<div style={{ marginBottom: picking ? 14 : 0 }}>
<DraftLineList
lines={lines}
maxQty={maxQty}
onQty={(k, q) => setLines((cur) => cur.map((l) => (l.key === k ? { ...l, qty: q } : l)))}
onRemove={(k) => setLines((cur) => cur.filter((l) => l.key !== k))}
/>
</div>
)}
{picking ? (
<GarmentPicker
items={list}
maxQty={maxQty}
addLabel={lines.length ? "Add it" : "Add to the request"}
onCancel={lines.length ? () => setAdding(false) : undefined}
// The size the record already knows — their recorded top or trouser size, or the
// size of the last one they were issued for everything else.
defaultSi={(it) => it.sizes.find((s) => String(s.size) === String(it.recorded))?.si ?? null}
note={(it, s) => (
<>
{it.recordedSource === "record" ? `Your recorded size is ${it.recorded}. ` : ""}
{it.recordedSource === "issued" ? `Last issued in ${it.recorded}. ` : ""}
{it.held > 0 ? `You hold ${it.held}. ` : ""}
{s ? (
<>
<StockTag word={s.word} />
{s.word === "none" && " — the linen room will order it in"}
{s.countedOn ? ` · counted ${fmtDate(s.countedOn)}` : ""}
</>
) : "Pick a size."}
</>
)}
onAdd={add}
/>
) : full ? (
<p style={{ fontSize: 13, color: N600, lineHeight: 1.55, margin: "12px 0 0" }}>
That is {maxLines} lines as much as one request carries. Send this one and
raise another for anything else.
</p>
) : (
<button
onClick={() => { setAdding(true); setErr(""); }}
style={{
width: "100%", minHeight: 52, marginTop: 2, border: "2px solid var(--color-text)",
borderRadius: 0, background: "transparent", color: "var(--color-text)", font: "inherit",
fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase",
textAlign: "left", padding: "0 16px", cursor: "pointer",
}}
>Add another garment</button>
)}
</>
)}
</NumberedField>
{/* What they hold and what they are entitled to, in the manager's own words. Shown while
they are still choosing rather than in the decline. */}
<div style={{ margin: "0 16px", background: "#fff", borderLeft: `6px solid ${allowance.over ? "var(--color-accent)" : "var(--color-text)"}`, padding: "14px 16px" }}>
<Kicker tone={allowance.over ? "attention" : "quiet"}>What you hold</Kicker>
<div style={{ fontSize: 15, lineHeight: 1.5, marginTop: 6 }}>
{holding.total === 0
? "Nothing on your record yet."
: `${holding.total} garment${holding.total === 1 ? "" : "s"}${holding.sets ? ` · ${holding.sets} set${holding.sets === 1 ? "" : "s"}` : ""}`}
</div>
<div style={{ fontSize: 14, lineHeight: 1.5, marginTop: 4, color: N700 }}>{allowance.label}</div>
<p style={{ fontSize: 13, lineHeight: 1.55, color: N600, margin: "8px 0 0" }}>{allowance.note}</p>
</div>
{lines.length > 0 && (
<NumberedField n={2} label="Why">
<OptionList
columns={2}
value={reason}
onPick={(k) => { setReason(k); setErr(""); }}
options={REQUEST_REASONS.map((r) => ({ key: r, label: r }))}
/>
<textarea
value={note} onChange={(e) => setNote(e.target.value)} rows={3}
aria-label="Anything the linen room should know (optional)"
placeholder="Anything the linen room should know (optional)"
style={{ width: "100%", minHeight: 84, marginTop: 14, padding: 12, border: "2px solid var(--color-divider)", borderRadius: 0, font: "inherit", fontSize: 15, resize: "none", background: "#fff", color: "var(--color-text)" }}
/>
<p style={{ fontSize: 13, color: N600, lineHeight: 1.55, margin: "12px 0 0" }}>
One reason covers the whole request. {managerName || "Your manager"} can approve some
garments and knock others back.
</p>
</NumberedField>
)}
<MError msg={err} onDismiss={() => setErr("")} />
{!managerName && (
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, padding: "16px 16px 0", margin: 0 }}>
Nobody is recorded as your approver yet, so this can&rsquo;t be sent. Ask the linen room
to set your manager on your staff record.
</p>
)}
<div style={{ height: 12 }} />
</MBody>
<MBar
label={busy ? "Sending…" : managerName ? `Send to ${managerName}` : "Send for approval"}
sub={lines.length ? `${garments} garment${garments === 1 ? "" : "s"} on ${lines.length} line${lines.length === 1 ? "" : "s"}` : undefined}
disabled={!lines.length || busy || !managerName}
onClick={async () => {
if (!lines.length) return;
const r = await mutate<{ id: string; notified: boolean }>("request.create", {
lines: lines.map((l) => ({ itemId: l.itemId, si: l.si, qty: l.qty })),
reason: reason || "", note,
});
if (!r.ok) { setErr(r.error); return; }
// `notified` means an email actually left the server, not that the manager has an account.
// Nothing was lost either way — the request is raised and waiting for them — but somebody
// who thinks their manager has been told will wait a fortnight before asking, so the one
// case where nobody has been told says so before the screen changes.
if (!r.result.notified) {
setSent({ id: r.result.id, manager: managerName });
return;
}
window.location.assign(`/my/orders/${r.result.id}`);
}}
/>
</>
);
}
+266
View File
@@ -0,0 +1,266 @@
"use client";
/* Reviewing one request: approve it, or knock back the garments that shouldn't go.
*
* One request covers everything the person asked for, so this screen shows the whole ask and
* settles it in one action. A manager can still refuse part of it — the tunic and the trousers
* yes, the fleece no — and each line carries its own control for that. Everything not knocked back
* is approved when the bar at the bottom is pressed, and the bar says how many that is, because
* "Approve" over a list of three garments with one struck out has to be unambiguous.
*
* The decline reason is compulsory and comes from a fixed list of three, and it is always shown to
* the staff member — per garment now, rather than for the request as a whole. That is the point:
* the thing this replaces is a request that goes quiet, and a refusal nobody can explain is the
* same failure with an extra step.
*
* A manager can be the person the request is for. Two ward managers commonly name each other as
* approver — that is how the top of the tree gets one at all — and the server now lets the wearer
* settle their own, provided somebody actually reports to them. The queue sets those apart; so does
* this screen, next to the button, because having been told on a list you scrolled past is not the
* same as being told at the moment you sign.
*
* The allowance line tells the manager what the cap is *and* that releasing the second allocation
* is not theirs to do. Operational Officers are the only capped role; for everyone else the
* manager's judgement is the number, and the screen says so rather than showing a limit that
* doesn't exist.
*/
import { useState } from "react";
import { MBar, MBody, MError, MRule, MTop } from "@/components/m";
import { ACCENT_700, CompactAction, INK, Kicker, LineList, N600, N700, lineText } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { DECLINE_REASONS } from "@/lib/staffreq";
import type { ReviewLine } from "@/lib/managerdata";
type Data = {
id: string; code: string; status: string;
subject: { id: string; name: string; num: string; group: string; ward: string; held: number; sets: number; approvedThisYear: number };
lines: ReviewLine[]; summary: string; garments: number; lineCount: number; decision: string | null;
reason: string; note: string; raisedByName: string;
allowance: { capped: boolean; label: string; note: string; over: boolean };
};
/** What the manager has pencilled against each line before they press the bar. */
type Call = { decision: "approved" | "declined"; reason: string };
export default function ReviewScreen({ data }: { data: Data }) {
const { me, mutate, busy } = useStaff();
/* Everything starts approved. That is not a default in the lazy sense — it is what the button at
* the bottom will do, spelled out on every line before it is pressed, so the manager is choosing
* what to refuse rather than ticking off what to allow. */
const [calls, setCalls] = useState<Record<string, Call>>(
() => Object.fromEntries(data.lines.map((l) => [l.id, { decision: "approved" as const, reason: "" }])),
);
/* Which line's reason list is open — or "*" for the one that settles the whole request. */
const [asking, setAsking] = useState<string | null>(null);
const [err, setErr] = useState("");
const decided = data.status !== "awaiting";
/* Whether the person deciding this is the person it is for. Matched on the staff number, which
* the register keeps unique within a facility; a name would start calling a stranger's request
* yours the day two people on the ward share one, and what is being marked here is an audit
* fact. */
const mine = data.subject.id === me.staffId;
/* The server takes a self-approval from anybody a request is addressed to (lib/staffops.ts
* decideRequest — the reports test was dropped, by Kyle's decision), so the screen no longer has
* a "not yours to settle" reading: it said the bar would come back refused, and the bar approved.
* What it does say, every time, is that a self-approval is written down as one. */
const mayApproveOwn = true;
const yes = data.lines.filter((l) => calls[l.id]?.decision === "approved").length;
const total = data.lines.length;
function decline(lineId: string, reason: string) {
setAsking(null);
setErr("");
setCalls((c) => (lineId === "*"
// Declining the lot: one reason against every line, which is also what makes the request's
// own decline reason true rather than invented.
? Object.fromEntries(data.lines.map((l) => [l.id, { decision: "declined" as const, reason }]))
: { ...c, [lineId]: { decision: "declined", reason } }));
}
async function send() {
const lines = data.lines.map((l) => ({
id: l.id,
decision: calls[l.id]?.decision ?? "approved",
reason: calls[l.id]?.reason ?? "",
}));
const every = lines.every((l) => l.decision === "declined");
// The op name matches the outcome the manager can see on the button; `lines` is what actually
// decides, garment by garment, and it has to name every one of them exactly once.
const r = await mutate(every ? "request.decline" : "request.approve", {
id: data.id,
lines,
// When the whole request went, and every line went for the same reason, that reason is the
// request's reason too — it is what the wearer's order and the decision email lead with.
reason: every && new Set(lines.map((l) => l.reason)).size === 1 ? lines[0].reason : undefined,
});
if (!r.ok) { setErr(r.error); return; }
window.location.assign("/my/approvals");
}
const barLabel = busy ? "Working…"
: yes === 0 ? (total === 1 ? "Decline" : `Decline all ${total}`)
: yes === total ? (total === 1 ? "Approve" : `Approve all ${total}`)
: `Approve ${yes} of ${total}`;
return (
<>
<MTop title="Review request" back right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{data.code}</span>} />
<MRule />
<MBody>
<div style={{ padding: "20px 16px 18px", borderBottom: "2px solid " + INK, background: "var(--color-bg)" }}>
<Kicker>{[data.subject.ward, data.subject.num, data.subject.group].filter(Boolean).join(" · ")}</Kicker>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 28, letterSpacing: "-0.02em", lineHeight: 1.15, marginTop: 8 }}>
{data.subject.name}
</div>
{mine && <div style={{ marginTop: 10 }}><Kicker tone="attention">Your own uniform</Kicker></div>}
<div style={{ fontSize: 14, color: N700, marginTop: 8 }}>
Holds {data.subject.held} item{data.subject.held === 1 ? "" : "s"}
{data.subject.approvedThisYear > 0 && ` · ${data.subject.approvedThisYear} request${data.subject.approvedThisYear === 1 ? "" : "s"} approved`}
</div>
</div>
<div style={{ padding: "16px 16px 0", display: "flex", alignItems: "baseline", gap: 12 }}>
<span style={{ flex: 1 }}><Kicker>Asking for</Kicker></span>
<span style={{ fontSize: 12, color: N600 }}>
{data.garments} garment{data.garments === 1 ? "" : "s"}{total > 1 ? ` · ${total} lines` : ""}
</span>
</div>
{decided ? (
<>
<div style={{ margin: "12px 16px 0" }}><LineList lines={data.lines} /></div>
<p style={{ fontSize: 14, lineHeight: 1.6, color: N700, padding: "12px 16px 0", margin: 0 }}>
{data.decision ? `${data.decision}. ` : ""}This one has already been settled it is with
the linen room or closed. Nothing here is waiting on you.
</p>
</>
) : (
<div style={{ display: "grid", gap: 2, margin: "12px 16px 0" }}>
{data.lines.map((l) => {
const call = calls[l.id] ?? { decision: "approved" as const, reason: "" };
const off = call.decision === "declined";
return (
<div key={l.id} style={{ background: "#fff", padding: "14px 16px", borderLeft: `6px solid ${off ? ACCENT_700 : "transparent"}` }}>
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.01em",
lineHeight: 1.25, textDecoration: off ? "line-through" : "none", color: off ? N600 : INK,
}}>{lineText(l)}</div>
<div style={{ fontSize: 13, color: N600, marginTop: 5, lineHeight: 1.45 }}>
{[
l.stock,
l.held ? `holds ${l.held}` : "holds none",
l.heldThisSize ? `${l.heldThisSize} in this size` : "",
].filter(Boolean).join(" · ")}
</div>
</div>
{off
? <CompactAction label="Undo" onClick={() => { setCalls((c) => ({ ...c, [l.id]: { decision: "approved", reason: "" } })); setAsking(null); }} />
: <CompactAction label="Decline" onClick={() => setAsking(asking === l.id ? null : l.id)} />}
</div>
{off && (
<div style={{ fontSize: 12.5, fontWeight: 800, color: ACCENT_700, marginTop: 8, letterSpacing: "0.06em", textTransform: "uppercase" }}>
{call.reason}
</div>
)}
{asking === l.id && (
<div style={{ marginTop: 12 }}>
<Kicker tone="attention">Why not this one?</Kicker>
<div style={{ display: "grid", gap: 2, marginTop: 10 }}>
{DECLINE_REASONS.map((r) => (
<button key={r} onClick={() => decline(l.id, r)} style={{
minHeight: 52, background: "var(--color-neutral-200)", color: INK, border: 0, borderRadius: 0,
textAlign: "left", padding: "0 14px", font: "inherit", fontSize: 15, fontWeight: 800, cursor: "pointer",
}}>{r}</button>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
{(data.reason || data.note || data.raisedByName) && (
<div style={{ margin: "12px 16px 0", background: "#fff", padding: "14px 16px" }}>
{data.reason && <div style={{ fontSize: 14, fontWeight: 800 }}>{data.reason}</div>}
{data.note && <p style={{ fontSize: 14, lineHeight: 1.55, color: N700, margin: data.reason ? "8px 0 0" : 0 }}>{data.note}</p>}
{data.raisedByName && (
<p style={{ fontSize: 13, color: N600, margin: "10px 0 0" }}>Raised for them by {data.raisedByName}.</p>
)}
</div>
)}
<div style={{ margin: "12px 16px 0", background: "#fff", borderLeft: `6px solid ${data.allowance.over ? "var(--color-accent)" : INK}`, padding: "14px 16px" }}>
<Kicker tone={data.allowance.over ? "attention" : "quiet"}>Allowance</Kicker>
<div style={{ fontSize: 15, lineHeight: 1.5, marginTop: 6 }}>{data.allowance.label}</div>
<p style={{ fontSize: 13, lineHeight: 1.55, color: N600, margin: "8px 0 0" }}>{data.allowance.note}</p>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
{!decided && (
<div style={{ padding: "16px 16px 0" }}>
{asking === "*" ? (
<>
<Kicker tone="attention">Why are you declining all of it?</Kicker>
<p style={{ fontSize: 13, lineHeight: 1.55, color: N600, margin: "8px 0 12px" }}>
{data.subject.name.split(" ")[0]} is told which one you picked.
</p>
<div style={{ display: "grid", gap: 2 }}>
{DECLINE_REASONS.map((r) => (
<button key={r} onClick={() => decline("*", r)} style={{
minHeight: 56, background: "#fff", color: INK, border: 0, borderRadius: 0, textAlign: "left",
padding: "0 16px", font: "inherit", fontSize: 15.5, fontWeight: 800, cursor: "pointer",
}}>{r}</button>
))}
</div>
<div style={{ marginTop: 12 }}><CompactAction label="Back" onClick={() => setAsking(null)} /></div>
</>
) : yes > 0 ? (
<CompactAction label={total === 1 ? "Decline it instead" : "Decline the whole request"} onClick={() => setAsking("*")} />
) : (
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: 0 }}>
Nothing on this request will be picked. {data.subject.name.split(" ")[0]} is told the
reason against each garment.
</p>
)}
</div>
)}
{mine && !decided && (
/* Last thing above the bar, because the bar is what does it. Whichever half of the rule
the reader is in, they find out here rather than from a refusal or from an auditor. */
<div style={{ margin: "16px 16px 0", background: "#fff", borderLeft: `6px solid ${mayApproveOwn ? INK : ACCENT_700}`, padding: "14px 16px" }}>
<Kicker tone="attention">{mayApproveOwn ? "You are signing for yourself" : "Not yours to settle"}</Kicker>
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "8px 0 0" }}>
{mayApproveOwn
? "These garments are for you, and this one is yours to decide. The request's own history will say, in words, that you approved your own uniform, and so will the record anybody reads afterwards."
: "These garments are for you, and only a manager with somebody reporting to them can decide their own. Nobody reports to you at the moment, so this will come back refused whichever way you send it — ask the linen room to hand it to another manager."}
</p>
</div>
)}
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
Only what you approve reaches the linen room, and it goes in one bag with one collection code.
</p>
<div style={{ height: 12 }} />
</MBody>
{!decided && (
<MBar
label={barLabel}
sub={yes > 0 && yes < total ? `${total - yes} declined` : undefined}
glyph={yes > 0 ? "check" : "arrow"}
tone={yes > 0 ? "accent" : "ink"}
disabled={busy || asking !== null}
onClick={send}
/>
)}
</>
);
}
+201
View File
@@ -0,0 +1,201 @@
"use client";
/* 2D — Ward round manifest. What the desk sees when the trolley arrives.
*
* Unclaimed bags from previous rounds sit **above** today's work. They are the linen room's
* biggest waste — a bag signed for on the ward and never collected is a garment out of stock and
* off the record — so the screen refuses to bury them under whatever arrived this morning.
*
* Anyone on the ward can sign, and whoever does is named on the requester's order. That is the
* whole audit story: a missing bag has a name against it.
*/
import { useState } from "react";
import { MBody, MError, MRule, MTop } from "@/components/m";
import { ACCENT_300, CompactAction, DoneRow, EdgeRow, GROUND, INK, Kicker, LineList, N300, N400, N600, N700, SecondaryBar } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import type { ReqLine, ReqRow } from "@/lib/staffdata";
import { formatInZone } from "@/lib/compute";
type Bag = {
id: string; code: string; subjectName: string;
/** What is actually in the bag: the approved lines, and nothing the manager knocked back. */
lines: ReqLine[]; summary: string; garments: number; lineCount: number;
status: string; signerName: string | null; signedAt: string | null; claimedAt: string | null;
since: string;
};
export default function RoundScreen({ ward, toSign, unclaimed, signedToday, raised }: {
ward: string; toSign: Bag[]; unclaimed: Bag[]; signedToday: Bag[];
/** Open requests this person raised for someone else, however they came to raise them. */
raised: ReqRow[];
}) {
const { mutate, busy, me } = useStaff();
/* `signedAt` arrives as a UTC instant, and both of the places it is shown used to read it as
* local text — slicing the first ten characters for the date, and formatting with no zone for the
* time. The ward round happens in the morning, which is exactly when the UTC date is still
* yesterday's, so the desk was routinely told a bag it signed for an hour ago went out the day
* before. The facility's zone answers both. */
const tz = me.tz;
const [err, setErr] = useState("");
async function sign(id: string) {
const r = await mutate("round.sign", { id });
if (!r.ok) setErr(r.error);
}
async function claim(id: string) {
const r = await mutate("round.claim", { id });
if (!r.ok) { setErr(r.error); return; }
window.location.reload();
}
return (
<>
<MTop title="Ward round" back right={<span style={{ fontSize: 12, color: N400 }}>{ward}</span>} />
<MRule />
<MBody>
<div style={{ background: INK, color: GROUND, padding: 18, display: "flex", alignItems: "center", gap: 16 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 34, letterSpacing: "-0.03em", lineHeight: 1 }}>
{toSign.length}
</div>
<div style={{ flex: 1, fontSize: 14, lineHeight: 1.4, color: N300 }}>
<div>{toSign.length === 1 ? "bag to sign" : "bags to sign"}</div>
<div>{toSign.length === 0 ? "nothing waiting" : "arriving today"}</div>
</div>
{signedToday.length > 0 && (
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>
{signedToday.length} signed
</div>
)}
</div>
<MError msg={err} onDismiss={() => setErr("")} />
{unclaimed.length > 0 && (
<>
<div style={{ padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
<Kicker tone="attention">Unclaimed from earlier rounds</Kicker>
</div>
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{unclaimed.map((b) => (
<EdgeRow key={b.id} tone="accent">
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
<div style={{ fontSize: 12.5, color: N600, marginTop: 4, lineHeight: 1.4 }}>
{b.summary} · {b.code}
</div>
<div style={{ fontSize: 12.5, color: N600, marginTop: 2 }}>
signed by {b.signerName === me.name ? "you" : b.signerName}
{b.signedAt ? ` ${formatInZone(b.signedAt, tz)}` : ""}
</div>
</div>
<CompactAction label="Nudge" onClick={() => { window.location.assign(`/my/orders/${b.id}/messages`); }} />
{/* The desks own way out of this list. Nudging only works if the requester
eventually opens the app; often the bag went days ago and the person who
knows that is the clerk standing where it used to be. */}
<CompactAction label="Collected" tone="accent" disabled={busy} onClick={() => claim(b.id)} />
</div>
</EdgeRow>
))}
</div>
</>
)}
<div style={{ padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
<Kicker>Arriving today</Kicker>
</div>
{toSign.length === 0 && signedToday.length === 0 ? (
<div style={{ padding: "22px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
Nothing on the round for {ward || "your ward"} right now.
</div>
) : (
<>
{toSign.map((b) => (
<div key={b.id} style={{ padding: "14px 16px", background: "#fff", borderTop: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
<div style={{ fontSize: 12.5, color: N600, marginTop: 4 }}>{b.summary} · {b.code}</div>
</div>
<CompactAction label="Sign" tone="accent" disabled={busy} onClick={() => sign(b.id)} />
</div>
{/* Signing is a signature: whoever puts their name to a bag of four garments should
be able to see the four before they do, not a count. Nothing declined is listed
— a knocked-back garment never reaches the trolley. */}
{b.lineCount > 1 && (
<div style={{ marginTop: 10 }}><LineList lines={b.lines} /></div>
)}
</div>
))}
{signedToday.map((b) => (
<DoneRow key={b.id}>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{b.subjectName}</div>
<div style={{ fontSize: 12.5, marginTop: 4 }}>
signed {b.signedAt ? formatInZone(b.signedAt, tz, { hour: "2-digit", minute: "2-digit", hour12: false }) : ""} by {b.signerName}
</div>
</div>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="square" aria-hidden><path d="m4 12 5 5L20 6" /></svg>
</div>
</DoneRow>
))}
</>
)}
{/* What this person raised in somebody else's name, still on its way. The bags above are
only the ones arriving today; a request typed in on Tuesday and approved on Thursday is
invisible there until it turns up on a trolley, so without this the only way to find out
where it had got to was to ring the linen room. */}
{raised.length > 0 && (
<>
<div style={{ padding: "18px 16px 8px", borderTop: "2px solid " + INK, borderBottom: "2px solid " + INK, background: GROUND }}>
<Kicker>Raised by you · still open</Kicker>
</div>
<div style={{ display: "grid", gap: 2, padding: "12px 0" }}>
{raised.map((r) => {
const st = statusText(r, { mine: false, first: r.subjectName?.split(" ")[0] });
return (
<EdgeRow key={r.id} tone="divider" href={`/my/orders/${r.id}`}>
<div style={{ display: "flex", gap: 12, alignItems: "baseline" }}>
<span style={{ flex: 1, fontSize: 16, fontWeight: 800 }}>{r.subjectName}</span>
<span style={{ fontSize: 12, color: N600 }}>{r.code}</span>
</div>
<div style={{ fontSize: 14.5, fontWeight: 600, marginTop: 5, lineHeight: 1.35 }}>{r.summary}</div>
<div style={{ fontSize: 12.5, color: N600, marginTop: 4 }}>
{[st.label, st.note].filter(Boolean).join(" · ")}
</div>
</EdgeRow>
);
})}
</div>
</>
)}
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
Anyone on the ward can sign. Whoever does appears on the requester&rsquo;s order, so a
missing bag has a name against it.
</p>
<div style={{ height: 12 }} />
</MBody>
{toSign.length > 1 && (
<div style={{ borderTop: "2px solid " + INK }}>
{/* Bulk signing is allowed but not encouraged — a secondary action, never the red one. */}
<SecondaryBar
label={busy ? "Signing…" : `Sign for all ${toSign.length} remaining`}
disabled={busy}
onClick={async () => {
for (const b of toSign) {
const r = await mutate("round.sign", { id: b.id });
if (!r.ok) { setErr(r.error); return; }
}
}}
/>
</div>
)}
</>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
/* 1H — Shelf check. Kill the "have you got any size 12s?" message before it is sent.
*
* Words, never counts. Wards see In stock / Low / None on shelf; the number stays in the linen
* room. That is partly the product rule and partly honesty — the count here is as fresh as the
* last stocktake of that garment, which is why each row says when it was counted rather than
* implying a live figure.
*/
import Link from "next/link";
import { useMemo, useState } from "react";
import { MBody, MRule, MTop, inputStyle } from "@/components/m";
import { INK, N600, N700, StockTag } from "@/components/staffui";
import StaffNav from "@/components/staffnav";
import { fmtDate } from "@/lib/compute";
type Size = { size: string; si: number; word: string; countedOn: string };
type Item = { id: string; item: string; type: string; gender: string; sizes: Size[]; recorded: string };
export default function ShelfScreen({ items }: { items: Item[] }) {
const [q, setQ] = useState("");
const shown = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return items;
return items.filter((i) =>
i.item.toLowerCase().includes(needle) ||
i.type.toLowerCase().includes(needle) ||
i.sizes.some((s) => String(s.size).toLowerCase() === needle));
}, [items, q]);
return (
<>
<MTop title="On the shelf" back />
<MRule />
<MBody>
<div style={{ background: "#fff", padding: 16, borderBottom: "2px solid " + INK }}>
{/* Named, not just placeheld: the placeholder is gone as soon as anyone types, and a
box that then announces itself as "edit, blank" is a box nobody can come back to. */}
<input
value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search garments or a size"
aria-label="Search garments or a size"
autoComplete="off" style={{ ...inputStyle, width: "100%" }}
/>
</div>
{shown.length === 0 && (
<div style={{ padding: "28px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
Nothing matches &ldquo;{q}&rdquo;.
</div>
)}
{shown.map((it) => (
<div key={it.id}>
<div style={{ padding: "18px 16px 8px", background: "var(--color-bg)", borderBottom: "2px solid " + INK }}>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>
{it.item}{it.gender && it.gender !== "Unisex" ? ` · ${it.gender}` : ""}
</span>
</div>
{it.sizes.map((s) => {
const row = (
<>
<span style={{ flex: 1, fontSize: 16, fontWeight: 800, textAlign: "left" }}>
{s.size}
{it.recorded && String(it.recorded) === String(s.size) && (
<span style={{ fontSize: 12, fontWeight: 600, color: N600, marginLeft: 8 }}>your size</span>
)}
</span>
<StockTag word={s.word} />
</>
);
const style: React.CSSProperties = {
display: "flex", gap: 12, alignItems: "center", padding: "14px 16px", background: "#fff",
borderTop: "1px solid var(--color-divider)", borderRight: 0, borderBottom: 0, width: "100%",
borderLeft: s.word === "none" ? "6px solid var(--color-accent)" : "6px solid transparent",
font: "inherit", color: INK, borderRadius: 0, textDecoration: "none",
};
// A size that isn't there is the one row worth tapping: it leads to the waitlist
// rather than a dead end, which is the whole point of 2B.
return s.word === "none" ? (
<Link key={s.si} href={`/my/waitlist?item=${it.id}&si=${s.si}`} style={style} className="tcx-bar">{row}</Link>
) : (
<div key={s.si} style={style}>{row}</div>
);
})}
</div>
))}
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
Availability comes from the last linen-room stocktake
{shown[0]?.sizes.find((s) => s.countedOn) ? ` — most recently ${fmtDate(shown.flatMap((i) => i.sizes).map((s) => s.countedOn).filter(Boolean).sort().reverse()[0] || "")}` : ""}.
Wards see words, not counts.
</p>
<div style={{ height: 12 }} />
</MBody>
<StaffNav />
</>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
/* 1F — Order messages. Ask about *this* order.
*
* One thread per order, and no general inbox. That is the rule that keeps this from becoming a
* chat app nobody staffs: every message arrives attached to the thing it is about, so whoever
* picks it up in the linen room already knows what is being asked.
*/
import { useEffect, useRef, useState } from "react";
import { Bubble, Composer, ContextStrip, DateSeparator, N600 } from "@/components/staffui";
import { MBody, MError, MRule, MTop } from "@/components/m";
import { useStaff } from "@/lib/staffclient";
import { statusText } from "@/lib/staffreq";
import { addDays, facilityDate, facilityToday, formatInZone } from "@/lib/compute";
type Msg = { id: string; fromStaff: boolean; authorName: string; body: string; at: string };
type Data = {
id: string; code: string; status: string; summary: string;
managerName: string; declineReason: string | null; holdUntil: string; ward: string;
signerName: string | null; signerRole: string | null;
messages: Msg[];
};
/* Both of these run twice — once on the server rendering this screen, once in the browser hydrating
* it — so neither may read the ambient zone. `toDateString()` did exactly that: on a UTC host a
* message sent at 08:00 Brisbane was separated under "Yesterday" and stamped 22:30, then flipped to
* "Today" and 08:30 when React took over. Comparing calendar dates in the facility's zone gives the
* same answer in both places, and it is the ward's answer. */
const dayLabel = (iso: string, tz: string) => {
const day = facilityDate(iso, tz);
if (!day) return "";
const today = facilityToday(tz);
if (day === today) return "Today";
if (day === addDays(today, -1)) return "Yesterday";
return formatInZone(iso, tz, { day: "numeric", month: "long" });
};
const timeLabel = (iso: string, tz: string) => formatInZone(iso, tz, { hour: "2-digit", minute: "2-digit", hour12: false });
export default function ThreadScreen({ data }: { data: Data }) {
const { mutate, busy, me } = useStaff();
const [body, setBody] = useState("");
const [err, setErr] = useState("");
const [sent, setSent] = useState<Msg[]>([]);
const endRef = useRef<HTMLDivElement>(null);
/* Anything the server has already told us about wins: `mutate` refreshes this screen, so a message
* we optimistically appended comes back in `data.messages` under the same id a moment later. Without
* this the nurse sees her own question twice, once from each list, for as long as she stays on the
* thread — and the clerk reading the same order sees the doubled conversation too. */
const confirmed = new Set(data.messages.map((m) => m.id));
const all = [...data.messages, ...sent.filter((m) => !confirmed.has(m.id))];
useEffect(() => { endRef.current?.scrollIntoView({ block: "end" }); }, [all.length]);
const st = statusText(data);
let lastDay = "";
return (
<>
<MTop title={data.code} back right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>Messages</span>} />
<MRule />
<ContextStrip>
{/* The summary rather than the lines: this strip is here to say which order the thread
belongs to, and a request for four garments would push the first message off the
screen. Whoever needs the detail is one tap away on the order itself. */}
{data.summary} · {st.label.toLowerCase()}
</ContextStrip>
<MBody>
{all.length === 0 && (
<div style={{ padding: "28px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
Nothing here yet. Ask the linen room about this order and they&rsquo;ll see it against
the request.
</div>
)}
{all.map((m) => {
const day = dayLabel(m.at, me.tz);
const sep = day !== lastDay;
lastDay = day;
return (
<div key={m.id}>
{sep && <DateSeparator>{day}</DateSeparator>}
<Bubble
mine={m.fromStaff && m.authorName === me.name}
author={m.fromStaff ? m.authorName : "Linen room"}
body={m.body}
stamp={timeLabel(m.at, me.tz)}
/>
</div>
);
})}
<MError msg={err} onDismiss={() => setErr("")} />
<div ref={endRef} style={{ height: 8 }} />
</MBody>
<Composer
value={body}
onChange={(v) => { setBody(v); setErr(""); }}
busy={busy}
placeholder="Ask about this order"
onSend={async () => {
const text = body.trim();
if (!text) return;
setBody("");
const r = await mutate<{ id: string; at: string }>("request.message", { id: data.id, body: text });
if (!r.ok) { setErr(r.error); setBody(text); return; }
// Shown immediately with the server's own stamp, so the thread doesn't jump when the
// page refreshes underneath it.
setSent((s) => [...s, { id: r.result.id, fromStaff: true, authorName: me.name, body: text, at: r.result.at }]);
}}
/>
</>
);
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
/* 2B — Waitlist. What "none on the shelf" leads to instead of a dead end.
*
* Position is shown **before** joining, because "you are fourth" and "you are fortieth" are
* different decisions and only one of them is worth waiting for. The nearest stocked sizes sit
* right underneath for the same reason: most people would rather have something that fits
* approximately today than exactly in three weeks, and the screen should let them say so.
*
* Joining needs no approval — a queue is not a request. Approval happens if and when the item
* lands and they accept it.
*/
import { useState } from "react";
import { MBar, MBody, MError, MRule, MTop } from "@/components/m";
import { ACCENT_300, CompactAction, DarkCard, DIVIDER, GROUND, INK, Kicker, N300, N600, N700, StockTag } from "@/components/staffui";
import { useStaff } from "@/lib/staffclient";
import { fmtDate, formatInZone } from "@/lib/compute";
import { WAITLIST_HOLD_HOURS } from "@/lib/staffreq";
type Alt = { si: number; size: string; word: string };
type Data = {
itemId: string; item: string; size: string; si: number;
lastRestocked: string; position: number; ahead: number;
joined: boolean; entryId: string | null; offeredAt: string | null;
holdUntil: string | null; offerExpired: boolean; acceptedAt: string | null;
alternatives: Alt[];
};
export default function WaitlistScreen({ data }: { data: Data }) {
const { mutate, busy, me } = useStaff();
const [err, setErr] = useState("");
const [joined, setJoined] = useState(data.joined);
/* Four states, and the screen used to know about two of them.
*
* An offer is not a standing invitation: it has been accepted, or it is live, or the 48-hour
* hold has run out. Keying only off `offeredAt` meant somebody who had already accepted was
* shown "Accept it" again — every tap answered "Nothing to accept" — and somebody whose hold
* had lapsed was shown a bar the server now refuses, with no word about why. */
const acceptedAt = data.acceptedAt;
const accepted = !!acceptedAt;
const heldForMe = !accepted && !!data.offeredAt && !data.offerExpired;
const lapsed = !accepted && !!data.offeredAt && data.offerExpired;
/* The deadline, with the time on it, in the facility's zone.
*
* `holdUntil` is an instant 48 hours after the offer, and it was being shown by slicing the first
* ten characters of the UTC string — so an offer made at 09:00 Brisbane printed the previous day's
* date, and a nurse reading "held until the 9th" had until the 10th. The hour matters as much as
* the date here: a hold that runs out mid-afternoon is not the same as one that runs to midnight. */
const heldUntil = data.holdUntil
? formatInZone(data.holdUntil, me.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })
: "";
return (
<>
<MTop title="Waitlist" back />
<MRule />
<MBody>
<div style={{ padding: "20px 16px 18px", borderBottom: "2px solid " + INK, background: GROUND }}>
<Kicker tone="attention">None on the shelf</Kicker>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.02em", lineHeight: 1.1, marginTop: 8 }}>
{data.item} {data.size}
</div>
<p style={{ fontSize: 14, lineHeight: 1.55, color: N700, margin: "10px 0 0" }}>
{data.lastRestocked ? `Last counted ${fmtDate(data.lastRestocked)}. ` : ""}
The linen room orders these in when the shelf runs down.
</p>
</div>
<MError msg={err} onDismiss={() => setErr("")} />
<div style={{ padding: 16 }}>
{accepted ? (
<DarkCard kicker="Accepted" title="Its with your manager" meta="You accepted this one, and it has gone to your manager for approval like any other request.">
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, color: N300 }}>
Accepted {formatInZone(acceptedAt || "", me.tz)}. Follow it on <a href="/my/orders" style={{ color: GROUND }}>your orders</a>.
</div>
</DarkCard>
) : heldForMe ? (
<DarkCard kicker="Its in" title="Held for you" meta="The linen room has one for you. Accept it and it goes to your manager for approval like any other request.">
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, color: N300 }}>
{heldUntil ? `Held until ${heldUntil}.` : "Held for you."}
</div>
</DarkCard>
) : (
<div style={{ background: INK, color: GROUND, padding: 18 }}>
<div style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: ACCENT_300 }}>
{joined ? "Youre on the list" : "If you join"}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 16, marginTop: 12 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 44, letterSpacing: "-0.03em", lineHeight: 1 }}>
{ordinal(data.position)}
</div>
<div style={{ flex: 1, fontSize: 14, lineHeight: 1.4, color: N300 }}>
<div>in queue</div>
<div>{data.ahead === 0 ? "nobody ahead of you" : `${data.ahead} ${data.ahead === 1 ? "person" : "people"} already waiting`}</div>
</div>
</div>
<div style={{ borderTop: "1px solid #4a4746", marginTop: 16, paddingTop: 14, fontSize: 13, lineHeight: 1.55, color: N300 }}>
{lapsed
? `One came in and was held for you until ${heldUntil || "the hold ran out"}. Nobody took it, so the hold has run out — youre still on the list, and the linen room can offer it again.`
: `Youll get a message the day it lands, and the item is held for you for ${WAITLIST_HOLD_HOURS} hours.`}
</div>
</div>
)}
</div>
{data.alternatives.length > 0 && !heldForMe && !accepted && (
<>
<div style={{ padding: "18px 16px 8px", borderBottom: "2px solid " + INK, background: GROUND }}>
<Kicker>Or take a stocked size</Kicker>
</div>
{data.alternatives.map((a) => (
<div key={a.si} style={{ display: "flex", gap: 12, alignItems: "center", padding: "14px 16px", background: "#fff", borderTop: `1px solid ${DIVIDER}` }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800 }}>{data.item} {a.size}</div>
<div style={{ fontSize: 12.5, marginTop: 4 }}><StockTag word={a.word} /></div>
</div>
<CompactAction label="Request" onClick={() => { window.location.assign(`/my/request?item=${data.itemId}&si=${a.si}`); }} />
</div>
))}
</>
)}
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, padding: 16, margin: 0 }}>
Waiting doesn&rsquo;t need approval. Your manager only sees it if the item comes in and you
accept it.
</p>
<div style={{ height: 12 }} />
</MBody>
{accepted ? null : heldForMe ? (
<MBar
label={busy ? "Working…" : "Accept it"}
glyph="check"
disabled={busy}
onClick={async () => {
const r = await mutate<{ request: { id: string } }>("waitlist.accept", { id: data.entryId });
if (!r.ok) { setErr(r.error); return; }
window.location.assign(`/my/orders/${r.result.request.id}`);
}}
/>
) : joined ? (
<MBar
label={busy ? "Working…" : "Leave the list"}
glyph="none"
tone="ink"
disabled={busy}
onClick={async () => {
const r = await mutate("waitlist.leave", { id: data.entryId });
if (!r.ok) { setErr(r.error); return; }
setJoined(false);
window.location.assign("/my/shelf");
}}
/>
) : (
<MBar
label={busy ? "Joining…" : "Join the waitlist"}
glyph="none"
disabled={busy}
onClick={async () => {
const r = await mutate("waitlist.join", { itemId: data.itemId, si: data.si });
if (!r.ok) { setErr(r.error); return; }
setJoined(true);
window.location.reload();
}}
/>
)}
</>
);
}
function ordinal(n: number) {
const s = ["th", "st", "nd", "rd"], v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
/* The manager's ward view: who on their team holds what.
*
* A list, not a dashboard. The only computed thing on it is each person's set count, and the
* footnote exists because the first question a manager asks about "3 of 6 sets" is whether
* something has to come back before the next set can. Nothing does — the owner's rule is six held
* at any time, reached without handing anything in.
*/
import { MBody, MRule, MTop } from "@/components/m";
import { INK, N600, N700 } from "@/components/staffui";
import ManagerNav from "./ManagerNav";
import { fmtDate } from "@/lib/compute";
type Row = { id: string; name: string; group: string; held: number; lastIssued: string; capped: boolean; setsLabel: string; isNewStarter: boolean };
export default function WardScreen({ ward, rows, anyCapped }: { ward: string; rows: Row[]; anyCapped: boolean }) {
return (
<>
<MTop
title={ward || "Your team"}
back
right={<span style={{ fontSize: 12, color: "var(--color-neutral-400)" }}>{rows.length} staff</span>}
/>
<MRule />
<MBody>
{rows.length === 0 ? (
<div style={{ padding: "28px 16px", fontSize: 14, color: N600, lineHeight: 1.6 }}>
Nobody is recorded as reporting to you. The linen room sets who approves each staff
member on their record.
</div>
) : (
<>
<div style={{ display: "flex", padding: "12px 16px", borderBottom: "2px solid " + INK, background: "var(--color-bg)" }}>
<span style={{ flex: 1, fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>{ward || "Your team"}</span>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: N600 }}>Items held</span>
</div>
{rows.map((r) => (
<div key={r.id} style={{ display: "flex", gap: 12, alignItems: "center", padding: "14px 16px", background: "#fff", borderTop: "1px solid var(--color-divider)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.3 }}>{r.name}</div>
<div style={{ fontSize: 12.5, color: N600, marginTop: 4, lineHeight: 1.4 }}>
{[
r.group,
r.capped ? r.setsLabel : "",
r.isNewStarter && r.lastIssued ? `set issued ${fmtDate(r.lastIssued)}` : "",
].filter(Boolean).join(" · ")}
</div>
</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20, fontVariantNumeric: "tabular-nums" }}>{r.held}</div>
</div>
))}
</>
)}
{anyCapped && (
<p style={{ fontSize: 14, lineHeight: 1.6, color: N700, padding: 16, margin: 0, background: "var(--color-bg)" }}>
The figure against each person is how many sets they hold, out of the most anyone may
hold at once. Nothing has to be handed back before the next set is issued.
</p>
)}
<div style={{ height: 12 }} />
</MBody>
<ManagerNav />
</>
);
}