feat/admin-portal #17
@@ -38,6 +38,26 @@ type PriceOverride = {
|
|||||||
currency: string;
|
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[] {
|
function initOverrides(rules: unknown): PriceOverride[] {
|
||||||
const r = rules as Record<string, unknown> | null | undefined;
|
const r = rules as Record<string, unknown> | null | undefined;
|
||||||
const pricing = r?.pricing as Record<string, unknown> | undefined;
|
const pricing = r?.pricing as Record<string, unknown> | undefined;
|
||||||
@@ -52,17 +72,80 @@ function initOverrides(rules: unknown): PriceOverride[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRulesData(
|
function getDefaultRules(): PlanFormRules {
|
||||||
mode: 'create' | 'edit',
|
return {
|
||||||
existingRules: unknown,
|
limits: {
|
||||||
priceOverrides: PriceOverride[]
|
maxCourts: 10,
|
||||||
): unknown {
|
maxBookingsPerDay: 100,
|
||||||
const base =
|
maxActiveUsers: '',
|
||||||
mode === 'edit' && typeof existingRules === 'object' && existingRules !== null
|
maxSports: '',
|
||||||
? { ...(existingRules as Record<string, unknown>) }
|
},
|
||||||
: {};
|
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 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) {
|
if (validOverrides.length > 0) {
|
||||||
const overrides: Record<string, { amount: number; currency: string }> = {};
|
const overrides: Record<string, { amount: number; currency: string }> = {};
|
||||||
@@ -72,19 +155,47 @@ function buildRulesData(
|
|||||||
currency: o.currency,
|
currency: o.currency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
rules.pricing = { overrides };
|
||||||
...base,
|
|
||||||
version: 'v2',
|
|
||||||
pricing: { overrides },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'edit') {
|
return rules;
|
||||||
const { pricing: _, ...clean } = base;
|
}
|
||||||
return Object.keys(clean).length > 0 ? clean : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
function PlanFormDialog({
|
||||||
@@ -97,9 +208,11 @@ function PlanFormDialog({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [activeTab, setActiveTab] = useState('basico');
|
||||||
const [code, setCode] = useState(plan?.code ?? '');
|
const [code, setCode] = useState(plan?.code ?? '');
|
||||||
const [name, setName] = useState(plan?.name ?? '');
|
const [name, setName] = useState(plan?.name ?? '');
|
||||||
const [price, setPrice] = useState(String(plan?.price ?? ''));
|
const [price, setPrice] = useState(String(plan?.price ?? ''));
|
||||||
|
const [formRules, setFormRules] = useState<PlanFormRules>(() => initRules(plan));
|
||||||
const [priceOverrides, setPriceOverrides] = useState<PriceOverride[]>(() =>
|
const [priceOverrides, setPriceOverrides] = useState<PriceOverride[]>(() =>
|
||||||
mode === 'edit' && plan ? initOverrides(plan.rules) : []
|
mode === 'edit' && plan ? initOverrides(plan.rules) : []
|
||||||
);
|
);
|
||||||
@@ -125,9 +238,20 @@ function PlanFormDialog({
|
|||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
const error = createMutation.error ?? updateMutation.error;
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const rules = buildRulesData(mode, plan?.rules, priceOverrides);
|
const rules = buildRulesData(formRules, priceOverrides);
|
||||||
|
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
@@ -159,9 +283,9 @@ function PlanFormDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="rounded-2xl sm:max-w-md">
|
<DialogContent className="rounded-2xl sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{mode === 'create' ? 'Crear plan' : 'Editar plan'}</DialogTitle>
|
<DialogTitle>{mode === 'create' ? 'Crear plan' : `Editar: ${plan!.name}`}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{mode === 'create'
|
{mode === 'create'
|
||||||
? 'Agregá un nuevo plan de suscripción.'
|
? 'Agregá un nuevo plan de suscripción.'
|
||||||
@@ -169,7 +293,26 @@ function PlanFormDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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' && (
|
{mode === 'create' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="code">Código</Label>
|
<Label htmlFor="code">Código</Label>
|
||||||
@@ -183,7 +326,7 @@ function PlanFormDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Nombre</Label>
|
<Label htmlFor="name">Nombre</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -195,10 +338,10 @@ function PlanFormDialog({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="price">
|
<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>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="price"
|
id="price"
|
||||||
@@ -211,7 +354,161 @@ function PlanFormDialog({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Precios por país</Label>
|
<Label>Precios por país</Label>
|
||||||
@@ -227,6 +524,12 @@ function PlanFormDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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) => (
|
{priceOverrides.map((override, index) => (
|
||||||
<div key={index} className="flex items-end gap-2">
|
<div key={index} className="flex items-end gap-2">
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
@@ -277,6 +580,7 @@ function PlanFormDialog({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const planRulesV1Schema = z.object({
|
|||||||
maxBookingsPerDay: positiveInt,
|
maxBookingsPerDay: positiveInt,
|
||||||
maxActiveUsers: positiveInt.optional(),
|
maxActiveUsers: positiveInt.optional(),
|
||||||
maxConcurrentBookingsPerSlot: positiveInt.optional(),
|
maxConcurrentBookingsPerSlot: positiveInt.optional(),
|
||||||
|
maxSports: positiveInt.optional(),
|
||||||
}),
|
}),
|
||||||
policies: z
|
policies: z
|
||||||
.object({
|
.object({
|
||||||
@@ -71,6 +72,7 @@ export const planLimitsSchema = z.object({
|
|||||||
maxBookingsPerDay: positiveInt,
|
maxBookingsPerDay: positiveInt,
|
||||||
maxActiveUsers: positiveInt.optional(),
|
maxActiveUsers: positiveInt.optional(),
|
||||||
maxConcurrentBookingsPerSlot: positiveInt.optional(),
|
maxConcurrentBookingsPerSlot: positiveInt.optional(),
|
||||||
|
maxSports: positiveInt.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const planWithFeaturesSchema = z.object({
|
export const planWithFeaturesSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user