- 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>
156 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|