cbf533d90b
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 440e645 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
102 lines
5.4 KiB
TypeScript
102 lines
5.4 KiB
TypeScript
"use client";
|
||
/* Size exchange — one movement, not a return followed by an issue. What comes back, what goes out,
|
||
and the staff record updated so nobody hands them the wrong size again next month. */
|
||
import { useCallback, useMemo, useState } from "react";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { useDerived, useSnap } from "@/lib/client";
|
||
import { isPantItem, isTopItem, key, label, onhand, staffName } from "@/lib/compute";
|
||
import MScan from "@/components/MScan";
|
||
import { GROUND, INK, MBar, MBody, MChips, MEmpty, MError, MRow, MRule, MSection, MTop } from "@/components/m";
|
||
import { useHeld, type Held } from "@/components/MPerson";
|
||
|
||
export default function MExchange() {
|
||
const { s, mutate, busy } = useSnap();
|
||
const { L, byId } = useDerived();
|
||
const router = useRouter();
|
||
const id = String(useParams().id || "");
|
||
const st = s.staff.find((x) => x.id === id);
|
||
const held = useHeld(s, id);
|
||
|
||
const [pick, setPick] = useState<Held | null>(null);
|
||
const [si, setSi] = useState(-1);
|
||
const [scan, setScan] = useState(false);
|
||
const [err, setErr] = useState("");
|
||
|
||
const it = pick ? byId[pick.itemId] : undefined;
|
||
const stock = useMemo(() => {
|
||
if (!it) return [] as number[];
|
||
return it.sizes.map((_, i) => onhand(s, L, key(it.id, i)));
|
||
}, [it, s, L]);
|
||
|
||
const onCode = useCallback((raw: string) => {
|
||
const k = s.barcodes[raw.trim()];
|
||
const hit = held.find((h) => h.key === k);
|
||
if (!hit) { setErr(`${raw.trim()} isn’t something ${st?.first ?? "they"} is holding.`); return; }
|
||
setPick(hit); setSi(-1); setErr("");
|
||
}, [s.barcodes, held, st]);
|
||
|
||
if (!st) return (<><MTop title="Exchange" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
|
||
|
||
const commit = async () => {
|
||
if (!pick || si < 0) return;
|
||
const r = await mutate<{ size: string }>("issue.exchange", { id: pick.issues[0].id, si, qty: 1 });
|
||
if (!r.ok) { setErr(r.error); return; }
|
||
router.push(`/m/person/${st.id}`);
|
||
};
|
||
|
||
const willUpdate = it && (isTopItem(it) || isPantItem(it));
|
||
|
||
return (
|
||
<>
|
||
<MTop title="Exchange" back right={staffName(st)} />
|
||
<MRule />
|
||
<MError msg={err} onDismiss={() => setErr("")} />
|
||
<MBody>
|
||
{!pick ? (
|
||
<>
|
||
<MSection label="What doesn’t fit?" right={`${held.reduce((t, h) => t + h.qty, 0)} out`} />
|
||
{held.length === 0
|
||
? <MEmpty title="Nothing to exchange" sub={`${staffName(st)} has no garments out at the moment.`} />
|
||
: held.map((h) => <MRow key={h.key} onClick={() => { setPick(h); setSi(-1); }} mark="ink" title={h.name} sub={`${h.qty} held`} />)}
|
||
{/* Same rule as the return screen: the scan bar is off when nothing is out, so the line
|
||
offering a scan goes with it. */}
|
||
{held.length > 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-700)" }}>Or scan the garment they’ve brought back.</div>}
|
||
</>
|
||
) : (
|
||
<>
|
||
<section style={{ background: INK, color: GROUND, padding: 16 }}>
|
||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>Taking back</div>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 8 }}>
|
||
<span aria-hidden="true" style={{ width: 4, height: 34, background: "#fff" }} />
|
||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20, letterSpacing: "-0.02em" }}>{pick.name}</span>
|
||
</div>
|
||
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "#fff", fontSize: 13, fontWeight: 700, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
|
||
</section>
|
||
|
||
<MSection label="Giving out" right={it ? label(it) : ""} />
|
||
<div style={{ padding: 16 }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||
<span aria-hidden="true" style={{ width: 4, height: 34, background: "var(--color-accent)" }} />
|
||
<span style={{ fontSize: 14, color: "var(--color-neutral-700)" }}>Pick the size that fits. Greyed sizes are the one coming back, or have none on the shelf.</span>
|
||
</div>
|
||
{it && <MChips sizes={it.sizes.map(String)} value={si} onPick={(i) => setSi(i)} disabled={(i) => i === pick.si || stock[i] <= 0} />}
|
||
{si >= 0 && it && (
|
||
<p style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 14, lineHeight: 1.6 }}>
|
||
{stock[si]} on the shelf in size {it.sizes[si]}. The old garment goes back to stock in the same movement
|
||
{willUpdate ? `, and ${st.first}’s recorded size becomes ${it.sizes[si]}.` : "."}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</MBody>
|
||
|
||
{pick
|
||
? <MBar label={busy ? "Recording…" : si >= 0 && it ? `Exchange for size ${it.sizes[si]}` : "Pick a size"} glyph="check" onClick={commit} disabled={busy || si < 0} />
|
||
: <MBar label="Scan the garment" glyph="scan" onClick={() => setScan(true)} disabled={held.length === 0} />}
|
||
|
||
{scan && <MScan title="Scan the garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
|
||
</>
|
||
);
|
||
}
|