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:
2026-05-12 11:53:34 +01:00
parent f9a8d83e5e
commit 4ce80b8fc0
12 changed files with 619 additions and 59 deletions

3
.gitignore vendored
View File

@@ -12,6 +12,9 @@ dist
dist-ssr dist-ssr
*.local *.local
# Local email preview artifacts
apps/api/preview-out/
# Env files # Env files
.env .env
.env.* .env.*

71
CLAUDE.md Normal file
View 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.

View 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.');

View 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:0011: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:0020:00):', r2);

View 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);
}

View File

@@ -146,6 +146,89 @@ interface SendResult {
error?: string; 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. * Envía el email correspondiente a un evento de notificación.
* Para reservation.updated acepta previousReservation para mostrar el diff. * Para reservation.updated acepta previousReservation para mostrar el diff.
@@ -189,12 +272,24 @@ export async function handleNotificationEvent(
ACTION_LABEL: 'Nueva Reserva', ACTION_LABEL: 'Nueva Reserva',
CHANGES_BLOCK: '', CHANGES_BLOCK: '',
}; };
const html = renderTemplate(template, vars); return sendCrudPair({
const subject = `[NUEVA RESERVA] ${originLabel}${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`; actionLabel: 'Nueva Reserva',
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html }); subjectTag: 'NUEVA RESERVA',
dateRange,
property: propertyLabel(reservation.property),
template,
vars,
originLabel,
clientName: reservation.client_name,
});
} }
case 'reservation.updated': { 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 isTeenriffa = reservation.origin === 'Teneriffa2000';
const template = isTeenriffa ? 'teneriffa-crud' : 'natur-crud'; const template = isTeenriffa ? 'teneriffa-crud' : 'natur-crud';
const originLabel = isTeenriffa ? 'Teneriffa' : 'Natur'; const originLabel = isTeenriffa ? 'Teneriffa' : 'Natur';
@@ -207,9 +302,16 @@ export async function handleNotificationEvent(
ACTION_LABEL: 'Reserva Modificada', ACTION_LABEL: 'Reserva Modificada',
CHANGES_BLOCK: changesBlock, CHANGES_BLOCK: changesBlock,
}; };
const html = renderTemplate(template, vars); return sendCrudPair({
const subject = `[MODIFICADA] ${originLabel}${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`; actionLabel: 'Reserva Modificada',
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html }); subjectTag: 'MODIFICADA',
dateRange,
property: propertyLabel(reservation.property),
template,
vars,
originLabel,
clientName: reservation.client_name,
});
} }
case 'reservation.cancelled': { case 'reservation.cancelled': {
@@ -223,9 +325,17 @@ export async function handleNotificationEvent(
CHANGES_BLOCK: '', CHANGES_BLOCK: '',
CANCEL_ALERT: `<div class="cancel-alert"><p>Esta reserva ha sido cancelada y eliminada del sistema.</p></div>`, CANCEL_ALERT: `<div class="cancel-alert"><p>Esta reserva ha sido cancelada y eliminada del sistema.</p></div>`,
}; };
const html = renderTemplate(template, vars); return sendCrudPair({
const subject = `[CANCELADA] ${originLabel}${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`; actionLabel: 'Reserva Cancelada',
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html }); subjectTag: 'CANCELADA',
dateRange,
property: propertyLabel(reservation.property),
template,
vars,
originLabel,
clientName: reservation.client_name,
cancelled: true,
});
} }
case 'reservation.reminder_24h': { case 'reservation.reminder_24h': {

View File

@@ -44,8 +44,8 @@ export function CalendarGrid({
const monthStart = startOfMonth(currentDate); const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(monthStart); const monthEnd = endOfMonth(monthStart);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 }); const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 }); const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd }); const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
@@ -218,56 +218,87 @@ export function CalendarGrid({
return () => document.removeEventListener('mouseup', cancel); return () => document.removeEventListener('mouseup', cancel);
}, [isDragging]); }, [isDragging]);
// --- Reservation blocks (visual only — clicks handled at grid body level) --- // --- Clasificación de reservas para layout ---
const renderReservationBlocks = () => { // Las reservas de un solo día se renderizan dentro de su celda; si hay varias en la
const blocks: React.ReactElement[] = []; // 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) => { // Reservas de un solo día agrupadas por su celda, ordenadas por hora de inicio
const startDate = parseISO(res.start_date); const singleDayByCell = new Map<number, Reservation[]>();
const endDate = parseISO(res.end_date); const multiDayRanges: ResRange[] = [];
for (const r of resRanges) {
const startIndex = calendarDays.findIndex(day => isSameDay(day, startDate)); if (r.startIdx === r.endIdx) {
if (startIndex === -1) return; const arr = singleDayByCell.get(r.startIdx) ?? [];
arr.push(r.res);
const endIndex = calendarDays.findIndex(day => isSameDay(day, endDate)); singleDayByCell.set(r.startIdx, arr);
if (endIndex === -1) return; } else {
multiDayRanges.push(r);
const totalDuration = differenceInDays(endDate, startDate) + 1; }
const nights = totalDuration - 1; }
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 isTeneriffa = res.origin === 'Teneriffa2000';
const gradient = viewerMode const gradient = viewerMode
? 'bg-stone-400/30 dark:bg-stone-500/30' ? 'bg-stone-400/30 dark:bg-stone-500/30'
: isTeneriffa : isTeneriffa
? 'bg-blue-600/30 dark:bg-blue-500/30' ? 'bg-blue-600/30 dark:bg-blue-500/30'
: 'bg-yellow-500/30 dark:bg-yellow-400/30'; : 'bg-yellow-500/30 dark:bg-yellow-400/30';
const borderClass = viewerMode const borderClass = viewerMode
? 'border-l-4 border-stone-400' ? 'border-l-4 border-stone-400'
: isTeneriffa : isTeneriffa
? 'border-l-4 border-blue-500' ? 'border-l-4 border-blue-500'
: 'border-l-4 border-yellow-500'; : 'border-l-4 border-yellow-500';
return { gradient, borderClass };
};
const textShadow = 'drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]'; const textShadow = 'drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]';
let currentDayIndex = startIndex; // --- Render ---
let blockIndex = 0; 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 weekIndex = Math.floor(currentDayIndex / 7);
const dayOfWeek = currentDayIndex % 7; const dayOfWeek = currentDayIndex % 7;
const daysUntilWeekEnd = 7 - dayOfWeek; const daysUntilWeekEnd = 7 - dayOfWeek;
const daysRemaining = endIndex - currentDayIndex + 1; const daysRemaining = endIdx - currentDayIndex + 1;
const daysInThisWeek = Math.min(daysUntilWeekEnd, daysRemaining); const daysInThisWeek = Math.min(daysUntilWeekEnd, daysRemaining);
const isFirstBlock = blockIndex === 0; const isFirstBlock = blockIndex === 0;
blocks.push( blocks.push(
<div <div
key={`${res.id}-week-${weekIndex}`} key={`${res.id}-week-${weekIndex}`}
onClick={(ev) => {
if (viewerMode) return;
ev.stopPropagation();
dragJustFinished.current = true;
onSelectReservation(res);
}}
className={` className={`
absolute pointer-events-none absolute ${gradient} ${borderClass} z-10
${gradient} ${borderClass}
z-10
flex flex-col justify-end items-center md:items-start md:p-1.5 flex flex-col justify-end items-center md:items-start md:p-1.5
${viewerMode ? 'pointer-events-none' : 'cursor-pointer hover:brightness-110'}
`} `}
style={{ style={{
top: `calc(${weekIndex} * var(--cell-height))`, top: `calc(${weekIndex} * var(--cell-height))`,
@@ -276,7 +307,6 @@ export function CalendarGrid({
height: 'var(--cell-height)', height: 'var(--cell-height)',
}} }}
> >
{/* Desktop */}
<div className="hidden md:block w-full"> <div className="hidden md:block w-full">
<div className={`text-xs font-black text-white truncate ${textShadow} px-1`}> <div className={`text-xs font-black text-white truncate ${textShadow} px-1`}>
{viewerMode ? 'Ocupado' : res.client_name} {viewerMode ? 'Ocupado' : res.client_name}
@@ -295,8 +325,6 @@ export function CalendarGrid({
</div> </div>
)} )}
</div> </div>
{/* Mobile */}
<div className="md:hidden w-full flex items-end justify-between pb-0.5 px-0.5"> <div className="md:hidden w-full flex items-end justify-between pb-0.5 px-0.5">
{daysInThisWeek > 1 && ( {daysInThisWeek > 1 && (
<span className={`text-[8px] font-black text-white/90 truncate uppercase tracking-tight ${textShadow}`}> <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; return blocks;
}; };
@@ -419,10 +517,10 @@ export function CalendarGrid({
{/* Days header */} {/* 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"> <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"> <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="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>
))} ))}
</div> </div>

View File

@@ -21,8 +21,8 @@ export function CustomMobileCalendar({ reservations, onSelectRange, onSelectRese
const monthStart = startOfMonth(currentDate); const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(monthStart); const monthEnd = endOfMonth(monthStart);
const startDate = startOfWeek(monthStart, { locale: es }); const startDate = startOfWeek(monthStart, { weekStartsOn: 1, locale: es });
const endDate = endOfWeek(monthEnd, { locale: es }); const endDate = endOfWeek(monthEnd, { weekStartsOn: 1, locale: es });
const days = eachDayOfInterval({ start: startDate, end: endDate }); const days = eachDayOfInterval({ start: startDate, end: endDate });
@@ -91,7 +91,7 @@ export function CustomMobileCalendar({ reservations, onSelectRange, onSelectRese
{/* Week Days */} {/* Week Days */}
<div className="grid grid-cols-7 px-3 py-2 bg-gray-50"> <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"> <div key={day} className="text-center text-[11px] font-semibold text-gray-500 uppercase tracking-wide py-1">
{day} {day}
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import type { NewReservation, Reservation, Property } from '../types'; 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 { differenceInDays, parseISO } from 'date-fns';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { PROPERTY_CONFIG, getExtraPersonRate } from '@naturcalabacera/shared'; 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) // Event toggle — local state (not a form field, controls section visibility)
const [isEvent, setIsEvent] = useState(false); 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). // Override manual de la tarifa por persona extra (€/pax/noche).
// null = usar tarifa automática por año. // null = usar tarifa automática por año.
const [extraRateOverride, setExtraRateOverride] = useState<number | null>(null); const [extraRateOverride, setExtraRateOverride] = useState<number | null>(null);
@@ -58,6 +61,10 @@ export function ReservationModal({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
reset({ reset({
client_name: '',
start_date: '',
end_date: '',
invoice_number: '',
origin: 'Teneriffa2000', origin: 'Teneriffa2000',
adults_count: 2, adults_count: 2,
children_count: 0, children_count: 0,
@@ -70,9 +77,12 @@ export function ReservationModal({
event_type: '', event_type: '',
event_type_other: '', event_type_other: '',
attendees_count: 0, attendees_count: 0,
start_time: '',
end_time: '',
...initialData, ...initialData,
}); });
setIsEvent(initialData?.is_event ?? false); setIsEvent(initialData?.is_event ?? false);
setHasTimes(Boolean(initialData?.start_time || initialData?.end_time));
const snapshotOverride = initialData?.pricing_snapshot?.extraPersonRateOverride; const snapshotOverride = initialData?.pricing_snapshot?.extraPersonRateOverride;
setExtraRateOverride(snapshotOverride ?? null); setExtraRateOverride(snapshotOverride ?? null);
clearErrors(); clearErrors();
@@ -85,6 +95,8 @@ export function ReservationModal({
const children = watch('children_count'); const children = watch('children_count');
const origin = watch('origin'); const origin = watch('origin');
const eventType = watch('event_type'); const eventType = watch('event_type');
const startTimeVal = watch('start_time');
const endTimeVal = watch('end_time');
const totalDays = startDate && endDate const totalDays = startDate && endDate
? Math.max(0, differenceInDays(parseISO(endDate), parseISO(startDate))) ? Math.max(0, differenceInDays(parseISO(endDate), parseISO(startDate)))
@@ -116,18 +128,44 @@ export function ReservationModal({
* Overlap check — intervalos semi-abiertos [start, end). * Overlap check — intervalos semi-abiertos [start, end).
* El día de salida de una reserva existente SÍ permite entrada ese mismo día: * 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). * 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 checkOverlap = (start: string, end: string, currentProperty: Property): boolean => {
const newStart = parseISO(start); const newHasTimes = hasTimes && !!startTimeVal && !!endTimeVal;
const newEnd = parseISO(end); const newStart = newHasTimes
? toMoment(start, startTimeVal, 'start')
: parseISO(start);
const newEnd = newHasTimes
? toMoment(end, endTimeVal, 'end')
: parseISO(end);
const toCheck = existingReservations.filter(r => { const toCheck = existingReservations.filter(r => {
if (mode === 'edit' && r.id === initialData?.id) return false; if (mode === 'edit' && r.id === initialData?.id) return false;
return r.property === currentProperty; return r.property === currentProperty;
}); });
return toCheck.some(res => { return toCheck.some(res => {
const resStart = parseISO(res.start_date); const resHasTimes = !!res.start_time && !!res.end_time;
const resEnd = parseISO(res.end_date); // 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; return newStart < resEnd && newEnd > resStart;
}); });
}; };
@@ -168,6 +206,11 @@ export function ReservationModal({
property, 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) { if (isEvent) {
saveData.is_event = true; saveData.is_event = true;
if (data.event_type) saveData.event_type = data.event_type; 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) */} {/* Canon — usa el conteo de huéspedes (adultos + niños) */}
{totalPeople > 0 && ( {totalPeople > 0 && (
<motion.div <motion.div
@@ -482,6 +526,70 @@ export function ReservationModal({
</div> </div>
</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 */} {/* 5. Huéspedes */}
<div> <div>
<label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Huéspedes</label> <label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Huéspedes</label>

View File

@@ -218,7 +218,8 @@ export function YearlyCalendar({
const monthStart = startOfMonth(month); const monthStart = startOfMonth(month);
const monthEnd = endOfMonth(month); const monthEnd = endOfMonth(month);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); 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); const emptySlots = Array(startDayOfWeek).fill(null);
return ( return (
@@ -228,7 +229,7 @@ export function YearlyCalendar({
</h3> </h3>
<div className="grid grid-cols-7 gap-1 mb-2"> <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 key={d} className="text-center text-[10px] font-bold text-slate-500">{d}</div>
))} ))}
</div> </div>

View File

@@ -49,6 +49,11 @@ export interface Reservation {
event_type?: string; // 'Boda' | 'Comunión' | 'Cumpleaños' | 'Evento privado' | 'Corporativo' | 'Otro' event_type?: string; // 'Boda' | 'Comunión' | 'Cumpleaños' | 'Evento privado' | 'Corporativo' | 'Otro'
event_type_other?: string; // descripción libre cuando event_type === 'Otro' event_type_other?: string; // descripción libre cuando event_type === 'Otro'
attendees_count?: number; // número de asistentes al evento 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'>; export type NewReservation = Omit<Reservation, 'id' | 'created_at'>;

View 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.';