Files
parking_solution/apps/web/src/SubscriptionPlansManager.tsx
T
julian e0e218fa61 refactor: plan timeframes use a per-day-of-week picker (like the V2 tariff)
The timeframes model was a coarse weekday/weekend split, which couldn't express
"open Saturdays" or different rules on a specific day — and it didn't match the
V2 tariff, which already has a proper per-day-of-week picker (Hën–Die).

Replace PlanTimeframes { weekday, weekend } with { days[], fromMin, toMin }: the
allowed window applies only on the selected days (0=Sun..6=Sat; empty = every
day); on unselected days the subscriber parks free. A "night plan, free
weekends" is just days [Mon..Fri] with a 20:00→08:00 window — the exact case
from before, now expressible alongside any other day combination.

outOfWindowGap reworked to the days model (per-day membership test instead of
the weekend helper); the plans editor reuses the tariff composer's Mon-first
checkbox row and the shared tariff.dow0..6 labels. No production plans carry
timeframes yet (feature shipped today), so the shape changed directly with no
migration. Unit tests updated + extended (Saturday-only, every-day, weekday
night); 81 shared tests pass. Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:43:21 +02:00

301 lines
14 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, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createSubscriptionPlan,
fetchSubscriptionPlans,
retireSubscriptionPlan,
type SubscriptionPeriod,
type SubscriptionPlan,
} from "./api.js";
import { Modal } from "./ui/Modal.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<SubscriptionPeriod, string> = {
day: "subs.perDay",
week: "subs.perWeek",
month: "subs.perMonth",
};
// 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<SubscriptionPlan[] | null>(null);
const [form, setForm] = useState<PlanForm | null>(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 }));
}
useEffect(reload, []);
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<typeof createSubscriptionPlan>[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();
}
/** 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;
// The CURRENT (latest active) version per planId, for the "in force" badge.
const now = new Date().toISOString();
const currentVersionId = new Map<string, string>();
for (const p of plans) {
if (p.active && p.effectiveFrom <= now && !currentVersionId.has(p.planId)) {
currentVersionId.set(p.planId, p.id); // plans come newest-first
}
}
return (
<section className="mx-auto max-w-3xl px-4 py-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
{t("plans.add")}
</button>
</div>
<p className="mb-3 text-[12px] text-term-muted">{t("plans.intro")}</p>
{msg && (
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
)}
{plans.length === 0 ? (
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
) : (
<table className="w-full text-left text-[13px]">
<thead className="text-[11px] uppercase tracking-wider text-term-muted">
<tr>
<th className="py-1">{t("plans.colName")}</th>
<th className="py-1">{t("plans.colPrice")}</th>
<th className="py-1">{t("plans.colEffective")}</th>
<th className="py-1" />
</tr>
</thead>
<tbody>
{plans.map((p) => {
const isCurrent = currentVersionId.get(p.planId) === p.id;
return (
<tr key={p.id} className="border-t border-term-border">
<td className="py-1.5">
{p.name}
{isCurrent && <span className="ml-2 rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>}
{!p.active && <span className="ml-2 text-[10px] text-term-muted">{t("plans.retired")}</span>}
</td>
<td className="py-1.5 tabular-nums">
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
</td>
<td className="py-1.5 text-term-muted">{new Date(p.effectiveFrom).toLocaleDateString()}</td>
<td className="py-1.5 text-right">
{isCurrent && (
<>
<button type="button" className="btn btn-sm" onClick={() => newVersionOf(p)}>
{t("plans.newVersion")}
</button>
<button type="button" className="btn btn-sm btn-danger ml-1" onClick={() => retire(p)}>
{t("plans.retire")}
</button>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
<Modal open={form != null} onClose={() => setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg">
{form && (
<>
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("plans.colName")}</label>
<input className="input" value={form.name} onChange={(e) => setForm((f) => f && { ...f, name: e.target.value })} placeholder={t("plans.namePlaceholder")} />
<label className="label">{t("plans.period")}</label>
<select className="select input w-auto" value={form.period} onChange={(e) => setForm((f) => f && { ...f, period: e.target.value as SubscriptionPeriod })}>
{PERIODS.map((p) => (
<option key={p} value={p}>{t(PERIOD_KEY[p])}</option>
))}
</select>
<label className="label">{t("plans.pricePer")}</label>
<span className="flex items-center gap-2">
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
</span>
</div>
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
window they're charged the transient tariff for the gap. Off = 24/7. */}
<div className="mt-3 border-t border-term-border pt-3">
<label className="flex items-center gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={form.restrictTimes}
onChange={(e) => setForm((f) => f && { ...f, restrictTimes: e.target.checked })}
/>
{t("plans.restrictTimes")}
</label>
{form.restrictTimes && (
<div className="mt-2 grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("plans.days")}</label>
<span className="flex flex-wrap gap-2">
{DOW_ORDER.map((d) => (
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={form.days.includes(d)}
onChange={() =>
setForm((f) =>
f && { ...f, days: f.days.includes(d) ? f.days.filter((x) => x !== d) : [...f.days, d] },
)
}
/>
{t(`tariff.dow${d}`)}
</label>
))}
</span>
<label className="label">{t("plans.window")}</label>
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted">
{t("plans.enterAfter")}
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
{t("plans.exitBefore")}
<input type="time" className="input w-28" value={form.winTo} onChange={(e) => setForm((f) => f && { ...f, winTo: e.target.value })} />
</span>
<label className="label">{t("plans.grace")}</label>
<span className="flex items-center gap-2">
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span>
</span>
</div>
)}
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p>
</div>
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
<div className="mt-4 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
</div>
</>
)}
</Modal>
</section>
);
}