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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
createRecurring,
|
||||
cancelRecurringGroup,
|
||||
listRecurringGroups,
|
||||
reschedule,
|
||||
updateRecurringGroup,
|
||||
updateStatus,
|
||||
} from './resources/bookings';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user