Files
ThreadCount 0910bc32c1 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).
2026-09-16 07:57:54 +10:00

96 lines
5.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/* Places & cost centres: a link to the locations editor (now in Stock) and the departments editor. */
import { useState } from "react";
import Link from "next/link";
import { useSnap } from "@/lib/client";
import { Field } from "@/components/ui";
import { Panel } from "@/components/portal";
import { csvOf, type DeptRec } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
import { Msg, SectionHead, useSaver } from "./common";
export default function PlacesSection() {
const { s, isAdmin, mutate } = useSnap();
const { msg, say, draft, setDraft, draftNow, forgetDraft, debounced, schedule } = useSaver();
const [nd, setNd] = useState({ name: "", cc: "" });
const nLocs = s.locations.filter((l) => !l.archived).length;
const deptStaff: Record<string, number> = {};
for (const st of s.staff) deptStaff[st.dept] = (deptStaff[st.dept] || 0) + 1;
/* The row's cost centre and name as typed, so one box saving the row never undoes the other. */
const deptCc = (d: DeptRec, from = draft) => from["dept:" + d.id] ?? d.cc;
const deptName = (d: DeptRec) => draft["deptname:" + d.id] ?? d.name;
function deptNameRefusal(d: DeptRec, name: string) {
if (!name) return `A ward needs a name — ${d.name} hasnt been changed.`;
const clash = s.depts.find((o) => o.id !== d.id && o.name.trim().toLowerCase() === name.toLowerCase());
return clash ? `${clash.name} is already on the list.` : "";
}
const deptSaveName = (d: DeptRec) => { const n = deptName(d).trim(); return deptNameRefusal(d, n) ? d.name : n; };
/* dept.save carries the old name forward, so staff and orders filed under it move with it. The
check waits for the typing to stop; half a name is not a refusal. */
function renameDept(d: DeptRec, v: string) {
const k = "deptname:" + d.id;
setDraft((x) => ({ ...x, [k]: v }));
schedule(k, async () => {
const name = v.trim();
const no = deptNameRefusal(d, name);
if (no) { forgetDraft(k, v); say("depts", no); return; }
if (name === d.name) return;
const r = await mutate("dept.save", { id: d.id, name, cc: deptCc(d, draftNow.current).trim() });
if (!r.ok) { forgetDraft(k, v); say("depts", r.error); return; }
say("depts", `Renamed to ${name}; its staff and orders moved with it.`);
}, 600);
}
// The name the register holds (not a rename still settling) and the cost centre as typed.
function exportDepts() {
downloadCsv(`threadcount-departments-${s.today}.csv`, csvOf(["dept", "cc", "staff"], s.depts.map((d) => [d.name, deptCc(d).trim(), deptStaff[d.name] || 0])));
}
return (
<>
<Panel title="Locations" aside={<><span className="tc-mono">{nLocs}</span> location{nLocs === 1 ? "" : "s"}</>}>
<div style={{ padding: "12px 16px", display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<span className="tc-meta-line">Rooms, shelves and bays are kept in Stock.</span>
<Link href="/app/stock?tab=locations" className="btn btn-secondary" style={{ marginLeft: "auto" }}>Open Stock Locations</Link>
</div>
</Panel>
<SectionHead meta="Wards with staff on them cant be removed."
right={<button className="btn btn-ghost" onClick={exportDepts} disabled={s.depts.length === 0}>Export CSV</button>}>
Departments &amp; cost centres
</SectionHead>
{s.depts.length > 0 && (
<div className="table-wrap" style={{ border: "2px solid var(--color-text)" }}>
<table className="tc-table" style={{ minWidth: 520 }}>
<thead><tr><th>Department / ward</th><th style={{ width: 180 }}>Cost centre</th><th className="num" style={{ width: 80 }}>Staff</th><th style={{ width: 44 }}><span className="sr-only">Remove</span></th></tr></thead>
<tbody>
{s.depts.map((d) => {
const n = deptStaff[d.name] || 0;
return (
<tr key={d.id}>
<td><input className="input" style={{ width: "100%", fontWeight: 600 }} aria-label={`Name of ${d.name}`} value={deptName(d)} onChange={(e) => renameDept(d, e.target.value)} disabled={!isAdmin} /></td>
<td><input className="input tc-mono" style={{ width: "100%" }} aria-label={`Cost centre for ${d.name}`} value={deptCc(d)} disabled={!isAdmin}
onChange={(e) => debounced("dept:" + d.id, e.target.value, "dept.save", { id: d.id, name: deptSaveName(d), cc: e.target.value.trim() }, "depts")} /></td>
<td className="num">{n}</td>
<td>{isAdmin && !n && <button className="btn btn-ghost btn-icon" title="Remove — no staff assigned" aria-label={`Remove ${d.name}`} onClick={async () => { const r = await mutate("dept.delete", { id: d.id }); say("depts", r.ok ? `${d.name} removed.` : r.error); }}>×</button>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{s.depts.length === 0 && <div className="tc-meta-line">No departments yet.</div>}
{isAdmin && (
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "flex-end" }}>
<Field label="New department / ward" style={{ width: 280, maxWidth: "100%" }}>{(c) => <input {...c} className="input" value={nd.name} onChange={(e) => setNd({ ...nd, name: e.target.value })} placeholder="e.g. Ward 5C" />}</Field>
<Field label="Cost centre" style={{ width: 180 }}>{(c) => <input {...c} className="input tc-mono" value={nd.cc} onChange={(e) => setNd({ ...nd, cc: e.target.value })} placeholder="e.g. RGH-5090" />}</Field>
<button className="btn btn-secondary" disabled={!nd.name.trim() || !nd.cc.trim()} onClick={async () => { const r = await mutate("dept.save", nd); say("depts", r.ok ? "Added." : r.error); if (r.ok) setNd({ name: "", cc: "" }); }}>Add</button>
</div>
)}
<Msg text={msg.depts} />
</>
);
}