feat: add recurring booking management functionality

- Implemented API endpoints for listing and updating recurring booking groups.
- Added handlers for listing and updating recurring groups in the backend.
- Created frontend components for displaying and editing recurring bookings.
- Enhanced booking provider to manage recurring groups and their states.
- Updated API client to include new methods for recurring bookings.
- Introduced new validation schemas for updating recurring groups.
- Added UI elements for managing recurring bookings in the booking interface.
This commit is contained in:
Jose Selesan
2026-06-16 15:16:38 -03:00
parent 1318e3bf57
commit 3b3def94ab
15 changed files with 936 additions and 5 deletions

View File

@@ -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),

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<string, number> = {
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<RecurringBookingGroup[]> {
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<RecurringBookingGroup> {
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<string, string> = {};
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(),
};
}

View File

@@ -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<BookingSlotDraft>) => void;
recurringGroups: RecurringBookingGroup[];
isRecurringGroupsLoading: boolean;
cancelRecurringGroup: (groupId: string) => void;
isCancellingRecurringGroup: boolean;
refreshRecurringGroups: () => void;
closeCreateBooking: () => void;
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
createRecurringBooking: (payload: CreateRecurringBookingPayload) => Promise<void>;
@@ -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,
]
);

View File

@@ -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 <BookingListView />;
}
if (viewMode === 'recurring') {
return <RecurringBookingListView />;
}
if (isMobile) {
return <BookingMobile />;
}

View File

@@ -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';

View File

@@ -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() {
<Download className="size-4 text-primary" />
Exportar reporte
</button>
{isRecurringEnabled && (
<button
type="button"
onClick={() => setViewMode('recurring')}
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted"
>
<Repeat className="size-4 text-muted-foreground" />
Ver turnos fijos
</button>
)}
</div>
</section>
);

View File

@@ -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<string>(group.courtId);
const [dayOfWeek, setDayOfWeek] = useState<string>(group.dayOfWeek);
const [startTime, setStartTime] = useState<string>(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 (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Editar turno fijo</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Cliente</Label>
<Input
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="Nombre del cliente"
/>
</div>
<div className="space-y-1.5">
<Label>Teléfono</Label>
<Input
value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)}
placeholder="Teléfono"
/>
</div>
<div className="space-y-1.5">
<Label>Email</Label>
<Input
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
placeholder="Email"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Cancha</Label>
<Select value={courtId} onValueChange={setCourtId}>
<SelectTrigger>
<SelectValue placeholder="Cancha" />
</SelectTrigger>
<SelectContent>
{courts.map((court) => (
<SelectItem key={court.id} value={court.id}>
{court.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Día</Label>
<Select value={dayOfWeek} onValueChange={setDayOfWeek}>
<SelectTrigger>
<SelectValue placeholder="Día" />
</SelectTrigger>
<SelectContent>
{DAY_OPTIONS.map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label>Horario</Label>
<Select value={startTime} onValueChange={setStartTime}>
<SelectTrigger>
<SelectValue placeholder="Horario" />
</SelectTrigger>
<SelectContent>
{availableSlots.map((slot) => (
<SelectItem key={slot} value={slot}>
{slot} - {minutesToTime(timeToMinutes(slot) + slotDuration)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button type="button" onClick={handleSave} disabled={saving}>
{saving ? 'Guardando...' : 'Guardar cambios'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<string, string> = {
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<RecurringBookingGroup | null>(null);
const [cancellingGroupId, setCancellingGroupId] = useState<string | null>(null);
return (
<>
<section className="rounded-lg border bg-card/85 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-4">
<div className="flex items-center gap-3">
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full"
onClick={() => setViewMode('panel')}
>
<ArrowLeft className="size-5" />
</Button>
<div>
<h2 className="text-base font-semibold">Turnos fijos</h2>
<p className="text-sm text-muted-foreground">
{recurringGroups.length}{' '}
{recurringGroups.length === 1 ? 'turno fijo' : 'turnos fijos'}
</p>
</div>
</div>
</div>
{isRecurringGroupsLoading && (
<div className="px-4 py-10 text-sm text-muted-foreground">Cargando turnos fijos...</div>
)}
{!isRecurringGroupsLoading && recurringGroups.length === 0 && (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
<Repeat className="mx-auto mb-3 size-10 opacity-40" />
<p>No hay turnos fijos creados.</p>
</div>
)}
{!isRecurringGroupsLoading && recurringGroups.length > 0 && (
<div className="overflow-x-auto">
<table className="hidden w-full sm:table">
<thead>
<tr className="border-b text-left text-xs font-medium uppercase tracking-wider text-muted-foreground">
<th className="px-4 py-3 font-medium">Cliente</th>
<th className="px-4 py-3 font-medium">Cancha</th>
<th className="px-4 py-3 font-medium">Día</th>
<th className="px-4 py-3 font-medium">Horario</th>
<th className="px-4 py-3 font-medium">Próxima fecha</th>
<th className="px-4 py-3 font-medium">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{recurringGroups.map((group) => {
const nextDate = getNextBookingDate(group);
return (
<tr key={group.id} className="transition-colors hover:bg-muted/50">
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<User className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm">{group.customerName || '-'}</span>
</div>
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<MapPin className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm">
{group.bookings[0]?.courtName ?? '-'}
</span>
</div>
</td>
<td className="px-4 py-4 text-sm">
{DAY_LABELS[group.dayOfWeek] ?? group.dayOfWeek}
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<Clock className="size-4 shrink-0 text-muted-foreground" />
<span className="text-sm">
{group.startTime} - {group.endTime}
</span>
</div>
</td>
<td className="px-4 py-4 text-sm">{formatDate(nextDate)}</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setEditingGroup(group)}
className="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
>
<Pencil className="size-3.5" />
Editar
</button>
<button
type="button"
onClick={() => setCancellingGroupId(group.id)}
disabled={isCancellingRecurringGroup}
className="flex items-center gap-1.5 rounded-md border border-red-200 px-2.5 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50"
>
<Trash2 className="size-3.5" />
Cancelar
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="divide-y sm:hidden">
{recurringGroups.map((group) => {
const nextDate = getNextBookingDate(group);
return (
<div key={group.id} className="px-4 py-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="size-4 text-muted-foreground" />
<span className="text-sm font-semibold">{group.customerName || '-'}</span>
</div>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{DAY_LABELS[group.dayOfWeek]}
</span>
</div>
<p className="text-xs text-muted-foreground">
{group.bookings[0]?.courtName} · {group.startTime} - {group.endTime}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Próxima: {formatDate(nextDate)}
</p>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => setEditingGroup(group)}
className="flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors hover:bg-muted"
>
<Pencil className="size-3" />
Editar
</button>
<button
type="button"
onClick={() => setCancellingGroupId(group.id)}
disabled={isCancellingRecurringGroup}
className="flex items-center gap-1 rounded-md border border-red-200 px-2 py-1 text-xs text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50"
>
<Trash2 className="size-3" />
Cancelar
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</section>
<Dialog
open={!!cancellingGroupId}
onOpenChange={(open) => !open && setCancellingGroupId(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancelar turno fijo</DialogTitle>
<DialogDescription>
¿Estás seguro de cancelar este turno fijo? Se eliminarán todas las reservas futuras.
Las reservas pasadas no se verán afectadas.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCancellingGroupId(null)}>
Volver
</Button>
<Button
variant="destructive"
disabled={isCancellingRecurringGroup}
onClick={() => {
if (cancellingGroupId) {
cancelRecurringGroup(cancellingGroupId);
setCancellingGroupId(null);
}
}}
>
{isCancellingRecurringGroup ? 'Cancelando...' : 'Sí, cancelar turno fijo'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{editingGroup && (
<RecurringBookingEditDialog
group={editingGroup}
open={!!editingGroup}
onClose={() => setEditingGroup(null)}
/>
)}
</>
);
}

View File

@@ -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,
},
};

View File

@@ -17,5 +17,7 @@ export {
createAdmin,
createRecurring,
cancelRecurringGroup,
listRecurringGroups,
updateRecurringGroup,
updateStatus,
} from './resources/bookings';

View File

@@ -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<ListRecurringGroupsResponse>(
`/api/admin-bookings/complex/${complexId}/recurring`
);
return response.data;
}
export async function updateRecurringGroup(groupId: string, payload: UpdateRecurringGroupInput) {
const response = await http.patch<RecurringBookingGroup>(
`/api/admin-bookings/recurring/${groupId}`,
payload
);
return response.data;
}
export async function cancelPublic(complexSlug: string, payload: CancelPublicBookingInput) {
const response = await http.post<PublicBooking>(
`/api/public-bookings/complex/${complexSlug}/cancel`,