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:
@@ -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> — {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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user