feat(subs): add a "how many periods" count that drives the end date

When subscriptions moved to the plan model the span became start + end dates,
which lost the simple "renew for N months/weeks/days" input — the operator had
to hand-compute the end date. (quantity is CARS, a separate axis, not periods.)

Add a count field to the sell form: the operator types e.g. 3, and validTo is
auto-derived as validFrom + count × the plan's period (day/week/month), with the
same month-overflow clamp the server uses (Jan 31 +1mo → Feb 28) so the preview
matches what's stored + charged. The end-date field stays directly editable for
an irregular span (the hotel checkout case), and editing it isn't overwritten by
the count effect. The count row shows the plan's unit ("× month").

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 19:46:06 +02:00
parent e0e218fa61
commit ff04ec10be
3 changed files with 56 additions and 1 deletions
+54 -1
View File
@@ -18,6 +18,7 @@ import {
type Subscription,
type SubscriptionCredential,
type SubscriptionInput,
type SubscriptionPeriod,
type SubscriptionPlan,
type SubscriptionQuote,
} from "./api.js";
@@ -34,11 +35,12 @@ interface FormState {
contact: string;
planId: string; // selected plan (sells/prices it); "" = comp (no charge)
quantity: string; // cars covered by this one subscription (price ×N)
count: string; // HOW MANY of the plan's period (e.g. 3 months) — drives the end date
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string; // span start (date)
validTo: string; // span end (date) — required when a plan is selected
validTo: string; // span end (date) — auto-filled from count, or set directly (hotel)
credentials: SubscriptionCredential[];
platesText: string; // comma/space separated
}
@@ -54,6 +56,7 @@ function emptyForm(): FormState {
contact: "",
planId: "",
quantity: "1",
count: "1",
tender: "cash",
carBound: true,
maxConcurrent: "1",
@@ -69,6 +72,7 @@ function formFrom(s: Subscription): FormState {
contact: s.contact ?? "",
planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only
quantity: String(s.quantity ?? 1),
count: "1",
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
@@ -92,6 +96,22 @@ function dateToISO(d: string): string | null {
return Number.isNaN(t) ? null : new Date(t).toISOString();
}
/** Add `count` of the plan's period to a yyyy-mm-dd start → yyyy-mm-dd end. Mirrors the
* server's whole-month clamp (Jan 31 +1mo → Feb 28) so the previewed end date matches
* what the sale will store. day/week are exact multiples of 24h. */
function addPeriods(startDate: string, period: SubscriptionPeriod, count: number): string | null {
const d = new Date(`${startDate}T00:00:00Z`);
if (Number.isNaN(d.getTime()) || count < 1) return null;
if (period === "day") d.setUTCDate(d.getUTCDate() + count);
else if (period === "week") d.setUTCDate(d.getUTCDate() + count * 7);
else {
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + count);
if (d.getUTCDate() < day) d.setUTCDate(0); // clamp month-overflow
}
return d.toISOString().slice(0, 10);
}
function toInput(f: FormState, isNew: boolean): SubscriptionInput {
const planSelected = isNew && f.planId.trim() !== "";
return {
@@ -155,6 +175,21 @@ export function SubscriptionManager() {
});
}, []);
// The currently-selected plan (for its period, to drive the count → end-date math).
const selectedPlan = plans.find((p) => p.planId === form.planId.trim()) ?? null;
// COUNT → END DATE. When the operator types "how many periods" (e.g. 3 months), derive
// validTo = validFrom + count × plan period. Keeps the common "renew for N" case to a
// single number while the end-date field stays directly editable (the hotel case).
useEffect(() => {
if (editing !== "new" || !selectedPlan || !form.validFrom.trim()) return;
const n = Math.round(Number(form.count));
if (!Number.isFinite(n) || n < 1) return;
const end = addPeriods(form.validFrom, selectedPlan.period, n);
if (end && end !== form.validTo) setForm((f) => ({ ...f, validTo: end }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing, form.planId, form.validFrom, form.count]);
// Live server-computed quote for the sell form: ceil(periods) × per-period price.
// Debounced; re-runs when the plan or the span changes. The operator can't override
// the amount — it's whatever the server returns.
@@ -440,6 +475,24 @@ export function SubscriptionManager() {
</span>
<label className="label">{t("subs.validFrom")}</label>
<input type="date" className="input w-44" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
{/* HOW MANY periods (e.g. 3 months) — the common "renew for N" case. Drives the
end date below; for an irregular span the operator can edit the end directly. */}
{editing === "new" && selectedPlan && (
<>
<label className="label">{t("subs.count")}</label>
<span className="flex flex-wrap items-center gap-2">
<input
className="input w-16"
value={form.count}
inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
/>
<span className="text-[12px] text-term-muted">
× {t(PERIOD_KEY[selectedPlan.period])}
</span>
</span>
</>
)}
<label className="label">{t("subs.validToEnd")}</label>
<span className="flex flex-wrap items-center gap-2">
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
+1
View File
@@ -389,6 +389,7 @@ export const en: Catalog = {
plan: "Plan",
quantity: "Cars",
quantityHint: "cars covered by this subscription (price ×N)",
count: "How many",
planNone: "— comp / no charge —",
planNoneAvail: "No plans defined — an admin must create one first.",
quoting: "pricing…",
+1
View File
@@ -400,6 +400,7 @@ export const sq = {
plan: "Plani",
quantity: "Makina",
quantityHint: "makina të mbuluara nga ky abonim (çmimi ×N)",
count: "Sa",
planNone: "— pa pagesë / falas —",
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
quoting: "duke llogaritur…",