018328a877
No card processor / POS terminal on any site yet. Offering "Card" would let an operator record a card payment that never cleared a terminal, corrupting the till reconciliation — a fraud/error surface on an operator-adversary system. Add apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false, gating both tender pickers (BoothPayModal, SubscriptionManager). With card off there's nothing to choose, so the tender row is suppressed and payment defaults to cash. UI-only gate: the Tender type, payment events, shift accounting, and reports still understand `card`, so historical card events and a future re-enable stay coherent. Verified via Playwright: an unpaid-ticket modal shows Total + "Pay + open barrier" with no tender/cash/card row. Wiki: new concepts/card-payments.md records the current cash-only state, the PCI-scope-out-of-app constraint, the future-POS device requirements, and the re-enable path (flip the flag once a bank-certified P2PE terminal is provisioned). Linked from index, parking-session, open-questions #3. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
693 lines
33 KiB
TypeScript
693 lines
33 KiB
TypeScript
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 { CARD_PAYMENTS_ENABLED } from "./lib/features.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-[0.75rem]">
|
||
<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-[0.75rem] text-term-amber">{t("subs.planNoneAvail")}</span>}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<label className="label">{t("subs.plan")}</label>
|
||
<span className="text-[0.8125rem] 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)) {
|
||
// Keep the 2-col grid flow intact: a lone cell here would shift every
|
||
// following row by one column (label↔input swap). Emit a full row —
|
||
// the version label + the "only one version" hint as its control.
|
||
return (
|
||
<>
|
||
<label className="label">{t("subs.version")}</label>
|
||
<span className="text-[0.75rem] 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-[0.75rem] 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-[0.75rem] 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. */}
|
||
{/* Tender picker — only meaningful when there's a choice. Card is hidden until a
|
||
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED); with cash-only there's
|
||
nothing to pick, so the whole row is suppressed (form.tender stays "cash").
|
||
See lib/features.ts + wiki/concepts/card-payments.md. */}
|
||
{form.planId.trim() !== "" && editing === "new" && CARD_PAYMENTS_ENABLED && (
|
||
<>
|
||
<label className="label">{t("subs.tender")}</label>
|
||
<span className="flex items-center gap-3">
|
||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] 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-[0.75rem] 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-[0.75rem] 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-[0.75rem] 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-[0.75rem] 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-[0.75rem] 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-[0.75rem] 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-[0.75rem] 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-[0.75rem]">
|
||
{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-[0.75rem] text-term-green" : "mt-3 text-[0.75rem] text-term-red"}>{msg.text}</p>}
|
||
</section>
|
||
);
|
||
}
|