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 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 { requirePermission } from "../auth.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
// 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 } }>(
"/api/subscription-plans/:planId/retire",
{ preHandler: planGuard },
@@ -136,4 +136,39 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
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 };
},
);
}
+97 -68
View File
@@ -1,10 +1,12 @@
import { Fragment, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createSubscriptionPlan,
deleteSubscriptionPlan,
fetchSubscriptionPlans,
fetchSubscriptions,
reactivateSubscriptionPlan,
retireSubscriptionPlan,
type PlanTimeframes,
type Subscription,
@@ -171,6 +173,29 @@ export function SubscriptionPlansManager() {
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) {
@@ -192,17 +217,25 @@ export function SubscriptionPlansManager() {
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 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) {
if (p.active && p.effectiveFrom <= now && !currentVersionId.has(p.planId)) {
currentVersionId.set(p.planId, p.id); // plans come newest-first
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="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">
<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())}>
@@ -215,77 +248,54 @@ export function SubscriptionPlansManager() {
<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>
) : (
<table className="w-full text-left text-[13px]">
<thead className="text-[11px] uppercase tracking-wider text-term-muted">
<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>
</thead>
<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) : [];
<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 === p.planId;
const isOpen = expanded === planId;
const canDelete = users.length === 0; // no sale references it → safe to delete
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">
<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-[10px] text-term-green">{t("plans.inForce")}</span>
) : (
<span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span>
)}
{versions > 1 && <span className="text-[10px] 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-[12px] text-term-muted">
<span className="tabular-nums text-term-text">
{(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 ? (
</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 : p.planId)}
onClick={() => setExpanded(isOpen ? null : planId)}
title={t("plans.usedByTitle")}
>
{t("plans.usedByCount", { active: activeUsers.length, total: users.length })} {isOpen ? "▾" : "▸"}
{t("plans.colUsedBy")}: {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>
<span className="text-term-muted">{t("plans.colUsedBy")}: {t("plans.usedByNone")}</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>
</div>
{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]">
<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")}
@@ -294,14 +304,33 @@ export function SubscriptionPlansManager() {
</li>
))}
</ul>
</td>
</tr>
)}
</Fragment>
{/* 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>
);
})}
</tbody>
</table>
</div>
)}
<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 }> {
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). */
export function quoteSubscription(body: {
planId: string;
+8
View File
@@ -464,8 +464,16 @@ export const en: Catalog = {
subscribers: "Subscribers",
inForce: "in force",
retired: "retired",
versionCount: "{{count}} versions",
newVersion: "New version",
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",
newVersionTitle: "Publish new version",
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",
inForce: "në fuqi",
retired: "i tërhequr",
versionCount: "{{count}} versione",
newVersion: "Version i ri",
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",
newVersionTitle: "Publiko version të ri",
newVersionHint: "Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.",