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 f976bd5 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 23:18:05 +10:00
commit c89010a4f1
424 changed files with 53588 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { QueueGroup, QueueRow } from "@/components/portal";
import { ErrorLine } from "@/components/ui";
import { openSlip } from "@/components/dialogs";
import { collectRows, collectionSlip } from "@/lib/today";
import { Lines } from "./Lines";
export default function CollectGroup() {
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const [err, setErr] = useState("");
const rows = useMemo(() => collectRows(s, byId, staffById), [s, byId, staffById]);
// Two people can work this list at once; a refusal has to be said, or the second click reads as
// the first one not having taken.
async function act(op: string, payload: unknown) { setErr(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); }
return (
<QueueGroup id="collect" icon="phone" title="Call to collect" count={rows.length}>
{err && <div style={{ padding: "0 16px 10px" }}><ErrorLine msg={err} /></div>}
{rows.map((r) => (
<QueueRow
key={r.p.id}
age={`${r.days}d`}
ageLabel="waiting"
urgent={r.late}
title={r.name}
titleMeta={r.phone ? (r.tel ? <a href={r.tel} style={{ color: "inherit" }}>{r.phone}</a> : r.phone) : undefined}
meta={<><Lines lines={r.lines} /> · {r.p.orderCode}{r.p.contacted ? " · contacted" : ""}</>}
actions={<>
{!r.p.contacted && <button type="button" className="btn btn-ghost" aria-label={`Mark ${r.name} called`} onClick={() => act("pickup.contacted", { id: r.p.id })}>Mark called</button>}
<button type="button" className="btn btn-ghost" aria-label={`Collection slip for ${r.name}`} onClick={() => openSlip("collection", collectionSlip(s, r.p, r.st))}>Slip</button>
<button type="button" className="btn btn-secondary" aria-label={`${r.name} picked up`} onClick={() => act("pickup.pickedUp", { id: r.p.id })}>Picked up</button>
</>}
/>
))}
</QueueGroup>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { QueueGroup, QueueRow } from "@/components/portal";
import { countRows } from "@/lib/today";
export default function CountsGroup() {
const { s } = useSnap();
const { byId } = useDerived();
const rows = useMemo(() => countRows(s, byId), [s, byId]);
return (
<QueueGroup id="counts" icon="doc" title="Counts due" count={rows.length}>
{rows.map((r) => (
<QueueRow
key={r.loc.id}
age={r.age}
ageLabel={r.ageLabel}
title={r.trail}
meta={<>{r.garments.length ? `${r.garments.join(", ")} · ` : ""}{r.last} · {r.sizes} line{r.sizes === 1 ? "" : "s"}</>}
actions={<Link href={r.href} className="btn btn-secondary" aria-label={`Start the count of ${r.trail}`}>Start the count</Link>}
/>
))}
</QueueGroup>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { Fragment } from "react";
/** "Softshell Jacket · L ×1, EN Scrub Top · L ×2" with the sizes in mono. */
export function Lines({ lines }: { lines: { garment: string; size: string; qty: number }[] }) {
return (
<>
{lines.map((l, i) => (
<Fragment key={i}>
{i > 0 && ", "}
{l.garment} · <span className="tc-mono">{l.size}</span> ×{l.qty}
</Fragment>
))}
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Panel } from "@/components/portal";
import { monthEndView } from "@/lib/today";
const ACCENT = "var(--color-accent-700)";
type Box = "open" | "flag" | "done" | "wait";
function Row({ box, first, children, state }: { box: Box; first?: boolean; children: React.ReactNode; state: React.ReactNode }) {
const border = box === "flag" ? "#ec3013" : box === "wait" ? "#b5b1af" : "#201e1d";
return (
<li style={{ display: "flex", alignItems: "center", gap: 14, padding: "11px 16px", borderTop: first ? 0 : "1px solid #cfcccb" }}>
<span aria-hidden="true" style={{ width: 16, height: 16, flex: "none", boxSizing: "border-box", border: `2px solid ${border}`, background: box === "done" ? "#201e1d" : "transparent" }} />
<span style={{ flex: 1, minWidth: 0, color: box === "wait" ? "#6c6764" : undefined }}>{children}</span>
<span className="tc-meta-line" style={{ flex: "none" }}>{state}</span>
</li>
);
}
export default function MonthEndPanel() {
const { s } = useSnap();
const { L, byId, staffById } = useDerived();
const m = useMemo(() => monthEndView(s, L, byId, staffById), [s, L, byId, staffById]);
return (
<Panel
id="month-end"
title={`Month-end · ${m.monthName}`}
aside={<span className="tc-mono">{m.daysLeft} day{m.daysLeft === 1 ? "" : "s"} left</span>}
foot={<Link href="/app/report" className="btn btn-ghost" style={{ marginLeft: "auto" }}>Month-end pack</Link>}
>
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
<Row first box={m.deliveriesOverdue > 0 ? "flag" : "done"}
state={m.deliveriesOverdue > 0 ? <span style={{ color: ACCENT }}>{m.deliveriesOverdue} overdue</span> : "done"}>
Deliveries booked in
</Row>
<Row box={m.stocktakeFiled ? "done" : "open"} state={m.stocktakeFiled ? m.stocktakeDate : "not yet"}>
{m.stocktakeFiled ? "Stock take filed" : <Link href="/app/stock?tab=count" style={{ color: "inherit" }}>Stock take filed</Link>}
</Row>
{m.unsignedReceipts > 0 && (
<Row box="open" state={<Link href="/app/staff?filter=unsigned" className="btn btn-ghost" style={{ minHeight: 0, padding: 0 }} aria-label={`Chase ${m.unsignedReceipts} unsigned receipts`}>Chase</Link>}>
{m.unsignedReceipts} receipt{m.unsignedReceipts === 1 ? "" : "s"} to sign
</Row>
)}
<Row box={m.journalReady ? "done" : m.stocktakeFiled ? "open" : "wait"}
state={m.journalReady ? "ready"
: m.unallocated > 0 ? <Link href="/app/report" style={{ color: ACCENT }}>{m.unallocated} unallocated</Link>
: "after the count"}>
Journal ready
</Row>
</ul>
</Panel>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { QueueGroup, QueueRow } from "@/components/portal";
import { ErrorLine } from "@/components/ui";
import { NewOrderDialog } from "@/components/dialogs";
import { useServerCounts } from "@/lib/portalcounts";
import { bagStock, isPickable, useRequestActions, useRequests, type RequestRow } from "@/components/requests/RequestList";
import { relativeDay } from "@/lib/today";
import { Lines } from "./Lines";
export default function PickGroup() {
const { s } = useSnap();
const { L, byId } = useDerived();
const server = useServerCounts();
const { data, error, loading, reload } = useRequests();
const { act, error: actErr } = useRequestActions(reload);
const [orderFor, setOrderFor] = useState<{ r: RequestRow; short: RequestRow["bag"] } | null>(null);
const rows = useMemo(() => {
if (!data) return [];
return data.requests
.filter(isPickable)
.sort((a, b) => ((a.decidedAt || a.createdAt) < (b.decidedAt || b.createdAt) ? -1 : 1))
.map((r) => ({ r, stock: bagStock(s, L, r) }));
}, [data, s, L]);
const count = data ? rows.length : server.pick;
return (
<>
<QueueGroup id="pick" icon="bag" title="Pick for approved requests" count={count}>
{actErr && <div style={{ padding: "0 16px 10px" }}><ErrorLine msg={actErr} /></div>}
{!data && error && !loading && (
<div className="tc-qrow">
<div className="tc-qrow-main tc-qrow-meta">Requests couldnt be loaded.</div>
<div className="tc-qrow-actions"><button type="button" className="btn btn-ghost" onClick={() => reload()}>Try again</button></div>
</div>
)}
{rows.map(({ r, stock }) => {
const lines = (r.bag.length ? r.bag : r.lines).map((l) => ({ garment: l.item, size: l.size, qty: l.qty }));
const when = relativeDay(r.decidedAt, s);
return (
<QueueRow
key={r.id}
age={r.code}
ageLabel={r.ward.slice(0, 10)}
title={<Link href={`/app/requests?open=${encodeURIComponent(r.id)}`} style={{ color: "inherit" }}>{r.staffName}</Link>}
meta={<>
<Lines lines={lines} />
{r.managerName ? ` · approved by ${r.managerName}` : " · approved"}{when ? ` ${when}` : ""}
{" · "}
{stock.inStock ? "in stock" : <span style={{ color: "var(--color-accent-700)", fontWeight: 600 }}>none on the shelf</span>}
</>}
actions={stock.inStock
? <button type="button" className="btn btn-secondary" aria-label={`Start picking ${r.code}`} onClick={() => act("request.pick", { id: r.id })}>Start picking</button>
: <button type="button" className="btn btn-ghost" aria-label={`Order in the garments for ${r.code}`} onClick={() => setOrderFor({ r, short: stock.shortLines })}>Order it in</button>}
/>
);
})}
</QueueGroup>
{orderFor && (
<NewOrderDialog
onClose={() => setOrderFor(null)}
initOrderFor="Staff Member"
initStaffId={orderFor.r.staffId}
initLines={orderFor.short.map((l) => ({ itemId: l.itemId, size: l.size, qty: l.qty }))}
initSupplier={orderFor.short[0] ? byId[orderFor.short[0].itemId]?.supplier : undefined}
/>
)}
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { QueueGroup, QueueRow } from "@/components/portal";
import { ReceiveDialog } from "@/components/dialogs";
import type { OrderRec } from "@/lib/compute";
import { receiveRows } from "@/lib/today";
export default function ReceiveGroup() {
const { s } = useSnap();
const { staffById } = useDerived();
const rows = useMemo(() => receiveRows(s, staffById), [s, staffById]);
const [receive, setReceive] = useState<OrderRec | null>(null);
return (
<>
<QueueGroup id="receive" icon="truck" title="Receive" count={rows.length}>
{rows.map((r) => (
<QueueRow
key={r.o.id}
age={r.age}
ageLabel={r.ageLabel}
urgent={r.overdue}
title={<>{r.o.supplier} · <Link href={`/app/orders/${r.o.id}`} style={{ color: "inherit" }}>{r.o.code}</Link></>}
meta={<>{r.lines} line{r.lines === 1 ? "" : "s"}{r.expected ? ` · expected ${r.expected}` : ""}{r.forName ? ` · for ${r.forName}` : ""}</>}
actions={<>
{r.chase && <a href={r.chase} className="btn btn-ghost" aria-label={`Chase ${r.o.supplier} about ${r.o.code}`}>Chase</a>}
<button type="button" className={"btn " + (r.overdue ? "btn-primary" : "btn-secondary")} aria-label={`Receive delivery for ${r.o.code}`} onClick={() => setReceive(r.o)}>Receive delivery</button>
</>}
/>
))}
</QueueGroup>
{receive && <ReceiveDialog order={receive} onClose={() => setReceive(null)} />}
</>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { QueueGroup, QueueRow } from "@/components/portal";
import { roundRows } from "@/lib/today";
export default function RoundGroup() {
const { s } = useSnap();
const { staffById } = useDerived();
const rows = useMemo(() => roundRows(s, staffById), [s, staffById]);
const bags = rows.reduce((t, r) => t + r.bags, 0);
return (
<QueueGroup id="round" icon="truck" title="Deliver on the round" count={bags}>
{rows.map((r) => (
<QueueRow
key={r.ward}
age={r.bags}
ageLabel={r.bags === 1 ? "bag" : "bags"}
title={r.ward}
meta={<>
{r.names.join(" · ")}{r.moreNames > 0 ? ` +${r.moreNames}` : ""}
{` · ${r.garments} garment${r.garments === 1 ? "" : "s"}`}
{r.desk ? " · signed for at the ward desk" : ""}
</>}
actions={<Link href={r.href} className="btn btn-secondary" aria-label={`Start the round for ${r.ward}`}>Start the round</Link>}
/>
))}
</QueueGroup>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Panel } from "@/components/portal";
import { atReorderVariants } from "@/lib/portalcounts";
import { runsOutRows } from "@/lib/today";
const MAX_ROWS = 6;
const ACCENT = "var(--color-accent-700)";
export default function RunsOutPanel() {
const { s, isAdmin } = useSnap();
const { L, byId } = useDerived();
const rows = useMemo(() => runsOutRows(s, L, byId), [s, L, byId]);
const atReorder = useMemo(() => atReorderVariants(s, L).length, [s, L]);
if (rows.length === 0) return null;
return (
<Panel
id="runs-out"
title="Runs out before a delivery"
aside={`${rows.length} line${rows.length === 1 ? "" : "s"}`}
foot={<>
<span className="tc-meta-line" style={{ marginRight: "auto" }}>{atReorder} line{atReorder === 1 ? "" : "s"} at reorder</span>
{isAdmin
? <Link href="/app/orders" className="btn btn-primary">Open the order list</Link>
: <Link href="/app/stock?filter=reorder" className="btn btn-secondary">Open stock</Link>}
</>}
>
<div style={{ overflowX: "auto" }}>
<table className="tc-table">
<thead>
<tr><th scope="col">Garment</th><th scope="col" className="num">On hand</th><th scope="col" className="num">Cover</th></tr>
</thead>
<tbody>
{rows.slice(0, MAX_ROWS).map((r) => (
<tr key={r.key}>
<td>
<Link href={`/app/stock/${r.itemId}`} style={{ fontWeight: 600, color: "inherit", textDecoration: "none" }}>{r.name}</Link>
<div className="tc-meta-line tc-mono">{r.size} · {r.out ? `reorder at ${r.ro}` : `lead ${r.leadDays} days`}</div>
</td>
<td className="num" style={r.oh <= 0 ? { color: ACCENT, fontWeight: 600 } : undefined}>{r.oh}</td>
<td className="num" style={r.out || r.short ? { color: ACCENT } : undefined}>
{r.out ? "out" : r.coverDays === null ? "—" : `${r.coverDays} day${r.coverDays === 1 ? "" : "s"}`}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Panel>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useSnap } from "@/lib/client";
import { Panel, QueueRow } from "@/components/portal";
import { useSetupSteps } from "@/components/Checklist";
/* The first-run checklist, as the first queue group until it is done or dismissed. Which steps are
done and when it shows come from Checklist's hook, so the rules live in one place. */
export default function SetupGroup() {
const { isAdmin } = useSnap();
const { steps, done, finished, visible, dismiss } = useSetupSteps();
const [welcome, setWelcome] = useState(false);
useEffect(() => { if (new URLSearchParams(window.location.search).get("welcome") === "1") setWelcome(true); }, []);
if (!visible) return null;
return (
<Panel
id="setup"
icon="doc"
title={welcome ? "Welcome to ThreadCount" : "Getting set up"}
count={steps.length - done}
foot={(isAdmin || welcome) ? <button type="button" className="btn btn-ghost" onClick={() => dismiss()}>{finished ? "Done" : "Dismiss"}</button> : undefined}
>
{steps.map((st) => (
<QueueRow
key={st.label}
age={<span aria-hidden="true">{st.done ? "✓" : "○"}</span>}
ageLabel={st.done ? "done" : "to do"}
title={<span style={st.done ? { textDecoration: "line-through", color: "var(--color-neutral-700)", fontWeight: 500 } : undefined}>{st.label}</span>}
meta={null}
actions={st.done ? undefined : <Link href={st.href} className="btn btn-secondary" aria-label={`${st.label}: ${st.cta}`}>{st.cta}</Link>}
/>
))}
</Panel>
);
}