diff --git a/apps/web/src/SubscriptionPlansManager.tsx b/apps/web/src/SubscriptionPlansManager.tsx index 4b7303c..3f83209 100644 --- a/apps/web/src/SubscriptionPlansManager.tsx +++ b/apps/web/src/SubscriptionPlansManager.tsx @@ -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 = { month: "subs.perMonth", }; +const STATUS_KEY: Record = { + 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(null); + const [subs, setSubs] = useState([]); + const [expanded, setExpanded] = useState(null); // planId whose subscribers are shown const [form, setForm] = useState(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() { {t("plans.colName")} {t("plans.colPrice")} + {t("plans.colHours")} + {t("plans.colUsedBy")} {t("plans.colEffective")} @@ -188,30 +232,72 @@ export function SubscriptionPlansManager() { {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 ( - - - {p.name} - {isCurrent && {t("plans.inForce")}} - {!p.active && {t("plans.retired")}} - - - {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])} - - {new Date(p.effectiveFrom).toLocaleDateString()} - - {isCurrent && ( - <> - - - - )} - - + + + + {p.name} + {isCurrent && {t("plans.inForce")}} + {!p.active && {t("plans.retired")}} + + + {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])} + + {timeframesSummary(p.timeframes, t)} + + {isCurrent ? ( + users.length > 0 ? ( + + ) : ( + {t("plans.usedByNone")} + ) + ) : ( + — + )} + + {new Date(p.effectiveFrom).toLocaleDateString()} + + {isCurrent && ( + <> + + + + )} + + + {isOpen && users.length > 0 && ( + + +
{t("plans.subscribers")}
+
    + {users.map((s) => ( +
  • + {s.holderName || t("subs.unnamed")} + {s.quantity > 1 && ×{s.quantity}} + {s.status !== "active" && ({t(STATUS_KEY[s.status])})} +
  • + ))} +
+ + + )} +
); })} diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 1c74c1c..ec1c4a4 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 6a79541..e5f6c71 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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",