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

@@ -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>
);
}