import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ApiError, armCapture, cancelCapture, createSubscription, deleteSubscription, fetchReaders, fetchSiteConfig, fetchSubscriptions, pollCapture, printSubscription, revokeSubscription, updateSubscription, type ReaderInfo, type Subscription, type SubscriptionCredential, type SubscriptionInput, } from "./api.js"; // Subscription admin. Create/edit/revoke/delete subscriptions + their credentials // (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A // subscription is mutable master data; every USE of it is a signed ledger event // elsewhere. See wiki/entities/subscription.md. const DEFAULT_CURRENCY = "ALL"; interface FormState { holderName: string; contact: string; priceMajor: string; // major units as typed (e.g. "10000"); "" = no price currency: string; carBound: boolean; // false = unbound (maxConcurrent null) maxConcurrent: string; validFrom: string; months: string; // months paid for; "" = none (use explicit validTo / open-ended) validTo: string; credentials: SubscriptionCredential[]; platesText: string; // comma/space separated } /** Today (UTC date, yyyy-mm-dd) for a sensible default validFrom on new subs. */ function todayISODate(): string { return new Date().toISOString().slice(0, 10); } function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState { return { holderName: "", contact: "", priceMajor: defaultPriceMajor, currency, carBound: true, maxConcurrent: "1", validFrom: todayISODate(), months: "1", validTo: "", credentials: [{ kind: "qr", value: "" }], platesText: "", }; } function formFrom(s: Subscription): FormState { return { holderName: s.holderName ?? "", contact: s.contact ?? "", priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "", currency: s.currency ?? DEFAULT_CURRENCY, carBound: s.maxConcurrent != null, maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1", validFrom: s.validFrom ?? "", months: "", // on edit, default to leaving the window as-is (explicit validTo below) validTo: s.validTo ?? "", credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }], platesText: s.plates.join(", "), }; } /** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the * server's addMonths so the form can preview the coverage end. */ function addMonthsDate(date: string, months: number): string | null { const d = new Date(`${date}T00:00:00Z`); if (Number.isNaN(d.getTime())) return null; const day = d.getUTCDate(); d.setUTCMonth(d.getUTCMonth() + months); if (d.getUTCDate() < day) d.setUTCDate(0); return d.toISOString().slice(0, 10); } const STATUS_KEY: Record = { active: "subs.statusActive", suspended: "subs.statusSuspended", revoked: "subs.statusRevoked", }; function toInput(f: FormState): SubscriptionInput { const major = Number(f.priceMajor); const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0; const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0)); return { holderName: f.holderName.trim() || null, contact: f.contact.trim() || null, priceMinor: priceSet ? Math.round(major * 100) : null, period: "monthly", currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null, maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null, validFrom: f.validFrom.trim() || null, // months (with validFrom) drives validTo server-side; else send the explicit end. months: monthsNum && f.validFrom.trim() ? monthsNum : null, validTo: f.validTo.trim() || null, // A QR credential with a blank value is sent as { kind:'qr' } (no value) so the // server auto-generates the code. RF (and pre-existing QR) keep their value. credentials: f.credentials .filter((c) => c.kind === "qr" || c.value.trim()) .map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })), plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean), }; } function priceLabel(s: Subscription, t: (k: string) => string): string { if (s.priceMinor == null) return t("subs.noPrice"); return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim(); } export function SubscriptionManager() { const { t } = useTranslation(); const [subs, setSubs] = useState(null); const [defaultPriceMajor, setDefaultPriceMajor] = useState(""); const [editing, setEditing] = useState(null); const [form, setForm] = useState(() => emptyForm()); const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); // Credential capture ("Read card"): which credential index is being captured, the // reader picker list, and a live status line. null = no capture in progress. const [capture, setCapture] = useState<{ credIndex: number; phase: "pick" | "waiting"; status?: string } | null>(null); const [readers, setReaders] = useState([]); const pollRef = useRef | null>(null); function reload() { fetchSubscriptions() .then((r) => setSubs(r.subscriptions)) .catch((e) => setMsg({ kind: "err", text: (e as Error).message })); } useEffect(() => { reload(); // Pull the site default monthly price to pre-fill new subscriptions. fetchSiteConfig() .then((c) => { if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100)); }) .catch(() => { /* non-fatal — the form just won't pre-fill */ }); }, []); function startNew() { setForm(emptyForm(defaultPriceMajor)); setEditing("new"); setMsg(null); } function startEdit(s: Subscription) { setForm(formFrom(s)); setEditing(s.id); setMsg(null); } async function save() { setMsg(null); try { if (editing === "new") { const created = await createSubscription(toInput(form)); setEditing(null); reload(); // Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the // operator can use "Print code" to retry). if (created.printed) { setMsg({ kind: "ok", text: t("subs.savedPrinted") }); } else if (created.printError) { setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) }); } else { setMsg({ kind: "ok", text: t("subs.saved") }); } return; } if (editing) await updateSubscription(editing, toInput(form)); setEditing(null); reload(); setMsg({ kind: "ok", text: t("subs.saved") }); } catch (e) { const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined; setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message }); } } async function doPrint(s: Subscription) { setMsg(null); try { const r = await printSubscription(s.id); setMsg({ kind: "ok", text: t("subs.printedOn", { printer: r.printedBy }) }); } catch (e) { setMsg({ kind: "err", text: (e as Error).message }); } } async function doRevoke(s: Subscription) { if (!confirm(t("subs.confirmRevoke", { name: s.holderName ?? s.id }))) return; await revokeSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message })); reload(); } async function doDelete(s: Subscription) { if (!confirm(t("subs.confirmDelete", { name: s.holderName ?? s.id }))) return; await deleteSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message })); reload(); } function setCred(i: number, patch: Partial) { setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) })); } function clearPoll() { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } } // Stop a capture in progress (cancel on the server + clear local state). function stopCapture() { clearPoll(); void cancelCapture().catch(() => {}); setCapture(null); } // "Read card" on credential i → load readers + show the picker. async function startCapture(i: number) { setMsg(null); try { const r = await fetchReaders(); setReaders(r.readers); setCapture({ credIndex: i, phase: "pick" }); } catch (e) { setMsg({ kind: "err", text: (e as Error).message }); } } // Operator picked a reader → arm it and poll until captured / expired. async function pickReader(deviceId: string) { const cap = capture; if (!cap) return; try { await armCapture(deviceId); setCapture({ ...cap, phase: "waiting", status: t("subs.captureWaiting") }); clearPoll(); pollRef.current = setInterval(async () => { try { const st = await pollCapture(); if (st.status === "captured") { clearPoll(); setCred(cap.credIndex, { value: st.value }); void cancelCapture().catch(() => {}); // clear the server-side result setCapture(null); setMsg({ kind: "ok", text: t("subs.captured", { value: st.value }) }); } else if (st.status === "expired" || st.status === "idle") { clearPoll(); setCapture({ ...cap, phase: "waiting", status: t("subs.captureTimeout") }); } } catch { /* transient poll error — keep polling */ } }, 700); } catch (e) { setMsg({ kind: "err", text: (e as Error).message }); setCapture(null); } } // Stop polling if the form closes or the component unmounts. useEffect(() => clearPoll, []); // Live coverage preview: when months + validFrom are set, show the end date and // (if priced) the N×monthly total the operator should collect. const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0)); const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null; const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor); const totalDue = coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN) ? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}` : null; const coverageHint = coverageEnd ? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "") : null; if (!subs) return null; return (

{t("subs.title")}

    {subs.map((s) => (
  • {s.holderName ?? t("subs.unnamed")} {t(STATUS_KEY[s.status])} {priceLabel(s, t)} {s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "} {s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })} {/* Print code — only when the subscription has a QR credential to encode. */} {s.credentials.some((c) => c.kind === "qr") && ( )} {s.status !== "revoked" && }
  • ))} {subs.length === 0 &&
  • {t("subs.noneYet")}
  • }
{editing == null ? ( ) : (

{editing === "new" ? t("subs.new") : t("subs.editTitle")}

setForm((f) => ({ ...f, holderName: e.target.value }))} /> setForm((f) => ({ ...f, contact: e.target.value }))} /> setForm((f) => ({ ...f, priceMajor: e.target.value }))} inputMode="decimal" placeholder={t("subs.pricePlaceholder")} style={{ width: 110 }} /> setForm((f) => ({ ...f, currency: e.target.value }))} style={{ width: 60 }} /> / {t("subs.perMonth")} {form.carBound && ( setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} /> )} setForm((f) => ({ ...f, validFrom: e.target.value }))} /> setForm((f) => ({ ...f, months: e.target.value }))} inputMode="numeric" placeholder="1" style={{ width: 50 }} /> {t("subs.monthsHint")} {/* Live preview of the coverage end + the N×price total. */} {coverageHint && {coverageHint}} setForm((f) => ({ ...f, validTo: e.target.value }))} /> setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />

{t("subs.credentials")}

{form.credentials.map((c, i) => (
{/* Operator chooses the credential type: QR (auto-generated) or RFID (read off a card via "Read card"). */} {c.kind === "qr" ? ( // QR codes are server-generated. Blank → "will be generated"; an // existing code is shown read-only (it can be printed; never typed). c.value.trim() ? ( ) : ( {t("subs.qrAutoGen")} ) ) : ( // RFID: the value is read off a physical card (or typed). "Read card" // arms a chosen reader and fills the captured value. setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} style={{ flex: 1, fontFamily: "ui-monospace, monospace" }} /> )} {c.kind === "rf" && ( )}
))} {/* Capture panel: pick a reader, present the card; the captured value fills the credential. The OTHER reader keeps serving the live flow. */} {capture && (
{capture.phase === "pick" ? ( <>
{t("subs.captureChooseReader")}
{readers.length === 0 && {t("subs.captureNoReaders")}} {readers.map((r) => ( ))}
) : (
{capture.status ?? t("subs.captureWaiting")}
)}
)}

{t("subs.needCredentialOrPlate")}

)} {msg &&

{msg.text}

}
); }