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 2d04e45 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { ErrorLine, Field, LiveRegion } from "@/components/ui";
|
||||
import { Panel, Tag } from "@/components/portal";
|
||||
import { FTE_OPTIONS, UNIFORM_STYLES, ccOf, entOf, fmtDate, isNursing, type StaffRec } from "@/lib/compute";
|
||||
import ManagerBox from "./ManagerBox";
|
||||
import ReportsBox from "./ReportsBox";
|
||||
import SelfService from "./SelfService";
|
||||
import { fullName, type Act } from "./shared";
|
||||
|
||||
export default function DetailsTab({ st, act, edit, base }: { st: StaffRec; act: Act; edit: boolean; base: string }) {
|
||||
const { s, isAdmin, mutate } = useSnap();
|
||||
const [offMsg, setOffMsg] = useState("");
|
||||
const reports = s.staff.filter((x) => x.managerId === st.id && !x.inactive)
|
||||
.sort((a, b) => `${a.last} ${a.first}`.localeCompare(`${b.last} ${b.first}`));
|
||||
const hasHistory = s.issues.some((i) => i.staffId === st.id) || s.orders.some((o) => o.staffId === st.id);
|
||||
|
||||
/* Taking somebody off the register also closes the requests still waiting on approval (staff.patch,
|
||||
same transaction). Nobody is emailed, so the count is said out loud here. */
|
||||
async function deactivate() {
|
||||
setOffMsg("");
|
||||
const r = await mutate<{ closedRequests?: number }>("staff.patch", { id: st.id, inactive: true });
|
||||
if (!r.ok) { setOffMsg(r.error); return; }
|
||||
const n = r.result?.closedRequests ?? 0;
|
||||
if (n) setOffMsg(`${n} ${n === 1 ? "request was" : "requests were"} waiting on approval and ${n === 1 ? "has" : "have"} been closed.`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tc-people-two">
|
||||
<div className="tc-people-stack">
|
||||
{edit ? <EditDetails st={st} base={base} /> : <ReadDetails st={st} act={act} isAdmin={isAdmin} base={base} />}
|
||||
<Panel title="Manager">
|
||||
<div className="tc-people-pad">
|
||||
<ManagerBox people={s.staff} subject={st} isAdmin={isAdmin} act={act} />
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
<div className="tc-people-stack">
|
||||
<Panel title={`Whose requests ${st.first || "they"} approves`} aside={reports.length ? `${reports.length} ${reports.length === 1 ? "person" : "people"}` : undefined}
|
||||
foot={st.dept ? <Link href={`/app/requests?ward=${encodeURIComponent(st.dept)}`} className="btn btn-ghost tc-people-ghost">Requests from {st.dept}</Link> : undefined}>
|
||||
<ReportsBox subject={st} reports={reports} people={s.staff} isAdmin={isAdmin} act={act} />
|
||||
</Panel>
|
||||
<Panel title="Staff app">
|
||||
<div className="tc-people-pad">
|
||||
{isAdmin ? (
|
||||
<label style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 13 }}>
|
||||
<input type="checkbox" checked={st.wardDesk} onChange={(e) => act("staff.patch", { id: st.id, wardDesk: e.target.checked })} style={{ width: 18, height: 18, flex: "none" }} />
|
||||
<span><b>On the ward desk</b>{st.dept ? ` · signs for ${st.dept} round bags` : " · no ward recorded"}</span>
|
||||
</label>
|
||||
) : (
|
||||
<div style={{ fontSize: 13 }}>{st.wardDesk ? <Tag>{st.dept ? `Ward desk · ${st.dept}` : "Ward desk"}</Tag> : "Not on a ward desk."}</div>
|
||||
)}
|
||||
<SelfService st={st} act={act} mutate={mutate} isAdmin={isAdmin} facility={s.settings.facility} tz={s.settings.timezone} />
|
||||
</div>
|
||||
</Panel>
|
||||
{isAdmin && (
|
||||
<Panel title="Register">
|
||||
<div className="tc-people-pad" style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{!st.inactive
|
||||
? <button type="button" className="btn btn-secondary" onClick={deactivate}>Deactivate</button>
|
||||
: <button type="button" className="btn btn-secondary" onClick={() => { setOffMsg(""); act("staff.patch", { id: st.id, inactive: false }); }}>Reactivate</button>}
|
||||
{!hasHistory && (
|
||||
<button type="button" className="btn btn-ghost" style={{ color: "var(--color-accent-700)" }}
|
||||
onClick={() => { if (confirm(`Delete ${fullName(st)} from the register?`)) act("staff.delete", { id: st.id }).then((ok) => { if (ok) window.location.href = "/app/staff"; }); }}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
<LiveRegion msg={offMsg} className="tc-people-pad" style={{ paddingTop: 0, fontSize: 13, fontWeight: 600 }} />
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadDetails({ st, act, isAdmin, base }: { st: StaffRec; act: Act; isAdmin: boolean; base: string }) {
|
||||
const { s } = useSnap();
|
||||
const of = entOf(s, st), limited = Number.isFinite(of);
|
||||
const fte = st.fte || "";
|
||||
const Row = ({ k, children }: { k: string; children: React.ReactNode }) => (
|
||||
<div className="tc-people-kv"><span className="k muted">{k}</span><span className="v">{children}</span></div>
|
||||
);
|
||||
return (
|
||||
<Panel title="Details" aside={isAdmin ? <Link href={`${base}?tab=details&edit=1`} scroll={false} className="btn btn-ghost tc-people-ghost">Edit details</Link> : undefined}>
|
||||
<div>
|
||||
<Row k="Phone">{st.phone ? <span className="tc-mono">{st.phone}</span> : "–"}</Row>
|
||||
<Row k="Ward">{st.dept || "–"}</Row>
|
||||
<Row k="Cost centre"><span className="tc-mono">{ccOf(s, st) || "–"}</span> <span className="tc-meta-line">{st.ccOverride ? "override" : "from ward"}</span></Row>
|
||||
<Row k="Sizes"><span className="tc-mono">{st.top || "–"} / {st.pants || "–"}</span></Row>
|
||||
<Row k="Uniform style">{st.uniformStyle || "Not set (every style)"}</Row>
|
||||
<Row k="Start date">{st.start ? fmtDate(st.start) : "–"}</Row>
|
||||
<Row k="Yearly report figure">{limited ? <><span className="tc-mono">{of}</span> garments</> : "Not measured"}</Row>
|
||||
<div className="tc-people-kv">
|
||||
<span className="k muted">Combined FTE</span>
|
||||
{isAdmin ? (
|
||||
<select className="input" aria-label="Combined FTE" value={fte} style={{ width: 150 }} onChange={(e) => act("staff.patch", { id: st.id, fte: e.target.value })}>
|
||||
<option value="">Not recorded</option>
|
||||
{fte && !FTE_OPTIONS.includes(fte) && <option value={fte}>{fte}</option>}
|
||||
{FTE_OPTIONS.map((v) => <option key={v}>{v}</option>)}
|
||||
</select>
|
||||
) : <span className="v tc-mono">{fte || "Not recorded"}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function EditDetails({ st, base }: { st: StaffRec; base: string }) {
|
||||
const { s, mutate } = useSnap();
|
||||
const router = useRouter();
|
||||
const [f, setF] = useState({ first: st.first, last: st.last, phone: st.phone, top: st.top, pants: st.pants, ent: st.ent === null ? "" : String(st.ent), group: st.group, dept: st.dept, ccOverride: st.ccOverride, start: st.start, uniformStyle: st.uniformStyle });
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ccCodes = [...new Set(s.depts.map((d) => d.cc).filter(Boolean))];
|
||||
const nursing = isNursing(s, { ...st, group: f.group });
|
||||
const close = () => router.replace(`${base}?tab=details`, { scroll: false });
|
||||
async function save() {
|
||||
if (!f.first.trim() || !f.last.trim()) return;
|
||||
setBusy(true); setErr("");
|
||||
const r = await mutate("staff.save", { id: st.id, num: st.num, ...f, ent: f.ent === "" ? null : parseInt(f.ent, 10) || 0, notes: st.notes });
|
||||
setBusy(false);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
close();
|
||||
}
|
||||
return (
|
||||
<Panel title="Details" aside="Editing" foot={<>
|
||||
<button type="button" className="btn btn-primary" onClick={save} disabled={busy || !f.first.trim() || !f.last.trim()}>Save</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={close}>Cancel</button>
|
||||
<span className="tc-meta-line" style={{ marginLeft: "auto" }}>Staff no. <span className="tc-mono">{st.num}</span> can't change</span>
|
||||
</>}>
|
||||
<div className="tc-people-pad">
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
{([["first", "First name"], ["last", "Last name"], ["phone", "Phone"], ["top", "Top size"], ["pants", "Pants size"]] as const).map(([k, lbl]) => (
|
||||
<Field key={k} label={lbl} error={(k === "first" || k === "last") && !f[k].trim() ? "A record needs a name." : undefined}>
|
||||
{(c) => <input {...c} className="input" value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })} />}
|
||||
</Field>
|
||||
))}
|
||||
<Field label="Uniform style" hint="Which cut the counter offers.">
|
||||
{(c) => (
|
||||
<select {...c} className="input" value={f.uniformStyle} onChange={(e) => setF({ ...f, uniformStyle: e.target.value })}>
|
||||
<option value="">Not set (every style)</option>
|
||||
{f.uniformStyle && !UNIFORM_STYLES.includes(f.uniformStyle) && <option value={f.uniformStyle}>{f.uniformStyle}</option>}
|
||||
{UNIFORM_STYLES.map((v) => <option key={v}>{v}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Yearly report figure (garments)" hint={nursing ? undefined : "Reports only; the counter ignores it."}>
|
||||
{(c) => nursing
|
||||
? <input {...c} className="input" value="Not measured on the FTE table" disabled />
|
||||
: <input {...c} className="input" inputMode="numeric" value={f.ent} placeholder={`Default ${s.settings.defaultEntitlement}`} onChange={(e) => setF({ ...f, ent: e.target.value.replace(/[^0-9]/g, "") })} />}
|
||||
</Field>
|
||||
<Field label="Staff group">
|
||||
{(c) => <select {...c} className="input" value={f.group} onChange={(e) => setF({ ...f, group: e.target.value })}>{!s.settings.staffGroups.includes(f.group) && <option value={f.group}>{f.group || "–"}</option>}{s.settings.staffGroups.map((g) => <option key={g}>{g}</option>)}</select>}
|
||||
</Field>
|
||||
<Field label="Ward">
|
||||
{(c) => <select {...c} className="input" value={f.dept} onChange={(e) => setF({ ...f, dept: e.target.value })}>{!s.depts.find((d) => d.name === f.dept) && <option value={f.dept}>{f.dept || "–"}</option>}{s.depts.map((d) => <option key={d.id} value={d.name}>{d.name}{d.cc ? ` (${d.cc})` : ""}</option>)}</select>}
|
||||
</Field>
|
||||
<Field label="Start date">{(c) => <input {...c} className="input" type="date" value={f.start} onChange={(e) => setF({ ...f, start: e.target.value })} />}</Field>
|
||||
<Field label="Cost centre override">
|
||||
{(c) => <select {...c} className="input" value={f.ccOverride} onChange={(e) => setF({ ...f, ccOverride: e.target.value })}><option value="">None (from ward)</option>{ccCodes.map((x) => <option key={x} value={x}>{x}</option>)}{f.ccOverride && !ccCodes.includes(f.ccOverride) && <option value={f.ccOverride}>{f.ccOverride}</option>}</select>}
|
||||
</Field>
|
||||
</div>
|
||||
<ErrorLine msg={err} />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { Empty, LiveRegion } from "@/components/ui";
|
||||
import { Panel, Tag } from "@/components/portal";
|
||||
import { HandInDialog, ReturnDialog, printCreditSlip, printHandInReceipt } from "@/components/dialogs";
|
||||
import { viewPhoto } from "@/lib/photo";
|
||||
import { statusText } from "@/lib/staffreq";
|
||||
import { approvalDeparture, facilityDate, fmtDate, issueCost, label, money, statusTag, type ApprovalRec, type IssueRec, type StaffRec } from "@/lib/compute";
|
||||
import type { RequestRow } from "@/components/requests/RequestList";
|
||||
import SignedToggle from "./SignedToggle";
|
||||
import type { RequestsHook } from "./RequestsTab";
|
||||
import type { Act } from "./shared";
|
||||
|
||||
/** A request whose approver is the person it is for, in the words of where it has got to. */
|
||||
const selfTag = (status: string) => (status === "awaiting" ? "Theirs to approve" : status === "declined" ? "Declined by themselves" : "Self-approved");
|
||||
|
||||
type FormRow = { kind: "approval"; on: string; a: ApprovalRec } | { kind: "request"; on: string; r: RequestRow };
|
||||
|
||||
export default function HistoryTab({ st, act, setErr, req }: { st: StaffRec; act: Act; setErr: (e: string) => void; req: RequestsHook }) {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const [ret, setRet] = useState<IssueRec | null>(null);
|
||||
const [handin, setHandin] = useState(false);
|
||||
const [hiMsg, setHiMsg] = useState("");
|
||||
const [alt, setAlt] = useState({ garment: "", desc: "" });
|
||||
|
||||
const issues = s.issues.filter((i) => i.staffId === st.id).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : b.createdAt.localeCompare(a.createdAt)));
|
||||
const handins = s.handins.filter((h) => h.staffId === st.id);
|
||||
const alterations = s.alterations.filter((a) => a.staffId === st.id);
|
||||
const orders = s.orders.filter((o) => o.staffId === st.id);
|
||||
const approvals = s.approvals.filter((a) => a.staffId === st.id).reverse().sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
||||
const openForm = (qs: string) => window.open(`/print/order-form?${qs}`, "_blank", "noopener");
|
||||
/* Approvals come off the snapshot; requests off /api/requests?staff=. Each row is labelled, because a
|
||||
signed approval and an unapproved ask weigh differently in an audit. */
|
||||
const forms: FormRow[] = [
|
||||
...approvals.map((a) => ({ kind: "approval" as const, on: a.date, a })),
|
||||
...(req.data?.requests ?? []).filter((r) => r.staffId === st.id).map((r) => ({ kind: "request" as const, on: facilityDate(r.createdAt, s.settings.timezone), r })),
|
||||
].sort((x, y) => (x.on < y.on ? 1 : x.on > y.on ? -1 : 0));
|
||||
|
||||
return (
|
||||
<div className="tc-people-stack">
|
||||
<Panel title="Issue history" aside={issues.length ? `${issues.length} ${issues.length === 1 ? "line" : "lines"}` : undefined}>
|
||||
{issues.length === 0 ? <div className="tc-people-pad"><Empty pad={1}>Nothing issued yet.</Empty></div> : (
|
||||
<div className="table-wrap">
|
||||
<table className="tc-table">
|
||||
<thead><tr><th>Date</th><th>Garment</th><th>Size</th><th className="num">Qty</th><th className="num">Value</th><th>Status</th><th>Signed</th><th><span className="sr-only">Action</span></th></tr></thead>
|
||||
<tbody>
|
||||
{issues.map((i) => {
|
||||
const it = byId[i.itemId];
|
||||
const size = it?.sizes[i.si] ?? "";
|
||||
return (
|
||||
<tr key={i.id}>
|
||||
<td className="tc-mono" style={{ fontSize: 12, whiteSpace: "nowrap" }}>{fmtDate(i.date)}</td>
|
||||
<td>{label(it)}</td>
|
||||
<td className="tc-mono">{size}</td>
|
||||
<td className="num">{i.qty}</td>
|
||||
<td className="num">{money(i.qty * issueCost(i, byId))}</td>
|
||||
<td>
|
||||
<span className="tc-people-tags">
|
||||
<Tag tone={i.returned ? "outline" : "quiet"}>{i.returned ? i.returned.cond.replace("Returned - ", "Returned – ") : i.preloved ? "Pre-loved" : i.direct ? "Collected" : "Issued"}</Tag>
|
||||
{i.handedIn && <Tag title={`Handed in ${fmtDate(i.handedIn)}`}>Handed in</Tag>}
|
||||
{i.override && <Tag tone="low">Override</Tag>}
|
||||
{i.returned?.photoId && <button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => viewPhoto(i.returned!.photoId!)}>Photo</button>}
|
||||
</span>
|
||||
</td>
|
||||
<td><SignedToggle issue={i} what={`${label(it)} ${size}`} act={act} /></td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
{!i.returned && !i.handedIn && <button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Return ${label(it)} ${size}`} onClick={() => setRet(i)}>Return</button>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="tc-people-two">
|
||||
<div className="tc-people-stack">
|
||||
<Panel title="Hand-ins" aside={<button type="button" className="btn btn-secondary" style={{ minHeight: 28, padding: "2px 10px" }} onClick={() => setHandin(true)}>Record hand-in</button>}>
|
||||
<LiveRegion msg={hiMsg} className="tc-people-pad" style={{ fontWeight: 600 }} />
|
||||
{handins.length === 0 ? <div className="tc-people-pad"><Empty pad={1}>No hand-ins on file.</Empty></div> : (
|
||||
<div className="tc-people-list">
|
||||
{handins.map((h) => {
|
||||
const good = h.lines.filter((l) => l.cond === "Good").reduce((t, l) => t + l.qty, 0);
|
||||
const rag = h.lines.filter((l) => l.cond === "Rag").reduce((t, l) => t + l.qty, 0);
|
||||
return (
|
||||
<div key={h.id} className="tc-people-item">
|
||||
<span className="d">{fmtDate(h.date)}</span>
|
||||
<span className="grow">{[good ? `${good} good` : "", rag ? `${rag} rag` : ""].filter(Boolean).join(" · ") || "–"} · received by {h.by}</span>
|
||||
{h.credit && <Tag tone="accent">Credited</Tag>}
|
||||
<button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => printHandInReceipt(s, st, h, byId)}>Receipt</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Alterations">
|
||||
<div className="tc-people-pad" style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<input className="input" style={{ flex: 1, minWidth: 150 }} aria-label="Garment to be altered" placeholder="Garment" value={alt.garment} onChange={(e) => setAlt({ ...alt, garment: e.target.value })} />
|
||||
<input className="input" style={{ flex: 1.4, minWidth: 180 }} aria-label="What the alteration is" placeholder="Alteration, e.g. hem 4cm" value={alt.desc} onChange={(e) => setAlt({ ...alt, desc: e.target.value })} />
|
||||
<button type="button" className="btn btn-secondary" disabled={!alt.garment.trim()} onClick={async () => { if (await act("alteration.add", { staffId: st.id, ...alt })) setAlt({ garment: "", desc: "" }); }}>Log alteration</button>
|
||||
</div>
|
||||
{alterations.length === 0 ? <div className="tc-people-pad" style={{ paddingTop: 0 }}><Empty pad={1}>No alterations recorded.</Empty></div> : (
|
||||
<div className="tc-people-list" style={{ borderTop: "1px solid #cfcccb" }}>
|
||||
{alterations.map((a) => (
|
||||
<div key={a.id} className="tc-people-item">
|
||||
<span className="d">{fmtDate(a.date)}</span>
|
||||
<span style={{ fontWeight: 600 }}>{a.garment}</span>
|
||||
<span className="grow">{a.desc || "–"}</span>
|
||||
<Tag tone={a.status === "Returned to staff" ? "quiet" : a.status === "At tailor" ? "accent" : "outline"}>{a.status}</Tag>
|
||||
{a.status !== "Returned to staff" && <button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => act("alteration.advance", { id: a.id })}>{a.status === "Requested" ? "Send to tailor" : "Mark returned"}</button>}
|
||||
<button type="button" className="btn btn-ghost btn-icon" title="Remove entry" aria-label={`Remove the alteration logged for ${a.garment}`} onClick={() => act("alteration.remove", { id: a.id })}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="tc-people-stack">
|
||||
<Panel title="Previous order forms">
|
||||
<LiveRegion msg={req.error ? "The requests raised for them couldn’t be loaded, so only approvals are listed." : undefined} tone="alert" className="tc-people-pad" style={{ fontWeight: 600, color: "var(--color-accent-700)" }} />
|
||||
{req.data?.moreRequests && <div className="tc-people-pad tc-meta-line">Only the latest {req.data.requestLimit} requests are listed.</div>}
|
||||
{!req.data && !req.error && <div className="tc-people-pad tc-meta-line">Loading requests…</div>}
|
||||
{forms.length === 0 && (req.data || req.error) && <div className="tc-people-pad"><Empty pad={1}>No order form printed or recorded yet.</Empty></div>}
|
||||
<div className="tc-people-list">
|
||||
{forms.map((x) => x.kind === "approval" ? (
|
||||
<ApprovalRow key={`a${x.a.id}`} a={x.a} st={st} today={s.today} isAdmin={isAdmin} act={act} openForm={openForm}
|
||||
credit={() => printCreditSlip(s, st, x.a)} />
|
||||
) : (
|
||||
<div key={`r${x.r.id}`} className="tc-people-item">
|
||||
<span className="d">{fmtDate(x.on)}</span>
|
||||
<Tag tone="quiet">Request</Tag>
|
||||
<span className="grow"><b className="tc-mono">{x.r.code}</b> · {x.r.garments} {x.r.garments === 1 ? "garment" : "garments"}{x.r.reason ? ` · ${x.r.reason}` : ""} · {statusText(x.r).label}</span>
|
||||
{x.r.managerId === st.id && <Tag tone="accent">{selfTag(x.r.status)}</Tag>}
|
||||
<button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Print the order form again for request ${x.r.code}`} onClick={() => openForm(`request=${encodeURIComponent(x.r.id)}`)}>Print the form</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title={`Orders for ${st.first || "them"}`}>
|
||||
{orders.length === 0 ? <div className="tc-people-pad"><Empty pad={1}>No orders placed for them.</Empty></div> : (
|
||||
<div className="tc-people-list">
|
||||
{orders.map((o) => (
|
||||
<div key={o.id} className="tc-people-item">
|
||||
<Link href={`/app/orders/${o.id}`} className="tc-mono" style={{ fontWeight: 600 }}>{o.code}</Link>
|
||||
<span className="d" style={{ minWidth: 0 }}>{fmtDate(o.date)}</span>
|
||||
<span className="grow">{o.lines.reduce((t, l) => t + l.qty, 0)} items · {o.supplier}</span>
|
||||
<span className={statusTag(o.status)}>{o.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
{ret && <ReturnDialog issue={ret} onClose={() => setRet(null)} />}
|
||||
{handin && <HandInDialog staff={st} onClose={() => setHandin(false)} onDone={(m) => { setErr(""); setHiMsg(m); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalRow({ a, st, today, isAdmin, act, openForm, credit }: {
|
||||
a: ApprovalRec; st: StaffRec; today: string; isAdmin: boolean; act: Act; openForm: (qs: string) => void; credit: () => void;
|
||||
}) {
|
||||
const rem = a.sets - a.used;
|
||||
const recorded = a.notes.trim();
|
||||
const over = recorded ? null : approvalDeparture(a);
|
||||
return (
|
||||
<div>
|
||||
<div className="tc-people-item">
|
||||
<span className="d">{fmtDate(a.date)}</span>
|
||||
<Tag>Approval</Tag>
|
||||
<span className="grow">{a.sets} {a.sets === 1 ? "set" : "sets"} approved by {a.by}{a.fte ? <> · FTE <span className="tc-mono">{a.fte}</span></> : ""}</span>
|
||||
{a.byStaffId === st.id && <Tag tone="accent">Self-approved</Tag>}
|
||||
{a.date > today && <Tag title="Dated after today, usually a mistyped year.">Dated ahead of today</Tag>}
|
||||
<Tag tone={rem > 0 ? "low" : "quiet"}>{rem > 0 ? `${rem} of ${a.sets} left` : "Fully collected"}</Tag>
|
||||
{a.photoId && <button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Open the signed form photographed on ${fmtDate(a.date)}`} onClick={() => viewPhoto(a.photoId!)}>Signed form</button>}
|
||||
<button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Print the approval recorded ${fmtDate(a.date)} as it was recorded`} onClick={() => openForm(`approval=${encodeURIComponent(a.id)}`)}>Print the form</button>
|
||||
<button type="button" className="btn btn-ghost tc-people-ghost" onClick={credit}>Credit slip</button>
|
||||
{isAdmin && <button type="button" className="btn btn-ghost btn-icon" title="Remove approval" aria-label={`Remove the ${fmtDate(a.date)} approval by ${a.by}`} onClick={() => { if (confirm("Remove this approval?")) act("approval.remove", { id: a.id }); }}>×</button>}
|
||||
</div>
|
||||
{(recorded || over) && <div className="tc-people-sub">{recorded || `${over} No reason was written down.`}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Field } from "@/components/ui";
|
||||
import { Tag } from "@/components/portal";
|
||||
import type { StaffRec } from "@/lib/compute";
|
||||
import { fullName, type Act } from "./shared";
|
||||
|
||||
/* The person's manager: approves their requests in the staff app and signs their paper form. One box,
|
||||
* found by search and saved on the pick. Anyone may be their own manager, marked Self-approved. */
|
||||
export default function ManagerBox({ people, subject, isAdmin, act }: { people: StaffRec[]; subject: StaffRec; isAdmin: boolean; act: Act }) {
|
||||
const [q, setQ] = useState("");
|
||||
const [changing, setChanging] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const results = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const needle = q.trim().toLowerCase();
|
||||
const mgr = subject.managerId ? people.find((x) => x.id === subject.managerId) : undefined;
|
||||
const matches = useMemo(() => {
|
||||
if (!needle) return [];
|
||||
return people.filter((x) => !x.inactive && (`${x.first} ${x.last}`.toLowerCase().includes(needle) || x.num.toLowerCase().includes(needle))).slice(0, 8);
|
||||
}, [people, needle]);
|
||||
|
||||
function onListKey(e: React.KeyboardEvent) {
|
||||
const fwd = e.key === "ArrowDown", back = e.key === "ArrowUp";
|
||||
if ((!fwd && !back) || matches.length === 0) return;
|
||||
e.preventDefault();
|
||||
const at = results.current.findIndex((el) => el === document.activeElement);
|
||||
const next = at < 0 ? 0 : at + (fwd ? 1 : -1);
|
||||
results.current[Math.max(0, Math.min(matches.length - 1, next))]?.focus();
|
||||
}
|
||||
|
||||
async function save(managerId: string) {
|
||||
setBusy(true);
|
||||
const ok = await act("staff.patch", { id: subject.id, managerId });
|
||||
setBusy(false);
|
||||
if (ok) { setQ(""); setChanging(false); }
|
||||
}
|
||||
|
||||
if (mgr && !changing) {
|
||||
const self = mgr.id === subject.id;
|
||||
const name = fullName(mgr);
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", fontSize: 14 }}>
|
||||
<b>{name}{self ? " (themselves)" : ""}</b>
|
||||
{mgr.num && <span className="tc-mono" style={{ fontSize: 12, color: "#57534f" }}>{mgr.num}</span>}
|
||||
{self && <Tag tone="accent">Self-approved</Tag>}
|
||||
{mgr.inactive && <Tag>Inactive</Tag>}
|
||||
{isAdmin && (
|
||||
<span style={{ marginLeft: "auto", display: "flex", gap: 14 }}>
|
||||
<button type="button" className="btn btn-ghost tc-people-ghost" disabled={busy} aria-label={`Change ${subject.first}’s manager, ${name} is set`} onClick={() => { setQ(""); setChanging(true); }}>Change</button>
|
||||
<button type="button" className="btn btn-ghost tc-people-ghost" disabled={busy} aria-label={`Remove ${name} as ${subject.first}’s manager`}
|
||||
onClick={() => { if (confirm(`Remove ${name} as ${subject.first}'s manager? ${subject.first} can't raise requests until one is set.`)) save(""); }}>Remove</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) return <div className="tc-meta-line" style={{ fontSize: 13 }}>None set.</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Field label="Manager" hint={changing ? undefined : "Approves requests and signs order forms."}>
|
||||
{(c) => (
|
||||
<input {...c} className="input" value={q} autoComplete="off" placeholder="Name or staff number" onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown" && matches.length) { e.preventDefault(); results.current[0]?.focus(); }
|
||||
else if (e.key === "Escape" && changing) { e.preventDefault(); setQ(""); setChanging(false); }
|
||||
}} />
|
||||
)}
|
||||
</Field>
|
||||
{changing && <button type="button" className="btn btn-ghost tc-people-ghost" style={{ marginTop: 8 }} onClick={() => { setQ(""); setChanging(false); }}>Cancel</button>}
|
||||
{needle !== "" && (
|
||||
<div role="group" aria-label="Matching staff" className="tc-people-pick" onKeyDown={onListKey}>
|
||||
{matches.map((x, i) => {
|
||||
const self = x.id === subject.id;
|
||||
return (
|
||||
<button key={x.id} type="button" ref={(el) => { results.current[i] = el; }} disabled={busy}
|
||||
aria-label={`Set ${x.first} ${x.last}${self ? " (themselves)" : ""}${x.num ? `, staff number ${x.num}` : ""} as ${subject.first}’s manager`}
|
||||
onClick={() => save(x.id)}>
|
||||
<b>{x.first} {x.last}</b>{self ? " (themselves)" : ""}
|
||||
<span style={{ color: "#57534f", marginLeft: 8 }}>{[x.num, x.dept].filter(Boolean).join(" · ")}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{matches.length === 0 && <div className="tc-meta-line">Nobody on the register matches that.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, ErrorLine } from "@/components/ui";
|
||||
import { Icon, Tabs } from "@/components/portal";
|
||||
import { StaffDialog } from "@/components/dialogs";
|
||||
import { requestCounts, useRequests } from "@/components/requests/RequestList";
|
||||
import type { StaffRec } from "@/lib/compute";
|
||||
import { PeopleStyles, fullName } from "./shared";
|
||||
import Summary from "./Summary";
|
||||
import UniformTab from "./UniformTab";
|
||||
import RequestsTab from "./RequestsTab";
|
||||
import HistoryTab from "./HistoryTab";
|
||||
import DetailsTab from "./DetailsTab";
|
||||
|
||||
const TAB_IDS = ["uniform", "requests", "history", "details"] as const;
|
||||
type TabId = (typeof TAB_IDS)[number];
|
||||
const asTab = (v: string | null): TabId => ((TAB_IDS as readonly string[]).includes(v || "") ? (v as TabId) : "uniform");
|
||||
|
||||
/** Head actions shared by the register and the record (ADMIN only). */
|
||||
function HeadActions({ onAdd }: { onAdd: () => void }) {
|
||||
const { isAdmin } = useSnap();
|
||||
if (!isAdmin) return null;
|
||||
return (
|
||||
<>
|
||||
<Link href="/app/settings?tab=data&import=staff" className="btn btn-onink">Import the register</Link>
|
||||
<button type="button" className="btn btn-primary" onClick={onAdd}>Add a person</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Crumb({ name }: { name: string }) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="tc-people-crumb">
|
||||
<Icon name="chevronLeft" size={16} />
|
||||
<Link href="/app/staff">People</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span aria-current="page">{name}</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StaffRecord() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { s } = useSnap();
|
||||
const [add, setAdd] = useState(false);
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
if (!st) {
|
||||
return (
|
||||
<section>
|
||||
<PeopleStyles />
|
||||
<PageHead title="People"><HeadActions onAdd={() => setAdd(true)} /></PageHead>
|
||||
<Crumb name="Not found" />
|
||||
<Empty>Nobody on the register has this record. <Link href="/app/staff">Back to People</Link></Empty>
|
||||
{add && <StaffDialog staff={null} onClose={() => setAdd(false)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
/* Keyed on the person, so walking from one record to another (through "Whose requests they
|
||||
approve") starts every draft, dialog and one-time code afresh instead of carrying it over. */
|
||||
return <RecordBody key={st.id} st={st} />;
|
||||
}
|
||||
|
||||
function RecordBody({ st }: { st: StaffRec }) {
|
||||
const { isAdmin, mutate } = useSnap();
|
||||
const sp = useSearchParams();
|
||||
const tab = asTab(sp.get("tab"));
|
||||
const edit = isAdmin && tab === "details" && sp.get("edit") === "1";
|
||||
const [add, setAdd] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const act = useCallback(async (op: string, payload: unknown) => {
|
||||
setErr("");
|
||||
const r = await mutate(op, payload);
|
||||
if (!r.ok) setErr(r.error);
|
||||
return r.ok;
|
||||
}, [mutate]);
|
||||
/* One fetch of this person's requests feeds the tab count, the Requests tab and the order-form history. */
|
||||
const req = useRequests({ staffId: st.id });
|
||||
const counts = req.data ? requestCounts(req.data, { staffId: st.id }) : null;
|
||||
const base = `/app/staff/${st.id}`;
|
||||
const hrefFor = (t: string) => (t === "uniform" ? base : `${base}?tab=${t}`);
|
||||
const tabs = [
|
||||
{ id: "uniform", label: "Uniform" },
|
||||
{ id: "requests", label: "Requests", count: counts ? counts.open : undefined },
|
||||
{ id: "history", label: "History" },
|
||||
{ id: "details", label: "Details & access" },
|
||||
];
|
||||
const label = tabs.find((t) => t.id === tab)!.label;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PeopleStyles />
|
||||
<PageHead title="People"><HeadActions onAdd={() => setAdd(true)} /></PageHead>
|
||||
<Crumb name={fullName(st)} />
|
||||
<Summary st={st} editHref={`${base}?tab=details&edit=1`} />
|
||||
<div className="tc-people-tabs">
|
||||
<Tabs label="Staff record" tabs={tabs} value={tab} hrefFor={hrefFor} />
|
||||
</div>
|
||||
<ErrorLine msg={err} />
|
||||
<div role="tabpanel" aria-label={label} style={{ marginTop: err ? 16 : 0 }}>
|
||||
{tab === "uniform" && <UniformTab st={st} act={act} setErr={setErr} />}
|
||||
{tab === "requests" && <RequestsTab st={st} req={req} />}
|
||||
{tab === "history" && <HistoryTab st={st} act={act} setErr={setErr} req={req} />}
|
||||
{tab === "details" && <DetailsTab st={st} act={act} edit={edit} base={base} />}
|
||||
</div>
|
||||
{add && <StaffDialog staff={null} onClose={() => setAdd(false)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { usePortalCounts } from "@/lib/portalcounts";
|
||||
import { PageHead, Empty } from "@/components/ui";
|
||||
import { Meter, Panel, Seg, SelectButton, Tag } from "@/components/portal";
|
||||
import { StaffDialog } from "@/components/dialogs";
|
||||
import { capState, ccFor, ccOf, csvOf, heldByStaff, isNursing, slipLive, staffName, type CapState, type GarmentCounts, type Snapshot, type StaffRec } from "@/lib/compute";
|
||||
import { downloadCsv } from "@/lib/print";
|
||||
import { PeopleStyles } from "./shared";
|
||||
|
||||
/* The register as this screen reads it: the snapshot, and the same staff indexed by id, so the
|
||||
* approver gap is a lookup rather than a scan of the register per row. */
|
||||
type Reg = { s: Snapshot; byId: Record<string, StaffRec> };
|
||||
|
||||
const approverOf = (r: Reg, st: StaffRec) => (st.managerId ? r.byId[st.managerId] : undefined);
|
||||
const NOTHING: GarmentCounts = { tops: 0, pants: 0, other: 0, sets: 0 };
|
||||
|
||||
/** AT LIMIT is a full half (six tops, or six pairs, or six outside a set); OVER is the counter's own answer. */
|
||||
const holdState = (c: CapState): "OVER" | "AT LIMIT" | "OK" =>
|
||||
c.over ? "OVER" : c.tops >= c.cap || c.pants >= c.cap || c.other >= c.otherCap ? "AT LIMIT" : "OK";
|
||||
|
||||
/** The tooltip behind the status tag: capState's own words inside the ceiling, what is true of the locker past it. */
|
||||
const holdWhy = (c: CapState) => {
|
||||
if (!c.over) return c.note;
|
||||
const past = [
|
||||
...(c.overTops || c.overPants ? [`${c.tops} ${c.tops === 1 ? "top" : "tops"} and ${c.pants} ${c.pants === 1 ? "pair" : "pairs"}, past the ${c.cap}-set ceiling`] : []),
|
||||
...(c.overOther ? [`${c.other} ${c.other === 1 ? "garment" : "garments"} outside a set, past the ${c.otherCap} allowed`] : []),
|
||||
];
|
||||
return `Holding ${past.join("; and ")}.`;
|
||||
};
|
||||
|
||||
/** A code is outstanding and would still be accepted at activation (slipLive is the activation route's own test). */
|
||||
const liveSlip = (s: Snapshot, st: StaffRec) => !!st.selfCode && slipLive(st.selfCodeAt, s.today, s.tz);
|
||||
|
||||
/* What a record still needs before the product can do its job for the person on it. `required`
|
||||
* gaps stop something working; the staff app and uniform style are offers, never a backlog.
|
||||
* Inactive staff have no gaps. */
|
||||
type GapKey = "manager" | "fte" | "sizes" | "app" | "style";
|
||||
const GAPS: { key: GapKey; short: string; filter: string; why: string; required: boolean; done?: string; missing: (r: Reg, st: StaffRec) => boolean }[] = [
|
||||
{ key: "manager", short: "Approver", filter: "No approver", required: true, why: "No approver on the register, so they can't raise a request.", missing: (r, st) => { const mgr = approverOf(r, st); return !mgr || mgr.inactive; } },
|
||||
{ key: "fte", short: "FTE", filter: "No FTE", required: true, why: "No FTE, so no initial kit is proposed.", missing: (r, st) => isNursing(r.s, st) && !st.fte.trim() },
|
||||
{ key: "sizes", short: "Sizes", filter: "No sizes", required: true, why: "No top or pants size recorded.", missing: (_r, st) => !st.top.trim() || !st.pants.trim() },
|
||||
{ key: "app", short: "Staff app", filter: "No staff app", required: false, why: "Optional: no account, and no code that still works.", done: "Nobody is waiting on that: everyone has an account or has been offered one.", missing: (r, st) => !st.selfEmail && !liveSlip(r.s, st) },
|
||||
{ key: "style", short: "Uniform style", filter: "No uniform style", required: false, why: "Optional: no cut set, so every style is offered.", done: "Nobody is waiting on that: every record has a uniform style set.", missing: (_r, st) => !st.uniformStyle.trim() },
|
||||
];
|
||||
const REQUIRED = GAPS.filter((g) => g.required);
|
||||
|
||||
const SHOW = ["all", "any", "manager", "fte", "sizes", "app", "style", "unsigned", "over"] as const;
|
||||
type Show = (typeof SHOW)[number];
|
||||
const asShow = (v: string | null): Show => (SHOW as readonly string[]).includes(v || "") ? (v as Show) : "all";
|
||||
|
||||
export default function Register() {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const counts = usePortalCounts();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname() || "/app/staff";
|
||||
const sp = useSearchParams();
|
||||
const [add, setAdd] = useState(false);
|
||||
const [q, setQ] = useState(() => sp.get("q") || "");
|
||||
const [group, setGroup] = useState(() => sp.get("group") || "All");
|
||||
const [show, setShow] = useState<Show>(() => asShow(sp.get("filter")));
|
||||
const [inactive, setInactive] = useState<"hide" | "show">(() => (sp.get("inactive") === "1" ? "show" : "hide"));
|
||||
|
||||
/* The filters live in the address, so Today's "Chase" link and a copied URL open the same list. */
|
||||
const sync = useCallback((next: { group?: string; show?: Show; inactive?: "hide" | "show" }) => {
|
||||
const p = new URLSearchParams();
|
||||
const g = next.group ?? group, f = next.show ?? show, i = next.inactive ?? inactive;
|
||||
if (q.trim()) p.set("q", q.trim());
|
||||
if (g !== "All") p.set("group", g);
|
||||
if (f !== "all") p.set("filter", f);
|
||||
if (i === "show") p.set("inactive", "1");
|
||||
const qs = p.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
}, [group, show, inactive, q, pathname, router]);
|
||||
|
||||
const reg = useMemo<Reg>(() => { const byId: Record<string, StaffRec> = {}; for (const st of s.staff) byId[st.id] = st; return { s, byId }; }, [s]);
|
||||
const gapsOf = useCallback((st: StaffRec) => (st.inactive ? [] : GAPS.filter((g) => g.missing(reg, st))), [reg]);
|
||||
const held = useMemo(() => heldByStaff(s), [s]);
|
||||
const holdingOf = useCallback((st: StaffRec) => capState({ held: held[st.id] || NOTHING, capSets: s.settings.capSets }), [held, s.settings.capSets]);
|
||||
|
||||
/** People holding at least one garment whose receipt is not signed. */
|
||||
const unsigned = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const i of s.issues) if (!i.receipt && !i.returned && !i.handedIn) set.add(i.staffId);
|
||||
return set;
|
||||
}, [s]);
|
||||
|
||||
/* Every queue count is over the whole active register, so narrowing the search never shrinks the job. */
|
||||
const tally = useMemo(() => {
|
||||
const c = { any: 0, manager: 0, fte: 0, sizes: 0, app: 0, style: 0, unsigned: 0, over: 0 } as Record<Exclude<Show, "all">, number>;
|
||||
for (const st of reg.s.staff) {
|
||||
if (st.inactive) continue;
|
||||
let some = false;
|
||||
for (const g of GAPS) if (g.missing(reg, st)) { c[g.key]++; if (g.required) some = true; }
|
||||
if (some) c.any++;
|
||||
if (unsigned.has(st.id)) c.unsigned++;
|
||||
if (holdingOf(st).over) c.over++;
|
||||
}
|
||||
return c;
|
||||
}, [reg, unsigned, holdingOf]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const ql = q.trim().toLowerCase();
|
||||
const wanted = (st: StaffRec) => {
|
||||
if (show === "all") return true;
|
||||
if (st.inactive) return false;
|
||||
if (show === "any") return REQUIRED.some((g) => g.missing(reg, st));
|
||||
if (show === "unsigned") return unsigned.has(st.id);
|
||||
if (show === "over") return holdingOf(st).over;
|
||||
return GAPS.find((g) => g.key === show)!.missing(reg, st);
|
||||
};
|
||||
return reg.s.staff.filter((st) => (inactive === "show" || !st.inactive) && (group === "All" || st.group === group) && wanted(st)
|
||||
&& (!ql || `${st.first} ${st.last}`.toLowerCase().includes(ql) || st.num.toLowerCase().includes(ql) || st.dept.toLowerCase().includes(ql)));
|
||||
}, [reg, q, group, inactive, show, unsigned, holdingOf]);
|
||||
|
||||
const groups = ["All", ...new Set(s.staff.map((st) => st.group).filter(Boolean))];
|
||||
const nInactive = s.staff.filter((st) => st.inactive).length;
|
||||
const nActive = s.staff.length - nInactive;
|
||||
const nDesk = s.staff.filter((st) => !st.inactive && st.wardDesk).length;
|
||||
const total = inactive === "show" ? s.staff.length : nActive;
|
||||
const gapSel = GAPS.find((g) => g.key === show);
|
||||
|
||||
/* The file is the rows on screen. Headers are the staff import template's, so an edited register
|
||||
imports back in Settings > Data. Notes and start dates stay out: this file gets emailed to wards.
|
||||
Department cost centre is the ward's own code (the template's cc); Cost centre in use is what an
|
||||
issue is charged to. Manager number is read back by the importer; the approver name is reference only. */
|
||||
function exportCsv() {
|
||||
const cols = ["Staff no.", "First name", "Last name", "Phone", "Group", "Department", "Department cost centre", "Cost centre override", "Cost centre in use", "Top", "Pants", "FTE", "Uniform style", "Manager number", "Approver name (reference only)", "Ward desk", "Entitlement", "Sets held", "Tops held", "Pairs held", "Outside a set held", "Ceiling (sets)", "Holding status", "Register status", "Missing"];
|
||||
downloadCsv(`threadcount-staff-${s.today}.csv`, csvOf(cols, rows.map((st) => {
|
||||
const c = holdingOf(st);
|
||||
const mgr = approverOf(reg, st);
|
||||
return [st.num, st.first, st.last, st.phone, st.group, st.dept, ccFor(s, st.dept), st.ccOverride, ccOf(s, st), st.top, st.pants, st.fte, st.uniformStyle, mgr?.num ?? "", mgr?.inactive ? `${staffName(mgr)} — no longer on the register` : staffName(mgr), st.wardDesk ? "Yes" : "No", st.ent ?? "", c.sets, c.tops, c.pants, c.other, c.cap,
|
||||
// One value per header: holding status and register status are separate columns. They were
|
||||
// written as one ("Inactive" or the holding state), which shifted every later column left
|
||||
// and left Missing blank.
|
||||
holdState(c), st.inactive ? "Inactive" : "Active", gapsOf(st).filter((g) => g.required).map((g) => g.short).join(", ")];
|
||||
})));
|
||||
}
|
||||
|
||||
const showOptions = [
|
||||
{ value: "all", label: "Everyone" },
|
||||
{ value: "any", label: `Missing something (${tally.any})` },
|
||||
...REQUIRED.map((g) => ({ value: g.key, label: `${g.filter} (${tally[g.key]})` })),
|
||||
...GAPS.filter((g) => !g.required).map((g) => ({ value: g.key, label: `${g.filter} (${tally[g.key]})` })),
|
||||
{ value: "unsigned", label: `Receipts to sign (${tally.unsigned})` },
|
||||
{ value: "over", label: `Over the ceiling (${tally.over})` },
|
||||
];
|
||||
|
||||
const emptyText = s.staff.length === 0
|
||||
? "No staff on the register yet. Add a person, or import the register."
|
||||
: show === "all" ? "No staff match."
|
||||
: show === "unsigned" ? (tally.unsigned === 0 ? "Every receipt is signed." : "Nobody in this search has a receipt to sign.")
|
||||
: show === "over" ? (tally.over === 0 ? "Nobody is over the ceiling." : "Nobody in this search is over the ceiling.")
|
||||
: tally[show] === 0
|
||||
? show === "any" ? "Every record is finished." : gapSel && !gapSel.required ? gapSel.done ?? "Nobody is waiting on that." : "Nobody on the register is missing that. That list is done."
|
||||
: "Nobody in this search matches. Clear the search or the group to see the rest.";
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PeopleStyles />
|
||||
<PageHead title="People">
|
||||
{isAdmin && <Link href="/app/settings?tab=data&import=staff" className="btn btn-onink">Import the register</Link>}
|
||||
{isAdmin && <button type="button" className="btn btn-primary" onClick={() => setAdd(true)}>Add a person</button>}
|
||||
</PageHead>
|
||||
|
||||
<div className="tc-people-filters">
|
||||
<input className="input" type="search" style={{ width: 260 }} aria-label="Search the register by name, number or ward" placeholder="Name, number or ward" value={q}
|
||||
onChange={(e) => setQ(e.target.value)} onBlur={() => sync({})} onKeyDown={(e) => { if (e.key === "Enter") sync({}); }} />
|
||||
<SelectButton label="Group" value={group} anyValue="All" options={groups.map((g) => ({ value: g, label: g }))} onChange={(v) => { setGroup(v); sync({ group: v }); }} />
|
||||
<SelectButton label="Show" value={show} anyValue="all" options={showOptions} onChange={(v) => { const f = asShow(v); setShow(f); sync({ show: f }); }} />
|
||||
{nInactive > 0 && (
|
||||
<Seg label="Inactive" opts={["hide", "show"] as const} value={inactive} labels={{ hide: "Active", show: `Include inactive (${nInactive})` }}
|
||||
onChange={(v) => { setInactive(v); sync({ inactive: v }); }} />
|
||||
)}
|
||||
<div className="tc-people-right">
|
||||
<button type="button" className="btn btn-ghost" onClick={exportCsv} disabled={rows.length === 0}>Export CSV</button>
|
||||
<Link href="/app/requests" className="btn btn-secondary">Requests <Tag tone="ink" mono>{counts.people.attention}</Tag></Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="tc-people-metaline">
|
||||
<span className="tc-mono">{nActive}</span> on the register · <span className="tc-mono">{nDesk}</span> on a ward desk · <span className={tally.over > 0 ? "hot" : undefined}><span className="tc-mono">{tally.over}</span> over the ceiling</span> · <span className={tally.any > 0 ? "hot" : undefined}><span className="tc-mono">{tally.any}</span> records to finish</span>
|
||||
</p>
|
||||
|
||||
<Panel title={q.trim() || group !== "All" || show !== "all" ? "Matching staff" : "The register"} foot={<span className="tc-mono" style={{ fontSize: 12 }}>Showing {rows.length} of {total}</span>}>
|
||||
{rows.length > 0 && (
|
||||
<div className="table-wrap">
|
||||
<table className="tc-table tc-people-reg">
|
||||
<thead>
|
||||
<tr><th>Staff no.</th><th>Name</th><th>Group</th><th>Ward</th><th>Ward desk</th><th>Cost centre</th><th>Sizes</th><th>Holding</th><th>Status</th><th>Missing</th><th><span className="sr-only">Open</span></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((st) => {
|
||||
const c = holdingOf(st), state = holdState(c);
|
||||
const missing = gapsOf(st);
|
||||
const name = `${st.first} ${st.last}`;
|
||||
return (
|
||||
<tr key={st.id} className={st.inactive ? "inactive" : undefined}>
|
||||
<td className="dsk tc-mono">{st.num}</td>
|
||||
<td className="nm">
|
||||
<Link href={`/app/staff/${st.id}`} className="link-name">{name}</Link>
|
||||
{st.phone && <div className="tc-mono dsk-meta" style={{ fontSize: 11, color: "#57534f" }}>{st.phone}</div>}
|
||||
<div className="mob"><span className="tc-mono">{st.num}</span>{[st.group, st.dept].filter(Boolean).length ? ` · ${[st.group, st.dept].filter(Boolean).join(" · ")}` : ""}</div>
|
||||
</td>
|
||||
<td className="dsk wrapcap">{st.group}</td>
|
||||
<td className="dsk wrapcap">{st.dept}</td>
|
||||
<td className="dsk">{st.wardDesk ? <Tag>desk</Tag> : <span style={{ color: "var(--color-neutral-600)" }}>–</span>}</td>
|
||||
<td className="dsk tc-mono">{ccOf(s, st) || "–"}</td>
|
||||
<td className="dsk sz tc-mono">{st.top || "–"} / {st.pants || "–"}</td>
|
||||
<td className="hold">
|
||||
<div className="meters">
|
||||
<Meter label="Tops" value={c.tops} of={c.cap} size="sm" />
|
||||
<Meter label="Pants" value={c.pants} of={c.cap} size="sm" />
|
||||
{c.other > 0 && <Meter label="Other" value={c.other} of={c.otherCap} size="sm" />}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{st.inactive ? <Tag>Inactive</Tag> : <Tag tone={state === "OVER" ? "accent" : state === "AT LIMIT" ? "outline" : "quiet"} title={holdWhy(c)}>{state}</Tag>}
|
||||
</td>
|
||||
<td>
|
||||
{missing.length
|
||||
? <span className="tc-people-tags">{missing.map((g) => g.required
|
||||
? <Tag key={g.key} title={g.why}>{g.short}</Tag>
|
||||
: <span key={g.key} style={{ fontSize: 11, color: "var(--color-neutral-600)" }} title={g.why}>{g.short}</span>)}</span>
|
||||
: <span className="dsk-dash" style={{ color: "var(--color-neutral-600)" }}>–</span>}
|
||||
</td>
|
||||
<td className="open" style={{ textAlign: "right" }}>
|
||||
<Link href={`/app/staff/${st.id}`} className="btn btn-ghost tc-people-ghost" aria-label={`View ${name}`}>View ›</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{rows.length === 0 && <div style={{ padding: "0 16px" }}><Empty pad={4}>{emptyText}</Empty></div>}
|
||||
</Panel>
|
||||
{add && <StaffDialog staff={null} onClose={() => setAdd(false)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ErrorLine } from "@/components/ui";
|
||||
import { Seg } from "@/components/portal";
|
||||
import RequestList, { requestCounts, type RequestsPayload } from "@/components/requests/RequestList";
|
||||
import type { StaffRec } from "@/lib/compute";
|
||||
|
||||
export type RequestsHook = { data: RequestsPayload | null; error: string; loading: boolean; reload: () => Promise<void> };
|
||||
|
||||
export default function RequestsTab({ st, req }: { st: StaffRec; req: RequestsHook }) {
|
||||
const [which, setWhich] = useState<"open" | "all">("open");
|
||||
const counts = req.data ? requestCounts(req.data, { staffId: st.id }) : null;
|
||||
return (
|
||||
<div>
|
||||
<div className="tc-people-filters">
|
||||
<Seg label="Which requests" opts={["open", "all"] as const} value={which} onChange={setWhich}
|
||||
labels={{ open: "Open", all: "All" }} counts={counts ? { open: counts.open, all: counts.all } : undefined} />
|
||||
<div className="tc-people-right">
|
||||
<Link href={`/app/requests?staff=${encodeURIComponent(st.id)}`} className="btn btn-secondary">Open in the queue</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorLine msg={req.error} />
|
||||
<div className="tc-pp">
|
||||
<RequestList staffId={st.id} filter={which} hidePerson title={null} data={req.data} reload={req.reload} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { ErrorLine } from "@/components/ui";
|
||||
import { printAccessSlip } from "@/components/dialogs";
|
||||
import { SLIP_DAYS, daysBetween, facilityDate, facilityToday, slipLive, type StaffRec } from "@/lib/compute";
|
||||
import type { Act, Mutate } from "./shared";
|
||||
|
||||
/* Staff app activation. The code comes back once, in the op's response, and is never in the snapshot. */
|
||||
export default function SelfService({ st, act, mutate, isAdmin, facility, tz }: { st: StaffRec; act: Act; mutate: Mutate; isAdmin: boolean; facility: string; tz: string }) {
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const today = facilityToday(tz);
|
||||
const live = slipLive(st.selfCodeAt, today, tz);
|
||||
const printed = facilityDate(st.selfCodeAt ?? "", tz);
|
||||
const age = printed ? daysBetween(printed, today) : null;
|
||||
const left = age === null ? null : SLIP_DAYS - age;
|
||||
const when = age === 0 ? "today" : age === 1 ? "yesterday" : `${age} days ago`;
|
||||
|
||||
if (code) {
|
||||
return (
|
||||
<div className="tc-people-confirm">
|
||||
<div className="tc-lbl">Code for {st.first}</div>
|
||||
<div className="tc-people-code">{code}</div>
|
||||
<div className="tc-people-hint">Shown once. Print or copy it now.</div>
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 10, flexWrap: "wrap" }}>
|
||||
<button type="button" className="btn btn-primary" onClick={() => printAccessSlip({ settings: { facility } }, st, code)}>Print the slip</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigator.clipboard?.writeText(code).catch(() => {})}>Copy</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setCode(null)}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
{st.selfEmail
|
||||
? <>Signed up as <b>{st.selfEmail}</b>.</>
|
||||
: st.selfCode
|
||||
? age === null
|
||||
? <>Code outstanding but <b>won't work</b> (no print date).</>
|
||||
: live
|
||||
? <>Code printed {when}, unused; expires {left === 1 ? "tomorrow" : `in ${left} days`}.</>
|
||||
: <>Code printed {when} has <b>expired</b>.</>
|
||||
: <>No staff-app login yet.</>}
|
||||
</div>
|
||||
<ErrorLine msg={err} />
|
||||
{isAdmin && (
|
||||
<div style={{ display: "flex", gap: 14, marginTop: 10, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{!st.selfEmail && (
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={async () => {
|
||||
setBusy(true); setErr("");
|
||||
const r = await mutate<{ code: string }>("staff.selfCode", { id: st.id });
|
||||
setBusy(false);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setCode(r.result.code);
|
||||
}}>{busy ? "Generating…" : st.selfCode ? "New code" : "Generate a code"}</button>
|
||||
)}
|
||||
{st.selfCode && !st.selfEmail && <button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => act("staff.selfClear", { id: st.id })}>Cancel the code</button>}
|
||||
{st.selfEmail && (
|
||||
<button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => {
|
||||
if (confirm(`Remove ${st.first}'s access? They'll be signed out and need a new code to get back in.`)) act("staff.selfUnlink", { id: st.id });
|
||||
}}>Remove access</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import type { IssueRec } from "@/lib/compute";
|
||||
import type { Act } from "./shared";
|
||||
|
||||
/** The receipt tick for one issue line (issue.receipt). Issuers may use it. */
|
||||
export default function SignedToggle({ issue, what, act }: { issue: IssueRec; what: string; act: Act }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
return (
|
||||
<button type="button" className={"tc-people-signed" + (issue.receipt ? "" : " no")} aria-pressed={issue.receipt} disabled={busy}
|
||||
aria-label={`Receipt signed for ${what}`}
|
||||
onClick={async () => { setBusy(true); await act("issue.receipt", { id: issue.id, receipt: !issue.receipt }); setBusy(false); }}>
|
||||
{issue.receipt ? "signed" : "not signed"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { Meter, Tag } from "@/components/portal";
|
||||
import { allowanceRouteOf, capCheck, ccOf, fmtDate, type StaffRec } from "@/lib/compute";
|
||||
import { fullName, pl } from "./shared";
|
||||
|
||||
const dot = (parts: React.ReactNode[]) => parts.map((p, i) => <Fragment key={i}>{i > 0 && " · "}{p}</Fragment>);
|
||||
|
||||
export default function Summary({ st, editHref }: { st: StaffRec; editHref: string }) {
|
||||
const { s, isAdmin } = useSnap();
|
||||
/* The counter's own question, so the meters and the counter never quote two figures. */
|
||||
const held = capCheck(s, st);
|
||||
const owed = held.owed.tops + held.owed.pants + held.owed.other;
|
||||
const route = allowanceRouteOf(s, st);
|
||||
const cc = ccOf(s, st);
|
||||
|
||||
const details: React.ReactNode[] = [];
|
||||
if (st.group) details.push(st.group);
|
||||
if (st.dept) details.push(st.dept);
|
||||
if (cc) details.push(<span className="tc-mono">{cc}</span>);
|
||||
if (st.uniformStyle) details.push(`${st.uniformStyle.replace("'", "’")} cut`);
|
||||
if (st.top || st.pants) details.push(<><span className="tc-mono">{st.top || "–"}</span> / <span className="tc-mono">{st.pants || "–"}</span></>);
|
||||
if (route === "fte" && st.fte) details.push(<>FTE <span className="tc-mono">{st.fte}</span></>);
|
||||
if (st.start) details.push(`started ${fmtDate(st.start)}`);
|
||||
|
||||
const reports = s.staff.filter((x) => x.managerId === st.id && !x.inactive);
|
||||
const mgr = st.managerId ? s.staff.find((x) => x.id === st.managerId) : undefined;
|
||||
const people: React.ReactNode[] = [];
|
||||
if (reports.length) {
|
||||
people.push(<>Approves requests for {pl(reports.length, "person", "people")}{st.dept && <> on <Link href={`/app/requests?ward=${encodeURIComponent(st.dept)}`}>{st.dept}</Link></>}</>);
|
||||
}
|
||||
if (mgr) {
|
||||
people.push(<>Manager {fullName(mgr)}{mgr.id === st.id && <> <Tag tone="accent">Self-approved</Tag></>}{mgr.inactive && <> <Tag>Inactive</Tag></>}</>);
|
||||
}
|
||||
if (st.phone) people.push(<span className="tc-mono">{st.phone}</span>);
|
||||
|
||||
return (
|
||||
<div className="tc-pp tc-people-summary">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="tc-people-name">
|
||||
<p className="nm">{fullName(st)}</p>
|
||||
<span className="tc-mono" style={{ fontSize: 12, color: "#57534f" }}>{st.num}</span>
|
||||
{st.selfEmail && <Tag>Staff app</Tag>}
|
||||
{st.wardDesk && <Tag>{st.dept ? `Ward desk · ${st.dept}` : "Ward desk"}</Tag>}
|
||||
{st.inactive && <Tag tone="accent">Inactive</Tag>}
|
||||
</div>
|
||||
{details.length > 0 && <div className="tc-people-sumline">{dot(details)}</div>}
|
||||
{people.length > 0 && <div className="tc-people-sumline2"><span>{dot(people)}</span></div>}
|
||||
</div>
|
||||
<div className="tc-people-meters">
|
||||
<Meter label="Tops" value={held.tops} of={held.cap} />
|
||||
<Meter label="Pants" value={held.pants} of={held.cap} />
|
||||
{held.other > 0 && <Meter label="Other" value={held.other} of={held.otherCap} />}
|
||||
{owed > 0 && <div className="tc-meta-line">{owed} still on order or waiting</div>}
|
||||
</div>
|
||||
<div className="tc-people-actions">
|
||||
{!st.inactive && <Link href={`/app/counter?staff=${encodeURIComponent(st.id)}`} className="btn btn-primary">Open at the counter</Link>}
|
||||
{isAdmin && <Link href={editHref} scroll={false} className="btn btn-ghost tc-people-ghost">Edit details</Link>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { Empty, Field } from "@/components/ui";
|
||||
import { Panel, QueueRow, Tag } from "@/components/portal";
|
||||
import { PhotoButton, ReturnDialog } from "@/components/dialogs";
|
||||
import { viewPhoto } from "@/lib/photo";
|
||||
import { PICKUP_LATE_DAYS } from "@/lib/portalcounts";
|
||||
import { CASUAL_ALLOWED, FTE_CASUAL, allowanceRouteOf, approvalDeparture, capCheck, daysBetween, fmtDate, initialGarments, initialSets, initialUsed, label, openApprovals, setsForFte, type ApprovalRec, type IssueRec, type StaffRec } from "@/lib/compute";
|
||||
import SignedToggle from "./SignedToggle";
|
||||
import { dayMonth, fullName, objectPronoun, type Act } from "./shared";
|
||||
|
||||
const ROUTE_LABEL = { fte: "FTE table", kit: "Starting kit", approval: "Manager approval" } as const;
|
||||
|
||||
export default function UniformTab({ st, act, setErr }: { st: StaffRec; act: Act; setErr: (e: string) => void }) {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const [ret, setRet] = useState<IssueRec | null>(null);
|
||||
|
||||
const held = capCheck(s, st);
|
||||
const holding = s.issues.filter((i) => i.staffId === st.id && !i.returned && !i.handedIn)
|
||||
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : b.createdAt.localeCompare(a.createdAt)));
|
||||
const garments = holding.reduce((t, i) => t + i.qty, 0);
|
||||
const pickups = s.pickups.filter((p) => p.staffId === st.id && !p.pickedUp)
|
||||
.map((p) => ({ p, days: p.received ? daysBetween(p.received, s.today) : 0 }))
|
||||
.sort((a, b) => b.days - a.days);
|
||||
|
||||
return (
|
||||
<div className="tc-people-uniform">
|
||||
<div className="tc-people-stack">
|
||||
<Panel title="Holding" aside={`${garments} ${garments === 1 ? "garment" : "garments"} · ${held.sets} of ${held.cap} sets`}>
|
||||
{holding.length === 0 ? <div className="tc-people-pad"><Empty pad={1}>Holding nothing.</Empty></div> : (
|
||||
<div className="table-wrap">
|
||||
<table className="tc-table">
|
||||
<thead><tr><th>Garment</th><th>Size</th><th className="num">Qty</th><th>Issued</th><th>Source</th><th><span className="sr-only">Action</span></th></tr></thead>
|
||||
<tbody>
|
||||
{holding.map((i) => {
|
||||
const it = byId[i.itemId];
|
||||
const size = it?.sizes[i.si] ?? "";
|
||||
return (
|
||||
<tr key={i.id}>
|
||||
<td>{label(it)}</td>
|
||||
<td className="tc-mono">{size}</td>
|
||||
<td className="num">{i.qty}</td>
|
||||
<td className="tc-mono" style={{ fontSize: 12, color: "#57534f", whiteSpace: "nowrap" }}>{fmtDate(i.date)}</td>
|
||||
<td style={{ fontSize: 12, color: "#57534f" }}>
|
||||
<span className="tc-people-tags">
|
||||
<span>{i.preloved ? "Pre-loved" : i.direct ? "Collected" : "Shelf"} ·</span>
|
||||
<SignedToggle issue={i} what={`${label(it)} ${size}`} act={act} />
|
||||
{i.override && <Tag tone="low">Override</Tag>}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<button type="button" className="btn btn-ghost tc-people-ghost" aria-label={`Return ${label(it)} ${size}`} onClick={() => setRet(i)}>Return</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{pickups.length > 0 && (
|
||||
<Panel title={`Waiting for ${objectPronoun(st)}`} aside={`${pickups.length} at the counter`}>
|
||||
{pickups.map(({ p, days }) => (
|
||||
<QueueRow key={p.id} age={`${days}d`} ageLabel="waiting" urgent={days >= PICKUP_LATE_DAYS}
|
||||
title={p.lines.map((l, n) => <span key={n}>{n > 0 && ", "}{label(byId[l.itemId])} · <span className="tc-mono">{l.size}</span> ×{l.qty}</span>)}
|
||||
meta={<><Link href={`/app/orders/${p.orderId}`} className="tc-mono">{p.orderCode}</Link>{p.received ? ` · arrived ${dayMonth(p.received)}` : ""}{p.contacted ? " · called" : ""}</>}
|
||||
actions={<>
|
||||
{!p.contacted && <button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => act("pickup.contacted", { id: p.id, contacted: true })}>Mark called</button>}
|
||||
<button type="button" className="btn btn-secondary" onClick={() => act("pickup.pickedUp", { id: p.id })}>Picked up</button>
|
||||
</>} />
|
||||
))}
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tc-people-stack">
|
||||
<ApprovalPanel st={st} act={act} setErr={setErr} />
|
||||
<NotePanel st={st} isAdmin={isAdmin} setErr={setErr} />
|
||||
</div>
|
||||
{ret && <ReturnDialog issue={ret} onClose={() => setRet(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalPanel({ st, act, setErr }: { st: StaffRec; act: Act; setErr: (e: string) => void }) {
|
||||
const { s } = useSnap();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [ap, setAp] = useState({ sets: "", fte: "", date: s.today, note: "" });
|
||||
const [photo, setPhoto] = useState<string | null>(null);
|
||||
|
||||
const noGroup = !(st.group || "").trim();
|
||||
const route = allowanceRouteOf(s, st);
|
||||
const fte = st.fte || "";
|
||||
const casual = fte.trim().toLowerCase() === FTE_CASUAL.toLowerCase();
|
||||
const proposed = setsForFte(fte);
|
||||
const kitSets = initialSets(s, st), kitOf = initialGarments(s, st), kitUsed = initialUsed(s, st.id);
|
||||
|
||||
const all: ApprovalRec[] = s.approvals.filter((a) => a.staffId === st.id)
|
||||
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
||||
const openAps = openApprovals(s, st.id);
|
||||
const latestOpen = openAps[openAps.length - 1];
|
||||
const used = openAps.reduce((t, a) => t + a.used, 0);
|
||||
const left = openAps.reduce((t, a) => t + a.sets - a.used, 0);
|
||||
const formPhoto = (latestOpen ?? all[0])?.photoId ?? null;
|
||||
|
||||
/* The form is recorded as signed by the manager on the register: one box, one signer. */
|
||||
const mgr = st.managerId ? s.staff.find((x) => x.id === st.managerId) : undefined;
|
||||
const apMgr = mgr && !mgr.inactive ? mgr : undefined;
|
||||
const apBy = apMgr ? fullName(apMgr) : "";
|
||||
const apFte = ap.fte.trim() || fte;
|
||||
const apProposed = setsForFte(apFte);
|
||||
const apOver = approvalDeparture({ sets: parseInt(ap.sets, 10) || 0, fte: apFte, by: apBy || "the manager" });
|
||||
const apFuture = ap.date > s.today;
|
||||
const invalid = !apMgr || !(parseInt(ap.sets, 10) > 0) || apFuture;
|
||||
|
||||
async function record() {
|
||||
if (!apMgr) return;
|
||||
const ok = await act("approval.add", { staffId: st.id, by: apBy, byStaffId: apMgr.id, sets: parseInt(ap.sets, 10), fte: ap.fte, date: ap.date, notes: ap.note.trim(), photoId: photo });
|
||||
if (ok) { setAp({ sets: "", fte: "", date: s.today, note: "" }); setPhoto(null); setOpen(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="Approval" aside={noGroup ? "No staff group" : ROUTE_LABEL[route]}>
|
||||
<div>
|
||||
{route === "fte" && !noGroup && (
|
||||
<div className="tc-people-kv">
|
||||
<span className="k">Proposed by FTE {fte && <span className="tc-mono">{fte}</span>}</span>
|
||||
<span className="v tc-mono">{!fte ? "no FTE recorded" : casual ? `${CASUAL_ALLOWED} sets` : proposed !== null ? `${proposed} sets` : "–"}</span>
|
||||
</div>
|
||||
)}
|
||||
{route === "kit" && !noGroup && (
|
||||
<div className="tc-people-kv">
|
||||
<span className="k">Starting kit</span>
|
||||
<span className="v tc-mono">{kitSets ?? 0} sets · {kitUsed} of {kitOf ?? 0} issued</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="tc-people-kv">
|
||||
<span className="k">Signed by the manager</span>
|
||||
<span className="v tc-mono">{latestOpen ? `${latestOpen.sets} sets · ${dayMonth(latestOpen.date)}` : "nothing signed"}</span>
|
||||
</div>
|
||||
<div className="tc-people-kv">
|
||||
<span className="k">Drawn</span>
|
||||
<span className="v tc-mono">{used} · {left} left</span>
|
||||
</div>
|
||||
<div className="tc-people-kv">
|
||||
{formPhoto && <button type="button" className="btn btn-ghost tc-people-ghost" onClick={() => viewPhoto(formPhoto)}>View the form</button>}
|
||||
<button type="button" className="btn btn-ghost tc-people-ghost" style={{ marginLeft: "auto" }}
|
||||
onClick={() => window.open(`/print/order-form?staff=${encodeURIComponent(st.id)}`, "_blank", "noopener")}>Print a new one</button>
|
||||
</div>
|
||||
<div className="tc-people-kv">
|
||||
<button type="button" className="btn btn-ghost tc-people-ghost" aria-expanded={open} onClick={() => setOpen((o) => !o)}>{open ? "Close the form" : "Record a signed form"}</button>
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="tc-people-form">
|
||||
<div className="row">
|
||||
<Field label="Sets" style={{ width: 70 }}>{(c) => <input {...c} className="input" inputMode="numeric" value={ap.sets} onChange={(e) => setAp({ ...ap, sets: e.target.value.replace(/[^0-9]/g, "") })} />}</Field>
|
||||
<Field label="FTE" style={{ width: 80 }}>{(c) => <input {...c} className="input" value={ap.fte} placeholder={fte || "1.0"} onChange={(e) => setAp({ ...ap, fte: e.target.value })} />}</Field>
|
||||
<Field label="Date signed" style={{ width: 170 }} error={apFuture ? "After today: check the year." : undefined}>
|
||||
{(c) => <input {...c} className="input" type="date" max={s.today} value={ap.date} onChange={(e) => setAp({ ...ap, date: e.target.value })} />}
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Note" style={{ marginTop: 8 }} hint="What the manager wrote beside the number.">
|
||||
{(c) => <textarea {...c} className="input" rows={2} maxLength={400} value={ap.note} onChange={(e) => setAp({ ...ap, note: e.target.value })} />}
|
||||
</Field>
|
||||
<div className="row" style={{ marginTop: 10, alignItems: "center" }}>
|
||||
<PhotoButton kind="approval" label="Photo the signed form" attached="Form photo attached" value={photo} onChange={setPhoto} onError={setErr} />
|
||||
<button type="button" className="btn btn-secondary" disabled={invalid} onClick={record}>Record approval</button>
|
||||
</div>
|
||||
{!apMgr && <div className="tc-people-hint">{mgr?.inactive ? "Their manager is inactive: set a new one first." : "Set their manager first."}</div>}
|
||||
{route === "fte" && apProposed !== null && <div className="tc-people-hint">The table proposes {apProposed} {apProposed === 1 ? "set" : "sets"} at FTE {apFte}.</div>}
|
||||
{apOver && <div className="tc-people-hint"><b>Recorded as:</b> {apOver}</div>}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/* Notes save 600ms after typing stops, and a pending save is flushed (not dropped) when the panel
|
||||
goes away: another tab, another record, or off the page. */
|
||||
function NotePanel({ st, isAdmin, setErr }: { st: StaffRec; isAdmin: boolean; setErr: (e: string) => void }) {
|
||||
const { mutate, refresh } = useSnap();
|
||||
const [value, setValue] = useState<string | null>(null);
|
||||
const [state, setState] = useState<"" | "saving" | "saved">("");
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pending = useRef<string | null>(null);
|
||||
const id = st.id;
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
if (savedTimer.current) clearTimeout(savedTimer.current);
|
||||
const v = pending.current;
|
||||
if (v === null) return;
|
||||
pending.current = null;
|
||||
fetch("/api/mutate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "staff.patch", payload: { id, notes: v } }), keepalive: true })
|
||||
.then(() => refresh()).catch(() => {});
|
||||
}, [id, refresh]);
|
||||
|
||||
function change(v: string) {
|
||||
setValue(v);
|
||||
pending.current = v;
|
||||
setState("saving");
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
if (savedTimer.current) clearTimeout(savedTimer.current);
|
||||
timer.current = setTimeout(async () => {
|
||||
pending.current = null;
|
||||
const r = await mutate("staff.patch", { id, notes: v });
|
||||
if (pending.current !== null) return;
|
||||
if (!r.ok) { setState(""); setErr(r.error); return; }
|
||||
setState("saved");
|
||||
savedTimer.current = setTimeout(() => setState(""), 3000);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
const text = value ?? st.notes;
|
||||
return (
|
||||
<Panel title="Note" aside={state === "saving" ? "Saving…" : state === "saved" ? "Saved" : undefined}>
|
||||
<div className="tc-people-pad">
|
||||
{isAdmin
|
||||
? <textarea className="input" rows={3} style={{ width: "100%", boxSizing: "border-box" }} aria-label={`Notes about ${fullName(st)}`} value={text} onChange={(e) => change(e.target.value)} />
|
||||
: text.trim() ? <div className="tc-people-noted" aria-label={`Notes about ${fullName(st)}`}>{text}</div> : <span className="tc-meta-line">No note.</span>}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
/* Helpers and the few layout rules the People screens need beyond components/portal.tsx.
|
||||
* The rules are scoped to class names prefixed tc-people- and hoisted once by React (href + precedence). */
|
||||
import type { StaffRec } from "@/lib/compute";
|
||||
|
||||
export type Act = (op: string, payload: unknown) => Promise<boolean>;
|
||||
export type Mutate = <T = unknown>(op: string, payload?: unknown) => Promise<{ ok: true; result: T } | { ok: false; error: string }>;
|
||||
|
||||
/** "3 Feb" from an ISO date. */
|
||||
export function dayMonth(iso: string | null | undefined): string {
|
||||
if (!iso || iso.length < 10) return iso || "–";
|
||||
const d = new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
|
||||
if (Number.isNaN(d.getTime())) return "–";
|
||||
return d.toLocaleDateString("en-AU", { day: "numeric", month: "short" });
|
||||
}
|
||||
|
||||
/** Same pronoun rule the counter uses: Women's → her, Men's → him, otherwise them. */
|
||||
export function objectPronoun(st: StaffRec): "her" | "him" | "them" {
|
||||
return st.uniformStyle === "Women's" ? "her" : st.uniformStyle === "Men's" ? "him" : "them";
|
||||
}
|
||||
|
||||
export const fullName = (st: StaffRec) => `${st.first} ${st.last}`.trim();
|
||||
export const pl = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
|
||||
/* Most rules live in app/globals.css under the portal redesign. These tighten the register so its
|
||||
* eleven columns fit a 1440px screen (with a scrollbar) without scrolling sideways: narrower cell
|
||||
* padding, a Holding column sized by its meters rather than a fixed floor, and capped Group/Ward. */
|
||||
const PEOPLE_CSS = `@media screen and (min-width: 781px) {
|
||||
table.tc-table.tc-people-reg th, table.tc-table.tc-people-reg td { padding-left: 8px; padding-right: 8px; }
|
||||
table.tc-table.tc-people-reg th:first-child, table.tc-table.tc-people-reg td:first-child { padding-left: 14px; }
|
||||
table.tc-table.tc-people-reg th:last-child, table.tc-table.tc-people-reg td:last-child { padding-right: 14px; }
|
||||
table.tc-table.tc-people-reg td.hold { min-width: 0; }
|
||||
table.tc-table.tc-people-reg .tc-meter { gap: 6px; }
|
||||
table.tc-table.tc-people-reg .tc-meter-label { width: 40px; }
|
||||
table.tc-table.tc-people-reg td.wrapcap { max-width: 120px; }
|
||||
table.tc-table.tc-people-reg td.open a { white-space: nowrap; }
|
||||
}`;
|
||||
|
||||
export function PeopleStyles() {
|
||||
return <style href="tc-people-register" precedence="default">{PEOPLE_CSS}</style>;
|
||||
}
|
||||
Reference in New Issue
Block a user