5e1a885dcb
Dates were a mix: catalog-formatted "25 Qershor 20:01" where screens used
formatRelativeDateTime, and browser-locale "7/6/2026, 9:34 AM" in ~20
places that called raw toLocaleString/-Date-/-Time-String. Unified:
- common.monthsShort in both catalogs (Jan/Shk/…/Qer/Korr/…/Dhj);
formatDate ("25 Qer", year only when not current), formatDateTime
("25 Qer 14:30", optional seconds), formatClock ("HH:mm", 24h) in
lib/format.ts. formatRelativeDateTime keeps Sot/Dje and switches its
older-dates branch to the same short months.
- Every raw toLocale* DATE call swept: shifts X-report line, plan
effective dates, sub version labels, drawer today feed, snapshot
tooltips, device footer checkedAt, event-detail timestamp (keeps
seconds — chain evidence), tariff composer active-since + version
sidebar. Number toLocaleString (thousand separators) untouched.
The catalogs in this commit also carry the keys for the two follow-up
commits (fee breakdown, composer increment labels).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
422 lines
19 KiB
TypeScript
422 lines
19 KiB
TypeScript
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<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")}: {formatDate(p.effectiveFrom, t)}</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" />
|
||
<select className="input w-auto" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })}>
|
||
{currencyOptions(form.currency).map((c) => (
|
||
<option key={c} value={c}>{c}</option>
|
||
))}
|
||
</select>
|
||
<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>
|
||
);
|
||
}
|