Merge pull request 'feat: add reschedule functionality for admin bookings' (#19) from feat/reschedule into development
Reviewed-on: #19
This commit is contained in:
@@ -125,18 +125,21 @@ model CourtBooking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model CourtBookingLog {
|
model CourtBookingLog {
|
||||||
id String @id @db.Uuid
|
id String @id @db.Uuid
|
||||||
bookingCode String @map("booking_code") @db.VarChar(8)
|
bookingCode String @map("booking_code") @db.VarChar(8)
|
||||||
courtId String @map("court_id") @db.Uuid
|
courtId String @map("court_id") @db.Uuid
|
||||||
bookingDate DateTime @map("booking_date") @db.Date
|
bookingDate DateTime @map("booking_date") @db.Date
|
||||||
startTime String @map("start_time") @db.VarChar(5)
|
startTime String @map("start_time") @db.VarChar(5)
|
||||||
endTime String @map("end_time") @db.VarChar(5)
|
endTime String @map("end_time") @db.VarChar(5)
|
||||||
previousStatus CourtBookingStatus @map("previous_status")
|
previousStatus CourtBookingStatus @map("previous_status")
|
||||||
newStatus CourtBookingStatus @map("new_status")
|
newStatus CourtBookingStatus @map("new_status")
|
||||||
customerName String @map("customer_name") @db.VarChar(120)
|
previousCourtId String? @map("previous_court_id") @db.Uuid
|
||||||
customerPhone String @map("customer_phone") @db.VarChar(30)
|
previousStartTime String? @map("previous_start_time") @db.VarChar(5)
|
||||||
customerEmail String @map("customer_email") @db.VarChar(254)
|
previousEndTime String? @map("previous_end_time") @db.VarChar(5)
|
||||||
changedAt DateTime @default(now()) @map("changed_at")
|
customerName String @map("customer_name") @db.VarChar(120)
|
||||||
|
customerPhone String @map("customer_phone") @db.VarChar(30)
|
||||||
|
customerEmail String @map("customer_email") @db.VarChar(254)
|
||||||
|
changedAt DateTime @default(now()) @map("changed_at")
|
||||||
|
|
||||||
@@map("court_booking_logs")
|
@@map("court_booking_logs")
|
||||||
@@index([courtId, newStatus])
|
@@index([courtId, newStatus])
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "court_booking_logs" ADD COLUMN "previous_court_id" UUID;
|
||||||
|
ALTER TABLE "court_booking_logs" ADD COLUMN "previous_start_time" VARCHAR(5);
|
||||||
|
ALTER TABLE "court_booking_logs" ADD COLUMN "previous_end_time" VARCHAR(5);
|
||||||
@@ -300,6 +300,172 @@ export function bookingCancelledHtml(data: BookingEmailData): string {
|
|||||||
return wrapLayout(content);
|
return wrapLayout(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BookingRescheduledEmailData = BookingEmailData & {
|
||||||
|
previousCourtName: string;
|
||||||
|
previousStartTime: string;
|
||||||
|
previousEndTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function bookingRescheduledHtml(data: BookingRescheduledEmailData): string {
|
||||||
|
const friendlyDate = formatFriendlyDate(data.date);
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 20px 16px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="display:inline-block;background-color:#f4f4f5;border-radius:999px;padding:4px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:11px;font-weight:700;letter-spacing:0.14em;color:#71717a;text-transform:uppercase;line-height:1.25rem;">
|
||||||
|
Reserva reprogramada
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<h1 style="margin:12px 0 0;font-size:28px;font-weight:700;color:#111827;letter-spacing:-0.025em;">
|
||||||
|
${data.complexName}
|
||||||
|
</h1>
|
||||||
|
</td>
|
||||||
|
<td valign="top" align="right" style="white-space:nowrap;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td valign="middle" style="padding-right:8px;">
|
||||||
|
<img src="${APP_BASE_URL}/playzer-favicon-512-transparent.png" alt="Playzer" width="32" height="32" style="display:block;" />
|
||||||
|
</td>
|
||||||
|
<td valign="middle">
|
||||||
|
<span style="font-size:18px;font-weight:700;color:#475569;letter-spacing:-0.025em;">Playzer</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 20px 16px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0fdf4;border-radius:24px;border:1px solid rgba(5,150,105,0.3);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 24px;">
|
||||||
|
<p style="margin:0;font-size:13px;font-weight:600;color:#15803d;">
|
||||||
|
Nuevo turno
|
||||||
|
</p>
|
||||||
|
<p style="margin:12px 0 0;font-size:28px;font-weight:900;color:#111827;line-height:1.1;letter-spacing:-0.025em;">
|
||||||
|
${friendlyDate}
|
||||||
|
</p>
|
||||||
|
<p style="margin:8px 0 0;font-size:24px;font-weight:900;color:#059669;line-height:1;letter-spacing:-0.025em;">
|
||||||
|
${data.startTime} — ${data.endTime}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 20px 16px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
|
||||||
|
<tr>
|
||||||
|
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
|
||||||
|
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
|
||||||
|
Cancha anterior
|
||||||
|
</p>
|
||||||
|
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
|
||||||
|
${data.previousCourtName} — ${data.sportName}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td width="50%" style="padding:16px;background-color:#ffffff;">
|
||||||
|
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
|
||||||
|
Cancha nueva
|
||||||
|
</p>
|
||||||
|
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
|
||||||
|
${data.courtName} — ${data.sportName}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 20px 16px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
|
||||||
|
<tr>
|
||||||
|
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
|
||||||
|
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
|
||||||
|
Horario anterior
|
||||||
|
</p>
|
||||||
|
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
|
||||||
|
${data.previousStartTime} — ${data.previousEndTime}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td width="50%" style="padding:16px;background-color:#ffffff;">
|
||||||
|
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
|
||||||
|
Horario nuevo
|
||||||
|
</p>
|
||||||
|
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
|
||||||
|
${data.startTime} — ${data.endTime}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 20px 16px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
|
||||||
|
<tr>
|
||||||
|
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
|
||||||
|
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
|
||||||
|
Cliente
|
||||||
|
</p>
|
||||||
|
<p style="margin:8px 0 0;font-size:14px;font-weight:600;color:#111827;">
|
||||||
|
${data.customerName}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td width="50%" style="padding:16px;background-color:#ffffff;">
|
||||||
|
<p style="margin:0;font-size:13px;font-weight:500;color:#6b7280;">
|
||||||
|
Código de reserva
|
||||||
|
</p>
|
||||||
|
<p style="margin:8px 0 0;font-family:monospace;font-size:16px;font-weight:700;letter-spacing:0.18em;color:#111827;">
|
||||||
|
${data.bookingCode}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
${
|
||||||
|
data.complexSlug
|
||||||
|
? `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 20px 24px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="background-color:#059669;border-radius:12px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:14px 32px;font-size:15px;font-weight:600;">
|
||||||
|
<a href="${APP_BASE_URL}/${data.complexSlug}/booking/confirmed/${data.bookingCode}" style="color:#ffffff;text-decoration:none;display:inline-block;">
|
||||||
|
Ver reserva
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>`
|
||||||
|
: ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return wrapLayout(content);
|
||||||
|
}
|
||||||
|
|
||||||
export function bookingNoShowHtml(data: BookingEmailData): string {
|
export function bookingNoShowHtml(data: BookingEmailData): string {
|
||||||
const friendlyDate = formatFriendlyDate(data.date);
|
const friendlyDate = formatFriendlyDate(data.date);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1924,6 +1924,9 @@ export const CourtBookingLogScalarFieldEnum = {
|
|||||||
endTime: 'endTime',
|
endTime: 'endTime',
|
||||||
previousStatus: 'previousStatus',
|
previousStatus: 'previousStatus',
|
||||||
newStatus: 'newStatus',
|
newStatus: 'newStatus',
|
||||||
|
previousCourtId: 'previousCourtId',
|
||||||
|
previousStartTime: 'previousStartTime',
|
||||||
|
previousEndTime: 'previousEndTime',
|
||||||
customerName: 'customerName',
|
customerName: 'customerName',
|
||||||
customerPhone: 'customerPhone',
|
customerPhone: 'customerPhone',
|
||||||
customerEmail: 'customerEmail',
|
customerEmail: 'customerEmail',
|
||||||
|
|||||||
@@ -293,6 +293,9 @@ export const CourtBookingLogScalarFieldEnum = {
|
|||||||
endTime: 'endTime',
|
endTime: 'endTime',
|
||||||
previousStatus: 'previousStatus',
|
previousStatus: 'previousStatus',
|
||||||
newStatus: 'newStatus',
|
newStatus: 'newStatus',
|
||||||
|
previousCourtId: 'previousCourtId',
|
||||||
|
previousStartTime: 'previousStartTime',
|
||||||
|
previousEndTime: 'previousEndTime',
|
||||||
customerName: 'customerName',
|
customerName: 'customerName',
|
||||||
customerPhone: 'customerPhone',
|
customerPhone: 'customerPhone',
|
||||||
customerEmail: 'customerEmail',
|
customerEmail: 'customerEmail',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createAdminBookingHandler } from '@/modules/admin-booking/handlers/crea
|
|||||||
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 { listRecurringGroupsHandler } from '@/modules/admin-booking/handlers/list-recurring-groups.handler';
|
||||||
|
import { rescheduleAdminBookingHandler } from '@/modules/admin-booking/handlers/reschedule-admin-booking.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 { updateRecurringGroupHandler } from '@/modules/admin-booking/handlers/update-recurring-group.handler';
|
||||||
import type { AppEnv } from '@/types/hono';
|
import type { AppEnv } from '@/types/hono';
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
createAdminBookingSchema,
|
createAdminBookingSchema,
|
||||||
createRecurringBookingSchema,
|
createRecurringBookingSchema,
|
||||||
listAdminBookingsQuerySchema,
|
listAdminBookingsQuerySchema,
|
||||||
|
rescheduleAdminBookingSchema,
|
||||||
updateAdminBookingStatusSchema,
|
updateAdminBookingStatusSchema,
|
||||||
updateRecurringGroupSchema,
|
updateRecurringGroupSchema,
|
||||||
} from '@repo/api-contract';
|
} from '@repo/api-contract';
|
||||||
@@ -71,3 +73,10 @@ adminBookingRoutes.patch(
|
|||||||
zValidator('json', updateAdminBookingStatusSchema),
|
zValidator('json', updateAdminBookingStatusSchema),
|
||||||
updateAdminBookingStatusHandler
|
updateAdminBookingStatusHandler
|
||||||
);
|
);
|
||||||
|
|
||||||
|
adminBookingRoutes.patch(
|
||||||
|
'/:id/reschedule',
|
||||||
|
zValidator('param', bookingIdParamsSchema),
|
||||||
|
zValidator('json', rescheduleAdminBookingSchema),
|
||||||
|
rescheduleAdminBookingHandler
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { db } from '@/lib/prisma';
|
||||||
|
import { sseManager } from '@/lib/sse';
|
||||||
|
import {
|
||||||
|
AdminBookingServiceError,
|
||||||
|
rescheduleAdminBooking,
|
||||||
|
} from '@/modules/admin-booking/services/admin-booking.service';
|
||||||
|
import { sendBookingRescheduled } from '@/services/booking-email.service';
|
||||||
|
import type { AppContext } from '@/types/hono';
|
||||||
|
import type { RescheduleAdminBookingInput } from '@repo/api-contract';
|
||||||
|
|
||||||
|
type BookingIdParams = { id: string };
|
||||||
|
|
||||||
|
export async function rescheduleAdminBookingHandler(c: AppContext) {
|
||||||
|
const { id } = c.req.valid('param' as never) as BookingIdParams;
|
||||||
|
const payload = c.req.valid('json' as never) as RescheduleAdminBookingInput;
|
||||||
|
const user = c.get('user');
|
||||||
|
|
||||||
|
const previous = await db.courtBooking.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
court: { select: { name: true } },
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
customerEmail: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const booking = await rescheduleAdminBooking(user.id, id, payload);
|
||||||
|
|
||||||
|
if (previous) {
|
||||||
|
void sendBookingRescheduled({
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
complexName: booking.complexName,
|
||||||
|
date: booking.date,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
courtName: booking.courtName,
|
||||||
|
sportName: booking.sport.name,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerEmail: previous.customerEmail,
|
||||||
|
previousCourtName: previous.court.name,
|
||||||
|
previousStartTime: previous.startTime,
|
||||||
|
previousEndTime: previous.endTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sseManager.emit(
|
||||||
|
`complex:${booking.complexId}`,
|
||||||
|
JSON.stringify({ type: 'reschedule', booking })
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(booking);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AdminBookingServiceError) {
|
||||||
|
return c.json({ message: error.message }, error.status);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
AdminBooking,
|
AdminBooking,
|
||||||
CreateAdminBookingInput,
|
CreateAdminBookingInput,
|
||||||
ListAdminBookingsQuery,
|
ListAdminBookingsQuery,
|
||||||
|
RescheduleAdminBookingInput,
|
||||||
UpdateAdminBookingStatusInput,
|
UpdateAdminBookingStatusInput,
|
||||||
} from '@repo/api-contract';
|
} from '@repo/api-contract';
|
||||||
import { v7 as uuidv7 } from 'uuid';
|
import { v7 as uuidv7 } from 'uuid';
|
||||||
@@ -598,3 +599,196 @@ export async function updateAdminBookingStatus(
|
|||||||
|
|
||||||
return mapBookingResponse({ ...booking, status: input.status });
|
return mapBookingResponse({ ...booking, status: input.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function rescheduleAdminBooking(
|
||||||
|
userId: string,
|
||||||
|
bookingId: string,
|
||||||
|
input: RescheduleAdminBookingInput
|
||||||
|
): Promise<AdminBooking> {
|
||||||
|
const booking = await db.courtBooking.findFirst({
|
||||||
|
where: {
|
||||||
|
id: bookingId,
|
||||||
|
court: {
|
||||||
|
complex: {
|
||||||
|
users: {
|
||||||
|
some: { userId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
complexId: true,
|
||||||
|
slotDurationMinutes: true,
|
||||||
|
sport: {
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
},
|
||||||
|
complex: {
|
||||||
|
select: { id: true, complexName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new AdminBookingServiceError('Reserva no encontrada.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status !== 'CONFIRMED') {
|
||||||
|
throw new AdminBookingServiceError(
|
||||||
|
'Solo se pueden reprogramar reservas en estado confirmada.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCourtId = input.courtId ?? booking.courtId;
|
||||||
|
const targetStartTime = input.startTime ?? booking.startTime;
|
||||||
|
|
||||||
|
if (targetCourtId === booking.courtId && targetStartTime === booking.startTime) {
|
||||||
|
throw new AdminBookingServiceError(
|
||||||
|
'Debe proporcionar al menos una cancha o un horario diferente.',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayOfWeek = getDayOfWeek(booking.bookingDate);
|
||||||
|
|
||||||
|
const targetCourt = await db.court.findFirst({
|
||||||
|
where: {
|
||||||
|
id: targetCourtId,
|
||||||
|
complexId: booking.court.complexId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
availabilities: {
|
||||||
|
where: { dayOfWeek },
|
||||||
|
orderBy: { startTime: 'asc' },
|
||||||
|
},
|
||||||
|
priceRules: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
|
sport: {
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetCourt) {
|
||||||
|
throw new AdminBookingServiceError('La cancha seleccionada no existe en el complejo.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetCourt.sport.id !== booking.court.sport.id) {
|
||||||
|
throw new AdminBookingServiceError(
|
||||||
|
'La cancha seleccionada no es del mismo deporte que la reserva original.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetCourt.isUnderMaintenance) {
|
||||||
|
throw new AdminBookingServiceError(
|
||||||
|
'La cancha seleccionada se encuentra en mantenimiento.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validSlots = buildSlots(targetCourt.availabilities, targetCourt.slotDurationMinutes);
|
||||||
|
const selectedEndMinutes = toMinutes(targetStartTime) + targetCourt.slotDurationMinutes;
|
||||||
|
const selectedSlot = {
|
||||||
|
startTime: targetStartTime,
|
||||||
|
endTime: minutesToTime(selectedEndMinutes),
|
||||||
|
};
|
||||||
|
|
||||||
|
const slotExists = validSlots.some(
|
||||||
|
(slot) => slot.startTime === selectedSlot.startTime && slot.endTime === selectedSlot.endTime
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!slotExists) {
|
||||||
|
throw new AdminBookingServiceError(
|
||||||
|
'El horario seleccionado no está disponible para esa cancha.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSlotInPast(booking.bookingDate, targetStartTime, targetCourt.slotDurationMinutes)) {
|
||||||
|
throw new AdminBookingServiceError('No se pueden reprogramar reservas en el pasado.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlappingBooking = await db.courtBooking.findFirst({
|
||||||
|
where: {
|
||||||
|
id: { not: bookingId },
|
||||||
|
courtId: targetCourtId,
|
||||||
|
bookingDate: booking.bookingDate,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
startTime: { lt: selectedSlot.endTime },
|
||||||
|
endTime: { gt: selectedSlot.startTime },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (overlappingBooking) {
|
||||||
|
throw new AdminBookingServiceError(
|
||||||
|
'El horario seleccionado ya fue reservado por otra reserva.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPrice = resolvePrice(
|
||||||
|
targetCourt,
|
||||||
|
dayOfWeek,
|
||||||
|
selectedSlot.startTime,
|
||||||
|
selectedSlot.endTime
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.courtBookingLog.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv7(),
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
courtId: booking.court.id,
|
||||||
|
bookingDate: booking.bookingDate,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerPhone: booking.customerPhone,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
previousStatus: booking.status,
|
||||||
|
newStatus: booking.status,
|
||||||
|
previousCourtId: booking.court.id,
|
||||||
|
previousStartTime: booking.startTime,
|
||||||
|
previousEndTime: booking.endTime,
|
||||||
|
changedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.courtBooking.update({
|
||||||
|
where: { id: booking.id },
|
||||||
|
data: {
|
||||||
|
courtId: targetCourtId,
|
||||||
|
startTime: selectedSlot.startTime,
|
||||||
|
endTime: selectedSlot.endTime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedBooking = {
|
||||||
|
...booking,
|
||||||
|
startTime: selectedSlot.startTime,
|
||||||
|
endTime: selectedSlot.endTime,
|
||||||
|
price: newPrice,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (targetCourtId !== booking.courtId) {
|
||||||
|
updatedBooking.court = {
|
||||||
|
id: targetCourt.id,
|
||||||
|
name: targetCourt.name,
|
||||||
|
slotDurationMinutes: targetCourt.slotDurationMinutes,
|
||||||
|
sport: targetCourt.sport,
|
||||||
|
complex: booking.court.complex,
|
||||||
|
} as typeof booking.court;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapBookingResponse(updatedBooking);
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ function mapCourtResponse(court: CourtWithRelations) {
|
|||||||
id: court.id,
|
id: court.id,
|
||||||
complexId: court.complexId,
|
complexId: court.complexId,
|
||||||
name: court.name,
|
name: court.name,
|
||||||
|
isUnderMaintenance: court.isUnderMaintenance,
|
||||||
sportId: court.sportId,
|
sportId: court.sportId,
|
||||||
sport: {
|
sport: {
|
||||||
id: court.sport.id,
|
id: court.sport.id,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
bookingCancelledHtml,
|
bookingCancelledHtml,
|
||||||
bookingConfirmationHtml,
|
bookingConfirmationHtml,
|
||||||
bookingNoShowHtml,
|
bookingNoShowHtml,
|
||||||
|
bookingRescheduledHtml,
|
||||||
} from '@/emails/booking-confirmation';
|
} from '@/emails/booking-confirmation';
|
||||||
import { sendMail } from '@/lib/mailer';
|
import { sendMail } from '@/lib/mailer';
|
||||||
|
|
||||||
@@ -86,3 +87,35 @@ export async function sendBookingNoShow(data: BookingEmailInput) {
|
|||||||
text: `Reserva no concretada en ${data.complexName}. Codigo: ${data.bookingCode}. Fecha: ${data.date}. Horario: ${data.startTime} - ${data.endTime}.`,
|
text: `Reserva no concretada en ${data.complexName}. Codigo: ${data.bookingCode}. Fecha: ${data.date}. Horario: ${data.startTime} - ${data.endTime}.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BookingRescheduledEmailInput = BookingEmailInput & {
|
||||||
|
previousCourtName: string;
|
||||||
|
previousStartTime: string;
|
||||||
|
previousEndTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function sendBookingRescheduled(data: BookingRescheduledEmailInput) {
|
||||||
|
if (!data.customerEmail) return;
|
||||||
|
|
||||||
|
const html = bookingRescheduledHtml({
|
||||||
|
bookingCode: data.bookingCode,
|
||||||
|
complexSlug: data.complexSlug,
|
||||||
|
complexName: data.complexName,
|
||||||
|
date: data.date,
|
||||||
|
startTime: data.startTime,
|
||||||
|
endTime: data.endTime,
|
||||||
|
courtName: data.courtName,
|
||||||
|
sportName: data.sportName,
|
||||||
|
customerName: data.customerName,
|
||||||
|
previousCourtName: data.previousCourtName,
|
||||||
|
previousStartTime: data.previousStartTime,
|
||||||
|
previousEndTime: data.previousEndTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendMail({
|
||||||
|
to: data.customerEmail,
|
||||||
|
subject: `Reserva reprogramada en ${data.complexName} | Playzer`,
|
||||||
|
html,
|
||||||
|
text: `Reserva reprogramada en ${data.complexName}. Codigo: ${data.bookingCode}. Nueva cancha: ${data.courtName}. Nuevo horario: ${data.startTime} - ${data.endTime}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { beforeEach, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
|
const rescheduleAdminBookingMock = mock(
|
||||||
|
async (_userId: string, _id: string, _input: unknown) => ({})
|
||||||
|
);
|
||||||
|
|
||||||
|
class MockAdminBookingServiceError extends Error {
|
||||||
|
status: 400 | 403 | 404 | 409;
|
||||||
|
|
||||||
|
constructor(message: string, status: 400 | 403 | 404 | 409 = 400) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AdminBookingServiceError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.module('@/modules/admin-booking/services/admin-booking.service', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
AdminBookingServiceError: MockAdminBookingServiceError,
|
||||||
|
rescheduleAdminBooking: rescheduleAdminBookingMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { rescheduleAdminBookingHandler } = await import(
|
||||||
|
'@/modules/admin-booking/handlers/reschedule-admin-booking.handler'
|
||||||
|
);
|
||||||
|
|
||||||
|
type HandlerContext = {
|
||||||
|
get: (key: 'user' | 'requestId') => { id: string } | undefined;
|
||||||
|
req: {
|
||||||
|
valid: (type: string) => Record<string, unknown>;
|
||||||
|
};
|
||||||
|
json: ReturnType<typeof mock>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createContext(input: { userId: string; body: unknown }) {
|
||||||
|
const json = mock((payload: unknown, init?: unknown) => ({
|
||||||
|
payload,
|
||||||
|
init,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const c = {
|
||||||
|
get: (key: 'user' | 'requestId') => {
|
||||||
|
if (key === 'user') return { id: input.userId };
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
req: {
|
||||||
|
valid: (type: string) => {
|
||||||
|
if (type === 'param') return { id: 'booking-1' };
|
||||||
|
if (type === 'json') return input.body as Record<string, unknown>;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
json,
|
||||||
|
} as unknown as HandlerContext;
|
||||||
|
|
||||||
|
return { c, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rescheduleAdminBookingMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 200 with the updated booking', async () => {
|
||||||
|
const updatedBooking = {
|
||||||
|
id: 'booking-1',
|
||||||
|
bookingCode: 'ABC123',
|
||||||
|
complexId: 'complex-1',
|
||||||
|
complexName: 'Complejo Test',
|
||||||
|
courtId: 'court-2',
|
||||||
|
courtName: 'Cancha 2',
|
||||||
|
sport: { id: 'sport-1', name: 'Tenis', slug: 'tenis' },
|
||||||
|
date: '2026-07-15',
|
||||||
|
startTime: '14:00',
|
||||||
|
endTime: '15:00',
|
||||||
|
customerName: 'Juan Perez',
|
||||||
|
customerPhone: '1144444444',
|
||||||
|
customerEmail: 'juan@example.com',
|
||||||
|
price: 15000,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
recurringGroupId: null,
|
||||||
|
createdAt: '2026-07-14T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-07-14T00:00:00.000Z',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
rescheduleAdminBookingMock.mockResolvedValue(updatedBooking);
|
||||||
|
|
||||||
|
const { c, json } = createContext({
|
||||||
|
userId: 'user-1',
|
||||||
|
body: { courtId: 'court-2', startTime: '14:00' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await rescheduleAdminBookingHandler(c as never);
|
||||||
|
|
||||||
|
expect(rescheduleAdminBookingMock).toHaveBeenCalledWith('user-1', 'booking-1', {
|
||||||
|
courtId: 'court-2',
|
||||||
|
startTime: '14:00',
|
||||||
|
});
|
||||||
|
expect(json.mock.calls[0]?.[0]).toEqual(updatedBooking);
|
||||||
|
expect(json.mock.calls[0]?.[1]).toBeUndefined();
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps AdminBookingServiceError to an error response', async () => {
|
||||||
|
rescheduleAdminBookingMock.mockRejectedValue(
|
||||||
|
new MockAdminBookingServiceError(
|
||||||
|
'Solo se pueden reprogramar reservas en estado confirmada.',
|
||||||
|
409
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { c, json } = createContext({
|
||||||
|
userId: 'user-1',
|
||||||
|
body: { startTime: '14:00' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await rescheduleAdminBookingHandler(c as never);
|
||||||
|
|
||||||
|
expect(rescheduleAdminBookingMock).toHaveBeenCalledWith('user-1', 'booking-1', {
|
||||||
|
startTime: '14:00',
|
||||||
|
});
|
||||||
|
expect(json.mock.calls[0]?.[0]).toEqual({
|
||||||
|
message: 'Solo se pueden reprogramar reservas en estado confirmada.',
|
||||||
|
});
|
||||||
|
expect(json.mock.calls[0]?.[1]).toBe(409);
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
});
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { beforeEach, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { prismaMock, transactionMock, uuidV7Mock } from '../support/prisma.mock';
|
||||||
|
|
||||||
|
const { AdminBookingServiceError, rescheduleAdminBooking } = await import(
|
||||||
|
'@/modules/admin-booking/services/admin-booking.service'
|
||||||
|
);
|
||||||
|
|
||||||
|
function makeBooking(overrides?: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
id: 'booking-1',
|
||||||
|
bookingCode: 'ABC123',
|
||||||
|
courtId: 'court-1',
|
||||||
|
bookingDate: new Date('2026-07-15T00:00:00.000Z'),
|
||||||
|
startTime: '10:00',
|
||||||
|
endTime: '11:00',
|
||||||
|
customerName: 'Juan Perez',
|
||||||
|
customerPhone: '1144444444',
|
||||||
|
customerEmail: 'juan@example.com',
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
recurringGroupId: null,
|
||||||
|
createdAt: new Date('2026-07-14T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-07-14T00:00:00.000Z'),
|
||||||
|
court: {
|
||||||
|
id: 'court-1',
|
||||||
|
name: 'Cancha 1',
|
||||||
|
complexId: 'complex-1',
|
||||||
|
slotDurationMinutes: 60,
|
||||||
|
sport: { id: 'sport-1', name: 'Tenis', slug: 'tenis' },
|
||||||
|
complex: { id: 'complex-1', complexName: 'Complejo Test' },
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// July 15, 2026 is a Wednesday
|
||||||
|
const WEDNESDAY = 'WEDNESDAY';
|
||||||
|
|
||||||
|
function makeCourt(overrides?: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
id: 'court-2',
|
||||||
|
name: 'Cancha 2',
|
||||||
|
complexId: 'complex-1',
|
||||||
|
slotDurationMinutes: 60,
|
||||||
|
basePrice: 15000,
|
||||||
|
isUnderMaintenance: false,
|
||||||
|
availabilities: [{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '18:00' }],
|
||||||
|
priceRules: [],
|
||||||
|
sport: { id: 'sport-1', name: 'Tenis', slug: 'tenis' },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prismaMock._reset();
|
||||||
|
transactionMock.mockClear();
|
||||||
|
uuidV7Mock.mockReset();
|
||||||
|
uuidV7Mock.mockReturnValue('00000000-0000-4000-8000-000000000001');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reschedules a booking changing both court and time', async () => {
|
||||||
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
||||||
|
prismaMock.court.findFirst.mockResolvedValue(makeCourt() as never);
|
||||||
|
|
||||||
|
prismaMock.courtBooking.findFirst
|
||||||
|
.mockResolvedValueOnce(makeBooking() as never) // initial fetch
|
||||||
|
.mockResolvedValueOnce(null as never); // overlap check -> no overlap
|
||||||
|
|
||||||
|
transactionMock.mockImplementation(async <T>(fn: (tx: typeof prismaMock) => Promise<T>) =>
|
||||||
|
fn(prismaMock)
|
||||||
|
);
|
||||||
|
|
||||||
|
prismaMock.courtBookingLog.create.mockResolvedValue({} as never);
|
||||||
|
prismaMock.courtBooking.update.mockResolvedValue(
|
||||||
|
makeBooking({
|
||||||
|
courtId: 'court-2',
|
||||||
|
startTime: '14:00',
|
||||||
|
endTime: '15:00',
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await rescheduleAdminBooking('user-1', 'booking-1', {
|
||||||
|
courtId: 'court-2',
|
||||||
|
startTime: '14:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.courtId).toBe('court-2');
|
||||||
|
expect(result.startTime).toBe('14:00');
|
||||||
|
expect(result.courtName).toBe('Cancha 2');
|
||||||
|
expect(prismaMock.courtBookingLog.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prismaMock.courtBooking.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'booking-1' },
|
||||||
|
data: {
|
||||||
|
courtId: 'court-2',
|
||||||
|
startTime: '14:00',
|
||||||
|
endTime: '15:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const logArgs = prismaMock.courtBookingLog.create.mock.calls[0]?.[0] as {
|
||||||
|
data: { previousCourtId: string; previousStartTime: string; previousEndTime: string };
|
||||||
|
};
|
||||||
|
expect(logArgs.data.previousCourtId).toBe('court-1');
|
||||||
|
expect(logArgs.data.previousStartTime).toBe('10:00');
|
||||||
|
expect(logArgs.data.previousEndTime).toBe('11:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reschedules a booking changing only the time', async () => {
|
||||||
|
prismaMock.courtBooking.findFirst
|
||||||
|
.mockResolvedValueOnce(makeBooking() as never) // initial fetch
|
||||||
|
.mockResolvedValueOnce(null as never); // overlap check
|
||||||
|
|
||||||
|
// Same court, different time
|
||||||
|
prismaMock.court.findFirst.mockResolvedValue(
|
||||||
|
makeCourt({
|
||||||
|
id: 'court-1',
|
||||||
|
name: 'Cancha 1',
|
||||||
|
availabilities: [
|
||||||
|
{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '18:00' },
|
||||||
|
],
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
transactionMock.mockImplementation(async <T>(fn: (tx: typeof prismaMock) => Promise<T>) =>
|
||||||
|
fn(prismaMock)
|
||||||
|
);
|
||||||
|
|
||||||
|
prismaMock.courtBookingLog.create.mockResolvedValue({} as never);
|
||||||
|
prismaMock.courtBooking.update.mockResolvedValue(
|
||||||
|
makeBooking({
|
||||||
|
startTime: '15:00',
|
||||||
|
endTime: '16:00',
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await rescheduleAdminBooking('user-1', 'booking-1', {
|
||||||
|
startTime: '15:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.startTime).toBe('15:00');
|
||||||
|
expect(result.courtId).toBe('court-1');
|
||||||
|
expect(prismaMock.courtBookingLog.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prismaMock.courtBooking.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'booking-1' },
|
||||||
|
data: {
|
||||||
|
courtId: 'court-1',
|
||||||
|
startTime: '15:00',
|
||||||
|
endTime: '16:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects when booking is not found', async () => {
|
||||||
|
prismaMock.courtBooking.findFirst.mockResolvedValue(null as never);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await rescheduleAdminBooking('user-1', 'non-existent', { startTime: '14:00' });
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
||||||
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(404);
|
||||||
|
expect(transactionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects when booking status is not CONFIRMED', async () => {
|
||||||
|
prismaMock.courtBooking.findFirst.mockResolvedValue(
|
||||||
|
makeBooking({ status: 'COMPLETED' }) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await rescheduleAdminBooking('user-1', 'booking-1', { startTime: '14:00' });
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
||||||
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
|
||||||
|
expect(transactionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects when slot is not available in target court', async () => {
|
||||||
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
||||||
|
// Court with no availabilities that match the requested time
|
||||||
|
prismaMock.court.findFirst.mockResolvedValue(
|
||||||
|
makeCourt({
|
||||||
|
id: 'court-2',
|
||||||
|
availabilities: [
|
||||||
|
{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '09:00' },
|
||||||
|
],
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await rescheduleAdminBooking('user-1', 'booking-1', {
|
||||||
|
courtId: 'court-2',
|
||||||
|
startTime: '14:00',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
||||||
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects when target court has a different sport', async () => {
|
||||||
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
||||||
|
prismaMock.court.findFirst.mockResolvedValue(
|
||||||
|
makeCourt({
|
||||||
|
sport: { id: 'sport-2', name: 'Fútbol', slug: 'futbol' },
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await rescheduleAdminBooking('user-1', 'booking-1', {
|
||||||
|
courtId: 'court-2',
|
||||||
|
startTime: '14:00',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
||||||
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
|
||||||
|
expect(transactionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects when target court is under maintenance', async () => {
|
||||||
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
||||||
|
prismaMock.court.findFirst.mockResolvedValue(
|
||||||
|
makeCourt({
|
||||||
|
isUnderMaintenance: true,
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await rescheduleAdminBooking('user-1', 'booking-1', {
|
||||||
|
courtId: 'court-2',
|
||||||
|
startTime: '14:00',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
||||||
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects when there is an overlapping booking', async () => {
|
||||||
|
prismaMock.courtBooking.findFirst
|
||||||
|
.mockResolvedValueOnce(makeBooking() as never) // initial fetch
|
||||||
|
.mockResolvedValueOnce({ id: 'overlap-1' } as never); // overlap found
|
||||||
|
|
||||||
|
prismaMock.court.findFirst.mockResolvedValue(
|
||||||
|
makeCourt({
|
||||||
|
availabilities: [
|
||||||
|
{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '18:00' },
|
||||||
|
],
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await rescheduleAdminBooking('user-1', 'booking-1', {
|
||||||
|
courtId: 'court-2',
|
||||||
|
startTime: '14:00',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
||||||
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
|
||||||
|
expect(transactionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects when neither courtId nor startTime changes', async () => {
|
||||||
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await rescheduleAdminBooking('user-1', 'booking-1', {});
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
||||||
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(400);
|
||||||
|
expect(transactionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects when target court does not belong to the same complex', async () => {
|
||||||
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
||||||
|
// Court belongs to a different complex
|
||||||
|
prismaMock.court.findFirst.mockResolvedValue(null as never);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await rescheduleAdminBooking('user-1', 'booking-1', {
|
||||||
|
courtId: 'other-complex-court',
|
||||||
|
startTime: '14:00',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
||||||
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(404);
|
||||||
|
expect(transactionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -22,6 +22,11 @@ export const dbMock = {
|
|||||||
user: prismaMock.user,
|
user: prismaMock.user,
|
||||||
complexInvitation: prismaMock.complexInvitation,
|
complexInvitation: prismaMock.complexInvitation,
|
||||||
sport: prismaMock.sport,
|
sport: prismaMock.sport,
|
||||||
|
court: prismaMock.court,
|
||||||
|
courtBooking: prismaMock.courtBooking,
|
||||||
|
courtBookingLog: prismaMock.courtBookingLog,
|
||||||
|
courtAvailability: prismaMock.courtAvailability,
|
||||||
|
courtPriceRule: prismaMock.courtPriceRule,
|
||||||
$transaction: transactionMock,
|
$transaction: transactionMock,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
Court,
|
Court,
|
||||||
PlanFeatureFlags,
|
PlanFeatureFlags,
|
||||||
RecurringBookingGroup,
|
RecurringBookingGroup,
|
||||||
|
RescheduleAdminBookingInput,
|
||||||
} from '@repo/api-contract';
|
} from '@repo/api-contract';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
@@ -100,6 +101,7 @@ interface BookingContextValue {
|
|||||||
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
|
createBooking: (payload: CreateManualBookingPayload) => Promise<void>;
|
||||||
createRecurringBooking: (payload: CreateRecurringBookingPayload) => Promise<void>;
|
createRecurringBooking: (payload: CreateRecurringBookingPayload) => Promise<void>;
|
||||||
updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => void;
|
updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => void;
|
||||||
|
rescheduleBooking: (bookingId: string, input: RescheduleAdminBookingInput) => void;
|
||||||
exportDayReport: () => void;
|
exportDayReport: () => void;
|
||||||
openBookingTools: (segment?: BookingTimelineSegment) => void;
|
openBookingTools: (segment?: BookingTimelineSegment) => void;
|
||||||
closeBookingTools: () => void;
|
closeBookingTools: () => void;
|
||||||
@@ -413,7 +415,26 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
[selectedDate]
|
[selectedDate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeBookingTools = () => setBookingToolsOpen(false);
|
const closeBookingTools = () => {
|
||||||
|
setBookingToolsOpen(false);
|
||||||
|
setSelectedSegment(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rescheduleMutation = useMutation({
|
||||||
|
mutationFn: (payload: { bookingId: string; input: RescheduleAdminBookingInput }) =>
|
||||||
|
apiClient.adminBookings.reschedule(payload.bookingId, payload.input),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
|
||||||
|
closeBookingTools();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rescheduleBooking = useCallback(
|
||||||
|
(bookingId: string, input: RescheduleAdminBookingInput) => {
|
||||||
|
rescheduleMutation.mutate({ bookingId, input });
|
||||||
|
},
|
||||||
|
[rescheduleMutation]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenCreateBooking = () => {
|
const handleOpenCreateBooking = () => {
|
||||||
@@ -534,6 +555,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
createBooking,
|
createBooking,
|
||||||
createRecurringBooking,
|
createRecurringBooking,
|
||||||
updateBookingStatus,
|
updateBookingStatus,
|
||||||
|
rescheduleBooking,
|
||||||
exportDayReport,
|
exportDayReport,
|
||||||
bookingToolsOpen,
|
bookingToolsOpen,
|
||||||
openBookingTools,
|
openBookingTools,
|
||||||
@@ -573,6 +595,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
summary,
|
summary,
|
||||||
timelineHours,
|
timelineHours,
|
||||||
updateBookingStatus,
|
updateBookingStatus,
|
||||||
|
rescheduleBooking,
|
||||||
viewMode,
|
viewMode,
|
||||||
visibleTimeRange,
|
visibleTimeRange,
|
||||||
bookingToolsOpen,
|
bookingToolsOpen,
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
ResponsiveDialog,
|
||||||
|
ResponsiveDialogClose,
|
||||||
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogDescription,
|
||||||
|
ResponsiveDialogFooter,
|
||||||
|
ResponsiveDialogHeader,
|
||||||
|
ResponsiveDialogTitle,
|
||||||
|
} from '@/components/ui/responsive-dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { AdminBooking } from '@repo/api-contract';
|
||||||
|
import { CalendarSync } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useBooking } from '../booking-provider';
|
||||||
|
import {
|
||||||
|
getDayOfWeek,
|
||||||
|
getNowTime,
|
||||||
|
isTodayIso,
|
||||||
|
minutesToTime,
|
||||||
|
timeToMinutes,
|
||||||
|
} from '../lib/booking-time';
|
||||||
|
|
||||||
|
interface BookingRescheduleDialogProps {
|
||||||
|
booking: AdminBooking;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingRescheduleDialog({
|
||||||
|
booking,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: BookingRescheduleDialogProps) {
|
||||||
|
const { courts, bookings, rescheduleBooking } = useBooking();
|
||||||
|
|
||||||
|
const [courtId, setCourtId] = useState(booking.courtId);
|
||||||
|
const [startTime, setStartTime] = useState(booking.startTime);
|
||||||
|
|
||||||
|
const sportId = booking.sport.id;
|
||||||
|
const filteredCourts = useMemo(
|
||||||
|
() => courts.filter((c) => c.sportId === sportId),
|
||||||
|
[courts, sportId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCourt = filteredCourts.find((c) => c.id === courtId);
|
||||||
|
const dayOfWeek = getDayOfWeek(booking.date);
|
||||||
|
|
||||||
|
const availableStartTimes = useMemo(() => {
|
||||||
|
if (!selectedCourt) return [];
|
||||||
|
|
||||||
|
const times: string[] = [];
|
||||||
|
const dayAvailability = selectedCourt.availability
|
||||||
|
.filter((range) => range.dayOfWeek === dayOfWeek)
|
||||||
|
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
|
||||||
|
|
||||||
|
const courtBookings = bookings.filter(
|
||||||
|
(b) =>
|
||||||
|
b.date === booking.date &&
|
||||||
|
b.courtId === selectedCourt.id &&
|
||||||
|
b.id !== booking.id &&
|
||||||
|
b.status !== 'CANCELLED'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const range of dayAvailability) {
|
||||||
|
const start = timeToMinutes(range.startTime);
|
||||||
|
const end = timeToMinutes(range.endTime);
|
||||||
|
|
||||||
|
for (
|
||||||
|
let minute = start;
|
||||||
|
minute + selectedCourt.slotDurationMinutes <= end;
|
||||||
|
minute += selectedCourt.slotDurationMinutes
|
||||||
|
) {
|
||||||
|
const slotEnd = minute + selectedCourt.slotDurationMinutes;
|
||||||
|
|
||||||
|
if (isTodayIso(booking.date) && minute <= timeToMinutes(getNowTime())) continue;
|
||||||
|
|
||||||
|
const isBooked = courtBookings.some((b) => {
|
||||||
|
const bookingStart = timeToMinutes(b.startTime);
|
||||||
|
const bookingEnd = timeToMinutes(b.endTime);
|
||||||
|
return minute < bookingEnd && slotEnd > bookingStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isBooked) {
|
||||||
|
times.push(minutesToTime(minute));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return times;
|
||||||
|
}, [bookings, booking.date, booking.id, dayOfWeek, selectedCourt]);
|
||||||
|
|
||||||
|
const hasChanges = courtId !== booking.courtId || startTime !== booking.startTime;
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!hasChanges) return;
|
||||||
|
|
||||||
|
const payload: Record<string, string> = {};
|
||||||
|
if (courtId !== booking.courtId) payload.courtId = courtId;
|
||||||
|
if (startTime !== booking.startTime) payload.startTime = startTime;
|
||||||
|
|
||||||
|
rescheduleBooking(booking.id, payload);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (newOpen) {
|
||||||
|
setCourtId(booking.courtId);
|
||||||
|
setStartTime(booking.startTime);
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<ResponsiveDialogContent>
|
||||||
|
<ResponsiveDialogHeader>
|
||||||
|
<ResponsiveDialogTitle>Reprogramar reserva</ResponsiveDialogTitle>
|
||||||
|
<ResponsiveDialogDescription>
|
||||||
|
Cambiá la cancha y/o el horario de la reserva para el mismo día.
|
||||||
|
</ResponsiveDialogDescription>
|
||||||
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border bg-card p-3 text-sm text-muted-foreground">
|
||||||
|
Reserva <strong>{booking.bookingCode}</strong> — {booking.customerName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Cancha</p>
|
||||||
|
<Select value={courtId} onValueChange={setCourtId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Seleccionar cancha" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{filteredCourts.map((court) => (
|
||||||
|
<SelectItem key={court.id} value={court.id} disabled={court.isUnderMaintenance}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{court.name} — {court.sport.name}
|
||||||
|
{court.isUnderMaintenance && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
(En mantenimiento)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Horario</p>
|
||||||
|
<Select
|
||||||
|
value={startTime}
|
||||||
|
onValueChange={setStartTime}
|
||||||
|
disabled={!selectedCourt || availableStartTimes.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
selectedCourt
|
||||||
|
? availableStartTimes.length === 0
|
||||||
|
? 'Sin horarios disponibles'
|
||||||
|
: 'Seleccionar horario'
|
||||||
|
: 'Seleccioná una cancha primero'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableStartTimes.map((time) => {
|
||||||
|
const slotMinutes = timeToMinutes(time);
|
||||||
|
const endMinutes = slotMinutes + (selectedCourt?.slotDurationMinutes ?? 0);
|
||||||
|
return (
|
||||||
|
<SelectItem key={time} value={time}>
|
||||||
|
{time} — {minutesToTime(endMinutes)}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveDialogFooter>
|
||||||
|
<ResponsiveDialogClose asChild>
|
||||||
|
<Button variant="outline">Cancelar</Button>
|
||||||
|
</ResponsiveDialogClose>
|
||||||
|
<Button onClick={handleConfirm} disabled={!hasChanges || !startTime || !courtId}>
|
||||||
|
<CalendarSync className="mr-2 h-4 w-4" />
|
||||||
|
Confirmar reprogramación
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
CalendarSync,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useBooking } from '../booking-provider';
|
import { useBooking } from '../booking-provider';
|
||||||
|
import { BookingRescheduleDialog } from './booking-reschedule-dialog';
|
||||||
|
|
||||||
function formatDate(date: string | undefined) {
|
function formatDate(date: string | undefined) {
|
||||||
if (!date) return '';
|
if (!date) return '';
|
||||||
@@ -72,6 +74,7 @@ export function BookingToolsDialog() {
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const booking = selectedSegment?.booking;
|
const booking = selectedSegment?.booking;
|
||||||
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
|
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
|
||||||
|
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||||
|
|
||||||
const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? {
|
const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? {
|
||||||
label: booking?.status,
|
label: booking?.status,
|
||||||
@@ -165,10 +168,19 @@ export function BookingToolsDialog() {
|
|||||||
onCancel={() => setConfirmCancelOpen(true)}
|
onCancel={() => setConfirmCancelOpen(true)}
|
||||||
onReminder={sendWhatsappReminder}
|
onReminder={sendWhatsappReminder}
|
||||||
onNoShow={() => updateStatus('NOSHOW')}
|
onNoShow={() => updateStatus('NOSHOW')}
|
||||||
|
onReschedule={() => setRescheduleOpen(true)}
|
||||||
/>
|
/>
|
||||||
</ResponsiveDialogContent>
|
</ResponsiveDialogContent>
|
||||||
</ResponsiveDialog>
|
</ResponsiveDialog>
|
||||||
{cancelConfirmDialog}
|
{cancelConfirmDialog}
|
||||||
|
|
||||||
|
{booking && (
|
||||||
|
<BookingRescheduleDialog
|
||||||
|
booking={booking}
|
||||||
|
open={rescheduleOpen}
|
||||||
|
onOpenChange={setRescheduleOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -268,6 +280,14 @@ export function BookingToolsDialog() {
|
|||||||
onClick={() => setConfirmCancelOpen(true)}
|
onClick={() => setConfirmCancelOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon={<CalendarSync className="h-5 w-5" />}
|
||||||
|
title="Reprogramar"
|
||||||
|
description="Cambiar la cancha y/o el horario de la reserva."
|
||||||
|
className="border-violet-500/40 bg-violet-500/10 text-lg text-violet-600 hover:bg-violet-500/15 hover:text-violet-800"
|
||||||
|
onClick={() => setRescheduleOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<MessageCircle className="h-5 w-5" />}
|
icon={<MessageCircle className="h-5 w-5" />}
|
||||||
title="Enviar recordatorio"
|
title="Enviar recordatorio"
|
||||||
@@ -299,6 +319,14 @@ export function BookingToolsDialog() {
|
|||||||
</ResponsiveDialogContent>
|
</ResponsiveDialogContent>
|
||||||
</ResponsiveDialog>
|
</ResponsiveDialog>
|
||||||
{cancelConfirmDialog}
|
{cancelConfirmDialog}
|
||||||
|
|
||||||
|
{booking && (
|
||||||
|
<BookingRescheduleDialog
|
||||||
|
booking={booking}
|
||||||
|
open={rescheduleOpen}
|
||||||
|
onOpenChange={setRescheduleOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -309,12 +337,14 @@ function MobileBookingToolsSheet({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onReminder,
|
onReminder,
|
||||||
onNoShow,
|
onNoShow,
|
||||||
|
onReschedule,
|
||||||
}: {
|
}: {
|
||||||
status: { label: string | undefined; className: string };
|
status: { label: string | undefined; className: string };
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onReminder: () => void;
|
onReminder: () => void;
|
||||||
onNoShow: () => void;
|
onNoShow: () => void;
|
||||||
|
onReschedule: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { selectedSegment } = useBooking();
|
const { selectedSegment } = useBooking();
|
||||||
const booking = selectedSegment?.booking;
|
const booking = selectedSegment?.booking;
|
||||||
@@ -395,6 +425,12 @@ function MobileBookingToolsSheet({
|
|||||||
className="border-primary/45 bg-primary/12 text-primary"
|
className="border-primary/45 bg-primary/12 text-primary"
|
||||||
onClick={onComplete}
|
onClick={onComplete}
|
||||||
/>
|
/>
|
||||||
|
<MobileActionButton
|
||||||
|
icon={<CalendarSync className="size-5" />}
|
||||||
|
label="Reprogramar"
|
||||||
|
className="border-violet-500/45 bg-violet-500/12 text-violet-500"
|
||||||
|
onClick={onReschedule}
|
||||||
|
/>
|
||||||
<MobileActionButton
|
<MobileActionButton
|
||||||
icon={<MessageCircle className="size-5" />}
|
icon={<MessageCircle className="size-5" />}
|
||||||
label="Recordar"
|
label="Recordar"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const apiClient = {
|
|||||||
createRecurring: api.createRecurring,
|
createRecurring: api.createRecurring,
|
||||||
cancelRecurringGroup: api.cancelRecurringGroup,
|
cancelRecurringGroup: api.cancelRecurringGroup,
|
||||||
listRecurringGroups: api.listRecurringGroups,
|
listRecurringGroups: api.listRecurringGroups,
|
||||||
|
reschedule: api.reschedule,
|
||||||
updateRecurringGroup: api.updateRecurringGroup,
|
updateRecurringGroup: api.updateRecurringGroup,
|
||||||
updateStatus: api.updateStatus,
|
updateStatus: api.updateStatus,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export {
|
|||||||
createRecurring,
|
createRecurring,
|
||||||
cancelRecurringGroup,
|
cancelRecurringGroup,
|
||||||
listRecurringGroups,
|
listRecurringGroups,
|
||||||
|
reschedule,
|
||||||
updateRecurringGroup,
|
updateRecurringGroup,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
} from './resources/bookings';
|
} from './resources/bookings';
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
PublicBooking,
|
PublicBooking,
|
||||||
PublicBookingConfirmation,
|
PublicBookingConfirmation,
|
||||||
RecurringBookingGroup,
|
RecurringBookingGroup,
|
||||||
|
RescheduleAdminBookingInput,
|
||||||
UpdateAdminBookingStatusInput,
|
UpdateAdminBookingStatusInput,
|
||||||
UpdateRecurringGroupInput,
|
UpdateRecurringGroupInput,
|
||||||
} from '@repo/api-contract';
|
} from '@repo/api-contract';
|
||||||
@@ -101,3 +102,11 @@ export async function updateStatus(bookingId: string, payload: UpdateAdminBookin
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reschedule(bookingId: string, payload: RescheduleAdminBookingInput) {
|
||||||
|
const response = await http.patch<AdminBooking>(
|
||||||
|
`/api/admin-bookings/${bookingId}/reschedule`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,19 @@ export const recurringBookingGroupSchema = z.object({
|
|||||||
updatedAt: z.string().datetime(),
|
updatedAt: z.string().datetime(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const rescheduleAdminBookingSchema = z
|
||||||
|
.object({
|
||||||
|
courtId: z.string().uuid('El courtId debe ser un UUID válido.').optional(),
|
||||||
|
startTime: z
|
||||||
|
.string()
|
||||||
|
.regex(TIME_REGEX, 'La hora debe tener formato HH:mm.')
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => data.courtId !== undefined || data.startTime !== undefined,
|
||||||
|
{ message: 'Debe proporcionar al menos una cancha o un horario diferente.' }
|
||||||
|
);
|
||||||
|
|
||||||
export const updateRecurringGroupSchema = z.object({
|
export const updateRecurringGroupSchema = z.object({
|
||||||
courtId: z.uuid('El courtId debe ser un UUID valido.').optional(),
|
courtId: z.uuid('El courtId debe ser un UUID valido.').optional(),
|
||||||
dayOfWeek: z
|
dayOfWeek: z
|
||||||
@@ -125,5 +138,6 @@ 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 RescheduleAdminBookingInput = z.infer<typeof rescheduleAdminBookingSchema>;
|
||||||
export type UpdateRecurringGroupInput = z.infer<typeof updateRecurringGroupSchema>;
|
export type UpdateRecurringGroupInput = z.infer<typeof updateRecurringGroupSchema>;
|
||||||
export type ListRecurringGroupsResponse = z.infer<typeof listRecurringGroupsResponseSchema>;
|
export type ListRecurringGroupsResponse = z.infer<typeof listRecurringGroupsResponseSchema>;
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export const courtSchema = z.object({
|
|||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
complexId: z.uuid(),
|
complexId: z.uuid(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
isUnderMaintenance: z.boolean(),
|
||||||
sportId: z.uuid(),
|
sportId: z.uuid(),
|
||||||
sport: courtSportSchema,
|
sport: courtSportSchema,
|
||||||
slotDurationMinutes: z.int().positive(),
|
slotDurationMinutes: z.int().positive(),
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export {
|
|||||||
listRecurringGroupsResponseSchema,
|
listRecurringGroupsResponseSchema,
|
||||||
recurringBookingGroupSchema,
|
recurringBookingGroupSchema,
|
||||||
recurringGroupStatusSchema,
|
recurringGroupStatusSchema,
|
||||||
|
rescheduleAdminBookingSchema,
|
||||||
updateAdminBookingStatusSchema,
|
updateAdminBookingStatusSchema,
|
||||||
updateRecurringGroupSchema,
|
updateRecurringGroupSchema,
|
||||||
} from './admin-booking';
|
} from './admin-booking';
|
||||||
@@ -96,6 +97,7 @@ export type {
|
|||||||
ListRecurringGroupsResponse,
|
ListRecurringGroupsResponse,
|
||||||
RecurringBookingGroup,
|
RecurringBookingGroup,
|
||||||
RecurringGroupStatus,
|
RecurringGroupStatus,
|
||||||
|
RescheduleAdminBookingInput,
|
||||||
UpdateAdminBookingStatusInput,
|
UpdateAdminBookingStatusInput,
|
||||||
UpdateRecurringGroupInput,
|
UpdateRecurringGroupInput,
|
||||||
} from './admin-booking';
|
} from './admin-booking';
|
||||||
|
|||||||
Reference in New Issue
Block a user