Files
threadcount-community/app/ops/(console)/facilities/[id]/Plan.tsx
T
ThreadCount 1bc2de655a ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:45:19 +10:00

108 lines
7.2 KiB
TypeScript
Raw 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";
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>
);
}