ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import type { PlanView } from "@/lib/ops/projections";
/* The billing desk for one facility. What the columns say, what they mean today, and the five
* acts from lib/ops/controls.ts. No money moves here: an invoice is raised in the accounting tool
* and its payment recorded with "Record a payment". Every act reloads, so the panel always shows
* what the server holds. */
const PLAN_OPTS: [string, string][] = [["", "No plan set"], ["hosted_small", "Hosted Small · free, 60 staff records"], ["hosted_facility", "Hosted Facility"], ["health_service", "Health Service"], ["private", "Private"]];
const STATE_WORDS: Record<PlanView["state"], string> = {
grandfathered: "Free — grandfathered, everything, for good",
free: "Free",
trial: "Trial",
active: "Paid",
grace: "Grace — period ended, still writable",
read_only: "Read-only",
};
export default function Plan({ facilityId, view, owner }: { facilityId: string; view: PlanView; owner: boolean }) {
const [editing, setEditing] = useState(false);
const [p, setP] = useState(view.plan);
const [n, setN] = useState(view.planNote);
const [g, setG] = useState(view.grandfathered);
const [err, setErr] = useState("");
const [busy, setBusy] = useState("");
async function act(body: Record<string, unknown>, tag: string) {
setBusy(tag); setErr("");
try {
const r = await fetch("/api/ops/controls", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "plan", facilityId, ...body }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) { setErr(j.error || "That didnt work."); return; }
window.location.reload();
} catch {
setErr("No connection — check the network and try again.");
} finally {
setBusy("");
}
}
const fmt = (d: Date | string | null) => d ? new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" }) : "—";
const label: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 4 };
const dl: React.CSSProperties = { display: "grid", gridTemplateColumns: "auto 1fr", gap: "6px 16px", fontSize: 13, margin: "10px 0 0" };
const dt: React.CSSProperties = { color: "var(--color-neutral-600)" };
const dd: React.CSSProperties = { margin: 0, fontWeight: 600, fontVariantNumeric: "tabular-nums" };
const b = (text: string, body: Record<string, unknown>, opts: { confirm?: string; disabled?: boolean } = {}) => (
<button type="button" className="btn" disabled={!!busy || opts.disabled} onClick={() => { if (!opts.confirm || window.confirm(opts.confirm)) void act(body, text); }}>{busy === text ? "…" : text}</button>
);
return (
<div className="tc-panel-body" style={{ fontSize: 13 }}>
{err && <div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontWeight: 600, color: "var(--color-accent-700)", marginBottom: 8 }}>{err}</div>}
<div style={{ display: "flex", alignItems: "baseline", gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 16 }}>{view.label}{view.grandfathered ? " · grandfathered" : ""}</div>
<div style={{ color: view.readOnly ? "var(--color-accent-700)" : "var(--color-neutral-700)", fontWeight: view.readOnly ? 700 : 400 }}>{STATE_WORDS[view.state]}</div>
</div>
{!editing && <button type="button" className="btn" onClick={() => setEditing(true)}>Edit</button>}
</div>
<dl style={dl}>
<dt style={dt}>Recorded as</dt><dd style={dd}>{view.planStatus}</dd>
<dt style={dt}>Trial ends</dt><dd style={dd}>{fmt(view.trialEndsAt)}</dd>
<dt style={dt}>Paid until</dt><dd style={dd}>{fmt(view.paidUntil)}</dd>
{view.graceEndsAt && <><dt style={dt}>Grace ends</dt><dd style={dd}>{fmt(view.graceEndsAt)}</dd></>}
<dt style={dt}>Staff ceiling</dt><dd style={dd}>{view.maxStaff === null ? "none" : view.maxStaff}</dd>
<dt style={dt}>Billing contact</dt><dd style={dd}>on the record reveal above</dd>
</dl>
{view.planNote && <div style={{ color: "var(--color-neutral-700)", marginTop: 10, whiteSpace: "pre-wrap" }}>{view.planNote}</div>}
{editing && (
<form onSubmit={(e) => { e.preventDefault(); void act({ act: "set", plan: p, planNote: n, grandfathered: g }, "Save"); }} style={{ display: "grid", gap: 10, marginTop: 14, paddingTop: 14, borderTop: "1px solid var(--color-divider)" }}>
<div>
<label htmlFor="plan-code" style={label}>Plan</label>
<select id="plan-code" className="input" value={p} onChange={(e) => setP(e.target.value)}>
{PLAN_OPTS.map(([v, t]) => <option key={v} value={v}>{t}</option>)}
</select>
</div>
<div>
<label htmlFor="plan-note" style={label}>Note</label>
<textarea id="plan-note" className="input" rows={3} maxLength={400} value={n} onChange={(e) => setN(e.target.value)} placeholder="PO number, agreed terms, renewal — no contacts here; reveal those above." />
</div>
<label style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 13 }}>
<input type="checkbox" checked={g} onChange={(e) => setG(e.target.checked)} disabled={!owner} />
<span>Grandfathered free with everything, for good{owner ? "" : " (an owners to change)"}</span>
</label>
<div style={{ display: "flex", gap: 8 }}>
<button type="submit" className="btn btn-primary" disabled={!!busy}>{busy === "Save" ? "Saving…" : "Save"}</button>
<button type="button" className="btn" onClick={() => { setEditing(false); setP(view.plan); setN(view.planNote); setG(view.grandfathered); setErr(""); }} disabled={!!busy}>Cancel</button>
</div>
</form>
)}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 14, paddingTop: 14, borderTop: "1px solid var(--color-divider)" }}>
{b(view.state === "trial" ? "Extend trial 30 days" : "Start 60-day trial", { act: "trial", days: view.state === "trial" ? 30 : 60 })}
{b("Record a payment · 12 months", { act: "paid", months: 12 }, { confirm: "Record a payment covering twelve months? The facility becomes Paid from today, or from the end of the current paid period." })}
{view.readOnly
? b("Make writable", { act: "readonly", on: false })
: b("Set read-only", { act: "readonly", on: true }, { confirm: "Set this facility read-only? Every write is refused until it is lifted; reports, exports and the backup keep working." })}
{(view.planStatus === "trial" || view.planStatus === "active") && b("Set free", { act: "free" }, { confirm: "Back to free on the current plan, clearing the trial and paid dates?" })}
</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 8 }}>
Nothing here moves money. Raise the invoice in the accounting tool, then record it. A period that ends runs a fortnight of grace before read-only; grandfathered rooms never go read-only on their own.
</div>
</div>
);
}