Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)

This commit is contained in:
2026-04-30 10:09:44 +01:00
commit a0ccb8ca64
188 changed files with 16418 additions and 0 deletions

View File

@@ -0,0 +1,508 @@
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>
);
}