Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)
This commit is contained in:
42
src/App.css
Normal file
42
src/App.css
Normal 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
191
src/App.tsx
Normal 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
1
src/assets/react.svg
Normal 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 |
252
src/components/CalendarGrid.tsx
Normal file
252
src/components/CalendarGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
src/components/CalendarGrid.tsx.backup
Normal file
222
src/components/CalendarGrid.tsx.backup
Normal 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 >
|
||||
);
|
||||
}
|
||||
155
src/components/CustomMobileCalendar.tsx
Normal file
155
src/components/CustomMobileCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/components/LoginPage.tsx
Normal file
108
src/components/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/MobileNavigation.tsx
Normal file
52
src/components/MobileNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
743
src/components/ReservationModal.tsx
Normal file
743
src/components/ReservationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/SearchBar.tsx
Normal file
30
src/components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/components/SettingsPage.tsx
Normal file
156
src/components/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/components/Sidebar.tsx
Normal file
80
src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/components/YearlyCalendar.tsx
Normal file
114
src/components/YearlyCalendar.tsx
Normal 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
38
src/hooks/useAuth.ts
Normal 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
|
||||
};
|
||||
}
|
||||
88
src/hooks/useReservations.ts
Normal file
88
src/hooks/useReservations.ts
Normal 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
39
src/index.css
Normal 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
20
src/lib/supabase.ts
Normal 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
14
src/main.tsx
Normal 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
38
src/types/index.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user