Compare commits
15 Commits
b20b5c2b8b
...
feat/court
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
630dedb507 | ||
|
|
49d2a13672 | ||
|
|
b2f9a14b87 | ||
|
|
b48dd4f15d | ||
|
|
fee0be7e1f | ||
|
|
721ebaa775 | ||
| 2ccff7dcaa | |||
|
|
4b1b06f00f | ||
|
|
af687fe2d8 | ||
| 9875f22d5a | |||
|
|
260d79fc99 | ||
|
|
1210854c22 | ||
|
|
7ca784d5f5 | ||
|
|
e441f15ee4 | ||
|
|
228004a7e0 |
10
AGENTS.md
10
AGENTS.md
@@ -204,6 +204,7 @@ Exportado como `wrapLayout(content)`. Proporciona:
|
|||||||
- Fondo: `#edf7f4`
|
- Fondo: `#edf7f4`
|
||||||
- Card blanca: `max-width: 520px`, `border-radius: 28px`, `box-shadow: 0 24px 70px rgba(15,23,42,0.12)`, `border: 1px solid rgba(5,9,20,0.1)`
|
- Card blanca: `max-width: 520px`, `border-radius: 28px`, `box-shadow: 0 24px 70px rgba(15,23,42,0.12)`, `border: 1px solid rgba(5,9,20,0.1)`
|
||||||
- Playzer favicon: `{APP_BASE_URL}/playzer-favicon-512-transparent.png` (está en `apps/frontend/public/`)
|
- Playzer favicon: `{APP_BASE_URL}/playzer-favicon-512-transparent.png` (está en `apps/frontend/public/`)
|
||||||
|
- **Sin `min-height: 100vh`**: la card empieza arriba, no centrada verticalmente
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { wrapLayout } from '@/emails/booking-confirmation';
|
import { wrapLayout } from '@/emails/booking-confirmation';
|
||||||
@@ -222,6 +223,15 @@ const html = wrapLayout(`<tr>...contenido...</tr>`);
|
|||||||
| **Pie** | Sin pie de marca. Solo texto secundario opcional centrado si es necesario. |
|
| **Pie** | Sin pie de marca. Solo texto secundario opcional centrado si es necesario. |
|
||||||
| **Sin botones de acción** | Los emails de booking **no** incluyen los botones "Compartir por WhatsApp" ni "Hacer otra reserva". |
|
| **Sin botones de acción** | Los emails de booking **no** incluyen los botones "Compartir por WhatsApp" ni "Hacer otra reserva". |
|
||||||
|
|
||||||
|
### Padding estándar
|
||||||
|
|
||||||
|
| Ubicación | Valor |
|
||||||
|
|-----------|-------|
|
||||||
|
| Wrapper (outer `<td>`) | `padding: 24px 12px` |
|
||||||
|
| Primer `<td>` del contenido (header) | `padding: 24px 20px 16px` |
|
||||||
|
| `<td>` intermedios (cards, texto) | `padding: 0 20px 16px` |
|
||||||
|
| Último `<td>` del contenido | `padding: 0 20px 24px` |
|
||||||
|
|
||||||
### Colores de badges según estado
|
### Colores de badges según estado
|
||||||
|
|
||||||
| Estado | Fondo badge | Texto badge |
|
| Estado | Fondo badge | Texto badge |
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ model CourtBooking {
|
|||||||
endTime String @map("end_time") @db.VarChar(5)
|
endTime String @map("end_time") @db.VarChar(5)
|
||||||
customerName String @map("customer_name") @db.VarChar(120)
|
customerName String @map("customer_name") @db.VarChar(120)
|
||||||
customerPhone String @map("customer_phone") @db.VarChar(30)
|
customerPhone String @map("customer_phone") @db.VarChar(30)
|
||||||
customerEmail String? @map("customer_email") @db.VarChar(254)
|
customerEmail String @map("customer_email") @db.VarChar(254)
|
||||||
status CourtBookingStatus @default(CONFIRMED)
|
status CourtBookingStatus @default(CONFIRMED)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -108,7 +108,7 @@ model CourtBookingLog {
|
|||||||
newStatus CourtBookingStatus @map("new_status")
|
newStatus CourtBookingStatus @map("new_status")
|
||||||
customerName String @map("customer_name") @db.VarChar(120)
|
customerName String @map("customer_name") @db.VarChar(120)
|
||||||
customerPhone String @map("customer_phone") @db.VarChar(30)
|
customerPhone String @map("customer_phone") @db.VarChar(30)
|
||||||
customerEmail String? @map("customer_email") @db.VarChar(254)
|
customerEmail String @map("customer_email") @db.VarChar(254)
|
||||||
changedAt DateTime @default(now()) @map("changed_at")
|
changedAt DateTime @default(now()) @map("changed_at")
|
||||||
|
|
||||||
@@map("court_booking_logs")
|
@@map("court_booking_logs")
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Made the column `customer_email` on table `court_booking_logs` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
- Made the column `customer_email` on table `court_bookings` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- Backfill existing NULL values with placeholder
|
||||||
|
UPDATE "court_booking_logs" SET "customer_email" = 'missing@playzer.app' WHERE "customer_email" IS NULL;
|
||||||
|
UPDATE "court_bookings" SET "customer_email" = 'missing@playzer.app' WHERE "customer_email" IS NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "court_booking_logs" ALTER COLUMN "customer_email" SET NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "court_bookings" ALTER COLUMN "customer_email" SET NOT NULL;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
type BookingEmailData = {
|
type BookingEmailData = {
|
||||||
bookingCode: string;
|
bookingCode: string;
|
||||||
|
complexSlug?: string;
|
||||||
complexName: string;
|
complexName: string;
|
||||||
date: string;
|
date: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
@@ -59,9 +60,9 @@ export function wrapLayout(content: string) {
|
|||||||
<style>${BASE_STYLES}</style>
|
<style>${BASE_STYLES}</style>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;padding:0;background-color:#edf7f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
<body style="margin:0;padding:0;background-color:#edf7f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#edf7f4;min-height:100vh;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#edf7f4;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding:32px 16px;">
|
<td align="center" style="padding:24px 12px;">
|
||||||
<table role="presentation" width="100%" style="max-width:520px;background-color:#ffffff;border-radius:28px;overflow:hidden;box-shadow:0 24px 70px rgba(15,23,42,0.12);border:1px solid rgba(5,9,20,0.1);">
|
<table role="presentation" width="100%" style="max-width:520px;background-color:#ffffff;border-radius:28px;overflow:hidden;box-shadow:0 24px 70px rgba(15,23,42,0.12);border:1px solid rgba(5,9,20,0.1);">
|
||||||
${content}
|
${content}
|
||||||
</table>
|
</table>
|
||||||
@@ -78,7 +79,7 @@ export function bookingConfirmationHtml(data: BookingEmailData): string {
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:32px 32px 24px;">
|
<td style="padding:24px 20px 16px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td valign="top">
|
<td valign="top">
|
||||||
@@ -111,7 +112,7 @@ export function bookingConfirmationHtml(data: BookingEmailData): string {
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 24px;">
|
<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);">
|
<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>
|
<tr>
|
||||||
<td style="padding:20px 24px;">
|
<td style="padding:20px 24px;">
|
||||||
@@ -147,7 +148,7 @@ export function bookingConfirmationHtml(data: BookingEmailData): string {
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 24px;">
|
<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;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
|
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
|
||||||
@@ -171,15 +172,30 @@ export function bookingConfirmationHtml(data: BookingEmailData): string {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
${
|
||||||
|
data.complexSlug
|
||||||
|
? `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 32px;">
|
<td style="padding:0 20px 24px;">
|
||||||
<p style="margin:0;font-size:13px;color:#6b7280;text-align:center;line-height:1.5;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
Presentá el código de reserva al llegar al complejo.
|
<tr>
|
||||||
<br />
|
<td align="center">
|
||||||
Si necesitás cancelar o modificar, contactate directamente con el complejo.
|
<table role="presentation" cellpadding="0" cellspacing="0" style="background-color:#059669;border-radius:12px;">
|
||||||
</p>
|
<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;">
|
||||||
|
Cancelar reserva
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`
|
||||||
|
: ''
|
||||||
|
}`;
|
||||||
|
|
||||||
return wrapLayout(content);
|
return wrapLayout(content);
|
||||||
}
|
}
|
||||||
@@ -189,7 +205,7 @@ export function bookingCancelledHtml(data: BookingEmailData): string {
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:32px 32px 24px;">
|
<td style="padding:24px 20px 16px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td valign="top">
|
<td valign="top">
|
||||||
@@ -222,7 +238,7 @@ export function bookingCancelledHtml(data: BookingEmailData): string {
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 24px;">
|
<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);">
|
<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>
|
<tr>
|
||||||
<td style="padding:20px 24px;">
|
<td style="padding:20px 24px;">
|
||||||
@@ -242,7 +258,7 @@ export function bookingCancelledHtml(data: BookingEmailData): string {
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 24px;">
|
<td style="padding:0 20px 16px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#fef2f2;border-radius:24px;border:1px solid rgba(220,38,38,0.3);">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#fef2f2;border-radius:24px;border:1px solid rgba(220,38,38,0.3);">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:20px 24px;">
|
<td style="padding:20px 24px;">
|
||||||
@@ -257,7 +273,7 @@ export function bookingCancelledHtml(data: BookingEmailData): string {
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 32px;">
|
<td style="padding:0 20px 24px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
|
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
|
||||||
@@ -289,7 +305,7 @@ export function bookingNoShowHtml(data: BookingEmailData): string {
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:32px 32px 24px;">
|
<td style="padding:24px 20px 16px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td valign="top">
|
<td valign="top">
|
||||||
@@ -322,7 +338,7 @@ export function bookingNoShowHtml(data: BookingEmailData): string {
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 24px;">
|
<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);">
|
<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>
|
<tr>
|
||||||
<td style="padding:20px 24px;">
|
<td style="padding:20px 24px;">
|
||||||
@@ -342,7 +358,7 @@ export function bookingNoShowHtml(data: BookingEmailData): string {
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 24px;">
|
<td style="padding:0 20px 16px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#fffbeb;border-radius:24px;border:1px solid rgba(217,119,6,0.3);">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#fffbeb;border-radius:24px;border:1px solid rgba(217,119,6,0.3);">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:20px 24px;">
|
<td style="padding:20px 24px;">
|
||||||
@@ -357,7 +373,7 @@ export function bookingNoShowHtml(data: BookingEmailData): string {
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 32px;">
|
<td style="padding:0 20px 24px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:22px;overflow:hidden;">
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
|
<td width="50%" style="padding:16px;border-right:1px solid #e5e7eb;background-color:#ffffff;">
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -198,7 +198,7 @@ export type CourtBookingGroupByOutputType = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail: string | null
|
customerEmail: string
|
||||||
status: $Enums.CourtBookingStatus
|
status: $Enums.CourtBookingStatus
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
@@ -234,7 +234,7 @@ export type CourtBookingWhereInput = {
|
|||||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
|
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
@@ -250,7 +250,7 @@ export type CourtBookingOrderByWithRelationInput = {
|
|||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
customerEmail?: Prisma.SortOrderInput | Prisma.SortOrder
|
customerEmail?: Prisma.SortOrder
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -270,7 +270,7 @@ export type CourtBookingWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
|
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
@@ -286,7 +286,7 @@ export type CourtBookingOrderByWithAggregationInput = {
|
|||||||
endTime?: Prisma.SortOrder
|
endTime?: Prisma.SortOrder
|
||||||
customerName?: Prisma.SortOrder
|
customerName?: Prisma.SortOrder
|
||||||
customerPhone?: Prisma.SortOrder
|
customerPhone?: Prisma.SortOrder
|
||||||
customerEmail?: Prisma.SortOrderInput | Prisma.SortOrder
|
customerEmail?: Prisma.SortOrder
|
||||||
status?: Prisma.SortOrder
|
status?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -307,7 +307,7 @@ export type CourtBookingScalarWhereWithAggregatesInput = {
|
|||||||
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
customerEmail?: Prisma.StringNullableWithAggregatesFilter<"CourtBooking"> | string | null
|
customerEmail?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
|
||||||
status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
|
||||||
@@ -321,7 +321,7 @@ export type CourtBookingCreateInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -337,7 +337,7 @@ export type CourtBookingUncheckedCreateInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -351,7 +351,7 @@ export type CourtBookingUpdateInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -367,7 +367,7 @@ export type CourtBookingUncheckedUpdateInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -382,7 +382,7 @@ export type CourtBookingCreateManyInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -396,7 +396,7 @@ export type CourtBookingUpdateManyMutationInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -411,7 +411,7 @@ export type CourtBookingUncheckedUpdateManyInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -532,7 +532,7 @@ export type CourtBookingCreateWithoutCourtInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -546,7 +546,7 @@ export type CourtBookingUncheckedCreateWithoutCourtInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -590,7 +590,7 @@ export type CourtBookingScalarWhereInput = {
|
|||||||
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
endTime?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
customerName?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
|
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
|
||||||
@@ -604,7 +604,7 @@ export type CourtBookingCreateManyCourtInput = {
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail?: string | null
|
customerEmail: string
|
||||||
status?: $Enums.CourtBookingStatus
|
status?: $Enums.CourtBookingStatus
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -618,7 +618,7 @@ export type CourtBookingUpdateWithoutCourtInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -632,7 +632,7 @@ export type CourtBookingUncheckedUpdateWithoutCourtInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -646,7 +646,7 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtInput = {
|
|||||||
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
endTime?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
customerName?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -742,7 +742,7 @@ export type $CourtBookingPayload<ExtArgs extends runtime.Types.Extensions.Intern
|
|||||||
endTime: string
|
endTime: string
|
||||||
customerName: string
|
customerName: string
|
||||||
customerPhone: string
|
customerPhone: string
|
||||||
customerEmail: string | null
|
customerEmail: string
|
||||||
status: $Enums.CourtBookingStatus
|
status: $Enums.CourtBookingStatus
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const auth = betterAuth({
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:32px 32px 24px;">
|
<td style="padding:24px 20px 16px;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td valign="top">
|
<td valign="top">
|
||||||
@@ -71,7 +71,7 @@ export const auth = betterAuth({
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 24px;">
|
padding:0 20px 16px;
|
||||||
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
|
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
|
||||||
Hacé click en el botón de abajo para verificar tu dirección de correo electrónico y empezar a usar Playzer.
|
Hacé click en el botón de abajo para verificar tu dirección de correo electrónico y empezar a usar Playzer.
|
||||||
</p>
|
</p>
|
||||||
@@ -79,7 +79,7 @@ export const auth = betterAuth({
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 32px;">
|
<td style="padding:0 20px 24px;">
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color:#059669;border-radius:12px;text-align:center;">
|
<td style="background-color:#059669;border-radius:12px;text-align:center;">
|
||||||
@@ -91,7 +91,7 @@ export const auth = betterAuth({
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0 32px 32px;">
|
<td style="padding:0 20px 24px;">
|
||||||
<p style="margin:0;font-size:13px;color:#9ca3af;text-align:center;line-height:1.5;">
|
<p style="margin:0;font-size:13px;color:#9ca3af;text-align:center;line-height:1.5;">
|
||||||
Si no creaste una cuenta en Playzer, ignorá este mensaje.
|
Si no creaste una cuenta en Playzer, ignorá este mensaje.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
34
apps/backend/src/lib/slot-validator.ts
Normal file
34
apps/backend/src/lib/slot-validator.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
function toMinutes(value: string): number {
|
||||||
|
const [hours, minutes] = value.split(':').map((part) => Number(part));
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSlotInPast(
|
||||||
|
bookingDate: Date,
|
||||||
|
startTime: string,
|
||||||
|
slotDurationMinutes: number,
|
||||||
|
now?: Date
|
||||||
|
): boolean {
|
||||||
|
const currentTime = now ?? new Date();
|
||||||
|
|
||||||
|
const todayStart = new Date(
|
||||||
|
currentTime.getFullYear(),
|
||||||
|
currentTime.getMonth(),
|
||||||
|
currentTime.getDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookingLocalStart = new Date(
|
||||||
|
bookingDate.getUTCFullYear(),
|
||||||
|
bookingDate.getUTCMonth(),
|
||||||
|
bookingDate.getUTCDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bookingLocalStart < todayStart) return true;
|
||||||
|
if (bookingLocalStart > todayStart) return false;
|
||||||
|
|
||||||
|
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||||
|
const slotStartMinutes = toMinutes(startTime);
|
||||||
|
const elapsed = currentMinutes - slotStartMinutes;
|
||||||
|
|
||||||
|
return elapsed > slotDurationMinutes / 2;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { db } from '@/lib/prisma';
|
||||||
import {
|
import {
|
||||||
AdminBookingServiceError,
|
AdminBookingServiceError,
|
||||||
createAdminBooking,
|
createAdminBooking,
|
||||||
@@ -16,8 +17,14 @@ export async function createAdminBookingHandler(c: AppContext) {
|
|||||||
try {
|
try {
|
||||||
const booking = await createAdminBooking(user.id, complexId, payload);
|
const booking = await createAdminBooking(user.id, complexId, payload);
|
||||||
|
|
||||||
|
const complex = await db.complex.findUnique({
|
||||||
|
where: { id: complexId },
|
||||||
|
select: { complexSlug: true },
|
||||||
|
});
|
||||||
|
|
||||||
void sendBookingConfirmation({
|
void sendBookingConfirmation({
|
||||||
bookingCode: booking.bookingCode,
|
bookingCode: booking.bookingCode,
|
||||||
|
complexSlug: complex?.complexSlug ?? '',
|
||||||
complexName: booking.complexName,
|
complexName: booking.complexName,
|
||||||
date: booking.date,
|
date: booking.date,
|
||||||
startTime: booking.startTime,
|
startTime: booking.startTime,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomInt } from 'node:crypto';
|
import { randomInt } from 'node:crypto';
|
||||||
import { CourtBookingStatus } from '@/generated/prisma/enums';
|
import { CourtBookingStatus } from '@/generated/prisma/enums';
|
||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
|
import { isSlotInPast } from '@/lib/slot-validator';
|
||||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||||
import type { DayOfWeek } from '@repo/api-contract';
|
import type { DayOfWeek } from '@repo/api-contract';
|
||||||
import type {
|
import type {
|
||||||
@@ -199,7 +200,7 @@ function mapBookingResponse(booking: {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
customerEmail: string | null;
|
customerEmail: string;
|
||||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -235,7 +236,7 @@ function mapBookingResponse(booking: {
|
|||||||
endTime: booking.endTime,
|
endTime: booking.endTime,
|
||||||
customerName: booking.customerName,
|
customerName: booking.customerName,
|
||||||
customerPhone: booking.customerPhone,
|
customerPhone: booking.customerPhone,
|
||||||
customerEmail: booking.customerEmail ?? undefined,
|
customerEmail: booking.customerEmail,
|
||||||
price: booking.price ?? 0,
|
price: booking.price ?? 0,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
createdAt: booking.createdAt.toISOString(),
|
createdAt: booking.createdAt.toISOString(),
|
||||||
@@ -361,6 +362,10 @@ export async function createAdminBooking(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSlotInPast(bookingDate, input.startTime, court.slotDurationMinutes)) {
|
||||||
|
throw new AdminBookingServiceError('No se pueden crear reservas en el pasado.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const booking = await db.$transaction(async (tx) => {
|
const booking = await db.$transaction(async (tx) => {
|
||||||
@@ -424,7 +429,7 @@ export async function createAdminBooking(
|
|||||||
endTime: selectedSlot.endTime,
|
endTime: selectedSlot.endTime,
|
||||||
customerName: input.customerName.trim(),
|
customerName: input.customerName.trim(),
|
||||||
customerPhone: input.customerPhone.trim(),
|
customerPhone: input.customerPhone.trim(),
|
||||||
customerEmail: input.customerEmail?.trim() || null,
|
customerEmail: input.customerEmail?.trim() ?? '',
|
||||||
status: 'CONFIRMED',
|
status: 'CONFIRMED',
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { sseManager } from '@/lib/sse';
|
||||||
|
import {
|
||||||
|
PublicBookingServiceError,
|
||||||
|
cancelPublicBooking,
|
||||||
|
} from '@/modules/public-booking/services/public-booking.service';
|
||||||
|
import { sendBookingCancelled } from '@/services/booking-email.service';
|
||||||
|
import type { AppContext } from '@/types/hono';
|
||||||
|
import type { CancelPublicBookingInput } from '@repo/api-contract';
|
||||||
|
|
||||||
|
type ComplexSlugParams = { complexSlug: string };
|
||||||
|
|
||||||
|
export async function cancelPublicBookingHandler(c: AppContext) {
|
||||||
|
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams;
|
||||||
|
const payload = c.req.valid('json' as never) as CancelPublicBookingInput;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const booking = await cancelPublicBooking(complexSlug, payload);
|
||||||
|
|
||||||
|
const channel = `complex-${booking.complexId}`;
|
||||||
|
sseManager.emit(
|
||||||
|
channel,
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'booking_cancelled',
|
||||||
|
booking: {
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
courtId: booking.courtId,
|
||||||
|
date: booking.date,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
void sendBookingCancelled({
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
complexSlug: booking.complexSlug,
|
||||||
|
complexName: booking.complexName,
|
||||||
|
date: booking.date,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
courtName: booking.courtName,
|
||||||
|
sportName: booking.sport.name,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(booking);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PublicBookingServiceError) {
|
||||||
|
return c.json({ message: error.message }, error.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export async function createPublicBookingHandler(c: AppContext) {
|
|||||||
|
|
||||||
void sendBookingConfirmation({
|
void sendBookingConfirmation({
|
||||||
bookingCode: booking.bookingCode,
|
bookingCode: booking.bookingCode,
|
||||||
|
complexSlug: booking.complexSlug,
|
||||||
complexName: booking.complexName,
|
complexName: booking.complexName,
|
||||||
date: booking.date,
|
date: booking.date,
|
||||||
startTime: booking.startTime,
|
startTime: booking.startTime,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import { cancelPublicBookingHandler } from '@/modules/public-booking/handlers/cancel-public-booking.handler';
|
||||||
import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler';
|
import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler';
|
||||||
import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler';
|
import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler';
|
||||||
import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler';
|
import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.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 { createPublicBookingSchema, publicAvailabilityQuerySchema } from '@repo/api-contract';
|
import {
|
||||||
|
cancelPublicBookingSchema,
|
||||||
|
createPublicBookingSchema,
|
||||||
|
publicAvailabilityQuerySchema,
|
||||||
|
} from '@repo/api-contract';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -33,3 +38,10 @@ publicBookingRoutes.get(
|
|||||||
zValidator('param', confirmationParamsSchema),
|
zValidator('param', confirmationParamsSchema),
|
||||||
getPublicBookingConfirmationHandler
|
getPublicBookingConfirmationHandler
|
||||||
);
|
);
|
||||||
|
|
||||||
|
publicBookingRoutes.post(
|
||||||
|
'/complex/:complexSlug/cancel',
|
||||||
|
zValidator('param', complexSlugParamsSchema),
|
||||||
|
zValidator('json', cancelPublicBookingSchema),
|
||||||
|
cancelPublicBookingHandler
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { randomInt } from 'node:crypto';
|
import { randomInt } from 'node:crypto';
|
||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
|
import { isSlotInPast } from '@/lib/slot-validator';
|
||||||
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
|
||||||
import type {
|
import type {
|
||||||
|
CancelPublicBookingInput,
|
||||||
CreatePublicBookingInput,
|
CreatePublicBookingInput,
|
||||||
DayOfWeek,
|
DayOfWeek,
|
||||||
PublicAvailabilityQuery,
|
PublicAvailabilityQuery,
|
||||||
@@ -42,7 +44,6 @@ export class PublicBookingServiceError extends Error {
|
|||||||
|
|
||||||
constructor(message: string, status: 400 | 403 | 404 | 409) {
|
constructor(message: string, status: 400 | 403 | 404 | 409) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'PublicBookingServiceError';
|
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,7 +277,7 @@ function mapBookingResponse(input: {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
customerEmail: string | null;
|
customerEmail: string;
|
||||||
price: number;
|
price: number;
|
||||||
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
|
||||||
court: {
|
court: {
|
||||||
@@ -306,7 +307,7 @@ function mapBookingResponse(input: {
|
|||||||
endTime: input.endTime,
|
endTime: input.endTime,
|
||||||
customerName: input.customerName,
|
customerName: input.customerName,
|
||||||
customerPhone: input.customerPhone,
|
customerPhone: input.customerPhone,
|
||||||
customerEmail: input.customerEmail ?? undefined,
|
customerEmail: input.customerEmail,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
price: input.price,
|
price: input.price,
|
||||||
createdAt: input.createdAt.toISOString(),
|
createdAt: input.createdAt.toISOString(),
|
||||||
@@ -386,7 +387,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
slug: booking.court.sport.slug,
|
slug: booking.court.sport.slug,
|
||||||
},
|
},
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
customerEmail: booking.customerEmail ?? undefined,
|
customerEmail: booking.customerEmail,
|
||||||
createdAt: booking.createdAt.toISOString(),
|
createdAt: booking.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -574,6 +575,10 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSlotInPast(bookingDate, input.startTime, selectedCourt.slotDurationMinutes)) {
|
||||||
|
throw new PublicBookingServiceError('No se pueden crear reservas en el pasado.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const booking = await db.$transaction(async (tx) => {
|
const booking = await db.$transaction(async (tx) => {
|
||||||
@@ -631,7 +636,7 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
endTime: selectedSlot.endTime,
|
endTime: selectedSlot.endTime,
|
||||||
customerName: input.customerName.trim(),
|
customerName: input.customerName.trim(),
|
||||||
customerPhone: input.customerPhone.trim(),
|
customerPhone: input.customerPhone.trim(),
|
||||||
customerEmail: input.customerEmail?.trim() || null,
|
customerEmail: input.customerEmail.trim(),
|
||||||
status: 'CONFIRMED',
|
status: 'CONFIRMED',
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -712,3 +717,108 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
|
|||||||
409
|
409
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelPublicBooking(complexSlug: string, input: CancelPublicBookingInput) {
|
||||||
|
const normalizedBookingCode = input.bookingCode.toUpperCase();
|
||||||
|
const booking = await db.courtBooking.findFirst({
|
||||||
|
where: {
|
||||||
|
bookingCode: normalizedBookingCode,
|
||||||
|
court: {
|
||||||
|
complex: {
|
||||||
|
complexSlug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
basePrice: true,
|
||||||
|
priceRules: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
|
sport: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
complex: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
complexName: true,
|
||||||
|
complexSlug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status !== 'CONFIRMED') {
|
||||||
|
throw new PublicBookingServiceError(
|
||||||
|
'Solo se pueden cancelar reservas en estado confirmada.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.customerPhone !== input.customerPhone.trim()) {
|
||||||
|
throw new PublicBookingServiceError('Los datos ingresados no coinciden con la reserva.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = formatIsoDate(booking.bookingDate);
|
||||||
|
const { dayOfWeek } = parseIsoDate(date);
|
||||||
|
const price = resolveSlotPrice(booking.court, dayOfWeek, {
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.courtBooking.delete({
|
||||||
|
where: { id: booking.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapBookingResponse({
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
bookingDate: booking.bookingDate,
|
||||||
|
createdAt: booking.createdAt,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
customerName: booking.customerName,
|
||||||
|
customerPhone: booking.customerPhone,
|
||||||
|
customerEmail: booking.customerEmail,
|
||||||
|
price,
|
||||||
|
status: 'CANCELLED',
|
||||||
|
court: {
|
||||||
|
id: booking.court.id,
|
||||||
|
name: booking.court.name,
|
||||||
|
complexId: booking.court.complex.id,
|
||||||
|
complexName: booking.court.complex.complexName,
|
||||||
|
complexSlug: booking.court.complex.complexSlug,
|
||||||
|
sport: booking.court.sport,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { sendMail } from '@/lib/mailer';
|
|||||||
|
|
||||||
type BookingEmailInput = {
|
type BookingEmailInput = {
|
||||||
bookingCode: string;
|
bookingCode: string;
|
||||||
|
complexSlug?: string;
|
||||||
complexName: string;
|
complexName: string;
|
||||||
date: string;
|
date: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
@@ -14,7 +15,7 @@ type BookingEmailInput = {
|
|||||||
courtName: string;
|
courtName: string;
|
||||||
sportName: string;
|
sportName: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerEmail?: string;
|
customerEmail: string;
|
||||||
price?: number;
|
price?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export async function sendBookingConfirmation(data: BookingEmailInput) {
|
|||||||
|
|
||||||
const html = bookingConfirmationHtml({
|
const html = bookingConfirmationHtml({
|
||||||
bookingCode: data.bookingCode,
|
bookingCode: data.bookingCode,
|
||||||
|
complexSlug: data.complexSlug,
|
||||||
complexName: data.complexName,
|
complexName: data.complexName,
|
||||||
date: data.date,
|
date: data.date,
|
||||||
startTime: data.startTime,
|
startTime: data.startTime,
|
||||||
|
|||||||
90
apps/backend/test/public-booking/slot-validator.test.ts
Normal file
90
apps/backend/test/public-booking/slot-validator.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { isSlotInPast } from '@/lib/slot-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a bookingDate like parseIsoDate does (UTC midnight).
|
||||||
|
*/
|
||||||
|
function bookingDate(year: number, month: number, day: number): Date {
|
||||||
|
return new Date(Date.UTC(year, month - 1, day));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a "now" date at a specific local time.
|
||||||
|
* Using local date constructor guarantees the date parts
|
||||||
|
* (getFullYear, getMonth, getDate) match the arguments
|
||||||
|
* regardless of the test runner's timezone.
|
||||||
|
*/
|
||||||
|
function localDate(year: number, month: number, day: number, hours: number, minutes: number): Date {
|
||||||
|
return new Date(year, month - 1, day, hours, minutes, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('future date — always allowed', () => {
|
||||||
|
const future = bookingDate(2027, 6, 10); // 2027-06-10
|
||||||
|
const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
|
||||||
|
|
||||||
|
expect(isSlotInPast(future, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('past date — rejected', () => {
|
||||||
|
const past = bookingDate(2025, 6, 8); // 2025-06-08
|
||||||
|
const now = localDate(2026, 6, 8, 12, 0); // 2026-06-08 12:00
|
||||||
|
|
||||||
|
expect(isSlotInPast(past, '10:00', 60, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot not started yet — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8); // 2026-06-08
|
||||||
|
const now = localDate(2026, 6, 8, 9, 0); // 09:00, slot empieza a las 10:00
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot in progress less than half elapsed — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 10); // 10:10, slot 10:00-11:00 (60 min), 10 min elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot in progress more than half elapsed — rejected', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 40); // 10:40, slot 10:00-11:00 (60 min), 40 min elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot already ended — rejected', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 11, 1); // 11:01, slot 10:00-11:00 terminó
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, exactly half elapsed — allowed (inclusive boundary)', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 30); // 10:30, slot 10:00-11:00 (60 min), exactamente 30 min
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('today, slot starts exactly now — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 10, 0); // 10:00 exacto
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '10:00', 60, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('90 min slot with 10 min elapsed — allowed', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 12, 10); // 12:10, slot 12:00-13:30, 10/90 elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '12:00', 90, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('90 min slot with 50 min elapsed — rejected', () => {
|
||||||
|
const today = bookingDate(2026, 6, 8);
|
||||||
|
const now = localDate(2026, 6, 8, 12, 50); // 12:50, slot 12:00-13:30, 50/90 elapsed
|
||||||
|
|
||||||
|
expect(isSlotInPast(today, '12:00', 90, now)).toBe(true);
|
||||||
|
});
|
||||||
@@ -1,20 +1,6 @@
|
|||||||
import { beforeEach, expect, mock, test } from 'bun:test';
|
import { beforeEach, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import { createPrismaMock } from 'bun-mock-prisma';
|
import { prismaMock, uuidV7Mock } from '../support/prisma.mock';
|
||||||
|
|
||||||
import type { PrismaClient } from '@/generated/prisma/client';
|
|
||||||
import type { PrismaClientMock } from 'bun-mock-prisma';
|
|
||||||
|
|
||||||
const prismaMock = createPrismaMock<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
|
||||||
|
|
||||||
mock.module('@/lib/prisma', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
db: {
|
|
||||||
sport: prismaMock.sport,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { uuidV7Mock } = await import('../support/prisma.mock');
|
|
||||||
|
|
||||||
const { createSport, getSportById, listSports, updateSport } = await import(
|
const { createSport, getSportById, listSports, updateSport } = await import(
|
||||||
'@/modules/sport/services/sport.service'
|
'@/modules/sport/services/sport.service'
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const dbMock = {
|
|||||||
complex: prismaMock.complex,
|
complex: prismaMock.complex,
|
||||||
user: prismaMock.user,
|
user: prismaMock.user,
|
||||||
complexInvitation: prismaMock.complexInvitation,
|
complexInvitation: prismaMock.complexInvitation,
|
||||||
|
sport: prismaMock.sport,
|
||||||
$transaction: transactionMock,
|
$transaction: transactionMock,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ interface CreateManualBookingPayload {
|
|||||||
startTime: string;
|
startTime: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
customerEmail?: string;
|
customerEmail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BookingContextValue {
|
interface BookingContextValue {
|
||||||
@@ -210,20 +210,14 @@ function buildSegmentsForCourt(
|
|||||||
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
|
function getSummary(schedules: BookingCourtSchedule[]): BookingSummary {
|
||||||
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
|
const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0);
|
||||||
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
|
const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0);
|
||||||
const maintenanceSlots = schedules.reduce(
|
const totalSegments = freeSlots + reservedSlots || 1;
|
||||||
(total, schedule) => total + schedule.metrics.maintenance,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalReservations: reservedSlots + maintenanceSlots + freeSlots,
|
totalReservations: freeSlots + reservedSlots,
|
||||||
freeSlots,
|
freeSlots,
|
||||||
reservedSlots,
|
reservedSlots,
|
||||||
maintenanceSlots,
|
|
||||||
freePercent: Math.round((freeSlots / totalSegments) * 100),
|
freePercent: Math.round((freeSlots / totalSegments) * 100),
|
||||||
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
|
reservedPercent: Math.round((reservedSlots / totalSegments) * 100),
|
||||||
maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +269,6 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
const metrics = {
|
const metrics = {
|
||||||
free: segments.filter((segment) => segment.status === 'free').length,
|
free: segments.filter((segment) => segment.status === 'free').length,
|
||||||
reserved: segments.filter((segment) => segment.status === 'reserved').length,
|
reserved: segments.filter((segment) => segment.status === 'reserved').length,
|
||||||
maintenance: segments.filter((segment) => segment.status === 'maintenance').length,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { court, segments, metrics };
|
return { court, segments, metrics };
|
||||||
@@ -316,7 +309,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
|||||||
startTime: payload.startTime,
|
startTime: payload.startTime,
|
||||||
customerName: payload.customerName,
|
customerName: payload.customerName,
|
||||||
customerPhone: payload.customerPhone,
|
customerPhone: payload.customerPhone,
|
||||||
customerEmail: payload.customerEmail || undefined,
|
customerEmail: payload.customerEmail,
|
||||||
}),
|
}),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
|
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { AdminBooking, Court } from '@repo/api-contract';
|
import type { AdminBooking, Court } from '@repo/api-contract';
|
||||||
|
|
||||||
export type BookingViewMode = 'panel' | 'status';
|
export type BookingViewMode = 'panel' | 'status';
|
||||||
export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance';
|
export type BookingStatusFilter = 'all' | 'free' | 'reserved';
|
||||||
export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance';
|
export type BookingSegmentStatus = 'free' | 'reserved';
|
||||||
|
|
||||||
export interface BookingTimeRange {
|
export interface BookingTimeRange {
|
||||||
start: string;
|
start: string;
|
||||||
@@ -13,16 +13,13 @@ export interface BookingSummary {
|
|||||||
totalReservations: number;
|
totalReservations: number;
|
||||||
freeSlots: number;
|
freeSlots: number;
|
||||||
reservedSlots: number;
|
reservedSlots: number;
|
||||||
maintenanceSlots: number;
|
|
||||||
freePercent: number;
|
freePercent: number;
|
||||||
reservedPercent: number;
|
reservedPercent: number;
|
||||||
maintenancePercent: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingCourtMetrics {
|
export interface BookingCourtMetrics {
|
||||||
free: number;
|
free: number;
|
||||||
reserved: number;
|
reserved: number;
|
||||||
maintenance: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingTimelineSegment {
|
export interface BookingTimelineSegment {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const bookingFormSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.min(6, 'Ingresa un telefono válido.')
|
.min(6, 'Ingresa un telefono válido.')
|
||||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||||
customerEmail: z.string().email('Ingresá un email válido.').optional().or(z.literal('')),
|
customerEmail: z.string().email('Ingresá un email válido.').or(z.literal('')),
|
||||||
});
|
});
|
||||||
|
|
||||||
type BookingForm = z.infer<typeof bookingFormSchema>;
|
type BookingForm = z.infer<typeof bookingFormSchema>;
|
||||||
@@ -156,7 +156,7 @@ export function BookingCreateDialog() {
|
|||||||
startTime,
|
startTime,
|
||||||
customerName: values.customerName,
|
customerName: values.customerName,
|
||||||
customerPhone: values.customerPhone,
|
customerPhone: values.customerPhone,
|
||||||
customerEmail: values.customerEmail || undefined,
|
customerEmail: values.customerEmail,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,17 +166,21 @@ export function BookingCreateDialog() {
|
|||||||
onOpenChange={(open) => !open && closeCreateBooking()}
|
onOpenChange={(open) => !open && closeCreateBooking()}
|
||||||
>
|
>
|
||||||
<ResponsiveDialogContent
|
<ResponsiveDialogContent
|
||||||
className="data-[variant=dialog]:max-w-lg"
|
className="group/create-booking data-[variant=dialog]:max-w-lg data-[variant=drawer]:!h-[92svh] data-[variant=drawer]:!max-h-[92svh] data-[variant=drawer]:overflow-hidden data-[variant=drawer]:px-0 data-[variant=drawer]:pb-0"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<ResponsiveDialogHeader>
|
<ResponsiveDialogHeader className="group-data-[variant=drawer]/create-booking:shrink-0 group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pb-3">
|
||||||
<ResponsiveDialogTitle>Nueva reserva</ResponsiveDialogTitle>
|
<ResponsiveDialogTitle>Nueva reserva</ResponsiveDialogTitle>
|
||||||
<ResponsiveDialogDescription>
|
<ResponsiveDialogDescription>
|
||||||
Crea un turno para atención telefónica o mostrador.
|
Crea un turno para atención telefónica o mostrador.
|
||||||
</ResponsiveDialogDescription>
|
</ResponsiveDialogDescription>
|
||||||
</ResponsiveDialogHeader>
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
<form id="booking-create-form" className="grid gap-4" onSubmit={handleSubmit(onSubmit)}>
|
<form
|
||||||
|
id="booking-create-form"
|
||||||
|
className="grid gap-4 group-data-[variant=drawer]/create-booking:min-h-0 group-data-[variant=drawer]/create-booking:flex-1 group-data-[variant=drawer]/create-booking:overflow-y-auto group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pb-4"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Fecha</FieldLabel>
|
<FieldLabel>Fecha</FieldLabel>
|
||||||
@@ -294,7 +298,7 @@ export function BookingCreateDialog() {
|
|||||||
{createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
|
{createBookingError && <p className="text-sm text-destructive">{createBookingError}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ResponsiveDialogFooter>
|
<ResponsiveDialogFooter className="group-data-[variant=drawer]/create-booking:shrink-0 group-data-[variant=drawer]/create-booking:border-t group-data-[variant=drawer]/create-booking:border-border/70 group-data-[variant=drawer]/create-booking:bg-popover/95 group-data-[variant=drawer]/create-booking:px-4 group-data-[variant=drawer]/create-booking:pt-3 group-data-[variant=drawer]/create-booking:pb-[calc(12px+env(safe-area-inset-bottom))]">
|
||||||
<ResponsiveDialogClose asChild>
|
<ResponsiveDialogClose asChild>
|
||||||
<Button variant="outline">Cancelar</Button>
|
<Button variant="outline">Cancelar</Button>
|
||||||
</ResponsiveDialogClose>
|
</ResponsiveDialogClose>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react';
|
import { CalendarDays, Download, 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';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export function BookingDaySummary() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label="Total de bloques"
|
label="Total de bloques"
|
||||||
value={summary.totalReservations}
|
value={summary.totalReservations}
|
||||||
@@ -35,13 +35,6 @@ export function BookingDaySummary() {
|
|||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
className="border-reserved/35 bg-reserved/10 text-reserved"
|
className="border-reserved/35 bg-reserved/10 text-reserved"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
|
||||||
label="Mantenimiento"
|
|
||||||
value={summary.maintenanceSlots}
|
|
||||||
detail={`${summary.maintenancePercent}% del total`}
|
|
||||||
icon={Drill}
|
|
||||||
className="border-maintenance/35 bg-maintenance/10 text-maintenance"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -77,7 +70,6 @@ export function BookingQuickActions() {
|
|||||||
useBooking();
|
useBooking();
|
||||||
const todayIso = toIsoDateLocal(new Date());
|
const todayIso = toIsoDateLocal(new Date());
|
||||||
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
|
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
|
||||||
const isViewingMaintenance = selectedStatus === 'maintenance';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
||||||
@@ -95,15 +87,6 @@ export function BookingQuickActions() {
|
|||||||
<CalendarDays className="size-4 text-muted-foreground" />
|
<CalendarDays className="size-4 text-muted-foreground" />
|
||||||
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
|
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedStatus(isViewingMaintenance ? 'all' : 'maintenance')}
|
|
||||||
aria-pressed={isViewingMaintenance}
|
|
||||||
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 aria-pressed:border-maintenance/50 aria-pressed:bg-maintenance/10"
|
|
||||||
>
|
|
||||||
<Drill className="size-4 text-maintenance" />
|
|
||||||
{isViewingMaintenance ? 'Ver todos los estados' : 'Ver mantenimiento'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={exportDayReport}
|
onClick={exportDayReport}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
Wrench,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
@@ -352,18 +351,33 @@ function MobilePanelHeader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MobileFilters() {
|
function MobileFilters() {
|
||||||
const { selectedDate, setSelectedDate } = useBooking();
|
const { selectedDate, setSelectedDate, moveSelectedDate } = useBooking();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="grid grid-cols-[52px_1fr_52px] gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 rounded-lg"
|
||||||
|
onClick={() => moveSelectedDate(-1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-5" />
|
||||||
|
</Button>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={fromIsoDateLocal(selectedDate)}
|
value={fromIsoDateLocal(selectedDate)}
|
||||||
onChange={(date) =>
|
onChange={(date) =>
|
||||||
setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date()))
|
setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date()))
|
||||||
}
|
}
|
||||||
className="h-12 w-full justify-center rounded-lg border-border/70 bg-card/75 px-4 text-sm font-medium"
|
className="h-12 rounded-lg border-border/70 bg-card/75 text-sm"
|
||||||
placeholder="Fecha"
|
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 rounded-lg"
|
||||||
|
onClick={() => moveSelectedDate(1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -385,11 +399,6 @@ function MobileSummary() {
|
|||||||
label="Reservados"
|
label="Reservados"
|
||||||
className="bg-reserved/15 text-reserved"
|
className="bg-reserved/15 text-reserved"
|
||||||
/>
|
/>
|
||||||
<SummaryPill
|
|
||||||
value={summary.maintenanceSlots}
|
|
||||||
label="Mantenim."
|
|
||||||
className="bg-maintenance/15 text-maintenance"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -411,7 +420,6 @@ function MobileStatusTab() {
|
|||||||
{ value: 'all', label: 'Todos' },
|
{ value: 'all', label: 'Todos' },
|
||||||
{ value: 'free', label: 'Libres' },
|
{ value: 'free', label: 'Libres' },
|
||||||
{ value: 'reserved', label: 'Reservados' },
|
{ value: 'reserved', label: 'Reservados' },
|
||||||
{ value: 'maintenance', label: 'Mantenimiento' },
|
|
||||||
] as const;
|
] as const;
|
||||||
const timeRangeOptions = [
|
const timeRangeOptions = [
|
||||||
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' },
|
{ label: '08:00 - 22:00', start: '08:00', end: '22:00' },
|
||||||
@@ -835,14 +843,10 @@ function MobileSegmentRow({
|
|||||||
) : (
|
) : (
|
||||||
<div className="max-w-[132px] text-right">
|
<div className="max-w-[132px] text-right">
|
||||||
<p className="truncate text-sm font-medium">
|
<p className="truncate text-sm font-medium">
|
||||||
{segment.booking?.customerName ?? 'Mantenimiento'}
|
{segment.booking?.customerName ?? 'Sin información'}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
<p className="mt-2 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
{segment.status === 'maintenance' ? (
|
<Users className="size-4" />
|
||||||
<Wrench className="size-4" />
|
|
||||||
) : (
|
|
||||||
<Users className="size-4" />
|
|
||||||
)}
|
|
||||||
{segment.booking?.customerPhone ?? 'Personal'}
|
{segment.booking?.customerPhone ?? 'Personal'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -853,23 +857,21 @@ function MobileSegmentRow({
|
|||||||
|
|
||||||
function SegmentStatusLine({ segment }: { segment: BookingTimelineSegment }) {
|
function SegmentStatusLine({ segment }: { segment: BookingTimelineSegment }) {
|
||||||
const status = segment.status;
|
const status = segment.status;
|
||||||
const label = status === 'free' ? 'Libre' : status === 'reserved' ? 'Reservado' : 'Mantenimiento';
|
const label = status === 'free' ? 'Libre' : 'Reservado';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-2 inline-flex items-center gap-2 text-sm',
|
'mt-2 inline-flex items-center gap-2 text-sm',
|
||||||
status === 'free' && 'text-primary',
|
status === 'free' && 'text-primary',
|
||||||
status === 'reserved' && 'text-reserved',
|
status === 'reserved' && 'text-reserved'
|
||||||
status === 'maintenance' && 'text-maintenance'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-3 rounded-full',
|
'size-3 rounded-full',
|
||||||
status === 'free' && 'bg-primary',
|
status === 'free' && 'bg-primary',
|
||||||
status === 'reserved' && 'bg-reserved',
|
status === 'reserved' && 'bg-reserved'
|
||||||
status === 'maintenance' && 'bg-maintenance'
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{label}
|
{label}
|
||||||
@@ -882,7 +884,6 @@ function StatusLegend() {
|
|||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
<LegendItem label="Libre" className="bg-primary" />
|
<LegendItem label="Libre" className="bg-primary" />
|
||||||
<LegendItem label="Reservado" className="bg-reserved" />
|
<LegendItem label="Reservado" className="bg-reserved" />
|
||||||
<LegendItem label="Mantenimiento" className="bg-maintenance" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -914,9 +915,7 @@ function MiniSlot({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'min-w-0 rounded-lg border px-1.5 py-2 text-center transition active:scale-[0.98] disabled:opacity-60',
|
'min-w-0 rounded-lg border px-1.5 py-2 text-center transition active:scale-[0.98] disabled:opacity-60',
|
||||||
segment.status === 'free' && 'border-primary/50 bg-primary/15 text-primary',
|
segment.status === 'free' && 'border-primary/50 bg-primary/15 text-primary',
|
||||||
segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved',
|
segment.status === 'reserved' && 'border-reserved/50 bg-reserved/15 text-reserved'
|
||||||
segment.status === 'maintenance' &&
|
|
||||||
'border-maintenance/50 bg-maintenance/15 text-maintenance'
|
|
||||||
)}
|
)}
|
||||||
disabled={!isActionable}
|
disabled={!isActionable}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -966,10 +965,6 @@ function MetricDots({ schedule }: { schedule: BookingCourtSchedule }) {
|
|||||||
<span className="size-3 rounded-full bg-reserved" />
|
<span className="size-3 rounded-full bg-reserved" />
|
||||||
{schedule.metrics.reserved}
|
{schedule.metrics.reserved}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1 text-maintenance">
|
|
||||||
<span className="size-3 rounded-full bg-maintenance" />
|
|
||||||
{schedule.metrics.maintenance}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const items = [
|
const items = [
|
||||||
{ label: 'Libre', className: 'bg-primary' },
|
{ label: 'Libre', className: 'bg-primary' },
|
||||||
{ label: 'Reservado', className: 'bg-reserved' },
|
{ label: 'Reservado', className: 'bg-reserved' },
|
||||||
{ label: 'Mantenimiento', className: 'bg-maintenance' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BookingStatusLegend() {
|
export function BookingStatusLegend() {
|
||||||
|
|||||||
@@ -215,10 +215,6 @@ function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) {
|
|||||||
<span className="size-2 rounded-full bg-reserved" />
|
<span className="size-2 rounded-full bg-reserved" />
|
||||||
{schedule.metrics.reserved}
|
{schedule.metrics.reserved}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<span className="size-2 rounded-full bg-maintenance" />
|
|
||||||
{schedule.metrics.maintenance}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,8 +243,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
|
'border-primary/70 bg-primary/20 text-primary hover:bg-primary/25',
|
||||||
segment.status === 'reserved' &&
|
segment.status === 'reserved' &&
|
||||||
'border-reserved/70 bg-reserved/80 text-reserved-foreground',
|
'border-reserved/70 bg-reserved/80 text-reserved-foreground',
|
||||||
segment.status === 'maintenance' &&
|
|
||||||
'border-maintenance/80 bg-maintenance/75 text-maintenance-foreground',
|
|
||||||
segment.booking?.status === 'COMPLETED' &&
|
segment.booking?.status === 'COMPLETED' &&
|
||||||
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
|
'border-reserved/70 bg-reserved/25 dark:text-reserved-foreground text-gray-600 line-through',
|
||||||
segment.booking?.status === 'NOSHOW' &&
|
segment.booking?.status === 'NOSHOW' &&
|
||||||
@@ -300,7 +295,7 @@ function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: Booki
|
|||||||
</div>
|
</div>
|
||||||
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
|
<span className="mt-1 flex items-center gap-1 truncate opacity-90">
|
||||||
<Users className="size-3" />
|
<Users className="size-3" />
|
||||||
{segment.booking?.customerName ?? 'Mantenimiento'}
|
{segment.booking?.customerName ?? 'Sin información'}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Clock } from 'lucide-react';
|
||||||
import { useBooking } from '../booking-provider';
|
import { useBooking } from '../booking-provider';
|
||||||
import type { BookingStatusFilter } from '../booking.types';
|
import type { BookingStatusFilter } from '../booking.types';
|
||||||
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
|
import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time';
|
||||||
@@ -24,7 +24,6 @@ const statusOptions: Array<{ value: BookingStatusFilter; label: string }> = [
|
|||||||
{ value: 'all', label: 'Todos' },
|
{ value: 'all', label: 'Todos' },
|
||||||
{ value: 'free', label: 'Libre' },
|
{ value: 'free', label: 'Libre' },
|
||||||
{ value: 'reserved', label: 'Reservado' },
|
{ value: 'reserved', label: 'Reservado' },
|
||||||
{ value: 'maintenance', label: 'Mantenimiento' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BookingToolbar() {
|
export function BookingToolbar() {
|
||||||
@@ -46,7 +45,7 @@ export function BookingToolbar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border bg-card/85 p-4 shadow-sm">
|
<section className="rounded-lg border bg-card/85 p-4 shadow-sm">
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr_auto] xl:items-end">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.6fr_1.1fr] xl:items-end">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Deporte</FieldLabel>
|
<FieldLabel>Deporte</FieldLabel>
|
||||||
<Select value={selectedSportId} onValueChange={setSelectedSportId}>
|
<Select value={selectedSportId} onValueChange={setSelectedSportId}>
|
||||||
@@ -133,11 +132,6 @@ export function BookingToolbar() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Button variant="outline" className="xl:self-end">
|
|
||||||
<SlidersHorizontal className="size-4" />
|
|
||||||
Filtros avanzados
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useBooking } from '../booking-provider';
|
import { useBooking } from '../booking-provider';
|
||||||
|
|
||||||
function formatDate(date: string | undefined) {
|
function formatDate(date: string | undefined) {
|
||||||
@@ -70,6 +71,7 @@ export function BookingToolsDialog() {
|
|||||||
useBooking();
|
useBooking();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const booking = selectedSegment?.booking;
|
const booking = selectedSegment?.booking;
|
||||||
|
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
|
||||||
|
|
||||||
const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? {
|
const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? {
|
||||||
label: booking?.status,
|
label: booking?.status,
|
||||||
@@ -93,149 +95,211 @@ export function BookingToolsDialog() {
|
|||||||
closeBookingTools();
|
closeBookingTools();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelConfirmDialog = (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={confirmCancelOpen}
|
||||||
|
onOpenChange={(open) => !open && setConfirmCancelOpen(false)}
|
||||||
|
>
|
||||||
|
<ResponsiveDialogContent>
|
||||||
|
<ResponsiveDialogHeader>
|
||||||
|
<ResponsiveDialogTitle>¿Cancelar reserva?</ResponsiveDialogTitle>
|
||||||
|
<ResponsiveDialogDescription>
|
||||||
|
Esta acción no se puede deshacer. Se eliminará la reserva y la cancha volverá a estar
|
||||||
|
disponible.
|
||||||
|
</ResponsiveDialogDescription>
|
||||||
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-lg border bg-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Cliente</span>
|
||||||
|
<span className="text-sm font-medium">{booking?.customerName ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Cancha</span>
|
||||||
|
<span className="text-sm font-medium">{booking?.courtName ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Fecha</span>
|
||||||
|
<span className="text-sm font-medium">{formatDate(booking?.date) || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Horario</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{booking?.startTime ?? '--:--'} a {booking?.endTime ?? '--:--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveDialogFooter>
|
||||||
|
<ResponsiveDialogClose asChild>
|
||||||
|
<Button variant="outline">Volver</Button>
|
||||||
|
</ResponsiveDialogClose>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmCancelOpen(false);
|
||||||
|
updateStatus('CANCELLED');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sí, cancelar reserva
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={bookingToolsOpen}
|
||||||
|
onOpenChange={(open) => !open && closeBookingTools()}
|
||||||
|
>
|
||||||
|
<ResponsiveDialogContent
|
||||||
|
className="data-[variant=drawer]:!h-[92dvh] data-[variant=drawer]:!max-h-[92dvh] data-[variant=drawer]:overflow-hidden data-[variant=drawer]:px-0 data-[variant=drawer]:pb-0"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<MobileBookingToolsSheet
|
||||||
|
status={status}
|
||||||
|
onComplete={() => updateStatus('COMPLETED')}
|
||||||
|
onCancel={() => setConfirmCancelOpen(true)}
|
||||||
|
onReminder={sendWhatsappReminder}
|
||||||
|
onNoShow={() => updateStatus('NOSHOW')}
|
||||||
|
/>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
{cancelConfirmDialog}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<ResponsiveDialog
|
<ResponsiveDialog
|
||||||
open={bookingToolsOpen}
|
open={bookingToolsOpen}
|
||||||
onOpenChange={(open) => !open && closeBookingTools()}
|
onOpenChange={(open) => !open && closeBookingTools()}
|
||||||
>
|
>
|
||||||
<ResponsiveDialogContent
|
<ResponsiveDialogContent
|
||||||
className="data-[variant=drawer]:!h-[92dvh] data-[variant=drawer]:!max-h-[92dvh] data-[variant=drawer]:overflow-hidden data-[variant=drawer]:px-0 data-[variant=drawer]:pb-0"
|
className="data-[variant=dialog]:max-w-5xl data-[variant=drawer]:max-h-[92dvh] data-[variant=drawer]:overflow-y-auto data-[variant=drawer]:px-4 data-[variant=drawer]:pb-5"
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<MobileBookingToolsSheet
|
<ResponsiveDialogHeader className="data-[variant=drawer]:px-0 data-[variant=drawer]:text-left">
|
||||||
status={status}
|
<ResponsiveDialogTitle>Detalles de la reserva</ResponsiveDialogTitle>
|
||||||
onComplete={() => updateStatus('COMPLETED')}
|
<ResponsiveDialogDescription>
|
||||||
onCancel={() => updateStatus('CANCELLED')}
|
Administra la reserva y su estado.
|
||||||
onReminder={sendWhatsappReminder}
|
</ResponsiveDialogDescription>
|
||||||
onNoShow={() => updateStatus('NOSHOW')}
|
</ResponsiveDialogHeader>
|
||||||
/>
|
|
||||||
|
<section className="mx-0 px-0 pt-3 pb-3 sm:-mx-6 sm:px-6 sm:pb-5">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-0">
|
||||||
|
<DetailItem
|
||||||
|
icon={<MapPin className="h-5 w-5" />}
|
||||||
|
label="Cancha"
|
||||||
|
value={selectedSegment?.booking?.courtName ?? '-'}
|
||||||
|
helper={selectedSegment?.booking?.sport?.name}
|
||||||
|
withDivider
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<BadgeCheck className="h-5 w-5" />}
|
||||||
|
label="Estado actual"
|
||||||
|
value={status.label}
|
||||||
|
valueClassName="text-emerald-400 uppercase dark:text-emerald-500"
|
||||||
|
withDivider
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<User className="h-5 w-5" />}
|
||||||
|
label="Cliente"
|
||||||
|
value={selectedSegment?.booking?.customerName ?? '-'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
label="Fecha"
|
||||||
|
value={`${formatDate(selectedSegment?.booking?.date)}`}
|
||||||
|
withDivider
|
||||||
|
className="sm:pt-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
label="Hora"
|
||||||
|
value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`}
|
||||||
|
withDivider
|
||||||
|
className="sm:pt-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailItem
|
||||||
|
icon={<Phone className="h-5 w-5" />}
|
||||||
|
label="Teléfono"
|
||||||
|
value={selectedSegment?.booking?.customerPhone ?? '-'}
|
||||||
|
className="sm:pt-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator className="dark:bg-slate-800 bg-slate-300" />
|
||||||
|
|
||||||
|
{booking?.status !== 'COMPLETED' && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-slate-100">Acciones disponibles</h3>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Elegí qué hacer según lo que ocurrió con el cliente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<ActionButton
|
||||||
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
|
title="Completar reserva"
|
||||||
|
description="El cliente se presentó para usar la cancha."
|
||||||
|
className="border-emerald-500/40 bg-emerald-500/10 text-lg text-emerald-600 hover:bg-emerald-500/15 hover:text-emerald-800"
|
||||||
|
onClick={() => {
|
||||||
|
updateStatus('COMPLETED');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon={<XCircle className="h-5 w-5" />}
|
||||||
|
title="Cancelar reserva"
|
||||||
|
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
|
||||||
|
className="border-red-500/40 bg-red-500/10 text-lg text-red-600 hover:bg-red-500/15 hover:text-red-800"
|
||||||
|
onClick={() => setConfirmCancelOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon={<MessageCircle className="h-5 w-5" />}
|
||||||
|
title="Enviar recordatorio"
|
||||||
|
description="Enviar un mensaje por WhatsApp al cliente."
|
||||||
|
className="border-sky-500/40 bg-sky-500/10 text-lg text-sky-600 hover:bg-sky-500/15 hover:text-sky-800"
|
||||||
|
onClick={sendWhatsappReminder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon={<AlertTriangle className="h-5 w-5" />}
|
||||||
|
title="Marcar como No Show"
|
||||||
|
description="El cliente no vino y no canceló."
|
||||||
|
className="border-amber-500/40 bg-amber-500/10 text-lg text-amber-600 hover:bg-amber-500/15 hover:text-amber-800"
|
||||||
|
onClick={() => {
|
||||||
|
updateStatus('NOSHOW');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ResponsiveDialogFooter className="data-[variant=drawer]:px-0">
|
||||||
|
<ResponsiveDialogClose asChild>
|
||||||
|
<Button variant="outline" className="data-[variant=drawer]:h-11">
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogClose>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
</ResponsiveDialogContent>
|
</ResponsiveDialogContent>
|
||||||
</ResponsiveDialog>
|
</ResponsiveDialog>
|
||||||
);
|
{cancelConfirmDialog}
|
||||||
}
|
</>
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveDialog open={bookingToolsOpen} onOpenChange={(open) => !open && closeBookingTools()}>
|
|
||||||
<ResponsiveDialogContent
|
|
||||||
className="data-[variant=dialog]:max-w-5xl data-[variant=drawer]:max-h-[92dvh] data-[variant=drawer]:overflow-y-auto data-[variant=drawer]:px-4 data-[variant=drawer]:pb-5"
|
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
<ResponsiveDialogHeader className="data-[variant=drawer]:px-0 data-[variant=drawer]:text-left">
|
|
||||||
<ResponsiveDialogTitle>Detalles de la reserva</ResponsiveDialogTitle>
|
|
||||||
<ResponsiveDialogDescription>
|
|
||||||
Administra la reserva y su estado.
|
|
||||||
</ResponsiveDialogDescription>
|
|
||||||
</ResponsiveDialogHeader>
|
|
||||||
|
|
||||||
<section className="mx-0 px-0 pt-3 pb-3 sm:-mx-6 sm:px-6 sm:pb-5">
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-0">
|
|
||||||
<DetailItem
|
|
||||||
icon={<MapPin className="h-5 w-5" />}
|
|
||||||
label="Cancha"
|
|
||||||
value={selectedSegment?.booking?.courtName ?? '-'}
|
|
||||||
helper={selectedSegment?.booking?.sport?.name}
|
|
||||||
withDivider
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailItem
|
|
||||||
icon={<BadgeCheck className="h-5 w-5" />}
|
|
||||||
label="Estado actual"
|
|
||||||
value={status.label}
|
|
||||||
valueClassName="text-emerald-400 uppercase dark:text-emerald-500"
|
|
||||||
withDivider
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailItem
|
|
||||||
icon={<User className="h-5 w-5" />}
|
|
||||||
label="Cliente"
|
|
||||||
value={selectedSegment?.booking?.customerName ?? '-'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailItem
|
|
||||||
icon={<Clock className="h-5 w-5" />}
|
|
||||||
label="Fecha"
|
|
||||||
value={`${formatDate(selectedSegment?.booking?.date)}`}
|
|
||||||
withDivider
|
|
||||||
className="sm:pt-10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailItem
|
|
||||||
icon={<Clock className="h-5 w-5" />}
|
|
||||||
label="Hora"
|
|
||||||
value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`}
|
|
||||||
withDivider
|
|
||||||
className="sm:pt-10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailItem
|
|
||||||
icon={<Phone className="h-5 w-5" />}
|
|
||||||
label="Teléfono"
|
|
||||||
value={selectedSegment?.booking?.customerPhone ?? '-'}
|
|
||||||
className="sm:pt-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Separator className="dark:bg-slate-800 bg-slate-300" />
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-slate-100">Acciones disponibles</h3>
|
|
||||||
<p className="text-sm text-slate-400">
|
|
||||||
Elegí qué hacer según lo que ocurrió con el cliente.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<ActionButton
|
|
||||||
icon={<CheckCircle2 className="h-5 w-5" />}
|
|
||||||
title="Completar reserva"
|
|
||||||
description="El cliente se presentó para usar la cancha."
|
|
||||||
className="border-emerald-500/40 bg-emerald-500/10 text-lg text-emerald-600 hover:bg-emerald-500/15 hover:text-emerald-800"
|
|
||||||
onClick={() => {
|
|
||||||
updateStatus('COMPLETED');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionButton
|
|
||||||
icon={<XCircle className="h-5 w-5" />}
|
|
||||||
title="Cancelar reserva"
|
|
||||||
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
|
|
||||||
className="border-red-500/40 bg-red-500/10 text-lg text-red-600 hover:bg-red-500/15 hover:text-red-800"
|
|
||||||
onClick={() => {
|
|
||||||
updateStatus('CANCELLED');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionButton
|
|
||||||
icon={<MessageCircle className="h-5 w-5" />}
|
|
||||||
title="Enviar recordatorio"
|
|
||||||
description="Enviar un mensaje por WhatsApp al cliente."
|
|
||||||
className="border-sky-500/40 bg-sky-500/10 text-lg text-sky-600 hover:bg-sky-500/15 hover:text-sky-800"
|
|
||||||
onClick={sendWhatsappReminder}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionButton
|
|
||||||
icon={<AlertTriangle className="h-5 w-5" />}
|
|
||||||
title="Marcar como No Show"
|
|
||||||
description="El cliente no vino y no canceló."
|
|
||||||
className="border-amber-500/40 bg-amber-500/10 text-lg text-amber-600 hover:bg-amber-500/15 hover:text-amber-800"
|
|
||||||
onClick={() => {
|
|
||||||
updateStatus('NOSHOW');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<ResponsiveDialogFooter className="data-[variant=drawer]:px-0">
|
|
||||||
<ResponsiveDialogClose asChild>
|
|
||||||
<Button variant="outline" className="data-[variant=drawer]:h-11">
|
|
||||||
Cerrar
|
|
||||||
</Button>
|
|
||||||
</ResponsiveDialogClose>
|
|
||||||
</ResponsiveDialogFooter>
|
|
||||||
</ResponsiveDialogContent>
|
|
||||||
</ResponsiveDialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,42 +385,44 @@ function MobileBookingToolsSheet({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="shrink-0 border-t border-border/70 bg-popover/95 px-4 py-3">
|
{booking?.status !== 'COMPLETED' && (
|
||||||
<h3 className="text-sm font-medium">Acciones</h3>
|
<section className="shrink-0 border-t border-border/70 bg-popover/95 px-4 py-3">
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<h3 className="text-sm font-medium">Acciones</h3>
|
||||||
<MobileActionButton
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
icon={<CheckCircle2 className="size-5" />}
|
<MobileActionButton
|
||||||
label="Completar"
|
icon={<CheckCircle2 className="size-5" />}
|
||||||
className="border-primary/45 bg-primary/12 text-primary"
|
label="Completar"
|
||||||
onClick={onComplete}
|
className="border-primary/45 bg-primary/12 text-primary"
|
||||||
/>
|
onClick={onComplete}
|
||||||
<MobileActionButton
|
/>
|
||||||
icon={<MessageCircle className="size-5" />}
|
<MobileActionButton
|
||||||
label="Recordar"
|
icon={<MessageCircle className="size-5" />}
|
||||||
className="border-reserved/45 bg-reserved/12 text-reserved"
|
label="Recordar"
|
||||||
onClick={onReminder}
|
className="border-reserved/45 bg-reserved/12 text-reserved"
|
||||||
/>
|
onClick={onReminder}
|
||||||
<MobileActionButton
|
/>
|
||||||
icon={<XCircle className="size-5" />}
|
<MobileActionButton
|
||||||
label="Cancelar"
|
icon={<XCircle className="size-5" />}
|
||||||
className="border-destructive/45 bg-destructive/12 text-destructive"
|
label="Cancelar"
|
||||||
onClick={onCancel}
|
className="border-destructive/45 bg-destructive/12 text-destructive"
|
||||||
/>
|
onClick={onCancel}
|
||||||
<MobileActionButton
|
/>
|
||||||
icon={<AlertTriangle className="size-5" />}
|
<MobileActionButton
|
||||||
label="No show"
|
icon={<AlertTriangle className="size-5" />}
|
||||||
className="border-warning/50 bg-warning/12 text-warning"
|
label="No show"
|
||||||
onClick={onNoShow}
|
className="border-warning/50 bg-warning/12 text-warning"
|
||||||
/>
|
onClick={onNoShow}
|
||||||
</div>
|
/>
|
||||||
<ResponsiveDialogFooter className="px-0 pb-0 pt-3">
|
</div>
|
||||||
<ResponsiveDialogClose asChild>
|
<ResponsiveDialogFooter className="px-0 pb-0 pt-3">
|
||||||
<Button variant="outline" className="h-11 w-full rounded-lg">
|
<ResponsiveDialogClose asChild>
|
||||||
Cerrar
|
<Button variant="outline" className="h-11 w-full rounded-lg">
|
||||||
</Button>
|
Cerrar
|
||||||
</ResponsiveDialogClose>
|
</Button>
|
||||||
</ResponsiveDialogFooter>
|
</ResponsiveDialogClose>
|
||||||
</section>
|
</ResponsiveDialogFooter>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,14 @@ export function HomePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function autoSelect() {
|
async function autoSelect() {
|
||||||
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
|
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
|
||||||
await apiClient.complexes.select({ complexId: myComplexesQuery.data[0].id });
|
try {
|
||||||
queryClient.invalidateQueries({ queryKey: ['current-complex'] });
|
const result = await apiClient.complexes.select({
|
||||||
|
complexId: myComplexesQuery.data[0].id,
|
||||||
|
});
|
||||||
|
queryClient.setQueryData(['current-complex'], result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al auto-seleccionar complejo:', error);
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
myComplexesQuery.data &&
|
myComplexesQuery.data &&
|
||||||
myComplexesQuery.data.length > 1 &&
|
myComplexesQuery.data.length > 1 &&
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ type ShareWhatsappButtonProps = {
|
|||||||
confirmation: PublicBookingConfirmation;
|
confirmation: PublicBookingConfirmation;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDateLabel(isoDate: string) {
|
function formatDateLabel(isoDate: string | null | undefined) {
|
||||||
|
if (!isoDate) return '';
|
||||||
const [year, month, day] = isoDate.split('-').map(Number);
|
const [year, month, day] = isoDate.split('-').map(Number);
|
||||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||||
|
|
||||||
@@ -58,19 +59,25 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-12 w-full rounded-xl border-slate-200 bg-white text-sm font-semibold text-slate-900 hover:bg-slate-50 sm:text-base"
|
className="h-12 w-full overflow-hidden rounded-xl border-slate-200 bg-white px-0 text-sm font-semibold text-slate-900 hover:bg-slate-50 sm:px-4 sm:text-base"
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<a href={whatsappUrl} target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
href={whatsappUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Compartir por WhatsApp"
|
||||||
|
title="Compartir por WhatsApp"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-4"
|
className="size-5"
|
||||||
style={{ color: `#${siWhatsapp.hex}` }}
|
style={{ color: `#${siWhatsapp.hex}` }}
|
||||||
>
|
>
|
||||||
<path d={siWhatsapp.path} fill="currentColor" />
|
<path d={siWhatsapp.path} fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
Compartir por WhatsApp
|
<span className="hidden sm:inline">Compartir por WhatsApp</span>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
import PlayzerLogo from '@/assets/playzer-logo-transparent.svg';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import {
|
||||||
|
ResponsiveDialog,
|
||||||
|
ResponsiveDialogClose,
|
||||||
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogDescription,
|
||||||
|
ResponsiveDialogFooter,
|
||||||
|
ResponsiveDialogHeader,
|
||||||
|
ResponsiveDialogTitle,
|
||||||
|
} from '@/components/ui/responsive-dialog';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -12,7 +22,9 @@ import {
|
|||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
Receipt,
|
Receipt,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { ShareWhatsappButton } from './components/share-whatsapp-button';
|
import { ShareWhatsappButton } from './components/share-whatsapp-button';
|
||||||
|
|
||||||
type PublicBookingConfirmationPageProps = {
|
type PublicBookingConfirmationPageProps = {
|
||||||
@@ -21,14 +33,12 @@ type PublicBookingConfirmationPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function extractMessage(error: unknown, fallback: string) {
|
function extractMessage(error: unknown, fallback: string) {
|
||||||
if (error instanceof ApiClientError) {
|
const msg = (error as { message?: string } | undefined)?.message;
|
||||||
return error.message || fallback;
|
return msg || fallback;
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateLabel(isoDate: string) {
|
function formatDateLabel(isoDate: string | undefined | null) {
|
||||||
|
if (!isoDate) return '';
|
||||||
const [year, month, day] = isoDate.split('-').map(Number);
|
const [year, month, day] = isoDate.split('-').map(Number);
|
||||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||||
|
|
||||||
@@ -58,23 +68,70 @@ export function PublicBookingConfirmationPage({
|
|||||||
bookingCode,
|
bookingCode,
|
||||||
}: PublicBookingConfirmationPageProps) {
|
}: PublicBookingConfirmationPageProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
|
const [customerPhone, setCustomerPhone] = useState('');
|
||||||
|
const [cancelError, setCancelError] = useState<string | null>(null);
|
||||||
|
const [cancelledBooking, setCancelledBooking] = useState<{
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
courtName: string;
|
||||||
|
sport: { name: string };
|
||||||
|
bookingCode: string;
|
||||||
|
price: number;
|
||||||
|
complexName: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const confirmationQuery = useQuery({
|
const confirmationQuery = useQuery({
|
||||||
queryKey: ['public-booking-confirmation', complexSlug, bookingCode],
|
queryKey: ['public-booking-confirmation', complexSlug, bookingCode],
|
||||||
queryFn: () => apiClient.publicBookings.getConfirmation(complexSlug, bookingCode),
|
queryFn: () => apiClient.publicBookings.getConfirmation(complexSlug, bookingCode),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: (phone: string) =>
|
||||||
|
apiClient.publicBookings.cancelPublic(complexSlug, {
|
||||||
|
bookingCode,
|
||||||
|
customerPhone: phone,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCancelClick = () => {
|
||||||
|
cancelMutation.reset();
|
||||||
|
setCustomerPhone('');
|
||||||
|
setCancelError(null);
|
||||||
|
setCancelDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmCancel = async () => {
|
||||||
|
setCancelError(null);
|
||||||
|
try {
|
||||||
|
const data = await cancelMutation.mutateAsync(customerPhone);
|
||||||
|
setCancelledBooking(data);
|
||||||
|
setCancelDialogOpen(false);
|
||||||
|
setCustomerPhone('');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('[Cancel]', error);
|
||||||
|
setCancelError(
|
||||||
|
(error as { message?: string } | undefined)?.message ?? 'No pudimos cancelar la reserva.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCancelled = cancelledBooking !== null;
|
||||||
|
const displayData = cancelledBooking ?? confirmationQuery.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[#edf7f4] text-[#111827]">
|
<main className="min-h-screen bg-[#edf7f4] text-[#111827]">
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-4 py-8 sm:px-6">
|
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-4 py-8 sm:px-6">
|
||||||
<section className="w-full rounded-[28px] border border-emerald-950/10 bg-white p-5 shadow-[0_24px_70px_rgba(15,23,42,0.12)] sm:p-8">
|
<section className="w-full rounded-[28px] border border-emerald-950/10 bg-white p-5 shadow-[0_24px_70px_rgba(15,23,42,0.12)] sm:p-8">
|
||||||
{confirmationQuery.isLoading && (
|
{confirmationQuery.isLoading && !cancelledBooking && (
|
||||||
<div className="flex items-center gap-3 text-sm text-slate-500">
|
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
<LoaderCircle className="size-5 animate-spin text-emerald-600" />
|
<LoaderCircle className="size-5 animate-spin text-emerald-600" />
|
||||||
Cargando confirmación...
|
Cargando confirmación...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{confirmationQuery.isError && (
|
{confirmationQuery.isError && !cancelledBooking && (
|
||||||
<div className="flex gap-3 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
<div className="flex gap-3 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
<AlertCircle className="mt-0.5 size-5 shrink-0" />
|
<AlertCircle className="mt-0.5 size-5 shrink-0" />
|
||||||
<p>
|
<p>
|
||||||
@@ -86,21 +143,27 @@ export function PublicBookingConfirmationPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{confirmationQuery.data && (
|
{displayData && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-emerald-700 uppercase">
|
{isCancelled ? (
|
||||||
<CheckCircle2 className="size-4" />
|
<div className="inline-flex items-center gap-2 rounded-full bg-red-50 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-red-600 uppercase">
|
||||||
Reserva confirmada
|
<XCircle className="size-4" />
|
||||||
</div>
|
Reserva cancelada
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-emerald-700 uppercase">
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
Reserva confirmada
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h1 className="mt-3 text-2xl font-bold tracking-tight sm:text-3xl">
|
<h1 className="mt-3 text-2xl font-bold tracking-tight sm:text-3xl">
|
||||||
{confirmationQuery.data.complexName}
|
{displayData.complexName}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-slate-600">
|
<div className="flex items-center">
|
||||||
<img src={PlayzerIcon} alt="Playzer" className="size-8" />
|
<img src={PlayzerLogo} alt="Playzer" className="h-9 w-auto sm:h-10" />
|
||||||
<span className="text-lg font-bold tracking-tight">Playzer</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,17 +175,17 @@ export function PublicBookingConfirmationPage({
|
|||||||
Tu turno
|
Tu turno
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-2xl font-black leading-tight tracking-tight text-slate-950 sm:text-4xl">
|
<p className="mt-3 text-2xl font-black leading-tight tracking-tight text-slate-950 sm:text-4xl">
|
||||||
{formatDateLabel(confirmationQuery.data.date)}
|
{formatDateLabel(displayData.date)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 flex items-center gap-2 text-3xl font-black leading-none text-emerald-700 sm:text-5xl">
|
<p className="mt-2 flex items-center gap-2 text-3xl font-black leading-none text-emerald-700 sm:text-5xl">
|
||||||
<Clock3 className="size-7 sm:size-9" />
|
<Clock3 className="size-7 sm:size-9" />
|
||||||
{confirmationQuery.data.startTime} - {confirmationQuery.data.endTime}
|
{displayData.startTime} - {displayData.endTime}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-emerald-600/20 bg-white/80 px-4 py-3 sm:min-w-40 sm:text-right">
|
<div className="rounded-2xl border border-emerald-600/20 bg-white/80 px-4 py-3 sm:min-w-40 sm:text-right">
|
||||||
<p className="text-xs font-medium text-slate-500">Precio del turno</p>
|
<p className="text-xs font-medium text-slate-500">Precio del turno</p>
|
||||||
<p className="mt-1 text-2xl font-bold text-slate-950">
|
<p className="mt-1 text-2xl font-bold text-slate-950">
|
||||||
{formatBookingPrice(confirmationQuery.data.price)}
|
{formatBookingPrice(displayData.price)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +199,7 @@ export function PublicBookingConfirmationPage({
|
|||||||
Cancha
|
Cancha
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-base font-semibold text-slate-950">
|
<p className="mt-2 text-base font-semibold text-slate-950">
|
||||||
{confirmationQuery.data.courtName} · {confirmationQuery.data.sport.name}
|
{displayData.courtName} · {displayData.sport.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-4">
|
<div className="bg-white p-4">
|
||||||
@@ -145,32 +208,90 @@ export function PublicBookingConfirmationPage({
|
|||||||
Código de reserva
|
Código de reserva
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 font-mono text-lg font-bold tracking-[0.18em] text-slate-950">
|
<p className="mt-2 font-mono text-lg font-bold tracking-[0.18em] text-slate-950">
|
||||||
{confirmationQuery.data.bookingCode}
|
{displayData.bookingCode}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
{isCancelled ? (
|
||||||
<ShareWhatsappButton confirmation={confirmationQuery.data} />
|
<div className="rounded-2xl bg-red-50 p-4 text-center text-sm text-red-700">
|
||||||
<Button
|
Esta reserva fue cancelada. Te enviamos un email con los detalles de la
|
||||||
type="button"
|
cancelación.
|
||||||
className="h-12 w-full rounded-xl bg-emerald-600 text-sm font-semibold text-white hover:bg-emerald-500 sm:text-base"
|
</div>
|
||||||
onClick={() => {
|
) : (
|
||||||
void navigate({
|
<div className="grid grid-cols-[48px_minmax(0,1fr)_minmax(0,1fr)] gap-2 sm:grid-cols-3 sm:gap-3">
|
||||||
to: '/$complexSlug/booking',
|
<ShareWhatsappButton confirmation={confirmationQuery.data!} />
|
||||||
params: { complexSlug },
|
<Button
|
||||||
});
|
type="button"
|
||||||
}}
|
variant="outline"
|
||||||
>
|
className="h-12 w-full rounded-xl border-red-200 text-sm font-semibold text-red-600 hover:bg-red-50 hover:text-red-700 sm:text-base"
|
||||||
Hacer otra reserva
|
onClick={handleCancelClick}
|
||||||
<ArrowRight className="size-4" />
|
>
|
||||||
</Button>
|
Cancelar
|
||||||
</div>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="h-12 w-full rounded-xl bg-emerald-600 text-sm font-semibold text-white hover:bg-emerald-500 sm:text-base"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate({
|
||||||
|
to: '/$complexSlug/booking',
|
||||||
|
params: { complexSlug },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Hacer otra reserva</span>
|
||||||
|
<span className="sm:hidden">Otra</span>
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
||||||
|
<ResponsiveDialogContent>
|
||||||
|
<ResponsiveDialogHeader>
|
||||||
|
<ResponsiveDialogTitle>Cancelar reserva</ResponsiveDialogTitle>
|
||||||
|
<ResponsiveDialogDescription>
|
||||||
|
Ingresá el número de teléfono que usaste al hacer la reserva para confirmar la
|
||||||
|
cancelación.
|
||||||
|
</ResponsiveDialogDescription>
|
||||||
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
|
<div className="px-6 pb-2">
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
placeholder="Ej: 1134567890"
|
||||||
|
value={customerPhone}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomerPhone(e.target.value);
|
||||||
|
setCancelError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cancelError && <p className="mt-2 text-sm text-red-600">{cancelError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveDialogFooter>
|
||||||
|
<ResponsiveDialogClose asChild>
|
||||||
|
<Button variant="outline">Volver</Button>
|
||||||
|
</ResponsiveDialogClose>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!customerPhone.trim() || cancelMutation.isPending}
|
||||||
|
onClick={handleConfirmCancel}
|
||||||
|
>
|
||||||
|
{cancelMutation.isPending ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Sí, cancelar reserva'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
Trophy,
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
Wrench,
|
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -62,7 +61,7 @@ const bookingFormSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.min(6, 'Ingresá un teléfono válido.')
|
.min(6, 'Ingresá un teléfono válido.')
|
||||||
.max(30, 'El teléfono no puede superar los 30 caracteres.'),
|
.max(30, 'El teléfono no puede superar los 30 caracteres.'),
|
||||||
customerEmail: z.string().email('Ingresá un email válido.').optional().or(z.literal('')),
|
customerEmail: z.string().email('Ingresá un email válido.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
||||||
@@ -376,7 +375,6 @@ function Legend() {
|
|||||||
<div className="flex flex-wrap gap-4 text-xs text-white/70">
|
<div className="flex flex-wrap gap-4 text-xs text-white/70">
|
||||||
<LegendDot color="bg-emerald-500" label="Libre" />
|
<LegendDot color="bg-emerald-500" label="Libre" />
|
||||||
<LegendDot color="bg-blue-500" label="Reservado" />
|
<LegendDot color="bg-blue-500" label="Reservado" />
|
||||||
<LegendDot color="bg-red-500" label="Mantenimiento" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1134,19 +1132,13 @@ function BookingTimeline({
|
|||||||
|
|
||||||
const left = ((end - range.start) / total) * 100;
|
const left = ((end - range.start) / total) * 100;
|
||||||
const width = ((Math.min(nextStart, range.end) - end) / total) * 100;
|
const width = ((Math.min(nextStart, range.end) - end) / total) * 100;
|
||||||
const maintenance = index % 2 === 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${court?.courtId}-blocked-${slot.startTime}`}
|
key={`${court?.courtId}-blocked-${slot.startTime}`}
|
||||||
className={`absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border ${
|
className="absolute top-7 flex h-12 min-w-[44px] items-center justify-center rounded border border-blue-500/70 bg-blue-500/24 text-blue-100"
|
||||||
maintenance
|
|
||||||
? 'border-red-500/80 bg-red-500/28 text-red-100'
|
|
||||||
: 'border-blue-500/70 bg-blue-500/24 text-blue-100'
|
|
||||||
}`}
|
|
||||||
style={{ left: `${left}%`, width: `${Math.max(width - 0.8, 4.5)}%` }}
|
style={{ left: `${left}%`, width: `${Math.max(width - 0.8, 4.5)}%` }}
|
||||||
>
|
>
|
||||||
{maintenance ? <Wrench className="size-4" /> : <Lock className="size-4" />}
|
<Lock className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1297,7 +1289,7 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
|
|||||||
|
|
||||||
<Field data-invalid={Boolean(errors.customerEmail)}>
|
<Field data-invalid={Boolean(errors.customerEmail)}>
|
||||||
<FieldLabel htmlFor="customerEmail" className="text-white/76">
|
<FieldLabel htmlFor="customerEmail" className="text-white/76">
|
||||||
Email <span className="text-white/40 font-normal">(opcional)</span>
|
Email
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="customerEmail"
|
id="customerEmail"
|
||||||
@@ -1445,7 +1437,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
|||||||
startTime: selectedSlot.startTime,
|
startTime: selectedSlot.startTime,
|
||||||
customerName: payload.customerName,
|
customerName: payload.customerName,
|
||||||
customerPhone: payload.customerPhone,
|
customerPhone: payload.customerPhone,
|
||||||
customerEmail: payload.customerEmail || undefined,
|
customerEmail: payload.customerEmail,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import type { ComplexWithRole } from '@repo/api-contract';
|
import type { ComplexWithRole } from '@repo/api-contract';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Building2, Loader2 } from 'lucide-react';
|
import { Building2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
@@ -13,12 +13,15 @@ export function SelectComplexPage() {
|
|||||||
queryFn: () => apiClient.complexes.listMine(),
|
queryFn: () => apiClient.complexes.listMine(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const selectMutation = useMutation({
|
const selectMutation = useMutation({
|
||||||
mutationFn: async (complexId: string) => {
|
mutationFn: async (complexId: string) => {
|
||||||
const response = await apiClient.complexes.select({ complexId });
|
const response = await apiClient.complexes.select({ complexId });
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(['current-complex'], data);
|
||||||
navigate({ to: '/' });
|
navigate({ to: '/' });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ body,
|
|||||||
--color-reserved: var(--reserved);
|
--color-reserved: var(--reserved);
|
||||||
--color-reserved-foreground: var(--reserved-foreground);
|
--color-reserved-foreground: var(--reserved-foreground);
|
||||||
|
|
||||||
--color-maintenance: var(--maintenance);
|
|
||||||
--color-maintenance-foreground: var(--maintenance-foreground);
|
|
||||||
|
|
||||||
--color-warning: var(--warning);
|
--color-warning: var(--warning);
|
||||||
--color-warning-foreground: var(--warning-foreground);
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
|
||||||
@@ -117,9 +114,6 @@ body,
|
|||||||
--reserved: oklch(0.58 0.19 255);
|
--reserved: oklch(0.58 0.19 255);
|
||||||
--reserved-foreground: oklch(0.985 0.01 255);
|
--reserved-foreground: oklch(0.985 0.01 255);
|
||||||
|
|
||||||
--maintenance: oklch(0.64 0.22 25);
|
|
||||||
--maintenance-foreground: oklch(0.985 0.01 25);
|
|
||||||
|
|
||||||
--warning: oklch(0.78 0.16 75);
|
--warning: oklch(0.78 0.16 75);
|
||||||
--warning-foreground: oklch(0.18 0.04 75);
|
--warning-foreground: oklch(0.18 0.04 75);
|
||||||
|
|
||||||
@@ -176,9 +170,6 @@ body,
|
|||||||
--reserved: oklch(0.61 0.2 255);
|
--reserved: oklch(0.61 0.2 255);
|
||||||
--reserved-foreground: oklch(0.97 0.012 255);
|
--reserved-foreground: oklch(0.97 0.012 255);
|
||||||
|
|
||||||
--maintenance: oklch(0.66 0.23 25);
|
|
||||||
--maintenance-foreground: oklch(0.98 0.01 25);
|
|
||||||
|
|
||||||
--warning: oklch(0.78 0.17 75);
|
--warning: oklch(0.78 0.17 75);
|
||||||
--warning-foreground: oklch(0.18 0.04 75);
|
--warning-foreground: oklch(0.18 0.04 75);
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const apiClient = {
|
|||||||
getAvailability: api.getAvailability,
|
getAvailability: api.getAvailability,
|
||||||
create: api.createPublic,
|
create: api.createPublic,
|
||||||
getConfirmation: api.getConfirmation,
|
getConfirmation: api.getConfirmation,
|
||||||
|
cancelPublic: api.cancelPublic,
|
||||||
},
|
},
|
||||||
adminBookings: {
|
adminBookings: {
|
||||||
listByComplex: api.listByComplex,
|
listByComplex: api.listByComplex,
|
||||||
|
|||||||
@@ -10,13 +10,35 @@ export const http: AxiosInstance = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function extractMessage(details: unknown, fallback: string): string {
|
function extractMessage(details: unknown, fallback: string): string {
|
||||||
if (
|
if (typeof details === 'object' && details) {
|
||||||
typeof details === 'object' &&
|
if ('message' in details && typeof details.message === 'string') {
|
||||||
details &&
|
return details.message;
|
||||||
'message' in details &&
|
}
|
||||||
typeof details.message === 'string'
|
if ('detail' in details && typeof details.detail === 'string') {
|
||||||
) {
|
return details.detail;
|
||||||
return details.message;
|
}
|
||||||
|
if ('error' in details && typeof details.error === 'object' && details.error) {
|
||||||
|
const zodError = details.error as Record<string, unknown>;
|
||||||
|
if (Array.isArray(zodError.issues) && zodError.issues.length > 0) {
|
||||||
|
const firstIssue = zodError.issues[0] as { message?: string };
|
||||||
|
if (typeof firstIssue.message === 'string') return firstIssue.message;
|
||||||
|
}
|
||||||
|
if (typeof zodError.message === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(zodError.message);
|
||||||
|
if (
|
||||||
|
Array.isArray(parsed) &&
|
||||||
|
parsed.length > 0 &&
|
||||||
|
typeof parsed[0]?.message === 'string'
|
||||||
|
) {
|
||||||
|
return parsed[0].message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not JSON, use raw string */
|
||||||
|
}
|
||||||
|
return zodError.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export * as sports from './resources/sports';
|
|||||||
export * as plans from './resources/plans';
|
export * as plans from './resources/plans';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
cancelPublic,
|
||||||
getAvailability,
|
getAvailability,
|
||||||
createPublic,
|
createPublic,
|
||||||
getConfirmation,
|
getConfirmation,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminBooking,
|
AdminBooking,
|
||||||
|
CancelPublicBookingInput,
|
||||||
CreateAdminBookingInput,
|
CreateAdminBookingInput,
|
||||||
CreatePublicBookingInput,
|
CreatePublicBookingInput,
|
||||||
PublicAvailabilityResponse,
|
PublicAvailabilityResponse,
|
||||||
@@ -51,6 +52,14 @@ export async function createAdmin(complexId: string, payload: CreateAdminBooking
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelPublic(complexSlug: string, payload: CancelPublicBookingInput) {
|
||||||
|
const response = await http.post<PublicBooking>(
|
||||||
|
`/api/public-bookings/complex/${complexSlug}/cancel`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateStatus(bookingId: string, payload: UpdateAdminBookingStatusInput) {
|
export async function updateStatus(bookingId: string, payload: UpdateAdminBookingStatusInput) {
|
||||||
const response = await http.patch<AdminBooking>(
|
const response = await http.patch<AdminBooking>(
|
||||||
`/api/admin-bookings/${bookingId}/status`,
|
`/api/admin-bookings/${bookingId}/status`,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const adminBookingSchema = z.object({
|
|||||||
endTime: z.string().regex(TIME_REGEX),
|
endTime: z.string().regex(TIME_REGEX),
|
||||||
customerName: z.string(),
|
customerName: z.string(),
|
||||||
customerPhone: z.string(),
|
customerPhone: z.string(),
|
||||||
customerEmail: z.string().optional(),
|
customerEmail: z.string(),
|
||||||
price: z.number().nonnegative(),
|
price: z.number().nonnegative(),
|
||||||
status: bookingStatusSchema,
|
status: bookingStatusSchema,
|
||||||
createdAt: z.string().datetime(),
|
createdAt: z.string().datetime(),
|
||||||
@@ -57,7 +57,6 @@ export const createAdminBookingSchema = z.object({
|
|||||||
customerEmail: z
|
customerEmail: z
|
||||||
.string()
|
.string()
|
||||||
.email('El email ingresado no es valido.')
|
.email('El email ingresado no es valido.')
|
||||||
.optional()
|
|
||||||
.or(z.literal('')),
|
.or(z.literal('')),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -98,9 +98,11 @@ export {
|
|||||||
publicBookingSchema,
|
publicBookingSchema,
|
||||||
publicBookingSlotSchema,
|
publicBookingSlotSchema,
|
||||||
publicBookingSportSchema,
|
publicBookingSportSchema,
|
||||||
|
cancelPublicBookingSchema,
|
||||||
} from './public-booking'
|
} from './public-booking'
|
||||||
export type {
|
export type {
|
||||||
CreatePublicBookingInput,
|
CreatePublicBookingInput,
|
||||||
|
CancelPublicBookingInput,
|
||||||
PublicAvailabilityCourt,
|
PublicAvailabilityCourt,
|
||||||
PublicAvailabilityQuery,
|
PublicAvailabilityQuery,
|
||||||
PublicAvailabilityResponse,
|
PublicAvailabilityResponse,
|
||||||
|
|||||||
@@ -59,9 +59,16 @@ export const createPublicBookingSchema = z.object({
|
|||||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||||
customerEmail: z
|
customerEmail: z
|
||||||
.string()
|
.string()
|
||||||
.email('El email ingresado no es valido.')
|
.email('El email ingresado no es valido.'),
|
||||||
.optional()
|
})
|
||||||
.or(z.literal('')),
|
|
||||||
|
export const cancelPublicBookingSchema = z.object({
|
||||||
|
bookingCode: z.string().regex(BOOKING_CODE_REGEX, 'El codigo de reserva no es valido.'),
|
||||||
|
customerPhone: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(6, 'El telefono debe tener al menos 6 caracteres.')
|
||||||
|
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const publicBookingSchema = z.object({
|
export const publicBookingSchema = z.object({
|
||||||
@@ -78,7 +85,7 @@ export const publicBookingSchema = z.object({
|
|||||||
endTime: z.string().regex(TIME_REGEX),
|
endTime: z.string().regex(TIME_REGEX),
|
||||||
customerName: z.string(),
|
customerName: z.string(),
|
||||||
customerPhone: z.string(),
|
customerPhone: z.string(),
|
||||||
customerEmail: z.string().optional(),
|
customerEmail: z.string(),
|
||||||
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
|
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
|
||||||
price: z.number().nonnegative(),
|
price: z.number().nonnegative(),
|
||||||
createdAt: z.string().datetime(),
|
createdAt: z.string().datetime(),
|
||||||
@@ -96,7 +103,7 @@ export const publicBookingConfirmationSchema = z.object({
|
|||||||
courtName: z.string(),
|
courtName: z.string(),
|
||||||
sport: publicBookingSportSchema,
|
sport: publicBookingSportSchema,
|
||||||
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
|
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
|
||||||
customerEmail: z.string().optional(),
|
customerEmail: z.string(),
|
||||||
createdAt: z.string().datetime(),
|
createdAt: z.string().datetime(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -106,5 +113,6 @@ export type PublicBookingSlot = z.infer<typeof publicBookingSlotSchema>
|
|||||||
export type PublicAvailabilityCourt = z.infer<typeof publicAvailabilityCourtSchema>
|
export type PublicAvailabilityCourt = z.infer<typeof publicAvailabilityCourtSchema>
|
||||||
export type PublicAvailabilityResponse = z.infer<typeof publicAvailabilityResponseSchema>
|
export type PublicAvailabilityResponse = z.infer<typeof publicAvailabilityResponseSchema>
|
||||||
export type CreatePublicBookingInput = z.infer<typeof createPublicBookingSchema>
|
export type CreatePublicBookingInput = z.infer<typeof createPublicBookingSchema>
|
||||||
|
export type CancelPublicBookingInput = z.infer<typeof cancelPublicBookingSchema>
|
||||||
export type PublicBooking = z.infer<typeof publicBookingSchema>
|
export type PublicBooking = z.infer<typeof publicBookingSchema>
|
||||||
export type PublicBookingConfirmation = z.infer<typeof publicBookingConfirmationSchema>
|
export type PublicBookingConfirmation = z.infer<typeof publicBookingConfirmationSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user