From c3e26f8f0eb2d084b8aa49846d016779e0718d05 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Wed, 10 Jun 2026 13:07:56 -0300 Subject: [PATCH] feat(admin): add maxSports field to plan rules and limits schemas --- .../src/features/admin/plans-page.tsx | 528 ++++++++++++++---- packages/api-contract/src/plan.ts | 2 + 2 files changed, 418 insertions(+), 112 deletions(-) diff --git a/apps/frontend/src/features/admin/plans-page.tsx b/apps/frontend/src/features/admin/plans-page.tsx index 4715950..28f42e3 100644 --- a/apps/frontend/src/features/admin/plans-page.tsx +++ b/apps/frontend/src/features/admin/plans-page.tsx @@ -38,6 +38,26 @@ type PriceOverride = { currency: string; }; +type PlanFormRules = { + limits: { + maxCourts: number; + maxBookingsPerDay: number; + maxActiveUsers: number | ''; + maxSports: number | ''; + }; + features: { + onlinePayments: boolean; + publicBookingPage: boolean; + advancedReports: boolean; + whatsappReminders: boolean; + }; + policies: { + maxAdvanceBookingDays: number | ''; + minCancellationNoticeHours: number | ''; + allowOverbooking: boolean; + }; +}; + function initOverrides(rules: unknown): PriceOverride[] { const r = rules as Record | null | undefined; const pricing = r?.pricing as Record | undefined; @@ -52,17 +72,80 @@ function initOverrides(rules: unknown): PriceOverride[] { })); } -function buildRulesData( - mode: 'create' | 'edit', - existingRules: unknown, - priceOverrides: PriceOverride[] -): unknown { - const base = - mode === 'edit' && typeof existingRules === 'object' && existingRules !== null - ? { ...(existingRules as Record) } - : {}; +function getDefaultRules(): PlanFormRules { + return { + limits: { + maxCourts: 10, + maxBookingsPerDay: 100, + maxActiveUsers: '', + maxSports: '', + }, + features: { + onlinePayments: false, + publicBookingPage: false, + advancedReports: false, + whatsappReminders: false, + }, + policies: { + maxAdvanceBookingDays: '', + minCancellationNoticeHours: '', + allowOverbooking: false, + }, + }; +} +function initRules(plan?: Plan): PlanFormRules { + if (!plan) return getDefaultRules(); + const r = plan.rules as Record | null | undefined; + const limits = r?.limits as Record | undefined; + const features = r?.features as Record | undefined; + const policies = r?.policies as Record | undefined; + + return { + limits: { + maxCourts: (limits?.maxCourts as number) ?? 10, + maxBookingsPerDay: (limits?.maxBookingsPerDay as number) ?? 100, + maxActiveUsers: (limits?.maxActiveUsers as number | undefined) ?? '', + maxSports: (limits?.maxSports as number | undefined) ?? '', + }, + features: { + onlinePayments: (features?.onlinePayments as boolean) ?? false, + publicBookingPage: (features?.publicBookingPage as boolean) ?? false, + advancedReports: (features?.advancedReports as boolean) ?? false, + whatsappReminders: (features?.whatsappReminders as boolean) ?? false, + }, + policies: { + maxAdvanceBookingDays: (policies?.maxAdvanceBookingDays as number | undefined) ?? '', + minCancellationNoticeHours: + (policies?.minCancellationNoticeHours as number | undefined) ?? '', + allowOverbooking: (policies?.allowOverbooking as boolean) ?? false, + }, + }; +} + +function buildRulesData(formRules: PlanFormRules, priceOverrides: PriceOverride[]): unknown { const validOverrides = priceOverrides.filter((o) => o.country.trim()); + const { limits, features, policies } = formRules; + + const rules: Record = { + version: validOverrides.length > 0 ? 'v2' : 'v1', + limits: { + maxCourts: limits.maxCourts, + maxBookingsPerDay: limits.maxBookingsPerDay, + ...(limits.maxActiveUsers !== '' && { maxActiveUsers: limits.maxActiveUsers }), + ...(limits.maxSports !== '' && { maxSports: limits.maxSports }), + }, + features, + policies: { + ...(policies.maxAdvanceBookingDays !== '' && { + maxAdvanceBookingDays: policies.maxAdvanceBookingDays, + }), + ...(policies.minCancellationNoticeHours !== '' && { + minCancellationNoticeHours: policies.minCancellationNoticeHours, + }), + allowOverbooking: policies.allowOverbooking, + }, + }; if (validOverrides.length > 0) { const overrides: Record = {}; @@ -72,19 +155,47 @@ function buildRulesData( currency: o.currency, }; } - return { - ...base, - version: 'v2', - pricing: { overrides }, - }; + rules.pricing = { overrides }; } - if (mode === 'edit') { - const { pricing: _, ...clean } = base; - return Object.keys(clean).length > 0 ? clean : {}; - } + return rules; +} - return {}; +const TABS = [ + { id: 'basico', label: 'Básico' }, + { id: 'limites', label: 'Límites' }, + { id: 'features', label: 'Funcionalidades' }, + { id: 'policies', label: 'Políticas' }, + { id: 'pricing', label: 'Precios x país' }, +] as const; + +function Toggle({ + checked, + onChange, + label, +}: { + checked: boolean; + onChange: (v: boolean) => void; + label: string; +}) { + return ( + + ); } function PlanFormDialog({ @@ -97,9 +208,11 @@ function PlanFormDialog({ onClose: () => void; }) { const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState('basico'); const [code, setCode] = useState(plan?.code ?? ''); const [name, setName] = useState(plan?.name ?? ''); const [price, setPrice] = useState(String(plan?.price ?? '')); + const [formRules, setFormRules] = useState(() => initRules(plan)); const [priceOverrides, setPriceOverrides] = useState(() => mode === 'edit' && plan ? initOverrides(plan.rules) : [] ); @@ -125,9 +238,20 @@ function PlanFormDialog({ const isPending = createMutation.isPending || updateMutation.isPending; const error = createMutation.error ?? updateMutation.error; + function updateRule( + section: S, + field: keyof PlanFormRules[S], + value: PlanFormRules[S][keyof PlanFormRules[S]] + ) { + setFormRules((prev) => ({ + ...prev, + [section]: { ...prev[section], [field]: value }, + })); + } + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - const rules = buildRulesData(mode, plan?.rules, priceOverrides); + const rules = buildRulesData(formRules, priceOverrides); if (mode === 'create') { createMutation.mutate({ @@ -159,9 +283,9 @@ function PlanFormDialog({ return ( !open && onClose()}> - + - {mode === 'create' ? 'Crear plan' : 'Editar plan'} + {mode === 'create' ? 'Crear plan' : `Editar: ${plan!.name}`} {mode === 'create' ? 'Agregá un nuevo plan de suscripción.' @@ -169,114 +293,294 @@ function PlanFormDialog({ -
- {mode === 'create' && ( -
- - setCode(e.target.value)} - placeholder="pro" - maxLength={10} - required - /> -
- )} - -
- - setName(e.target.value)} - placeholder="Pro" - maxLength={30} - required - /> -
- -
- - setPrice(e.target.value)} - placeholder="29.99" - required - /> -
- -
-
- - -
+ {tab.label} + + ))} +
- {priceOverrides.map((override, index) => ( -
-
- + {activeTab === 'basico' && ( +
+ {mode === 'create' && ( +
+ updateOverride(index, 'country', e.target.value)} - placeholder="AR" - maxLength={2} - className="uppercase" + id="code" + value={code} + onChange={(e) => setCode(e.target.value)} + placeholder="pro" + maxLength={10} + required />
-
- + )} +
+
+ setName(e.target.value)} + placeholder="Pro" + maxLength={30} + required + /> +
+
+ + updateOverride(index, 'amount', e.target.value)} - placeholder="14999" + value={price} + onChange={(e) => setPrice(e.target.value)} + placeholder="29.99" + required />
-
- - +
+
+ )} + + {activeTab === 'limites' && ( +
+
+
+ + updateRule('limits', 'maxCourts', Number(e.target.value))} + required + />
+
+ + + updateRule('limits', 'maxBookingsPerDay', Number(e.target.value)) + } + required + /> +
+
+ + + updateRule( + 'limits', + 'maxActiveUsers', + e.target.value === '' ? '' : Number(e.target.value) + ) + } + placeholder="Ilimitado" + /> +
+
+ + + updateRule( + 'limits', + 'maxSports', + e.target.value === '' ? '' : Number(e.target.value) + ) + } + placeholder="Ilimitado" + /> +
+
+
+ )} + + {activeTab === 'features' && ( +
+ {( + [ + ['onlinePayments', 'Pagos online'], + ['publicBookingPage', 'Página de reservas pública'], + ['advancedReports', 'Reportes avanzados'], + ['whatsappReminders', 'Recordatorios por WhatsApp'], + ] as const + ).map(([key, label]) => ( +
+ + updateRule('features', key, v)} + label={label} + /> +
+ ))} +
+ )} + + {activeTab === 'policies' && ( +
+
+
+ + + updateRule( + 'policies', + 'maxAdvanceBookingDays', + e.target.value === '' ? '' : Number(e.target.value) + ) + } + placeholder="Sin límite" + /> +
+
+ + + updateRule( + 'policies', + 'minCancellationNoticeHours', + e.target.value === '' ? '' : Number(e.target.value) + ) + } + placeholder="Sin restricción" + /> +
+
+
+ + updateRule('policies', 'allowOverbooking', v)} + label="Permitir sobreventa" + /> +
+
+ )} + + {activeTab === 'pricing' && ( +
+
+
- ))} -
+ + {priceOverrides.length === 0 && ( +

+ Sin precios personalizados. Se usa el precio global USD. +

+ )} + + {priceOverrides.map((override, index) => ( +
+
+ + updateOverride(index, 'country', e.target.value)} + placeholder="AR" + maxLength={2} + className="uppercase" + /> +
+
+ + updateOverride(index, 'amount', e.target.value)} + placeholder="14999" + /> +
+
+ + +
+ +
+ ))} +
+ )} {error && (

diff --git a/packages/api-contract/src/plan.ts b/packages/api-contract/src/plan.ts index c2799df..496de9e 100644 --- a/packages/api-contract/src/plan.ts +++ b/packages/api-contract/src/plan.ts @@ -17,6 +17,7 @@ export const planRulesV1Schema = z.object({ maxBookingsPerDay: positiveInt, maxActiveUsers: positiveInt.optional(), maxConcurrentBookingsPerSlot: positiveInt.optional(), + maxSports: positiveInt.optional(), }), policies: z .object({ @@ -71,6 +72,7 @@ export const planLimitsSchema = z.object({ maxBookingsPerDay: positiveInt, maxActiveUsers: positiveInt.optional(), maxConcurrentBookingsPerSlot: positiveInt.optional(), + maxSports: positiveInt.optional(), }) export const planWithFeaturesSchema = z.object({