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 e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { Field, LiveRegion } from "@/components/ui";
|
||||
import { PRICES } from "@/lib/plan";
|
||||
|
||||
/* 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. Three
|
||||
* things to do: ask for an invoice, which emails the owner and nothing more; pay by card, which
|
||||
* leaves for a page Stripe hosts and appears only when the server has Stripe keys (`cardsOn`) and
|
||||
* the room is not grandfathered; 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. */
|
||||
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 cardsOn = (s.plan as { cardsOn?: boolean }).cardsOn === true;
|
||||
const card = (s.plan as { card?: boolean }).card === true;
|
||||
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" }) : "—";
|
||||
|
||||
// Back from Stripe: ?card=ok or ?card=cancelled, said once and taken off the address.
|
||||
useEffect(() => {
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
const back = q.get("card");
|
||||
if (!back) return;
|
||||
if (back === "ok") setMsg("Payment set up. The plan updates when Stripe confirms it — a minute at most.");
|
||||
else if (back === "cancelled") setMsg("Card payment cancelled. Nothing was charged.");
|
||||
q.delete("card");
|
||||
const rest = q.toString();
|
||||
window.history.replaceState(null, "", window.location.pathname + (rest ? `?${rest}` : ""));
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** Checkout or the Billing Portal: both answer a URL to leave for. */
|
||||
async function stripeGo(kind: "checkout" | "portal") {
|
||||
setBusy(kind); setMsg("");
|
||||
try {
|
||||
const r = await fetch(`/api/billing/${kind}`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
||||
const j = (await r.json().catch(() => ({}))) as { url?: string; error?: string };
|
||||
if (r.ok && j.url) { window.location.assign(j.url); return; }
|
||||
setMsg(j.error || "Could not reach the payment page.");
|
||||
} catch {
|
||||
setMsg("Could not reach the payment page.");
|
||||
}
|
||||
setBusy("");
|
||||
}
|
||||
|
||||
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";
|
||||
// The card button: a room that could buy (free, trial, grace, read-only) and is not already
|
||||
// paying by card. A card-backed room gets Manage card instead.
|
||||
const showPay = cardsOn && !p.grandfathered && !card && p.state !== "active";
|
||||
const showManage = cardsOn && !p.grandfathered && card;
|
||||
|
||||
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))}
|
||||
{card && row("Paid by", "Card · monthly")}
|
||||
{p.org && row("Health service", p.org.name)}
|
||||
{row("Billing contact", p.billingEmail || "—")}
|
||||
{p.invoicer && row("Invoiced by", p.invoicer)}
|
||||
</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)" }}>{cardsOn ? "Invoice or card" : "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", alignItems: "center" }}>
|
||||
<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>
|
||||
{showPay && (
|
||||
<>
|
||||
<button className="btn btn-secondary" disabled={!!busy} onClick={() => stripeGo("checkout")}>{busy === "checkout" ? "Opening…" : "Pay by card"}</button>
|
||||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", fontVariantNumeric: "tabular-nums" }}>Hosted Facility · ${PRICES.hostedMonthly} a month, ex GST</span>
|
||||
</>
|
||||
)}
|
||||
{showManage && <button className="btn btn-secondary" disabled={!!busy} onClick={() => stripeGo("portal")}>{busy === "portal" ? "Opening…" : "Manage card"}</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 && !card && <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)" }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user