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 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
/* Person record — who they are, what they're holding, what has happened, and the three things
|
||||
you can do about it. Issuing starts here: 1B, person first. */
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { fmtDate, itemMap, staffName, variantName } from "@/lib/compute";
|
||||
import { GROUND, INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
import { MPersonHead, useHeld } from "@/components/MPerson";
|
||||
|
||||
export default function MPersonPage() {
|
||||
const { s, isAdmin, mutate } = useSnap();
|
||||
const id = String(useParams().id || "");
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const held = useHeld(s, id);
|
||||
const byId = useMemo(() => itemMap(s), [s]);
|
||||
// Shown once, then gone: the code is a credential and is never in the snapshot.
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const origin = typeof window === "undefined" ? "threadcount.tech" : window.location.host;
|
||||
|
||||
const history = useMemo(() => {
|
||||
if (!st) return [];
|
||||
const out: { text: string; date: string }[] = [];
|
||||
for (const i of s.issues) {
|
||||
if (i.staffId !== id) continue;
|
||||
const it = byId[i.itemId];
|
||||
const size = String(it?.sizes[i.si] ?? i.si);
|
||||
out.push({ text: `Issued ${i.qty} × ${variantName(it, size)}`, date: i.date });
|
||||
if (i.returned) out.push({ text: `${i.returned.cond} — ${variantName(it, size)}`, date: i.returned.date });
|
||||
if (i.handedIn) out.push({ text: `Handed in — ${variantName(it, size)}`, date: i.handedIn });
|
||||
}
|
||||
return out.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)).slice(0, 25);
|
||||
}, [s, id, st, byId]);
|
||||
|
||||
if (!st) return (<><MTop title="Person" back /><MRule /><MBody><MEmpty title="No such staff member" sub="They may have been removed from the register." /></MBody></>);
|
||||
|
||||
const total = held.reduce((t, h) => t + h.qty, 0);
|
||||
/* Issue, Exchange and Return are docked at the foot of the window with nothing underneath them,
|
||||
so Android draws the gesture handle across their bottom edge. The inset goes inside the bar the
|
||||
way the shared MBar and MAction take it — same custom property, so an ancestor that zeroes it
|
||||
for a bar sitting mid-screen would zero this one too — and the accent still runs to the bottom
|
||||
of the glass while the words stay above the handle. Without it the lower third of "Issue" is
|
||||
untappable, and this is the row a counter hand hits all day. */
|
||||
const SAFE_BOTTOM = "var(--tcx-safe-bottom, env(safe-area-inset-bottom, 0px))";
|
||||
const foot: React.CSSProperties = { flex: 1, minHeight: `calc(64px + ${SAFE_BOTTOM})`, display: "flex", alignItems: "center", padding: `0 16px ${SAFE_BOTTOM}`, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", textDecoration: "none" };
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Person" back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MPersonHead s={s} st={st} />
|
||||
<MSection label="Holding now" right={`${total} item${total === 1 ? "" : "s"}`} />
|
||||
{held.length === 0
|
||||
? <div style={{ padding: "22px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing out at the moment.</div>
|
||||
: held.map((h) => <MRow key={h.key} title={h.name} right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums" }}>{h.qty}</span>} />)}
|
||||
|
||||
<MSection label="Their own record" />
|
||||
{code ? (
|
||||
<>
|
||||
<div style={{ padding: "16px" }}>
|
||||
<div style={{ fontFamily: "ui-monospace, Menlo, Consolas, monospace", fontSize: 26, fontWeight: 800, letterSpacing: "0.06em" }}>{code}</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: "var(--color-neutral-700)", margin: "8px 0 0" }}>
|
||||
Read this out or write it down now — it can't be shown again. They go to{" "}
|
||||
<b>{origin}/my</b>, choose “I have a code”, and set an email and password.
|
||||
</p>
|
||||
</div>
|
||||
<MBar label="Done" tone="ink" glyph="none" onClick={() => setCode(null)} />
|
||||
</>
|
||||
) : st.selfEmail ? (
|
||||
<div style={{ padding: "16px", fontSize: 14, lineHeight: 1.55, color: "var(--color-neutral-600)" }}>
|
||||
Signed up as {st.selfEmail} — they can look up their own record instead of coming to the counter.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: "16px" }}>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.55, color: "var(--color-neutral-600)", margin: 0 }}>
|
||||
{st.selfCode
|
||||
? "A code is out but hasn’t been used. Make a new one if they’ve lost it — the old one stops working."
|
||||
: "Give them a code and they can check what they hold on their own phone. Read-only."}
|
||||
</p>
|
||||
</div>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
{isAdmin && (
|
||||
<MBar label={busy ? "Generating…" : st.selfCode ? "New code" : "Generate a code"} tone="ink" glyph="none" disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true); setErr("");
|
||||
const r = await mutate<{ code: string }>("staff.selfCode", { id: st.id });
|
||||
setBusy(false);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setCode(r.result.code);
|
||||
}} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MSection label="History" />
|
||||
{history.length === 0
|
||||
? <div style={{ padding: "22px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing recorded for {staffName(st)} yet.</div>
|
||||
: history.map((h, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<span style={{ flex: 1, fontSize: 14.5 }}>{h.text}</span>
|
||||
<span style={{ fontSize: 13, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{fmtDate(h.date)}</span>
|
||||
</div>
|
||||
))}
|
||||
</MBody>
|
||||
<div style={{ display: "flex", flex: `0 0 calc(64px + ${SAFE_BOTTOM})`, borderTop: "2px solid " + INK }}>
|
||||
<Link href={`/m/issue/${st.id}`} style={{ ...foot, background: "var(--color-accent)", color: "#fff" }}>Issue</Link>
|
||||
<Link href={`/m/person/${st.id}/exchange`} style={{ ...foot, background: "var(--color-neutral-200)", color: INK, borderLeft: "1px solid " + GROUND }}>Exchange</Link>
|
||||
<Link href={`/m/person/${st.id}/return`} style={{ ...foot, background: "var(--color-neutral-200)", color: INK, borderLeft: "1px solid " + GROUND }}>Return</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
/* Return — scan the garment or pick it off what they're holding, say how many and what state
|
||||
they're in, confirm. The conditions are ThreadCount's real four: only "fit for use" puts a
|
||||
garment back on the shelf. */
|
||||
import { useCallback, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { fmtDate, staffName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
|
||||
import { useHeld, type Held } from "@/components/MPerson";
|
||||
|
||||
const CONDITIONS: [string, string, string][] = [
|
||||
["Returned - Good", "Fit for use — back to shelf", "Counts back into stock the moment it’s confirmed."],
|
||||
["Returned - Damaged", "Damaged — needs repair", "Stays off the shelf and stays charged to the cost centre."],
|
||||
["Written Off", "Condemn — beyond repair", "Written off. Nothing comes back to stock."],
|
||||
["Lost", "Lost", "Never came back. Stays charged."],
|
||||
];
|
||||
|
||||
export default function MReturn() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
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);
|
||||
// How many of that garment are actually on the counter. Three of a size can be out on one issue
|
||||
// line, and one pair coming back is one pair — crediting the whole line put two garments that
|
||||
// are still on a ward back onto the shelf.
|
||||
const [qty, setQty] = useState(1);
|
||||
const [cond, setCond] = useState("Returned - Good");
|
||||
const [scan, setScan] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const choose = useCallback((h: Held) => { setPick(h); setQty(h.qty); setErr(""); }, []);
|
||||
|
||||
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; }
|
||||
choose(hit);
|
||||
}, [s.barcodes, held, st, choose]);
|
||||
|
||||
if (!st) return (<><MTop title="Return" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
|
||||
|
||||
const confirm = async () => {
|
||||
if (!pick) return;
|
||||
// What they hold in this size can be spread over several issue lines, so returning four of
|
||||
// them is several movements. Oldest line first — the garment that has been out longest is the
|
||||
// one that came back — and the last line is split when it is only partly returned.
|
||||
const rows = [...pick.issues].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
let left = Math.min(qty, pick.qty);
|
||||
const asked = left;
|
||||
for (const i of rows) {
|
||||
if (left <= 0) break;
|
||||
const take = Math.min(i.qty, left);
|
||||
const r = await mutate("issue.return", { id: i.id, cond, qty: take });
|
||||
if (!r.ok) {
|
||||
// Some of them may already be back. Say so rather than leave the counter to guess, and
|
||||
// send them back to a fresh list rather than acting on what is now a stale row.
|
||||
setErr(asked - left > 0 ? `${asked - left} of ${asked} went back before this stopped — ${r.error}` : r.error);
|
||||
setPick(null);
|
||||
return;
|
||||
}
|
||||
left -= take;
|
||||
}
|
||||
router.push(`/m/person/${st.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Return" back right={staffName(st)} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
{pick ? (
|
||||
<>
|
||||
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em" }}>{pick.name}</div>
|
||||
<div style={{ fontSize: 14, color: "var(--color-neutral-600)", marginTop: 6 }}>
|
||||
Issued to {staffName(st)}, {fmtDate(pick.issues[0].date)}
|
||||
</div>
|
||||
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
|
||||
</div>
|
||||
|
||||
<MSection label="How many are coming back?" right={`${pick.qty} out`} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, padding: 16, borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<span style={{ flex: 1, fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.55 }}>
|
||||
{pick.qty === 1
|
||||
? "One is out, so this is it."
|
||||
: `${pick.qty} are out. Count what is on the counter — the rest stays on ${st.first}’s record.`}
|
||||
</span>
|
||||
<MStepper n={qty} onChange={setQty} min={1} max={pick.qty} />
|
||||
</div>
|
||||
|
||||
<MSection label="Condition" />
|
||||
<div style={{ padding: 16, display: "grid", gap: 8 }}>
|
||||
{CONDITIONS.map(([value, title, note]) => {
|
||||
const on = cond === value;
|
||||
return (
|
||||
<button key={value} onClick={() => setCond(value)} aria-pressed={on}
|
||||
style={{ textAlign: "left", padding: "16px 18px", minHeight: 64, border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, cursor: "pointer" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, letterSpacing: "-0.01em" }}>{title}</div>
|
||||
<div style={{ fontSize: 13, marginTop: 4, color: on ? "var(--color-neutral-400)" : "var(--color-neutral-600)" }}>{note}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MSection label="What is coming back?" right={`${held.reduce((t, h) => t + h.qty, 0)} out`} />
|
||||
{held.length === 0
|
||||
? <MEmpty title="Nothing to return" sub={`${staffName(st)} has no garments out at the moment.`} />
|
||||
: held.map((h) => <MRow key={h.key} onClick={() => choose(h)} mark="ink" title={h.name} sub={`${h.qty} held · issued ${fmtDate(h.issues[0].date)}`} />)}
|
||||
{/* A return has to match a record they hold, which is why the scan bar below is off when
|
||||
nothing is out — so don't invite a scan the bar then refuses. */}
|
||||
{held.length > 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-700)" }}>Or scan the garment.</div>}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
|
||||
{pick
|
||||
? <MBar label={busy ? "Recording…" : qty === 1 ? "Confirm return" : `Confirm return of ${qty}`} glyph="check" onClick={confirm} disabled={busy} />
|
||||
: <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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user