feat(plans): reactivate + delete-when-unused; card layout fixes overlap

Addresses three issues with the plan catalog screen:

1. Retired plans had NO actions (the action cell was gated on "current
   version", which a retired plan lacks) — so there was no way to make one
   in-force again. Add POST /:planId/reactivate (inverse of retire) + a
   Reactivate button on retired plans.

2. No delete. Add DELETE /:planId, allowed ONLY when zero subscriptions
   reference the planId (any version) — a referenced plan version must survive
   for reproducible repricing/audit, so an in-use delete returns 409 and the UI
   says "retire it instead". The Delete button only shows when the plan has 0
   subscribers.

3. The 6-column table overflowed max-w-3xl: action buttons overlapped and the
   status badges wrapped to a second line. Replace it with a CARD list (one card
   per planId, grouped across versions): name + status on top, price · hours ·
   effective on a wrap row, "used by N" expandable to holder names, and actions
   on their own bordered row — nothing overlaps, badges stay inline.

Build+lint 12/12 (i18n parity). Verified on a DB copy: unused plans report
deletable; retire→reactivate flips active back.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 20:27:36 +02:00
parent 488dcb5e4e
commit 36f30d39ff
5 changed files with 179 additions and 92 deletions
+37 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, eq, subscriptionPlans, type Db } from "@parking/db"; import { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db";
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared"; import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requirePermission } from "../auth.js";
import { siteTz } from "../subscription-window.js"; import { siteTz } from "../subscription-window.js";
@@ -124,7 +124,7 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
}); });
// Retire a plan (soft): mark every version of this planId inactive so it's no longer // Retire a plan (soft): mark every version of this planId inactive so it's no longer
// sellable. History (and past sales' planVersionId) is preserved. Re-publish to revive. // sellable. History (and past sales' planVersionId) is preserved. Reactivate to revive.
app.post<{ Params: { planId: string } }>( app.post<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId/retire", "/api/subscription-plans/:planId/retire",
{ preHandler: planGuard }, { preHandler: planGuard },
@@ -136,4 +136,39 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
return { planId: req.params.planId, retired: true }; return { planId: req.params.planId, retired: true };
}, },
); );
// REACTIVATE a retired plan: mark its versions active again so it's sellable. The
// latest-effective version becomes "in force" again. (The inverse of retire.)
app.post<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId/reactivate",
{ preHandler: planGuard },
async (req) => {
db.update(subscriptionPlans)
.set({ active: true })
.where(eq(subscriptionPlans.planId, req.params.planId))
.run();
return { planId: req.params.planId, reactivated: true };
},
);
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
// version). A referenced plan version MUST survive: a subscription's planVersionId is
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
// when in use (the admin should retire instead). Removes all versions of the planId.
app.delete<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId",
{ preHandler: planGuard },
async (req, reply) => {
const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all();
if (refs.length > 0) {
return reply.code(409).send({
error: "plan is in use and cannot be deleted",
code: "plan_in_use",
subscribers: refs.length,
});
}
db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run();
return { planId: req.params.planId, deleted: true };
},
);
} }
+119 -90
View File
@@ -1,10 +1,12 @@
import { Fragment, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ApiError, ApiError,
createSubscriptionPlan, createSubscriptionPlan,
deleteSubscriptionPlan,
fetchSubscriptionPlans, fetchSubscriptionPlans,
fetchSubscriptions, fetchSubscriptions,
reactivateSubscriptionPlan,
retireSubscriptionPlan, retireSubscriptionPlan,
type PlanTimeframes, type PlanTimeframes,
type Subscription, type Subscription,
@@ -171,6 +173,29 @@ export function SubscriptionPlansManager() {
await retireSubscriptionPlan(p.planId).catch((e) => setMsg({ kind: "err", text: (e as Error).message })); await retireSubscriptionPlan(p.planId).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload(); 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). */ /** Publish a new version of an existing plan (pre-fills its identity + last values). */
function newVersionOf(p: SubscriptionPlan) { function newVersionOf(p: SubscriptionPlan) {
@@ -192,17 +217,25 @@ export function SubscriptionPlansManager() {
if (!plans) return null; if (!plans) return null;
// The CURRENT (latest active) version per planId, for the "in force" badge. // 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 now = new Date().toISOString();
const currentVersionId = new Map<string, string>(); const groups: { planId: string; head: SubscriptionPlan; active: boolean; versions: number }[] = [];
const seen = new Map<string, number>();
for (const p of plans) { for (const p of plans) {
if (p.active && p.effectiveFrom <= now && !currentVersionId.has(p.planId)) { const idx = seen.get(p.planId);
currentVersionId.set(p.planId, p.id); // plans come newest-first 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 ( return (
<section className="mx-auto max-w-3xl px-4 py-6"> <section className="mx-auto max-w-2xl px-4 py-6">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3> <h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}> <button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
@@ -215,93 +248,89 @@ export function SubscriptionPlansManager() {
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div> <div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
)} )}
{plans.length === 0 ? ( {groups.length === 0 ? (
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p> <p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
) : ( ) : (
<table className="w-full text-left text-[13px]"> <div className="flex flex-col gap-2">
<thead className="text-[11px] uppercase tracking-wider text-term-muted"> {groups.map(({ planId, head: p, active, versions }) => {
<tr> const users = subscribersOf(planId);
<th className="py-1">{t("plans.colName")}</th> const activeUsers = users.filter((s) => s.status === "active");
<th className="py-1">{t("plans.colPrice")}</th> const isOpen = expanded === planId;
<th className="py-1">{t("plans.colHours")}</th> const canDelete = users.length === 0; // no sale references it → safe to delete
<th className="py-1">{t("plans.colUsedBy")}</th> return (
<th className="py-1">{t("plans.colEffective")}</th> <div key={planId} className={`card p-3 ${active ? "" : "opacity-70"}`}>
<th className="py-1" /> {/* Header: name + status badge */}
</tr> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
</thead> <span className="font-semibold text-term-text">{p.name}</span>
<tbody> {active ? (
{plans.map((p) => { <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>
const isCurrent = currentVersionId.get(p.planId) === p.id; ) : (
// Count subscribers only against the CURRENT row of each planId (the list <span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span>
// 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 (
<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>}
{!p.active && <span className="ml-2 text-[10px] text-term-muted">{t("plans.retired")}</span>}
</td>
<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 && (
<>
<button type="button" className="btn btn-sm" onClick={() => newVersionOf(p)}>
{t("plans.newVersion")}
</button>
<button type="button" className="btn btn-sm btn-danger ml-1" onClick={() => retire(p)}>
{t("plans.retire")}
</button>
</>
)}
</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> {versions > 1 && <span className="text-[10px] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
); </div>
})}
</tbody> {/* Details: price · hours · effective */}
</table> <div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] 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-[12px]">
{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-[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>
)}
{/* 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"> <Modal open={form != null} onClose={() => setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg">
+7
View File
@@ -614,6 +614,13 @@ export function createSubscriptionPlan(body: {
export function retireSubscriptionPlan(planId: string): Promise<{ planId: string; retired: boolean }> { export function retireSubscriptionPlan(planId: string): Promise<{ planId: string; retired: boolean }> {
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/retire`, { method: "POST" }); return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/retire`, { method: "POST" });
} }
export function reactivateSubscriptionPlan(planId: string): Promise<{ planId: string; reactivated: boolean }> {
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/reactivate`, { method: "POST" });
}
/** Delete a plan (all versions). Rejects (409 plan_in_use) if any subscription uses it. */
export function deleteSubscriptionPlan(planId: string): Promise<{ planId: string; deleted: boolean }> {
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}`, { method: "DELETE" });
}
/** Live quote for the sell form (server-computed; the operator can't override it). */ /** Live quote for the sell form (server-computed; the operator can't override it). */
export function quoteSubscription(body: { export function quoteSubscription(body: {
planId: string; planId: string;
+8
View File
@@ -464,8 +464,16 @@ export const en: Catalog = {
subscribers: "Subscribers", subscribers: "Subscribers",
inForce: "in force", inForce: "in force",
retired: "retired", retired: "retired",
versionCount: "{{count}} versions",
newVersion: "New version", newVersion: "New version",
retire: "Retire", retire: "Retire",
reactivate: "Reactivate",
delete: "Delete",
deleteTitle: "Delete this plan permanently (only when no subscription uses it)",
reactivated: "“{{name}}” is sellable again.",
confirmDelete: "Delete the plan “{{name}}” permanently? This can't be undone.",
deleted: "Plan “{{name}}” deleted.",
deleteInUse: "Can't delete — subscriptions still use this plan. Retire it instead.",
newTitle: "New plan", newTitle: "New plan",
newVersionTitle: "Publish new version", newVersionTitle: "Publish new version",
newVersionHint: "This publishes a NEW version of the plan — existing sales keep their original price.", newVersionHint: "This publishes a NEW version of the plan — existing sales keep their original price.",
+8
View File
@@ -475,8 +475,16 @@ export const sq = {
subscribers: "Abonentët", subscribers: "Abonentët",
inForce: "në fuqi", inForce: "në fuqi",
retired: "i tërhequr", retired: "i tërhequr",
versionCount: "{{count}} versione",
newVersion: "Version i ri", newVersion: "Version i ri",
retire: "Tërhiq", retire: "Tërhiq",
reactivate: "Riaktivizo",
delete: "Fshij",
deleteTitle: "Fshij këtë plan përgjithmonë (vetëm kur asnjë abonim nuk e përdor)",
reactivated: "“{{name}}” është përsëri i shitshëm.",
confirmDelete: "Të fshihet plani “{{name}}” përgjithmonë? Kjo s'mund të kthehet.",
deleted: "Plani “{{name}}” u fshi.",
deleteInUse: "S'mund të fshihet — abonime ende e përdorin këtë plan. Tërhiqe në vend të kësaj.",
newTitle: "Plan i ri", newTitle: "Plan i ri",
newVersionTitle: "Publiko version të ri", newVersionTitle: "Publiko version të ri",
newVersionHint: "Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.", newVersionHint: "Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.",