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:
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)] ?? '';
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -74,6 +74,8 @@ export const apiClient = {
|
||||
adminBookings: {
|
||||
listByComplex: api.listByComplex,
|
||||
create: api.createAdmin,
|
||||
createRecurring: api.createRecurring,
|
||||
cancelRecurringGroup: api.cancelRecurringGroup,
|
||||
updateStatus: api.updateStatus,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,5 +15,7 @@ export {
|
||||
getConfirmation,
|
||||
listByComplex,
|
||||
createAdmin,
|
||||
createRecurring,
|
||||
cancelRecurringGroup,
|
||||
updateStatus,
|
||||
} from './resources/bookings';
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user