diff --git a/apps/backend/src/lib/http/problem-mapper.ts b/apps/backend/src/lib/http/problem-mapper.ts index 7b97a10..65ff271 100644 --- a/apps/backend/src/lib/http/problem-mapper.ts +++ b/apps/backend/src/lib/http/problem-mapper.ts @@ -17,6 +17,7 @@ function statusFromError(error: AppError): ContentfulStatusCode { case 'unexpected': return 500; default: + return 500; } } diff --git a/apps/frontend/src/features/booking/components/booking-mobile.tsx b/apps/frontend/src/features/booking/components/booking-mobile.tsx index 3c4f4bd..cfb7387 100644 --- a/apps/frontend/src/features/booking/components/booking-mobile.tsx +++ b/apps/frontend/src/features/booking/components/booking-mobile.tsx @@ -17,6 +17,7 @@ import { ChevronLeft, ChevronRight, CircleDot, + Download, Dumbbell, Grid2X2, Laptop, @@ -38,10 +39,13 @@ import { useBooking } from '../booking-provider'; import type { BookingCourtSchedule, BookingTimelineSegment } from '../booking.types'; import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time'; +type MobileTab = 'panel' | 'status' | 'bookings' | 'more'; + export function BookingMobile() { const { schedules, isLoading, isError, errorMessage } = useBooking(); const [selectedCourtId, setSelectedCourtId] = useState(null); const [search, setSearch] = useState(''); + const [activeTab, setActiveTab] = useState('panel'); const selectedSchedule = useMemo(() => { if (!selectedCourtId) return null; @@ -67,52 +71,60 @@ export function BookingMobile() {
- - - + {activeTab === 'panel' && ( + <> + + + -
-

Canchas

-
- - setSearch(event.target.value)} - /> -
+
+

Canchas

+
+ + setSearch(event.target.value)} + /> +
- {isLoading && ( -
- Cargando reservas... -
- )} + {isLoading && ( +
+ Cargando reservas... +
+ )} - {isError && !isLoading && ( -
- {errorMessage} -
- )} + {isError && !isLoading && ( +
+ {errorMessage} +
+ )} - {!isLoading && !isError && filteredSchedules.length === 0 && ( -
- No hay canchas para los filtros seleccionados. -
- )} + {!isLoading && !isError && filteredSchedules.length === 0 && ( +
+ No hay canchas para los filtros seleccionados. +
+ )} - {!isLoading && - !isError && - filteredSchedules.map((schedule) => ( - setSelectedCourtId(schedule.court.id)} - /> - ))} -
+ {!isLoading && + !isError && + filteredSchedules.map((schedule) => ( + setSelectedCourtId(schedule.court.id)} + /> + ))} +
+ + )} + + {activeTab === 'status' && } + {activeTab === 'bookings' && } + {activeTab === 'more' && }
- +
); } @@ -406,6 +418,262 @@ function MobileSummary() { ); } +function MobileStatusTab() { + const { + courts, + selectedSportId, + selectedStatus, + visibleTimeRange, + setSelectedSportId, + setSelectedStatus, + setVisibleTimeRange, + } = useBooking(); + const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()]; + const activeRangeValue = `${visibleTimeRange.start}-${visibleTimeRange.end}`; + const statusOptions = [ + { value: 'all', label: 'Todos' }, + { value: 'free', label: 'Libres' }, + { value: 'reserved', label: 'Reservados' }, + { value: 'maintenance', label: 'Mantenimiento' }, + ] as const; + 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' }, + ]; + + return ( + <> +
+

Estado

+

Filtra disponibilidad por cancha.

+
+ + + + +
+
+

Estado

+
+ {statusOptions.map((option) => ( + setSelectedStatus(option.value)} + /> + ))} +
+
+ +
+

Deporte

+
+ setSelectedSportId('all')} + /> + {sports.map((sport) => ( + setSelectedSportId(sport.id)} + /> + ))} +
+
+ +
+

Horario visible

+
+ {timeRangeOptions.map((option) => ( + setVisibleTimeRange({ start: option.start, end: option.end })} + /> + ))} +
+
+
+ +
+ +
+ + ); +} + +function MobileFilterChip({ + active, + label, + onClick, +}: { + active: boolean; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function MobileBookingsTab() { + const { selectedDate, schedules, openBookingTools, openCreateBooking } = useBooking(); + const reservedSegments = schedules + .flatMap((schedule) => + schedule.segments + .filter((segment) => segment.booking) + .map((segment) => ({ schedule, segment })) + ) + .sort((a, b) => a.segment.startMinutes - b.segment.startMinutes); + + return ( + <> +
+
+

Reservas

+

{formatMobileDate(selectedDate)}

+
+ +
+ + + +
+ {reservedSegments.length === 0 ? ( +
+ No hay reservas para este día. +
+ ) : ( + reservedSegments.map(({ schedule, segment }) => ( + + )) + )} +
+ + ); +} + +function MobileMoreTab() { + const navigate = useNavigate(); + const { currentComplexSlug } = useCurrentComplexStore(); + const { openCreateBooking, exportDayReport } = useBooking(); + + return ( + <> +
+

Más

+

Accesos rápidos del panel.

+
+ +
+ } + label="Nueva reserva" + onClick={() => openCreateBooking()} + /> + } + label="Exportar reservas del día" + onClick={exportDayReport} + /> + } + label="Mi perfil" + onClick={() => { + void navigate({ to: '/profile' }); + }} + /> + {currentComplexSlug && ( + } + label="Configuración del complejo" + onClick={() => { + void navigate({ + to: '/complex/$slug/edit', + params: { slug: currentComplexSlug }, + }); + }} + /> + )} +
+ + ); +} + +function MobileMenuAction({ + icon, + label, + onClick, +}: { + icon: React.ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function formatMobileDate(date: string) { + return new Intl.DateTimeFormat('es-AR', { + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(new Date(`${date}T00:00:00`)); +} + function SummaryPill({ value, label, @@ -729,28 +997,38 @@ function MetricDots({ schedule }: { schedule: BookingCourtSchedule }) { ); } -function MobileBottomNav({ active }: { active: 'panel' | 'status' | 'bookings' | 'more' }) { +function MobileBottomNav({ + active, + onSelect, +}: { + active: MobileTab; + onSelect: (tab: MobileTab) => void; +}) { return ( ); @@ -760,14 +1038,17 @@ function BottomNavItem({ active, icon, label, -}: { active: boolean; icon: React.ReactNode; label: string }) { + onClick, +}: { active: boolean; icon: React.ReactNode; label: string; onClick: () => void }) { return (