344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
95 lines
5.6 KiB
TypeScript
95 lines
5.6 KiB
TypeScript
"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)" }} />
|
||
</>
|
||
);
|
||
}
|