Files
threadcount-community/components/PlanTab.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

95 lines
5.6 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 { useSnap } from "@/lib/client";
import { Field, LiveRegion } from "@/components/ui";
/* Settings Plan. Admin only, and only once plans are live.
*
* Numbers and short labels, no selling: the plan's name, where it stands, the staff count against
* the ceiling when there is one, what the hosted backups keep, and the billing contact. Two
* things to do: ask for an invoice, which emails the owner and nothing more, and download the
* backup, which is the same file Data offers and is here because a room deciding whether to pay
* should have its way out beside the button. No card yet; that is a later phase. */
const STATE_WORDS: Record<string, string> = {
grandfathered: "Free · everything included, for good",
free: "Free",
trial: "Trial",
active: "Paid",
grace: "Ended · still writable",
read_only: "Read-only",
};
export default function PlanTab() {
const { s, mutate } = useSnap();
const p = s.plan;
const [email, setEmail] = useState(p.billingEmail);
const [want, setWant] = useState<"hosted_facility" | "health_service">("hosted_facility");
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState("");
const fmt = (iso: string | null) => iso ? new Date(iso).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" }) : "—";
const row = (k: string, v: React.ReactNode) => (
<div style={{ display: "grid", gridTemplateColumns: "160px 1fr", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
<div style={{ padding: "10px 12px", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", background: "var(--color-surface)" }}>{k}</div>
<div style={{ padding: "10px 12px", fontVariantNumeric: "tabular-nums" }}>{v}</div>
</div>
);
async function run(op: "plan.billing" | "plan.invoice", payload: Record<string, unknown>, done: string) {
setBusy(op); setMsg("");
const r = await mutate(op, payload);
setBusy("");
setMsg(r.ok ? done : r.error);
}
const ends = p.state === "trial" ? "Trial ends" : p.state === "active" ? "Paid until" : p.state === "grace" ? "Writable until" : null;
const paidOrTrial = p.state === "active" || p.state === "trial";
return (
<>
<div className="sec" style={{ marginTop: "var(--space-6)" }}>Plan</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", marginTop: "var(--space-3)" }}>{p.label}</div>
<div style={{ border: "2px solid var(--color-text)", marginTop: "var(--space-3)", maxWidth: 640 }}>
{row("Status", <b style={{ color: p.readOnly ? "var(--color-accent-700)" : undefined }}>{STATE_WORDS[p.state] || p.state}{p.state === "trial" && p.daysLeft !== null ? ` · ${p.daysLeft} days left` : ""}{p.state === "grace" && p.graceDaysLeft !== null ? ` · ${p.graceDaysLeft} days` : ""}</b>)}
{row("Staff records", p.maxStaff === null ? String(p.staff) : `${p.staff} of ${p.maxStaff}`)}
{row("Backups", `Nightly · ${p.backupDays} days`)}
{ends && row(ends, fmt(p.state === "grace" ? p.graceEndsAt : p.endsAt))}
{row("Billing contact", p.billingEmail || "—")}
</div>
{p.readOnly && (
<div style={{ fontSize: 13, marginTop: "var(--space-3)", maxWidth: 640, lineHeight: 1.6 }}>
Reports, exports and the backup still work. Writes start again when a payment is recorded.
</div>
)}
{!p.grandfathered && (
<>
<div className="sec" style={{ marginTop: "var(--space-6)" }}>Invoice</div>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)", marginTop: "var(--space-3)", maxWidth: 640 }}>
<Field label="Billing contact" hint="Where the invoice goes.">{(c) => <input {...c} className="input" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />}</Field>
<Field label="Plan">{(c) => (
<select {...c} className="input" value={want} onChange={(e) => setWant(e.target.value as typeof want)}>
<option value="hosted_facility">Hosted Facility · annual</option>
<option value="health_service">Health Service · annual</option>
</select>
)}</Field>
</div>
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
<button className="btn btn-primary" disabled={!!busy || !email.trim()} onClick={() => run("plan.invoice", { email: email.trim(), plan: want }, "Requested. The invoice comes by email.")}>{busy === "plan.invoice" ? "Sending…" : "Request an invoice"}</button>
<button className="btn btn-secondary" disabled={!!busy || email.trim() === p.billingEmail} onClick={() => run("plan.billing", { email: email.trim() }, "Saved.")}>Save contact</button>
<a className="btn btn-ghost" href="/api/backup" download>Download backup</a>
</div>
{paidOrTrial && <div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: "var(--space-2)" }}>A renewal invoice is requested the same way.</div>}
</>
)}
{p.grandfathered && (
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
<a className="btn btn-ghost" href="/api/backup" download>Download backup</a>
</div>
)}
<LiveRegion msg={msg} style={{ fontSize: 12, color: "var(--color-accent-700)", fontWeight: 600, marginTop: "var(--space-2)" }} />
</>
);
}