feat: add recurring booking management functionality

- Implemented API endpoints for listing and updating recurring booking groups.
- Added handlers for listing and updating recurring groups in the backend.
- Created frontend components for displaying and editing recurring bookings.
- Enhanced booking provider to manage recurring groups and their states.
- Updated API client to include new methods for recurring bookings.
- Introduced new validation schemas for updating recurring groups.
- Added UI elements for managing recurring bookings in the booking interface.
This commit is contained in:
Jose Selesan
2026-06-16 15:16:38 -03:00
parent 1318e3bf57
commit 3b3def94ab
15 changed files with 936 additions and 5 deletions

View File

@@ -1,5 +1,11 @@
import { ApiClientError, apiClient } from '@/lib/api-client';
import type { AdminBooking, ComplexWithRole, Court, PlanFeatureFlags } from '@repo/api-contract';
import type {
AdminBooking,
ComplexWithRole,
Court,
PlanFeatureFlags,
RecurringBookingGroup,
} from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
type ReactNode,
@@ -85,6 +91,11 @@ interface BookingContextValue {
setViewMode: (mode: BookingViewMode) => void;
setVisibleTimeRange: (range: BookingTimeRange) => void;
openCreateBooking: (draft?: Partial<BookingSlotDraft>) => void;
recurringGroups: RecurringBookingGroup[];
isRecurringGroupsLoading: boolean;
cancelRecurringGroup: (groupId: string) => void;
isCancellingRecurringGroup: boolean;
refreshRecurringGroups: () => void;
closeCreateBooking: () => void;
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
createRecurringBooking: (payload: CreateRecurringBookingPayload) => Promise<void>;
@@ -258,6 +269,20 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
queryFn: () => apiClient.courts.listByComplex(complex.id),
});
const recurringGroupsQuery = useQuery({
queryKey: ['recurring-groups', complex.id],
queryFn: () => apiClient.adminBookings.listRecurringGroups(complex.id),
enabled: viewMode === 'recurring',
});
const cancelRecurringGroupMutation = useMutation({
mutationFn: (groupId: string) => apiClient.adminBookings.cancelRecurringGroup(groupId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['recurring-groups', complex.id] });
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
},
});
const bookingsQuery = useQuery({
queryKey: ['admin-bookings', complex.id, selectedDate],
queryFn: () =>
@@ -480,6 +505,12 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
'No pudimos cargar el panel de reservas.'
)
: null,
recurringGroups: recurringGroupsQuery.data?.groups ?? [],
isRecurringGroupsLoading: recurringGroupsQuery.isLoading,
cancelRecurringGroup: (groupId: string) => cancelRecurringGroupMutation.mutate(groupId),
isCancellingRecurringGroup: cancelRecurringGroupMutation.isPending,
refreshRecurringGroups: () =>
queryClient.invalidateQueries({ queryKey: ['recurring-groups', complex.id] }),
isCreateBookingOpen,
createBookingError:
createBookingMutation.isError || createRecurringBookingMutation.isError
@@ -545,6 +576,9 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
viewMode,
visibleTimeRange,
bookingToolsOpen,
recurringGroupsQuery.data?.groups,
recurringGroupsQuery.isLoading,
cancelRecurringGroupMutation.isPending,
]
);

View File

@@ -12,6 +12,7 @@ import { BookingMobile } from './components/booking-mobile';
import { BookingTimeline } from './components/booking-timeline';
import { BookingToolbar } from './components/booking-toolbar';
import { BookingToolsDialog } from './components/booking-tools-dialog';
import { RecurringBookingListView } from './components/recurring-booking-list';
interface BookingProps {
complex: ComplexWithRole;
@@ -71,6 +72,10 @@ function BookingInner() {
return <BookingListView />;
}
if (viewMode === 'recurring') {
return <RecurringBookingListView />;
}
if (isMobile) {
return <BookingMobile />;
}

View File

@@ -1,6 +1,6 @@
import type { AdminBooking, Court } from '@repo/api-contract';
export type BookingViewMode = 'panel' | 'status' | 'list';
export type BookingViewMode = 'panel' | 'status' | 'list' | 'recurring';
export type BookingStatusFilter = 'all' | 'free' | 'reserved';
export type BookingSegmentStatus = 'free' | 'reserved';

View File

@@ -1,4 +1,4 @@
import { CalendarDays, Download, ShieldCheck, UsersRound } from 'lucide-react';
import { CalendarDays, Download, Repeat, ShieldCheck, UsersRound } from 'lucide-react';
import { useBooking } from '../booking-provider';
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
@@ -66,7 +66,7 @@ function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCar
}
export function BookingQuickActions() {
const { setSelectedDate, setViewMode, exportDayReport } = useBooking();
const { setSelectedDate, setViewMode, exportDayReport, isRecurringEnabled } = useBooking();
const todayIso = toIsoDateLocal(new Date());
return (
@@ -92,6 +92,16 @@ export function BookingQuickActions() {
<Download className="size-4 text-primary" />
Exportar reporte
</button>
{isRecurringEnabled && (
<button
type="button"
onClick={() => setViewMode('recurring')}
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted"
>
<Repeat className="size-4 text-muted-foreground" />
Ver turnos fijos
</button>
)}
</div>
</section>
);

View File

@@ -0,0 +1,210 @@
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiClient } from '@/lib/api-client';
import type { RecurringBookingGroup } from '@repo/api-contract';
import { useEffect, useState } from 'react';
import { useBooking } from '../booking-provider';
import { minutesToTime, timeToMinutes } from '../lib/booking-time';
const DAY_OPTIONS = [
{ value: 'MONDAY', label: 'Lunes' },
{ value: 'TUESDAY', label: 'Martes' },
{ value: 'WEDNESDAY', label: 'Miércoles' },
{ value: 'THURSDAY', label: 'Jueves' },
{ value: 'FRIDAY', label: 'Viernes' },
{ value: 'SATURDAY', label: 'Sábado' },
{ value: 'SUNDAY', label: 'Domingo' },
];
interface RecurringBookingEditDialogProps {
group: RecurringBookingGroup;
open: boolean;
onClose: () => void;
}
export function RecurringBookingEditDialog({
group,
open,
onClose,
}: RecurringBookingEditDialogProps) {
const { courts, refreshRecurringGroups } = useBooking();
const [courtId, setCourtId] = useState<string>(group.courtId);
const [dayOfWeek, setDayOfWeek] = useState<string>(group.dayOfWeek);
const [startTime, setStartTime] = useState<string>(group.startTime);
const [customerName, setCustomerName] = useState(group.customerName);
const [customerPhone, setCustomerPhone] = useState(group.customerPhone);
const [customerEmail, setCustomerEmail] = useState(group.customerEmail);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
setCourtId(group.courtId);
setDayOfWeek(group.dayOfWeek);
setStartTime(group.startTime);
setCustomerName(group.customerName);
setCustomerPhone(group.customerPhone);
setCustomerEmail(group.customerEmail);
}, [group]);
const selectedCourt = courts.find((c) => c.id === courtId);
const slotDuration = selectedCourt?.slotDurationMinutes ?? 60;
const availableSlots: string[] = [];
if (selectedCourt) {
const availabilities = selectedCourt.availability.filter((a) => a.dayOfWeek === dayOfWeek);
for (const avail of availabilities) {
const startMins = timeToMinutes(avail.startTime);
const endMins = timeToMinutes(avail.endTime);
for (let m = startMins; m + slotDuration <= endMins; m += slotDuration) {
availableSlots.push(minutesToTime(m));
}
}
}
const handleSave = async () => {
if (!customerName || customerName.trim().length < 2) {
setError('El nombre debe tener al menos 2 caracteres.');
return;
}
setError('');
setSaving(true);
try {
await apiClient.adminBookings.updateRecurringGroup(group.id, {
...(courtId !== group.courtId && { courtId }),
...(dayOfWeek !== group.dayOfWeek && {
dayOfWeek: dayOfWeek as
| 'MONDAY'
| 'TUESDAY'
| 'WEDNESDAY'
| 'THURSDAY'
| 'FRIDAY'
| 'SATURDAY'
| 'SUNDAY',
}),
...(startTime !== group.startTime && { startTime }),
...(customerName !== group.customerName && { customerName }),
...(customerPhone !== group.customerPhone && { customerPhone }),
...(customerEmail !== group.customerEmail && { customerEmail }),
});
await refreshRecurringGroups();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : 'Error al guardar los cambios.');
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Editar turno fijo</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Cliente</Label>
<Input
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="Nombre del cliente"
/>
</div>
<div className="space-y-1.5">
<Label>Teléfono</Label>
<Input
value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)}
placeholder="Teléfono"
/>
</div>
<div className="space-y-1.5">
<Label>Email</Label>
<Input
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
placeholder="Email"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Cancha</Label>
<Select value={courtId} onValueChange={setCourtId}>
<SelectTrigger>
<SelectValue placeholder="Cancha" />
</SelectTrigger>
<SelectContent>
{courts.map((court) => (
<SelectItem key={court.id} value={court.id}>
{court.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Día</Label>
<Select value={dayOfWeek} onValueChange={setDayOfWeek}>
<SelectTrigger>
<SelectValue placeholder="Día" />
</SelectTrigger>
<SelectContent>
{DAY_OPTIONS.map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label>Horario</Label>
<Select value={startTime} onValueChange={setStartTime}>
<SelectTrigger>
<SelectValue placeholder="Horario" />
</SelectTrigger>
<SelectContent>
{availableSlots.map((slot) => (
<SelectItem key={slot} value={slot}>
{slot} - {minutesToTime(timeToMinutes(slot) + slotDuration)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button type="button" onClick={handleSave} disabled={saving}>
{saving ? 'Guardando...' : 'Guardar cambios'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,246 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { RecurringBookingGroup } from '@repo/api-contract';
import { ArrowLeft, Clock, MapPin, Pencil, Repeat, Trash2, User } from 'lucide-react';
import { useState } from 'react';
import { useBooking } from '../booking-provider';
import { RecurringBookingEditDialog } from './recurring-booking-edit-dialog';
const DAY_LABELS: Record<string, string> = {
MONDAY: 'Lunes',
TUESDAY: 'Martes',
WEDNESDAY: 'Miércoles',
THURSDAY: 'Jueves',
FRIDAY: 'Viernes',
SATURDAY: 'Sábado',
SUNDAY: 'Domingo',
};
function getNextBookingDate(group: RecurringBookingGroup): string {
const today = new Date().toISOString().slice(0, 10);
const future = group.bookings
.filter((b) => b.date >= today && b.status === 'CONFIRMED')
.sort((a, b) => a.date.localeCompare(b.date));
return future[0]?.date ?? '-';
}
function formatDate(dateStr: string): string {
if (dateStr === '-') return '-';
const [y, m, d] = dateStr.split('-');
return `${d}/${m}/${y}`;
}
export function RecurringBookingListView() {
const {
setViewMode,
recurringGroups,
isRecurringGroupsLoading,
cancelRecurringGroup,
isCancellingRecurringGroup,
} = useBooking();
const [editingGroup, setEditingGroup] = useState<RecurringBookingGroup | null>(null);
const [cancellingGroupId, setCancellingGroupId] = useState<string | null>(null);
return (
<>
<section className="rounded-lg border bg-card/85 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-4">
<div className="flex items-center gap-3">
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full"
onClick={() => setViewMode('panel')}
>
<ArrowLeft className="size-5" />
</Button>
<div>
<h2 className="text-base font-semibold">Turnos fijos</h2>
<p className="text-sm text-muted-foreground">
{recurringGroups.length}{' '}
{recurringGroups.length === 1 ? 'turno fijo' : 'turnos fijos'}
</p>
</div>
</div>
</div>
{isRecurringGroupsLoading && (
<div className="px-4 py-10 text-sm text-muted-foreground">Cargando turnos fijos...</div>
)}
{!isRecurringGroupsLoading && recurringGroups.length === 0 && (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
<Repeat className="mx-auto mb-3 size-10 opacity-40" />
<p>No hay turnos fijos creados.</p>
</div>
)}
{!isRecurringGroupsLoading && recurringGroups.length > 0 && (
<div className="overflow-x-auto">
<table className="hidden w-full sm:table">
<thead>
<tr className="border-b text-left text-xs font-medium uppercase tracking-wider text-muted-foreground">
<th className="px-4 py-3 font-medium">Cliente</th>
<th className="px-4 py-3 font-medium">Cancha</th>
<th className="px-4 py-3 font-medium">Día</th>
<th className="px-4 py-3 font-medium">Horario</th>
<th className="px-4 py-3 font-medium">Próxima fecha</th>
<th className="px-4 py-3 font-medium">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{recurringGroups.map((group) => {
const nextDate = getNextBookingDate(group);
return (
<tr key={group.id} className="transition-colors hover:bg-muted/50">
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<User className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm">{group.customerName || '-'}</span>
</div>
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<MapPin className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm">
{group.bookings[0]?.courtName ?? '-'}
</span>
</div>
</td>
<td className="px-4 py-4 text-sm">
{DAY_LABELS[group.dayOfWeek] ?? group.dayOfWeek}
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<Clock className="size-4 shrink-0 text-muted-foreground" />
<span className="text-sm">
{group.startTime} - {group.endTime}
</span>
</div>
</td>
<td className="px-4 py-4 text-sm">{formatDate(nextDate)}</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setEditingGroup(group)}
className="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
>
<Pencil className="size-3.5" />
Editar
</button>
<button
type="button"
onClick={() => setCancellingGroupId(group.id)}
disabled={isCancellingRecurringGroup}
className="flex items-center gap-1.5 rounded-md border border-red-200 px-2.5 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50"
>
<Trash2 className="size-3.5" />
Cancelar
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="divide-y sm:hidden">
{recurringGroups.map((group) => {
const nextDate = getNextBookingDate(group);
return (
<div key={group.id} className="px-4 py-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="size-4 text-muted-foreground" />
<span className="text-sm font-semibold">{group.customerName || '-'}</span>
</div>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{DAY_LABELS[group.dayOfWeek]}
</span>
</div>
<p className="text-xs text-muted-foreground">
{group.bookings[0]?.courtName} · {group.startTime} - {group.endTime}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Próxima: {formatDate(nextDate)}
</p>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => setEditingGroup(group)}
className="flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors hover:bg-muted"
>
<Pencil className="size-3" />
Editar
</button>
<button
type="button"
onClick={() => setCancellingGroupId(group.id)}
disabled={isCancellingRecurringGroup}
className="flex items-center gap-1 rounded-md border border-red-200 px-2 py-1 text-xs text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50"
>
<Trash2 className="size-3" />
Cancelar
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</section>
<Dialog
open={!!cancellingGroupId}
onOpenChange={(open) => !open && setCancellingGroupId(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancelar turno fijo</DialogTitle>
<DialogDescription>
¿Estás seguro de cancelar este turno fijo? Se eliminarán todas las reservas futuras.
Las reservas pasadas no se verán afectadas.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCancellingGroupId(null)}>
Volver
</Button>
<Button
variant="destructive"
disabled={isCancellingRecurringGroup}
onClick={() => {
if (cancellingGroupId) {
cancelRecurringGroup(cancellingGroupId);
setCancellingGroupId(null);
}
}}
>
{isCancellingRecurringGroup ? 'Cancelando...' : 'Sí, cancelar turno fijo'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{editingGroup && (
<RecurringBookingEditDialog
group={editingGroup}
open={!!editingGroup}
onClose={() => setEditingGroup(null)}
/>
)}
</>
);
}

View File

@@ -76,6 +76,8 @@ export const apiClient = {
create: api.createAdmin,
createRecurring: api.createRecurring,
cancelRecurringGroup: api.cancelRecurringGroup,
listRecurringGroups: api.listRecurringGroups,
updateRecurringGroup: api.updateRecurringGroup,
updateStatus: api.updateStatus,
},
};

View File

@@ -17,5 +17,7 @@ export {
createAdmin,
createRecurring,
cancelRecurringGroup,
listRecurringGroups,
updateRecurringGroup,
updateStatus,
} from './resources/bookings';

View File

@@ -3,11 +3,13 @@ import type {
CancelPublicBookingInput,
CreateAdminBookingInput,
CreatePublicBookingInput,
ListRecurringGroupsResponse,
PublicAvailabilityResponse,
PublicBooking,
PublicBookingConfirmation,
RecurringBookingGroup,
UpdateAdminBookingStatusInput,
UpdateRecurringGroupInput,
} from '@repo/api-contract';
import type { CreateRecurringBookingInput } from '@repo/api-contract';
import { http } from '../http';
@@ -69,6 +71,21 @@ export async function cancelRecurringGroup(groupId: string) {
return response.data;
}
export async function listRecurringGroups(complexId: string) {
const response = await http.get<ListRecurringGroupsResponse>(
`/api/admin-bookings/complex/${complexId}/recurring`
);
return response.data;
}
export async function updateRecurringGroup(groupId: string, payload: UpdateRecurringGroupInput) {
const response = await http.patch<RecurringBookingGroup>(
`/api/admin-bookings/recurring/${groupId}`,
payload
);
return response.data;
}
export async function cancelPublic(complexSlug: string, payload: CancelPublicBookingInput) {
const response = await http.post<PublicBooking>(
`/api/public-bookings/complex/${complexSlug}/cancel`,