Files
parking_solution/apps/web/src/SubscriptionPlansManager.tsx
T
julian ae736a9e3e feat(shift): current shift in the list + modal actions; full-width layout everywhere
Shift screen:
- The standalone ShiftControl block is gone from /shift. The open/CURRENT shift now
  appears at the TOP of the shift list (CURRENT badge, live figures synthesized from
  the X-report), unified with history. Selecting it shows its live activity log.
- Shift ACTIONS moved into the current shift's detail pane, each opening a MODAL:
  End shift (confirm → signed Z-report result), drawer voucher (Mandat in/out),
  takings-so-far (X-report). When no shift is open, a Start-shift button shows.
- The current shift's log auto-refreshes (5s); a closed shift is bounded by its
  window. /setup/shifts stays read-only history (no manage props). Deleted the now-
  orphaned ShiftControl.tsx.

Layout:
- Every screen is now full-width like /booth — stripped the per-screen
  `mx-auto max-w-*` caps (Logs, Subscriptions, Plans, Tariff, Users, Roles, Setup
  layout, Shifts). The shell <main> already provides padding.

Build+lint 12/12 (i18n parity). Verified a live open shift surfaces as the CURRENT
list entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 23:44:27 +02:00

416 lines
19 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,
deleteSubscriptionPlan,
fetchSubscriptionPlans,
fetchSubscriptions,
reactivateSubscriptionPlan,
retireSubscriptionPlan,
type PlanTimeframes,
type Subscription,
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",
};
const STATUS_KEY: Record<Subscription["status"], string> = {
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<SubscriptionPlan[] | null>(null);
const [subs, setSubs] = useState<Subscription[]>([]);
const [expanded, setExpanded] = useState<string | null>(null); // planId whose subscribers are shown
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 }));
// 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<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();
}
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<string, number>();
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 (
<section className="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>
)}
{groups.length === 0 ? (
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
) : (
<div className="flex flex-col gap-2">
{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 (
<div key={planId} className={`card p-3 ${active ? "" : "opacity-70"}`}>
{/* Header: name + status badge */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-semibold text-term-text">{p.name}</span>
{active ? (
<span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>
) : (
<span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span>
)}
{versions > 1 && <span className="text-[10px] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
</div>
{/* Details: price · hours · effective */}
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] text-term-muted">
<span className="tabular-nums text-term-text">
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
</span>
<span>{timeframesSummary(p.timeframes, t)}</span>
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
</div>
{/* Used by */}
<div className="mt-1 text-[12px]">
{users.length > 0 ? (
<button
type="button"
className="text-term-cyan hover:underline tabular-nums"
onClick={() => setExpanded(isOpen ? null : planId)}
title={t("plans.usedByTitle")}
>
{t("plans.colUsedBy")}: {t("plans.usedByCount", { active: activeUsers.length, total: users.length })} {isOpen ? "▾" : "▸"}
</button>
) : (
<span className="text-term-muted">{t("plans.colUsedBy")}: {t("plans.usedByNone")}</span>
)}
</div>
{isOpen && users.length > 0 && (
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[12px]">
{users.map((s) => (
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
{s.holderName || t("subs.unnamed")}
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
{s.status !== "active" && <span className="ml-1 text-[10px]">({t(STATUS_KEY[s.status])})</span>}
</li>
))}
</ul>
)}
{/* Actions — own row, never overlapping */}
<div className="mt-2 flex flex-wrap gap-1.5 border-t border-term-border pt-2">
{active ? (
<>
<button type="button" className="btn btn-sm" onClick={() => newVersionOf(p)}>{t("plans.newVersion")}</button>
<button type="button" className="btn btn-sm btn-danger" onClick={() => retire(p)}>{t("plans.retire")}</button>
</>
) : (
<button type="button" className="btn btn-go btn-sm" onClick={() => reactivate(p)}>{t("plans.reactivate")}</button>
)}
{canDelete && (
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => del(p)}
title={t("plans.deleteTitle")}
>
{t("plans.delete")}
</button>
)}
</div>
</div>
);
})}
</div>
)}
<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>
);
}