diff --git a/apps/web/src/SubscriptionManager.tsx b/apps/web/src/SubscriptionManager.tsx
index a5f920d..b8d4487 100644
--- a/apps/web/src/SubscriptionManager.tsx
+++ b/apps/web/src/SubscriptionManager.tsx
@@ -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() {
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 && (
+ <>
+
+
+ setForm((f) => ({ ...f, count: e.target.value }))}
+ />
+
+ × {t(PERIOD_KEY[selectedPlan.period])}
+
+
+ >
+ )}
setForm((f) => ({ ...f, validTo: e.target.value }))} />
diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts
index b1980b8..1c74c1c 100644
--- a/apps/web/src/lib/i18n/en.ts
+++ b/apps/web/src/lib/i18n/en.ts
@@ -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…",
diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts
index ffba8d1..6a79541 100644
--- a/apps/web/src/lib/i18n/sq.ts
+++ b/apps/web/src/lib/i18n/sq.ts
@@ -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…",