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,222 @@
import { 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, Ban } from 'lucide-react';
import type { Reservation } from '../types';
interface Props {
reservations: Reservation[];
onSelectDay: (day: Date) => void;
onSelectReservation: (reservation: Reservation) => void;
}
export function CalendarGrid({ reservations, onSelectDay, onSelectReservation }: Props) {
const [currentDate, setCurrentDate] = useState(new Date());
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 });
// Organizar en semanas
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));
// Función para verificar si un día está ocupado
const isDayOccupied = (day: Date): boolean => {
return reservations.some(res => {
const startDate = parseISO(res.start_date);
const endDate = parseISO(res.end_date);
// Un día está ocupado si está dentro del rango [start_date, end_date] (ambos inclusive)
return isWithinInterval(day, { start: startDate, end: endDate }) ||
isSameDay(day, startDate) ||
isSameDay(day, endDate);
});
};
// Renderizar bloques de reserva
const renderReservationBlocks = () => {
return reservations.map((res) => {
const startDate = parseISO(res.start_date);
const endDate = parseISO(res.end_date);
const dayIndex = calendarDays.findIndex(day => isSameDay(day, startDate));
if (dayIndex === -1) return null;
const weekIndex = Math.floor(dayIndex / 7);
const dayOfWeek = dayIndex % 7;
const duration = differenceInDays(endDate, startDate) + 1;
const nights = duration - 1;
const isTeneriffa = res.origin === 'Teneriffa2000';
const gradient = isTeneriffa
? 'from-blue-600/90 via-blue-500/90 to-blue-400/90'
: 'from-yellow-600/90 via-yellow-500/90 to-yellow-400/90';
const borderColor = isTeneriffa ? 'border-blue-400' : 'border-yellow-400';
const shadowColor = isTeneriffa ? 'shadow-blue-500/50' : 'shadow-yellow-500/50';
return (
<div
key={res.id}
onClick={() => onSelectReservation(res)}
className={`
absolute cursor-pointer group
bg-gradient-to-r ${gradient} ${borderColor}
border-l-4 rounded-2xl p-3
hover:scale-105 transition-all duration-300
shadow-2xl ${shadowColor}
backdrop-blur-xl
z-10
`}
style={{
top: `${weekIndex * 100 + 50}px`,
left: `${(dayOfWeek * 100 / 7) + 0.75}%`,
width: `${Math.min(duration, 7 - dayOfWeek) * (100 / 7) - 1.5}%`,
height: '60px'
}}
>
<div className=\"relative z-10\">
<div className=\"text-sm font-bold text-white truncate drop-shadow-lg\">{res.client_name}</div>
<div className=\"flex items-center gap-3 mt-1\">
< div className =\"flex items-center gap-1 text-white/90\">
< Moon className =\"w-3 h-3\" />
< span className =\"text-[11px] font-semibold\">{nights}n</span>
</div >
<div className=\"flex items-center gap-1 text-white/90\">
< Users className =\"w-3 h-3\" />
< span className =\"text-[11px] font-semibold\">{res.adults_count + res.children_count}p</span>
</div >
</div >
</div >
</div >
);
});
};
return (
<div className=\"flex-1 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 rounded-3xl p-8 shadow-2xl border border-slate-700/50 backdrop-blur-xl\">
{/* Header */ }
<div className=\"flex items-center justify-between mb-8\">
< div >
<h2 className=\"text-4xl font-black text-transparent bg-gradient-to-r from-white to-slate-300 bg-clip-text capitalize\">
{ format(currentDate, 'MMMM yyyy', { locale: es }) }
</h2 >
<p className=\"text-sm text-slate-400 mt-1 font-medium\">Vista mensual de reservas</p>
</div >
<div className=\"flex items-center gap-3\">
< button
onClick = { prevMonth }
className =\"p-3 rounded-xl bg-gradient-to-br from-slate-700 to-slate-800 hover:from-slate-600 hover:to-slate-700 text-slate-200 transition-all duration-300 shadow-lg hover:shadow-xl hover:scale-110 border border-slate-600/50\"
>
<ChevronLeft className=\"w-5 h-5\" />
</button >
<button
onClick={nextMonth}
className=\"p-3 rounded-xl bg-gradient-to-br from-slate-700 to-slate-800 hover:from-slate-600 hover:to-slate-700 text-slate-200 transition-all duration-300 shadow-lg hover:shadow-xl hover:scale-110 border border-slate-600/50\"
>
<ChevronRight className=\"w-5 h-5\" />
</button >
</div >
</div >
{/* Calendar */ }
< div className =\"bg-slate-800/30 backdrop-blur-xl rounded-2xl overflow-hidden border border-slate-700/50 shadow-2xl\">
{/* Days header */ }
<div className=\"grid grid-cols-7 bg-gradient-to-r from-slate-800/80 to-slate-700/80 border-b border-slate-600/50 backdrop-blur\">
{
['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map((day) => (
<div key={day} className=\"text-center py-4 text-sm font-black text-slate-300 uppercase tracking-widest\">
{ day }
</div >
))
}
</div >
{/* Calendar grid */ }
< div className =\"relative\">
{
weeks.map((week, weekIdx) => (
<div key={weekIdx} className=\"grid grid-cols-7 border-b border-slate-700/30 last:border-b-0\">
{
week.map((day) => {
const isCurrentMonth = isSameMonth(day, monthStart);
const isOccupied = isDayOccupied(day);
return (
<div
key={day.toString()}
onClick={() => {
if (!isOccupied) {
onSelectDay(day);
}
}}
className={`
relative h-28 p-3 border-r border-slate-700/30 last:border-r-0
transition-all duration-300 group
${!isCurrentMonth ? 'bg-slate-900/50' : ''}
${isOccupied
? 'cursor-not-allowed bg-red-900/10'
: 'cursor-pointer hover:bg-gradient-to-br hover:from-slate-700/50 hover:to-slate-600/30'
}
`}
>
<span className={`
inline-flex items-center justify-center w-8 h-8 rounded-lg text-sm font-bold
transition-all duration-300
${isCurrentMonth
? isOccupied
? 'text-red-400/50'
: 'text-slate-100 group-hover:bg-white/10 group-hover:scale-110'
: 'text-slate-600'
}
`}>
{format(day, 'd')}
</span>
{/* Indicador visual de día ocupado */}
{isOccupied && isCurrentMonth && (
<div className=\"absolute top-2 right-2\">
<Ban className=\"w-4 h-4 text-red-400/30\" />
</div>
)
}
</div >
);
})
}
</div >
))}
{/* Reservation blocks */ }
{ renderReservationBlocks() }
</div >
</div >
{/* Legend */ }
< div className =\"mt-6 flex items-center gap-8 text-sm text-slate-400\">
< div className =\"flex items-center gap-3 px-4 py-2 bg-gradient-to-r from-blue-500/10 to-transparent rounded-xl border border-blue-500/20\">
< div className =\"w-4 h-4 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg shadow-blue-500/50\"></div>
< span className =\"font-semibold\">Teneriffa2000</span>
</div >
<div className=\"flex items-center gap-3 px-4 py-2 bg-gradient-to-r from-yellow-500/10 to-transparent rounded-xl border border-yellow-500/20\">
< div className =\"w-4 h-4 rounded-lg bg-gradient-to-br from-yellow-500 to-yellow-600 shadow-lg shadow-yellow-500/50\"></div>
< span className =\"font-semibold\">Naturcalabacera</span>
</div >
</div >
</div >
);
}