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 a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 22:54:09 +10:00
commit a6f1059ddf
424 changed files with 53535 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Empty, Field } from "@/components/ui";
import type { StaffRec } from "@/lib/compute";
import { fullName, type Act } from "./shared";
/* Who this person approves for: the Manager arrow drawn from the manager's end. Adding somebody here
* rewrites THEIR record, so the confirm card names that person and the manager they leave first. */
export default function ReportsBox({ subject, reports, people, isAdmin, act }: { subject: StaffRec; reports: StaffRec[]; people: StaffRec[]; isAdmin: boolean; act: Act }) {
const [q, setQ] = useState("");
const [pick, setPick] = useState<StaffRec | null>(null);
const needle = q.trim().toLowerCase();
const matches = useMemo(() => {
if (!needle) return [];
return people.filter((x) => !x.inactive && x.id !== subject.id && x.managerId !== subject.id
&& (`${x.first} ${x.last}`.toLowerCase().includes(needle) || x.num.toLowerCase().includes(needle))).slice(0, 8);
}, [people, subject.id, needle]);
const from = pick ? people.find((x) => x.id === pick.managerId) : undefined;
return (
<div>
{reports.length === 0
? <div className="tc-people-pad"><Empty pad={1}>Nobody reports to {subject.first || "them"} yet.</Empty></div>
: (
<div className="tc-people-list">
{reports.map((x) => (
<div key={x.id} className="tc-people-item">
<Link href={`/app/staff/${x.id}`} className="link-name" style={{ fontWeight: 600 }}>{fullName(x)}</Link>{x.id === subject.id ? " (themselves)" : ""}
<span className="grow tc-meta-line">{[x.num, x.dept].filter(Boolean).join(" · ") || ""}</span>
{isAdmin && (
<button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Take ${fullName(x)} off ${subject.first}s list, they will have no approver`}
onClick={() => { if (confirm(`Take ${fullName(x)} off ${subject.first}'s list? They'll have no manager, so they can't raise requests until one is set.`)) act("staff.patch", { id: x.id, managerId: "" }); }}>
Remove from list
</button>
)}
</div>
))}
</div>
)}
{isAdmin && (
<div className="tc-people-pad" style={{ borderTop: reports.length ? "1px solid #cfcccb" : undefined }}>
{subject.inactive ? (
<div className="tc-meta-line">Reactivate {subject.first} to give them reports.</div>
) : pick ? (
<div className="tc-people-confirm" style={{ marginTop: 0 }}>
Send <b>{fullName(pick)}</b>s requests to {fullName(subject)}{from ? <> instead of {fullName(from)}</> : null}?
<div style={{ display: "flex", gap: 10, marginTop: 10, flexWrap: "wrap" }}>
<button type="button" className="btn btn-secondary" onClick={async () => { if (await act("staff.patch", { id: pick.id, managerId: subject.id })) { setPick(null); setQ(""); } }}>Change {pick.first}s record</button>
<button type="button" className="btn btn-ghost" onClick={() => setPick(null)}>Cancel</button>
</div>
</div>
) : (
<>
<Field label="Add somebody who reports to them">
{(c) => <input {...c} className="input" value={q} autoComplete="off" placeholder="Name or staff number" onChange={(e) => setQ(e.target.value)} />}
</Field>
{needle !== "" && (
<div role="group" aria-label="Matching staff" className="tc-people-pick">
{matches.map((x) => (
<button key={x.id} type="button" onClick={() => setPick(x)} aria-label={`Send ${fullName(x)}s requests to ${fullName(subject)}`}>
<b>{fullName(x)}</b>
<span style={{ color: "#57534f", marginLeft: 8 }}>{[x.num, x.dept].filter(Boolean).join(" · ")}</span>
</button>
))}
{matches.length === 0 && <div className="tc-meta-line">Nobody else on the register matches that.</div>}
</div>
)}
</>
)}
</div>
)}
</div>
);
}