feat: add reschedule functionality for admin bookings

- Implemented rescheduleAdminBooking service to allow users to change court and time for confirmed bookings.
- Added validation for court availability, maintenance status, and overlapping bookings.
- Created reschedule-admin-booking handler to process rescheduling requests and send confirmation emails.
- Updated booking email service to include rescheduling notifications.
- Enhanced frontend components to support booking rescheduling, including a new dialog for selecting new court and time.
- Added tests for rescheduling logic, covering various scenarios including validation errors and successful reschedules.
- Updated Prisma schema to log previous court and time for audit purposes.
This commit is contained in:
Jose Selesan
2026-06-24 08:38:10 -03:00
parent fd4d8b7abd
commit dce312d426
23 changed files with 1231 additions and 17 deletions

View File

@@ -5,6 +5,7 @@ import type {
Court,
PlanFeatureFlags,
RecurringBookingGroup,
RescheduleAdminBookingInput,
} from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
@@ -100,6 +101,7 @@ interface BookingContextValue {
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
createRecurringBooking: (payload: CreateRecurringBookingPayload) => Promise<void>;
updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => void;
rescheduleBooking: (bookingId: string, input: RescheduleAdminBookingInput) => void;
exportDayReport: () => void;
openBookingTools: (segment?: BookingTimelineSegment) => void;
closeBookingTools: () => void;
@@ -413,7 +415,26 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
[selectedDate]
);
const closeBookingTools = () => setBookingToolsOpen(false);
const closeBookingTools = () => {
setBookingToolsOpen(false);
setSelectedSegment(null);
};
const rescheduleMutation = useMutation({
mutationFn: (payload: { bookingId: string; input: RescheduleAdminBookingInput }) =>
apiClient.adminBookings.reschedule(payload.bookingId, payload.input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
closeBookingTools();
},
});
const rescheduleBooking = useCallback(
(bookingId: string, input: RescheduleAdminBookingInput) => {
rescheduleMutation.mutate({ bookingId, input });
},
[rescheduleMutation]
);
useEffect(() => {
const handleOpenCreateBooking = () => {
@@ -534,6 +555,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
createBooking,
createRecurringBooking,
updateBookingStatus,
rescheduleBooking,
exportDayReport,
bookingToolsOpen,
openBookingTools,
@@ -573,6 +595,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
summary,
timelineHours,
updateBookingStatus,
rescheduleBooking,
viewMode,
visibleTimeRange,
bookingToolsOpen,

View File

@@ -0,0 +1,203 @@
import { Button } from '@/components/ui/button';
import {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from '@/components/ui/responsive-dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { AdminBooking } from '@repo/api-contract';
import { CalendarSync } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useBooking } from '../booking-provider';
import {
getDayOfWeek,
getNowTime,
isTodayIso,
minutesToTime,
timeToMinutes,
} from '../lib/booking-time';
interface BookingRescheduleDialogProps {
booking: AdminBooking;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function BookingRescheduleDialog({
booking,
open,
onOpenChange,
}: BookingRescheduleDialogProps) {
const { courts, bookings, rescheduleBooking } = useBooking();
const [courtId, setCourtId] = useState(booking.courtId);
const [startTime, setStartTime] = useState(booking.startTime);
const sportId = booking.sport.id;
const filteredCourts = useMemo(
() => courts.filter((c) => c.sportId === sportId),
[courts, sportId]
);
const selectedCourt = filteredCourts.find((c) => c.id === courtId);
const dayOfWeek = getDayOfWeek(booking.date);
const availableStartTimes = useMemo(() => {
if (!selectedCourt) return [];
const times: string[] = [];
const dayAvailability = selectedCourt.availability
.filter((range) => range.dayOfWeek === dayOfWeek)
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
const courtBookings = bookings.filter(
(b) =>
b.date === booking.date &&
b.courtId === selectedCourt.id &&
b.id !== booking.id &&
b.status !== 'CANCELLED'
);
for (const range of dayAvailability) {
const start = timeToMinutes(range.startTime);
const end = timeToMinutes(range.endTime);
for (
let minute = start;
minute + selectedCourt.slotDurationMinutes <= end;
minute += selectedCourt.slotDurationMinutes
) {
const slotEnd = minute + selectedCourt.slotDurationMinutes;
if (isTodayIso(booking.date) && minute <= timeToMinutes(getNowTime())) continue;
const isBooked = courtBookings.some((b) => {
const bookingStart = timeToMinutes(b.startTime);
const bookingEnd = timeToMinutes(b.endTime);
return minute < bookingEnd && slotEnd > bookingStart;
});
if (!isBooked) {
times.push(minutesToTime(minute));
}
}
}
return times;
}, [bookings, booking.date, booking.id, dayOfWeek, selectedCourt]);
const hasChanges = courtId !== booking.courtId || startTime !== booking.startTime;
const handleConfirm = () => {
if (!hasChanges) return;
const payload: Record<string, string> = {};
if (courtId !== booking.courtId) payload.courtId = courtId;
if (startTime !== booking.startTime) payload.startTime = startTime;
rescheduleBooking(booking.id, payload);
onOpenChange(false);
};
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setCourtId(booking.courtId);
setStartTime(booking.startTime);
}
onOpenChange(newOpen);
};
return (
<ResponsiveDialog open={open} onOpenChange={handleOpenChange}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Reprogramar reserva</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Cambiá la cancha y/o el horario de la reserva para el mismo día.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<div className="space-y-4">
<div className="rounded-lg border bg-card p-3 text-sm text-muted-foreground">
Reserva <strong>{booking.bookingCode}</strong> &mdash; {booking.customerName}
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Cancha</p>
<Select value={courtId} onValueChange={setCourtId}>
<SelectTrigger>
<SelectValue placeholder="Seleccionar cancha" />
</SelectTrigger>
<SelectContent>
{filteredCourts.map((court) => (
<SelectItem key={court.id} value={court.id} disabled={court.isUnderMaintenance}>
<span className="flex items-center gap-2">
{court.name} &mdash; {court.sport.name}
{court.isUnderMaintenance && (
<span className="ml-auto text-xs text-muted-foreground">
(En mantenimiento)
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Horario</p>
<Select
value={startTime}
onValueChange={setStartTime}
disabled={!selectedCourt || availableStartTimes.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
selectedCourt
? availableStartTimes.length === 0
? 'Sin horarios disponibles'
: 'Seleccionar horario'
: 'Seleccioná una cancha primero'
}
/>
</SelectTrigger>
<SelectContent>
{availableStartTimes.map((time) => {
const slotMinutes = timeToMinutes(time);
const endMinutes = slotMinutes + (selectedCourt?.slotDurationMinutes ?? 0);
return (
<SelectItem key={time} value={time}>
{time} &mdash; {minutesToTime(endMinutes)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Cancelar</Button>
</ResponsiveDialogClose>
<Button onClick={handleConfirm} disabled={!hasChanges || !startTime || !courtId}>
<CalendarSync className="mr-2 h-4 w-4" />
Confirmar reprogramación
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -15,6 +15,7 @@ import {
AlertTriangle,
BadgeCheck,
CalendarDays,
CalendarSync,
CheckCircle2,
Clock,
MapPin,
@@ -26,6 +27,7 @@ import {
} from 'lucide-react';
import { useState } from 'react';
import { useBooking } from '../booking-provider';
import { BookingRescheduleDialog } from './booking-reschedule-dialog';
function formatDate(date: string | undefined) {
if (!date) return '';
@@ -72,6 +74,7 @@ export function BookingToolsDialog() {
const isMobile = useIsMobile();
const booking = selectedSegment?.booking;
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
const [rescheduleOpen, setRescheduleOpen] = useState(false);
const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? {
label: booking?.status,
@@ -165,10 +168,19 @@ export function BookingToolsDialog() {
onCancel={() => setConfirmCancelOpen(true)}
onReminder={sendWhatsappReminder}
onNoShow={() => updateStatus('NOSHOW')}
onReschedule={() => setRescheduleOpen(true)}
/>
</ResponsiveDialogContent>
</ResponsiveDialog>
{cancelConfirmDialog}
{booking && (
<BookingRescheduleDialog
booking={booking}
open={rescheduleOpen}
onOpenChange={setRescheduleOpen}
/>
)}
</>
);
}
@@ -268,6 +280,14 @@ export function BookingToolsDialog() {
onClick={() => setConfirmCancelOpen(true)}
/>
<ActionButton
icon={<CalendarSync className="h-5 w-5" />}
title="Reprogramar"
description="Cambiar la cancha y/o el horario de la reserva."
className="border-violet-500/40 bg-violet-500/10 text-lg text-violet-600 hover:bg-violet-500/15 hover:text-violet-800"
onClick={() => setRescheduleOpen(true)}
/>
<ActionButton
icon={<MessageCircle className="h-5 w-5" />}
title="Enviar recordatorio"
@@ -299,6 +319,14 @@ export function BookingToolsDialog() {
</ResponsiveDialogContent>
</ResponsiveDialog>
{cancelConfirmDialog}
{booking && (
<BookingRescheduleDialog
booking={booking}
open={rescheduleOpen}
onOpenChange={setRescheduleOpen}
/>
)}
</>
);
}
@@ -309,12 +337,14 @@ function MobileBookingToolsSheet({
onCancel,
onReminder,
onNoShow,
onReschedule,
}: {
status: { label: string | undefined; className: string };
onComplete: () => void;
onCancel: () => void;
onReminder: () => void;
onNoShow: () => void;
onReschedule: () => void;
}) {
const { selectedSegment } = useBooking();
const booking = selectedSegment?.booking;
@@ -395,6 +425,12 @@ function MobileBookingToolsSheet({
className="border-primary/45 bg-primary/12 text-primary"
onClick={onComplete}
/>
<MobileActionButton
icon={<CalendarSync className="size-5" />}
label="Reprogramar"
className="border-violet-500/45 bg-violet-500/12 text-violet-500"
onClick={onReschedule}
/>
<MobileActionButton
icon={<MessageCircle className="size-5" />}
label="Recordar"

View File

@@ -77,6 +77,7 @@ export const apiClient = {
createRecurring: api.createRecurring,
cancelRecurringGroup: api.cancelRecurringGroup,
listRecurringGroups: api.listRecurringGroups,
reschedule: api.reschedule,
updateRecurringGroup: api.updateRecurringGroup,
updateStatus: api.updateStatus,
},

View File

@@ -18,6 +18,7 @@ export {
createRecurring,
cancelRecurringGroup,
listRecurringGroups,
reschedule,
updateRecurringGroup,
updateStatus,
} from './resources/bookings';

View File

@@ -8,6 +8,7 @@ import type {
PublicBooking,
PublicBookingConfirmation,
RecurringBookingGroup,
RescheduleAdminBookingInput,
UpdateAdminBookingStatusInput,
UpdateRecurringGroupInput,
} from '@repo/api-contract';
@@ -101,3 +102,11 @@ export async function updateStatus(bookingId: string, payload: UpdateAdminBookin
);
return response.data;
}
export async function reschedule(bookingId: string, payload: RescheduleAdminBookingInput) {
const response = await http.patch<AdminBooking>(
`/api/admin-bookings/${bookingId}/reschedule`,
payload
);
return response.data;
}