ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
/* The one thing on the console that cannot be undone. Owner only (the server checks too), the
|
||||
* facility's exact name typed, and a live code from the operator's own second factor. */
|
||||
export default function Danger({ facilityId, name, owner, totpEnabled }: { facilityId: string; name: string; owner: boolean; totpEnabled: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
const r = await fetch("/api/ops/controls", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "facility.delete", facilityId, confirm, code }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) { setErr(j.error || "That didn’t work."); return; }
|
||||
window.location.assign("/ops/facilities?deleted=1");
|
||||
} catch {
|
||||
setErr("No connection — check the network and try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const label: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 4 };
|
||||
|
||||
if (!owner) return <div className="tc-panel-body" style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>Only an owner can delete a facility.</div>;
|
||||
if (!totpEnabled) return <div className="tc-panel-body" style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>Enrol a second factor under <a href="/ops/security">Your sign-in</a> before deleting anything.</div>;
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="tc-panel-body" style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ flex: 1, color: "var(--color-neutral-700)" }}>Deletes every record, image and account belonging to this facility. There is no undo beyond the database dumps on the box.</div>
|
||||
<button type="button" className="btn" onClick={() => setOpen(true)}>Delete facility…</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<form onSubmit={submit} className="tc-panel-body" style={{ fontSize: 13, display: "grid", gap: 10 }}>
|
||||
<div>
|
||||
<label htmlFor="danger-confirm" style={label}>Type the facility name exactly</label>
|
||||
<input id="danger-confirm" className="input" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder={name} autoComplete="off" autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="danger-code" style={label}>Code from your authenticator</label>
|
||||
<input id="danger-code" className="input" inputMode="numeric" autoComplete="one-time-code" maxLength={7} value={code} onChange={(e) => setCode(e.target.value)} style={{ maxWidth: 160 }} />
|
||||
</div>
|
||||
{err && <div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontWeight: 600, color: "var(--color-accent-700)" }}>{err}</div>}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy || confirm.trim() !== name || code.replace(/\s+/g, "").length < 6}>{busy ? "Deleting…" : "Delete this facility"}</button>
|
||||
<button type="button" className="btn" onClick={() => { setOpen(false); setConfirm(""); setCode(""); setErr(""); }} disabled={busy}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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 didn’t 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 owner’s 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
/* The reveal control: a button, a reason, a confirmation. On success the page reloads and the
|
||||
* server renders the contacts through the reveal role — the values never travel in this response. */
|
||||
export default function Reveal({ facilityId, minutes }: { facilityId: string; minutes: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
const r = await fetch("/api/ops/reveal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ facilityId, reason }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) { setErr(j.error || "That didn’t work."); return; }
|
||||
window.location.reload();
|
||||
} catch {
|
||||
setErr("No connection — check the network and try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return <button type="button" className="btn" onClick={() => setOpen(true)} style={{ marginTop: 12 }}>Reveal contacts…</button>;
|
||||
}
|
||||
return (
|
||||
<form onSubmit={submit} style={{ marginTop: 12, display: "grid", gap: 8 }}>
|
||||
<label htmlFor="reveal-reason" style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
|
||||
Reason — goes in the trail and the email
|
||||
</label>
|
||||
<textarea
|
||||
id="reveal-reason"
|
||||
className="input"
|
||||
rows={3}
|
||||
maxLength={400}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. Coordinator emailed support about a restore; calling back."
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
{err && <div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)" }}>{err}</div>}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy || reason.trim().length < 8}>{busy ? "Revealing…" : `Reveal for ${minutes} minutes`}</button>
|
||||
<button type="button" className="btn" onClick={() => { setOpen(false); setErr(""); }} disabled={busy}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireOperator } from "@/lib/ops/session";
|
||||
import { facility } from "@/lib/ops/projections";
|
||||
import { revealedContacts, REVEAL_MINUTES } from "@/lib/ops/reveal";
|
||||
import Reveal from "./Reveal";
|
||||
import Plan from "./Plan";
|
||||
import Danger from "./Danger";
|
||||
|
||||
/* One facility: configuration, counts, activity. Contacts are masked — the ops_ro role cannot read
|
||||
* them, and the projection never asks. They appear only while this operator holds an unexpired
|
||||
* reveal grant, read through the separate reveal role (lib/ops/reveal.ts).
|
||||
* Nothing about a wearer is reachable from here at any level. */
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const dl: React.CSSProperties = { display: "grid", gridTemplateColumns: "auto 1fr", gap: "8px 16px", fontSize: 13, margin: 0 };
|
||||
const dt: React.CSSProperties = { color: "var(--color-neutral-600)" };
|
||||
const dd: React.CSSProperties = { margin: 0, fontWeight: 600, fontVariantNumeric: "tabular-nums" };
|
||||
const masked: React.CSSProperties = { fontFamily: "ui-monospace, Menlo, monospace", letterSpacing: "0.02em", color: "var(--color-neutral-700)" };
|
||||
|
||||
export default async function OpsFacility({ params }: { params: Promise<{ id: string }> }) {
|
||||
const op = await requireOperator();
|
||||
const { id } = await params;
|
||||
const f = await facility(id);
|
||||
if (!f) notFound();
|
||||
const rv = f.isDemo ? null : await revealedContacts(op.id, id);
|
||||
const fmt = (d: Date) => d.toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||
const hm = (d: Date) => d.toLocaleTimeString("en-AU", { timeZone: "Australia/Brisbane", hour: "2-digit", minute: "2-digit" });
|
||||
const n = (x: number) => x.toLocaleString();
|
||||
const c = f.counts;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<div className="sec" style={{ border: 0, padding: 0 }}>Facility{f.isDemo ? " · demo" : ""}</div>
|
||||
<h1 className="h1">{f.name}</h1>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>
|
||||
Created {fmt(f.createdAt)} · {f.timezone} · rev {n(f.rev)} · {f.backupAgeDays === null ? "never exported" : `last export ${f.backupAgeDays} days ago`}
|
||||
</div>
|
||||
</div>
|
||||
<a className="btn" href="/ops/facilities">All facilities</a>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 24, marginTop: 20 }}>
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head"><span>Contacts</span><span className="tc-panel-aside">{rv ? `revealed until ${hm(rv.expiresAt)}` : "masked"}</span></div>
|
||||
<div className="tc-panel-body" style={{ fontSize: 13 }}>
|
||||
{rv ? (
|
||||
<>
|
||||
<div style={{ padding: "6px 0", borderBottom: "1px solid var(--color-divider)" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Coordinator</div><span style={{ fontWeight: 600 }}>{rv.coordinator || "—"}</span></div>
|
||||
<div style={{ padding: "6px 0", borderBottom: "1px solid var(--color-divider)" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Email</div><span style={{ fontWeight: 600 }}>{rv.coordinatorEmail || "—"}</span></div>
|
||||
<div style={{ padding: "6px 0" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Phone</div><span style={{ fontWeight: 600 }}>{rv.coordinatorPhone || "—"}</span></div>
|
||||
<div style={{ borderTop: "2px solid var(--color-text)", marginTop: 12, paddingTop: 10, fontSize: 12.5, color: "var(--color-neutral-700)", display: "flex", gap: 8 }}>
|
||||
<span style={{ color: "var(--color-accent)", fontWeight: 800 }}>▌</span>
|
||||
<span>{`Revealed ${hm(rv.at)} for ${REVEAL_MINUTES} minutes, in the trail and emailed. Reason: ${rv.reason}`}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: "6px 0", borderBottom: "1px solid var(--color-divider)" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Coordinator</div><span style={masked}>•••••</span></div>
|
||||
<div style={{ padding: "6px 0", borderBottom: "1px solid var(--color-divider)" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Email</div><span style={masked}>•••@•••</span></div>
|
||||
<div style={{ padding: "6px 0" }}><div className="sec" style={{ border: 0, padding: 0, fontSize: 11 }}>Phone</div><span style={masked}>•• •••• ••••</span></div>
|
||||
<div style={{ borderTop: "2px solid var(--color-text)", marginTop: 12, paddingTop: 10, fontSize: 12.5, color: "var(--color-neutral-700)", display: "flex", gap: 8 }}>
|
||||
<span style={{ color: "var(--color-accent)", fontWeight: 800 }}>▌</span>
|
||||
<span>The console’s database role cannot read these. Revealing them is a separate act: it asks for a reason, lasts thirty minutes, is written to the operator trail and emails you a copy. Nothing about a wearer can be revealed at any level.</span>
|
||||
</div>
|
||||
{!f.isDemo && <Reveal facilityId={f.id} minutes={REVEAL_MINUTES} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head"><span>Configuration</span></div>
|
||||
<dl className="tc-panel-body" style={dl}>
|
||||
<dt style={dt}>Ceiling</dt><dd style={dd}>{f.config.capSets} sets held at any time</dd>
|
||||
<dt style={dt}>Starting kit</dt><dd style={dd}>{f.config.initialSets} sets</dd>
|
||||
<dt style={dt}>Staff groups</dt><dd style={dd}>{f.config.groups} named · {f.config.fteGroups} on the FTE table · {f.config.kitGroups} on the starting kit</dd>
|
||||
<dt style={dt}>Reorder default</dt><dd style={dd}>{f.config.defaultReorder}</dd>
|
||||
<dt style={dt}>GL account</dt><dd style={dd}>{f.config.glAccountSet ? "set" : "not set"}</dd>
|
||||
<dt style={dt}>Journal line</dt><dd style={dd}>{f.config.journalDesc}</dd>
|
||||
<dt style={dt}>Barcode lookup</dt><dd style={dd}>{f.config.barcodeLookup ? "on" : "off"}</dd>
|
||||
<dt style={dt}>Slip organisation</dt><dd style={dd}>{f.config.slipOrgSet ? "set" : "not set"}</dd>
|
||||
<dt style={dt}>Single sign-on</dt><dd style={dd}>{`${f.config.sso}${f.config.ssoStaff ? " · staff too" : ""}`}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tc-panel" style={{ marginTop: 24 }}>
|
||||
<div className="tc-panel-head"><span>Counts</span><span className="tc-panel-aside">no records readable</span></div>
|
||||
<dl className="tc-panel-body" style={{ ...dl, gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))" }}>
|
||||
{/* Whole phrases, not `{value} word`: React separates adjacent text and expressions with
|
||||
comment nodes, so "1 admin" would render as "1<!-- --> admin" and never match a grep. */}
|
||||
<div><dt style={dt}>Coordinators</dt><dd style={dd}>{`${n(c.users)} · ${c.admins} admin · ${c.usersWith2fa} with 2FA${c.inactiveUsers ? ` · ${c.inactiveUsers} inactive` : ""}`}</dd></div>
|
||||
<div><dt style={dt}>Staff register</dt><dd style={dd}>{`${n(c.staff)} · ${n(c.activeStaff)} active`}</dd></div>
|
||||
<div><dt style={dt}>Staff accounts</dt><dd style={dd}>{`${n(c.staffAccounts)} · ${n(c.accountsSeen7d)} seen this week`}</dd></div>
|
||||
<div><dt style={dt}>Catalogue</dt><dd style={dd}>{n(c.items)}</dd></div>
|
||||
<div><dt style={dt}>Issues</dt><dd style={dd}>{n(c.issues)}</dd></div>
|
||||
<div><dt style={dt}>Orders</dt><dd style={dd}>{n(c.orders)}</dd></div>
|
||||
<div><dt style={dt}>Requests</dt><dd style={dd}>{n(c.requests)}</dd></div>
|
||||
<div><dt style={dt}>Events, 24h</dt><dd style={dd}>{n(c.events24h)}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{!f.isDemo && (
|
||||
<>
|
||||
<div className="tc-panel" style={{ marginTop: 24 }}>
|
||||
<div className="tc-panel-head"><span>Plan</span><span className="tc-panel-aside">the billing desk · every act in the trail</span></div>
|
||||
<Plan facilityId={f.id} view={f.planView} owner={op.role === "OWNER"} />
|
||||
</div>
|
||||
|
||||
<div className="tc-panel" style={{ marginTop: 24, borderColor: "var(--color-accent)" }}>
|
||||
<div className="tc-panel-head"><span>Danger zone</span><span className="tc-panel-aside">cannot be undone</span></div>
|
||||
<Danger facilityId={f.id} name={f.name} owner={op.role === "OWNER"} totpEnabled={op.totpEnabled} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user