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:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,5 +17,7 @@ export {
|
||||
createAdmin,
|
||||
createRecurring,
|
||||
cancelRecurringGroup,
|
||||
listRecurringGroups,
|
||||
updateRecurringGroup,
|
||||
updateStatus,
|
||||
} from './resources/bookings';
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user