Files
parking_solution/apps/web/src/SubscriptionPlansManager.tsx
T
julian cce99aadfd
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s
fix(web): booth UI/UX pass — readable font scaling + booth layout/report clarity
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in f706726, which used CSS
`zoom` — reverted here for the rem approach below.)

Font scaling (the A−/A+ control now actually works without breaking layout):
- The control scaled via CSS `zoom`, which also scaled viewport-locked containers
  (h-screen frame, max-h-[90vh] modals) so at 130% modal headers/footers were
  pushed off-screen. Reworked to scale TEXT only: converted every `text-[Npx]`
  font utility to rem across the web app (~230 sites in 25 files + the
  .label/.hint/.btn component classes + body in index.css; 16px root, so 100% is
  visually identical), and applyFontScale now sets the ROOT font-size. vh/h-screen
  layout stays put, so chrome never clips; tall content scrolls its own container.
  Verified at 130%: text 12px→15.6px while the frame stayed viewport-height.

Live feed (event rows):
- Plate, badges and reason now flow inline after the identity and wrap only when
  the row runs out of width — no more forced second line when there's empty space.
- Dropped the redundant TARGË via-badge (the plate chip already conveys it).
- Removed the Direction filter group (Hyrje/Dalje) — it duplicated the entry/exit
  options already in the Type filter.

Active sessions:
- Rebuilt as a real table (Ticket/subscriber · Plate · Entry · Elapsed) so columns
  align and long values (subscriber names, ticket ids) no longer truncate.
- Dropped the status column (an unpaid transient is normal; a subscriber shows ★ +
  name; overstay keeps a row tint). Removed the now-redundant status filter; only
  the Transient/Subscriber filter remains. Plate is now searchable (uses s.plate).

Shift report (close-shift modal + Shift History + printed Z-report slip):
- Removed the confusing `shitje` (subscription-sales) sub-line — Abonime is the
  total; only the out-of-window part is broken out. subscriptionSalesMinor stays in
  the signed payload (audit data), just not displayed/printed.
- Show the inherited opening cash ("Arka fillestare") above the expected drawer, so
  opening + cash-taken = expected reads clearly. Money values no longer line-wrap.

Subscription edit modal:
- Fixed the 2-col grid alignment: a lone "only one version" cell was shifting every
  following row by one column — it now emits a full label+value pair.

Removed orphaned i18n keys (fStatus*, fDir*, srcSubSales) from sq+en (parity kept).
Full workspace build/lint/test green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 15:15:09 +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-[0.8125rem] 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-[0.75rem] text-term-muted">{t("plans.intro")}</p>
{msg && (
<div className={`mb-3 text-[0.75rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
)}
{groups.length === 0 ? (
<p className="text-[0.8125rem] 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-[0.625rem] text-term-green">{t("plans.inForce")}</span>
) : (
<span className="rounded border border-term-border px-1 text-[0.625rem] text-term-muted">{t("plans.retired")}</span>
)}
{versions > 1 && <span className="text-[0.625rem] 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-[0.75rem] 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-[0.75rem]">
{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-[0.75rem]">
{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-[0.625rem]">({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-[0.75rem] 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-[0.75rem] 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-[0.75rem] 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-[0.75rem] 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-[0.75rem] text-term-muted">{t("plans.graceHint")}</span>
</span>
</div>
)}
<p className="mt-1.5 text-[0.6875rem] text-term-muted">{t("plans.timeframesHint")}</p>
</div>
{form.planId && <p className="mt-2 text-[0.6875rem] 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>
);
}