import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ApiError, armCapture, cancelCapture, createSubscription, deleteSubscription, fetchReaders, fetchSubscriptionPlans, fetchSubscriptions, pollCapture, printSubscription, quoteSubscription, revokeSubscription, updateSubscription, type ReaderInfo, type Subscription, type SubscriptionCredential, type SubscriptionInput, type SubscriptionPlan, type SubscriptionQuote, } from "./api.js"; import { Modal } from "./ui/Modal.js"; // Subscription admin. Create/edit/revoke/delete subscriptions + their credentials // (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over // a date span — the operator never types a price (the amount is looked up: ceil(periods) // × per-period price). A subscription is mutable master data; every USE of it is a // signed ledger event elsewhere. See wiki/entities/subscription.md. interface FormState { holderName: string; contact: string; planId: string; // selected plan (sells/prices it); "" = comp (no charge) quantity: string; // cars covered by this one subscription (price ×N) tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank) carBound: boolean; // false = unbound (maxConcurrent null) maxConcurrent: string; validFrom: string; // span start (date) validTo: string; // span end (date) — required when a plan is selected 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(): FormState { return { holderName: "", contact: "", planId: "", quantity: "1", tender: "cash", carBound: true, maxConcurrent: "1", validFrom: todayISODate(), validTo: "", credentials: [{ kind: "qr", value: "" }], platesText: "", }; } function formFrom(s: Subscription): FormState { return { holderName: s.holderName ?? "", contact: s.contact ?? "", planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only quantity: String(s.quantity ?? 1), tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale carBound: s.maxConcurrent != null, maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1", validFrom: (s.validFrom ?? "").slice(0, 10), validTo: (s.validTo ?? "").slice(0, 10), credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }], platesText: s.plates.join(", "), }; } const STATUS_KEY: Record = { active: "subs.statusActive", suspended: "subs.statusSuspended", revoked: "subs.statusRevoked", }; /** A yyyy-mm-dd date → an ISO instant (UTC midnight) for the span endpoints. */ function dateToISO(d: string): string | null { if (!d.trim()) return null; const t = Date.parse(`${d}T00:00:00Z`); return Number.isNaN(t) ? null : new Date(t).toISOString(); } function toInput(f: FormState, isNew: boolean): SubscriptionInput { const planSelected = isNew && f.planId.trim() !== ""; return { holderName: f.holderName.trim() || null, contact: f.contact.trim() || null, // A SALE: send the chosen plan; price is looked up server-side. On edit we never // re-sell, so no planId is sent (price/plan stay frozen). planId: planSelected ? f.planId.trim() : null, quantity: Math.max(1, Math.round(Number(f.quantity) || 1)), tender: f.tender, maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null, validFrom: dateToISO(f.validFrom), validTo: dateToISO(f.validTo), // 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), }; } const PERIOD_KEY: Record = { day: "subs.perDay", week: "subs.perWeek", month: "subs.perMonth", }; function priceLabel(s: Subscription, t: (k: string) => string): string { if (s.priceMinor == null) return t("subs.noPrice"); return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""}`.trim(); } export function SubscriptionManager() { const { t } = useTranslation(); const [subs, setSubs] = useState(null); const [plans, setPlans] = useState([]); const [quote, setQuote] = useState(null); const [quoting, setQuoting] = useState(false); 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(); // Load the sellable plan catalog (the operator picks one instead of typing a price). fetchSubscriptionPlans() .then((r) => setPlans(r.plans)) .catch(() => { /* non-fatal — the form will show "no plans" */ }); }, []); // Live server-computed quote for the sell form: ceil(periods) × per-period price. // Debounced; re-runs when the plan or the span changes. The operator can't override // the amount — it's whatever the server returns. useEffect(() => { if (editing !== "new" || !form.planId.trim() || !form.validTo.trim() || !form.validFrom.trim()) { setQuote(null); return; } const from = dateToISO(form.validFrom); const to = dateToISO(form.validTo); if (!from || !to || Date.parse(to) <= Date.parse(from)) { setQuote(null); return; } const quantity = Math.max(1, Math.round(Number(form.quantity) || 1)); let cancelled = false; setQuoting(true); const h = setTimeout(() => { quoteSubscription({ planId: form.planId.trim(), validFrom: from, validTo: to, quantity }) .then((q) => !cancelled && setQuote(q)) .catch(() => !cancelled && setQuote(null)) .finally(() => !cancelled && setQuoting(false)); }, 200); return () => { cancelled = true; clearTimeout(h); }; }, [editing, form.planId, form.validFrom, form.validTo, form.quantity]); function startNew() { setForm(emptyForm()); setQuote(null); 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, true)); setEditing(null); reload(); // The recorded SALE (signed payment) — confirm the amount taken so the operator // sees it was logged, and warn if no shift was open (the takings still recorded, // but won't fall inside a shift Z-report until/unless one covers the time). const sale = created.sale ? " " + t("subs.saleRecorded", { amount: (created.sale.amountMinor / 100).toLocaleString(), currency: created.sale.currency ?? "", tender: t(created.sale.tender === "card" ? "subs.tenderCard" : "subs.tenderCash"), }) + (created.sale.inShift ? "" : " " + t("subs.saleNoShift")) : ""; // Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the // operator can use "Print code" to retry). if (created.printError) { setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) + sale }); } else if (created.printed) { setMsg({ kind: "ok", text: t("subs.savedPrinted") + sale }); } else { setMsg({ kind: "ok", text: t("subs.saved") + sale }); } return; } if (editing) await updateSubscription(editing, toInput(form, false)); 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, []); 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")}
  • }
setEditing(null)} title={editing === "new" ? t("subs.new") : t("subs.editTitle")} width="max-w-2xl" >
setForm((f) => ({ ...f, holderName: e.target.value }))} /> setForm((f) => ({ ...f, contact: e.target.value }))} /> {/* PLAN — the operator selects an admin-defined plan; the price is looked up (never typed). On edit the plan/price is frozen, shown read-only. */} {editing === "new" ? ( <> {plans.length === 0 && {t("subs.planNoneAvail")}} ) : ( <> {form.planId || t("subs.noPrice")} )} {/* Quantity — cars covered by this ONE subscription (a family pays once for N cars). Price ×N; maxConcurrent below pre-fills to it. */} {form.planId.trim() !== "" && editing === "new" && ( <> setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))} /> {t("subs.quantityHint")} )} {/* Tender — only relevant when selling a plan (a SALE). The sale appends a signed payment so the money shows in the feed/drawer/Z-report. */} {form.planId.trim() !== "" && editing === "new" && ( <> {t("subs.tenderHint")} )} {form.carBound && ( setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} /> )} setForm((f) => ({ ...f, validFrom: e.target.value }))} /> setForm((f) => ({ ...f, validTo: e.target.value }))} /> {/* Live SERVER quote: ceil(periods) × per-period price. The operator can't override it — this is exactly what will be charged + signed. */} {editing === "new" && form.planId.trim() !== "" && ( {quoting ? t("subs.quoting") : quote ? t("subs.quoteLine", { periods: quote.periods, unit: t(PERIOD_KEY[quote.period]), amount: (quote.amountMinor / 100).toLocaleString(), currency: quote.currency, }) + (quote.quantity && quote.quantity > 1 ? ` (×${quote.quantity})` : "") : t("subs.quotePrompt")} )} 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")} /> )} {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}

}
); }