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 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 }))} />
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
Reference in New Issue
Block a user