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:
@@ -18,6 +18,7 @@ import {
|
|||||||
type Subscription,
|
type Subscription,
|
||||||
type SubscriptionCredential,
|
type SubscriptionCredential,
|
||||||
type SubscriptionInput,
|
type SubscriptionInput,
|
||||||
|
type SubscriptionPeriod,
|
||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
type SubscriptionQuote,
|
type SubscriptionQuote,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
@@ -34,11 +35,12 @@ interface FormState {
|
|||||||
contact: string;
|
contact: string;
|
||||||
planId: string; // selected plan (sells/prices it); "" = comp (no charge)
|
planId: string; // selected plan (sells/prices it); "" = comp (no charge)
|
||||||
quantity: string; // cars covered by this one subscription (price ×N)
|
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)
|
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
|
||||||
carBound: boolean; // false = unbound (maxConcurrent null)
|
carBound: boolean; // false = unbound (maxConcurrent null)
|
||||||
maxConcurrent: string;
|
maxConcurrent: string;
|
||||||
validFrom: string; // span start (date)
|
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[];
|
credentials: SubscriptionCredential[];
|
||||||
platesText: string; // comma/space separated
|
platesText: string; // comma/space separated
|
||||||
}
|
}
|
||||||
@@ -54,6 +56,7 @@ function emptyForm(): FormState {
|
|||||||
contact: "",
|
contact: "",
|
||||||
planId: "",
|
planId: "",
|
||||||
quantity: "1",
|
quantity: "1",
|
||||||
|
count: "1",
|
||||||
tender: "cash",
|
tender: "cash",
|
||||||
carBound: true,
|
carBound: true,
|
||||||
maxConcurrent: "1",
|
maxConcurrent: "1",
|
||||||
@@ -69,6 +72,7 @@ function formFrom(s: Subscription): FormState {
|
|||||||
contact: s.contact ?? "",
|
contact: s.contact ?? "",
|
||||||
planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only
|
planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only
|
||||||
quantity: String(s.quantity ?? 1),
|
quantity: String(s.quantity ?? 1),
|
||||||
|
count: "1",
|
||||||
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
|
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
|
||||||
carBound: s.maxConcurrent != null,
|
carBound: s.maxConcurrent != null,
|
||||||
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
|
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();
|
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 {
|
function toInput(f: FormState, isNew: boolean): SubscriptionInput {
|
||||||
const planSelected = isNew && f.planId.trim() !== "";
|
const planSelected = isNew && f.planId.trim() !== "";
|
||||||
return {
|
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.
|
// 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
|
// Debounced; re-runs when the plan or the span changes. The operator can't override
|
||||||
// the amount — it's whatever the server returns.
|
// the amount — it's whatever the server returns.
|
||||||
@@ -440,6 +475,24 @@ export function SubscriptionManager() {
|
|||||||
</span>
|
</span>
|
||||||
<label className="label">{t("subs.validFrom")}</label>
|
<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 }))} />
|
<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>
|
<label className="label">{t("subs.validToEnd")}</label>
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
<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 }))} />
|
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ export const en: Catalog = {
|
|||||||
plan: "Plan",
|
plan: "Plan",
|
||||||
quantity: "Cars",
|
quantity: "Cars",
|
||||||
quantityHint: "cars covered by this subscription (price ×N)",
|
quantityHint: "cars covered by this subscription (price ×N)",
|
||||||
|
count: "How many",
|
||||||
planNone: "— comp / no charge —",
|
planNone: "— comp / no charge —",
|
||||||
planNoneAvail: "No plans defined — an admin must create one first.",
|
planNoneAvail: "No plans defined — an admin must create one first.",
|
||||||
quoting: "pricing…",
|
quoting: "pricing…",
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ export const sq = {
|
|||||||
plan: "Plani",
|
plan: "Plani",
|
||||||
quantity: "Makina",
|
quantity: "Makina",
|
||||||
quantityHint: "makina të mbuluara nga ky abonim (çmimi ×N)",
|
quantityHint: "makina të mbuluara nga ky abonim (çmimi ×N)",
|
||||||
|
count: "Sa",
|
||||||
planNone: "— pa pagesë / falas —",
|
planNone: "— pa pagesë / falas —",
|
||||||
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
|
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
|
||||||
quoting: "duke llogaritur…",
|
quoting: "duke llogaritur…",
|
||||||
|
|||||||
Reference in New Issue
Block a user