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…",