feat(plans): show hours, period/currency, and subscriber dependencies in the plan list

Deleting versioned plans is unsafe (a plan version referenced by a subscription's
planVersionId must survive for reproducible repricing/audit) — so instead of
delete, give the admin the VISIBILITY they actually needed:

- Hours column: a compact timeframes summary ("Hën–Pre 21:00–08:00" / "24/7"),
  so two same-priced plans are distinguishable at a glance.
- Period + currency are already in the price cell; the hours column removes the
  remaining ambiguity between night/day plans.
- "Used by" column: a count of subscriptions on each (current) plan (active /
  total), expandable to the holder names — so you can see what depends on a plan
  before retiring or replacing it. Computed client-side from the existing
  subscriptions list (both screens are admin-grade; no new endpoint).

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 20:14:30 +02:00
parent c64457020f
commit 488dcb5e4e
3 changed files with 124 additions and 24 deletions
+88 -2
View File
@@ -1,10 +1,13 @@
import { useEffect, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createSubscriptionPlan,
fetchSubscriptionPlans,
fetchSubscriptions,
retireSubscriptionPlan,
type PlanTimeframes,
type Subscription,
type SubscriptionPeriod,
type SubscriptionPlan,
} from "./api.js";
@@ -23,6 +26,32 @@ const PERIOD_KEY: Record<SubscriptionPeriod, string> = {
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];
@@ -73,6 +102,8 @@ function minToHHMM(min: number): string {
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);
@@ -81,9 +112,20 @@ export function SubscriptionPlansManager() {
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);
@@ -181,6 +223,8 @@ export function SubscriptionPlansManager() {
<tr>
<th className="py-1">{t("plans.colName")}</th>
<th className="py-1">{t("plans.colPrice")}</th>
<th className="py-1">{t("plans.colHours")}</th>
<th className="py-1">{t("plans.colUsedBy")}</th>
<th className="py-1">{t("plans.colEffective")}</th>
<th className="py-1" />
</tr>
@@ -188,8 +232,14 @@ export function SubscriptionPlansManager() {
<tbody>
{plans.map((p) => {
const isCurrent = currentVersionId.get(p.planId) === p.id;
// Count subscribers only against the CURRENT row of each planId (the list
// shows all versions; we don't want to double-count per version).
const users = isCurrent ? subscribersOf(p.planId) : [];
const activeUsers = users.filter((s) => s.status === "active");
const isOpen = expanded === p.planId;
return (
<tr key={p.id} className="border-t border-term-border">
<Fragment key={p.id}>
<tr 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>}
@@ -198,6 +248,25 @@ export function SubscriptionPlansManager() {
<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">{timeframesSummary(p.timeframes, t)}</td>
<td className="py-1.5">
{isCurrent ? (
users.length > 0 ? (
<button
type="button"
className="text-term-cyan hover:underline tabular-nums"
onClick={() => setExpanded(isOpen ? null : p.planId)}
title={t("plans.usedByTitle")}
>
{t("plans.usedByCount", { active: activeUsers.length, total: users.length })} {isOpen ? "▾" : "▸"}
</button>
) : (
<span className="text-term-muted">{t("plans.usedByNone")}</span>
)
) : (
<span className="text-term-muted">—</span>
)}
</td>
<td className="py-1.5 text-term-muted">{new Date(p.effectiveFrom).toLocaleDateString()}</td>
<td className="py-1.5 text-right">
{isCurrent && (
@@ -212,6 +281,23 @@ export function SubscriptionPlansManager() {
)}
</td>
</tr>
{isOpen && users.length > 0 && (
<tr className="bg-term-bg">
<td colSpan={6} className="px-3 py-2">
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("plans.subscribers")}</div>
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 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>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
+7
View File
@@ -454,7 +454,14 @@ export const en: Catalog = {
noneYet: "No plans yet. Add one so the booth can sell subscriptions.",
colName: "Name",
colPrice: "Price",
colHours: "Hours",
colUsedBy: "Used by",
colEffective: "Effective",
allHours: "24/7",
usedByCount: "{{active}} active / {{total}}",
usedByNone: "none",
usedByTitle: "Show the subscriptions on this plan",
subscribers: "Subscribers",
inForce: "in force",
retired: "retired",
newVersion: "New version",
+7
View File
@@ -465,7 +465,14 @@ export const sq = {
noneYet: "Asnjë plan ende. Shto një që kabina të shesë abonime.",
colName: "Emri",
colPrice: "Çmimi",
colHours: "Orari",
colUsedBy: "Përdorur nga",
colEffective: "Vlen nga",
allHours: "24/7",
usedByCount: "{{active}} aktive / {{total}}",
usedByNone: "asnjë",
usedByTitle: "Shfaq abonimet në këtë plan",
subscribers: "Abonentët",
inForce: "në fuqi",
retired: "i tërhequr",
newVersion: "Version i ri",