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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 couldn’t 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user