feat/fixed-bookings #18
@@ -3,7 +3,9 @@ import { cancelRecurringGroupHandler } from '@/modules/admin-booking/handlers/ca
|
|||||||
import { createAdminBookingHandler } from '@/modules/admin-booking/handlers/create-admin-booking.handler';
|
import { createAdminBookingHandler } from '@/modules/admin-booking/handlers/create-admin-booking.handler';
|
||||||
import { createAdminRecurringBookingHandler } from '@/modules/admin-booking/handlers/create-admin-recurring-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 { 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 { 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 type { AppEnv } from '@/types/hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +13,7 @@ import {
|
|||||||
createRecurringBookingSchema,
|
createRecurringBookingSchema,
|
||||||
listAdminBookingsQuerySchema,
|
listAdminBookingsQuerySchema,
|
||||||
updateAdminBookingStatusSchema,
|
updateAdminBookingStatusSchema,
|
||||||
|
updateRecurringGroupSchema,
|
||||||
} from '@repo/api-contract';
|
} from '@repo/api-contract';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -49,6 +52,19 @@ adminBookingRoutes.post(
|
|||||||
cancelRecurringGroupHandler
|
cancelRecurringGroupHandler
|
||||||
);
|
);
|
||||||
|
|
||||||
|
adminBookingRoutes.get(
|
||||||
|
'/complex/:complexId/recurring',
|
||||||
|
zValidator('param', complexIdParamsSchema),
|
||||||
|
listRecurringGroupsHandler
|
||||||
|
);
|
||||||
|
|
||||||
|
adminBookingRoutes.patch(
|
||||||
|
'/recurring/:groupId',
|
||||||
|
zValidator('param', groupIdParamsSchema),
|
||||||
|
zValidator('json', updateRecurringGroupSchema),
|
||||||
|
updateRecurringGroupHandler
|
||||||
|
);
|
||||||
|
|
||||||
adminBookingRoutes.patch(
|
adminBookingRoutes.patch(
|
||||||
'/:id/status',
|
'/:id/status',
|
||||||
zValidator('param', bookingIdParamsSchema),
|
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,
|
isFeatureEnabled,
|
||||||
parsePlanRules,
|
parsePlanRules,
|
||||||
} from '@/modules/plan/services/plan-rules.service';
|
} 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 { v7 as uuidv7 } from 'uuid';
|
||||||
import { AdminBookingServiceError } from './admin-booking.service';
|
import { AdminBookingServiceError } from './admin-booking.service';
|
||||||
|
|
||||||
@@ -15,6 +19,16 @@ const BOOKING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|||||||
const BOOKING_CODE_LENGTH = 6;
|
const BOOKING_CODE_LENGTH = 6;
|
||||||
const MAX_RECURRING_WEEKS = 52;
|
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 = [
|
const DAY_OF_WEEK_BY_INDEX = [
|
||||||
DayOfWeekEnum.SUNDAY,
|
DayOfWeekEnum.SUNDAY,
|
||||||
DayOfWeekEnum.MONDAY,
|
DayOfWeekEnum.MONDAY,
|
||||||
@@ -574,3 +588,306 @@ export async function cancelRecurringGroup(userId: string, groupId: string) {
|
|||||||
|
|
||||||
return { ok: true };
|
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 { 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -85,6 +91,11 @@ interface BookingContextValue {
|
|||||||
setViewMode: (mode: BookingViewMode) => void;
|
setViewMode: (mode: BookingViewMode) => void;
|
||||||
setVisibleTimeRange: (range: BookingTimeRange) => void;
|
setVisibleTimeRange: (range: BookingTimeRange) => void;
|
||||||
openCreateBooking: (draft?: Partial<BookingSlotDraft>) => void;
|
openCreateBooking: (draft?: Partial<BookingSlotDraft>) => void;
|
||||||
|
recurringGroups: RecurringBookingGroup[];
|
||||||
|
isRecurringGroupsLoading: boolean;
|
||||||
|
cancelRecurringGroup: (groupId: string) => void;
|
||||||
|
isCancellingRecurringGroup: boolean;
|
||||||
|
refreshRecurringGroups: () => void;
|
||||||
closeCreateBooking: () => void;
|
closeCreateBooking: () => void;
|
||||||
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
|
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
|
||||||
createRecurringBooking: (payload: CreateRecurringBookingPayload) => Promise<void>;
|
createRecurringBooking: (payload: CreateRecurringBookingPayload) => Promise<void>;
|
||||||
@@ -258,6 +269,20 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
queryFn: () => apiClient.courts.listByComplex(complex.id),
|
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({
|
const bookingsQuery = useQuery({
|
||||||
queryKey: ['admin-bookings', complex.id, selectedDate],
|
queryKey: ['admin-bookings', complex.id, selectedDate],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -480,6 +505,12 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
'No pudimos cargar el panel de reservas.'
|
'No pudimos cargar el panel de reservas.'
|
||||||
)
|
)
|
||||||
: null,
|
: 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,
|
isCreateBookingOpen,
|
||||||
createBookingError:
|
createBookingError:
|
||||||
createBookingMutation.isError || createRecurringBookingMutation.isError
|
createBookingMutation.isError || createRecurringBookingMutation.isError
|
||||||
@@ -545,6 +576,9 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
viewMode,
|
viewMode,
|
||||||
visibleTimeRange,
|
visibleTimeRange,
|
||||||
bookingToolsOpen,
|
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 { BookingTimeline } from './components/booking-timeline';
|
||||||
import { BookingToolbar } from './components/booking-toolbar';
|
import { BookingToolbar } from './components/booking-toolbar';
|
||||||
import { BookingToolsDialog } from './components/booking-tools-dialog';
|
import { BookingToolsDialog } from './components/booking-tools-dialog';
|
||||||
|
import { RecurringBookingListView } from './components/recurring-booking-list';
|
||||||
|
|
||||||
interface BookingProps {
|
interface BookingProps {
|
||||||
complex: ComplexWithRole;
|
complex: ComplexWithRole;
|
||||||
@@ -71,6 +72,10 @@ function BookingInner() {
|
|||||||
return <BookingListView />;
|
return <BookingListView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'recurring') {
|
||||||
|
return <RecurringBookingListView />;
|
||||||
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return <BookingMobile />;
|
return <BookingMobile />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AdminBooking, Court } from '@repo/api-contract';
|
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 BookingStatusFilter = 'all' | 'free' | 'reserved';
|
||||||
export type BookingSegmentStatus = '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 { useBooking } from '../booking-provider';
|
||||||
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
|
import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time';
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCar
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BookingQuickActions() {
|
export function BookingQuickActions() {
|
||||||
const { setSelectedDate, setViewMode, exportDayReport } = useBooking();
|
const { setSelectedDate, setViewMode, exportDayReport, isRecurringEnabled } = useBooking();
|
||||||
const todayIso = toIsoDateLocal(new Date());
|
const todayIso = toIsoDateLocal(new Date());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,6 +92,16 @@ export function BookingQuickActions() {
|
|||||||
<Download className="size-4 text-primary" />
|
<Download className="size-4 text-primary" />
|
||||||
Exportar reporte
|
Exportar reporte
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</section>
|
</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,
|
create: api.createAdmin,
|
||||||
createRecurring: api.createRecurring,
|
createRecurring: api.createRecurring,
|
||||||
cancelRecurringGroup: api.cancelRecurringGroup,
|
cancelRecurringGroup: api.cancelRecurringGroup,
|
||||||
|
listRecurringGroups: api.listRecurringGroups,
|
||||||
|
updateRecurringGroup: api.updateRecurringGroup,
|
||||||
updateStatus: api.updateStatus,
|
updateStatus: api.updateStatus,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,5 +17,7 @@ export {
|
|||||||
createAdmin,
|
createAdmin,
|
||||||
createRecurring,
|
createRecurring,
|
||||||
cancelRecurringGroup,
|
cancelRecurringGroup,
|
||||||
|
listRecurringGroups,
|
||||||
|
updateRecurringGroup,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
} from './resources/bookings';
|
} from './resources/bookings';
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import type {
|
|||||||
CancelPublicBookingInput,
|
CancelPublicBookingInput,
|
||||||
CreateAdminBookingInput,
|
CreateAdminBookingInput,
|
||||||
CreatePublicBookingInput,
|
CreatePublicBookingInput,
|
||||||
|
ListRecurringGroupsResponse,
|
||||||
PublicAvailabilityResponse,
|
PublicAvailabilityResponse,
|
||||||
PublicBooking,
|
PublicBooking,
|
||||||
PublicBookingConfirmation,
|
PublicBookingConfirmation,
|
||||||
RecurringBookingGroup,
|
RecurringBookingGroup,
|
||||||
UpdateAdminBookingStatusInput,
|
UpdateAdminBookingStatusInput,
|
||||||
|
UpdateRecurringGroupInput,
|
||||||
} from '@repo/api-contract';
|
} from '@repo/api-contract';
|
||||||
import type { CreateRecurringBookingInput } from '@repo/api-contract';
|
import type { CreateRecurringBookingInput } from '@repo/api-contract';
|
||||||
import { http } from '../http';
|
import { http } from '../http';
|
||||||
@@ -69,6 +71,21 @@ export async function cancelRecurringGroup(groupId: string) {
|
|||||||
return response.data;
|
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) {
|
export async function cancelPublic(complexSlug: string, payload: CancelPublicBookingInput) {
|
||||||
const response = await http.post<PublicBooking>(
|
const response = await http.post<PublicBooking>(
|
||||||
`/api/public-bookings/complex/${complexSlug}/cancel`,
|
`/api/public-bookings/complex/${complexSlug}/cancel`,
|
||||||
|
|||||||
@@ -90,6 +90,31 @@ export const recurringBookingGroupSchema = z.object({
|
|||||||
updatedAt: z.string().datetime(),
|
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<typeof bookingStatusSchema>;
|
export type BookingStatus = z.infer<typeof bookingStatusSchema>;
|
||||||
export type RecurringGroupStatus = z.infer<typeof recurringGroupStatusSchema>;
|
export type RecurringGroupStatus = z.infer<typeof recurringGroupStatusSchema>;
|
||||||
export type AdminBookingSport = z.infer<typeof adminBookingSportSchema>;
|
export type AdminBookingSport = z.infer<typeof adminBookingSportSchema>;
|
||||||
@@ -100,3 +125,5 @@ export type CreateAdminBookingInput = z.infer<typeof createAdminBookingSchema>;
|
|||||||
export type CreateRecurringBookingInput = z.infer<typeof createRecurringBookingSchema>;
|
export type CreateRecurringBookingInput = z.infer<typeof createRecurringBookingSchema>;
|
||||||
export type RecurringBookingGroup = z.infer<typeof recurringBookingGroupSchema>;
|
export type RecurringBookingGroup = z.infer<typeof recurringBookingGroupSchema>;
|
||||||
export type UpdateAdminBookingStatusInput = z.infer<typeof updateAdminBookingStatusSchema>;
|
export type UpdateAdminBookingStatusInput = z.infer<typeof updateAdminBookingStatusSchema>;
|
||||||
|
export type UpdateRecurringGroupInput = z.infer<typeof updateRecurringGroupSchema>;
|
||||||
|
export type ListRecurringGroupsResponse = z.infer<typeof listRecurringGroupsResponseSchema>;
|
||||||
|
|||||||
@@ -79,9 +79,11 @@ export {
|
|||||||
createRecurringBookingSchema,
|
createRecurringBookingSchema,
|
||||||
listAdminBookingsQuerySchema,
|
listAdminBookingsQuerySchema,
|
||||||
listAdminBookingsResponseSchema,
|
listAdminBookingsResponseSchema,
|
||||||
|
listRecurringGroupsResponseSchema,
|
||||||
recurringBookingGroupSchema,
|
recurringBookingGroupSchema,
|
||||||
recurringGroupStatusSchema,
|
recurringGroupStatusSchema,
|
||||||
updateAdminBookingStatusSchema,
|
updateAdminBookingStatusSchema,
|
||||||
|
updateRecurringGroupSchema,
|
||||||
} from './admin-booking';
|
} from './admin-booking';
|
||||||
export type {
|
export type {
|
||||||
AdminBooking,
|
AdminBooking,
|
||||||
@@ -91,9 +93,11 @@ export type {
|
|||||||
CreateRecurringBookingInput,
|
CreateRecurringBookingInput,
|
||||||
ListAdminBookingsQuery,
|
ListAdminBookingsQuery,
|
||||||
ListAdminBookingsResponse,
|
ListAdminBookingsResponse,
|
||||||
|
ListRecurringGroupsResponse,
|
||||||
RecurringBookingGroup,
|
RecurringBookingGroup,
|
||||||
RecurringGroupStatus,
|
RecurringGroupStatus,
|
||||||
UpdateAdminBookingStatusInput,
|
UpdateAdminBookingStatusInput,
|
||||||
|
UpdateRecurringGroupInput,
|
||||||
} from './admin-booking';
|
} from './admin-booking';
|
||||||
export {
|
export {
|
||||||
createPublicBookingSchema,
|
createPublicBookingSchema,
|
||||||
|
|||||||
Reference in New Issue
Block a user