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

View File

@@ -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<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)]';
// --- 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(
<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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>