feat: add recurring bookings feature with related handlers and services

- Introduced recurring booking groups with the ability to create and cancel them.
- Added database migrations for recurring bookings, including new tables and relationships.
- Implemented handlers for creating and canceling recurring bookings in the admin booking module.
- Enhanced existing booking services to support recurring bookings logic.
- Updated API contract to include new schemas for recurring bookings.
- Refactored existing code for improved readability and maintainability.
This commit is contained in:
Jose Selesan
2026-06-16 11:58:06 -03:00
parent 1b5eb253f2
commit 1318e3bf57
38 changed files with 2025 additions and 265 deletions

View File

@@ -50,6 +50,7 @@ type PlanFormRules = {
publicBookingPage: boolean;
advancedReports: boolean;
whatsappReminders: boolean;
fixedSlots: boolean;
};
policies: {
maxAdvanceBookingDays: number | '';
@@ -85,6 +86,7 @@ function getDefaultRules(): PlanFormRules {
publicBookingPage: false,
advancedReports: false,
whatsappReminders: false,
fixedSlots: false,
},
policies: {
maxAdvanceBookingDays: '',
@@ -113,6 +115,7 @@ function initRules(plan?: Plan): PlanFormRules {
publicBookingPage: (features?.publicBookingPage as boolean) ?? false,
advancedReports: (features?.advancedReports as boolean) ?? false,
whatsappReminders: (features?.whatsappReminders as boolean) ?? false,
fixedSlots: (features?.fixedSlots as boolean) ?? false,
},
policies: {
maxAdvanceBookingDays: (policies?.maxAdvanceBookingDays as number | undefined) ?? '',
@@ -437,6 +440,7 @@ function PlanFormDialog({
['publicBookingPage', 'Página de reservas pública'],
['advancedReports', 'Reportes avanzados'],
['whatsappReminders', 'Recordatorios por WhatsApp'],
['fixedSlots', 'Turnos fijos'],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center justify-between">

View File

@@ -1,5 +1,5 @@
import { ApiClientError, apiClient } from '@/lib/api-client';
import type { AdminBooking, ComplexWithRole, Court } from '@repo/api-contract';
import type { AdminBooking, ComplexWithRole, Court, PlanFeatureFlags } from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
type ReactNode,
@@ -44,6 +44,16 @@ interface CreateManualBookingPayload {
customerEmail: string;
}
interface CreateRecurringBookingPayload {
courtId: string;
date: string;
startTime: string;
customerName: string;
customerPhone: string;
customerEmail: string;
recurringEndDate?: string;
}
interface BookingContextValue {
complex: ComplexWithRole;
courts: Court[];
@@ -66,6 +76,8 @@ interface BookingContextValue {
createBookingError: string | null;
isCreatingBooking: boolean;
bookingToolsOpen: boolean;
planFeatures: PlanFeatureFlags | null;
isRecurringEnabled: boolean;
setSelectedDate: (date: string) => void;
moveSelectedDate: (amount: number) => void;
setSelectedSportId: (sportId: string) => void;
@@ -75,6 +87,7 @@ interface BookingContextValue {
openCreateBooking: (draft?: Partial<BookingSlotDraft>) => void;
closeCreateBooking: () => void;
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
createRecurringBooking: (payload: CreateRecurringBookingPayload) => Promise<void>;
updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => void;
exportDayReport: () => void;
openBookingTools: (segment?: BookingTimelineSegment) => void;
@@ -325,6 +338,26 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
},
});
const createRecurringBookingMutation = useMutation({
mutationFn: (payload: CreateRecurringBookingPayload) =>
apiClient.adminBookings.createRecurring(complex.id, {
date: payload.date,
courtId: payload.courtId,
startTime: payload.startTime,
customerName: payload.customerName,
customerPhone: payload.customerPhone,
customerEmail: payload.customerEmail,
isRecurring: true,
recurringEndDate: payload.recurringEndDate,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
await queryClient.invalidateQueries({ queryKey: ['manual-booking-availability'] });
setIsCreateBookingOpen(false);
setSelectedSlot(null);
},
});
const updateStatusMutation = useMutation({
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW' }) =>
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
@@ -413,6 +446,16 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
URL.revokeObjectURL(url);
}, [bookings, complex.complexSlug, selectedDate]);
const planFeatures = complex.planFeatures ?? null;
const isRecurringEnabled = planFeatures?.fixedSlots ?? false;
const createRecurringBooking = useCallback(
async (payload: CreateRecurringBookingPayload) => {
await createRecurringBookingMutation.mutateAsync(payload);
},
[createRecurringBookingMutation]
);
const value = useMemo<BookingContextValue>(
() => ({
complex,
@@ -438,10 +481,17 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
)
: null,
isCreateBookingOpen,
createBookingError: createBookingMutation.isError
? extractMessage(createBookingMutation.error, 'No pudimos crear la reserva.')
: null,
isCreatingBooking: createBookingMutation.isPending,
createBookingError:
createBookingMutation.isError || createRecurringBookingMutation.isError
? extractMessage(
createBookingMutation.error ?? createRecurringBookingMutation.error,
'No pudimos crear la reserva.'
)
: null,
isCreatingBooking:
createBookingMutation.isPending || createRecurringBookingMutation.isPending,
planFeatures,
isRecurringEnabled,
setSelectedDate,
moveSelectedDate: (amount) => setSelectedDate((date) => addDaysIso(date, amount)),
setSelectedSportId,
@@ -451,6 +501,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
openCreateBooking,
closeCreateBooking,
createBooking,
createRecurringBooking,
updateBookingStatus,
exportDayReport,
bookingToolsOpen,
@@ -473,10 +524,16 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
createBookingMutation.error,
createBookingMutation.isError,
createBookingMutation.isPending,
createRecurringBooking,
createRecurringBookingMutation.error,
createRecurringBookingMutation.isError,
createRecurringBookingMutation.isPending,
currentTime,
exportDayReport,
isCreateBookingOpen,
openCreateBooking,
planFeatures,
isRecurringEnabled,
schedules,
selectedDate,
selectedSlot,

View File

@@ -18,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
@@ -60,12 +61,16 @@ export function BookingCreateDialog() {
isCreatingBooking,
closeCreateBooking,
createBooking,
createRecurringBooking,
isRecurringEnabled,
} = useBooking();
const [date, setDate] = useState(selectedSlot?.date ?? selectedDate);
const [sportId, setSportId] = useState(selectedSlot?.sportId ?? 'all');
const [courtId, setCourtId] = useState(selectedSlot?.courtId ?? '');
const [startTime, setStartTime] = useState(selectedSlot?.startTime ?? '');
const [isRecurring, setIsRecurring] = useState(false);
const [recurringEndDate, setRecurringEndDate] = useState<string | undefined>(undefined);
const {
register,
@@ -90,6 +95,8 @@ export function BookingCreateDialog() {
setSportId(selectedSlot?.sportId ?? 'all');
setCourtId(selectedSlot?.courtId ?? '');
setStartTime(selectedSlot?.startTime ?? '');
setIsRecurring(false);
setRecurringEndDate(undefined);
reset();
if (selectedSlot?.courtId && selectedSlot?.startTime) {
@@ -155,14 +162,26 @@ export function BookingCreateDialog() {
}, [availableStartTimes, startTime]);
const onSubmit = async (values: BookingForm) => {
await createBooking({
courtId,
date,
startTime,
customerName: values.customerName,
customerPhone: values.customerPhone,
customerEmail: values.customerEmail,
});
if (isRecurring) {
await createRecurringBooking({
courtId,
date,
startTime,
customerName: values.customerName,
customerPhone: values.customerPhone,
customerEmail: values.customerEmail,
recurringEndDate,
});
} else {
await createBooking({
courtId,
date,
startTime,
customerName: values.customerName,
customerPhone: values.customerPhone,
customerEmail: values.customerEmail,
});
}
};
return (
@@ -260,6 +279,52 @@ export function BookingCreateDialog() {
</Field>
</div>
{isRecurringEnabled && (
<>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="text-sm font-medium">Repetir todas las semanas</p>
<p className="text-xs text-muted-foreground">
{isRecurring
? `Se creará una reserva todos los ${getDayOfWeekLabel(date)} a las ${startTime || '...'}`
: 'Crea turnos fijos en el mismo horario'}
</p>
</div>
<Button
type="button"
variant={isRecurring ? 'default' : 'outline'}
size="sm"
onClick={() => setIsRecurring(!isRecurring)}
className="shrink-0"
>
{isRecurring ? 'Activado' : 'Desactivado'}
</Button>
</div>
{isRecurring && (
<Field>
<FieldLabel>
Repetir hasta{' '}
<span className="text-muted-foreground font-normal">(opcional)</span>
</FieldLabel>
<DatePicker
value={fromIsoDateLocal(recurringEndDate ?? '')}
onChange={(nextDate) => {
setRecurringEndDate(nextDate ? toIsoDateLocal(nextDate) : undefined);
}}
/>
<p className="mt-1 text-xs text-muted-foreground">
Si no se selecciona una fecha, se repetirá por tiempo indefinido.
</p>
</Field>
)}
</>
)}
<Separator />
<Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
<Input
@@ -312,10 +377,29 @@ export function BookingCreateDialog() {
form="booking-create-form"
disabled={!isValid || isCreatingBooking || !courtId || !startTime}
>
{isCreatingBooking ? 'Guardando...' : 'Crear reserva'}
{isCreatingBooking
? isRecurring
? 'Creando turnos...'
: 'Guardando...'
: isRecurring
? 'Crear turno fijo'
: 'Crear reserva'}
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}
function getDayOfWeekLabel(dateIso: string): string {
const labels: Record<string, string> = {
MONDAY: 'lunes',
TUESDAY: 'martes',
WEDNESDAY: 'miércoles',
THURSDAY: 'jueves',
FRIDAY: 'viernes',
SATURDAY: 'sábado',
SUNDAY: 'domingo',
};
return labels[getDayOfWeek(dateIso)] ?? '';
}

View File

@@ -13,6 +13,7 @@ const FEATURE_LABELS: Record<string, string> = {
onlinePayments: 'Pagos online',
advancedReports: 'Reportes avanzados',
whatsappReminders: 'Recordatorios WhatsApp',
fixedSlots: 'Turnos fijos',
};
export function PlanSelectStep({ form, plans }: PlanSelectStepProps) {
@@ -24,6 +25,7 @@ export function PlanSelectStep({ form, plans }: PlanSelectStepProps) {
'onlinePayments',
'advancedReports',
'whatsappReminders',
'fixedSlots',
] as const;
return (

View File

@@ -74,6 +74,8 @@ export const apiClient = {
adminBookings: {
listByComplex: api.listByComplex,
create: api.createAdmin,
createRecurring: api.createRecurring,
cancelRecurringGroup: api.cancelRecurringGroup,
updateStatus: api.updateStatus,
},
};

View File

@@ -15,5 +15,7 @@ export {
getConfirmation,
listByComplex,
createAdmin,
createRecurring,
cancelRecurringGroup,
updateStatus,
} from './resources/bookings';

View File

@@ -6,8 +6,10 @@ import type {
PublicAvailabilityResponse,
PublicBooking,
PublicBookingConfirmation,
RecurringBookingGroup,
UpdateAdminBookingStatusInput,
} from '@repo/api-contract';
import type { CreateRecurringBookingInput } from '@repo/api-contract';
import { http } from '../http';
export async function getAvailability(
@@ -52,6 +54,21 @@ export async function createAdmin(complexId: string, payload: CreateAdminBooking
return response.data;
}
export async function createRecurring(complexId: string, payload: CreateRecurringBookingInput) {
const response = await http.post<RecurringBookingGroup>(
`/api/admin-bookings/complex/${complexId}/recurring`,
payload
);
return response.data;
}
export async function cancelRecurringGroup(groupId: string) {
const response = await http.post<{ ok: boolean }>(
`/api/admin-bookings/recurring/${groupId}/cancel`
);
return response.data;
}
export async function cancelPublic(complexSlug: string, payload: CancelPublicBookingInput) {
const response = await http.post<PublicBooking>(
`/api/public-bookings/complex/${complexSlug}/cancel`,