feat: horarios opcionales en reservas, calendarios en lunes y emails filtrados
- Reservas: campos opcionales start_time/end_time (migración 011, schema natur_reservas) + toggle en el modal y detección de solapamiento por horario cuando ambas reservas los tienen definidos. Permite encajar varios eventos el mismo día. - Calendario mensual y anual ahora empiezan en lunes; vista móvil incluida. - Celdas con varios eventos el mismo día se dividen en franjas horizontales mostrando el horario; las reservas multi-día siguen ocupando la celda completa. - Modal: reset de campos vacíos (client_name, fechas, factura) para evitar que el nombre de la última reserva se filtre al crear una nueva. - Emails: las modificaciones solo disparan correo cuando cambian fechas u horas; el correo a Teneriffa pasa a formato reducido (solo fechas + propiedad) mientras que Natur sigue recibiendo el detalle completo. Mantenimiento sin cambios. - CLAUDE.md con guía operativa (schema natur_reservas, stack, convenciones). - Scripts de preview/envío de emails para pruebas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,6 +12,9 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Local email preview artifacts
|
||||
apps/api/preview-out/
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
71
CLAUDE.md
Normal file
71
CLAUDE.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# CLAUDE.md — Naturcalabacera App Reservas
|
||||
|
||||
Guía operativa para agentes de Claude Code trabajando en este repo. Lee este archivo antes de tocar código o sugerir SQL.
|
||||
|
||||
---
|
||||
|
||||
## 1. Base de datos (Supabase) — CRÍTICO
|
||||
|
||||
La instancia de Supabase es **compartida** entre varios proyectos. Hay dos schemas:
|
||||
|
||||
- `public` → **otro proyecto, NO tocar**.
|
||||
- `natur_reservas` → este proyecto.
|
||||
|
||||
> **Toda sentencia SQL debe cualificar el schema explícitamente**: `natur_reservas.reservations`, `natur_reservas.contracts`, etc.
|
||||
> Nunca escribir `reservations` a secas en migraciones, queries ad-hoc, RLS, triggers, vistas o funciones. Aplica también al SQL Editor del dashboard.
|
||||
|
||||
### Ejemplo correcto
|
||||
|
||||
```sql
|
||||
ALTER TABLE natur_reservas.reservations
|
||||
ADD COLUMN IF NOT EXISTS start_time TIME;
|
||||
```
|
||||
|
||||
### Migraciones
|
||||
|
||||
Viven en `supabase/migrations/NNN_*.sql`. Se aplican con `supabase db push` o pegando el SQL en el dashboard. **Antes de añadir una nueva**, revisar las anteriores y replicar la convención de cualificar con `natur_reservas.`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stack y estructura
|
||||
|
||||
Monorepo pnpm con workspaces:
|
||||
|
||||
- `apps/web` — Frontend React + TypeScript + Vite + Tailwind. Vista principal: calendario de reservas (mensual + anual).
|
||||
- `apps/api` — Backend Express. Webhooks (n8n relay para correos), jobs.
|
||||
- `packages/shared` — Tipos (`Reservation`, `Property`, etc.) y utilidades (`PROPERTY_CONFIG`, pricing).
|
||||
|
||||
Comandos raíz:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev:web # frontend (http://localhost:5173)
|
||||
pnpm dev:api # API
|
||||
pnpm build # build web + api
|
||||
pnpm lint
|
||||
pnpm test # tests del paquete shared
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Dominio
|
||||
|
||||
- 2 propiedades: **Los Dragos** (`los_dragos`) y **La Esquinita** (`la_esquinita`).
|
||||
- 2 orígenes de reserva: **Teneriffa2000** (alquiler estándar) y **Naturcalabacera** (vacacional + eventos).
|
||||
- Reservas Naturcalabacera pueden marcarse `is_event = true` (boda, cumpleaños, etc.) con su propio cálculo de canon (precio base por noche + extra por pax sobre `includedPersons`, tarifa por año, IGIC).
|
||||
- Horarios opcionales `start_time` / `end_time` (cualquier reserva, cualquier origen): cuando ambas reservas en conflicto los tienen, el solapamiento se calcula a nivel de momento (fecha + hora), no solo de fecha. Permite encajar varios eventos el mismo día.
|
||||
|
||||
---
|
||||
|
||||
## 4. Despliegue
|
||||
|
||||
Gitea + Dokploy en `72.62.155.93`. URLs con `traefik.me` (HTTP). Dockerfile por app.
|
||||
|
||||
---
|
||||
|
||||
## 5. Convenciones del repo
|
||||
|
||||
- Sin commits sin permiso explícito del usuario.
|
||||
- Calendario empieza en **lunes** (`weekStartsOn: 1`) en todas las vistas.
|
||||
- Comentarios mínimos: solo cuando el "por qué" no es obvio del código.
|
||||
- Idioma de interfaz: español. Identificadores en inglés.
|
||||
43
apps/api/scripts/preview-teneriffa-email.mjs
Normal file
43
apps/api/scripts/preview-teneriffa-email.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
// Genera previews HTML del correo minimalista para Teneriffa
|
||||
// (las 2 reservas del 19 de mayo de Los Dragos).
|
||||
// Uso: node apps/api/scripts/preview-teneriffa-email.mjs
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR = join(__dirname, '../preview-out');
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
function renderTeneriffaMinimal(actionLabel, dateRange, property, cancelled = false) {
|
||||
const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="es"><head><meta charset="UTF-8"></head>
|
||||
<body style="font-family:Arial,sans-serif;background:#f4f4f4;margin:0;padding:24px;">
|
||||
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
|
||||
<div style="background:${accent};color:#fff;padding:20px 24px;">
|
||||
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;opacity:0.85;font-weight:700;">${actionLabel}</p>
|
||||
</div>
|
||||
<div style="padding:24px;">
|
||||
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Fechas</p>
|
||||
<p style="margin:0 0 18px;font-size:18px;font-weight:800;color:#111;">${dateRange}</p>
|
||||
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Propiedad</p>
|
||||
<p style="margin:0;font-size:16px;font-weight:700;color:#111;">${property}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
const dateRange = '19 may 2026 – 19 may 2026';
|
||||
const property = 'Los Dragos';
|
||||
|
||||
const f1 = join(OUT_DIR, 'teneriffa-email-19may-evento1.html');
|
||||
writeFileSync(f1, renderTeneriffaMinimal('Nueva Reserva', dateRange, property));
|
||||
console.log('→', f1);
|
||||
|
||||
const f2 = join(OUT_DIR, 'teneriffa-email-19may-evento2.html');
|
||||
writeFileSync(f2, renderTeneriffaMinimal('Nueva Reserva', dateRange, property));
|
||||
console.log('→', f2);
|
||||
|
||||
console.log('\nAbre los archivos en el navegador para ver cómo se renderiza el correo en Teneriffa.');
|
||||
60
apps/api/scripts/send-test-teneriffa-email.mjs
Normal file
60
apps/api/scripts/send-test-teneriffa-email.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
// Script de prueba: envía dos correos minimalistas de Teneriffa para las dos
|
||||
// reservas del 19 de mayo de 2026 (Los Dragos), tal como las recibiría Teneriffa
|
||||
// con el nuevo formato (solo fechas + propiedad).
|
||||
// Uso: node apps/api/scripts/send-test-teneriffa-email.mjs <DESTINO>
|
||||
|
||||
// .env se carga vía `node --env-file=.env`
|
||||
const WEBHOOK = process.env.N8N_EMAIL_WEBHOOK_URL;
|
||||
const FROM = process.env.EMAIL_FROM ?? 'Naturcalabacera <reservas@naturcalabacera.com>';
|
||||
const TO = process.argv[2] ?? 'kilian.parraga@gmail.com';
|
||||
|
||||
if (!WEBHOOK) {
|
||||
console.error('Falta N8N_EMAIL_WEBHOOK_URL en el .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function renderTeneriffaMinimal(actionLabel, dateRange, property, cancelled = false) {
|
||||
const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="es"><head><meta charset="UTF-8"></head>
|
||||
<body style="font-family:Arial,sans-serif;background:#f4f4f4;margin:0;padding:24px;">
|
||||
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
|
||||
<div style="background:${accent};color:#fff;padding:20px 24px;">
|
||||
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;opacity:0.85;font-weight:700;">${actionLabel}</p>
|
||||
</div>
|
||||
<div style="padding:24px;">
|
||||
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Fechas</p>
|
||||
<p style="margin:0 0 18px;font-size:18px;font-weight:800;color:#111;">${dateRange}</p>
|
||||
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Propiedad</p>
|
||||
<p style="margin:0;font-size:16px;font-weight:700;color:#111;">${property}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function send({ subject, html }) {
|
||||
const res = await fetch(WEBHOOK, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to: TO, subject, html, from: FROM }),
|
||||
});
|
||||
const text = await res.text().catch(() => '');
|
||||
return { ok: res.ok, status: res.status, body: text.slice(0, 200) };
|
||||
}
|
||||
|
||||
const dateRange = '19 may 2026 – 19 may 2026';
|
||||
const property = 'Los Dragos';
|
||||
|
||||
// Reserva #1: evento mañana
|
||||
const r1 = await send({
|
||||
subject: `[NUEVA RESERVA] ${dateRange} · ${property}`,
|
||||
html: renderTeneriffaMinimal('Nueva Reserva', dateRange, property),
|
||||
});
|
||||
console.log('Email #1 (Pruebas Kilian 10:00–11:00):', r1);
|
||||
|
||||
// Reserva #2: evento tarde
|
||||
const r2 = await send({
|
||||
subject: `[NUEVA RESERVA] ${dateRange} · ${property}`,
|
||||
html: renderTeneriffaMinimal('Nueva Reserva', dateRange, property),
|
||||
});
|
||||
console.log('Email #2 (Pruebas Kilian 12:00–20:00):', r2);
|
||||
50
apps/api/scripts/send-test-teneriffa-variants.mjs
Normal file
50
apps/api/scripts/send-test-teneriffa-variants.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
// Envía 3 ejemplos del correo minimalista de Teneriffa: nueva, modificada, cancelada.
|
||||
// Uso: N8N_EMAIL_WEBHOOK_URL=... node apps/api/scripts/send-test-teneriffa-variants.mjs <destino>
|
||||
|
||||
const WEBHOOK = process.env.N8N_EMAIL_WEBHOOK_URL;
|
||||
const FROM = process.env.EMAIL_FROM ?? 'Naturcalabacera <reservas@naturcalabacera.com>';
|
||||
const TO = process.argv[2] ?? 'kilian.parraga@gmail.com';
|
||||
|
||||
if (!WEBHOOK) { console.error('Falta N8N_EMAIL_WEBHOOK_URL'); process.exit(1); }
|
||||
|
||||
function renderTeneriffaMinimal(actionLabel, dateRange, property, cancelled = false) {
|
||||
const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="es"><head><meta charset="UTF-8"></head>
|
||||
<body style="font-family:Arial,sans-serif;background:#f4f4f4;margin:0;padding:24px;">
|
||||
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
|
||||
<div style="background:${accent};color:#fff;padding:20px 24px;">
|
||||
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;opacity:0.85;font-weight:700;">${actionLabel}</p>
|
||||
</div>
|
||||
<div style="padding:24px;">
|
||||
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Fechas</p>
|
||||
<p style="margin:0 0 18px;font-size:18px;font-weight:800;color:#111;">${dateRange}</p>
|
||||
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Propiedad</p>
|
||||
<p style="margin:0;font-size:16px;font-weight:700;color:#111;">${property}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function send(subject, html) {
|
||||
const res = await fetch(WEBHOOK, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to: TO, subject, html, from: FROM }),
|
||||
});
|
||||
const text = await res.text().catch(() => '');
|
||||
return { ok: res.ok, status: res.status, body: text.slice(0, 200) };
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{ tag: 'NUEVA RESERVA', label: 'Nueva Reserva', range: '03 jul 2026 – 10 jul 2026', prop: 'La Esquinita' },
|
||||
{ tag: 'MODIFICADA', label: 'Reserva Modificada', range: '03 jul 2026 – 12 jul 2026', prop: 'La Esquinita' },
|
||||
{ tag: 'CANCELADA', label: 'Reserva Cancelada', range: '03 jul 2026 – 12 jul 2026', prop: 'La Esquinita', cancelled: true },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
const subject = `[${c.tag}] ${c.range} · ${c.prop}`;
|
||||
const html = renderTeneriffaMinimal(c.label, c.range, c.prop, c.cancelled);
|
||||
const r = await send(subject, html);
|
||||
console.log(c.tag.padEnd(15), r);
|
||||
}
|
||||
@@ -146,6 +146,89 @@ interface SendResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Campos cuya modificación dispara el email de "reserva modificada".
|
||||
* El resto se considera cambio menor (observaciones, registro, factura,
|
||||
* snapshot de precios, servicios, asistentes, etc.) y NO envía correo
|
||||
* para no saturar las bandejas de entrada.
|
||||
*/
|
||||
const MAJOR_UPDATE_FIELDS: (keyof Reservation)[] = [
|
||||
'start_date',
|
||||
'end_date',
|
||||
'start_time',
|
||||
'end_time',
|
||||
];
|
||||
|
||||
function hasMajorChange(prev: Reservation, curr: Reservation): boolean {
|
||||
return MAJOR_UPDATE_FIELDS.some(field => {
|
||||
const a = prev[field] ?? '';
|
||||
const b = curr[field] ?? '';
|
||||
return a !== b;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Email reducido para Teneriffa: fechas + propiedad + acción.
|
||||
* Sin datos del cliente, sin servicios, sin observaciones.
|
||||
*/
|
||||
function renderTeneriffaMinimal(
|
||||
actionLabel: string,
|
||||
dateRange: string,
|
||||
property: string,
|
||||
cancelled = false,
|
||||
): string {
|
||||
const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="es"><head><meta charset="UTF-8"></head>
|
||||
<body style="font-family:Arial,sans-serif;background:#f4f4f4;margin:0;padding:24px;">
|
||||
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
|
||||
<div style="background:${accent};color:#fff;padding:20px 24px;">
|
||||
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;opacity:0.85;font-weight:700;">${actionLabel}</p>
|
||||
</div>
|
||||
<div style="padding:24px;">
|
||||
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Fechas</p>
|
||||
<p style="margin:0 0 18px;font-size:18px;font-weight:800;color:#111;">${dateRange}</p>
|
||||
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Propiedad</p>
|
||||
<p style="margin:0;font-size:16px;font-weight:700;color:#111;">${property}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function sendCrudPair(opts: {
|
||||
actionLabel: string;
|
||||
subjectTag: string;
|
||||
dateRange: string;
|
||||
property: string;
|
||||
template: 'teneriffa-crud' | 'natur-crud';
|
||||
vars: Record<string, string>;
|
||||
originLabel: string;
|
||||
clientName: string;
|
||||
cancelled?: boolean;
|
||||
}): Promise<SendResult> {
|
||||
// 1) Correo reducido a Teneriffa (solo fechas + propiedad)
|
||||
const minHtml = renderTeneriffaMinimal(opts.actionLabel, opts.dateRange, opts.property, opts.cancelled);
|
||||
const minSubject = `[${opts.subjectTag}] ${opts.dateRange} · ${opts.property}`;
|
||||
const minRes = await sendEmail({
|
||||
to: env.NOTIFICATION_EMAIL_TENERIFFA,
|
||||
subject: minSubject,
|
||||
html: minHtml,
|
||||
});
|
||||
|
||||
// 2) Correo completo a Natur (HTML con todos los detalles)
|
||||
const fullHtml = renderTemplate(opts.template, opts.vars);
|
||||
const fullSubject = `[${opts.subjectTag}] ${opts.originLabel} — ${opts.clientName} | ${opts.property} | ${opts.dateRange}`;
|
||||
const fullRes = await sendEmail({
|
||||
to: env.NOTIFICATION_EMAIL_NATUR,
|
||||
subject: fullSubject,
|
||||
html: fullHtml,
|
||||
});
|
||||
|
||||
if (!minRes.success) return minRes;
|
||||
if (!fullRes.success) return fullRes;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía el email correspondiente a un evento de notificación.
|
||||
* Para reservation.updated acepta previousReservation para mostrar el diff.
|
||||
@@ -189,12 +272,24 @@ export async function handleNotificationEvent(
|
||||
ACTION_LABEL: 'Nueva Reserva',
|
||||
CHANGES_BLOCK: '',
|
||||
};
|
||||
const html = renderTemplate(template, vars);
|
||||
const subject = `[NUEVA RESERVA] ${originLabel} — ${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
|
||||
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
|
||||
return sendCrudPair({
|
||||
actionLabel: 'Nueva Reserva',
|
||||
subjectTag: 'NUEVA RESERVA',
|
||||
dateRange,
|
||||
property: propertyLabel(reservation.property),
|
||||
template,
|
||||
vars,
|
||||
originLabel,
|
||||
clientName: reservation.client_name,
|
||||
});
|
||||
}
|
||||
|
||||
case 'reservation.updated': {
|
||||
// Skip si los cambios son solo en campos menores (observaciones, factura, servicios, etc.)
|
||||
// Solo dispara email cuando cambian fechas u horarios. Evita inundar bandejas.
|
||||
if (previousReservation && !hasMajorChange(previousReservation, reservation)) {
|
||||
return { success: true };
|
||||
}
|
||||
const isTeenriffa = reservation.origin === 'Teneriffa2000';
|
||||
const template = isTeenriffa ? 'teneriffa-crud' : 'natur-crud';
|
||||
const originLabel = isTeenriffa ? 'Teneriffa' : 'Natur';
|
||||
@@ -207,9 +302,16 @@ export async function handleNotificationEvent(
|
||||
ACTION_LABEL: 'Reserva Modificada',
|
||||
CHANGES_BLOCK: changesBlock,
|
||||
};
|
||||
const html = renderTemplate(template, vars);
|
||||
const subject = `[MODIFICADA] ${originLabel} — ${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
|
||||
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
|
||||
return sendCrudPair({
|
||||
actionLabel: 'Reserva Modificada',
|
||||
subjectTag: 'MODIFICADA',
|
||||
dateRange,
|
||||
property: propertyLabel(reservation.property),
|
||||
template,
|
||||
vars,
|
||||
originLabel,
|
||||
clientName: reservation.client_name,
|
||||
});
|
||||
}
|
||||
|
||||
case 'reservation.cancelled': {
|
||||
@@ -223,9 +325,17 @@ export async function handleNotificationEvent(
|
||||
CHANGES_BLOCK: '',
|
||||
CANCEL_ALERT: `<div class="cancel-alert"><p>Esta reserva ha sido cancelada y eliminada del sistema.</p></div>`,
|
||||
};
|
||||
const html = renderTemplate(template, vars);
|
||||
const subject = `[CANCELADA] ${originLabel} — ${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
|
||||
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
|
||||
return sendCrudPair({
|
||||
actionLabel: 'Reserva Cancelada',
|
||||
subjectTag: 'CANCELADA',
|
||||
dateRange,
|
||||
property: propertyLabel(reservation.property),
|
||||
template,
|
||||
vars,
|
||||
originLabel,
|
||||
clientName: reservation.client_name,
|
||||
cancelled: true,
|
||||
});
|
||||
}
|
||||
|
||||
case 'reservation.reminder_24h': {
|
||||
|
||||
@@ -44,8 +44,8 @@ export function CalendarGrid({
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(monthStart);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||
|
||||
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
|
||||
|
||||
@@ -218,56 +218,87 @@ export function CalendarGrid({
|
||||
return () => document.removeEventListener('mouseup', cancel);
|
||||
}, [isDragging]);
|
||||
|
||||
// --- Reservation blocks (visual only — clicks handled at grid body level) ---
|
||||
const renderReservationBlocks = () => {
|
||||
const blocks: React.ReactElement[] = [];
|
||||
// --- Clasificación de reservas para layout ---
|
||||
// Las reservas de un solo día se renderizan dentro de su celda; si hay varias en la
|
||||
// misma celda, esa celda concreta se divide en N franjas horizontales (sin afectar
|
||||
// al resto de la fila). Las reservas multi-día se siguen renderizando como bandas
|
||||
// que abarcan varios días a altura completa.
|
||||
type ResRange = { res: Reservation; startIdx: number; endIdx: number };
|
||||
const resRanges: ResRange[] = reservations
|
||||
.map(res => {
|
||||
const startIdx = calendarDays.findIndex(day => isSameDay(day, parseISO(res.start_date)));
|
||||
const endIdx = calendarDays.findIndex(day => isSameDay(day, parseISO(res.end_date)));
|
||||
if (startIdx === -1 || endIdx === -1) return null;
|
||||
return { res, startIdx, endIdx };
|
||||
})
|
||||
.filter((x): x is ResRange => x !== null);
|
||||
|
||||
reservations.forEach((res) => {
|
||||
const startDate = parseISO(res.start_date);
|
||||
const endDate = parseISO(res.end_date);
|
||||
|
||||
const startIndex = calendarDays.findIndex(day => isSameDay(day, startDate));
|
||||
if (startIndex === -1) return;
|
||||
|
||||
const endIndex = calendarDays.findIndex(day => isSameDay(day, endDate));
|
||||
if (endIndex === -1) return;
|
||||
|
||||
const totalDuration = differenceInDays(endDate, startDate) + 1;
|
||||
const nights = totalDuration - 1;
|
||||
// Reservas de un solo día agrupadas por su celda, ordenadas por hora de inicio
|
||||
const singleDayByCell = new Map<number, Reservation[]>();
|
||||
const multiDayRanges: ResRange[] = [];
|
||||
for (const r of resRanges) {
|
||||
if (r.startIdx === r.endIdx) {
|
||||
const arr = singleDayByCell.get(r.startIdx) ?? [];
|
||||
arr.push(r.res);
|
||||
singleDayByCell.set(r.startIdx, arr);
|
||||
} else {
|
||||
multiDayRanges.push(r);
|
||||
}
|
||||
}
|
||||
for (const arr of singleDayByCell.values()) {
|
||||
arr.sort((a, b) => (a.start_time ?? '00:00').localeCompare(b.start_time ?? '00:00'));
|
||||
}
|
||||
|
||||
const stylesForRes = (res: Reservation) => {
|
||||
const isTeneriffa = res.origin === 'Teneriffa2000';
|
||||
const gradient = viewerMode
|
||||
? 'bg-stone-400/30 dark:bg-stone-500/30'
|
||||
: isTeneriffa
|
||||
? 'bg-blue-600/30 dark:bg-blue-500/30'
|
||||
: 'bg-yellow-500/30 dark:bg-yellow-400/30';
|
||||
|
||||
const borderClass = viewerMode
|
||||
? 'border-l-4 border-stone-400'
|
||||
: isTeneriffa
|
||||
? 'border-l-4 border-blue-500'
|
||||
: 'border-l-4 border-yellow-500';
|
||||
return { gradient, borderClass };
|
||||
};
|
||||
const textShadow = 'drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]';
|
||||
|
||||
let currentDayIndex = startIndex;
|
||||
let blockIndex = 0;
|
||||
// --- Render ---
|
||||
const renderReservationBlocks = () => {
|
||||
const blocks: React.ReactElement[] = [];
|
||||
|
||||
while (currentDayIndex <= endIndex) {
|
||||
// 1) Multi-día: banda completa que abarca semanas (comportamiento original)
|
||||
multiDayRanges.forEach(({ res, startIdx, endIdx }) => {
|
||||
const startDate = parseISO(res.start_date);
|
||||
const endDate = parseISO(res.end_date);
|
||||
const nights = differenceInDays(endDate, startDate);
|
||||
const { gradient, borderClass } = stylesForRes(res);
|
||||
|
||||
let currentDayIndex = startIdx;
|
||||
let blockIndex = 0;
|
||||
while (currentDayIndex <= endIdx) {
|
||||
const weekIndex = Math.floor(currentDayIndex / 7);
|
||||
const dayOfWeek = currentDayIndex % 7;
|
||||
const daysUntilWeekEnd = 7 - dayOfWeek;
|
||||
const daysRemaining = endIndex - currentDayIndex + 1;
|
||||
const daysRemaining = endIdx - currentDayIndex + 1;
|
||||
const daysInThisWeek = Math.min(daysUntilWeekEnd, daysRemaining);
|
||||
const isFirstBlock = blockIndex === 0;
|
||||
|
||||
blocks.push(
|
||||
<div
|
||||
key={`${res.id}-week-${weekIndex}`}
|
||||
onClick={(ev) => {
|
||||
if (viewerMode) return;
|
||||
ev.stopPropagation();
|
||||
dragJustFinished.current = true;
|
||||
onSelectReservation(res);
|
||||
}}
|
||||
className={`
|
||||
absolute pointer-events-none
|
||||
${gradient} ${borderClass}
|
||||
z-10
|
||||
absolute ${gradient} ${borderClass} z-10
|
||||
flex flex-col justify-end items-center md:items-start md:p-1.5
|
||||
${viewerMode ? 'pointer-events-none' : 'cursor-pointer hover:brightness-110'}
|
||||
`}
|
||||
style={{
|
||||
top: `calc(${weekIndex} * var(--cell-height))`,
|
||||
@@ -276,7 +307,6 @@ export function CalendarGrid({
|
||||
height: 'var(--cell-height)',
|
||||
}}
|
||||
>
|
||||
{/* Desktop */}
|
||||
<div className="hidden md:block w-full">
|
||||
<div className={`text-xs font-black text-white truncate ${textShadow} px-1`}>
|
||||
{viewerMode ? 'Ocupado' : res.client_name}
|
||||
@@ -295,8 +325,6 @@ export function CalendarGrid({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden w-full flex items-end justify-between pb-0.5 px-0.5">
|
||||
{daysInThisWeek > 1 && (
|
||||
<span className={`text-[8px] font-black text-white/90 truncate uppercase tracking-tight ${textShadow}`}>
|
||||
@@ -315,6 +343,76 @@ export function CalendarGrid({
|
||||
}
|
||||
});
|
||||
|
||||
// 2) Una-celda: si hay >1 en la misma celda, se divide solo esa celda en N franjas.
|
||||
singleDayByCell.forEach((list, dayIdx) => {
|
||||
const weekIndex = Math.floor(dayIdx / 7);
|
||||
const dayOfWeek = dayIdx % 7;
|
||||
const lanes = list.length;
|
||||
const isSplit = lanes > 1;
|
||||
list.forEach((res, lane) => {
|
||||
const { gradient, borderClass } = stylesForRes(res);
|
||||
const hasTimes = !!res.start_time && !!res.end_time;
|
||||
// Lane 0 comparte espacio horizontal con el badge del número del día.
|
||||
// Le damos padding-left para que el contenido no se solape con el número.
|
||||
const needsBadgeOffset = isSplit && lane === 0;
|
||||
blocks.push(
|
||||
<div
|
||||
key={`${res.id}-cell-${dayIdx}`}
|
||||
onClick={(ev) => {
|
||||
if (viewerMode) return;
|
||||
ev.stopPropagation();
|
||||
dragJustFinished.current = true;
|
||||
onSelectReservation(res);
|
||||
}}
|
||||
className={`
|
||||
absolute ${gradient} ${borderClass} z-10
|
||||
flex items-center
|
||||
${viewerMode ? 'pointer-events-none' : 'cursor-pointer hover:brightness-110'}
|
||||
`}
|
||||
style={{
|
||||
top: `calc(${weekIndex} * var(--cell-height) + ${lane} * (var(--cell-height) / ${lanes}))`,
|
||||
left: `${(dayOfWeek * 100 / 7)}%`,
|
||||
width: `${(100 / 7)}%`,
|
||||
height: `calc(var(--cell-height) / ${lanes})`,
|
||||
paddingLeft: needsBadgeOffset ? '2.25rem' : '0.5rem',
|
||||
paddingRight: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{/* Desktop: línea compacta horario · nombre */}
|
||||
<div className="hidden md:flex items-center gap-1.5 w-full min-w-0">
|
||||
{hasTimes && (
|
||||
<span className={`text-[10px] font-bold text-white/95 tabular-nums whitespace-nowrap ${textShadow}`}>
|
||||
{res.start_time?.slice(0, 5)}–{res.end_time?.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[11px] font-black text-white truncate ${textShadow}`}>
|
||||
{viewerMode ? 'Ocupado' : res.client_name}
|
||||
</span>
|
||||
{!isSplit && !viewerMode && (
|
||||
<span className={`flex items-center gap-0.5 text-white text-[9px] font-bold ml-auto ${textShadow}`}>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
{res.adults_count + res.children_count}p
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: hora si está partido, nombre si hay sitio */}
|
||||
<div className="md:hidden w-full flex items-center justify-center gap-1 px-0.5">
|
||||
{hasTimes && isSplit ? (
|
||||
<span className={`text-[8px] font-black text-white/95 tabular-nums whitespace-nowrap ${textShadow}`}>
|
||||
{res.start_time?.slice(0, 5)}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`text-[8px] font-black text-white/90 truncate uppercase tracking-tight ${textShadow}`}>
|
||||
{viewerMode ? 'Ocupado' : res.client_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return blocks;
|
||||
};
|
||||
|
||||
@@ -419,10 +517,10 @@ export function CalendarGrid({
|
||||
|
||||
{/* Days header */}
|
||||
<div className="grid grid-cols-7 bg-stone-100 dark:bg-emerald-950/30 border-b border-stone-200 dark:border-emerald-900/30 backdrop-blur-sm shrink-0">
|
||||
{['D', 'L', 'M', 'X', 'J', 'V', 'S'].map((day, i) => (
|
||||
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((day, i) => (
|
||||
<div key={i} className="text-center py-1.5 md:py-3 text-[9px] md:text-xs font-black text-stone-400 dark:text-emerald-500/60 uppercase tracking-widest">
|
||||
<span className="md:hidden">{day}</span>
|
||||
<span className="hidden md:inline">{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'][i]}</span>
|
||||
<span className="hidden md:inline">{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'][i]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@ export function CustomMobileCalendar({ reservations, onSelectRange, onSelectRese
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(monthStart);
|
||||
const startDate = startOfWeek(monthStart, { locale: es });
|
||||
const endDate = endOfWeek(monthEnd, { locale: es });
|
||||
const startDate = startOfWeek(monthStart, { weekStartsOn: 1, locale: es });
|
||||
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1, locale: es });
|
||||
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
|
||||
@@ -91,7 +91,7 @@ export function CustomMobileCalendar({ reservations, onSelectRange, onSelectRese
|
||||
|
||||
{/* Week Days */}
|
||||
<div className="grid grid-cols-7 px-3 py-2 bg-gray-50">
|
||||
{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(day => (
|
||||
{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map(day => (
|
||||
<div key={day} className="text-center text-[11px] font-semibold text-gray-500 uppercase tracking-wide py-1">
|
||||
{day}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { NewReservation, Reservation, Property } from '../types';
|
||||
import { X, Check, Trash2, AlertCircle, ChevronDown, Zap, Paperclip, Receipt } from 'lucide-react';
|
||||
import { X, Check, Trash2, AlertCircle, ChevronDown, Zap, Paperclip, Receipt, Clock } from 'lucide-react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { PROPERTY_CONFIG, getExtraPersonRate } from '@naturcalabacera/shared';
|
||||
@@ -47,6 +47,9 @@ export function ReservationModal({
|
||||
// Event toggle — local state (not a form field, controls section visibility)
|
||||
const [isEvent, setIsEvent] = useState(false);
|
||||
|
||||
// Toggle de horarios opcionales (entrada/salida con hora). Aplicable a cualquier reserva.
|
||||
const [hasTimes, setHasTimes] = useState(false);
|
||||
|
||||
// Override manual de la tarifa por persona extra (€/pax/noche).
|
||||
// null = usar tarifa automática por año.
|
||||
const [extraRateOverride, setExtraRateOverride] = useState<number | null>(null);
|
||||
@@ -58,6 +61,10 @@ export function ReservationModal({
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
reset({
|
||||
client_name: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
invoice_number: '',
|
||||
origin: 'Teneriffa2000',
|
||||
adults_count: 2,
|
||||
children_count: 0,
|
||||
@@ -70,9 +77,12 @@ export function ReservationModal({
|
||||
event_type: '',
|
||||
event_type_other: '',
|
||||
attendees_count: 0,
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
...initialData,
|
||||
});
|
||||
setIsEvent(initialData?.is_event ?? false);
|
||||
setHasTimes(Boolean(initialData?.start_time || initialData?.end_time));
|
||||
const snapshotOverride = initialData?.pricing_snapshot?.extraPersonRateOverride;
|
||||
setExtraRateOverride(snapshotOverride ?? null);
|
||||
clearErrors();
|
||||
@@ -85,6 +95,8 @@ export function ReservationModal({
|
||||
const children = watch('children_count');
|
||||
const origin = watch('origin');
|
||||
const eventType = watch('event_type');
|
||||
const startTimeVal = watch('start_time');
|
||||
const endTimeVal = watch('end_time');
|
||||
|
||||
const totalDays = startDate && endDate
|
||||
? Math.max(0, differenceInDays(parseISO(endDate), parseISO(startDate)))
|
||||
@@ -116,18 +128,44 @@ export function ReservationModal({
|
||||
* Overlap check — intervalos semi-abiertos [start, end).
|
||||
* El día de salida de una reserva existente SÍ permite entrada ese mismo día:
|
||||
* si res=[19, 20] y new=[20, 21], no hay solapamiento (check-out y check-in mismo día).
|
||||
* Fórmula: overlap iff newStart < resEnd AND newEnd > resStart
|
||||
* Para eventos con horarios definidos, comparamos momentos (fecha+hora) en lugar de solo días,
|
||||
* permitiendo que dos eventos compartan el mismo día siempre que sus horarios no se solapen.
|
||||
*/
|
||||
const toMoment = (date: string, time: string | undefined, fallback: 'start' | 'end'): Date => {
|
||||
const hhmm = (time && /^\d{2}:\d{2}/.test(time)) ? time : (fallback === 'start' ? '00:00' : '24:00');
|
||||
if (hhmm === '24:00') {
|
||||
// 24:00 = inicio del día siguiente
|
||||
const d = parseISO(`${date}T00:00:00`);
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d;
|
||||
}
|
||||
return parseISO(`${date}T${hhmm}:00`);
|
||||
};
|
||||
|
||||
const checkOverlap = (start: string, end: string, currentProperty: Property): boolean => {
|
||||
const newStart = parseISO(start);
|
||||
const newEnd = parseISO(end);
|
||||
const newHasTimes = hasTimes && !!startTimeVal && !!endTimeVal;
|
||||
const newStart = newHasTimes
|
||||
? toMoment(start, startTimeVal, 'start')
|
||||
: parseISO(start);
|
||||
const newEnd = newHasTimes
|
||||
? toMoment(end, endTimeVal, 'end')
|
||||
: parseISO(end);
|
||||
|
||||
const toCheck = existingReservations.filter(r => {
|
||||
if (mode === 'edit' && r.id === initialData?.id) return false;
|
||||
return r.property === currentProperty;
|
||||
});
|
||||
return toCheck.some(res => {
|
||||
const resStart = parseISO(res.start_date);
|
||||
const resEnd = parseISO(res.end_date);
|
||||
const resHasTimes = !!res.start_time && !!res.end_time;
|
||||
// Solo usamos horas si ambas reservas las tienen. Si una no tiene horas
|
||||
// se trata como día completo y bloquea la otra (comportamiento conservador).
|
||||
const useTimes = newHasTimes && resHasTimes;
|
||||
const resStart = useTimes
|
||||
? toMoment(res.start_date, res.start_time, 'start')
|
||||
: parseISO(res.start_date);
|
||||
const resEnd = useTimes
|
||||
? toMoment(res.end_date, res.end_time, 'end')
|
||||
: parseISO(res.end_date);
|
||||
return newStart < resEnd && newEnd > resStart;
|
||||
});
|
||||
};
|
||||
@@ -168,6 +206,11 @@ export function ReservationModal({
|
||||
property,
|
||||
};
|
||||
|
||||
if (hasTimes) {
|
||||
if (data.start_time) saveData.start_time = data.start_time;
|
||||
if (data.end_time) saveData.end_time = data.end_time;
|
||||
}
|
||||
|
||||
if (isEvent) {
|
||||
saveData.is_event = true;
|
||||
if (data.event_type) saveData.event_type = data.event_type;
|
||||
@@ -363,6 +406,7 @@ export function ReservationModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Canon — usa el conteo de huéspedes (adultos + niños) */}
|
||||
{totalPeople > 0 && (
|
||||
<motion.div
|
||||
@@ -482,6 +526,70 @@ export function ReservationModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4b. Horarios opcionales — para cualquier reserva (Teneriffa o Natur) */}
|
||||
<div className="bg-slate-800/60 border border-slate-700 rounded-2xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-emerald-400" />
|
||||
<span className="font-semibold text-slate-200 text-sm">Definir horarios de entrada y salida</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHasTimes(v => {
|
||||
if (v) {
|
||||
// Al desactivar, limpiamos los valores
|
||||
setValue('start_time', '');
|
||||
setValue('end_time', '');
|
||||
}
|
||||
return !v;
|
||||
});
|
||||
}}
|
||||
className={`w-11 h-6 rounded-full transition-all duration-300 relative flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 ${hasTimes ? 'bg-emerald-500' : 'bg-slate-600'}`}
|
||||
aria-pressed={hasTimes}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-300 ${hasTimes ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{hasTimes && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex gap-3 p-3 rounded-xl bg-slate-700/60 border border-slate-600">
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold tracking-wider">Hora entrada</label>
|
||||
<input
|
||||
type="time"
|
||||
step="900"
|
||||
{...register('start_time')}
|
||||
className="bg-transparent w-full font-bold text-white mt-1 focus:outline-none [color-scheme:dark] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px bg-slate-600 self-stretch" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] text-slate-400 uppercase font-bold tracking-wider">Hora salida</label>
|
||||
<input
|
||||
type="time"
|
||||
step="900"
|
||||
{...register('end_time')}
|
||||
className="bg-transparent w-full font-bold text-white mt-1 focus:outline-none [color-scheme:dark] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1.5 ml-1">
|
||||
Permite encajar varias reservas el mismo día sin que se solapen (p. ej. evento que acaba a las 12:00 y otro que empieza a las 17:00).
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 5. Huéspedes */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Huéspedes</label>
|
||||
|
||||
@@ -218,7 +218,8 @@ export function YearlyCalendar({
|
||||
const monthStart = startOfMonth(month);
|
||||
const monthEnd = endOfMonth(month);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
const startDayOfWeek = getDay(monthStart);
|
||||
// Semana empieza en lunes: domingo (0) pasa a posición 6, lunes (1) a 0...
|
||||
const startDayOfWeek = (getDay(monthStart) + 6) % 7;
|
||||
const emptySlots = Array(startDayOfWeek).fill(null);
|
||||
|
||||
return (
|
||||
@@ -228,7 +229,7 @@ export function YearlyCalendar({
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['D', 'L', 'M', 'X', 'J', 'V', 'S'].map(d => (
|
||||
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map(d => (
|
||||
<div key={d} className="text-center text-[10px] font-bold text-slate-500">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,11 @@ export interface Reservation {
|
||||
event_type?: string; // 'Boda' | 'Comunión' | 'Cumpleaños' | 'Evento privado' | 'Corporativo' | 'Otro'
|
||||
event_type_other?: string; // descripción libre cuando event_type === 'Otro'
|
||||
attendees_count?: number; // número de asistentes al evento
|
||||
// Horarios opcionales de entrada/salida (HH:mm). Aplicables a cualquier reserva.
|
||||
// Cuando ambos están definidos, dos reservas pueden compartir el mismo día/fecha
|
||||
// siempre que sus rangos horarios no se solapen.
|
||||
start_time?: string; // HH:mm
|
||||
end_time?: string; // HH:mm
|
||||
}
|
||||
|
||||
export type NewReservation = Omit<Reservation, 'id' | 'created_at'>;
|
||||
|
||||
11
supabase/migrations/011_add_event_times.sql
Normal file
11
supabase/migrations/011_add_event_times.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Horarios opcionales de entrada/salida para cualquier reserva.
|
||||
-- Permite que dos reservas compartan el mismo día siempre que sus rangos horarios no se solapen
|
||||
-- (p. ej. una boda que termina a las 12:00 y un cumpleaños que empieza a las 17:00).
|
||||
-- Aplicable tanto a Naturcalabacera (eventos) como a Teneriffa.
|
||||
|
||||
ALTER TABLE natur_reservas.reservations
|
||||
ADD COLUMN IF NOT EXISTS start_time TIME,
|
||||
ADD COLUMN IF NOT EXISTS end_time TIME;
|
||||
|
||||
COMMENT ON COLUMN natur_reservas.reservations.start_time IS 'Hora de entrada (HH:mm). Opcional. Si está presente junto con end_time, se usa para detectar solapamientos por horario.';
|
||||
COMMENT ON COLUMN natur_reservas.reservations.end_time IS 'Hora de salida (HH:mm). Opcional. Si está presente junto con start_time, se usa para detectar solapamientos por horario.';
|
||||
Reference in New Issue
Block a user