feat(admin): add maxSports field to plan rules and limits schemas
This commit is contained in:
@@ -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<string, unknown> | null | undefined;
|
||||
const pricing = r?.pricing as Record<string, unknown> | 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<string, unknown>) }
|
||||
: {};
|
||||
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<string, unknown> | null | undefined;
|
||||
const limits = r?.limits as Record<string, unknown> | undefined;
|
||||
const features = r?.features as Record<string, unknown> | undefined;
|
||||
const policies = r?.policies as Record<string, unknown> | 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<string, unknown> = {
|
||||
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<string, { amount: number; currency: string }> = {};
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-10 shrink-0 items-center rounded-full transition-colors ${
|
||||
checked ? 'bg-primary' : 'bg-input'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block size-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||
checked ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
<span className="sr-only">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<PlanFormRules>(() => initRules(plan));
|
||||
const [priceOverrides, setPriceOverrides] = useState<PriceOverride[]>(() =>
|
||||
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<S extends keyof PlanFormRules>(
|
||||
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 (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="rounded-2xl sm:max-w-md">
|
||||
<DialogContent className="rounded-2xl sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'create' ? 'Crear plan' : 'Editar plan'}</DialogTitle>
|
||||
<DialogTitle>{mode === 'create' ? 'Crear plan' : `Editar: ${plan!.name}`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'create'
|
||||
? 'Agregá un nuevo plan de suscripción.'
|
||||
@@ -169,7 +293,26 @@ function PlanFormDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="-mx-6 flex gap-0 border-b border-border/60 px-6">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`-mb-px border-b-2 px-3 pb-2 pt-1 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'basico' && (
|
||||
<div className="space-y-4">
|
||||
{mode === 'create' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">Código</Label>
|
||||
@@ -183,7 +326,7 @@ function PlanFormDialog({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nombre</Label>
|
||||
<Input
|
||||
@@ -195,10 +338,10 @@ function PlanFormDialog({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price">
|
||||
Precio USD <span className="text-xs text-muted-foreground">(fallback global)</span>
|
||||
Precio USD
|
||||
<span className="text-xs text-muted-foreground">(fallback global)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="price"
|
||||
@@ -211,7 +354,161 @@ function PlanFormDialog({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'limites' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxCourts">Máx. canchas</Label>
|
||||
<Input
|
||||
id="maxCourts"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formRules.limits.maxCourts}
|
||||
onChange={(e) => updateRule('limits', 'maxCourts', Number(e.target.value))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxBookingsPerDay">Máx. turnos/día</Label>
|
||||
<Input
|
||||
id="maxBookingsPerDay"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formRules.limits.maxBookingsPerDay}
|
||||
onChange={(e) =>
|
||||
updateRule('limits', 'maxBookingsPerDay', Number(e.target.value))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxActiveUsers">
|
||||
Máx. usuarios activos
|
||||
<span className="text-xs text-muted-foreground">(opcional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="maxActiveUsers"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formRules.limits.maxActiveUsers}
|
||||
onChange={(e) =>
|
||||
updateRule(
|
||||
'limits',
|
||||
'maxActiveUsers',
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
placeholder="Ilimitado"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxSports">
|
||||
Máx. deportes
|
||||
<span className="text-xs text-muted-foreground">(opcional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="maxSports"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formRules.limits.maxSports}
|
||||
onChange={(e) =>
|
||||
updateRule(
|
||||
'limits',
|
||||
'maxSports',
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
placeholder="Ilimitado"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'features' && (
|
||||
<div className="space-y-4">
|
||||
{(
|
||||
[
|
||||
['onlinePayments', 'Pagos online'],
|
||||
['publicBookingPage', 'Página de reservas pública'],
|
||||
['advancedReports', 'Reportes avanzados'],
|
||||
['whatsappReminders', 'Recordatorios por WhatsApp'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<Label className="cursor-pointer">{label}</Label>
|
||||
<Toggle
|
||||
checked={formRules.features[key]}
|
||||
onChange={(v) => updateRule('features', key, v)}
|
||||
label={label}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'policies' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxAdvanceBookingDays">
|
||||
Anticipación máx. (días)
|
||||
<span className="text-xs text-muted-foreground">(opcional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="maxAdvanceBookingDays"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formRules.policies.maxAdvanceBookingDays}
|
||||
onChange={(e) =>
|
||||
updateRule(
|
||||
'policies',
|
||||
'maxAdvanceBookingDays',
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
placeholder="Sin límite"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minCancellationNoticeHours">
|
||||
Cancelación mín. (horas)
|
||||
<span className="text-xs text-muted-foreground">(opcional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="minCancellationNoticeHours"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formRules.policies.minCancellationNoticeHours}
|
||||
onChange={(e) =>
|
||||
updateRule(
|
||||
'policies',
|
||||
'minCancellationNoticeHours',
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
placeholder="Sin restricción"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="cursor-pointer" htmlFor="allowOverbooking">
|
||||
Permitir sobreventa
|
||||
</Label>
|
||||
<Toggle
|
||||
checked={formRules.policies.allowOverbooking}
|
||||
onChange={(v) => updateRule('policies', 'allowOverbooking', v)}
|
||||
label="Permitir sobreventa"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'pricing' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Precios por país</Label>
|
||||
@@ -227,6 +524,12 @@ function PlanFormDialog({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{priceOverrides.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sin precios personalizados. Se usa el precio global USD.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{priceOverrides.map((override, index) => (
|
||||
<div key={index} className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
@@ -277,6 +580,7 @@ function PlanFormDialog({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user