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).
This commit is contained in:
ThreadCount
2026-09-16 07:57:54 +10:00
commit 0910bc32c1
457 changed files with 55952 additions and 0 deletions
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* A size exchange is Swap size on a Hand back line. */
export default async function ExchangeFor({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/* The person screen is the issue screen: who they are, how near the six sets they hold, and the
* Issue | Hand back | History segments. Each segment draws its own docked bar through setBar; a
* finished hand back swaps the whole screen for the Done screen in place. */
import { useCallback, useRef, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { approvalRemaining, capCheck, isNursing, staffName } from "@/lib/compute";
import { MBody, MEmpty, MError, MHead, MHeadRow, MMeterPair, MPill, MRule, MSeg, MTop } from "@/components/m";
import { useBasket } from "@/components/MBasket";
import { DoneScreen, type DoneProps } from "@/components/SignFlow";
import HandBackTab from "@/components/person/HandBackTab";
import IssueTab from "@/components/m/issue/IssueTab";
import HistoryTab from "@/components/m/issue/HistoryTab";
import { personMeta, plural } from "@/components/m/issue/meta";
type Tab = "issue" | "back" | "history";
const TABS: { key: Tab; label: string }[] = [
{ key: "issue", label: "Issue" },
{ key: "back", label: "Hand back" },
{ key: "history", label: "History" },
];
const asTab = (v: string | null): Tab => (v === "back" || v === "history" ? v : "issue");
export default function MPersonPage() {
const { s } = useSnap();
const id = String(useParams().id || "");
const sp = useSearchParams();
const basket = useBasket();
const st = s.staff.find((x) => x.id === id);
const [tab, setTab] = useState<Tab>(() => asTab(sp.get("tab")));
const [bar, setBarNode] = useState<React.ReactNode>(null);
const [err, setErr] = useState("");
const [done, setDone] = useState<DoneProps | null>(null);
const top = useRef<HTMLSpanElement | null>(null);
const setBar = useCallback((b: React.ReactNode) => setBarNode(b), []);
const onError = useCallback((m: string) => setErr(m), []);
const onDone = useCallback((d: DoneProps) => { setErr(""); setDone(d); }, []);
if (done) return <DoneScreen {...done} />;
if (!st) {
return (
<>
<MTop title="Person" back />
<MRule />
<MBody pad><MEmpty title="No such staff member" /></MBody>
</>
);
}
const pickTab = (k: Tab) => {
if (k === tab) return;
setErr("");
setTab(k);
try { window.history.replaceState(null, "", `/m/person/${encodeURIComponent(id)}${k === "issue" ? "" : `?tab=${k}`}`); } catch { /* not fatal */ }
const body = top.current?.parentElement;
if (body) body.scrollTop = 0;
};
const lines = basket.issue(id);
const cap = capCheck(s, st, lines);
const left = approvalRemaining(s, st.id);
const props = { staffId: st.id, setBar, onDone, onError };
return (
<>
<MTop title={staffName(st)} back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<span ref={top} aria-hidden="true" style={{ display: "block", height: 0 }} />
<MHead name={staffName(st)} meta={personMeta(s, st)}>
{tab === "issue" && (
<>
<MMeterPair items={[
{ label: "Tops held", held: cap.tops, adding: cap.addTops, cap: cap.cap },
{ label: "Pants held", held: cap.pants, adding: cap.addPants, cap: cap.cap },
]} />
{isNursing(s, st) && <MHeadRow label="Manager approval" value={`${plural(left, "set")} left`} />}
</>
)}
</MHead>
{st.inactive && <div style={{ margin: "-4px 0 10px" }}><MPill tone="accent">Inactive</MPill></div>}
<MSeg label="Record" value={tab} options={TABS} onPick={pickTab} />
{tab === "issue" ? <IssueTab {...props} /> : tab === "back" ? <HandBackTab {...props} /> : <HistoryTab {...props} />}
</MBody>
{bar}
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* Returns are the Hand back segment of the person's record. */
export default async function ReturnFrom({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
/* Sign (issue): what is being handed over, the manager approval deduction, their signature and the
* slip switch. issue.create records the lines, the per-line reasons, the approval deduction and the
* signed slip in one locked write; SignFlow then shows the Done screen in place. */
import { useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { approvalRemaining, isNursing, isPantItem, isTopItem, issueLineFlags, label, staffName } from "@/lib/compute";
import { MBody, MButton, MEmpty, MHead, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
import { useBasket, type IssueLine } from "@/components/MBasket";
import SignFlow from "@/components/SignFlow";
import { personMeta, plural } from "@/components/m/issue/meta";
export default function SignIssue() {
const { s, mutate } = useSnap();
const { byId } = useDerived();
const id = String(useParams().id || "");
const basket = useBasket();
const st = s.staff.find((x) => x.id === id);
// Once issued the basket is cleared; the lines are kept here so the screen does not fall back to
// "nothing to issue" in the moment before the Done screen replaces it.
const [frozen, setFrozen] = useState<IssueLine[] | null>(null);
const lines = frozen ?? basket.issue(id);
if (!st || !lines.length) {
return (
<>
<MTop title="Sign" back />
<MRule />
<MBody pad>
<MEmpty title={st ? "Nothing to issue" : "No such staff member"} />
{st && <MButton label="Back to their record" href={`/m/person/${encodeURIComponent(st.id)}`} />}
</MBody>
</>
);
}
const n = lines.reduce((t, l) => t + l.qty, 0);
const tops = lines.reduce((t, l) => t + (isTopItem(byId[l.itemId]) ? l.qty : 0), 0);
const pants = lines.reduce((t, l) => t + (isPantItem(byId[l.itemId]) ? l.qty : 0), 0);
const sets = Math.max(tops, pants);
const left = approvalRemaining(s, st.id);
const approval = isNursing(s, st) && left > 0;
const most = Math.min(left, sets);
const chosen = basket.deduct(id);
const deduct = Math.max(0, Math.min(most, chosen ?? most));
const extra = approval ? (
<>
<MSection label="Manager approval" right={`${plural(left, "set")} left`} />
<MRow title="Take off the approval" sub={`This issue is ${plural(sets, "set")}`}
right={<MStepper label="sets off the approval" n={deduct} min={0} max={most} onChange={(v) => basket.setDeduct(id, v)} />} />
</>
) : undefined;
const commit = async ({ sigId, slip }: { sigId: string; slip: boolean }) => {
const flags = issueLineFlags(s, st, lines);
const r = await mutate<{ slipId: string | null }>("issue.create", {
staffId: st.id,
lines: lines.map((l, i) => ({ itemId: l.itemId, si: l.si, qty: l.qty, src: "stock", reason: flags[i] ? l.reason || "" : "" })),
override: flags.some(Boolean),
lineReasons: true,
apDeduct: approval ? deduct : 0,
sigId,
slip,
});
if (!r.ok) return { ok: false as const, error: r.error };
const sent = lines;
setFrozen(sent);
basket.clear("issue", st.id);
return {
ok: true as const,
done: {
head: `${plural(n, "item")} issued`,
sub: `${staffName(st)} · ${slip ? "slip sent to their staff app" : "signed"}`,
shelfKeys: sent.map((l) => l.key),
next: "scan" as const,
},
};
};
return (
<SignFlow
kind="issue"
head={<MHead name={staffName(st)} meta={personMeta(s, st)} />}
lines={lines.map((l) => ({ key: l.key, name: `${label(byId[l.itemId])} ${String(byId[l.itemId]?.sizes[l.si] ?? l.si)}`, qty: l.qty }))}
signerName={staffName(st)}
extra={extra}
slip={{ available: !!st.selfEmail }}
barLabel={`Issue ${plural(n, "item")}`}
commit={commit}
/>
);
}