From 36f30d39ffc29b3ded9e3e5c05d84c096889ef7d Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 20 Jun 2026 20:27:36 +0200 Subject: [PATCH] feat(plans): reactivate + delete-when-unused; card layout fixes overlap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/routes/subscription-plans.ts | 39 +++- apps/web/src/SubscriptionPlansManager.tsx | 209 +++++++++++-------- apps/web/src/api.ts | 7 + apps/web/src/lib/i18n/en.ts | 8 + apps/web/src/lib/i18n/sq.ts | 8 + 5 files changed, 179 insertions(+), 92 deletions(-) diff --git a/apps/server/src/routes/subscription-plans.ts b/apps/server/src/routes/subscription-plans.ts index 6a88a47..8c0ba72 100644 --- a/apps/server/src/routes/subscription-plans.ts +++ b/apps/server/src/routes/subscription-plans.ts @@ -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 }; + }, + ); } diff --git a/apps/web/src/SubscriptionPlansManager.tsx b/apps/web/src/SubscriptionPlansManager.tsx index 3f83209..1136458 100644 --- a/apps/web/src/SubscriptionPlansManager.tsx +++ b/apps/web/src/SubscriptionPlansManager.tsx @@ -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(); + const groups: { planId: string; head: SubscriptionPlan; active: boolean; versions: number }[] = []; + const seen = new Map(); 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 ( -
+

{t("plans.title")}

+ + {/* Details: price · hours · effective */} +
+ + {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])} + + {timeframesSummary(p.timeframes, t)} + {t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()} +
+ + {/* Used by */} +
+ {users.length > 0 ? ( + + ) : ( + {t("plans.colUsedBy")}: {t("plans.usedByNone")} + )} +
+ {isOpen && users.length > 0 && ( +
    + {users.map((s) => ( +
  • + {s.holderName || t("subs.unnamed")} + {s.quantity > 1 && ×{s.quantity}} + {s.status !== "active" && ({t(STATUS_KEY[s.status])})} +
  • + ))} +
+ )} + + {/* Actions — own row, never overlapping */} +
+ {active ? ( + <> + + + + ) : ( + + )} + {canDelete && ( + + )} +
+ + ); + })} + )} setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg"> diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 14de818..76a8cf6 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index ec1c4a4..74e8590 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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.", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index e5f6c71..31f30e3 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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.",