Files
Gesti-n-Reservas-Naturcalab…/apps/web/src/components/CustomMobileCalendar.tsx
Kilian 4ce80b8fc0 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>
2026-05-12 11:53:34 +01:00

156 lines
5.2 KiB
TypeScript

import { useState } from 'react';
import {
format, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameMonth, isSameDay, addMonths, subMonths,
isWithinInterval, parseISO, isBefore
} from 'date-fns';
import { es } from 'date-fns/locale/es';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import type { Reservation } from '../types';
interface Props {
reservations: Reservation[];
onSelectRange: (start: Date, end: Date) => void;
onSelectReservation: (reservation: Reservation) => void;
}
export function CustomMobileCalendar({ reservations, onSelectRange, onSelectReservation }: Props) {
const [currentDate, setCurrentDate] = useState(new Date());
const [selectionStart, setSelectionStart] = useState<Date | null>(null);
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(monthStart);
const startDate = startOfWeek(monthStart, { weekStartsOn: 1, locale: es });
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1, locale: es });
const days = eachDayOfInterval({ start: startDate, end: endDate });
const nextMonth = () => setCurrentDate(addMonths(currentDate, 1));
const prevMonth = () => setCurrentDate(subMonths(currentDate, 1));
const getReservationForDay = (day: Date) => {
return reservations.find(res =>
isWithinInterval(day, { start: parseISO(res.start_date), end: parseISO(res.end_date) })
);
};
const rangeHasOverlap = (start: Date, end: Date) => {
const rangeDays = eachDayOfInterval({ start, end });
return rangeDays.some(day => getReservationForDay(day));
};
const handleDayClick = (day: Date) => {
const existingRes = getReservationForDay(day);
if (existingRes) {
onSelectReservation(existingRes);
setSelectionStart(null);
return;
}
if (!selectionStart) {
setSelectionStart(day);
return;
}
let start = selectionStart;
let end = day;
if (isBefore(day, selectionStart)) {
start = day;
end = selectionStart;
}
if (rangeHasOverlap(start, end)) {
toast.error("No puedes seleccionar un rango que incluya días ya reservados.");
setSelectionStart(null);
return;
}
onSelectRange(start, end);
setSelectionStart(null);
};
return (
<div className="bg-white rounded-3xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 bg-white border-b border-gray-100">
<h2 className="text-lg font-bold text-gray-900 capitalize">
{format(currentDate, 'MMMM yyyy', { locale: es })}
</h2>
<div className="flex space-x-1">
<button onClick={prevMonth} className="p-2 rounded-lg hover:bg-gray-50">
<ChevronLeft size={20} className="text-gray-600" />
</button>
<button onClick={nextMonth} className="p-2 rounded-lg hover:bg-gray-50">
<ChevronRight size={20} className="text-gray-600" />
</button>
</div>
</div>
{/* Week Days */}
<div className="grid grid-cols-7 px-3 py-2 bg-gray-50">
{['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>
))}
</div>
{/* Days Grid - NUEVO DISEÑO TIPO AIRBNB */}
<div className="grid grid-cols-7 gap-0 p-3 bg-white">
{days.map((day) => {
const dayRes = getReservationForDay(day);
const isSelected = selectionStart && isSameDay(day, selectionStart);
const isCurrentMonth = isSameMonth(day, monthStart);
// Palette de colores según referencia
let bgColor = 'bg-white';
let numberBgColor = '';
let numberTextColor = 'text-gray-400';
if (dayRes) {
if (dayRes.origin === 'Teneriffa2000') {
bgColor = 'bg-blue-100'; // Fondo azul pastel
numberBgColor = 'bg-blue-500'; // Círculo azul intenso
numberTextColor = 'text-white';
} else {
bgColor = 'bg-yellow-100'; // Fondo amarillo pastel
numberBgColor = 'bg-yellow-500'; // Círculo amarillo intenso
numberTextColor = 'text-white';
}
} else if (isSelected) {
bgColor = 'bg-gray-800';
numberBgColor = 'bg-white';
numberTextColor = 'text-gray-800';
} else if (isCurrentMonth) {
numberTextColor = 'text-gray-900';
}
return (
<div
key={day.toString()}
onClick={() => handleDayClick(day)}
className={`
relative h-12 flex items-center justify-center cursor-pointer
transition-all duration-150
${bgColor}
`}
>
<div className={`
w-8 h-8 rounded-full flex items-center justify-center
text-sm font-semibold
${numberBgColor}
${numberTextColor}
`}>
{format(day, 'd')}
</div>
</div>
);
})}
</div>
</div>
);
}