import { useEffect, useRef, useState } from 'react'; import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, addMonths, subMonths, isSameDay, differenceInDays, parseISO, isWithinInterval } from 'date-fns'; import { es } from 'date-fns/locale/es'; import { ChevronLeft, ChevronRight, Users, Moon } from 'lucide-react'; import type { Reservation } from '../types'; import { useProperty } from '../contexts/PropertyContext'; import { PROPERTY_CONFIG } from '@naturcalabacera/shared'; import { ServiceIcons } from './ServiceIcons'; interface Props { reservations: Reservation[]; onSelectDay: (day: Date) => void; onSelectReservation: (reservation: Reservation) => void; onSelectRange?: (start: Date, end: Date) => void; isLoading?: boolean; viewerMode?: boolean; } export function CalendarGrid({ reservations, onSelectDay, onSelectReservation, onSelectRange, isLoading: _isLoading = false, viewerMode = false, }: Props) { const { property } = useProperty(); const propertyConfig = PROPERTY_CONFIG[property]; const [currentDate, setCurrentDate] = useState(new Date()); // Drag-to-select state const [dragStart, setDragStart] = useState(null); const [dragEnd, setDragEnd] = useState(null); const [isDragging, setIsDragging] = useState(false); const gridBodyRef = useRef(null); // Tracks mouse movement to distinguish click vs drag const mouseMoved = useRef(false); // Prevents the click event that fires after mouseUp from triggering onSelectDay const dragJustFinished = useRef(false); const monthStart = startOfMonth(currentDate); const monthEnd = endOfMonth(monthStart); const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 }); const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 }); const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd }); const weeks: Date[][] = []; for (let i = 0; i < calendarDays.length; i += 7) { weeks.push(calendarDays.slice(i, i + 7)); } const nextMonth = () => setCurrentDate(addMonths(currentDate, 1)); const prevMonth = () => setCurrentDate(subMonths(currentDate, 1)); // Display occupancy: a day is occupied for rendering if within [start, end] inclusive const isDayOccupied = (day: Date): boolean => { return reservations.some(res => { const s = parseISO(res.start_date); const e = parseISO(res.end_date); return isWithinInterval(day, { start: s, end: e }) || isSameDay(day, s) || isSameDay(day, e); }); }; // Get reservation for a specific day (for click-through on occupied cells) const getReservationForDay = (day: Date): Reservation | undefined => { return reservations.find(res => { const s = parseISO(res.start_date); const e = parseISO(res.end_date); return isWithinInterval(day, { start: s, end: e }) || isSameDay(day, s) || isSameDay(day, e); }); }; // Calculate which calendar day is under a given coordinate const getDayFromCoords = (clientX: number, clientY: number): Date | null => { const el = gridBodyRef.current; if (!el) return null; const rect = el.getBoundingClientRect(); const x = clientX - rect.left; const y = clientY - rect.top; if (x < 0 || x >= rect.width || y < 0 || y >= rect.height) return null; const col = Math.min(Math.floor(x / (rect.width / 7)), 6); const row = Math.min(Math.floor(y / (rect.height / weeks.length)), weeks.length - 1); return weeks[row]?.[col] ?? null; }; // Is a day inside the current drag selection range? const isInDragSelection = (day: Date): boolean => { if (!dragStart || !dragEnd) return false; const start = dragStart <= dragEnd ? dragStart : dragEnd; const end = dragStart <= dragEnd ? dragEnd : dragStart; return day >= start && day <= end; }; // Confirm drag and open modal const confirmDrag = (start: Date, end: Date) => { const s = start <= end ? start : end; const e = start <= end ? end : start; if (isSameDay(s, e)) { onSelectDay(s); } else if (onSelectRange) { onSelectRange(s, e); } else { onSelectDay(s); } }; // --- Grid body mouse events --- const handleGridMouseDown = (ev: React.MouseEvent) => { if (viewerMode) return; const day = getDayFromCoords(ev.clientX, ev.clientY); if (!day) return; // Allow drag start on any day (free or occupied boundary) // Drag is only initiated on non-occupied days if (isDayOccupied(day)) return; ev.preventDefault(); mouseMoved.current = false; setDragStart(day); setDragEnd(day); setIsDragging(true); }; const handleGridMouseMove = (ev: React.MouseEvent) => { if (!isDragging || !dragStart) return; mouseMoved.current = true; const day = getDayFromCoords(ev.clientX, ev.clientY); if (day) setDragEnd(day); }; const handleGridMouseUp = (ev: React.MouseEvent) => { if (!isDragging || !dragStart || !dragEnd) return; const hasMoved = mouseMoved.current; const start = dragStart; const end = dragEnd; setIsDragging(false); setDragStart(null); setDragEnd(null); if (hasMoved && !isSameDay(start, end)) { dragJustFinished.current = true; confirmDrag(start, end); } // Single clicks are handled entirely by handleGridClick ev.stopPropagation(); }; // Handle click on grid body (for free-day single clicks when drag is not involved) const handleGridClick = (ev: React.MouseEvent) => { if (viewerMode || isDragging) return; // Ignore the synthetic click that fires immediately after a drag ends if (dragJustFinished.current) { dragJustFinished.current = false; return; } const day = getDayFromCoords(ev.clientX, ev.clientY); if (!day) return; const res = getReservationForDay(day); if (res) { // Click on occupied day — open reservation onSelectReservation(res); } else { // Click on free day — open create modal onSelectDay(day); } }; // --- Touch events (mobile drag) --- const handleTouchStart = (ev: React.TouchEvent) => { if (viewerMode) return; const touch = ev.touches[0]; const day = getDayFromCoords(touch.clientX, touch.clientY); if (!day || isDayOccupied(day)) return; mouseMoved.current = false; setDragStart(day); setDragEnd(day); setIsDragging(true); }; const handleTouchMove = (ev: React.TouchEvent) => { if (!isDragging) return; mouseMoved.current = true; const touch = ev.touches[0]; const day = getDayFromCoords(touch.clientX, touch.clientY); if (day) setDragEnd(day); }; const handleTouchEnd = () => { if (!isDragging || !dragStart || !dragEnd) return; const hasMoved = mouseMoved.current; const start = dragStart; const end = dragEnd; setIsDragging(false); setDragStart(null); setDragEnd(null); if (hasMoved && !isSameDay(start, end)) { dragJustFinished.current = true; confirmDrag(start, end); } else { onSelectDay(start); } }; // Cancel drag if mouse leaves the window useEffect(() => { const cancel = () => { if (isDragging) { setIsDragging(false); setDragStart(null); setDragEnd(null); } }; document.addEventListener('mouseup', cancel); return () => document.removeEventListener('mouseup', cancel); }, [isDragging]); // --- Reservation blocks (visual only — clicks handled at grid body level) --- const renderReservationBlocks = () => { const blocks: React.ReactElement[] = []; reservations.forEach((res) => { const startDate = parseISO(res.start_date); const endDate = parseISO(res.end_date); 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 blockIndex = 0; while (currentDayIndex <= endIndex) { const weekIndex = Math.floor(currentDayIndex / 7); const dayOfWeek = currentDayIndex % 7; const daysUntilWeekEnd = 7 - dayOfWeek; const daysRemaining = endIndex - currentDayIndex + 1; const daysInThisWeek = Math.min(daysUntilWeekEnd, daysRemaining); const isFirstBlock = blockIndex === 0; blocks.push(
{/* Desktop */}
{viewerMode ? 'Ocupado' : res.client_name}
{!viewerMode && isFirstBlock && (
{nights}n
{res.adults_count + res.children_count}p
)}
{/* Mobile */}
{daysInThisWeek > 1 && ( {viewerMode ? 'Ocupado' : res.client_name} )} {!viewerMode && daysInThisWeek > 2 && isFirstBlock && ( )}
); currentDayIndex += daysInThisWeek; blockIndex++; } }); return blocks; }; // Drag selection highlight blocks const renderDragSelection = () => { if (!isDragging || !dragStart || !dragEnd) return null; const start = dragStart <= dragEnd ? dragStart : dragEnd; const end = dragStart <= dragEnd ? dragEnd : dragStart; const startIndex = calendarDays.findIndex(d => d >= start); if (startIndex === -1) return null; const endIndex = calendarDays.findIndex(d => d >= end); const effectiveEnd = endIndex === -1 ? calendarDays.length - 1 : endIndex; const selBlocks: React.ReactElement[] = []; let cur = startIndex; while (cur <= effectiveEnd) { const weekIndex = Math.floor(cur / 7); const dayOfWeek = cur % 7; const daysUntilWeekEnd = 7 - dayOfWeek; const daysRemaining = effectiveEnd - cur + 1; const span = Math.min(daysUntilWeekEnd, daysRemaining); selBlocks.push(
); cur += span; } return selBlocks; }; // Days count label during drag const dragNights = dragStart && dragEnd ? Math.abs(differenceInDays(dragEnd, dragStart)) : 0; return (
{/* Header */}

{format(currentDate, 'MMMM yyyy', { locale: es })}

{/* Drag hint — shown only during active drag */} {isDragging && dragNights > 0 && ( {dragNights} noche{dragNights !== 1 ? 's' : ''} )}

{viewerMode ? 'Vista de disponibilidad' : 'Clic para crear · Arrastra para rango'}

{propertyConfig.label}
{/* Grid container */}
{/* Days header */}
{['D', 'L', 'M', 'X', 'J', 'V', 'S'].map((day, i) => (
{day} {['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'][i]}
))}
{/* Grid body — handles all pointer interactions via coordinates */}
{/* Grid cells — purely visual, no pointer-events needed */} {weeks.map((week, weekIdx) => (
{week.map((day) => { const isCurrentMonth = isSameMonth(day, monthStart); const isOccupied = isDayOccupied(day); const inSel = isInDragSelection(day); const isToday = isSameDay(day, new Date()); return (
{format(day, 'd')}
); })}
))} {/* Reservation color blocks */} {renderReservationBlocks()} {/* Drag selection overlay */} {renderDragSelection()}
{/* Legend */}
Teneriffa
Natur
); }