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:
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
createSubscriptionPlan,
|
createSubscriptionPlan,
|
||||||
fetchSubscriptionPlans,
|
fetchSubscriptionPlans,
|
||||||
|
fetchSubscriptions,
|
||||||
retireSubscriptionPlan,
|
retireSubscriptionPlan,
|
||||||
|
type PlanTimeframes,
|
||||||
|
type Subscription,
|
||||||
type SubscriptionPeriod,
|
type SubscriptionPeriod,
|
||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
@@ -23,6 +26,32 @@ const PERIOD_KEY: Record<SubscriptionPeriod, string> = {
|
|||||||
month: "subs.perMonth",
|
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
|
// 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).
|
// shared tariff.dow0..6 i18n keys (Hën..Die / Mon..Sun).
|
||||||
const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0];
|
const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0];
|
||||||
@@ -73,6 +102,8 @@ function minToHHMM(min: number): string {
|
|||||||
export function SubscriptionPlansManager() {
|
export function SubscriptionPlansManager() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [plans, setPlans] = useState<SubscriptionPlan[] | null>(null);
|
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 [form, setForm] = useState<PlanForm | null>(null);
|
||||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
|
|
||||||
@@ -81,9 +112,20 @@ export function SubscriptionPlansManager() {
|
|||||||
fetchSubscriptionPlans(true)
|
fetchSubscriptionPlans(true)
|
||||||
.then((r) => setPlans(r.plans))
|
.then((r) => setPlans(r.plans))
|
||||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
.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, []);
|
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() {
|
async function save() {
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
@@ -181,6 +223,8 @@ export function SubscriptionPlansManager() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="py-1">{t("plans.colName")}</th>
|
<th className="py-1">{t("plans.colName")}</th>
|
||||||
<th className="py-1">{t("plans.colPrice")}</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">{t("plans.colEffective")}</th>
|
||||||
<th className="py-1" />
|
<th className="py-1" />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -188,8 +232,14 @@ export function SubscriptionPlansManager() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{plans.map((p) => {
|
{plans.map((p) => {
|
||||||
const isCurrent = currentVersionId.get(p.planId) === p.id;
|
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 (
|
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">
|
<td className="py-1.5">
|
||||||
{p.name}
|
{p.name}
|
||||||
{isCurrent && <span className="ml-2 rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>}
|
{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">
|
<td className="py-1.5 tabular-nums">
|
||||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||||
</td>
|
</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-term-muted">{new Date(p.effectiveFrom).toLocaleDateString()}</td>
|
||||||
<td className="py-1.5 text-right">
|
<td className="py-1.5 text-right">
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
@@ -212,6 +281,23 @@ export function SubscriptionPlansManager() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
|
|||||||
@@ -454,7 +454,14 @@ export const en: Catalog = {
|
|||||||
noneYet: "No plans yet. Add one so the booth can sell subscriptions.",
|
noneYet: "No plans yet. Add one so the booth can sell subscriptions.",
|
||||||
colName: "Name",
|
colName: "Name",
|
||||||
colPrice: "Price",
|
colPrice: "Price",
|
||||||
|
colHours: "Hours",
|
||||||
|
colUsedBy: "Used by",
|
||||||
colEffective: "Effective",
|
colEffective: "Effective",
|
||||||
|
allHours: "24/7",
|
||||||
|
usedByCount: "{{active}} active / {{total}}",
|
||||||
|
usedByNone: "none",
|
||||||
|
usedByTitle: "Show the subscriptions on this plan",
|
||||||
|
subscribers: "Subscribers",
|
||||||
inForce: "in force",
|
inForce: "in force",
|
||||||
retired: "retired",
|
retired: "retired",
|
||||||
newVersion: "New version",
|
newVersion: "New version",
|
||||||
|
|||||||
@@ -465,7 +465,14 @@ export const sq = {
|
|||||||
noneYet: "Asnjë plan ende. Shto një që kabina të shesë abonime.",
|
noneYet: "Asnjë plan ende. Shto një që kabina të shesë abonime.",
|
||||||
colName: "Emri",
|
colName: "Emri",
|
||||||
colPrice: "Çmimi",
|
colPrice: "Çmimi",
|
||||||
|
colHours: "Orari",
|
||||||
|
colUsedBy: "Përdorur nga",
|
||||||
colEffective: "Vlen 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",
|
inForce: "në fuqi",
|
||||||
retired: "i tërhequr",
|
retired: "i tërhequr",
|
||||||
newVersion: "Version i ri",
|
newVersion: "Version i ri",
|
||||||
|
|||||||
Reference in New Issue
Block a user