diff --git a/apps/frontend/src/features/booking/booking-provider.tsx b/apps/frontend/src/features/booking/booking-provider.tsx index 0250121..8781102 100644 --- a/apps/frontend/src/features/booking/booking-provider.tsx +++ b/apps/frontend/src/features/booking/booking-provider.tsx @@ -57,12 +57,14 @@ interface BookingContextValue { summary: BookingSummary; currentTime: string | null; selectedSlot: BookingSlotDraft | null; + selectedSegment: BookingTimelineSegment | null; isLoading: boolean; isError: boolean; errorMessage: string | null; isCreateBookingOpen: boolean; createBookingError: string | null; isCreatingBooking: boolean; + bookingToolsOpen: boolean; setSelectedDate: (date: string) => void; moveSelectedDate: (amount: number) => void; setSelectedSportId: (sportId: string) => void; @@ -72,8 +74,10 @@ interface BookingContextValue { openCreateBooking: (draft?: Partial) => void; closeCreateBooking: () => void; createBooking: (payload: CreateManualBookingPayload) => Promise; - updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED') => void; + updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => void; exportDayReport: () => void; + openBookingTools: (segment?: BookingTimelineSegment) => void; + closeBookingTools: () => void; } const BookingContext = createContext(null); @@ -230,6 +234,8 @@ export function BookingProvider({ children, complex }: BookingProviderProps) { const [visibleTimeRange, setVisibleTimeRange] = useState(DEFAULT_TIME_RANGE); const [selectedSlot, setSelectedSlot] = useState(null); const [isCreateBookingOpen, setIsCreateBookingOpen] = useState(false); + const [selectedSegment, setSelectedSegment] = useState(null); + const [bookingToolsOpen, setBookingToolsOpen] = useState(false); const courtsQuery = useQuery({ queryKey: ['courts', complex.id], @@ -249,7 +255,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) { [bookingsQuery.data?.bookings, selectedDate] ); - const courts = useMemo(() => courtsQuery.data ?? [], [courtsQuery.data]); + const courts = useMemo(() => courtsQuery.data?.sort((a, b) => a.name.localeCompare(b.name)) ?? [], [courtsQuery.data]); const filteredCourts = useMemo(() => { if (selectedSportId === 'all') return courts; @@ -315,7 +321,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) { }); const updateStatusMutation = useMutation({ - mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) => + mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW' }) => apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] }); @@ -335,6 +341,15 @@ export function BookingProvider({ children, complex }: BookingProviderProps) { [selectedDate] ); + const openBookingTools = useCallback( + (segment?: BookingTimelineSegment) => { + setSelectedSegment(segment ?? null); + setBookingToolsOpen(true); + console.log(JSON.stringify(segment, null, 2)) + }, [selectedDate]); + + const closeBookingTools = () => setBookingToolsOpen(false) + useEffect(() => { const handleOpenCreateBooking = () => { openCreateBooking(); @@ -361,7 +376,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) { ); const updateBookingStatus = useCallback( - (bookingId: string, status: 'CANCELLED' | 'COMPLETED') => { + (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => { updateStatusMutation.mutate({ bookingId, status }); }, [updateStatusMutation] @@ -431,6 +446,10 @@ export function BookingProvider({ children, complex }: BookingProviderProps) { createBooking, updateBookingStatus, exportDayReport, + bookingToolsOpen, + openBookingTools, + closeBookingTools, + selectedSegment }), [ bookings, @@ -461,6 +480,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) { updateBookingStatus, viewMode, visibleTimeRange, + bookingToolsOpen ] ); diff --git a/apps/frontend/src/features/booking/booking.tsx b/apps/frontend/src/features/booking/booking.tsx index ebdaea5..004f07d 100644 --- a/apps/frontend/src/features/booking/booking.tsx +++ b/apps/frontend/src/features/booking/booking.tsx @@ -5,6 +5,7 @@ import { BookingDaySummary, BookingQuickActions } from './components/booking-day import { BookingHeader } from './components/booking-header'; import { BookingTimeline } from './components/booking-timeline'; import { BookingToolbar } from './components/booking-toolbar'; +import { BookingToolsDialog } from './components/booking-tools-dialog'; interface BookingProps { complex: ComplexWithRole; @@ -23,6 +24,7 @@ export function Booking({ complex }: BookingProps) { + ); } diff --git a/apps/frontend/src/features/booking/components/booking-timeline.tsx b/apps/frontend/src/features/booking/components/booking-timeline.tsx index ec0a98c..372fa94 100644 --- a/apps/frontend/src/features/booking/components/booking-timeline.tsx +++ b/apps/frontend/src/features/booking/components/booking-timeline.tsx @@ -210,7 +210,7 @@ interface BookingSlotBlockProps { } function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: BookingSlotBlockProps) { - const { openCreateBooking, updateBookingStatus } = useBooking(); + const { openCreateBooking, updateBookingStatus, openBookingTools } = useBooking(); const left = ((segment.startMinutes - rangeStart) / totalMinutes) * 100; const width = ((segment.endMinutes - segment.startMinutes) / totalMinutes) * 100; const isFree = segment.status === 'free'; @@ -225,13 +225,16 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki segment.status === 'reserved' && 'border-reserved/70 bg-reserved/80 text-reserved-foreground', segment.status === 'maintenance' && - 'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground' + 'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground', + segment.booking?.status === 'COMPLETED' && + 'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through', + )} style={{ left: `calc(${left}% + 4px)`, width: `calc(${width}% - 8px)`, }} - onClick={() => { + onClick={(event) => { if (isFree) { openCreateBooking({ courtId: schedule.court.id, @@ -239,13 +242,11 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki sportId: schedule.court.sportId, }); } else { - //console.log(JSON.stringify(segment, null, 2)); - setPopoverOpen(true); - } - }} - onDoubleClick={() => { - if (segment.booking?.status === 'CONFIRMED') { - updateBookingStatus(segment.booking.id, 'COMPLETED'); + if(event.shiftKey && segment.booking?.status === 'CONFIRMED') { + updateBookingStatus(segment.booking.id, 'COMPLETED'); + } else { + openBookingTools(segment); + } } }} title={ diff --git a/apps/frontend/src/features/booking/components/booking-tools-dialog.tsx b/apps/frontend/src/features/booking/components/booking-tools-dialog.tsx new file mode 100644 index 0000000..debaf9d --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-tools-dialog.tsx @@ -0,0 +1,303 @@ +import { ResponsiveDialog, ResponsiveDialogClose, ResponsiveDialogContent, ResponsiveDialogDescription, ResponsiveDialogFooter, ResponsiveDialogHeader, ResponsiveDialogTitle } from "@/components/ui/responsive-dialog"; +import { useBooking } from "../booking-provider"; +import { Button } from "@/components/ui/button"; +import { + CheckCircle2, + Clock, + MapPin, + MessageCircle, + Phone, + User, + XCircle, + AlertTriangle, + BadgeCheck, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; + +function formatDate(date: string | undefined) { + if (!date) return ""; + + const formatted = new Intl.DateTimeFormat("es-AR", { + weekday: "long", + day: "numeric", + month: "long", + }).format(new Date(`${date}T00:00:00`)); + + return formatted + .split(" ") + .map((word, index) => { + if (index === 0 || index === 3) { + return word.charAt(0).toUpperCase() + word.slice(1); + } + return word; + }) + .join(" "); +} + +const statusConfig: Record = { + + CONFIRMED: { + label: "Reservada", + className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + }, + COMPLETED: { + label: "Completada", + className: "bg-sky-500/15 text-sky-400 border-sky-500/30", + }, + CANCELLED: { + label: "Cancelada", + className: "bg-red-500/15 text-red-400 border-red-500/30", + }, + NOSHOW: { + label: "No show", + className: "bg-amber-500/15 text-amber-400 border-amber-500/30", + }, + +}; + +export function BookingToolsDialog() { + const { + selectedSegment, + bookingToolsOpen, + closeBookingTools, + updateBookingStatus, + } = useBooking(); + + + const status = statusConfig[selectedSegment?.booking?.status ?? "CONFIRMED"] ?? { + label: selectedSegment?.booking?.status, + className: "bg-slate-500/15 text-slate-300 border-slate-500/30", + }; + + const sendWhatsappReminder = () => { + const phone = selectedSegment?.booking?.customerPhone; + const message = ` + Hola ${selectedSegment?.booking?.customerName}. \nTe recordamos que tenés una reserva para el día *${formatDate(selectedSegment?.booking?.date)} de ${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime} en la cancha ${selectedSegment?.booking?.courtName} (${selectedSegment?.booking?.sport?.name})*. ¡Te esperamos! \nSi no vas a poder asistir, por favor avisanos para que otros clientes puedan reservar. Gracias.\n\n*Equipo de ${selectedSegment?.booking?.complexName}*`; + + const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`; + window.open(url, '_blank'); + } + + return ( + !open && closeBookingTools()} + > + event.preventDefault()} + > + + Detalles de la reserva + + Administra la reserva y su estado. + + + +
+
+ } + label="Cancha" + value={selectedSegment?.booking?.courtName ?? "-"} + helper={selectedSegment?.booking?.sport?.name} + withDivider + /> + + } + label="Estado actual" + value={status.label} + valueClassName="text-emerald-400 uppercase dark:text-emerald-500" + withDivider + /> + + } + label="Cliente" + value={selectedSegment?.booking?.customerName ?? "-"} + /> + + } + label="Fecha" + value={`${formatDate(selectedSegment?.booking?.date)}`} + withDivider + className="pt-10" + /> + + } + label="Hora" + value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`} + withDivider + className="pt-10" + /> + + } + label="Teléfono" + value={selectedSegment?.booking?.customerPhone ?? "-"} + className="pt-10" + /> + +
+ +
+ + + +
+
+

Acciones disponibles

+

+ Elegí qué hacer según lo que ocurrió con el cliente. +

+
+
+ } + title="Completar reserva" + description="El cliente se presentó para usar la cancha." + className="border-emerald-500/40 bg-emerald-500/10 text-emerald-600 text-lg hover:bg-emerald-500/15 hover:text-esmerald-800" + onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'COMPLETED')} + /> + + } + title="Cancelar reserva" + description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible." + className="border-red-500/40 bg-red-500/10 text-red-600 text-lg hover:bg-red-500/15 hover:text-red-800" + onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'CANCELLED')} + /> + + } + title="Enviar recordatorio" + description="Enviar un mensaje por WhatsApp al cliente." + className="border-sky-500/40 bg-sky-500/10 text-sky-600 text-lg hover:bg-sky-500/15 hover:text-sky-800" + onClick={sendWhatsappReminder} + /> + + } + title="Marcar como No Show" + description="El cliente no vino y no canceló." + className="border-amber-500/40 bg-amber-500/10 text-amber-600 text-lg hover:bg-amber-500/15 hover:text-amber-800" + onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'NOSHOW')} + /> +
+
+ + + + + + +
+
+ ); +} + +function DetailItem({ + icon, + label, + value, + helper, + valueClassName, + withDivider, + className, +}: { + icon: React.ReactNode; + label: string; + value: string; + helper?: string; + valueClassName?: string; + withDivider?: boolean; + className?: string; +}) { + + return ( +
+ {withDivider && ( +
+ )} + +
+ {icon} +
+
+

+ {label} +

+

+ {value} +

+ {helper && ( +

+ {helper} +

+ )} +
+
+ + ); +} + +function ActionButton({ + icon, + title, + description, + className, + onClick +}: { + icon: React.ReactNode; + title: string; + description: string; + className: string; + onClick?: () => void; +}) { + return ( + + ); + +} \ No newline at end of file diff --git a/packages/api-contract/src/admin-booking.ts b/packages/api-contract/src/admin-booking.ts index 12fd5ab..dd41dd2 100644 --- a/packages/api-contract/src/admin-booking.ts +++ b/packages/api-contract/src/admin-booking.ts @@ -55,7 +55,7 @@ export const createAdminBookingSchema = z.object({ }) export const updateAdminBookingStatusSchema = z.object({ - status: z.enum(['CANCELLED', 'COMPLETED']), + status: z.enum(['CANCELLED', 'COMPLETED', 'NOSHOW']), }) export type BookingStatus = z.infer