Files
ThreadCount 0910bc32c1 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 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-16 07:57:54 +10:00

256 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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}`);
}}
/>
</>
);
}