import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ApiError, createSubscriptionPlan, deleteSubscriptionPlan, fetchSubscriptionPlans, fetchSubscriptions, reactivateSubscriptionPlan, retireSubscriptionPlan, type PlanTimeframes, type Subscription, type SubscriptionPeriod, type SubscriptionPlan, } from "./api.js"; import { Modal } from "./ui/Modal.js"; import { formatDate } from "./lib/format.js"; import { currencyOptions } from "./lib/currencies.js"; // Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the // operator sells from (so the operator never types a price). Editing a plan PUBLISHES A // NEW VERSION (new effectiveFrom) — past sales keep their recorded version. Retire is // soft (active=0). Mirrors the tariff composer. See wiki/entities/subscription.md. const DEFAULT_CURRENCY = "ALL"; const PERIODS: SubscriptionPeriod[] = ["day", "week", "month"]; const PERIOD_KEY: Record = { day: "subs.perDay", week: "subs.perWeek", month: "subs.perMonth", }; const STATUS_KEY: Record = { active: "subs.statusActive", suspended: "subs.statusSuspended", revoked: "subs.statusRevoked", }; /** minutes-of-day → "HH:MM" for the timeframes summary. */ function fmtMin(min: number): string { return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`; } /** A compact human summary of a plan's timeframes, e.g. "Mon–Fri 21:00–08:00" or "24/7". * Uses the shared tariff.dow labels for day names. */ function timeframesSummary(tf: PlanTimeframes | null | undefined, t: (k: string) => string): string { if (!tf) return t("plans.allHours"); // 24/7 const days = tf.days && tf.days.length > 0 ? tf.days : [0, 1, 2, 3, 4, 5, 6]; // Render selected days Monday-first; collapse to a range label only when contiguous // Mon–Fri / Sat–Sun for the common cases, else list them. const set = new Set(days); const isWeekdays = [1, 2, 3, 4, 5].every((d) => set.has(d)) && ![0, 6].some((d) => set.has(d)); const dayLabel = isWeekdays ? `${t("tariff.dow1")}–${t("tariff.dow5")}` : [1, 2, 3, 4, 5, 6, 0].filter((d) => set.has(d)).map((d) => t(`tariff.dow${d}`)).join(","); return `${dayLabel} ${fmtMin(tf.fromMin)}–${fmtMin(tf.toMin)}`; } // Day-of-week picker, Monday-first (mirrors the tariff composer). Labels come from the // shared tariff.dow0..6 i18n keys (Hën..Die / Mon..Sun). const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0]; interface PlanForm { planId: string; // blank on a brand-new plan; set when publishing a new version name: string; period: SubscriptionPeriod; priceMajor: string; currency: string; // Timeframes (tariff bridge). Off → 24/7. On → an allowed window (enter-after / // exit-before as HH:MM) on the SELECTED days (0=Sun..6=Sat); on unselected days the // subscriber parks free. Plus grace minutes. restrictTimes: boolean; days: number[]; // days the window applies to; empty = every day winFrom: string; // window opens (HH:MM) — when the subscriber may enter winTo: string; // window closes (HH:MM) — by when they should exit graceMin: string; } function emptyForm(): PlanForm { return { planId: "", name: "", period: "month", priceMajor: "", currency: DEFAULT_CURRENCY, restrictTimes: false, days: [1, 2, 3, 4, 5], // default Mon–Fri (the common "night plan, free weekends") winFrom: "20:00", winTo: "08:00", graceMin: "0", }; } /** "HH:MM" → minutes-of-day, or null if blank/invalid. */ function hhmmToMin(s: string): number | null { const m = /^(\d{1,2}):(\d{2})$/.exec(s.trim()); if (!m) return null; const min = Number(m[1]) * 60 + Number(m[2]); return min >= 0 && min <= 1439 ? min : null; } /** minutes-of-day → "HH:MM". */ function minToHHMM(min: number): string { return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`; } export function SubscriptionPlansManager() { const { t } = useTranslation(); const [plans, setPlans] = useState(null); const [subs, setSubs] = useState([]); const [expanded, setExpanded] = useState(null); // planId whose subscribers are shown const [form, setForm] = useState(null); const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); function reload() { // ?all=1 → every version (history), so the admin sees superseded prices too. fetchSubscriptionPlans(true) .then((r) => setPlans(r.plans)) .catch((e) => setMsg({ kind: "err", text: (e as Error).message })); // Subscriptions carry planId — group them to show "who depends on this plan". fetchSubscriptions() .then((r) => setSubs(r.subscriptions)) .catch(() => { /* non-fatal — counts just won't show */ }); } useEffect(reload, []); // Subscribers per planId (active first), for the count badge + expandable list. function subscribersOf(planId: string): Subscription[] { return subs.filter((s) => s.planId === planId); } async function save() { if (!form) return; setMsg(null); const major = Number(form.priceMajor); if (!form.name.trim()) return setMsg({ kind: "err", text: t("plans.needName") }); if (!Number.isFinite(major) || major <= 0) return setMsg({ kind: "err", text: t("plans.needPrice") }); // Build the timeframes blob from the form (null = 24/7). The window [winFrom, winTo) // (wraps midnight for a night plan) applies on the SELECTED days; unselected days are // unrestricted. Empty days = every day. The server stamps the site tz. let timeframes = null as Parameters[0]["timeframes"]; if (form.restrictTimes) { const from = hhmmToMin(form.winFrom); const to = hhmmToMin(form.winTo); if (from == null || to == null) return setMsg({ kind: "err", text: t("plans.needWindow") }); if (form.days.length === 0) return setMsg({ kind: "err", text: t("plans.needDays") }); timeframes = { days: [...form.days].sort((a, b) => a - b), fromMin: from, toMin: to, graceMin: Math.max(0, Math.round(Number(form.graceMin) || 0)), }; } try { await createSubscriptionPlan({ planId: form.planId.trim() || undefined, name: form.name.trim(), period: form.period, pricePerPeriodMinor: Math.round(major * 100), currency: form.currency.trim() || DEFAULT_CURRENCY, timeframes, }); setForm(null); reload(); setMsg({ kind: "ok", text: t("plans.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 retire(p: SubscriptionPlan) { if (!confirm(t("plans.confirmRetire", { name: p.name }))) return; await retireSubscriptionPlan(p.planId).catch((e) => setMsg({ kind: "err", text: (e as Error).message })); reload(); } async function reactivate(p: SubscriptionPlan) { setMsg(null); try { await reactivateSubscriptionPlan(p.planId); setMsg({ kind: "ok", text: t("plans.reactivated", { name: p.name }) }); reload(); } catch (e) { setMsg({ kind: "err", text: (e as Error).message }); } } async function del(p: SubscriptionPlan) { if (!confirm(t("plans.confirmDelete", { name: p.name }))) return; setMsg(null); try { await deleteSubscriptionPlan(p.planId); setMsg({ kind: "ok", text: t("plans.deleted", { name: p.name }) }); reload(); } catch (e) { // 409 → plan is in use; explain why it can't be deleted (retire instead). const inUse = e instanceof ApiError && e.status === 409; setMsg({ kind: "err", text: inUse ? t("plans.deleteInUse") : (e as Error).message }); } } /** Publish a new version of an existing plan (pre-fills its identity + last values). */ function newVersionOf(p: SubscriptionPlan) { const tf = p.timeframes ?? null; setForm({ planId: p.planId, name: p.name, period: p.period, priceMajor: String(p.pricePerPeriodMinor / 100), currency: p.currency, restrictTimes: tf != null, days: tf?.days && tf.days.length > 0 ? [...tf.days] : [1, 2, 3, 4, 5], winFrom: tf?.fromMin != null ? minToHHMM(tf.fromMin) : "20:00", winTo: tf?.toMin != null ? minToHHMM(tf.toMin) : "08:00", graceMin: String(tf?.graceMin ?? 0), }); setMsg(null); } if (!plans) return null; // GROUP versions by planId; the newest version (plans come newest-first) represents the // plan in the list. A planId is "in force" when its versions are active; "retired" // otherwise. One card per planId — avoids the cramped multi-version table. const now = new Date().toISOString(); const groups: { planId: string; head: SubscriptionPlan; active: boolean; versions: number }[] = []; const seen = new Map(); for (const p of plans) { const idx = seen.get(p.planId); if (idx == null) { seen.set(p.planId, groups.length); groups.push({ planId: p.planId, head: p, active: p.active && p.effectiveFrom <= now, versions: 1 }); } else { groups[idx]!.versions += 1; if (p.active && p.effectiveFrom <= now) groups[idx]!.active = true; } } return (

{t("plans.title")}

{t("plans.intro")}

{msg && (
{msg.text}
)} {groups.length === 0 ? (

{t("plans.noneYet")}

) : (
{groups.map(({ planId, head: p, active, versions }) => { const users = subscribersOf(planId); const activeUsers = users.filter((s) => s.status === "active"); const isOpen = expanded === planId; const canDelete = users.length === 0; // no sale references it → safe to delete return (
{/* Header: name + status badge */}
{p.name} {active ? ( {t("plans.inForce")} ) : ( {t("plans.retired")} )} {versions > 1 && {t("plans.versionCount", { count: versions })}}
{/* Details: price · hours · effective */}
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])} {timeframesSummary(p.timeframes, t)} {t("plans.colEffective")}: {formatDate(p.effectiveFrom, t)}
{/* Used by */}
{users.length > 0 ? ( ) : ( {t("plans.colUsedBy")}: {t("plans.usedByNone")} )}
{isOpen && users.length > 0 && (
    {users.map((s) => (
  • {s.holderName || t("subs.unnamed")} {s.quantity > 1 && ×{s.quantity}} {s.status !== "active" && ({t(STATUS_KEY[s.status])})}
  • ))}
)} {/* Actions — own row, never overlapping */}
{active ? ( <> ) : ( )} {canDelete && ( )}
); })}
)} setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg"> {form && ( <>
setForm((f) => f && { ...f, name: e.target.value })} placeholder={t("plans.namePlaceholder")} /> setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" /> / {t(PERIOD_KEY[form.period])}
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the window they're charged the transient tariff for the gap. Off = 24/7. */}
{form.restrictTimes && (
{DOW_ORDER.map((d) => ( ))} {t("plans.enterAfter")} setForm((f) => f && { ...f, winFrom: e.target.value })} /> {t("plans.exitBefore")} setForm((f) => f && { ...f, winTo: e.target.value })} /> setForm((f) => f && { ...f, graceMin: e.target.value })} /> {t("plans.graceHint")}
)}

{t("plans.timeframesHint")}

{form.planId &&

{t("plans.newVersionHint")}

}
)}
); }