diff --git a/.gitignore b/.gitignore index 868bfeb..9fb823d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +# Local email preview artifacts +apps/api/preview-out/ + # Env files .env .env.* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8ef435 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/apps/api/scripts/preview-teneriffa-email.mjs b/apps/api/scripts/preview-teneriffa-email.mjs new file mode 100644 index 0000000..9cdc171 --- /dev/null +++ b/apps/api/scripts/preview-teneriffa-email.mjs @@ -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 ` + + +
+
+

${actionLabel}

+
+
+

Fechas

+

${dateRange}

+

Propiedad

+

${property}

+
+
+`; +} + +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.'); diff --git a/apps/api/scripts/send-test-teneriffa-email.mjs b/apps/api/scripts/send-test-teneriffa-email.mjs new file mode 100644 index 0000000..8f98cf9 --- /dev/null +++ b/apps/api/scripts/send-test-teneriffa-email.mjs @@ -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 + +// .env se carga vía `node --env-file=.env` +const WEBHOOK = process.env.N8N_EMAIL_WEBHOOK_URL; +const FROM = process.env.EMAIL_FROM ?? 'Naturcalabacera '; +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 ` + + +
+
+

${actionLabel}

+
+
+

Fechas

+

${dateRange}

+

Propiedad

+

${property}

+
+
+`; +} + +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); diff --git a/apps/api/scripts/send-test-teneriffa-variants.mjs b/apps/api/scripts/send-test-teneriffa-variants.mjs new file mode 100644 index 0000000..ec075ca --- /dev/null +++ b/apps/api/scripts/send-test-teneriffa-variants.mjs @@ -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 + +const WEBHOOK = process.env.N8N_EMAIL_WEBHOOK_URL; +const FROM = process.env.EMAIL_FROM ?? 'Naturcalabacera '; +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 ` + + +
+
+

${actionLabel}

+
+
+

Fechas

+

${dateRange}

+

Propiedad

+

${property}

+
+
+`; +} + +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); +} diff --git a/apps/api/src/events/handler.ts b/apps/api/src/events/handler.ts index 9b3cdee..8e438d5 100644 --- a/apps/api/src/events/handler.ts +++ b/apps/api/src/events/handler.ts @@ -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 ` + + +
+
+

${actionLabel}

+
+
+

Fechas

+

${dateRange}

+

Propiedad

+

${property}

+
+
+`; +} + +async function sendCrudPair(opts: { + actionLabel: string; + subjectTag: string; + dateRange: string; + property: string; + template: 'teneriffa-crud' | 'natur-crud'; + vars: Record; + originLabel: string; + clientName: string; + cancelled?: boolean; +}): Promise { + // 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: `

Esta reserva ha sido cancelada y eliminada del sistema.

`, }; - 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': { diff --git a/apps/web/src/components/CalendarGrid.tsx b/apps/web/src/components/CalendarGrid.tsx index 2829520..70f0cd2 100644 --- a/apps/web/src/components/CalendarGrid.tsx +++ b/apps/web/src/components/CalendarGrid.tsx @@ -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) --- + // --- 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); + + // Reservas de un solo día agrupadas por su celda, ordenadas por hora de inicio + const singleDayByCell = new Map(); + 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)]'; + + // --- Render --- const renderReservationBlocks = () => { const blocks: React.ReactElement[] = []; - reservations.forEach((res) => { + // 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); - 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; - - 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'; - const textShadow = 'drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]'; - - let currentDayIndex = startIndex; + let currentDayIndex = startIdx; let blockIndex = 0; - - while (currentDayIndex <= endIndex) { + 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(
{ + 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 */}
{viewerMode ? 'Ocupado' : res.client_name} @@ -295,8 +325,6 @@ export function CalendarGrid({
)}
- - {/* Mobile */}
{daysInThisWeek > 1 && ( @@ -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( +
{ + 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 */} +
+ {hasTimes && ( + + {res.start_time?.slice(0, 5)}–{res.end_time?.slice(0, 5)} + + )} + + {viewerMode ? 'Ocupado' : res.client_name} + + {!isSplit && !viewerMode && ( + + + {res.adults_count + res.children_count}p + + )} +
+ + {/* Mobile: hora si está partido, nombre si hay sitio */} +
+ {hasTimes && isSplit ? ( + + {res.start_time?.slice(0, 5)} + + ) : ( + + {viewerMode ? 'Ocupado' : res.client_name} + + )} +
+
+ ); + }); + }); + return blocks; }; @@ -419,10 +517,10 @@ export function CalendarGrid({ {/* Days header */}
- {['D', 'L', 'M', 'X', 'J', 'V', 'S'].map((day, i) => ( + {['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((day, i) => (
{day} - {['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'][i]} + {['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'][i]}
))}
diff --git a/apps/web/src/components/CustomMobileCalendar.tsx b/apps/web/src/components/CustomMobileCalendar.tsx index c7262e7..e4e606e 100644 --- a/apps/web/src/components/CustomMobileCalendar.tsx +++ b/apps/web/src/components/CustomMobileCalendar.tsx @@ -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 */}
- {['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(day => ( + {['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map(day => (
{day}
diff --git a/apps/web/src/components/ReservationModal.tsx b/apps/web/src/components/ReservationModal.tsx index e5af702..9c15b8f 100644 --- a/apps/web/src/components/ReservationModal.tsx +++ b/apps/web/src/components/ReservationModal.tsx @@ -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(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 && (
+ {/* 4b. Horarios opcionales — para cualquier reserva (Teneriffa o Natur) */} +
+
+
+ + Definir horarios de entrada y salida +
+ +
+ + + {hasTimes && ( + +
+
+ + +
+
+
+ + +
+
+

+ 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). +

+ + )} + +
+ {/* 5. Huéspedes */}
diff --git a/apps/web/src/components/YearlyCalendar.tsx b/apps/web/src/components/YearlyCalendar.tsx index 50fc3f7..bc4a797 100644 --- a/apps/web/src/components/YearlyCalendar.tsx +++ b/apps/web/src/components/YearlyCalendar.tsx @@ -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({
- {['D', 'L', 'M', 'X', 'J', 'V', 'S'].map(d => ( + {['L', 'M', 'X', 'J', 'V', 'S', 'D'].map(d => (
{d}
))}
diff --git a/packages/shared/src/types/reservation.ts b/packages/shared/src/types/reservation.ts index c6c4f44..7dbe870 100644 --- a/packages/shared/src/types/reservation.ts +++ b/packages/shared/src/types/reservation.ts @@ -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; diff --git a/supabase/migrations/011_add_event_times.sql b/supabase/migrations/011_add_event_times.sql new file mode 100644 index 0000000..44a7659 --- /dev/null +++ b/supabase/migrations/011_add_event_times.sql @@ -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.';