Files
parking_solution/apps/web/src/SubscriptionManager.tsx
T
julian 78d1f6808a feat(subs): admin can correct a subscription's plan VERSION
A subscription froze its planVersionId at sale (reproducible pricing). There was
no way to move a sold sub onto a different VERSION of the SAME plan — needed when
an admin publishes v2 with different timeframes (e.g. mujor-naten-cdo-dite v1
"every day" → v2 "weekdays only") and wants an existing subscriber on it, or back
on v1.

Backend (PUT /api/subscriptions/:id):
- accept planVersionId; honored only with the subscription:plan permission
  (stronger than subscription:update — a plan-management action). Non-privileged
  caller sending a change → 403, not silently dropped.
- validated to belong to the sub's EXISTING planId (a different plan = a
  different price basis = a re-sale → 400).
- price/currency/period/planId stay frozen; only planVersionId moves. The swap is
  server-logged for audit (the row is mutable master data, not on the ledger).
  Past signed entry/exit events keep their own windowTariffVersionId, so history
  reprices identically — only future access uses the new version's windows.

Frontend (SubscriptionManager):
- pass the session user through the route (like RolesManager).
- admin-only "Versioni" picker in the edit modal: lists every version of the
  sub's plan by effective date + a timeframe summary (days + window, or 24/7),
  current pre-selected. The plan itself stays read-only. Sends planVersionId only
  when it changed.
- i18n: subs.version/versionHint/versionCurrent/versionOnlyOne/everyDay/allDay
  in both sq + en.

Verified on a writable DB copy: version changed, price + planId frozen,
cross-plan version rejected. Live DB untouched. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:08:58 +02:00

680 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
can,
type Permission,
type ReaderInfo,
type SessionUser,
type Subscription,
type SubscriptionCredential,
type SubscriptionInput,
type SubscriptionPeriod,
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)
// EDIT-only: which VERSION of planId this sub is on. Admins can correct it to another
// version of the SAME plan; "" when the sub has no plan. origPlanVersionId is the
// loaded value, so we only send a change.
planVersionId: string;
origPlanVersionId: string;
quantity: string; // cars covered by this one subscription (price ×N)
count: string; // HOW MANY of the plan's period (e.g. 3 months) — drives the end date
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) — auto-filled from count, or set directly (hotel)
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: "",
planVersionId: "",
origPlanVersionId: "",
quantity: "1",
count: "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; the plan itself stays frozen
planVersionId: s.planVersionId ?? "", // but an admin may correct WHICH version
origPlanVersionId: s.planVersionId ?? "",
quantity: String(s.quantity ?? 1),
count: "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<Subscription["status"], string> = {
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();
}
/** Add `count` of the plan's period to a yyyy-mm-dd start → yyyy-mm-dd end. Mirrors the
* server's whole-month clamp (Jan 31 +1mo → Feb 28) so the previewed end date matches
* what the sale will store. day/week are exact multiples of 24h. */
function addPeriods(startDate: string, period: SubscriptionPeriod, count: number): string | null {
const d = new Date(`${startDate}T00:00:00Z`);
if (Number.isNaN(d.getTime()) || count < 1) return null;
if (period === "day") d.setUTCDate(d.getUTCDate() + count);
else if (period === "week") d.setUTCDate(d.getUTCDate() + count * 7);
else {
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + count);
if (d.getUTCDate() < day) d.setUTCDate(0); // clamp month-overflow
}
return d.toISOString().slice(0, 10);
}
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),
// EDIT-only correction: send the version ONLY when an admin picked a different one
// (same plan, different timeframes). Server gates it on subscription:plan.
...(!isNew && f.planVersionId && f.planVersionId !== f.origPlanVersionId
? { planVersionId: f.planVersionId }
: {}),
};
}
const PERIOD_KEY: Record<SubscriptionPlan["period"], string> = {
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();
}
/** "HH:MM" from minutes-of-day. */
function hhmm(min: number): string {
return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
}
/** Day-of-week shorthand for a version label: "çdo ditë" when all 7 (or none), else the
* Mon-first short names (tariff.dow* keys, e.g. "Hën–Pre"). */
function daysLabel(days: number[] | undefined, t: (k: string) => string): string {
const set = days && days.length > 0 ? days : [0, 1, 2, 3, 4, 5, 6];
if (set.length === 7) return t("subs.everyDay");
const order = [1, 2, 3, 4, 5, 6, 0];
return order.filter((d) => set.includes(d)).map((d) => t(`tariff.dow${d}`)).join(", ");
}
/** A one-line label for a plan VERSION in the correction picker: effective date + its
* timeframe summary (or "24/7" when the version has no window). */
function versionLabel(v: SubscriptionPlan, t: (k: string) => string): string {
const eff = new Date(v.effectiveFrom);
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : eff.toLocaleString();
const tf = v.timeframes;
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
return `${date} · ${rules}`;
}
export function SubscriptionManager({ user }: { user: SessionUser | null }) {
const { t } = useTranslation();
// Correcting which plan VERSION a sold sub is on is a plan-management action (changes
// its access rules), so it's gated on subscription:plan, not routine subscription:update.
const canChangeVersion = can(user, "subscription:plan" as Permission);
const [subs, setSubs] = useState<Subscription[] | null>(null);
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
// ALL plan versions (history) — only needed to populate the admin version-correction
// picker on edit; the sale form uses the active-only `plans` above.
const [allPlanVersions, setAllPlanVersions] = useState<SubscriptionPlan[]>([]);
const [quote, setQuote] = useState<SubscriptionQuote | null>(null);
const [quoting, setQuoting] = useState(false);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(() => 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<ReaderInfo[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | 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" */
});
// Admins can correct a sub's version — load EVERY version (history) for that picker.
if (canChangeVersion) {
fetchSubscriptionPlans(true)
.then((r) => setAllPlanVersions(r.plans))
.catch(() => {
/* non-fatal — the version picker just won't populate */
});
}
}, [canChangeVersion]);
// The currently-selected plan (for its period, to drive the count → end-date math).
const selectedPlan = plans.find((p) => p.planId === form.planId.trim()) ?? null;
// COUNT → END DATE. When the operator types "how many periods" (e.g. 3 months), derive
// validTo = validFrom + count × plan period. Keeps the common "renew for N" case to a
// single number while the end-date field stays directly editable (the hotel case).
useEffect(() => {
if (editing !== "new" || !selectedPlan || !form.validFrom.trim()) return;
const n = Math.round(Number(form.count));
if (!Number.isFinite(n) || n < 1) return;
const end = addPeriods(form.validFrom, selectedPlan.period, n);
if (end && end !== form.validTo) setForm((f) => ({ ...f, validTo: end }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing, form.planId, form.validFrom, form.count]);
// 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<SubscriptionCredential>) {
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 (
<section className="px-4 py-6">
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
<ul className="mb-3 list-none p-0">
{subs.map((s) => (
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[12px]">
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
<span className="text-term-muted">
{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 })}
</span>
<span className="flex-1" />
{/* Print code — only when the subscription has a QR credential to encode. */}
{s.credentials.some((c) => c.kind === "qr") && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
{s.status !== "revoked" && <button type="button" className="btn btn-ghost btn-sm" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
<button type="button" className="btn btn-danger btn-sm" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
</li>
))}
{subs.length === 0 && <li className="py-2 text-term-muted">{t("subs.noneYet")}</li>}
</ul>
<button type="button" className="btn btn-go btn-sm" onClick={startNew}>{t("subs.add")}</button>
<Modal
open={editing != null}
onClose={() => setEditing(null)}
title={editing === "new" ? t("subs.new") : t("subs.editTitle")}
width="max-w-2xl"
>
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("subs.holderName")}</label>
<input className="input" value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label className="label">{t("subs.contact")}</label>
<input className="input" value={form.contact} onChange={(e) => 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" ? (
<>
<label className="label">{t("subs.plan")}</label>
<span className="flex flex-wrap items-center gap-2">
<select
className="select input w-auto"
value={form.planId}
onChange={(e) => setForm((f) => ({ ...f, planId: e.target.value }))}
>
<option value="">{t("subs.planNone")}</option>
{plans.map((p) => (
<option key={p.planId} value={p.planId}>
{p.name} — {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
</option>
))}
</select>
{plans.length === 0 && <span className="text-[12px] text-term-amber">{t("subs.planNoneAvail")}</span>}
</span>
</>
) : (
<>
<label className="label">{t("subs.plan")}</label>
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
{/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may
move the sub to a different VERSION of that same plan (e.g. one with
different timeframes). Price stays as billed. Only shown when the sub has
a plan AND there's more than one version of it. */}
{canChangeVersion && form.planId.trim() !== "" && (() => {
const versions = allPlanVersions
.filter((v) => v.planId === form.planId.trim())
.sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom));
// Include the sub's current version even if it's been retired/superseded
// off the list, so the dropdown always shows where it stands.
if (!versions.some((v) => v.id === form.planVersionId) && form.planVersionId) {
const cur = allPlanVersions.find((v) => v.id === form.planVersionId);
if (cur) versions.unshift(cur);
}
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) {
return <span className="text-[12px] text-term-muted">{t("subs.versionOnlyOne")}</span>;
}
return (
<>
<label className="label">{t("subs.version")}</label>
<span className="flex flex-col gap-1">
<select
className="select input w-auto"
value={form.planVersionId}
onChange={(e) => setForm((f) => ({ ...f, planVersionId: e.target.value }))}
>
{versions.map((v) => (
<option key={v.id} value={v.id}>
{versionLabel(v, t)}
{v.id === form.origPlanVersionId ? ` — ${t("subs.versionCurrent")}` : ""}
</option>
))}
</select>
<span className="text-[12px] text-term-muted">{t("subs.versionHint")}</span>
</span>
</>
);
})()}
</>
)}
{/* 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" && (
<>
<label className="label">{t("subs.quantity")}</label>
<span className="flex flex-wrap items-center gap-2">
<input
className="input w-16"
value={form.quantity}
inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
/>
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span>
</span>
</>
)}
{/* 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" && (
<>
<label className="label">{t("subs.tender")}</label>
<span className="flex items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
<input
type="radio"
name="tender"
className="accent-term-amber"
checked={form.tender === "cash"}
onChange={() => setForm((f) => ({ ...f, tender: "cash" }))}
/>
{t("subs.tenderCash")}
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
<input
type="radio"
name="tender"
className="accent-term-amber"
checked={form.tender === "card"}
onChange={() => setForm((f) => ({ ...f, tender: "card" }))}
/>
{t("subs.tenderCard")}
</label>
<span className="text-[12px] text-term-muted">{t("subs.tenderHint")}</span>
</span>
</>
)}
<label className="label">{t("subs.carLimit")}</label>
<span className="flex items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
</label>
{form.carBound && (
<input className="input w-16" value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} />
)}
</span>
<label className="label">{t("subs.validFrom")}</label>
<input type="date" className="input w-44" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
{/* HOW MANY periods (e.g. 3 months) — the common "renew for N" case. Drives the
end date below; for an irregular span the operator can edit the end directly. */}
{editing === "new" && selectedPlan && (
<>
<label className="label">{t("subs.count")}</label>
<span className="flex flex-wrap items-center gap-2">
<input
className="input w-16"
value={form.count}
inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
/>
<span className="text-[12px] text-term-muted">
× {t(PERIOD_KEY[selectedPlan.period])}
</span>
</span>
</>
)}
<label className="label">{t("subs.validToEnd")}</label>
<span className="flex flex-wrap items-center gap-2">
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => 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() !== "" && (
<span className="text-[12px] text-term-cyan">
{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")}
</span>
)}
</span>
<label className="label">{t("subs.boundPlates")}</label>
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div>
<h4 className="mt-4 mb-1 text-[12px] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
{form.credentials.map((c, i) => (
<div key={i} className="mb-1.5 flex items-center gap-2">
{/* Operator chooses the credential type: QR (auto-generated) or RFID
(read off a card via "Read card"). */}
<select className="select input-sm w-auto" value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="qr">{t("subs.qr")}</option>
<option value="rf">{t("subs.rfCardTag")}</option>
</select>
{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() ? (
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
) : (
<span className="flex-1 self-center text-[12px] italic text-term-muted">{t("subs.qrAutoGen")}</span>
)
) : (
// RFID: the value is read off a physical card (or typed). "Read card"
// arms a chosen reader and fills the captured value.
<input className="input input-sm flex-1" value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} />
)}
{c.kind === "rf" && (
<button type="button" className="btn btn-sm" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div>
))}
<button type="button" className="btn btn-sm" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
{/* Capture panel: pick a reader, present the card; the captured value fills
the credential. The OTHER reader keeps serving the live flow. */}
{capture && (
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[12px]">
{capture.phase === "pick" ? (
<>
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
<div className="flex flex-wrap gap-2">
{readers.length === 0 && <span className="text-term-red">{t("subs.captureNoReaders")}</span>}
{readers.map((r) => (
<button key={r.id} type="button" className="btn btn-pay btn-sm" onClick={() => pickReader(r.id)}>
{t(`devices.role.${r.direction}`)} ({r.driverId})
</button>
))}
<button type="button" className="btn btn-ghost btn-sm" onClick={stopCapture}>{t("subs.cancel")}</button>
</div>
</>
) : (
<div className="flex items-center gap-3">
<span className="text-term-text">{capture.status ?? t("subs.captureWaiting")}</span>
<button type="button" className="btn btn-ghost btn-sm" onClick={stopCapture}>{t("subs.cancel")}</button>
</div>
)}
</div>
)}
<p className="hint mt-3">{t("subs.needCredentialOrPlate")}</p>
<div className="mt-4 flex items-center gap-2">
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("subs.save")}</button>
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
</div>
</Modal>
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[12px] text-term-green" : "mt-3 text-[12px] text-term-red"}>{msg.text}</p>}
</section>
);
}