diff --git a/apps/frontend/src/components/header.tsx b/apps/frontend/src/components/header.tsx index 41c4454..ccce9d3 100644 --- a/apps/frontend/src/components/header.tsx +++ b/apps/frontend/src/components/header.tsx @@ -1,67 +1,143 @@ -import { useState } from "react" -import { CalendarDays, LogOut, Menu, Settings, User, X } from "lucide-react" +import { + CalendarDays, + Check, + Laptop, + LogOut, + Menu, + Moon, + Settings, + Sun, + User, + X, +} from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; -import PlayzerIcon from "@/assets/playzer-favicon-512-transparent.png" -import { Link, useLocation } from "@tanstack/react-router" +import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, useLocation, useNavigate } from '@tanstack/react-router'; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" +import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" -import { useAuth } from "@/lib/auth" +} from '@/components/ui/dropdown-menu'; +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; +import { UserAvatar } from '@/components/user-avatar'; +import { apiClient } from '@/lib/api-client'; +import { useAuth } from '@/lib/auth'; +import { acceptStoredPendingInvite } from '@/lib/invitations'; +import { useCurrentComplexStore } from '@/lib/stores/current-complex-store'; +import { useTheme } from '@/lib/theme'; interface NavItemProps { - to: string - label: string - isMobile?: boolean - onClick?: () => void + to: '/' | '/about'; + label: string; + isMobile?: boolean; + onClick?: () => void; } function NavItem({ to, label, isMobile, onClick }: NavItemProps) { - const location = useLocation() - const currentPath = location.pathname - const isActive = to === "/" ? currentPath === "/" : currentPath.startsWith(to) + const location = useLocation(); + const currentPath = location.pathname; + const isActive = to === '/' ? currentPath === '/' : currentPath.startsWith(to); return ( - ) + ); } export function Header() { - const [open, setOpen] = useState(false) + const [open, setOpen] = useState(false); + const navigate = useNavigate(); + const queryClient = useQueryClient(); const { user, isAuthenticated, displayName, avatarUrl, signOut } = useAuth(); - + const { theme, setTheme } = useTheme(); + const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore(); + const processedInviteForUser = useRef(null); + const myComplexesQuery = useQuery({ + queryKey: ['my-complexes'], + enabled: isAuthenticated, + queryFn: () => apiClient.complexes.listMine(), + }); + + useEffect(() => { + if (!isAuthenticated) { + processedInviteForUser.current = null; + return; + } + + const userKey = user?.email ?? displayName ?? 'authenticated-user'; + + if (processedInviteForUser.current === userKey) { + return; + } + + processedInviteForUser.current = userKey; + + void (async () => { + await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations); + await queryClient.invalidateQueries({ queryKey: ['my-complexes'] }); + })(); + }, [displayName, isAuthenticated, queryClient, user?.email]); + + const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug; + const currentComplexName = useMemo(() => { + const complexes = myComplexesQuery.data ?? []; + if (complexes.length === 0) return 'Mi complejo'; + + const selected = complexes.find((complex) => complex.complexSlug === complexSlug); + + return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo'; + }, [complexSlug, myComplexesQuery.data]); + const menuItems = [ - { label: "Inicio", to: "/" }, - { label: "Reservas", to: "/booking" }, - { label: "Canchas", to: "/canchas" }, - { label: "Dashboard", to: "/dashboard" }, - { label: "Contacto", to: "/contacto" }, - ] + { label: 'Inicio', to: '/' }, + { label: 'About', to: '/about' }, + ] as const; + + const handleSelectComplex = async (complexId: string, nextSlug: string) => { + await apiClient.complexes.select({ complexId }); + setCurrentComplex(nextSlug); + await queryClient.invalidateQueries({ queryKey: ['current-complex'] }); + }; + + const handleSignOut = async () => { + await signOut(); + await navigate({ to: '/login' }); + }; + + const handleOpenBookingCreate = async () => { + if (location.pathname !== '/') { + await navigate({ to: '/' }); + } + + window.setTimeout(() => { + window.dispatchEvent(new Event('playzer:open-booking-create')); + }, 0); + }; return (
@@ -87,66 +163,147 @@ export function Header() {
-
- - - - + {isAuthenticated ? ( + + + + - - -
- - - - JD - - + + +
+ -
- {displayName} - - {user?.email} - +
+ {displayName} + {user?.email} + + {currentComplexName} + +
-
-
+ - + - - - Mi Perfil - + {myComplexesQuery.data && myComplexesQuery.data.length > 1 && ( + <> + + Cambiar complejo + + {myComplexesQuery.data.map((complex) => ( + { + void handleSelectComplex(complex.id, complex.complexSlug); + }} + > + + {complex.complexSlug === complexSlug && } + + {complex.complexName} + + ))} + + + )} - - - Configuración - + + Apariencia + + setTheme(value as 'light' | 'dark' | 'system')} + > + + + Light + + + + Dark + + + + System + + - + - - - Cerrar sesión - -
- + { + void navigate({ to: '/profile' }); + }} + > + + Mi Perfil + + + {currentComplexSlug && ( + { + void navigate({ + to: '/complex/$slug/edit', + params: { slug: currentComplexSlug }, + }); + }} + > + + Configuración + + )} + + + + { + void handleSignOut(); + }} + > + + Cerrar sesión + + + + ) : ( + + )} @@ -162,18 +319,19 @@ export function Header() {
- - - - JD - - +
-

John Doe

-

- john@example.com -

+

{displayName}

+

{user?.email}

+ {isAuthenticated && ( +

{currentComplexName}

+ )}
@@ -192,44 +350,131 @@ export function Header() {
-

- Cuenta -

+
+

+ Cuenta +

+
@@ -239,5 +484,5 @@ export function Header() {
- ) -} \ No newline at end of file + ); +} diff --git a/apps/frontend/src/components/layout.tsx b/apps/frontend/src/components/layout.tsx index 27bdf15..7f46592 100644 --- a/apps/frontend/src/components/layout.tsx +++ b/apps/frontend/src/components/layout.tsx @@ -1,8 +1,8 @@ -import type { ReactNode } from "react" -import { Header } from "./header" +import type { ReactNode } from 'react'; +import { Header } from './header'; interface LayoutProps { - children: ReactNode + children: ReactNode; } export function Layout({ children }: LayoutProps) { @@ -14,7 +14,7 @@ export function Layout({ children }: LayoutProps) { {children} - ) + ); } function Background() { @@ -30,5 +30,5 @@ function Background() {
- ) -} \ No newline at end of file + ); +} diff --git a/apps/frontend/src/components/ui/sheet.tsx b/apps/frontend/src/components/ui/sheet.tsx index 49a6af2..ea3e4a4 100644 --- a/apps/frontend/src/components/ui/sheet.tsx +++ b/apps/frontend/src/components/ui/sheet.tsx @@ -1,30 +1,24 @@ -import * as React from "react" -import { Dialog as SheetPrimitive } from "radix-ui" +import { Dialog as SheetPrimitive } from 'radix-ui'; +import * as React from 'react'; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { XIcon } from "lucide-react" +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { XIcon } from 'lucide-react'; function Sheet({ ...props }: React.ComponentProps) { - return + return ; } -function SheetTrigger({ - ...props -}: React.ComponentProps) { - return +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; } -function SheetClose({ - ...props -}: React.ComponentProps) { - return +function SheetClose({ ...props }: React.ComponentProps) { + return ; } -function SheetPortal({ - ...props -}: React.ComponentProps) { - return +function SheetPortal({ ...props }: React.ComponentProps) { + return ; } function SheetOverlay({ @@ -35,23 +29,23 @@ function SheetOverlay({ - ) + ); } function SheetContent({ className, children, - side = "right", + side = 'right', showCloseButton = true, ...props }: React.ComponentProps & { - side?: "top" | "right" | "bottom" | "left" - showCloseButton?: boolean + side?: 'top' | 'right' | 'bottom' | 'left'; + showCloseButton?: boolean; }) { return ( @@ -60,7 +54,7 @@ function SheetContent({ data-slot="sheet-content" data-side={side} className={cn( - "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", + 'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10', className )} {...props} @@ -68,56 +62,45 @@ function SheetContent({ {children} {showCloseButton && ( - )} - ) + ); } -function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { +function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { +function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -function SheetTitle({ - className, - ...props -}: React.ComponentProps) { +function SheetTitle({ className, ...props }: React.ComponentProps) { return ( - ) + ); } function SheetDescription({ @@ -127,10 +110,10 @@ function SheetDescription({ return ( - ) + ); } export { @@ -142,4 +125,4 @@ export { SheetFooter, SheetTitle, SheetDescription, -} +}; diff --git a/apps/frontend/src/features/booking/booking-provider.tsx b/apps/frontend/src/features/booking/booking-provider.tsx new file mode 100644 index 0000000..32b6e61 --- /dev/null +++ b/apps/frontend/src/features/booking/booking-provider.tsx @@ -0,0 +1,472 @@ +import { ApiClientError, apiClient } from '@/lib/api-client'; +import type { AdminBooking, ComplexWithRole, Court } from '@repo/api-contract'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import type { + BookingCourtSchedule, + BookingSlotDraft, + BookingStatusFilter, + BookingSummary, + BookingTimeRange, + BookingTimelineSegment, + BookingViewMode, +} from './booking.types'; +import { + addDaysIso, + formatBookingDate, + getDayOfWeek, + getNowTime, + isTodayIso, + minutesToTime, + timeToMinutes, + toIsoDateLocal, +} from './lib/booking-time'; + +interface BookingProviderProps { + children: ReactNode; + complex: ComplexWithRole; +} + +interface CreateManualBookingPayload { + courtId: string; + date: string; + startTime: string; + customerName: string; + customerPhone: string; +} + +interface BookingContextValue { + complex: ComplexWithRole; + courts: Court[]; + bookings: AdminBooking[]; + schedules: BookingCourtSchedule[]; + selectedDate: string; + selectedSportId: string; + selectedStatus: BookingStatusFilter; + viewMode: BookingViewMode; + visibleTimeRange: BookingTimeRange; + timelineHours: string[]; + summary: BookingSummary; + currentTime: string | null; + selectedSlot: BookingSlotDraft | null; + isLoading: boolean; + isError: boolean; + errorMessage: string | null; + isCreateBookingOpen: boolean; + createBookingError: string | null; + isCreatingBooking: boolean; + setSelectedDate: (date: string) => void; + moveSelectedDate: (amount: number) => void; + setSelectedSportId: (sportId: string) => void; + setSelectedStatus: (status: BookingStatusFilter) => void; + setViewMode: (mode: BookingViewMode) => void; + setVisibleTimeRange: (range: BookingTimeRange) => void; + openCreateBooking: (draft?: Partial) => void; + closeCreateBooking: () => void; + createBooking: (payload: CreateManualBookingPayload) => Promise; + updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED') => void; + exportDayReport: () => void; +} + +const BookingContext = createContext(null); + +const DEFAULT_TIME_RANGE: BookingTimeRange = { + start: '08:00', + end: '22:00', +}; + +function extractMessage(error: unknown, fallback: string) { + if (error instanceof ApiClientError) { + return error.message || fallback; + } + + if (error instanceof Error) { + return error.message || fallback; + } + + return fallback; +} + +function makeTimelineHours(range: BookingTimeRange) { + const start = timeToMinutes(range.start); + const end = timeToMinutes(range.end); + const hours: string[] = []; + + for (let minute = start; minute <= end; minute += 60) { + hours.push(minutesToTime(minute)); + } + + return hours; +} + +function isActiveBooking(booking: AdminBooking) { + return booking.status === 'CONFIRMED' || booking.status === 'COMPLETED'; +} + +function getCourtBookings(court: Court, bookings: AdminBooking[], selectedDate: string) { + return bookings + .filter( + (booking) => + booking.courtId === court.id && booking.date === selectedDate && isActiveBooking(booking) + ) + .sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime)); +} + +function overlapsBooking(start: number, end: number, booking: AdminBooking) { + const bookingStart = timeToMinutes(booking.startTime); + const bookingEnd = timeToMinutes(booking.endTime); + + return start < bookingEnd && end > bookingStart; +} + +function buildSegmentsForCourt( + court: Court, + bookings: AdminBooking[], + selectedDate: string, + visibleRange: BookingTimeRange +) { + const dayOfWeek = getDayOfWeek(selectedDate); + const rangeStart = timeToMinutes(visibleRange.start); + const rangeEnd = timeToMinutes(visibleRange.end); + const courtBookings = getCourtBookings(court, bookings, selectedDate); + const segments: BookingTimelineSegment[] = []; + const slotDuration = court.slotDurationMinutes; + + const availabilityRanges = court.availability + .filter((range) => range.dayOfWeek === dayOfWeek) + .map((range) => ({ + start: Math.max(timeToMinutes(range.startTime), rangeStart), + end: Math.min(timeToMinutes(range.endTime), rangeEnd), + })) + .filter((range) => range.start < range.end) + .sort((a, b) => a.start - b.start); + + for (const booking of courtBookings) { + const bookingStart = timeToMinutes(booking.startTime); + const bookingEnd = timeToMinutes(booking.endTime); + + if (bookingEnd > rangeStart && bookingStart < rangeEnd) { + segments.push({ + id: booking.id, + courtId: court.id, + status: 'reserved', + startTime: booking.startTime, + endTime: booking.endTime, + startMinutes: Math.max(bookingStart, rangeStart), + endMinutes: Math.min(bookingEnd, rangeEnd), + booking, + }); + } + } + + for (const availability of availabilityRanges) { + for ( + let slotStart = availability.start; + slotStart + slotDuration <= availability.end; + slotStart += slotDuration + ) { + const slotEnd = slotStart + slotDuration; + const isBooked = courtBookings.some((booking) => + overlapsBooking(slotStart, slotEnd, booking) + ); + + if (isBooked) continue; + + const slotStartTime = minutesToTime(slotStart); + const slotEndTime = minutesToTime(slotEnd); + + segments.push({ + id: `${court.id}-free-${slotStartTime}-${slotEndTime}`, + courtId: court.id, + status: 'free', + startTime: slotStartTime, + endTime: slotEndTime, + startMinutes: slotStart, + endMinutes: slotEnd, + }); + } + } + + segments.sort((a, b) => a.startMinutes - b.startMinutes); + + return segments; +} + +function getSummary(schedules: BookingCourtSchedule[]): BookingSummary { + const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0); + const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0); + const maintenanceSlots = schedules.reduce( + (total, schedule) => total + schedule.metrics.maintenance, + 0 + ); + const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1; + + return { + totalReservations: reservedSlots + maintenanceSlots + freeSlots, + freeSlots, + reservedSlots, + maintenanceSlots, + freePercent: Math.round((freeSlots / totalSegments) * 100), + reservedPercent: Math.round((reservedSlots / totalSegments) * 100), + maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100), + }; +} + +export function BookingProvider({ children, complex }: BookingProviderProps) { + const queryClient = useQueryClient(); + const todayIso = useMemo(() => toIsoDateLocal(new Date()), []); + const [selectedDate, setSelectedDate] = useState(todayIso); + const [selectedSportId, setSelectedSportId] = useState('all'); + const [selectedStatus, setSelectedStatus] = useState('all'); + const [viewMode, setViewMode] = useState('panel'); + const [visibleTimeRange, setVisibleTimeRange] = useState(DEFAULT_TIME_RANGE); + const [selectedSlot, setSelectedSlot] = useState(null); + const [isCreateBookingOpen, setIsCreateBookingOpen] = useState(false); + + const courtsQuery = useQuery({ + queryKey: ['courts', complex.id], + queryFn: () => apiClient.courts.listByComplex(complex.id), + }); + + const bookingsQuery = useQuery({ + queryKey: ['admin-bookings', complex.id, selectedDate], + queryFn: () => + apiClient.adminBookings.listByComplex(complex.id, { + fromDate: selectedDate, + }), + }); + + const bookings = useMemo( + () => (bookingsQuery.data?.bookings ?? []).filter((booking) => booking.date === selectedDate), + [bookingsQuery.data?.bookings, selectedDate] + ); + + const courts = useMemo(() => courtsQuery.data ?? [], [courtsQuery.data]); + + const filteredCourts = useMemo(() => { + if (selectedSportId === 'all') return courts; + return courts.filter((court) => court.sportId === selectedSportId); + }, [courts, selectedSportId]); + + const schedules = useMemo(() => { + return filteredCourts + .map((court) => { + const segments = buildSegmentsForCourt(court, bookings, selectedDate, visibleTimeRange); + const metrics = { + free: segments.filter((segment) => segment.status === 'free').length, + reserved: segments.filter((segment) => segment.status === 'reserved').length, + maintenance: segments.filter((segment) => segment.status === 'maintenance').length, + }; + + return { court, segments, metrics }; + }) + .filter((schedule) => { + if (selectedStatus === 'all') return true; + return schedule.segments.some((segment) => segment.status === selectedStatus); + }); + }, [bookings, filteredCourts, selectedDate, selectedStatus, visibleTimeRange]); + + const summary = useMemo(() => getSummary(schedules), [schedules]); + const timelineHours = useMemo(() => makeTimelineHours(visibleTimeRange), [visibleTimeRange]); + + const currentTime = useMemo(() => { + if (!isTodayIso(selectedDate)) return null; + + const now = getNowTime(); + const nowMinutes = timeToMinutes(now); + const start = timeToMinutes(visibleTimeRange.start); + const end = timeToMinutes(visibleTimeRange.end); + + if (nowMinutes < start || nowMinutes > end) return null; + return now; + }, [selectedDate, visibleTimeRange]); + + const createBookingMutation = useMutation({ + mutationFn: (payload: CreateManualBookingPayload) => + apiClient.adminBookings.create(complex.id, { + date: payload.date, + courtId: payload.courtId, + startTime: payload.startTime, + customerName: payload.customerName, + customerPhone: payload.customerPhone, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] }); + await queryClient.invalidateQueries({ queryKey: ['manual-booking-availability'] }); + setIsCreateBookingOpen(false); + setSelectedSlot(null); + }, + }); + + const updateStatusMutation = useMutation({ + mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) => + apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] }); + }, + }); + + const openCreateBooking = useCallback( + (draft?: Partial) => { + setSelectedSlot({ + date: draft?.date ?? selectedDate, + courtId: draft?.courtId, + startTime: draft?.startTime, + sportId: draft?.sportId, + }); + setIsCreateBookingOpen(true); + }, + [selectedDate] + ); + + useEffect(() => { + const handleOpenCreateBooking = () => { + openCreateBooking(); + }; + + window.addEventListener('playzer:open-booking-create', handleOpenCreateBooking); + + return () => { + window.removeEventListener('playzer:open-booking-create', handleOpenCreateBooking); + }; + }, [openCreateBooking]); + + const closeCreateBooking = useCallback(() => { + setIsCreateBookingOpen(false); + setSelectedSlot(null); + createBookingMutation.reset(); + }, [createBookingMutation]); + + const createBooking = useCallback( + async (payload: CreateManualBookingPayload) => { + await createBookingMutation.mutateAsync(payload); + }, + [createBookingMutation] + ); + + const updateBookingStatus = useCallback( + (bookingId: string, status: 'CANCELLED' | 'COMPLETED') => { + updateStatusMutation.mutate({ bookingId, status }); + }, + [updateStatusMutation] + ); + + const exportDayReport = useCallback(() => { + const rows = [ + ['Fecha', 'Cancha', 'Deporte', 'Inicio', 'Fin', 'Cliente', 'Telefono', 'Estado'], + ...bookings.map((booking) => [ + booking.date, + booking.courtName, + booking.sport.name, + booking.startTime, + booking.endTime, + booking.customerName, + booking.customerPhone, + booking.status, + ]), + ]; + const csv = rows.map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `reservas-${complex.complexSlug}-${selectedDate}.csv`; + link.click(); + URL.revokeObjectURL(url); + }, [bookings, complex.complexSlug, selectedDate]); + + const value = useMemo( + () => ({ + complex, + courts, + bookings, + schedules, + selectedDate, + selectedSportId, + selectedStatus, + viewMode, + visibleTimeRange, + timelineHours, + summary, + currentTime, + selectedSlot, + isLoading: courtsQuery.isLoading || bookingsQuery.isLoading, + isError: courtsQuery.isError || bookingsQuery.isError, + errorMessage: + courtsQuery.isError || bookingsQuery.isError + ? extractMessage( + courtsQuery.error ?? bookingsQuery.error, + 'No pudimos cargar el panel de reservas.' + ) + : null, + isCreateBookingOpen, + createBookingError: createBookingMutation.isError + ? extractMessage(createBookingMutation.error, 'No pudimos crear la reserva.') + : null, + isCreatingBooking: createBookingMutation.isPending, + setSelectedDate, + moveSelectedDate: (amount) => setSelectedDate((date) => addDaysIso(date, amount)), + setSelectedSportId, + setSelectedStatus, + setViewMode, + setVisibleTimeRange, + openCreateBooking, + closeCreateBooking, + createBooking, + updateBookingStatus, + exportDayReport, + }), + [ + bookings, + bookingsQuery.error, + bookingsQuery.isError, + bookingsQuery.isLoading, + closeCreateBooking, + complex, + courts, + courtsQuery.error, + courtsQuery.isError, + courtsQuery.isLoading, + createBooking, + createBookingMutation.error, + createBookingMutation.isError, + createBookingMutation.isPending, + currentTime, + exportDayReport, + isCreateBookingOpen, + openCreateBooking, + schedules, + selectedDate, + selectedSlot, + selectedSportId, + selectedStatus, + summary, + timelineHours, + updateBookingStatus, + viewMode, + visibleTimeRange, + ] + ); + + return {children}; +} + +export function useBooking() { + const context = useContext(BookingContext); + + if (!context) { + throw new Error('useBooking must be used within BookingProvider'); + } + + return context; +} + +export { formatBookingDate }; diff --git a/apps/frontend/src/features/booking/booking.tsx b/apps/frontend/src/features/booking/booking.tsx new file mode 100644 index 0000000..ebdaea5 --- /dev/null +++ b/apps/frontend/src/features/booking/booking.tsx @@ -0,0 +1,28 @@ +import type { ComplexWithRole } from '@repo/api-contract'; +import { BookingProvider } from './booking-provider'; +import { BookingCreateDialog } from './components/booking-create-dialog'; +import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary'; +import { BookingHeader } from './components/booking-header'; +import { BookingTimeline } from './components/booking-timeline'; +import { BookingToolbar } from './components/booking-toolbar'; + +interface BookingProps { + complex: ComplexWithRole; +} + +export function Booking({ complex }: BookingProps) { + return ( + +
+ + + +
+ + +
+
+ +
+ ); +} diff --git a/apps/frontend/src/features/booking/booking.types.ts b/apps/frontend/src/features/booking/booking.types.ts new file mode 100644 index 0000000..a933d99 --- /dev/null +++ b/apps/frontend/src/features/booking/booking.types.ts @@ -0,0 +1,50 @@ +import type { AdminBooking, Court } from '@repo/api-contract'; + +export type BookingViewMode = 'panel' | 'status'; +export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance'; +export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance'; + +export interface BookingTimeRange { + start: string; + end: string; +} + +export interface BookingSummary { + totalReservations: number; + freeSlots: number; + reservedSlots: number; + maintenanceSlots: number; + freePercent: number; + reservedPercent: number; + maintenancePercent: number; +} + +export interface BookingCourtMetrics { + free: number; + reserved: number; + maintenance: number; +} + +export interface BookingTimelineSegment { + id: string; + courtId: string; + status: BookingSegmentStatus; + startTime: string; + endTime: string; + startMinutes: number; + endMinutes: number; + booking?: AdminBooking; +} + +export interface BookingCourtSchedule { + court: Court; + segments: BookingTimelineSegment[]; + metrics: BookingCourtMetrics; +} + +export interface BookingSlotDraft { + courtId?: string; + date: string; + startTime?: string; + sportId?: string; +} diff --git a/apps/frontend/src/features/booking/components/booking-create-dialog.tsx b/apps/frontend/src/features/booking/components/booking-create-dialog.tsx new file mode 100644 index 0000000..4e180bc --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-create-dialog.tsx @@ -0,0 +1,285 @@ +import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Field, FieldError, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, +} from '@/components/ui/responsive-dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { useBooking } from '../booking-provider'; +import { + fromIsoDateLocal, + getDayOfWeek, + minutesToTime, + timeToMinutes, + toIsoDateLocal, +} from '../lib/booking-time'; + +const bookingFormSchema = z.object({ + customerName: z + .string() + .trim() + .min(2, 'Ingresa un nombre válido.') + .max(120, 'El nombre no puede superar los 120 caracteres.'), + customerPhone: z + .string() + .trim() + .min(6, 'Ingresa un telefono válido.') + .max(30, 'El telefono no puede superar los 30 caracteres.'), +}); + +type BookingForm = z.infer; + +export function BookingCreateDialog() { + const { + courts, + bookings, + selectedDate, + selectedSlot, + isCreateBookingOpen, + createBookingError, + isCreatingBooking, + closeCreateBooking, + createBooking, + } = useBooking(); + + const [date, setDate] = useState(selectedSlot?.date ?? selectedDate); + const [sportId, setSportId] = useState(selectedSlot?.sportId ?? 'all'); + const [courtId, setCourtId] = useState(selectedSlot?.courtId ?? ''); + const [startTime, setStartTime] = useState(selectedSlot?.startTime ?? ''); + + const { + register, + handleSubmit, + reset, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(bookingFormSchema), + mode: 'onChange', + defaultValues: { + customerName: '', + customerPhone: '', + }, + }); + + useEffect(() => { + if (!isCreateBookingOpen) return; + + setDate(selectedSlot?.date ?? selectedDate); + setSportId(selectedSlot?.sportId ?? 'all'); + setCourtId(selectedSlot?.courtId ?? ''); + setStartTime(selectedSlot?.startTime ?? ''); + reset(); + }, [isCreateBookingOpen, reset, selectedDate, selectedSlot]); + + const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()]; + const filteredCourts = useMemo(() => { + if (sportId === 'all') return courts; + return courts.filter((court) => court.sportId === sportId); + }, [courts, sportId]); + const selectedCourt = courts.find((court) => court.id === courtId); + const availableStartTimes = useMemo(() => { + if (!selectedCourt) return []; + + const times: string[] = []; + const dayOfWeek = getDayOfWeek(date); + const dayAvailability = selectedCourt.availability + .filter((range) => range.dayOfWeek === dayOfWeek) + .sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime)); + const courtBookings = bookings.filter( + (booking) => + booking.date === date && + booking.courtId === selectedCourt.id && + booking.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; + const isBooked = courtBookings.some((booking) => { + const bookingStart = timeToMinutes(booking.startTime); + const bookingEnd = timeToMinutes(booking.endTime); + return minute < bookingEnd && slotEnd > bookingStart; + }); + + if (!isBooked) { + times.push(minutesToTime(minute)); + } + } + } + + return times; + }, [bookings, date, selectedCourt]); + + useEffect(() => { + if (startTime && !availableStartTimes.includes(startTime)) { + setStartTime(''); + } + }, [availableStartTimes, startTime]); + + const onSubmit = async (values: BookingForm) => { + await createBooking({ + courtId, + date, + startTime, + customerName: values.customerName, + customerPhone: values.customerPhone, + }); + }; + + return ( + !open && closeCreateBooking()} + > + event.preventDefault()} + > + + Nueva reserva + + Crea un turno para atención telefónica o mostrador. + + + +
+
+ + Fecha + { + setDate(nextDate ? toIsoDateLocal(nextDate) : selectedDate); + }} + /> + + + + Deporte + + + + + Cancha + + + + + Horario + + +
+ + + Nombre + + + + + + Teléfono + + + + + {createBookingError &&

{createBookingError}

} +
+ + + + + + + +
+
+ ); +} diff --git a/apps/frontend/src/features/booking/components/booking-day-summary.tsx b/apps/frontend/src/features/booking/components/booking-day-summary.tsx new file mode 100644 index 0000000..939446b --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-day-summary.tsx @@ -0,0 +1,118 @@ +import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react'; +import { useBooking } from '../booking-provider'; +import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time'; + +export function BookingDaySummary() { + const { selectedDate, summary } = useBooking(); + + return ( +
+
+

Resumen del día

+

+ {formatBookingDate(selectedDate, { year: 'numeric' })} +

+
+ +
+ + + + +
+
+ ); +} + +interface SummaryCardProps { + label: string; + value: number; + detail?: string; + icon: typeof CalendarDays; + className: string; +} + +function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCardProps) { + return ( +
+
+
+ +
+
+

{label}

+

{value}

+ {detail &&

{detail}

} +
+
+
+ ); +} + +export function BookingQuickActions() { + const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } = + useBooking(); + const todayIso = toIsoDateLocal(new Date()); + const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved'; + const isViewingMaintenance = selectedStatus === 'maintenance'; + + return ( +
+

Acciones rápidas

+
+ + + +
+
+ ); +} diff --git a/apps/frontend/src/features/booking/components/booking-header.tsx b/apps/frontend/src/features/booking/components/booking-header.tsx new file mode 100644 index 0000000..67ec4cc --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-header.tsx @@ -0,0 +1,18 @@ +import { useBooking } from '../booking-provider'; + +export function BookingHeader() { + const { complex } = useBooking(); + + return ( +
+
+

+ Panel de Reservas +

+

+ Gestiona las canchas de {complex.complexName} +

+
+
+ ); +} diff --git a/apps/frontend/src/features/booking/components/booking-status-legend.tsx b/apps/frontend/src/features/booking/components/booking-status-legend.tsx new file mode 100644 index 0000000..889e512 --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-status-legend.tsx @@ -0,0 +1,18 @@ +const items = [ + { label: 'Libre', className: 'bg-primary' }, + { label: 'Reservado', className: 'bg-reserved' }, + { label: 'Mantenimiento', className: 'bg-maintenance' }, +]; + +export function BookingStatusLegend() { + return ( +
+ {items.map((item) => ( + + + {item.label} + + ))} +
+ ); +} diff --git a/apps/frontend/src/features/booking/components/booking-timeline.tsx b/apps/frontend/src/features/booking/components/booking-timeline.tsx new file mode 100644 index 0000000..e200a83 --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-timeline.tsx @@ -0,0 +1,299 @@ +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { ChevronLeft, ChevronRight, Dumbbell, Plus, Users } from 'lucide-react'; +import { useBooking } from '../booking-provider'; +import type { BookingCourtSchedule, BookingTimelineSegment } from '../booking.types'; +import { timeToMinutes } from '../lib/booking-time'; +import { BookingStatusLegend } from './booking-status-legend'; + +const courtInfoWidth = 220; +const hourWidth = 104; + +export function BookingTimeline() { + const { + schedules, + timelineHours, + visibleTimeRange, + currentTime, + isLoading, + isError, + errorMessage, + } = useBooking(); + + const rangeStart = timeToMinutes(visibleTimeRange.start); + const rangeEnd = timeToMinutes(visibleTimeRange.end); + const totalMinutes = rangeEnd - rangeStart; + const gridWidth = Math.max((timelineHours.length - 1) * hourWidth, 760); + const timelineMarks = timelineHours.map((hour) => ({ + hour, + left: ((timeToMinutes(hour) - rangeStart) / totalMinutes) * 100, + })); + + return ( +
+
+ +
+ + +
+
+ + {isLoading && ( +
Cargando reservas...
+ )} + + {isError && !isLoading && ( +
{errorMessage}
+ )} + + {!isLoading && !isError && schedules.length === 0 && ( +
+ No hay canchas para los filtros seleccionados. +
+ )} + + {!isLoading && !isError && schedules.length > 0 && ( +
+
+ + +
+ {schedules.map((schedule) => ( + + ))} +
+ + {currentTime && ( + + )} +
+
+ )} +
+ ); +} + +interface TimelineHeaderProps { + timelineMarks: Array<{ hour: string; left: number }>; + courtInfoWidth: number; + gridWidth: number; +} + +function TimelineHeader({ timelineMarks, courtInfoWidth, gridWidth }: TimelineHeaderProps) { + return ( +
+
+
+ {timelineMarks.map((mark) => ( +
+ {mark.hour} +
+ ))} +
+
+ ); +} + +interface BookingCourtRowProps { + schedule: BookingCourtSchedule; + courtInfoWidth: number; + gridWidth: number; + rangeStart: number; + totalMinutes: number; + timelineMarks: Array<{ hour: string; left: number }>; +} + +function BookingCourtRow({ + schedule, + courtInfoWidth, + gridWidth, + rangeStart, + totalMinutes, + timelineMarks, +}: BookingCourtRowProps) { + return ( +
+ +
+
+ {timelineMarks.map((mark) => ( + + ))} +
+ +
+ {schedule.segments.map((segment) => ( + + ))} +
+
+
+ ); +} + +function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) { + return ( +
+
+ +
+
+

{schedule.court.name}

+

{schedule.court.sport.name}

+
+ + + {schedule.metrics.free} + + + + {schedule.metrics.reserved} + + + + {schedule.metrics.maintenance} + +
+
+
+ ); +} + +interface BookingSlotBlockProps { + segment: BookingTimelineSegment; + schedule: BookingCourtSchedule; + rangeStart: number; + totalMinutes: number; +} + +function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: BookingSlotBlockProps) { + const { openCreateBooking, updateBookingStatus } = useBooking(); + const left = ((segment.startMinutes - rangeStart) / totalMinutes) * 100; + const width = ((segment.endMinutes - segment.startMinutes) / totalMinutes) * 100; + const isFree = segment.status === 'free'; + + return ( + + ); +} + +interface CurrentTimeIndicatorProps { + time: string; + courtInfoWidth: number; + gridWidth: number; + rangeStart: number; + totalMinutes: number; +} + +function CurrentTimeIndicator({ + time, + courtInfoWidth, + gridWidth, + rangeStart, + totalMinutes, +}: CurrentTimeIndicatorProps) { + const left = courtInfoWidth + ((timeToMinutes(time) - rangeStart) / totalMinutes) * gridWidth; + + return ( +
+
+ {time} +
+
+
+ ); +} diff --git a/apps/frontend/src/features/booking/components/booking-toolbar.tsx b/apps/frontend/src/features/booking/components/booking-toolbar.tsx new file mode 100644 index 0000000..8569554 --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-toolbar.tsx @@ -0,0 +1,144 @@ +import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Field, FieldLabel } from '@/components/ui/field'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react'; +import { useBooking } from '../booking-provider'; +import type { BookingStatusFilter } from '../booking.types'; +import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time'; + +const timeRangeOptions = [ + { label: '08:00 - 22:00', start: '08:00', end: '22:00' }, + { label: '06:00 - 00:00', start: '06:00', end: '23:59' }, + { label: '08:00 - 14:00', start: '08:00', end: '14:00' }, + { label: '14:00 - 22:00', start: '14:00', end: '22:00' }, +]; + +const statusOptions: Array<{ value: BookingStatusFilter; label: string }> = [ + { value: 'all', label: 'Todos' }, + { value: 'free', label: 'Libre' }, + { value: 'reserved', label: 'Reservado' }, + { value: 'maintenance', label: 'Mantenimiento' }, +]; + +export function BookingToolbar() { + const { + courts, + selectedDate, + selectedSportId, + selectedStatus, + visibleTimeRange, + setSelectedDate, + moveSelectedDate, + setSelectedSportId, + setSelectedStatus, + setVisibleTimeRange, + } = useBooking(); + + const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()]; + const activeRangeValue = `${visibleTimeRange.start}-${visibleTimeRange.end}`; + + return ( +
+
+ + Deporte + + + + + Estado + + + + + Fecha +
+ + + setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date())) + } + /> + +
+
+ + + Hora + + + + +
+
+ ); +} diff --git a/apps/frontend/src/features/booking/lib/booking-time.ts b/apps/frontend/src/features/booking/lib/booking-time.ts new file mode 100644 index 0000000..1b34009 --- /dev/null +++ b/apps/frontend/src/features/booking/lib/booking-time.ts @@ -0,0 +1,66 @@ +import type { DayOfWeek } from '@repo/api-contract'; + +export function toIsoDateLocal(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function fromIsoDateLocal(dateIso: string): Date | undefined { + const [year, month, day] = dateIso.split('-').map(Number); + if (!year || !month || !day) return undefined; + return new Date(year, month - 1, day); +} + +export function timeToMinutes(time: string) { + const [hours, minutes] = time.split(':').map(Number); + return hours * 60 + minutes; +} + +export function minutesToTime(minutes: number) { + const hours = Math.floor(minutes / 60); + const remainder = minutes % 60; + return `${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`; +} + +export function addDaysIso(dateIso: string, amount: number) { + const date = fromIsoDateLocal(dateIso) ?? new Date(); + date.setDate(date.getDate() + amount); + return toIsoDateLocal(date); +} + +export function isTodayIso(dateIso: string) { + return dateIso === toIsoDateLocal(new Date()); +} + +export function formatBookingDate(dateIso: string, options?: Intl.DateTimeFormatOptions) { + const date = fromIsoDateLocal(dateIso); + if (!date) return dateIso; + + return new Intl.DateTimeFormat('es-AR', { + weekday: 'long', + day: '2-digit', + month: 'long', + year: options?.year, + ...options, + }).format(date); +} + +export function getDayOfWeek(dateIso: string): DayOfWeek { + const date = fromIsoDateLocal(dateIso) ?? new Date(); + const day = date.getDay(); + + if (day === 0) return 'SUNDAY'; + if (day === 1) return 'MONDAY'; + if (day === 2) return 'TUESDAY'; + if (day === 3) return 'WEDNESDAY'; + if (day === 4) return 'THURSDAY'; + if (day === 5) return 'FRIDAY'; + return 'SATURDAY'; +} + +export function getNowTime() { + const now = new Date(); + return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; +} diff --git a/apps/frontend/src/features/home/home-page.tsx b/apps/frontend/src/features/home/home-page.tsx index f4aec80..29b7bac 100644 --- a/apps/frontend/src/features/home/home-page.tsx +++ b/apps/frontend/src/features/home/home-page.tsx @@ -1,62 +1,9 @@ -import { Button } from '@/components/ui/button'; -import { DatePicker } from '@/components/ui/date-picker'; -import { Field, FieldError, FieldLabel } from '@/components/ui/field'; -import { Input } from '@/components/ui/input'; -import { - ResponsiveDialog, - ResponsiveDialogClose, - ResponsiveDialogContent, - ResponsiveDialogDescription, - ResponsiveDialogFooter, - ResponsiveDialogHeader, - ResponsiveDialogTitle, - ResponsiveDialogTrigger, -} from '@/components/ui/responsive-dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import { Booking } from '@/features/booking/booking'; import { ApiClientError, apiClient } from '@/lib/api-client'; import { useCurrentComplexStore } from '@/lib/stores/current-complex-store'; -import { zodResolver } from '@hookform/resolvers/zod'; -import type { AdminBooking } from '@repo/api-contract'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; -import { Check, Copy } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -const manualBookingSchema = z.object({ - customerName: z - .string() - .trim() - .min(2, 'Ingresa un nombre válido.') - .max(120, 'El nombre no puede superar los 120 caracteres.'), - customerPhone: z - .string() - .trim() - .min(6, 'Ingresa un telefono válido.') - .max(30, 'El telefono no puede superar los 30 caracteres.'), -}); - -type ManualBookingForm = z.infer; - -function toIsoDateLocal(date: Date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -function fromIsoDateLocal(dateIso: string): Date | undefined { - const [year, month, day] = dateIso.split('-').map(Number); - if (!year || !month || !day) return undefined; - return new Date(year, month - 1, day); -} +import { useEffect } from 'react'; function extractMessage(error: unknown, fallback: string) { if (error instanceof ApiClientError) { @@ -66,71 +13,9 @@ function extractMessage(error: unknown, fallback: string) { return fallback; } -function formatDateLabel(dateIso: string) { - const [year, month, day] = dateIso.split('-').map(Number); - - if (!year || !month || !day) return dateIso; - - const date = new Date(year, month - 1, day); - return new Intl.DateTimeFormat('es-AR', { - weekday: 'long', - day: '2-digit', - month: 'long', - }).format(date); -} - -function getEffectiveStatus( - booking: AdminBooking -): 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' { - if (booking.status !== 'CONFIRMED') { - return booking.status; - } - - // Check if booking is past end time and still confirmed - const now = new Date(); - const bookingDateTime = new Date(`${booking.date}T${booking.endTime}:00`); - - if (now > bookingDateTime) { - return 'NO_SHOW'; - } - - return 'CONFIRMED'; -} - -function effectiveStatusLabel(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') { - if (status === 'NO_SHOW') return 'No show'; - if (status === 'COMPLETED') return 'Cumplida'; - if (status === 'CANCELLED') return 'Cancelada'; - return 'Confirmada'; -} - -function effectiveStatusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') { - if (status === 'NO_SHOW') { - return 'border-orange-200 bg-orange-50 text-orange-700'; - } - if (status === 'COMPLETED') { - return 'border-emerald-200 bg-emerald-50 text-emerald-700'; - } - - if (status === 'CANCELLED') { - return 'border-rose-200 bg-rose-50 text-rose-700'; - } - - return 'border-sky-200 bg-sky-50 text-sky-700'; -} - export function HomePage() { const queryClient = useQueryClient(); const navigate = useNavigate(); - const todayIso = useMemo(() => toIsoDateLocal(new Date()), []); - - const [fromDate, setFromDate] = useState(todayIso); - const [manualDate, setManualDate] = useState(todayIso); - const [selectedSportId, setSelectedSportId] = useState(); - const [selectedCourtId, setSelectedCourtId] = useState(''); - const [selectedStartTime, setSelectedStartTime] = useState(''); - const [isManualBookingOpen, setIsManualBookingOpen] = useState(false); - const [urlCopied, setUrlCopied] = useState(false); const currentComplexQuery = useQuery({ queryKey: ['current-complex'], @@ -152,6 +37,7 @@ export function HomePage() { queryClient.invalidateQueries({ queryKey: ['admin-bookings'] }); } }); + return unsubscribe; }, [queryClient]); @@ -191,7 +77,6 @@ export function HomePage() { if (!selectedComplex?.id) return; const channel = `complex-${selectedComplex.id}`; - let eventSource: EventSource | null = null; let reconnectTimeout: ReturnType | null = null; let reconnectAttempts = 0; @@ -199,27 +84,32 @@ export function HomePage() { let lastEventId: string | null = null; let pollInterval: ReturnType | null = null; + const startPolling = () => { + if (pollInterval) return; + + pollInterval = setInterval(() => { + void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex.id] }); + }, 10000); + }; + const connect = () => { if (eventSource) { eventSource.close(); } if (reconnectAttempts >= maxReconnectAttempts) { - console.log('[SSE] Max reconnect attempts reached, switching to polling'); startPolling(); return; } - const url = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/events/${encodeURIComponent(channel)}`; + const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; + const url = `${baseUrl}/api/events/${encodeURIComponent(channel)}`; eventSource = new EventSource(url); eventSource.onerror = () => { if (eventSource?.readyState === EventSource.CLOSED) { reconnectAttempts++; - console.log( - `[SSE] Connection closed, retry ${reconnectAttempts}/${maxReconnectAttempts}` - ); reconnectTimeout = setTimeout(() => { connect(); }, 2000); @@ -239,15 +129,6 @@ export function HomePage() { }); }; - const startPolling = () => { - if (pollInterval) return; - - console.log('[SSE] Starting polling fallback'); - pollInterval = setInterval(() => { - void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex.id] }); - }, 10000); - }; - connect(); return () => { @@ -261,526 +142,21 @@ export function HomePage() { }; }, [selectedComplex?.id, queryClient]); - const bookingsQuery = useQuery({ - queryKey: ['admin-bookings', selectedComplex?.id, fromDate], - enabled: Boolean(selectedComplex?.id), - queryFn: () => - apiClient.adminBookings.listByComplex(selectedComplex?.id as string, { - fromDate, - }), - }); - - const manualAvailabilityQuery = useQuery({ - queryKey: [ - 'manual-booking-availability', - selectedComplex?.complexSlug, - manualDate, - selectedSportId, - ], - enabled: Boolean(selectedComplex?.complexSlug && manualDate), - queryFn: () => - apiClient.publicBookings.getAvailability(selectedComplex?.complexSlug as string, { - date: manualDate, - ...(selectedSportId ? { sportId: selectedSportId } : {}), - }), - }); - - useEffect(() => { - const availability = manualAvailabilityQuery.data; - - if (!availability) return; - - if (!availability.sportSelectionRequired) { - if (availability.sports[0] && !selectedSportId) { - setSelectedSportId(availability.sports[0].id); - } - } else if ( - selectedSportId && - !availability.sports.some((sport) => sport.id === selectedSportId) - ) { - setSelectedSportId(undefined); - } - }, [manualAvailabilityQuery.data, selectedSportId]); - - useEffect(() => { - if (!manualAvailabilityQuery.data) return; - - if ( - selectedCourtId && - !manualAvailabilityQuery.data.courts.some((court) => court.courtId === selectedCourtId) - ) { - setSelectedCourtId(''); - setSelectedStartTime(''); - } - }, [manualAvailabilityQuery.data, selectedCourtId]); - - const selectedCourt = useMemo(() => { - return manualAvailabilityQuery.data?.courts.find((court) => court.courtId === selectedCourtId); - }, [manualAvailabilityQuery.data?.courts, selectedCourtId]); - - useEffect(() => { - if (!selectedCourt) { - setSelectedStartTime(''); - return; - } - - if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) { - setSelectedStartTime(''); - } - }, [selectedCourt, selectedStartTime]); - - const groupedBookings = useMemo(() => { - const groups = new Map(); - - for (const booking of bookingsQuery.data?.bookings ?? []) { - const current = groups.get(booking.date) ?? []; - current.push(booking); - groups.set(booking.date, current); - } - - return [...groups.entries()]; - }, [bookingsQuery.data?.bookings]); - - const updateStatusMutation = useMutation({ - mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) => - apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }); - void queryClient.invalidateQueries({ - queryKey: ['manual-booking-availability', selectedComplex?.complexSlug], - }); - }, - }); - - const { - register, - handleSubmit, - reset, - formState: { errors, isValid }, - } = useForm({ - resolver: zodResolver(manualBookingSchema), - mode: 'onChange', - defaultValues: { - customerName: '', - customerPhone: '', - }, - }); - - const createManualBookingMutation = useMutation({ - mutationFn: async (values: ManualBookingForm) => { - if (!selectedComplex?.id) { - throw new Error('No se encontró el complejo actual.'); - } - - if (!selectedCourtId || !selectedStartTime) { - throw new Error('Debes seleccionar cancha y horario.'); - } - - return apiClient.adminBookings.create(selectedComplex.id, { - date: manualDate, - courtId: selectedCourtId, - startTime: selectedStartTime, - customerName: values.customerName, - customerPhone: values.customerPhone, - }); - }, - onSuccess: async () => { - reset(); - setSelectedCourtId(''); - setSelectedStartTime(''); - setIsManualBookingOpen(false); - - await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }); - await queryClient.invalidateQueries({ - queryKey: ['manual-booking-availability', selectedComplex?.complexSlug], - }); - }, - }); - - const onSubmitManualBooking = async (values: ManualBookingForm) => { - await createManualBookingMutation.mutateAsync(values); - }; - - const publicBookingUrl = selectedComplex - ? `${window.location.origin}/${selectedComplex.complexSlug}/booking` - : ''; - - const copyToClipboard = async () => { - await navigator.clipboard.writeText(publicBookingUrl); - setUrlCopied(true); - setTimeout(() => { - setUrlCopied(false); - }, 3000); - }; - if (myComplexesQuery.isLoading) { - return ( -
-

Cargando panel...

-
- ); + return

Cargando panel...

; } if (myComplexesQuery.isError) { return ( -
-

- {extractMessage(myComplexesQuery.error, 'No pudimos cargar tus complejos.')} -

-
+

+ {extractMessage(myComplexesQuery.error, 'No pudimos cargar tus complejos.')} +

); } if (!selectedComplex) { - return ( -
-

No tienes complejos asignados todavía.

-
- ); + return

No tienes complejos asignados todavía.

; } - return ( -
-
-
-
-

Panel de reservas

-

- Gestiona turnos del día y futuros para {selectedComplex.complexName}. -

-
- - {publicBookingUrl} - - -
-
- { - setIsManualBookingOpen(open); - if (!open) { - reset(); - setManualDate(todayIso); - setSelectedSportId(undefined); - setSelectedCourtId(''); - setSelectedStartTime(''); - } - }} - > - - - - e.preventDefault()} - > - - Reserva manual - - Crea un turno para atención telefónica o mostrador. - - - -
-
- - Fecha - { - const isoDate = date ? toIsoDateLocal(date) : todayIso; - setManualDate(isoDate); - setSelectedCourtId(''); - setSelectedStartTime(''); - }} - minDate={new Date()} - placeholder="Selecciona una fecha" - /> - - - {manualAvailabilityQuery.data?.sportSelectionRequired && ( - - Deporte - - - )} - - - Cancha - - - - - Horario - - -
- - {manualAvailabilityQuery.isLoading && ( -

Cargando horarios disponibles...

- )} - - {manualAvailabilityQuery.isError && ( -

- {extractMessage( - manualAvailabilityQuery.error, - 'No pudimos cargar disponibilidad para la reserva manual.' - )} -

- )} - - - Nombre - - - - - - Teléfono - - - - - {createManualBookingMutation.isError && ( -

- {extractMessage( - createManualBookingMutation.error, - 'No pudimos crear la reserva manual.' - )} -

- )} -
- - - - - - - -
-
-
-
- -
-
- - Mostrar desde - { - const isoDate = date ? toIsoDateLocal(date) : todayIso; - setFromDate(isoDate); - }} - disabled={(date) => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const checkDate = new Date(date); - checkDate.setHours(0, 0, 0, 0); - return checkDate < today; - }} - placeholder="Selecciona una fecha" - /> - -
- - {bookingsQuery.isLoading && ( -

Cargando reservas...

- )} - - {bookingsQuery.isError && ( -

- {extractMessage(bookingsQuery.error, 'No pudimos cargar las reservas.')} -

- )} - - {!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && ( -

- No hay reservas para la fecha seleccionada en adelante. -

- )} - -
- {groupedBookings.map(([date, bookings]) => ( -
-

{formatDateLabel(date)}

-
- {bookings.map((booking) => { - const effectiveStatus = getEffectiveStatus(booking); - const canManage = - booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED'; - const isMutating = updateStatusMutation.isPending; - - return ( -
-
-

- {booking.startTime} - {booking.endTime} · {booking.courtName} -

-

- {booking.customerName} ({booking.customerPhone}) · Código{' '} - {booking.bookingCode} -

-
- -
- - {effectiveStatusLabel(effectiveStatus)} - - - {canManage && ( - <> - - - - )} -
-
- ); - })} -
-
- ))} -
- - {updateStatusMutation.isError && ( -

- {extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')} -

- )} -
-
- ); + return ; } diff --git a/apps/frontend/src/features/layout/root-layout.tsx b/apps/frontend/src/features/layout/root-layout.tsx deleted file mode 100644 index 7bd98ec..0000000 --- a/apps/frontend/src/features/layout/root-layout.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { UserAvatar } from '@/components/user-avatar'; -import { apiClient } from '@/lib/api-client'; -import { useAuth } from '@/lib/auth'; -import { acceptStoredPendingInvite } from '@/lib/invitations'; -import { useCurrentComplexStore } from '@/lib/stores/current-complex-store'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Link, Outlet, useNavigate } from '@tanstack/react-router'; -import { LogOut, Menu, UserRound } from 'lucide-react'; -import { useEffect, useMemo, useRef } from 'react'; -import { ThemeSwitcher } from './theme-switcher'; - -export function RootLayout() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const { user, isAuthenticated, displayName, avatarUrl, signOut } = useAuth(); - const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore(); - const processedInviteForUser = useRef(null); - const myComplexesQuery = useQuery({ - queryKey: ['my-complexes'], - enabled: isAuthenticated, - queryFn: () => apiClient.complexes.listMine(), - }); - - useEffect(() => { - if (!isAuthenticated) { - processedInviteForUser.current = null; - return; - } - - const userKey = user?.email ?? displayName ?? 'authenticated-user'; - - if (processedInviteForUser.current === userKey) { - return; - } - - processedInviteForUser.current = userKey; - - void (async () => { - await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations); - await queryClient.invalidateQueries({ queryKey: ['my-complexes'] }); - })(); - }, [displayName, isAuthenticated, queryClient, user?.email]); - - const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug; - - const currentComplexName = useMemo(() => { - const complexes = myComplexesQuery.data ?? []; - if (complexes.length === 0) return 'Mi complejo'; - - const selected = complexes.find((complex) => complex.complexSlug === complexSlug); - - return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo'; - }, [complexSlug, myComplexesQuery.data]); - - const handleSignOut = async () => { - await signOut(); - await navigate({ to: '/login' }); - }; - - return ( -
-
-
-

{currentComplexName}

- -
- -
- - - {!isAuthenticated && ( - - )} - - {isAuthenticated && ( - - - - - - Mi cuenta - - {myComplexesQuery.data && myComplexesQuery.data.length > 1 && ( - <> - - Cambiar complejo - - {myComplexesQuery.data.map((complex) => ( - { - await apiClient.complexes.select({ complexId: complex.id }); - setCurrentComplex(complex.complexSlug); - }} - > - {complex.complexSlug === complexSlug && } - {complex.complexName} - - ))} - - - )} - { - void navigate({ to: '/profile' }); - }} - > - - Profile - - { - void handleSignOut(); - }} - > - - Sign out - - - - )} - - - - - - - Navegación - - { - void navigate({ to: '/' }); - }} - > - Home - - { - void navigate({ to: '/about' }); - }} - > - About - - {isAuthenticated && currentComplexSlug && ( - { - void navigate({ - to: '/complex/$slug/edit', - params: { slug: currentComplexSlug }, - }); - }} - > - Configuración - - )} - - - {isAuthenticated ? ( - <> - { - void navigate({ to: '/profile' }); - }} - > - - Profile - - { - void handleSignOut(); - }} - > - - Sign out - - - ) : ( - { - void navigate({ to: '/login' }); - }} - > - Login - - )} - - -
-
-
- -
-
- ); -} diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index dac14b1..328efd3 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -215,4 +215,33 @@ body, [role="button"]:not(:disabled) { cursor: pointer; } -} \ No newline at end of file +} + +@layer utilities { + .booking-scrollbar { + scrollbar-color: oklch(0.67 0.21 158 / 62%) oklch(1 0 0 / 8%); + scrollbar-width: thin; + } + + .booking-scrollbar::-webkit-scrollbar { + height: 12px; + } + + .booking-scrollbar::-webkit-scrollbar-track { + background: oklch(1 0 0 / 6%); + border-radius: 999px; + } + + .booking-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient(90deg, oklch(0.67 0.21 158 / 72%), oklch(0.61 0.2 255 / 58%)); + border: 3px solid transparent; + border-radius: 999px; + background-clip: padding-box; + } + + .booking-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient(90deg, oklch(0.67 0.21 158 / 88%), oklch(0.61 0.2 255 / 72%)); + border: 3px solid transparent; + background-clip: padding-box; + } +} diff --git a/apps/frontend/src/lib/theme.tsx b/apps/frontend/src/lib/theme.tsx index 8b39396..30e3b61 100644 --- a/apps/frontend/src/lib/theme.tsx +++ b/apps/frontend/src/lib/theme.tsx @@ -1,6 +1,7 @@ import { type PropsWithChildren, createContext, + useCallback, useContext, useEffect, useMemo, @@ -14,6 +15,7 @@ type ThemeContextValue = { theme: Theme; resolvedTheme: ResolvedTheme; setTheme: (theme: Theme) => void; + toggleTheme: () => void; }; const THEME_STORAGE_KEY = 'theme'; @@ -27,6 +29,18 @@ function applyResolvedTheme(theme: ResolvedTheme) { document.documentElement.classList.toggle('dark', theme === 'dark'); } +function isEditableElement(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + + const tagName = target.tagName.toLowerCase(); + return ( + tagName === 'input' || + tagName === 'textarea' || + tagName === 'select' || + target.isContentEditable + ); +} + export function ThemeProvider({ children }: PropsWithChildren) { const [theme, setThemeState] = useState('system'); const [resolvedTheme, setResolvedTheme] = useState('light'); @@ -54,18 +68,37 @@ export function ThemeProvider({ children }: PropsWithChildren) { return () => mediaQuery.removeEventListener('change', updateTheme); }, [theme]); - const setTheme = (nextTheme: Theme) => { + const setTheme = useCallback((nextTheme: Theme) => { setThemeState(nextTheme); localStorage.setItem(THEME_STORAGE_KEY, nextTheme); - }; + }, []); + + const toggleTheme = useCallback(() => { + setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); + }, [resolvedTheme, setTheme]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (isEditableElement(event.target)) return; + if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + if (event.key.toLowerCase() !== 'd') return; + + event.preventDefault(); + toggleTheme(); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [toggleTheme]); const value = useMemo( () => ({ theme, resolvedTheme, setTheme, + toggleTheme, }), - [theme, resolvedTheme] + [theme, resolvedTheme, setTheme, toggleTheme] ); return {children}; diff --git a/apps/frontend/src/routes/__root.tsx b/apps/frontend/src/routes/__root.tsx index ad27d02..fba4c84 100644 --- a/apps/frontend/src/routes/__root.tsx +++ b/apps/frontend/src/routes/__root.tsx @@ -23,5 +23,5 @@ function RootRoute() {
- ) -} \ No newline at end of file + ); +} diff --git a/apps/frontend/src/routes/_app/route.tsx b/apps/frontend/src/routes/_app/route.tsx index ed8b452..f3724a9 100644 --- a/apps/frontend/src/routes/_app/route.tsx +++ b/apps/frontend/src/routes/_app/route.tsx @@ -1,6 +1,6 @@ -import { RootLayout } from '@/features/layout/root-layout'; import { createFileRoute } from '@tanstack/react-router'; +import { Outlet } from '@tanstack/react-router'; export const Route = createFileRoute('/_app')({ - component: RootLayout, + component: Outlet, });