- 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.
204 lines
6.6 KiB
TypeScript
204 lines
6.6 KiB
TypeScript
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> — {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} — {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} — {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>
|
|
);
|
|
}
|