509 lines
20 KiB
TypeScript
509 lines
20 KiB
TypeScript
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<Date | null>(null);
|
|
const [dragEnd, setDragEnd] = useState<Date | null>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const gridBodyRef = useRef<HTMLDivElement>(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(
|
|
<div
|
|
key={`${res.id}-week-${weekIndex}`}
|
|
className={`
|
|
absolute pointer-events-none
|
|
${gradient} ${borderClass}
|
|
z-10
|
|
flex flex-col justify-end items-center md:items-start md:p-1.5
|
|
`}
|
|
style={{
|
|
top: `calc(${weekIndex} * var(--cell-height))`,
|
|
left: `${(dayOfWeek * 100 / 7)}%`,
|
|
width: `${(daysInThisWeek * (100 / 7))}%`,
|
|
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}
|
|
</div>
|
|
{!viewerMode && isFirstBlock && (
|
|
<div className="flex items-center gap-1.5 mt-0.5 px-1">
|
|
<div className={`flex items-center gap-0.5 text-white ${textShadow}`}>
|
|
<Moon className="w-2.5 h-2.5" />
|
|
<span className="text-[9px] font-bold">{nights}n</span>
|
|
</div>
|
|
<div className={`flex items-center gap-0.5 text-white ${textShadow}`}>
|
|
<Users className="w-2.5 h-2.5" />
|
|
<span className="text-[9px] font-bold">{res.adults_count + res.children_count}p</span>
|
|
</div>
|
|
<ServiceIcons reservation={res} />
|
|
</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}`}>
|
|
{viewerMode ? 'Ocupado' : res.client_name}
|
|
</span>
|
|
)}
|
|
{!viewerMode && daysInThisWeek > 2 && isFirstBlock && (
|
|
<ServiceIcons reservation={res} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
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(
|
|
<div
|
|
key={`sel-${cur}`}
|
|
className="absolute z-20 pointer-events-none bg-emerald-500/20 border-l-2 border-r-2 border-emerald-400/60 border-t border-b border-emerald-400/30"
|
|
style={{
|
|
top: `calc(${weekIndex} * var(--cell-height))`,
|
|
left: `${(dayOfWeek * 100 / 7)}%`,
|
|
width: `${(span * (100 / 7))}%`,
|
|
height: 'var(--cell-height)',
|
|
}}
|
|
/>
|
|
);
|
|
cur += span;
|
|
}
|
|
return selBlocks;
|
|
};
|
|
|
|
// Days count label during drag
|
|
const dragNights = dragStart && dragEnd
|
|
? Math.abs(differenceInDays(dragEnd, dragStart))
|
|
: 0;
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-emerald-950/10 rounded-3xl p-3 md:p-5 shadow-xl border border-stone-200 dark:border-emerald-900/30 backdrop-blur-xl transition-colors duration-500 flex flex-col">
|
|
|
|
<style>{`
|
|
:root {
|
|
--cell-height: 2.75rem;
|
|
--header-height: 1.75rem;
|
|
}
|
|
@media (min-width: 768px) {
|
|
:root {
|
|
--cell-height: 5rem;
|
|
--header-height: 2.75rem;
|
|
}
|
|
}
|
|
`}</style>
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-3 md:mb-4 shrink-0 overflow-visible">
|
|
<div className="min-w-0 overflow-visible flex-1">
|
|
<div className="flex items-baseline gap-3">
|
|
<h2 className="text-xl md:text-2xl font-black text-transparent bg-gradient-to-r from-emerald-800 to-amber-600 dark:from-white dark:to-emerald-200 bg-clip-text capitalize pb-1 leading-tight">
|
|
{format(currentDate, 'MMMM yyyy', { locale: es })}
|
|
</h2>
|
|
{/* Drag hint — shown only during active drag */}
|
|
{isDragging && dragNights > 0 && (
|
|
<span className="text-xs font-bold text-emerald-500 dark:text-emerald-400 animate-pulse">
|
|
{dragNights} noche{dragNights !== 1 ? 's' : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<p className="text-xs text-stone-500 dark:text-emerald-400/60 font-medium">
|
|
{viewerMode ? 'Vista de disponibilidad' : 'Clic para crear · Arrastra para rango'}
|
|
</p>
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-gradient-to-r ${propertyConfig.color.gradient} text-white`}>
|
|
{propertyConfig.label}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0 ml-3">
|
|
<button
|
|
onClick={prevMonth}
|
|
className="p-2 rounded-xl bg-stone-100 dark:bg-emerald-900/20 hover:bg-emerald-50 dark:hover:bg-emerald-900/40 text-stone-600 dark:text-emerald-100/80 transition-all duration-300 shadow-sm hover:shadow-md hover:scale-110 border border-stone-200 dark:border-emerald-500/20"
|
|
>
|
|
<ChevronLeft className="w-4 h-4 md:w-5 md:h-5" />
|
|
</button>
|
|
<button
|
|
onClick={nextMonth}
|
|
className="p-2 rounded-xl bg-stone-100 dark:bg-emerald-900/20 hover:bg-emerald-50 dark:hover:bg-emerald-900/40 text-stone-600 dark:text-emerald-100/80 transition-all duration-300 shadow-sm hover:shadow-md hover:scale-110 border border-stone-200 dark:border-emerald-500/20"
|
|
>
|
|
<ChevronRight className="w-4 h-4 md:w-5 md:h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Grid container */}
|
|
<div className="bg-stone-50 dark:bg-black/20 backdrop-blur-xl rounded-2xl overflow-hidden border border-stone-200 dark:border-emerald-900/30 shadow-2xl shadow-stone-200/50 dark:shadow-black/20 relative">
|
|
<div className="w-full flex flex-col">
|
|
|
|
{/* 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) => (
|
|
<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>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Grid body — handles all pointer interactions via coordinates */}
|
|
<div
|
|
ref={gridBodyRef}
|
|
className={`relative ${!viewerMode ? (isDragging ? 'cursor-crosshair' : 'cursor-pointer') : 'cursor-default'}`}
|
|
onMouseDown={handleGridMouseDown}
|
|
onMouseMove={handleGridMouseMove}
|
|
onMouseUp={handleGridMouseUp}
|
|
onClick={handleGridClick}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
// Prevent text selection during drag
|
|
style={{ userSelect: isDragging ? 'none' : undefined }}
|
|
>
|
|
{/* Grid cells — purely visual, no pointer-events needed */}
|
|
{weeks.map((week, weekIdx) => (
|
|
<div key={weekIdx} className="grid grid-cols-7 border-b border-stone-200 dark:border-emerald-900/30 last:border-b-0">
|
|
{week.map((day) => {
|
|
const isCurrentMonth = isSameMonth(day, monthStart);
|
|
const isOccupied = isDayOccupied(day);
|
|
const inSel = isInDragSelection(day);
|
|
const isToday = isSameDay(day, new Date());
|
|
|
|
return (
|
|
<div
|
|
key={day.toString()}
|
|
className={`
|
|
relative border-r border-stone-200 dark:border-emerald-900/30 last:border-r-0
|
|
p-1 md:p-2
|
|
${!isCurrentMonth ? 'bg-stone-100/50 dark:bg-emerald-950/30' : ''}
|
|
${inSel && !isOccupied ? 'bg-emerald-50/60 dark:bg-emerald-900/10' : ''}
|
|
${isToday && isCurrentMonth ? 'bg-blue-50/60 dark:bg-blue-500/5' : ''}
|
|
`}
|
|
style={{ height: 'var(--cell-height)' }}
|
|
>
|
|
<span className={`
|
|
inline-flex items-center justify-center w-4 h-4 md:w-7 md:h-7 rounded-md text-[9px] md:text-xs font-bold
|
|
transition-all duration-200 relative z-30
|
|
${isToday && isCurrentMonth ? 'bg-blue-500 text-white ring-2 ring-blue-400/60 ring-offset-1 ring-offset-white dark:ring-offset-emerald-950 shadow-lg shadow-blue-500/30' : ''}
|
|
${!isToday && isCurrentMonth
|
|
? isOccupied
|
|
? 'text-white drop-shadow-md'
|
|
: inSel
|
|
? 'text-emerald-700 dark:text-emerald-300'
|
|
: 'text-stone-700 dark:text-emerald-100/80'
|
|
: !isCurrentMonth ? 'text-stone-300 dark:text-emerald-900/40' : ''
|
|
}
|
|
`}>
|
|
{format(day, 'd')}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
|
|
{/* Reservation color blocks */}
|
|
{renderReservationBlocks()}
|
|
|
|
{/* Drag selection overlay */}
|
|
{renderDragSelection()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="mt-3 flex flex-wrap items-center gap-3 md:gap-5 text-xs text-stone-500 dark:text-emerald-400/60 shrink-0">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/5 rounded-xl border border-blue-200 dark:border-blue-500/10">
|
|
<div className="w-3 h-3 rounded-md bg-gradient-to-br from-blue-400 to-blue-600 shadow-sm shadow-blue-500/50 shrink-0" />
|
|
<span className="font-semibold text-stone-700 dark:text-emerald-100/80">Teneriffa</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 dark:bg-amber-500/5 rounded-xl border border-amber-200 dark:border-amber-500/10">
|
|
<div className="w-3 h-3 rounded-md bg-gradient-to-br from-amber-400 to-amber-600 shadow-sm shadow-amber-500/50 shrink-0" />
|
|
<span className="font-semibold text-stone-700 dark:text-emerald-100/80">Natur</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|