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

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

191
src/App.tsx Normal file
View File

@@ -0,0 +1,191 @@
import { useState, useEffect } from 'react';
import { useReservations } from './hooks/useReservations';
import { Sidebar } from './components/Sidebar';
import { MobileNavigation } from './components/MobileNavigation';
import { CalendarGrid } from './components/CalendarGrid';
import { ReservationModal } from './components/ReservationModal';
import { SearchBar } from './components/SearchBar';
import { SettingsPage } from './components/SettingsPage';
import { YearlyCalendar } from './components/YearlyCalendar';
import { LoginPage } from './components/LoginPage';
import { useAuth } from './hooks/useAuth';
import type { NewReservation, Reservation } from './types';
import { format } from 'date-fns';
import { Plus, Settings, Loader2 } from 'lucide-react';
import { Toaster, toast } from 'sonner';
console.log("App.tsx file loaded (module level)");
function App() {
console.log("App.tsx: Component rendering");
const { session, loading: authLoading } = useAuth();
console.log("App.tsx: Auth State", { session, authLoading });
const {
reservations,
loading: reservationsLoading,
createReservation,
updateReservation,
deleteReservation,
refreshResolver
} = useReservations();
console.log("App.tsx: reservations hook loaded", { count: reservations.length, loading: reservationsLoading });
const [currentView, setCurrentView] = useState("calendar");
const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [selectedReservation, setSelectedReservation] = useState<Partial<Reservation>>({});
const [searchTerm, setSearchTerm] = useState("");
// Initialize theme from localStorage
useEffect(() => {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (!savedTheme && systemPrefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, []);
const filteredReservations = reservations.filter(res => {
return res.client_name.toLowerCase().includes(searchTerm.toLowerCase());
});
if (authLoading) {
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center">
<Loader2 className="w-10 h-10 text-blue-500 animate-spin" />
</div>
);
}
if (!session) {
return (
<>
<Toaster position="top-right" theme="dark" richColors />
<LoginPage />
</>
);
}
const handleSelectReservation = (event: Reservation) => {
setSelectedReservation(event);
setModalMode("edit");
setModalOpen(true);
};
const handleSelectDay = (day: Date) => {
setSelectedReservation({
start_date: format(day, "yyyy-MM-dd"),
end_date: format(day, "yyyy-MM-dd"),
origin: "Teneriffa2000",
adults_count: 2,
children_count: 0,
});
setModalMode("create");
setModalOpen(true);
};
const handleSave = async (data: NewReservation) => {
try {
if (modalMode === "create") {
await createReservation(data);
} else {
if (selectedReservation.id) {
await updateReservation(selectedReservation.id, data);
}
}
setModalOpen(false);
refreshResolver();
toast.success("Reserva guardada correctamente");
} catch (error) {
console.error("Error saving:", error);
toast.error("Error al guardar la reserva");
}
};
const handleDelete = async (id: string) => {
try {
await deleteReservation(id);
setModalOpen(false);
refreshResolver();
toast.success("Reserva eliminada correctamente");
} catch (error) {
console.error("Error deleting:", error);
toast.error("Error al eliminar la reserva");
}
};
return (
<div className="flex flex-col md:flex-row h-screen bg-stone-50 dark:bg-black overflow-hidden transition-colors duration-500">
<Toaster position="top-right" theme="dark" richColors />
<Sidebar currentView={currentView} onNavigate={setCurrentView} />
<div className="flex-1 flex flex-col overflow-hidden relative">
<header className="bg-white/80 dark:bg-emerald-950/20 backdrop-blur-xl border-b border-stone-200 dark:border-emerald-900/50 px-4 md:px-10 py-5 md:py-7 shadow-sm dark:shadow-2xl transition-colors duration-500">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl md:text-3xl font-black text-transparent bg-gradient-to-r from-emerald-600 via-emerald-500 to-amber-500 bg-clip-text">
Calendar
</h1>
<p className="text-xs md:text-sm text-stone-500 dark:text-emerald-400/60 mt-2 font-medium flex items-center gap-2">
<span className="inline-block w-2 h-2 bg-emerald-500 rounded-full shadow-lg shadow-emerald-500/50 animate-pulse"></span>
Gestiona tus reservas y disponibilidad
</p>
</div>
<button
onClick={() => handleSelectDay(new Date())}
className="group px-4 md:px-8 py-3 md:py-4 bg-gradient-to-r from-emerald-600 via-emerald-500 to-amber-500 hover:from-emerald-500 hover:via-emerald-400 hover:to-amber-400 text-white font-bold rounded-2xl transition-all duration-300 shadow-xl shadow-emerald-500/20 hover:shadow-emerald-500/40 hover:scale-105 border border-emerald-400/20 flex items-center gap-2 md:gap-3"
>
<Plus className="w-5 h-5 group-hover:rotate-90 transition-transform duration-300" />
<span className="hidden md:inline">Nueva Reserva</span>
<span className="md:hidden">Nueva</span>
</button>
</div>
</header>
<main className="flex-1 overflow-auto p-4 md:p-10 pb-24 md:pb-10 bg-stone-50 dark:bg-black transition-colors duration-500">
{currentView === "calendar" && (
<>
<SearchBar
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<CalendarGrid
reservations={filteredReservations}
onSelectDay={handleSelectDay}
onSelectReservation={handleSelectReservation}
isLoading={reservationsLoading}
/>
</>
)}
{currentView === "settings" && (
<SettingsPage />
)}
{currentView === "yearly" && (
<YearlyCalendar reservations={reservations} year={2026} />
)}
</main>
<MobileNavigation currentView={currentView} onNavigate={setCurrentView} />
</div>
<ReservationModal
isOpen={modalOpen}
mode={modalMode}
initialData={selectedReservation}
existingReservations={reservations}
onClose={() => setModalOpen(false)}
onSave={handleSave}
onDelete={handleDelete}
/>
</div>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,252 @@
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 } from 'lucide-react';
import type { Reservation } from '../types';
interface Props {
reservations: Reservation[];
onSelectDay: (day: Date) => void;
onSelectReservation: (reservation: Reservation) => void;
isLoading?: boolean;
}
export function CalendarGrid({ reservations, onSelectDay, onSelectReservation, isLoading = false }: 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 });
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));
const isDayOccupied = (day: Date): boolean => {
return reservations.some(res => {
const startDate = parseISO(res.start_date);
const endDate = parseISO(res.end_date);
return isWithinInterval(day, { start: startDate, end: endDate }) ||
isSameDay(day, startDate) ||
isSameDay(day, endDate);
});
};
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 = isTeneriffa
? 'bg-blue-600/30 dark:bg-blue-500/30'
: 'bg-yellow-500/30 dark:bg-yellow-400/30';
const borderClass = 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}`}
onClick={() => onSelectReservation(res)}
className={`
absolute cursor-pointer
${gradient} ${borderClass}
transition-all duration-300
hover:opacity-80
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`}>{res.client_name}</div>
{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>
</div>
)}
</div>
{/* Mobile */}
<div className="md:hidden w-full flex items-end justify-center pb-0.5">
{daysInThisWeek > 1 && (
<span className={`text-[8px] font-black text-white/90 truncate px-0.5 uppercase tracking-tight ${textShadow}`}>
{res.client_name}
</span>
)}
</div>
</div>
);
currentDayIndex += daysInThisWeek;
blockIndex++;
}
});
return blocks;
};
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">
{/* Responsive cell height variables */}
<style>{`
:root {
--cell-height: 2.75rem;
--header-height: 1.75rem;
}
@media (min-width: 768px) {
:root {
--cell-height: 5rem;
--header-height: 3rem;
}
}
`}</style>
{/* Header — overflow-visible to avoid bg-clip-text clipping */}
<div className="flex items-center justify-between mb-3 md:mb-5 shrink-0 overflow-visible">
<div className="min-w-0 overflow-visible">
<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>
<p className="text-xs text-stone-500 dark:text-emerald-400/60 font-medium">Vista mensual de reservas</p>
</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 — overflow-hidden only here, not on outer wrapper */}
<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 */}
<div className="relative">
{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);
return (
<div
key={day.toString()}
onClick={() => {
if (!isOccupied) onSelectDay(day);
}}
className={`
relative border-r border-stone-200 dark:border-emerald-900/30 last:border-r-0
transition-all duration-300
z-20
pointer-events-none
p-1 md:p-2
${!isCurrentMonth ? 'bg-stone-100/50 dark:bg-emerald-950/30' : ''}
`}
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-300
${isCurrentMonth
? isOccupied
? 'text-white drop-shadow-md'
: 'text-stone-700 dark:text-emerald-100/80'
: 'text-stone-300 dark:text-emerald-900/40'
}
`}>
{format(day, 'd')}
</span>
</div>
);
})}
</div>
))}
{/* Reservation overlay blocks */}
{renderReservationBlocks()}
</div>
</div>
</div>
{/* Legend */}
<div className="mt-3 md:mt-4 flex flex-wrap items-center gap-3 md:gap-6 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"></div>
<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"></div>
<span className="font-semibold text-stone-700 dark:text-emerald-100/80">Natur</span>
</div>
</div>
</div>
);
}

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 >
);
}

View File

@@ -0,0 +1,155 @@
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, { locale: es });
const endDate = endOfWeek(monthEnd, { 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">
{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].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>
);
}

View File

@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { supabase } from '../lib/supabase';
import { Calendar, Loader2, Lock, Mail, ArrowRight } from 'lucide-react';
import { toast } from 'sonner';
export function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
toast.error('Error al iniciar sesión: ' + error.message);
} else {
toast.success('¡Bienvenido de nuevo!');
}
} catch (err) {
toast.error('Ocurrió un error inesperado');
console.error(err);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-stone-50 dark:bg-black flex items-center justify-center p-4 transition-colors duration-500">
<div className="w-full max-w-md bg-white dark:bg-emerald-950/10 backdrop-blur-xl border border-stone-200 dark:border-emerald-900/30 rounded-3xl shadow-2xl shadow-stone-200/50 dark:shadow-emerald-900/10 overflow-hidden">
{/* Header */}
<div className="p-8 text-center border-b border-stone-100 dark:border-emerald-900/30 bg-gradient-to-b from-stone-50/50 to-transparent dark:from-emerald-900/5">
<div className="w-16 h-16 bg-gradient-to-br from-emerald-500 to-emerald-700 rounded-2xl mx-auto flex items-center justify-center shadow-lg shadow-emerald-500/30 mb-6 transform rotate-3 hover:rotate-6 transition-transform duration-300">
<Calendar className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-black text-stone-900 dark:text-white mb-2 tracking-tight">
Naturcalabacera
</h1>
<p className="text-stone-500 dark:text-emerald-400/60 font-medium">
Sistema de Gestión de Reservas
</p>
</div>
{/* Form */}
<form onSubmit={handleLogin} className="p-8 space-y-6">
<div className="space-y-2">
<label className="text-xs font-bold text-stone-400 dark:text-emerald-500/60 uppercase tracking-wider ml-1">Email Corporativo</label>
<div className="relative group">
<Mail className="absolute left-4 top-3.5 w-5 h-5 text-stone-400 dark:text-emerald-600/60 group-focus-within:text-emerald-500 transition-colors" />
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-stone-50 dark:bg-black/40 border border-stone-200 dark:border-emerald-900/30 rounded-xl py-3 pl-12 pr-4 text-stone-900 dark:text-white placeholder:text-stone-400 dark:placeholder:text-emerald-800/40 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all font-medium"
placeholder="nombre@empresa.com"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-stone-400 dark:text-emerald-500/60 uppercase tracking-wider ml-1">Contraseña</label>
<div className="relative group">
<Lock className="absolute left-4 top-3.5 w-5 h-5 text-stone-400 dark:text-emerald-600/60 group-focus-within:text-emerald-500 transition-colors" />
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-stone-50 dark:bg-black/40 border border-stone-200 dark:border-emerald-900/30 rounded-xl py-3 pl-12 pr-4 text-stone-900 dark:text-white placeholder:text-stone-400 dark:placeholder:text-emerald-800/40 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all font-medium"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 text-white font-bold py-4 rounded-xl shadow-xl shadow-emerald-500/20 hover:shadow-emerald-500/40 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 flex items-center justify-center gap-2 group disabled:opacity-70 disabled:cursor-not-allowed"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
Iniciar Sesión
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
</form>
{/* Footer */}
<div className="p-6 bg-stone-50/50 dark:bg-emerald-950/20 border-t border-stone-100 dark:border-emerald-900/30 text-center">
<p className="text-xs text-stone-400 dark:text-emerald-600/40 font-medium">
Protegido por autenticación segura
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { Calendar, Settings, CalendarDays } from 'lucide-react';
interface Props {
currentView: string;
onNavigate: (view: string) => void;
}
export function MobileNavigation({ currentView, onNavigate }: Props) {
const menuItems = [
{ id: 'calendar', label: 'Mensual', icon: Calendar },
{ id: 'yearly', label: 'Anual', icon: CalendarDays },
{ id: 'settings', label: 'Ajustes', icon: Settings },
];
return (
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-emerald-950/90 backdrop-blur-xl border-t border-stone-200 dark:border-emerald-900/30 z-50 pb-safe">
<div className="flex justify-around items-center p-2">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = currentView === item.id;
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={`
flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-300
${isActive
? 'text-emerald-600 dark:text-emerald-400'
: 'text-stone-400 dark:text-emerald-600/40 hover:text-stone-600 dark:hover:text-emerald-400/80'
}
`}
>
<div className={`
p-2 rounded-xl mb-1 transition-all duration-300
${isActive
? 'bg-emerald-100 dark:bg-emerald-900/40'
: 'bg-transparent'
}
`}>
<Icon className={`w-6 h-6 ${isActive ? 'scale-110' : ''}`} />
</div>
<span className={`text-[10px] font-bold ${isActive ? 'opacity-100' : 'opacity-70'}`}>
{item.label}
</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,743 @@
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import type { NewReservation, Reservation } from '../types';
import {
X, Check, Trash2, AlertCircle, Upload, FileText, ChevronDown,
Users, Zap, Home, Maximize2, ExternalLink
} from 'lucide-react';
import { supabase } from '../lib/supabase';
import { differenceInDays, parseISO, isWithinInterval, isSameDay } from 'date-fns';
import { motion, AnimatePresence } from 'framer-motion';
interface Props {
isOpen: boolean;
mode: 'create' | 'edit';
initialData?: Partial<Reservation>;
existingReservations?: Reservation[];
onClose: () => void;
onSave: (data: NewReservation) => Promise<void>;
onDelete?: (id: string) => Promise<void>;
}
const PROPERTY_PRICING = {
los_dragos: { label: 'Los Dragos', base: 900, included: 50, extraRate: 12 },
la_esquinita: { label: 'La Esquinita', base: 1450, included: 60, extraRate: 12 },
} as const;
const EVENT_TYPES = ['Boda', 'Comunión', 'Cumpleaños', 'Evento privado', 'Corporativo', 'Otro'];
const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
export function ReservationModal({
isOpen,
mode,
initialData,
existingReservations = [],
onClose,
onSave,
onDelete
}: Props) {
const {
register,
handleSubmit,
watch,
reset,
setValue,
setError,
clearErrors,
formState: { errors }
} = useForm<NewReservation>();
// Event local state
const [isEvent, setIsEvent] = useState(false);
// Contract upload local state
const [contractFile, setContractFile] = useState<File | null>(null);
const [contractError, setContractError] = useState<string | null>(null);
const [contractUploading, setContractUploading] = useState(false);
const [existingContractUrl, setExistingContractUrl] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [contractPreviewOpen, setContractPreviewOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
reset({
origin: 'Teneriffa2000',
property: 'los_dragos',
adults_count: 2,
children_count: 0,
government_registration: '',
has_cleaning: false,
has_pool_heating: false,
has_flies_products: false,
observations: '',
is_event: false,
event_type: '',
event_type_other: '',
attendees_count: 0,
...initialData
});
setIsEvent(initialData?.is_event ?? false);
setContractFile(null);
setContractError(null);
setContractUploading(false);
setExistingContractUrl(initialData?.contract_url ?? null);
setLocalPreviewUrl(null);
setContractPreviewOpen(false);
clearErrors();
}
}, [isOpen, initialData, reset, clearErrors]);
// Build a local object URL when a file is selected; revoke on cleanup
useEffect(() => {
if (!contractFile) { setLocalPreviewUrl(null); return; }
const url = URL.createObjectURL(contractFile);
setLocalPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [contractFile]);
const isImageFile = contractFile
? contractFile.type.startsWith('image/')
: existingContractUrl
? /\.(jpg|jpeg|png)(\?|$)/i.test(existingContractUrl)
: false;
const previewSrc = localPreviewUrl ?? existingContractUrl;
const uploadContract = async (file: File, prop: string): Promise<string> => {
const bucket = prop === 'la_esquinita' ? 'contratos-la-esquinita' : 'contratos-los-dragos';
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const path = `${Date.now()}_${safeName}`;
const { data, error } = await supabase.storage.from(bucket).upload(path, file, { cacheControl: '3600', upsert: false });
if (error) throw error;
const { data: { publicUrl } } = supabase.storage.from(bucket).getPublicUrl(data.path);
return publicUrl;
};
const startDate = watch('start_date');
const endDate = watch('end_date');
const origin = watch('origin');
const property = watch('property') ?? 'los_dragos';
const adults = watch('adults_count');
const children = watch('children_count');
const eventType = watch('event_type');
const attendeesCount = Number(watch('attendees_count')) || 0;
const totalDays = startDate && endDate
? Math.max(0, differenceInDays(parseISO(endDate), parseISO(startDate)))
: 0;
const totalPeople = (Number(adults) || 0) + (Number(children) || 0);
// Auto-correct end_date when start_date changes
useEffect(() => {
if (startDate && endDate) {
if (parseISO(endDate) < parseISO(startDate)) {
setValue('end_date', startDate);
clearErrors('end_date');
}
}
}, [startDate, endDate, setValue, clearErrors]);
// Pricing calculation
const pricing = (() => {
const config = PROPERTY_PRICING[property as keyof typeof PROPERTY_PRICING] ?? PROPERTY_PRICING.los_dragos;
const extra = Math.max(0, attendeesCount - config.included);
const subtotal = config.base + extra * config.extraRate;
return { ...config, extra, subtotal };
})();
// Overlap check
const checkOverlap = (start: string, end: string): boolean => {
const newStart = parseISO(start);
const newEnd = parseISO(end);
const reservationsToCheck = mode === 'edit'
? existingReservations.filter(r => r.id !== initialData?.id)
: existingReservations;
return reservationsToCheck.some(res => {
const resStart = parseISO(res.start_date);
const resEnd = parseISO(res.end_date);
const startOverlaps = isWithinInterval(newStart, { start: resStart, end: resEnd }) ||
isSameDay(newStart, resStart) || isSameDay(newStart, resEnd);
const endOverlaps = isWithinInterval(newEnd, { start: resStart, end: resEnd }) ||
isSameDay(newEnd, resStart) || isSameDay(newEnd, resEnd);
const contains = newStart < resStart && newEnd > resEnd;
return startOverlaps || endOverlaps || contains;
});
};
const onSubmit = async (data: NewReservation) => {
if (!data.start_date || !data.end_date) {
setError('start_date', { type: 'manual', message: 'Las fechas son obligatorias' });
return;
}
if (parseISO(data.end_date) < parseISO(data.start_date)) {
setError('end_date', { type: 'manual', message: 'La fecha de salida no puede ser anterior a la de entrada' });
return;
}
if (checkOverlap(data.start_date, data.end_date)) {
setError('start_date', { type: 'manual', message: 'Las fechas seleccionadas se superponen con otra reserva' });
return;
}
// Upload contract if a new file was selected
let contractUrl = existingContractUrl ?? undefined;
if (contractFile) {
try {
setContractUploading(true);
contractUrl = await uploadContract(contractFile, data.property ?? 'los_dragos');
} catch (err) {
setContractError('Error al subir el contrato. Inténtalo de nuevo.');
setContractUploading(false);
return;
} finally {
setContractUploading(false);
}
}
// Include event flag in payload
const payload: NewReservation = {
...data,
is_event: isEvent,
event_type: isEvent ? data.event_type : undefined,
event_type_other: isEvent && data.event_type === 'Otro' ? data.event_type_other : undefined,
attendees_count: isEvent ? attendeesCount : undefined,
contract_url: contractUrl,
};
await onSave(payload);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
if (!file) return;
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
setContractError('Solo se permiten archivos PDF, JPEG o PNG');
return;
}
if (file.size > MAX_FILE_SIZE) {
setContractError('El archivo no puede superar los 10 MB');
return;
}
setContractError(null);
setContractFile(file);
setExistingContractUrl(null);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleRemoveContract = () => {
setContractFile(null);
setExistingContractUrl(null);
setContractError(null);
setLocalPreviewUrl(null);
};
const handleOpenPdf = () => {
const url = localPreviewUrl ?? existingContractUrl;
if (url) window.open(url, '_blank', 'noopener,noreferrer');
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/60 backdrop-blur-md z-40"
/>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 bg-slate-900 z-50 rounded-t-[2.5rem] shadow-[0_-10px_60px_rgba(0,0,0,0.5)] h-[92vh] overflow-y-auto w-full max-w-md mx-auto"
>
{/* Sticky drag handle */}
<div className="sticky top-0 bg-slate-900 pt-4 pb-2 z-10 flex justify-center">
<div className="w-16 h-1.5 bg-slate-700 rounded-full" />
</div>
<div className="px-6 pb-8">
{/* Header */}
<div className="flex justify-between items-start mb-6">
<div>
<h2 className="text-3xl font-extrabold text-white tracking-tight">
{mode === 'create' ? 'Nueva Estancia' : 'Editar Estancia'}
</h2>
<p className="text-base text-slate-400 mt-1 font-medium">
{totalDays} noches · {totalPeople} personas
</p>
</div>
<button
onClick={onClose}
className="p-3 bg-slate-800 rounded-full hover:bg-slate-700 transition-colors"
>
<X size={22} className="text-slate-300" />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* Validation error */}
{(errors.start_date || errors.end_date) && (
<div className="bg-red-950/40 border border-red-800 rounded-2xl p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-bold text-red-300">Error de validación</p>
<p className="text-sm text-red-400 mt-0.5">
{errors.start_date?.message || errors.end_date?.message}
</p>
</div>
</div>
)}
{/* 1. Origen */}
<div>
<label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Empresa</label>
<div className="grid grid-cols-2 gap-3">
<label className="cursor-pointer">
<input type="radio" value="Teneriffa2000" {...register('origin')} className="peer sr-only" />
<div className="p-4 rounded-2xl bg-slate-800 border-2 border-slate-700 peer-checked:border-blue-500 peer-checked:bg-blue-900/30 hover:bg-slate-700 transition-all flex flex-col items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<span className="font-bold text-slate-300 peer-checked:text-blue-300 text-sm">Teneriffa</span>
</div>
</label>
<label className="cursor-pointer">
<input type="radio" value="Naturcalabacera" {...register('origin')} className="peer sr-only" />
<div className="p-4 rounded-2xl bg-slate-800 border-2 border-slate-700 peer-checked:border-yellow-500 peer-checked:bg-yellow-900/30 hover:bg-slate-700 transition-all flex flex-col items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
<span className="font-bold text-slate-300 peer-checked:text-yellow-300 text-sm">Natur</span>
</div>
</label>
</div>
</div>
{/* 2. Propiedad */}
<div>
<label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Propiedad</label>
<div className="grid grid-cols-2 gap-3">
<label className="cursor-pointer">
<input type="radio" value="los_dragos" {...register('property')} className="peer sr-only" />
<div className="p-3.5 rounded-2xl bg-slate-800 border-2 border-slate-700 peer-checked:border-emerald-500 peer-checked:bg-emerald-900/30 hover:bg-slate-700 transition-all flex flex-col items-center gap-1">
<Home className="w-4 h-4 text-slate-400 peer-checked:text-emerald-400" />
<span className="font-bold text-slate-300 peer-checked:text-emerald-300 text-sm">Los Dragos</span>
</div>
</label>
<label className="cursor-pointer">
<input type="radio" value="la_esquinita" {...register('property')} className="peer sr-only" />
<div className="p-3.5 rounded-2xl bg-slate-800 border-2 border-slate-700 peer-checked:border-emerald-500 peer-checked:bg-emerald-900/30 hover:bg-slate-700 transition-all flex flex-col items-center gap-1">
<Home className="w-4 h-4 text-slate-400 peer-checked:text-emerald-400" />
<span className="font-bold text-slate-300 peer-checked:text-emerald-300 text-sm">La Esquinita</span>
</div>
</label>
</div>
</div>
{/* 3. Evento (solo Natur) */}
<AnimatePresence>
{origin === 'Naturcalabacera' && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="bg-slate-800/60 border border-slate-700 rounded-2xl p-4 space-y-4">
{/* Toggle */}
<label className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-yellow-400" />
<span className="font-semibold text-slate-200 text-sm">Es un evento</span>
</div>
<div
className={`w-11 h-6 rounded-full transition-all duration-300 relative flex-shrink-0 ${isEvent ? 'bg-yellow-500' : 'bg-slate-600'}`}
onClick={() => setIsEvent(!isEvent)}
>
<div className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-300 ${isEvent ? 'translate-x-5' : 'translate-x-0'}`} />
</div>
</label>
{/* Evento details */}
<AnimatePresence>
{isEvent && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden space-y-4"
>
{/* Tipo de evento */}
<div>
<label className="block text-xs font-bold text-slate-400 mb-1.5 uppercase tracking-wider">Tipo de evento</label>
<div className="relative">
<select
{...register('event_type')}
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white font-medium focus:outline-none focus:ring-2 focus:ring-yellow-500/50 focus:border-yellow-500/50 appearance-none cursor-pointer"
>
<option value="" className="bg-slate-800">Selecciona tipo...</option>
{EVENT_TYPES.map(t => (
<option key={t} value={t} className="bg-slate-800">{t}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
</div>
</div>
{/* Custom event type */}
{eventType === 'Otro' && (
<input
{...register('event_type_other')}
placeholder="Describe el tipo de evento..."
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 font-medium focus:outline-none focus:ring-2 focus:ring-yellow-500/50 focus:border-yellow-500/50"
/>
)}
{/* Asistentes */}
<div>
<label className="block text-xs font-bold text-slate-400 mb-1.5 uppercase tracking-wider">Número de asistentes</label>
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Users className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="number"
min="1"
{...register('attendees_count', { valueAsNumber: true })}
placeholder="0"
className="w-full pl-10 pr-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 font-medium focus:outline-none focus:ring-2 focus:ring-yellow-500/50 focus:border-yellow-500/50"
/>
</div>
</div>
</div>
{/* Pricing block */}
{attendeesCount > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-slate-900/60 border border-emerald-800/40 rounded-xl p-4 space-y-3"
>
<div className="flex items-center gap-2 mb-1">
<div className="w-1.5 h-4 bg-gradient-to-b from-emerald-400 to-emerald-600 rounded-full"></div>
<p className="text-xs font-black text-emerald-400 uppercase tracking-wider">Cálculo de Canon</p>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-400">Propiedad</span>
<span className="text-slate-200 font-semibold">{pricing.label}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-400">Canon base</span>
<span className="text-slate-200 font-semibold">{pricing.base.toLocaleString('es-ES')} </span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-400">Personas incluidas</span>
<span className="text-slate-200 font-semibold">hasta {pricing.included} pax</span>
</div>
{pricing.extra > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-400">Personas extra ({pricing.extra} × {pricing.extraRate} )</span>
<span className="text-yellow-400 font-semibold">+{(pricing.extra * pricing.extraRate).toLocaleString('es-ES')} </span>
</div>
)}
<div className="border-t border-slate-700 pt-3 flex items-center justify-between">
<span className="text-slate-300 font-bold text-sm">Subtotal</span>
<span className="text-white font-black text-lg">{pricing.subtotal.toLocaleString('es-ES')} </span>
</div>
<p className="text-[10px] text-slate-500 text-right">* IGIC no incluido</p>
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 4. Cliente */}
<div>
<label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Cliente</label>
<input
{...register('client_name', { required: true })}
placeholder="Nombre completo"
className="w-full px-5 py-4 bg-slate-800 border border-slate-700 rounded-2xl text-white text-base font-medium focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 placeholder-slate-500 transition-all"
/>
</div>
{/* 5. Fechas */}
<div className={`flex gap-4 p-4 rounded-2xl border-2 transition-all ${errors.start_date || errors.end_date
? 'bg-red-950/30 border-red-800'
: 'bg-slate-800 border-slate-700'
}`}>
<div className="flex-1 min-w-0">
<label className="text-[10px] text-slate-400 uppercase font-bold tracking-wider">Entrada</label>
<input
type="date"
{...register('start_date', { required: true })}
className="bg-transparent w-full font-bold text-white mt-1 focus:outline-none [color-scheme:dark] text-sm"
/>
</div>
<div className="w-px bg-slate-600 self-stretch"></div>
<div className="flex-1 min-w-0">
<label className="text-[10px] text-slate-400 uppercase font-bold tracking-wider">Salida</label>
<input
type="date"
{...register('end_date', { required: true })}
min={startDate || undefined}
className="bg-transparent w-full font-bold text-white mt-1 focus:outline-none [color-scheme:dark] text-sm"
/>
</div>
</div>
{/* 6. Huéspedes */}
<div>
<label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Huéspedes</label>
<div className="flex gap-3">
<div className="flex-1 flex flex-col items-center p-4 bg-slate-800 border border-slate-700 rounded-2xl">
<span className="text-xs font-medium text-slate-400 mb-2">Adultos</span>
<input
type="number"
min="1"
{...register('adults_count')}
className="w-full text-center text-2xl font-bold bg-transparent text-white border-none p-0 focus:outline-none focus:ring-0"
/>
</div>
<div className="flex-1 flex flex-col items-center p-4 bg-slate-800 border border-slate-700 rounded-2xl">
<span className="text-xs font-medium text-slate-400 mb-2">Niños</span>
<input
type="number"
min="0"
{...register('children_count')}
className="w-full text-center text-2xl font-bold bg-transparent text-white border-none p-0 focus:outline-none focus:ring-0"
/>
</div>
</div>
</div>
{/* 7. Extras */}
<div>
<label className="block text-xs font-bold text-slate-400 mb-2 ml-1 uppercase tracking-wider">Extras</label>
<div className="space-y-2">
{[
{ id: 'has_cleaning', label: 'Limpieza Final' },
{ id: 'has_pool_heating', label: 'Calefacción Piscina' },
{ id: 'has_flies_products', label: 'Productos Moscas' }
].map(extra => (
<label key={extra.id} className="flex items-center justify-between p-4 bg-slate-800 border border-slate-700 rounded-2xl cursor-pointer hover:bg-slate-750 hover:border-slate-600 transition-colors">
<span className="font-medium text-slate-200 text-sm">{extra.label}</span>
<input
type="checkbox"
{...register(extra.id as any)}
className="w-5 h-5 rounded-md text-emerald-500 bg-slate-700 border-slate-600 focus:ring-emerald-500/50 focus:ring-2"
/>
</label>
))}
</div>
</div>
{/* 8. Registro Gubernamental */}
<div className="bg-red-950/30 p-4 rounded-2xl border border-red-900/40">
<label className="text-sm font-bold text-red-300 flex items-center mb-2">
Registro Gubernamental
<span className="ml-auto text-[10px] bg-red-900/40 text-red-400 px-2 py-1 rounded-full uppercase tracking-wide font-bold">Requerido</span>
</label>
<input
{...register('government_registration')}
placeholder="Identificador oficial..."
className="block w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-red-500/40 text-red-200 placeholder-slate-600 font-medium"
/>
</div>
{/* 9. Observaciones */}
<div className="bg-slate-800/50 p-4 rounded-2xl border border-slate-700">
<label className="text-sm font-bold text-slate-300 flex items-center mb-2">
Observaciones
<span className="ml-auto text-[10px] bg-slate-700 text-slate-400 px-2 py-1 rounded-full uppercase tracking-wide font-bold">Opcional</span>
</label>
<textarea
{...register('observations')}
placeholder="Notas internas, peticiones especiales..."
rows={3}
className="block w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-emerald-500/40 text-slate-100 placeholder-slate-500 font-medium resize-none"
/>
</div>
{/* 10. Contrato (opcional) */}
<div className="bg-slate-800/50 p-4 rounded-2xl border border-slate-700">
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-bold text-slate-300 flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-400" />
Contrato
</label>
<span className="text-[10px] bg-slate-700 text-slate-400 px-2 py-1 rounded-full uppercase tracking-wide font-bold">Opcional</span>
</div>
{(contractFile || existingContractUrl) ? (
<div className="space-y-2">
{isImageFile ? (
/* ── Image thumbnail ── */
<button
type="button"
onClick={() => setContractPreviewOpen(true)}
className="relative w-full overflow-hidden rounded-xl border border-slate-600 hover:border-emerald-500/60 transition-colors group"
>
<img
src={previewSrc!}
alt="Contrato"
className="w-full h-36 object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors">
<Maximize2 className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow-lg" />
</div>
</button>
) : (
/* ── PDF card ── */
<button
type="button"
onClick={handleOpenPdf}
className="w-full flex items-center gap-3 p-3 bg-slate-700 rounded-xl border border-slate-600 hover:border-emerald-500/60 transition-colors"
>
<div className="w-10 h-10 flex items-center justify-center bg-red-900/40 rounded-lg flex-shrink-0">
<FileText className="w-5 h-5 text-red-400" />
</div>
<div className="flex-1 text-left min-w-0">
<p className="text-sm font-semibold text-slate-200 truncate">
{contractFile?.name ?? 'contrato.pdf'}
</p>
<p className="text-xs text-slate-400">
{contractFile ? formatFileSize(contractFile.size) : 'PDF'} · Toca para ver
</p>
</div>
<ExternalLink className="w-4 h-4 text-slate-400 flex-shrink-0" />
</button>
)}
{/* Replace / remove row */}
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-slate-400 hover:text-emerald-400 bg-slate-700/50 hover:bg-slate-700 rounded-lg transition-colors"
>
<Upload className="w-3 h-3" />
Reemplazar
</button>
<button
type="button"
onClick={handleRemoveContract}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-slate-400 hover:text-red-400 bg-slate-700/50 hover:bg-slate-700 rounded-lg transition-colors"
>
<X className="w-3 h-3" />
Eliminar
</button>
</div>
</div>
) : (
/* ── Drop / pick area ── */
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex flex-col items-center justify-center gap-2 py-7 px-4 border-2 border-dashed border-slate-600 hover:border-emerald-600/50 hover:bg-emerald-900/10 rounded-xl text-slate-400 hover:text-emerald-400 font-medium text-sm transition-all"
>
<Upload className="w-5 h-5" />
<span>Arrastra archivos o pulsa para seleccionar</span>
<span className="text-xs text-slate-500">PDF, JPEG, PNG · máx. 10 MB</span>
</button>
)}
{contractUploading && (
<p className="mt-2 text-xs text-emerald-400 flex items-center gap-1.5 animate-pulse">
Subiendo contrato
</p>
)}
{contractError && (
<p className="mt-2 text-xs text-red-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{contractError}
</p>
)}
<input
ref={fileInputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={handleFileChange}
className="hidden"
/>
</div>
<div className="h-2"></div>
{/* Actions */}
<div className="flex gap-3 pb-6">
{mode === 'edit' && onDelete && (
<button
type="button"
onClick={() => onDelete(initialData?.id as string)}
className="p-4 bg-slate-800 border border-red-900/40 text-red-400 rounded-2xl hover:bg-red-950/40 hover:border-red-900/60 transition-colors"
>
<Trash2 size={22} />
</button>
)}
<button
type="submit"
disabled={!!errors.start_date}
className="flex-1 py-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-base rounded-2xl shadow-xl shadow-emerald-900/30 transform transition-all active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
<Check size={22} />
{mode === 'create' ? 'Crear Reserva' : 'Guardar Cambios'}
</button>
</div>
</form>
</div>
</motion.div>
{/* ── Image lightbox ── */}
<AnimatePresence>
{contractPreviewOpen && isImageFile && previewSrc && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setContractPreviewOpen(false)}
className="fixed inset-0 bg-black/92 z-[60] flex items-center justify-center p-4"
>
<button
type="button"
onClick={() => setContractPreviewOpen(false)}
className="absolute top-5 right-5 p-2.5 bg-slate-800/80 rounded-full hover:bg-slate-700 transition-colors"
>
<X className="w-5 h-5 text-white" />
</button>
<motion.img
initial={{ scale: 0.92, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.92, opacity: 0 }}
src={previewSrc}
alt="Contrato"
className="max-w-full max-h-full object-contain rounded-2xl shadow-2xl"
onClick={e => e.stopPropagation()}
/>
</motion.div>
)}
</AnimatePresence>
</>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,30 @@
import { Search, Filter } from 'lucide-react';
interface SearchBarProps {
searchTerm: string;
onSearchChange: (term: string) => void;
}
export function SearchBar({
searchTerm,
onSearchChange,
}: SearchBarProps) {
return (
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-slate-400" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2.5 border border-slate-700 rounded-xl leading-5 bg-slate-800/50 text-slate-200 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 sm:text-sm transition-all shadow-lg backdrop-blur-sm"
placeholder="Buscar por nombre de huésped..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { useState, useEffect } from 'react';
import { Save, Moon, Bell, LogOut, User } from 'lucide-react';
import { useAuth } from '../hooks/useAuth';
import { toast } from 'sonner';
export function SettingsPage() {
const { user, signOut } = useAuth();
const [darkMode, setDarkMode] = useState(true);
const [soundEnabled, setSoundEnabled] = useState(false);
const [loading, setLoading] = useState(false);
// Initialize state from local storage or system preference
useEffect(() => {
const isDark = document.documentElement.classList.contains('dark');
setDarkMode(isDark);
const storedSound = localStorage.getItem('soundEnabled') === 'true';
setSoundEnabled(storedSound || false);
}, []);
const toggleDarkMode = () => {
const newMode = !darkMode;
setDarkMode(newMode);
if (newMode) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
};
const toggleSound = () => {
const newSound = !soundEnabled;
setSoundEnabled(newSound);
localStorage.setItem('soundEnabled', String(newSound));
if (newSound) {
toast.success("Sonido activado (simulado)");
}
};
const handleLogout = async () => {
try {
setLoading(true);
await signOut();
toast.success("Has cerrado sesión");
} catch (error) {
console.error("Error signing out:", error);
toast.error("Error al cerrar sesión");
} finally {
setLoading(false);
}
};
const handleSave = () => {
toast.success("Preferencias guardadas correctamente");
};
return (
<div className="p-8 max-w-4xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<header className="mb-8">
<h1 className="text-3xl font-black text-stone-900 dark:text-white tracking-tight mb-2">Ajustes</h1>
<p className="text-stone-500 dark:text-emerald-400/60 text-lg">Configura el comportamiento y apariencia de la aplicación.</p>
</header>
{/* 1. Apariencia y Notificaciones */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white dark:bg-emerald-900/10 backdrop-blur-xl border border-stone-200 dark:border-emerald-800/30 rounded-3xl p-8 shadow-xl shadow-stone-200/50 dark:shadow-black/20">
<div className="flex items-center gap-4 mb-6">
<div className="p-3 bg-purple-500/10 rounded-2xl">
<Moon className="w-6 h-6 text-purple-400" />
</div>
<div>
<h2 className="text-xl font-bold text-stone-900 dark:text-white">Apariencia</h2>
<p className="text-stone-500 dark:text-emerald-400/60 text-sm">Personaliza la interfaz</p>
</div>
</div>
<div
className="flex items-center justify-between p-4 bg-stone-100 dark:bg-black/40 rounded-2xl border border-stone-200 dark:border-emerald-800/30 cursor-pointer hover:border-emerald-500/50 transition-colors"
onClick={toggleDarkMode}
>
<span className="font-medium text-stone-700 dark:text-stone-200">Modo Oscuro</span>
<div className={`w-12 h-6 rounded-full relative transition-colors duration-300 ${darkMode ? 'bg-emerald-500' : 'bg-stone-300'}`}>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow-md transition-all duration-300 ${darkMode ? 'right-1' : 'left-1'}`}></div>
</div>
</div>
</div>
<div className="bg-white dark:bg-emerald-900/10 backdrop-blur-xl border border-stone-200 dark:border-emerald-800/30 rounded-3xl p-8 shadow-xl shadow-stone-200/50 dark:shadow-black/20">
<div className="flex items-center gap-4 mb-6">
<div className="p-3 bg-amber-500/10 rounded-2xl">
<Bell className="w-6 h-6 text-amber-500" />
</div>
<div>
<h2 className="text-xl font-bold text-stone-900 dark:text-white">Notificaciones</h2>
<p className="text-stone-500 dark:text-emerald-400/60 text-sm">Alertas y sonidos</p>
</div>
</div>
<div
className="flex items-center justify-between p-4 bg-stone-100 dark:bg-black/40 rounded-2xl border border-stone-200 dark:border-emerald-800/30 cursor-pointer hover:border-emerald-500/50 transition-colors"
onClick={toggleSound}
>
<span className="font-medium text-stone-700 dark:text-stone-200">Sonido de Toasts</span>
<div className={`w-12 h-6 rounded-full relative transition-colors duration-300 ${soundEnabled ? 'bg-amber-500' : 'bg-stone-300'}`}>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow-md transition-all duration-300 ${soundEnabled ? 'right-1' : 'left-1'}`}></div>
</div>
</div>
</div>
</section>
{/* 2. Zona de Usuario / Sesión */}
<section className="bg-white dark:bg-emerald-900/10 backdrop-blur-xl border border-stone-200 dark:border-emerald-800/30 rounded-3xl p-8 shadow-xl shadow-stone-200/50 dark:shadow-black/20">
<div className="flex items-center gap-4 mb-6">
<div className="p-3 bg-red-500/10 rounded-2xl">
<User className="w-6 h-6 text-red-400" />
</div>
<div>
<h2 className="text-xl font-bold text-stone-900 dark:text-white">Cuenta</h2>
<p className="text-stone-500 dark:text-emerald-400/60 text-sm">Gestionar la sesión actual</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-500 to-amber-500 flex items-center justify-center font-bold text-white shadow-lg shadow-emerald-500/20">
{user?.email?.[0].toUpperCase() || 'U'}
</div>
<div>
<p className="text-stone-900 dark:text-white font-bold">Usuario Identificado</p>
<p className="text-stone-500 dark:text-stone-400 text-xs">{user?.email}</p>
</div>
</div>
<button
onClick={handleLogout}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 rounded-xl transition-all border border-red-200 dark:border-red-500/20 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<LogOut size={18} />
{loading ? 'Saliendo...' : 'Cerrar Sesión'}
</button>
</div>
</section>
<div className="flex justify-end pt-4">
<button
onClick={handleSave}
className="flex items-center gap-2 px-8 py-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-2xl shadow-xl shadow-emerald-500/20 hover:scale-105 active:scale-95 transition-all"
>
<Save size={20} />
Guardar Cambios
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { Calendar, Settings, CalendarDays } from 'lucide-react';
interface Props {
currentView: string;
onNavigate: (view: string) => void;
}
export function Sidebar({ currentView, onNavigate }: Props) {
const menuItems = [
{ id: 'calendar', label: 'Mensual', icon: Calendar },
{ id: 'yearly', label: 'Anual 2026', icon: CalendarDays },
{ id: 'settings', label: 'Ajustes', icon: Settings },
];
return (
<div className="hidden md:flex w-72 bg-white dark:bg-emerald-950/10 h-screen flex-col border-r border-stone-200 dark:border-emerald-900/30 backdrop-blur-xl transition-colors duration-500">
{/* Logo */}
<div className="p-8 border-b border-stone-200 dark:border-emerald-900/30">
<h1 className="text-3xl font-black text-stone-900 dark:text-white flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-emerald-500 to-emerald-700 rounded-xl shadow-lg shadow-emerald-500/30">
<Calendar className="w-6 h-6 text-white" />
</div>
<span className="bg-gradient-to-r from-emerald-600 to-amber-500 bg-clip-text text-transparent">
Reservas
</span>
</h1>
<p className="text-xs text-stone-500 dark:text-emerald-400/60 mt-2 font-medium">Sistema de Gestión</p>
</div>
{/* Navigation */}
<nav className="flex-1 p-6 space-y-2">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = currentView === item.id;
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={`
group w-full flex items-center gap-4 px-5 py-4 rounded-2xl
transition-all duration-300 relative overflow-hidden
${isActive
? 'bg-gradient-to-r from-emerald-600 to-emerald-500 text-white shadow-xl shadow-emerald-500/20 scale-105'
: 'text-stone-500 dark:text-emerald-400/60 hover:text-emerald-700 dark:hover:text-emerald-200 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 hover:scale-105'
}
`}
>
{isActive && (
<div className="absolute inset-0 bg-gradient-to-r from-emerald-400 to-amber-400 opacity-20 blur-xl"></div>
)}
<div className={`
p-2 rounded-xl transition-all duration-300 relative z-10
${isActive ? 'bg-white/20' : 'bg-stone-100 dark:bg-emerald-900/20 group-hover:bg-white dark:group-hover:bg-emerald-800/40'}
`}>
<Icon className="w-5 h-5" />
</div>
<span className="font-semibold relative z-10">{item.label}</span>
</button>
);
})}
</nav>
{/* Footer - Leyenda */}
<div className="p-6 border-t border-stone-200 dark:border-emerald-900/30 bg-stone-50/50 dark:bg-emerald-950/20 backdrop-blur">
<p className="text-xs font-bold text-stone-400 dark:text-emerald-500/40 mb-4 uppercase tracking-wider">Proveedores</p>
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-blue-50/50 dark:bg-blue-500/5 rounded-xl border border-blue-200 dark:border-blue-500/10">
<div className="w-3 h-3 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 shadow-lg shadow-blue-500/30"></div>
<span className="text-sm font-semibold text-stone-600 dark:text-slate-300">Teneriffa</span>
</div>
<div className="flex items-center gap-3 p-3 bg-amber-50/50 dark:bg-amber-500/5 rounded-xl border border-amber-200 dark:border-amber-500/10">
<div className="w-3 h-3 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 shadow-lg shadow-amber-500/30"></div>
<span className="text-sm font-semibold text-stone-600 dark:text-amber-100/70">Naturcalabacera</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { format, eachDayOfInterval, endOfMonth, startOfMonth, startOfYear, endOfYear, eachMonthOfInterval, getDay } from 'date-fns';
import { es } from 'date-fns/locale';
import type { Reservation } from '../types';
interface Props {
reservations: Reservation[];
year: number;
}
export function YearlyCalendar({ reservations = [], year }: Props) {
const months = eachMonthOfInterval({
start: startOfYear(new Date(year, 0, 1)),
end: endOfYear(new Date(year, 0, 1))
});
const isDayOccupied = (date: Date) => {
if (!reservations || !Array.isArray(reservations)) return false;
return reservations.some(res => {
if (!res.start_date || !res.end_date) return false;
const start = new Date(res.start_date);
const end = new Date(res.end_date);
if (isNaN(start.getTime()) || isNaN(end.getTime())) return false;
// Ajuste de zona horaria simple
const checkDate = new Date(date);
checkDate.setHours(12, 0, 0, 0);
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
return checkDate >= start && checkDate <= end;
});
};
return (
<div className="p-8 max-w-[1600px] mx-auto animate-in fade-in zoom-in duration-500">
<header className="mb-8 flex justify-between items-end">
<div>
<h1 className="text-4xl font-black text-white tracking-tighter mb-2">Vista Anual {year}</h1>
<p className="text-slate-400 text-lg">Panorama global de ocupación.</p>
</div>
<div className="flex gap-4 items-center bg-slate-800/50 px-4 py-2 rounded-full border border-slate-700">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500"></div>
<span className="text-slate-300 text-xs font-bold uppercase tracking-wider">Libre</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]"></div>
<span className="text-slate-300 text-xs font-bold uppercase tracking-wider">Ocupado</span>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{months.map((month) => {
const monthStart = startOfMonth(month);
const monthEnd = endOfMonth(month);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const startDayOfWeek = getDay(monthStart); // 0 = Sunday
// Create empty slots for days before the 1st of the month
const emptySlots = Array(startDayOfWeek).fill(null);
return (
<div key={month.toString()} className="bg-slate-800/40 backdrop-blur-md border border-slate-700/50 rounded-2xl p-4 hover:border-slate-600 transition-colors shadow-lg">
<h3 className="text-lg font-bold text-white mb-4 capitalize text-center border-b border-slate-700/50 pb-2">
{format(month, 'MMMM', { locale: es })}
</h3>
<div className="grid grid-cols-7 gap-1 mb-2">
{['D', 'L', 'M', 'X', 'J', 'V', 'S'].map(d => (
<div key={d} className="text-center text-[10px] font-bold text-slate-500">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{emptySlots.map((_, i) => (
<div key={`empty-${i}`} />
))}
{days.map((day) => {
const occupied = isDayOccupied(day);
return (
<div
key={day.toString()}
className={`
aspect-square rounded-md flex items-center justify-center text-xs font-medium relative group transition-all duration-300
${occupied
? 'bg-red-500/30 text-red-100 shadow-[0_0_10px_rgba(239,68,68,0.2)] hover:bg-red-500/40'
: 'bg-emerald-500/10 text-emerald-500/60 hover:bg-emerald-500/20 hover:text-emerald-400'
}
`}
title={format(day, 'dd/MM/yyyy')}
>
{format(day, 'd')}
{/* Tooltip on hover if occupied (simplified) */}
{occupied && (
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 hidden group-hover:block whitespace-nowrap z-10 bg-black/80 backdrop-blur text-white text-[10px] px-2 py-1 rounded shadow-lg border border-white/10">
Ocupado
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}

38
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,38 @@
import { useState, useEffect } from 'react';
import { supabase } from '../lib/supabase';
import type { Session, User } from '@supabase/supabase-js';
export function useAuth() {
const [session, setSession] = useState<Session | null>(null);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 1. Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
});
// 2. Listen for changes
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
});
return () => subscription.unsubscribe();
}, []);
const signOut = async () => {
await supabase.auth.signOut();
};
return {
session,
user,
loading,
signOut
};
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import type { Reservation, NewReservation } from '../types';
export function useReservations() {
const [reservations, setReservations] = useState<Reservation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchReservations = async () => {
try {
setLoading(true);
const { data, error } = await supabase
.from('reservations')
.select('*')
.order('start_date', { ascending: true });
if (error) throw error;
setReservations(data || []);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchReservations();
// Realtime subscription
const channel = supabase
.channel('reservations_updates')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'reservations' },
() => {
// Simplest strategy: refetch all on change to ensure consistency
fetchReservations();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
const createReservation = async (reservation: NewReservation) => {
const { error } = await supabase.from('reservations').insert([reservation]);
if (error) throw error;
};
const updateReservation = async (id: string, updates: Partial<Reservation>) => {
// 1. Get current state to check logic before update if needed (or rely on UI)
// For webhook logic, we check government_registration change.
// However, since we are client-side, the webhook/trigger logic 'ideally' lives in backend
// or we simulate it here. The prompt said "Webhook (n8n/API): Un endpoint que recibe una petición POST".
// We will just do the update here. If we need to fire the webhook from client:
// Check previous value for triggering webhook manually if needed,
// BUT usually DB triggers are better. User asked for specific logic.
// Let's implement the trigger logic in the UI layer (Modal) or here.
// Since we don't have the "previous" value easily here without fetching,
// we will assume the caller handles the comparison or we just update.
const { error } = await supabase
.from('reservations')
.update(updates)
.eq('id', id);
if (error) throw error;
};
const deleteReservation = async (id: string) => {
const { error } = await supabase.from('reservations').delete().eq('id', id);
if (error) throw error;
};
return {
reservations,
loading,
error,
createReservation,
updateReservation,
deleteReservation,
refreshResolver: fetchReservations
};
}

39
src/index.css Normal file
View File

@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-teneriffa: #60a5fa;
/* Keep brand colors for now, or shift if requested */
--color-natur: #facc15;
}
/* Default to Light Mode */
html,
body {
background-color: #fafaf9;
/* stone-50 */
color: #1c1917;
/* stone-900 */
}
/* Dark Mode Overrides */
.dark html,
.dark body {
background-color: #020617;
/* slate-950 / black */
color: #f1f5f9;
/* slate-100 */
}
body {
margin: 0;
padding: 0;
}
#root {
min-height: 100vh;
@apply bg-stone-50 dark:bg-black transition-colors duration-500;
}
}

20
src/lib/supabase.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseKey) {
console.error('CRITICAL: Missing Supabase Environment Variables in .env');
console.error('VITE_SUPABASE_URL:', supabaseUrl);
// Don't throw, just warn to keep app alive for debugging
}
export const supabase = createClient(
supabaseUrl || 'https://placeholder.supabase.co',
supabaseKey || 'placeholder-key',
{
db: {
schema: import.meta.env.VITE_SUPABASE_SCHEMA || 'public'
}
}
);

14
src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
console.log("Main.tsx: App version starting");
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
console.log("Main.tsx: Wrapped App in StrictMode and rendered");

38
src/types/index.ts Normal file
View File

@@ -0,0 +1,38 @@
export type ReservationOrigin = 'Teneriffa2000' | 'Naturcalabacera';
export type Property = 'los_dragos' | 'la_esquinita';
export interface Reservation {
id: string;
created_at?: string;
start_date: string; // YYYY-MM-DD
end_date: string; // YYYY-MM-DD
client_name: string;
origin: ReservationOrigin;
property?: Property;
invoice_number?: string;
adults_count: number;
children_count: number;
has_cleaning: boolean;
has_pool_heating: boolean;
has_flies_products: boolean;
government_registration?: string;
observations?: string;
contract_url?: string;
// Event fields (Naturcalabacera only)
is_event?: boolean;
event_type?: string;
event_type_other?: string;
attendees_count?: number;
}
export type NewReservation = Omit<Reservation, 'id' | 'created_at'>;
export interface WebhookPayload {
event: 'registration_filled';
reservation_id: string;
client_name: string;
government_registration: string;
invoice_number?: string;
start_date: string;
end_date: string;
}