Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
/* Asking for a uniform, in three taps.
|
||||
*
|
||||
* 01 what you need — the things already on your record first, in the size the record has for them
|
||||
* 02 the size, each one carrying the word the ward is allowed to see, and how many
|
||||
* 03 why
|
||||
*
|
||||
* The old screen put a picker dialog between the person and all three, which is six or seven taps
|
||||
* for the commonest ask in the product: another of the top I already wear. The picker is gone; the
|
||||
* garments they hold are at the top of the list, choosing one fills in their size, and the bar
|
||||
* names the person it is going to — "Send to" and the approver's own name, not "Submit" —
|
||||
* because who has it is the single most asked question about a request.
|
||||
*
|
||||
* Two things the mockup's three taps do not cover are kept, because the product rests on them.
|
||||
* One request may carry up to REQUEST_MAX_LINES garments — the manager's per-line decline, the one
|
||||
* collection code and one bag all depend on it — so *Add another garment* appears once a line is
|
||||
* complete and the chosen lines are listed above. And the allowance the manager will measure this
|
||||
* against is on screen while they are still choosing, in one line, rather than arriving inside a
|
||||
* refusal against a number they were never shown.
|
||||
*
|
||||
* No prices, no payment, no basket, and no count of what is on the shelf: a size carries a word.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { MBar, MBody, MChipRow, MError, MONO, MRule, MStepper, MTop, useToast } from "@/components/m";
|
||||
import {
|
||||
ACCENT, DraftLineList, INK, N200, N500, N600, N700, NumberedField, OptionList, SecondaryBar,
|
||||
type DraftLine,
|
||||
} from "@/components/staffui";
|
||||
import Sent from "@/components/screens/Sent";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { REQUEST_REASONS, stockLabel, type StockWord } from "@/lib/staffreq";
|
||||
import { genderLabel } from "@/lib/compute";
|
||||
|
||||
type Size = { size: string; si: number; word: StockWord | string; countedOn: string; held: number };
|
||||
type Item = {
|
||||
id: string; item: string; type: string; gender: string; sizes: Size[];
|
||||
recorded: string; recordedSource: "record" | "issued" | ""; held: number;
|
||||
};
|
||||
type Allowance = { capped: boolean; label: string; note: string; over: boolean };
|
||||
|
||||
/** The word under a size chip. The short lowercase forms are the mockup's; `stockLabel()` is what
|
||||
* a screen reader is given, so the vocabulary the rest of the app uses is the one announced. */
|
||||
function chipWord(w: string): string {
|
||||
return w === "in_stock" ? "in stock" : w === "low" ? "low" : "none";
|
||||
}
|
||||
|
||||
function lineOf(l: { qty: number; item: string; size: string }): string {
|
||||
return l.qty > 1 ? `${l.qty} × ${l.item} — ${l.size}` : `${l.item} — ${l.size}`;
|
||||
}
|
||||
|
||||
export default function RequestScreen({
|
||||
items, managerName, swap, heldItemIds, preItemId, preSi, preReason, filledFrom,
|
||||
holding, allowance, notifyWays, maxLines, maxQty,
|
||||
}: {
|
||||
items: Item[]; managerName: string; swap: boolean; heldItemIds: string[];
|
||||
preItemId: string | null; preSi: number | null; preReason: string | null;
|
||||
/** The code of the request *Same again* copied, for the one line that says so. */
|
||||
filledFrom: string | null;
|
||||
holding: { total: number; sets: number }; allowance: Allowance;
|
||||
notifyWays: { email: boolean; push: boolean };
|
||||
maxLines: number; maxQty: number;
|
||||
}) {
|
||||
const { mutate, busy } = useStaff();
|
||||
const toast = useToast();
|
||||
|
||||
// Swapping a size is a request against something you already hold, so the list is the shorter
|
||||
// one. Everything else about the screen is identical — a swap is not a different kind of ask.
|
||||
const base = swap && heldItemIds.length ? items.filter((i) => heldItemIds.includes(i.id)) : items;
|
||||
/* The things on your record first. That is the whole point of the redesign: the commonest ask is
|
||||
* another of something you already wear, and it used to be somewhere down an alphabetical list
|
||||
* of the entire catalogue. Within each half the linen room's own order is kept. */
|
||||
const list = [...base.filter((i) => i.held > 0), ...base.filter((i) => i.held === 0)];
|
||||
// Names that more than one garment on this list answers to (see the option label below).
|
||||
const sharedNames = new Set(list.filter((i, _n, all) => all.some((o) => o.id !== i.id && o.item === i.item)).map((i) => i.item));
|
||||
|
||||
const [lines, setLines] = useState<DraftLine[]>([]);
|
||||
/* A pre-selection only counts if it is on the list this screen is offering. `?swap=1` narrows the
|
||||
* list to what they hold, and an `?item=` arriving from the shelf or a waitlist alternative can
|
||||
* fall outside it — leaving a chosen garment nothing could resolve, and steps 02 and 03 never
|
||||
* drawn. */
|
||||
const [itemId, setItemId] = useState<string | null>(() => (preItemId && list.some((i) => i.id === preItemId) ? preItemId : null));
|
||||
const [si, setSi] = useState<number | null>(preSi);
|
||||
const [qty, setQty] = useState(1);
|
||||
const [reason, setReason] = useState<string | null>(preReason);
|
||||
const [err, setErr] = useState("");
|
||||
const [sent, setSent] = useState<{ id: string; code: string; manager: string; notified: boolean; lines: string[] } | null>(null);
|
||||
|
||||
const item = list.find((i) => i.id === itemId) || null;
|
||||
const size = item && si !== null ? item.sizes.find((s) => s.si === si) || null : null;
|
||||
const current = item && size ? { itemId: item.id, si: size.si, item: item.item, size: String(size.size), qty } : null;
|
||||
const all = current ? [...lines, current] : lines;
|
||||
const garments = all.reduce((n, l) => n + l.qty, 0);
|
||||
const full = all.length >= maxLines;
|
||||
const ready = all.length > 0 && !!reason && !!managerName;
|
||||
/* Which step the bar is still waiting for.
|
||||
*
|
||||
* The mockup draws "pick an item" under a disabled bar, which is right on an empty screen and
|
||||
* wrong a moment later: with a garment and a size already chosen and only the reason outstanding,
|
||||
* it tells somebody to do the one thing they have just done. So the note names what is actually
|
||||
* missing, in the same order the screen asks for it. */
|
||||
const missing = !itemId && all.length === 0
|
||||
? { short: "pick an item", full: "Pick an item, a size and a reason" }
|
||||
: itemId && si === null
|
||||
? { short: "pick a size", full: "Pick a size" }
|
||||
: !reason
|
||||
? { short: "pick a reason", full: "Pick a reason" }
|
||||
: { short: "pick an item", full: "Pick an item, a size and a reason" };
|
||||
|
||||
/** Choosing a garment opens on the size the record already has for it — the difference between
|
||||
* three taps and five. Not when that size is the one that isn't there: pre-selecting a size the
|
||||
* linen room cannot pick is worse than asking. */
|
||||
function pickItem(id: string) {
|
||||
setErr("");
|
||||
setItemId(id);
|
||||
setQty(1);
|
||||
const it = list.find((i) => i.id === id);
|
||||
const rec = it ? it.sizes.find((s) => String(s.size) === String(it.recorded)) : null;
|
||||
setSi(rec && rec.word !== "none" ? rec.si : null);
|
||||
}
|
||||
|
||||
/** Put the garment being chosen on the list and start the next one. */
|
||||
function addAnother() {
|
||||
if (!current) return;
|
||||
setLines((cur) => {
|
||||
// The same garment in the same size twice is one line of two: the server sums duplicates
|
||||
// before it writes them, so two identical rows would be showing something that cannot save.
|
||||
const at = cur.findIndex((x) => x.itemId === current.itemId && x.si === current.si);
|
||||
if (at >= 0) {
|
||||
const next = [...cur];
|
||||
next[at] = { ...next[at], qty: Math.min(maxQty, next[at].qty + current.qty) };
|
||||
return next;
|
||||
}
|
||||
return [...cur, { ...current, key: `${current.itemId}:${current.si}:${cur.length}` }];
|
||||
});
|
||||
setItemId(null);
|
||||
setSi(null);
|
||||
setQty(1);
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
/* What happens next, answered by what this server can actually do rather than by what is
|
||||
* usually true: a registered phone, an email that really went out, or neither. */
|
||||
const who = sent.manager || "your manager";
|
||||
return (
|
||||
<Sent
|
||||
headline={`Sent to ${who}`}
|
||||
sub={`${sent.lines.join(", ")} · ${sent.code}`}
|
||||
next={
|
||||
notifyWays.push
|
||||
? "You get a notification when it is approved, and again when it is ready."
|
||||
: sent.notified
|
||||
? `${who} has been emailed.`
|
||||
: `It is waiting with ${who}.`
|
||||
}
|
||||
actions={[
|
||||
{ label: "Open the order", href: `/my/orders/${sent.id}` },
|
||||
{ label: "Back to home", href: "/my" },
|
||||
]}
|
||||
bar={{ label: "Back to home", href: "/my" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={swap ? "Swap a size" : "Request an item"} back backHref="/my" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
{/* The mockup toasts "Filled in from R-0042". Kept as a line on the screen rather than a
|
||||
toast: the toast host is mounted, but this screen is server-rendered and arrives with
|
||||
the garment already chosen, so a 2.2-second toast is gone — or never announced — before
|
||||
somebody has looked up from the list. A line a screen reader is told about survives. */}
|
||||
{filledFrom && (
|
||||
<div role="status" style={{
|
||||
background: INK, color: "var(--color-bg)", borderLeft: `6px solid ${ACCENT}`,
|
||||
padding: "12px 14px", fontSize: 14, fontWeight: 800,
|
||||
}}>Filled in from {filledFrom}</div>
|
||||
)}
|
||||
|
||||
{lines.length > 0 && (
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<DraftLineList
|
||||
lines={lines}
|
||||
maxQty={maxQty}
|
||||
onQty={(k, q) => setLines((cur) => cur.map((l) => (l.key === k ? { ...l, qty: q } : l)))}
|
||||
onRemove={(k) => setLines((cur) => cur.filter((l) => l.key !== k))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NumberedField n={1} label={swap ? "What you’re swapping" : "What you need"} first>
|
||||
{list.length === 0 ? (
|
||||
<p style={{ fontSize: 14, color: N600, lineHeight: 1.6, margin: 0 }}>
|
||||
{swap
|
||||
? "Nothing on your record to swap. Ask for an item instead."
|
||||
: "The linen room hasn’t listed any garments yet."}
|
||||
</p>
|
||||
) : (
|
||||
<OptionList
|
||||
value={itemId}
|
||||
onPick={pickItem}
|
||||
options={list.map((i) => {
|
||||
const held = i.sizes.find((s) => s.held > 0);
|
||||
return {
|
||||
key: i.id,
|
||||
/* Two garments can carry the same name in different cuts — a women's and a
|
||||
unisex scrub top are two rows on the register, and somebody set to "either"
|
||||
is offered both. Named identically they read as the list repeating itself, so
|
||||
the cut is added to whichever names are shared, and to nothing else. */
|
||||
label: sharedNames.has(i.item) ? `${i.item} · ${genderLabel(i.gender)}` : i.item,
|
||||
meta: i.held > 0 && held
|
||||
? `You hold ${i.held === 1 ? "one" : i.held} in ${held.size}`
|
||||
: "Not on your record",
|
||||
};
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</NumberedField>
|
||||
|
||||
{item && (
|
||||
<>
|
||||
<NumberedField n={2} label="Size">
|
||||
{/* Every size is shown, including the ones that aren't there: "it isn't there" is
|
||||
information somebody came for. A none size is a link to the queue for it —
|
||||
⛔ never a `disabled` button, which takes no tap and no focus, so the one route
|
||||
that answers their problem would be unreachable by touch and by keyboard alike. */}
|
||||
<div role="group" aria-label="Size" style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{item.sizes.map((s) => {
|
||||
const on = si === s.si;
|
||||
const none = s.word === "none";
|
||||
const st: React.CSSProperties = {
|
||||
minHeight: 46, minWidth: 50, padding: "0 12px", borderRadius: 0,
|
||||
border: `2px solid ${none ? "var(--color-divider)" : INK}`,
|
||||
background: none ? "transparent" : on ? INK : "#fff",
|
||||
color: none ? N500 : on ? "var(--color-bg)" : INK,
|
||||
font: "inherit", fontSize: 14, fontWeight: 700, textDecoration: "none",
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6,
|
||||
cursor: "pointer",
|
||||
};
|
||||
const inner = (
|
||||
<>
|
||||
{s.size}
|
||||
<small style={{ fontFamily: MONO, fontSize: 11, fontWeight: 500, color: on ? N200 : N600 }}>
|
||||
{chipWord(String(s.word))}
|
||||
</small>
|
||||
</>
|
||||
);
|
||||
return none ? (
|
||||
<Link
|
||||
key={s.si}
|
||||
href={`/my/waitlist?item=${encodeURIComponent(item.id)}&si=${s.si}`}
|
||||
aria-disabled="true"
|
||||
aria-label={`${s.size}, ${stockLabel("none")} — join the waitlist`}
|
||||
style={st}
|
||||
>{inner}</Link>
|
||||
) : (
|
||||
<button
|
||||
key={s.si}
|
||||
type="button"
|
||||
onClick={() => { setSi(s.si); setErr(""); }}
|
||||
aria-pressed={on}
|
||||
aria-label={`${s.size}, ${stockLabel(s.word as StockWord)}`}
|
||||
style={st}
|
||||
>{inner}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{size && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 12, minHeight: 60, marginTop: 8,
|
||||
borderTop: "1px solid var(--color-divider)", paddingTop: 10,
|
||||
}}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontWeight: 700 }}>How many</span>
|
||||
<MStepper n={qty} onChange={setQty} min={1} max={maxQty} label="garments" />
|
||||
</div>
|
||||
)}
|
||||
</NumberedField>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* One reason covers the whole request, so it stays put once anything is on the list.
|
||||
Gated on the garment being chosen, *Add another garment* took the only control that sets
|
||||
a reason off the screen — and the bar then asked for one that could no longer be picked. */}
|
||||
{(item || lines.length > 0) && (
|
||||
<NumberedField n={3} label="Why">
|
||||
{/* Chips, as the mockup draws them. Four short words do not need four 58px boxes with a
|
||||
chevron each: the reason is the last of the three taps, and at option-list size it
|
||||
pushed the bar that sends the request off the bottom of the screen. */}
|
||||
<MChipRow
|
||||
label="Why"
|
||||
value={reason}
|
||||
onPick={(r) => { setReason(r); setErr(""); }}
|
||||
options={REQUEST_REASONS.map((r) => ({ value: r as string, label: r }))}
|
||||
/>
|
||||
</NumberedField>
|
||||
)}
|
||||
|
||||
{/* What they hold and what they are measured against, in one line, while they are still
|
||||
choosing — not inside the decline. Same sum the manager's review screen uses. */}
|
||||
<div style={{
|
||||
margin: "0 16px", background: "#fff", padding: "14px 16px",
|
||||
borderLeft: `6px solid ${allowance.over ? ACCENT : INK}`,
|
||||
fontSize: 14, lineHeight: 1.5, color: N700,
|
||||
}}>
|
||||
{holding.total === 0 ? "Nothing on your record yet" : `${holding.total} garment${holding.total === 1 ? "" : "s"} on your record`}
|
||||
{" · "}{allowance.label}
|
||||
</div>
|
||||
|
||||
{current && !full && (
|
||||
<div style={{ padding: "16px 16px 0" }}>
|
||||
<SecondaryBar label="Add another garment" onClick={addAnother} />
|
||||
</div>
|
||||
)}
|
||||
{full && (
|
||||
<p style={{ fontSize: 13, color: N600, lineHeight: 1.55, margin: "12px 16px 0" }}>
|
||||
That is {maxLines} garments — as much as one request carries.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{!managerName && (
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, padding: "16px 16px 0", margin: 0 }}>
|
||||
Nobody is recorded as your approver yet, so this can’t be sent. Ask the linen room
|
||||
to set your manager on your staff record.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ height: 12 }} />
|
||||
</MBody>
|
||||
{/* What the bar is still waiting for. A single "pick an item" was what the mockup drew on an
|
||||
empty screen, but it stayed there with a garment and a size already chosen and only the
|
||||
reason outstanding — telling somebody to do the one thing they have just done. */}
|
||||
<MBar
|
||||
label={busy ? "Sending…" : managerName ? `Send to ${managerName}` : "Send for approval"}
|
||||
small={ready ? `${garments} garment${garments === 1 ? "" : "s"}` : managerName ? missing.short : "no approver"}
|
||||
disabled={!ready || busy}
|
||||
offReason={managerName ? missing.full : "Nobody is recorded as your approver yet"}
|
||||
onClick={async () => {
|
||||
if (!ready) return;
|
||||
const r = await mutate<{ id: string; code: string; manager: string; notified: boolean }>("request.create", {
|
||||
lines: all.map((l) => ({ itemId: l.itemId, si: l.si, qty: l.qty })),
|
||||
reason: reason || "",
|
||||
// The one thing the swap route has always told the linen room, kept now that the free
|
||||
// note has gone: this is an exchange, not another garment on top.
|
||||
note: swap ? "Swapping a size." : "",
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
toast("Sent");
|
||||
setSent({
|
||||
id: r.result.id, code: r.result.code, manager: r.result.manager || managerName,
|
||||
notified: r.result.notified, lines: all.map(lineOf),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user