diff --git a/apps/backend/src/modules/admin-booking/admin-booking.routes.ts b/apps/backend/src/modules/admin-booking/admin-booking.routes.ts index 24bdf9a..f6aa181 100644 --- a/apps/backend/src/modules/admin-booking/admin-booking.routes.ts +++ b/apps/backend/src/modules/admin-booking/admin-booking.routes.ts @@ -3,7 +3,9 @@ import { cancelRecurringGroupHandler } from '@/modules/admin-booking/handlers/ca import { createAdminBookingHandler } from '@/modules/admin-booking/handlers/create-admin-booking.handler'; import { createAdminRecurringBookingHandler } from '@/modules/admin-booking/handlers/create-admin-recurring-booking.handler'; import { listAdminBookingsHandler } from '@/modules/admin-booking/handlers/list-admin-bookings.handler'; +import { listRecurringGroupsHandler } from '@/modules/admin-booking/handlers/list-recurring-groups.handler'; import { updateAdminBookingStatusHandler } from '@/modules/admin-booking/handlers/update-admin-booking-status.handler'; +import { updateRecurringGroupHandler } from '@/modules/admin-booking/handlers/update-recurring-group.handler'; import type { AppEnv } from '@/types/hono'; import { zValidator } from '@hono/zod-validator'; import { @@ -11,6 +13,7 @@ import { createRecurringBookingSchema, listAdminBookingsQuerySchema, updateAdminBookingStatusSchema, + updateRecurringGroupSchema, } from '@repo/api-contract'; import { Hono } from 'hono'; import { z } from 'zod'; @@ -49,6 +52,19 @@ adminBookingRoutes.post( cancelRecurringGroupHandler ); +adminBookingRoutes.get( + '/complex/:complexId/recurring', + zValidator('param', complexIdParamsSchema), + listRecurringGroupsHandler +); + +adminBookingRoutes.patch( + '/recurring/:groupId', + zValidator('param', groupIdParamsSchema), + zValidator('json', updateRecurringGroupSchema), + updateRecurringGroupHandler +); + adminBookingRoutes.patch( '/:id/status', zValidator('param', bookingIdParamsSchema), diff --git a/apps/backend/src/modules/admin-booking/handlers/list-recurring-groups.handler.ts b/apps/backend/src/modules/admin-booking/handlers/list-recurring-groups.handler.ts new file mode 100644 index 0000000..6f79004 --- /dev/null +++ b/apps/backend/src/modules/admin-booking/handlers/list-recurring-groups.handler.ts @@ -0,0 +1,19 @@ +import { AdminBookingServiceError } from '@/modules/admin-booking/services/admin-booking.service'; +import { listRecurringGroups } from '@/modules/admin-booking/services/admin-recurring-booking.service'; +import type { AppContext } from '@/types/hono'; + +type ComplexIdParams = { complexId: string }; + +export async function listRecurringGroupsHandler(c: AppContext) { + const { complexId } = c.req.valid('param' as never) as ComplexIdParams; + + try { + const groups = await listRecurringGroups(complexId); + return c.json({ groups }); + } catch (error) { + if (error instanceof AdminBookingServiceError) { + return c.json({ message: error.message }, error.status); + } + throw error; + } +} diff --git a/apps/backend/src/modules/admin-booking/handlers/update-recurring-group.handler.ts b/apps/backend/src/modules/admin-booking/handlers/update-recurring-group.handler.ts new file mode 100644 index 0000000..7df3c30 --- /dev/null +++ b/apps/backend/src/modules/admin-booking/handlers/update-recurring-group.handler.ts @@ -0,0 +1,22 @@ +import { AdminBookingServiceError } from '@/modules/admin-booking/services/admin-booking.service'; +import { updateRecurringGroup } from '@/modules/admin-booking/services/admin-recurring-booking.service'; +import type { AppContext } from '@/types/hono'; +import type { UpdateRecurringGroupInput } from '@repo/api-contract'; + +type GroupIdParams = { groupId: string }; + +export async function updateRecurringGroupHandler(c: AppContext) { + const { groupId } = c.req.valid('param' as never) as GroupIdParams; + const payload = c.req.valid('json' as never) as UpdateRecurringGroupInput; + const user = c.get('user'); + + try { + const group = await updateRecurringGroup(user.id, groupId, payload); + return c.json(group); + } catch (error) { + if (error instanceof AdminBookingServiceError) { + return c.json({ message: error.message }, error.status); + } + throw error; + } +} diff --git a/apps/backend/src/modules/admin-booking/services/admin-recurring-booking.service.ts b/apps/backend/src/modules/admin-booking/services/admin-recurring-booking.service.ts index 3f5b926..5793a84 100644 --- a/apps/backend/src/modules/admin-booking/services/admin-recurring-booking.service.ts +++ b/apps/backend/src/modules/admin-booking/services/admin-recurring-booking.service.ts @@ -7,7 +7,11 @@ import { isFeatureEnabled, parsePlanRules, } from '@/modules/plan/services/plan-rules.service'; -import type { CreateRecurringBookingInput, RecurringBookingGroup } from '@repo/api-contract'; +import type { + CreateRecurringBookingInput, + RecurringBookingGroup, + UpdateRecurringGroupInput, +} from '@repo/api-contract'; import { v7 as uuidv7 } from 'uuid'; import { AdminBookingServiceError } from './admin-booking.service'; @@ -15,6 +19,16 @@ const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const BOOKING_CODE_LENGTH = 6; const MAX_RECURRING_WEEKS = 52; +const DAY_INDEX_BY_VALUE: Record = { + SUNDAY: 0, + MONDAY: 1, + TUESDAY: 2, + WEDNESDAY: 3, + THURSDAY: 4, + FRIDAY: 5, + SATURDAY: 6, +}; + const DAY_OF_WEEK_BY_INDEX = [ DayOfWeekEnum.SUNDAY, DayOfWeekEnum.MONDAY, @@ -574,3 +588,306 @@ export async function cancelRecurringGroup(userId: string, groupId: string) { return { ok: true }; } + +export async function listRecurringGroups(complexId: string): Promise { + const groups = await db.recurringBookingGroup.findMany({ + where: { complexId, status: 'ACTIVE' }, + include: { + court: { + select: { + id: true, + name: true, + sport: { + select: { id: true, name: true, slug: true }, + }, + complex: { + select: { id: true, complexName: true }, + }, + }, + }, + bookings: { + orderBy: { bookingDate: 'asc' }, + include: { + court: { + select: { + id: true, + name: true, + sport: { + select: { id: true, name: true, slug: true }, + }, + complex: { + select: { id: true, complexName: true }, + }, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return groups.map((group) => ({ + id: group.id, + complexId: group.complexId, + courtId: group.courtId, + startTime: group.startTime, + endTime: group.endTime, + dayOfWeek: group.dayOfWeek, + startDate: formatIsoDate(group.startDate), + endDate: group.endDate ? formatIsoDate(group.endDate) : null, + status: group.status, + customerName: group.customerName, + customerPhone: group.customerPhone, + customerEmail: group.customerEmail, + bookings: group.bookings.map(mapBookingResponse), + createdAt: group.createdAt.toISOString(), + updatedAt: group.updatedAt.toISOString(), + })); +} + +export async function updateRecurringGroup( + userId: string, + groupId: string, + input: UpdateRecurringGroupInput +): Promise { + const group = await db.recurringBookingGroup.findUnique({ + where: { id: groupId }, + include: { + complex: { + select: { + id: true, + users: { + where: { userId }, + select: { userId: true }, + }, + }, + }, + }, + }); + + if (!group) { + throw new AdminBookingServiceError('Grupo de turnos fijos no encontrado.', 404); + } + + if (group.complex.users.length === 0) { + throw new AdminBookingServiceError('No tienes permisos para administrar este complejo.', 403); + } + + if (group.status === 'CANCELLED') { + throw new AdminBookingServiceError('No se puede editar un grupo cancelado.', 409); + } + + const courtId = input.courtId ?? group.courtId; + const dayOfWeek = input.dayOfWeek ?? group.dayOfWeek; + const startTime = input.startTime ?? group.startTime; + + const scheduleChanged = + input.courtId !== undefined || input.dayOfWeek !== undefined || input.startTime !== undefined; + + if (scheduleChanged) { + const court = await db.court.findFirst({ + where: { id: courtId, complexId: group.complexId }, + include: { + availabilities: { + where: { dayOfWeek: dayOfWeek as DayOfWeek }, + orderBy: { startTime: 'asc' }, + }, + }, + }); + + if (!court) { + throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404); + } + + const validSlots = buildSlots(court.availabilities, court.slotDurationMinutes); + const selectedEndMinutes = toMinutes(startTime) + court.slotDurationMinutes; + const endTime = minutesToTime(selectedEndMinutes); + + const slotExists = validSlots.some( + (slot) => slot.startTime === startTime && slot.endTime === endTime + ); + + if (!slotExists) { + throw new AdminBookingServiceError( + 'El horario seleccionado no esta disponible para esa cancha.', + 409 + ); + } + + const now = new Date(); + const todayStart = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + ); + + await db.$transaction(async (tx) => { + await tx.recurringBookingGroup.update({ + where: { id: groupId }, + data: { + courtId, + dayOfWeek: dayOfWeek as DayOfWeek, + startTime, + endTime, + customerName: input.customerName?.trim() ?? group.customerName, + customerPhone: input.customerPhone?.trim() ?? group.customerPhone, + customerEmail: input.customerEmail?.trim() ?? group.customerEmail, + }, + }); + + const futureBookings = await tx.courtBooking.findMany({ + where: { + recurringGroupId: groupId, + bookingDate: { gte: todayStart }, + status: 'CONFIRMED', + }, + }); + + for (const booking of futureBookings) { + await tx.courtBookingLog.create({ + data: { + id: uuidv7(), + bookingCode: booking.bookingCode, + courtId: booking.courtId, + bookingDate: booking.bookingDate, + startTime: booking.startTime, + endTime: booking.endTime, + customerName: booking.customerName, + customerPhone: booking.customerPhone, + customerEmail: booking.customerEmail, + previousStatus: booking.status, + newStatus: 'CANCELLED', + changedAt: new Date(), + }, + }); + + await tx.courtBooking.delete({ + where: { id: booking.id }, + }); + } + + const dayIndex = DAY_INDEX_BY_VALUE[dayOfWeek] ?? 0; + const recurringDates = Array.from( + generateRecurringDates(todayStart, group.endDate, dayIndex) + ); + + for (const date of recurringDates) { + const overlappingBooking = await tx.courtBooking.findFirst({ + where: { + courtId, + bookingDate: date, + status: 'CONFIRMED', + startTime: { lt: endTime }, + endTime: { gt: startTime }, + }, + }); + + if (overlappingBooking) { + throw new AdminBookingServiceError( + `El horario seleccionado ya fue reservado para el dia ${formatIsoDate(date)}.`, + 409 + ); + } + + await tx.courtBooking.create({ + data: { + id: uuidv7(), + bookingCode: generateBookingCode(), + courtId, + bookingDate: date, + startTime, + endTime, + customerName: input.customerName?.trim() ?? group.customerName, + customerPhone: input.customerPhone?.trim() ?? group.customerPhone, + customerEmail: input.customerEmail?.trim() ?? group.customerEmail, + status: 'CONFIRMED', + recurringGroupId: groupId, + }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + }, + }); + } + }); + } else { + await db.recurringBookingGroup.update({ + where: { id: groupId }, + data: { + ...(input.customerName !== undefined && { customerName: input.customerName.trim() }), + ...(input.customerPhone !== undefined && { customerPhone: input.customerPhone.trim() }), + ...(input.customerEmail !== undefined && { customerEmail: input.customerEmail.trim() }), + }, + }); + + const now = new Date(); + const todayStart = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + ); + + const updateData: Record = {}; + if (input.customerName !== undefined) updateData.customerName = input.customerName.trim(); + if (input.customerPhone !== undefined) updateData.customerPhone = input.customerPhone.trim(); + if (input.customerEmail !== undefined) updateData.customerEmail = input.customerEmail.trim(); + + if (Object.keys(updateData).length > 0) { + await db.courtBooking.updateMany({ + where: { recurringGroupId: groupId, bookingDate: { gte: todayStart } }, + data: updateData, + }); + } + } + + const updated = await db.recurringBookingGroup.findUnique({ + where: { id: groupId }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + bookings: { + orderBy: { bookingDate: 'asc' }, + include: { + court: { + select: { + id: true, + name: true, + sport: { select: { id: true, name: true, slug: true } }, + complex: { select: { id: true, complexName: true } }, + }, + }, + }, + }, + }, + }); + + if (!updated) { + throw new AdminBookingServiceError('Error al actualizar el grupo.', 409); + } + + return { + id: updated.id, + complexId: updated.complexId, + courtId: updated.courtId, + startTime: updated.startTime, + endTime: updated.endTime, + dayOfWeek: updated.dayOfWeek, + startDate: formatIsoDate(updated.startDate), + endDate: updated.endDate ? formatIsoDate(updated.endDate) : null, + status: updated.status, + customerName: updated.customerName, + customerPhone: updated.customerPhone, + customerEmail: updated.customerEmail, + bookings: updated.bookings.map(mapBookingResponse), + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + }; +} diff --git a/apps/frontend/src/features/booking/booking-provider.tsx b/apps/frontend/src/features/booking/booking-provider.tsx index bff1821..4515ba8 100644 --- a/apps/frontend/src/features/booking/booking-provider.tsx +++ b/apps/frontend/src/features/booking/booking-provider.tsx @@ -1,5 +1,11 @@ import { ApiClientError, apiClient } from '@/lib/api-client'; -import type { AdminBooking, ComplexWithRole, Court, PlanFeatureFlags } from '@repo/api-contract'; +import type { + AdminBooking, + ComplexWithRole, + Court, + PlanFeatureFlags, + RecurringBookingGroup, +} from '@repo/api-contract'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { type ReactNode, @@ -85,6 +91,11 @@ interface BookingContextValue { setViewMode: (mode: BookingViewMode) => void; setVisibleTimeRange: (range: BookingTimeRange) => void; openCreateBooking: (draft?: Partial) => void; + recurringGroups: RecurringBookingGroup[]; + isRecurringGroupsLoading: boolean; + cancelRecurringGroup: (groupId: string) => void; + isCancellingRecurringGroup: boolean; + refreshRecurringGroups: () => void; closeCreateBooking: () => void; createBooking: (payload: CreateManualBookingPayload) => Promise; createRecurringBooking: (payload: CreateRecurringBookingPayload) => Promise; @@ -258,6 +269,20 @@ export function BookingProvider({ children, complex }: BookingProviderProps) { queryFn: () => apiClient.courts.listByComplex(complex.id), }); + const recurringGroupsQuery = useQuery({ + queryKey: ['recurring-groups', complex.id], + queryFn: () => apiClient.adminBookings.listRecurringGroups(complex.id), + enabled: viewMode === 'recurring', + }); + + const cancelRecurringGroupMutation = useMutation({ + mutationFn: (groupId: string) => apiClient.adminBookings.cancelRecurringGroup(groupId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['recurring-groups', complex.id] }); + await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] }); + }, + }); + const bookingsQuery = useQuery({ queryKey: ['admin-bookings', complex.id, selectedDate], queryFn: () => @@ -480,6 +505,12 @@ export function BookingProvider({ children, complex }: BookingProviderProps) { 'No pudimos cargar el panel de reservas.' ) : null, + recurringGroups: recurringGroupsQuery.data?.groups ?? [], + isRecurringGroupsLoading: recurringGroupsQuery.isLoading, + cancelRecurringGroup: (groupId: string) => cancelRecurringGroupMutation.mutate(groupId), + isCancellingRecurringGroup: cancelRecurringGroupMutation.isPending, + refreshRecurringGroups: () => + queryClient.invalidateQueries({ queryKey: ['recurring-groups', complex.id] }), isCreateBookingOpen, createBookingError: createBookingMutation.isError || createRecurringBookingMutation.isError @@ -545,6 +576,9 @@ export function BookingProvider({ children, complex }: BookingProviderProps) { viewMode, visibleTimeRange, bookingToolsOpen, + recurringGroupsQuery.data?.groups, + recurringGroupsQuery.isLoading, + cancelRecurringGroupMutation.isPending, ] ); diff --git a/apps/frontend/src/features/booking/booking.tsx b/apps/frontend/src/features/booking/booking.tsx index 301dfc6..8011293 100644 --- a/apps/frontend/src/features/booking/booking.tsx +++ b/apps/frontend/src/features/booking/booking.tsx @@ -12,6 +12,7 @@ import { BookingMobile } from './components/booking-mobile'; import { BookingTimeline } from './components/booking-timeline'; import { BookingToolbar } from './components/booking-toolbar'; import { BookingToolsDialog } from './components/booking-tools-dialog'; +import { RecurringBookingListView } from './components/recurring-booking-list'; interface BookingProps { complex: ComplexWithRole; @@ -71,6 +72,10 @@ function BookingInner() { return ; } + if (viewMode === 'recurring') { + return ; + } + if (isMobile) { return ; } diff --git a/apps/frontend/src/features/booking/booking.types.ts b/apps/frontend/src/features/booking/booking.types.ts index 775dca3..e7c7218 100644 --- a/apps/frontend/src/features/booking/booking.types.ts +++ b/apps/frontend/src/features/booking/booking.types.ts @@ -1,6 +1,6 @@ import type { AdminBooking, Court } from '@repo/api-contract'; -export type BookingViewMode = 'panel' | 'status' | 'list'; +export type BookingViewMode = 'panel' | 'status' | 'list' | 'recurring'; export type BookingStatusFilter = 'all' | 'free' | 'reserved'; export type BookingSegmentStatus = 'free' | 'reserved'; diff --git a/apps/frontend/src/features/booking/components/booking-day-summary.tsx b/apps/frontend/src/features/booking/components/booking-day-summary.tsx index 31a07c6..316bb8b 100644 --- a/apps/frontend/src/features/booking/components/booking-day-summary.tsx +++ b/apps/frontend/src/features/booking/components/booking-day-summary.tsx @@ -1,4 +1,4 @@ -import { CalendarDays, Download, ShieldCheck, UsersRound } from 'lucide-react'; +import { CalendarDays, Download, Repeat, ShieldCheck, UsersRound } from 'lucide-react'; import { useBooking } from '../booking-provider'; import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time'; @@ -66,7 +66,7 @@ function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCar } export function BookingQuickActions() { - const { setSelectedDate, setViewMode, exportDayReport } = useBooking(); + const { setSelectedDate, setViewMode, exportDayReport, isRecurringEnabled } = useBooking(); const todayIso = toIsoDateLocal(new Date()); return ( @@ -92,6 +92,16 @@ export function BookingQuickActions() { Exportar reporte + {isRecurringEnabled && ( + + )} ); diff --git a/apps/frontend/src/features/booking/components/recurring-booking-edit-dialog.tsx b/apps/frontend/src/features/booking/components/recurring-booking-edit-dialog.tsx new file mode 100644 index 0000000..b0877de --- /dev/null +++ b/apps/frontend/src/features/booking/components/recurring-booking-edit-dialog.tsx @@ -0,0 +1,210 @@ +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { apiClient } from '@/lib/api-client'; +import type { RecurringBookingGroup } from '@repo/api-contract'; +import { useEffect, useState } from 'react'; +import { useBooking } from '../booking-provider'; +import { minutesToTime, timeToMinutes } from '../lib/booking-time'; + +const DAY_OPTIONS = [ + { value: 'MONDAY', label: 'Lunes' }, + { value: 'TUESDAY', label: 'Martes' }, + { value: 'WEDNESDAY', label: 'Miércoles' }, + { value: 'THURSDAY', label: 'Jueves' }, + { value: 'FRIDAY', label: 'Viernes' }, + { value: 'SATURDAY', label: 'Sábado' }, + { value: 'SUNDAY', label: 'Domingo' }, +]; + +interface RecurringBookingEditDialogProps { + group: RecurringBookingGroup; + open: boolean; + onClose: () => void; +} + +export function RecurringBookingEditDialog({ + group, + open, + onClose, +}: RecurringBookingEditDialogProps) { + const { courts, refreshRecurringGroups } = useBooking(); + + const [courtId, setCourtId] = useState(group.courtId); + const [dayOfWeek, setDayOfWeek] = useState(group.dayOfWeek); + const [startTime, setStartTime] = useState(group.startTime); + const [customerName, setCustomerName] = useState(group.customerName); + const [customerPhone, setCustomerPhone] = useState(group.customerPhone); + const [customerEmail, setCustomerEmail] = useState(group.customerEmail); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setCourtId(group.courtId); + setDayOfWeek(group.dayOfWeek); + setStartTime(group.startTime); + setCustomerName(group.customerName); + setCustomerPhone(group.customerPhone); + setCustomerEmail(group.customerEmail); + }, [group]); + + const selectedCourt = courts.find((c) => c.id === courtId); + const slotDuration = selectedCourt?.slotDurationMinutes ?? 60; + + const availableSlots: string[] = []; + if (selectedCourt) { + const availabilities = selectedCourt.availability.filter((a) => a.dayOfWeek === dayOfWeek); + for (const avail of availabilities) { + const startMins = timeToMinutes(avail.startTime); + const endMins = timeToMinutes(avail.endTime); + for (let m = startMins; m + slotDuration <= endMins; m += slotDuration) { + availableSlots.push(minutesToTime(m)); + } + } + } + + const handleSave = async () => { + if (!customerName || customerName.trim().length < 2) { + setError('El nombre debe tener al menos 2 caracteres.'); + return; + } + + setError(''); + setSaving(true); + + try { + await apiClient.adminBookings.updateRecurringGroup(group.id, { + ...(courtId !== group.courtId && { courtId }), + ...(dayOfWeek !== group.dayOfWeek && { + dayOfWeek: dayOfWeek as + | 'MONDAY' + | 'TUESDAY' + | 'WEDNESDAY' + | 'THURSDAY' + | 'FRIDAY' + | 'SATURDAY' + | 'SUNDAY', + }), + ...(startTime !== group.startTime && { startTime }), + ...(customerName !== group.customerName && { customerName }), + ...(customerPhone !== group.customerPhone && { customerPhone }), + ...(customerEmail !== group.customerEmail && { customerEmail }), + }); + + await refreshRecurringGroups(); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Error al guardar los cambios.'); + } finally { + setSaving(false); + } + }; + + return ( + !open && onClose()}> + + + Editar turno fijo + + +
+
+ + setCustomerName(e.target.value)} + placeholder="Nombre del cliente" + /> +
+ +
+ + setCustomerPhone(e.target.value)} + placeholder="Teléfono" + /> +
+ +
+ + setCustomerEmail(e.target.value)} + placeholder="Email" + /> +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/features/booking/components/recurring-booking-list.tsx b/apps/frontend/src/features/booking/components/recurring-booking-list.tsx new file mode 100644 index 0000000..668e334 --- /dev/null +++ b/apps/frontend/src/features/booking/components/recurring-booking-list.tsx @@ -0,0 +1,246 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import type { RecurringBookingGroup } from '@repo/api-contract'; +import { ArrowLeft, Clock, MapPin, Pencil, Repeat, Trash2, User } from 'lucide-react'; +import { useState } from 'react'; +import { useBooking } from '../booking-provider'; +import { RecurringBookingEditDialog } from './recurring-booking-edit-dialog'; + +const DAY_LABELS: Record = { + MONDAY: 'Lunes', + TUESDAY: 'Martes', + WEDNESDAY: 'Miércoles', + THURSDAY: 'Jueves', + FRIDAY: 'Viernes', + SATURDAY: 'Sábado', + SUNDAY: 'Domingo', +}; + +function getNextBookingDate(group: RecurringBookingGroup): string { + const today = new Date().toISOString().slice(0, 10); + const future = group.bookings + .filter((b) => b.date >= today && b.status === 'CONFIRMED') + .sort((a, b) => a.date.localeCompare(b.date)); + return future[0]?.date ?? '-'; +} + +function formatDate(dateStr: string): string { + if (dateStr === '-') return '-'; + const [y, m, d] = dateStr.split('-'); + return `${d}/${m}/${y}`; +} + +export function RecurringBookingListView() { + const { + setViewMode, + recurringGroups, + isRecurringGroupsLoading, + cancelRecurringGroup, + isCancellingRecurringGroup, + } = useBooking(); + + const [editingGroup, setEditingGroup] = useState(null); + const [cancellingGroupId, setCancellingGroupId] = useState(null); + + return ( + <> +
+
+
+ +
+

Turnos fijos

+

+ {recurringGroups.length}{' '} + {recurringGroups.length === 1 ? 'turno fijo' : 'turnos fijos'} +

+
+
+
+ + {isRecurringGroupsLoading && ( +
Cargando turnos fijos...
+ )} + + {!isRecurringGroupsLoading && recurringGroups.length === 0 && ( +
+ +

No hay turnos fijos creados.

+
+ )} + + {!isRecurringGroupsLoading && recurringGroups.length > 0 && ( +
+ + + + + + + + + + + + + {recurringGroups.map((group) => { + const nextDate = getNextBookingDate(group); + return ( + + + + + + + + + ); + })} + +
ClienteCanchaDíaHorarioPróxima fechaAcciones
+
+ + {group.customerName || '-'} +
+
+
+ + + {group.bookings[0]?.courtName ?? '-'} + +
+
+ {DAY_LABELS[group.dayOfWeek] ?? group.dayOfWeek} + +
+ + + {group.startTime} - {group.endTime} + +
+
{formatDate(nextDate)} +
+ + +
+
+ +
+ {recurringGroups.map((group) => { + const nextDate = getNextBookingDate(group); + return ( +
+
+
+ + {group.customerName || '-'} +
+ + {DAY_LABELS[group.dayOfWeek]} + +
+

+ {group.bookings[0]?.courtName} · {group.startTime} - {group.endTime} +

+

+ Próxima: {formatDate(nextDate)} +

+
+ + +
+
+ ); + })} +
+
+ )} +
+ + !open && setCancellingGroupId(null)} + > + + + Cancelar turno fijo + + ¿Estás seguro de cancelar este turno fijo? Se eliminarán todas las reservas futuras. + Las reservas pasadas no se verán afectadas. + + + + + + + + + + {editingGroup && ( + setEditingGroup(null)} + /> + )} + + ); +} diff --git a/apps/frontend/src/lib/api-client.ts b/apps/frontend/src/lib/api-client.ts index cd521d1..4e084c3 100644 --- a/apps/frontend/src/lib/api-client.ts +++ b/apps/frontend/src/lib/api-client.ts @@ -76,6 +76,8 @@ export const apiClient = { create: api.createAdmin, createRecurring: api.createRecurring, cancelRecurringGroup: api.cancelRecurringGroup, + listRecurringGroups: api.listRecurringGroups, + updateRecurringGroup: api.updateRecurringGroup, updateStatus: api.updateStatus, }, }; diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts index 6249f02..ddcb67f 100644 --- a/apps/frontend/src/lib/api/index.ts +++ b/apps/frontend/src/lib/api/index.ts @@ -17,5 +17,7 @@ export { createAdmin, createRecurring, cancelRecurringGroup, + listRecurringGroups, + updateRecurringGroup, updateStatus, } from './resources/bookings'; diff --git a/apps/frontend/src/lib/api/resources/bookings.ts b/apps/frontend/src/lib/api/resources/bookings.ts index 11999df..735d11c 100644 --- a/apps/frontend/src/lib/api/resources/bookings.ts +++ b/apps/frontend/src/lib/api/resources/bookings.ts @@ -3,11 +3,13 @@ import type { CancelPublicBookingInput, CreateAdminBookingInput, CreatePublicBookingInput, + ListRecurringGroupsResponse, PublicAvailabilityResponse, PublicBooking, PublicBookingConfirmation, RecurringBookingGroup, UpdateAdminBookingStatusInput, + UpdateRecurringGroupInput, } from '@repo/api-contract'; import type { CreateRecurringBookingInput } from '@repo/api-contract'; import { http } from '../http'; @@ -69,6 +71,21 @@ export async function cancelRecurringGroup(groupId: string) { return response.data; } +export async function listRecurringGroups(complexId: string) { + const response = await http.get( + `/api/admin-bookings/complex/${complexId}/recurring` + ); + return response.data; +} + +export async function updateRecurringGroup(groupId: string, payload: UpdateRecurringGroupInput) { + const response = await http.patch( + `/api/admin-bookings/recurring/${groupId}`, + payload + ); + return response.data; +} + export async function cancelPublic(complexSlug: string, payload: CancelPublicBookingInput) { const response = await http.post( `/api/public-bookings/complex/${complexSlug}/cancel`, diff --git a/packages/api-contract/src/admin-booking.ts b/packages/api-contract/src/admin-booking.ts index 063b338..a88f91b 100644 --- a/packages/api-contract/src/admin-booking.ts +++ b/packages/api-contract/src/admin-booking.ts @@ -90,6 +90,31 @@ export const recurringBookingGroupSchema = z.object({ updatedAt: z.string().datetime(), }); +export const updateRecurringGroupSchema = z.object({ + courtId: z.uuid('El courtId debe ser un UUID valido.').optional(), + dayOfWeek: z + .enum(['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']) + .optional(), + startTime: z.string().regex(TIME_REGEX, 'La hora debe tener formato HH:mm.').optional(), + customerName: z + .string() + .trim() + .min(2, 'El nombre debe tener al menos 2 caracteres.') + .max(120, 'El nombre no puede superar los 120 caracteres.') + .optional(), + customerPhone: z + .string() + .trim() + .min(6, 'El telefono debe tener al menos 6 caracteres.') + .max(30, 'El telefono no puede superar los 30 caracteres.') + .optional(), + customerEmail: z.string().email('El email ingresado no es valido.').or(z.literal('')).optional(), +}); + +export const listRecurringGroupsResponseSchema = z.object({ + groups: z.array(recurringBookingGroupSchema), +}); + export type BookingStatus = z.infer; export type RecurringGroupStatus = z.infer; export type AdminBookingSport = z.infer; @@ -100,3 +125,5 @@ export type CreateAdminBookingInput = z.infer; export type CreateRecurringBookingInput = z.infer; export type RecurringBookingGroup = z.infer; export type UpdateAdminBookingStatusInput = z.infer; +export type UpdateRecurringGroupInput = z.infer; +export type ListRecurringGroupsResponse = z.infer; diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 5ee9a5c..34af853 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -79,9 +79,11 @@ export { createRecurringBookingSchema, listAdminBookingsQuerySchema, listAdminBookingsResponseSchema, + listRecurringGroupsResponseSchema, recurringBookingGroupSchema, recurringGroupStatusSchema, updateAdminBookingStatusSchema, + updateRecurringGroupSchema, } from './admin-booking'; export type { AdminBooking, @@ -91,9 +93,11 @@ export type { CreateRecurringBookingInput, ListAdminBookingsQuery, ListAdminBookingsResponse, + ListRecurringGroupsResponse, RecurringBookingGroup, RecurringGroupStatus, UpdateAdminBookingStatusInput, + UpdateRecurringGroupInput, } from './admin-booking'; export { createPublicBookingSchema,