feat/admin-portal #17

Merged
jselesan merged 6 commits from feat/admin-portal into development 2026-06-10 16:08:58 +00:00
64 changed files with 3867 additions and 122 deletions
Showing only changes of commit c3e26f8f0e - Show all commits

View File

@@ -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">

View File

@@ -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({