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

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

15
apps/web/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# ─────────────────────────────────────────────
# apps/web — Variables de entorno
# Copia este archivo como .env y rellena los valores.
# Los prefijos VITE_ son accesibles en el navegador.
# ─────────────────────────────────────────────
# Supabase (self-hosted)
VITE_SUPABASE_URL=https://tu-supabase.tudominio.com
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# URL del backend API (apps/api)
VITE_API_URL=http://localhost:3001
# Chatbot — poner true para mostrar el contenedor inferior
VITE_CHATBOT_ENABLED=false

23
apps/web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
apps/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>reservas-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

42
apps/web/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "@naturcalabacera/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@naturcalabacera/shared": "workspace:*",
"@supabase/supabase-js": "^2.95.3",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.33.0",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
apps/web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
apps/web/src/App.css Normal file
View File

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

275
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,275 @@
import { useState, useEffect } from 'react';
import { useReservations } from './hooks/useReservations';
import { usePropertyTheme } from './hooks/usePropertyTheme';
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 { UserManagement } from './components/UserManagement';
import { LoginPage } from './components/LoginPage';
import { ChatbotContainer } from './components/ChatbotContainer';
import { useAuth } from './hooks/useAuth';
import { PropertyProvider, useProperty } from './contexts/PropertyContext';
import { UserRoleProvider, useUserRoleContext } from './contexts/UserRoleContext';
import type { NewReservation, Reservation } from './types';
import { format } from 'date-fns';
import { Plus, Loader2 } from 'lucide-react';
import { Toaster, toast } from 'sonner';
// Componente interno que ya tiene acceso al PropertyContext y UserRoleContext
function AppContent() {
const { property } = useProperty();
const theme = usePropertyTheme();
const { isViewer, isStaff, isAdmin } = useUserRoleContext();
const {
reservations,
loading: reservationsLoading,
createReservation,
updateReservation,
deleteReservation,
refreshResolver,
} = useReservations(property);
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('');
const filteredReservations = reservations.filter(res =>
res.client_name.toLowerCase().includes(searchTerm.toLowerCase())
);
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 handleSelectRange = (start: Date, end: Date) => {
setSelectedReservation({
start_date: format(start, 'yyyy-MM-dd'),
end_date: format(end, 'yyyy-MM-dd'),
origin: 'Teneriffa2000',
adults_count: 2,
children_count: 0,
});
setModalMode('create');
setModalOpen(true);
};
const handleSave = async (data: NewReservation): Promise<Reservation | void> => {
try {
let savedReservation: Reservation;
const previousSnapshot = modalMode === 'edit' ? { ...selectedReservation } as Reservation : undefined;
if (modalMode === 'create') {
savedReservation = await createReservation(data);
} else {
if (selectedReservation.id) {
await updateReservation(selectedReservation.id, data);
savedReservation = { ...selectedReservation, ...data } as Reservation;
} else {
return;
}
}
refreshResolver();
toast.success('Reserva guardada correctamente');
// Notificar al API backend para programar notificaciones
const operation = modalMode === 'create' ? 'created' : 'updated';
void notifyApi(savedReservation, operation, previousSnapshot);
return savedReservation;
} catch (error) {
console.error('Error saving:', error);
toast.error('Error al guardar la reserva');
}
};
const handleDelete = async (id: string) => {
try {
const reservationToDelete = reservations.find(r => r.id === id);
await deleteReservation(id);
setModalOpen(false);
refreshResolver();
toast.success('Reserva eliminada correctamente');
if (reservationToDelete) {
void notifyApi(reservationToDelete, 'cancelled');
}
} 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 overflow-hidden transition-all duration-700 ${theme.rootBg}`}>
<Toaster position="top-right" theme="dark" richColors />
<Sidebar currentView={currentView} onNavigate={setCurrentView} isViewer={isViewer} isAdmin={isAdmin} />
<div className="flex-1 flex flex-col overflow-hidden relative">
<header className={`${theme.headerBg} backdrop-blur-xl border-b ${theme.headerBorder} px-4 md:px-10 py-4 md:py-6 shadow-2xl transition-all duration-700`}>
{/* Relative container: center title absolutely, left/right content on sides */}
<div className="relative flex items-center justify-center min-h-[3rem] md:min-h-[3.5rem]">
{/* Left: status dot + tagline (desktop only) */}
<div className="absolute left-0 hidden md:flex items-center gap-2">
<span className={`inline-block w-2 h-2 rounded-full shadow-lg ${theme.dotColor} ${theme.dotShadow} animate-pulse flex-shrink-0`} />
<span className={`text-xs font-medium ${theme.subtitleText}`}>
Gestiona tus reservas y disponibilidad
</span>
</div>
{/* Center: property name — always truly centered */}
<h1
className={`text-3xl md:text-5xl font-black tracking-tight text-transparent bg-gradient-to-r ${theme.titleGradient} bg-clip-text ${theme.titleShadow} transition-all duration-700 select-none pointer-events-none`}
>
{theme.name}
</h1>
{/* Right: Nueva Reserva button */}
{isStaff && (
<div className="absolute right-0">
<button
onClick={() => handleSelectDay(new Date())}
className={`group px-4 md:px-7 py-2.5 md:py-3.5 bg-gradient-to-r ${theme.buttonBg} hover:opacity-90 text-white font-bold rounded-2xl transition-all duration-300 shadow-xl ${theme.buttonShadow} ${theme.buttonHoverShadow} hover:scale-105 border border-white/10 flex items-center gap-2`}
>
<Plus className="w-4 h-4 md:w-5 md:h-5 group-hover:rotate-90 transition-transform duration-300" />
<span className="hidden md:inline text-sm">Nueva Reserva</span>
</button>
</div>
)}
</div>
{/* Mobile subtitle below title */}
<div className="flex md:hidden justify-center mt-1.5">
<span className={`text-[11px] font-medium ${theme.subtitleText} flex items-center gap-1.5`}>
<span className={`inline-block w-1.5 h-1.5 rounded-full ${theme.dotColor} animate-pulse`} />
Gestiona tus reservas y disponibilidad
</span>
</div>
</header>
<main className={`flex-1 overflow-auto p-4 md:p-10 pb-24 md:pb-10 ${theme.mainBg} transition-all duration-700`}>
{currentView === 'calendar' && (
<>
<SearchBar searchTerm={searchTerm} onSearchChange={setSearchTerm} />
<CalendarGrid
reservations={filteredReservations}
onSelectDay={handleSelectDay}
onSelectRange={handleSelectRange}
onSelectReservation={handleSelectReservation}
isLoading={reservationsLoading}
viewerMode={isViewer}
/>
</>
)}
{currentView === 'settings' && <SettingsPage />}
{currentView === 'yearly' && (
<YearlyCalendar
reservations={reservations}
onSelectDay={isStaff ? handleSelectDay : undefined}
onSelectRange={isStaff ? handleSelectRange : undefined}
onSelectReservation={handleSelectReservation}
viewerMode={isViewer}
/>
)}
{currentView === 'users' && isAdmin && <UserManagement />}
</main>
<MobileNavigation currentView={currentView} onNavigate={setCurrentView} isViewer={isViewer} isAdmin={isAdmin} />
</div>
<ChatbotContainer />
{isStaff && (
<ReservationModal
isOpen={modalOpen}
mode={modalMode}
initialData={selectedReservation}
existingReservations={reservations}
onClose={() => setModalOpen(false)}
onSave={handleSave}
onDelete={handleDelete}
/>
)}
</div>
);
}
// Llama al API backend para programar notificaciones (fire-and-forget)
async function notifyApi(
reservation: Reservation,
operation: 'created' | 'updated' | 'cancelled',
previousReservation?: Reservation
) {
const apiUrl = import.meta.env.VITE_API_URL;
if (!apiUrl) return; // Si no hay API configurada, skip silencioso
try {
await fetch(`${apiUrl}/api/notifications/reservation-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reservation, operation, previousReservation }),
});
} catch {
// No bloquear la UI si el API no está disponible
console.warn('[notifications] API no disponible');
}
}
function App() {
// 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 { session, loading: authLoading } = useAuth();
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 />
</>
);
}
return (
<PropertyProvider>
<UserRoleProvider>
<AppContent />
</UserRoleProvider>
</PropertyProvider>
);
}
export default App;

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,508 @@
import { useEffect, useRef, useState } from 'react';
import {
format, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameMonth, addMonths, subMonths,
isSameDay, differenceInDays, parseISO, isWithinInterval
} from 'date-fns';
import { es } from 'date-fns/locale/es';
import { ChevronLeft, ChevronRight, Users, Moon } from 'lucide-react';
import type { Reservation } from '../types';
import { useProperty } from '../contexts/PropertyContext';
import { PROPERTY_CONFIG } from '@naturcalabacera/shared';
import { ServiceIcons } from './ServiceIcons';
interface Props {
reservations: Reservation[];
onSelectDay: (day: Date) => void;
onSelectReservation: (reservation: Reservation) => void;
onSelectRange?: (start: Date, end: Date) => void;
isLoading?: boolean;
viewerMode?: boolean;
}
export function CalendarGrid({
reservations,
onSelectDay,
onSelectReservation,
onSelectRange,
isLoading: _isLoading = false,
viewerMode = false,
}: Props) {
const { property } = useProperty();
const propertyConfig = PROPERTY_CONFIG[property];
const [currentDate, setCurrentDate] = useState(new Date());
// Drag-to-select state
const [dragStart, setDragStart] = useState<Date | null>(null);
const [dragEnd, setDragEnd] = useState<Date | null>(null);
const [isDragging, setIsDragging] = useState(false);
const gridBodyRef = useRef<HTMLDivElement>(null);
// Tracks mouse movement to distinguish click vs drag
const mouseMoved = useRef(false);
// Prevents the click event that fires after mouseUp from triggering onSelectDay
const dragJustFinished = useRef(false);
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(monthStart);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
const weeks: Date[][] = [];
for (let i = 0; i < calendarDays.length; i += 7) {
weeks.push(calendarDays.slice(i, i + 7));
}
const nextMonth = () => setCurrentDate(addMonths(currentDate, 1));
const prevMonth = () => setCurrentDate(subMonths(currentDate, 1));
// Display occupancy: a day is occupied for rendering if within [start, end] inclusive
const isDayOccupied = (day: Date): boolean => {
return reservations.some(res => {
const s = parseISO(res.start_date);
const e = parseISO(res.end_date);
return isWithinInterval(day, { start: s, end: e }) || isSameDay(day, s) || isSameDay(day, e);
});
};
// Get reservation for a specific day (for click-through on occupied cells)
const getReservationForDay = (day: Date): Reservation | undefined => {
return reservations.find(res => {
const s = parseISO(res.start_date);
const e = parseISO(res.end_date);
return isWithinInterval(day, { start: s, end: e }) || isSameDay(day, s) || isSameDay(day, e);
});
};
// Calculate which calendar day is under a given coordinate
const getDayFromCoords = (clientX: number, clientY: number): Date | null => {
const el = gridBodyRef.current;
if (!el) return null;
const rect = el.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
if (x < 0 || x >= rect.width || y < 0 || y >= rect.height) return null;
const col = Math.min(Math.floor(x / (rect.width / 7)), 6);
const row = Math.min(Math.floor(y / (rect.height / weeks.length)), weeks.length - 1);
return weeks[row]?.[col] ?? null;
};
// Is a day inside the current drag selection range?
const isInDragSelection = (day: Date): boolean => {
if (!dragStart || !dragEnd) return false;
const start = dragStart <= dragEnd ? dragStart : dragEnd;
const end = dragStart <= dragEnd ? dragEnd : dragStart;
return day >= start && day <= end;
};
// Confirm drag and open modal
const confirmDrag = (start: Date, end: Date) => {
const s = start <= end ? start : end;
const e = start <= end ? end : start;
if (isSameDay(s, e)) {
onSelectDay(s);
} else if (onSelectRange) {
onSelectRange(s, e);
} else {
onSelectDay(s);
}
};
// --- Grid body mouse events ---
const handleGridMouseDown = (ev: React.MouseEvent) => {
if (viewerMode) return;
const day = getDayFromCoords(ev.clientX, ev.clientY);
if (!day) return;
// Allow drag start on any day (free or occupied boundary)
// Drag is only initiated on non-occupied days
if (isDayOccupied(day)) return;
ev.preventDefault();
mouseMoved.current = false;
setDragStart(day);
setDragEnd(day);
setIsDragging(true);
};
const handleGridMouseMove = (ev: React.MouseEvent) => {
if (!isDragging || !dragStart) return;
mouseMoved.current = true;
const day = getDayFromCoords(ev.clientX, ev.clientY);
if (day) setDragEnd(day);
};
const handleGridMouseUp = (ev: React.MouseEvent) => {
if (!isDragging || !dragStart || !dragEnd) return;
const hasMoved = mouseMoved.current;
const start = dragStart;
const end = dragEnd;
setIsDragging(false);
setDragStart(null);
setDragEnd(null);
if (hasMoved && !isSameDay(start, end)) {
dragJustFinished.current = true;
confirmDrag(start, end);
}
// Single clicks are handled entirely by handleGridClick
ev.stopPropagation();
};
// Handle click on grid body (for free-day single clicks when drag is not involved)
const handleGridClick = (ev: React.MouseEvent) => {
if (viewerMode || isDragging) return;
// Ignore the synthetic click that fires immediately after a drag ends
if (dragJustFinished.current) {
dragJustFinished.current = false;
return;
}
const day = getDayFromCoords(ev.clientX, ev.clientY);
if (!day) return;
const res = getReservationForDay(day);
if (res) {
// Click on occupied day — open reservation
onSelectReservation(res);
} else {
// Click on free day — open create modal
onSelectDay(day);
}
};
// --- Touch events (mobile drag) ---
const handleTouchStart = (ev: React.TouchEvent) => {
if (viewerMode) return;
const touch = ev.touches[0];
const day = getDayFromCoords(touch.clientX, touch.clientY);
if (!day || isDayOccupied(day)) return;
mouseMoved.current = false;
setDragStart(day);
setDragEnd(day);
setIsDragging(true);
};
const handleTouchMove = (ev: React.TouchEvent) => {
if (!isDragging) return;
mouseMoved.current = true;
const touch = ev.touches[0];
const day = getDayFromCoords(touch.clientX, touch.clientY);
if (day) setDragEnd(day);
};
const handleTouchEnd = () => {
if (!isDragging || !dragStart || !dragEnd) return;
const hasMoved = mouseMoved.current;
const start = dragStart;
const end = dragEnd;
setIsDragging(false);
setDragStart(null);
setDragEnd(null);
if (hasMoved && !isSameDay(start, end)) {
dragJustFinished.current = true;
confirmDrag(start, end);
} else {
onSelectDay(start);
}
};
// Cancel drag if mouse leaves the window
useEffect(() => {
const cancel = () => {
if (isDragging) {
setIsDragging(false);
setDragStart(null);
setDragEnd(null);
}
};
document.addEventListener('mouseup', cancel);
return () => document.removeEventListener('mouseup', cancel);
}, [isDragging]);
// --- Reservation blocks (visual only — clicks handled at grid body level) ---
const renderReservationBlocks = () => {
const blocks: React.ReactElement[] = [];
reservations.forEach((res) => {
const startDate = parseISO(res.start_date);
const endDate = parseISO(res.end_date);
const startIndex = calendarDays.findIndex(day => isSameDay(day, startDate));
if (startIndex === -1) return;
const endIndex = calendarDays.findIndex(day => isSameDay(day, endDate));
if (endIndex === -1) return;
const totalDuration = differenceInDays(endDate, startDate) + 1;
const nights = totalDuration - 1;
const isTeneriffa = res.origin === 'Teneriffa2000';
const gradient = viewerMode
? 'bg-stone-400/30 dark:bg-stone-500/30'
: isTeneriffa
? 'bg-blue-600/30 dark:bg-blue-500/30'
: 'bg-yellow-500/30 dark:bg-yellow-400/30';
const borderClass = viewerMode
? 'border-l-4 border-stone-400'
: isTeneriffa
? 'border-l-4 border-blue-500'
: 'border-l-4 border-yellow-500';
const textShadow = 'drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]';
let currentDayIndex = startIndex;
let blockIndex = 0;
while (currentDayIndex <= endIndex) {
const weekIndex = Math.floor(currentDayIndex / 7);
const dayOfWeek = currentDayIndex % 7;
const daysUntilWeekEnd = 7 - dayOfWeek;
const daysRemaining = endIndex - currentDayIndex + 1;
const daysInThisWeek = Math.min(daysUntilWeekEnd, daysRemaining);
const isFirstBlock = blockIndex === 0;
blocks.push(
<div
key={`${res.id}-week-${weekIndex}`}
className={`
absolute pointer-events-none
${gradient} ${borderClass}
z-10
flex flex-col justify-end items-center md:items-start md:p-1.5
`}
style={{
top: `calc(${weekIndex} * var(--cell-height))`,
left: `${(dayOfWeek * 100 / 7)}%`,
width: `${(daysInThisWeek * (100 / 7))}%`,
height: 'var(--cell-height)',
}}
>
{/* Desktop */}
<div className="hidden md:block w-full">
<div className={`text-xs font-black text-white truncate ${textShadow} px-1`}>
{viewerMode ? 'Ocupado' : res.client_name}
</div>
{!viewerMode && isFirstBlock && (
<div className="flex items-center gap-1.5 mt-0.5 px-1">
<div className={`flex items-center gap-0.5 text-white ${textShadow}`}>
<Moon className="w-2.5 h-2.5" />
<span className="text-[9px] font-bold">{nights}n</span>
</div>
<div className={`flex items-center gap-0.5 text-white ${textShadow}`}>
<Users className="w-2.5 h-2.5" />
<span className="text-[9px] font-bold">{res.adults_count + res.children_count}p</span>
</div>
<ServiceIcons reservation={res} />
</div>
)}
</div>
{/* Mobile */}
<div className="md:hidden w-full flex items-end justify-between pb-0.5 px-0.5">
{daysInThisWeek > 1 && (
<span className={`text-[8px] font-black text-white/90 truncate uppercase tracking-tight ${textShadow}`}>
{viewerMode ? 'Ocupado' : res.client_name}
</span>
)}
{!viewerMode && daysInThisWeek > 2 && isFirstBlock && (
<ServiceIcons reservation={res} />
)}
</div>
</div>
);
currentDayIndex += daysInThisWeek;
blockIndex++;
}
});
return blocks;
};
// Drag selection highlight blocks
const renderDragSelection = () => {
if (!isDragging || !dragStart || !dragEnd) return null;
const start = dragStart <= dragEnd ? dragStart : dragEnd;
const end = dragStart <= dragEnd ? dragEnd : dragStart;
const startIndex = calendarDays.findIndex(d => d >= start);
if (startIndex === -1) return null;
const endIndex = calendarDays.findIndex(d => d >= end);
const effectiveEnd = endIndex === -1 ? calendarDays.length - 1 : endIndex;
const selBlocks: React.ReactElement[] = [];
let cur = startIndex;
while (cur <= effectiveEnd) {
const weekIndex = Math.floor(cur / 7);
const dayOfWeek = cur % 7;
const daysUntilWeekEnd = 7 - dayOfWeek;
const daysRemaining = effectiveEnd - cur + 1;
const span = Math.min(daysUntilWeekEnd, daysRemaining);
selBlocks.push(
<div
key={`sel-${cur}`}
className="absolute z-20 pointer-events-none bg-emerald-500/20 border-l-2 border-r-2 border-emerald-400/60 border-t border-b border-emerald-400/30"
style={{
top: `calc(${weekIndex} * var(--cell-height))`,
left: `${(dayOfWeek * 100 / 7)}%`,
width: `${(span * (100 / 7))}%`,
height: 'var(--cell-height)',
}}
/>
);
cur += span;
}
return selBlocks;
};
// Days count label during drag
const dragNights = dragStart && dragEnd
? Math.abs(differenceInDays(dragEnd, dragStart))
: 0;
return (
<div className="bg-white dark:bg-emerald-950/10 rounded-3xl p-3 md:p-5 shadow-xl border border-stone-200 dark:border-emerald-900/30 backdrop-blur-xl transition-colors duration-500 flex flex-col">
<style>{`
:root {
--cell-height: 2.75rem;
--header-height: 1.75rem;
}
@media (min-width: 768px) {
:root {
--cell-height: 5rem;
--header-height: 2.75rem;
}
}
`}</style>
{/* Header */}
<div className="flex items-center justify-between mb-3 md:mb-4 shrink-0 overflow-visible">
<div className="min-w-0 overflow-visible flex-1">
<div className="flex items-baseline gap-3">
<h2 className="text-xl md:text-2xl font-black text-transparent bg-gradient-to-r from-emerald-800 to-amber-600 dark:from-white dark:to-emerald-200 bg-clip-text capitalize pb-1 leading-tight">
{format(currentDate, 'MMMM yyyy', { locale: es })}
</h2>
{/* Drag hint — shown only during active drag */}
{isDragging && dragNights > 0 && (
<span className="text-xs font-bold text-emerald-500 dark:text-emerald-400 animate-pulse">
{dragNights} noche{dragNights !== 1 ? 's' : ''}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-stone-500 dark:text-emerald-400/60 font-medium">
{viewerMode ? 'Vista de disponibilidad' : 'Clic para crear · Arrastra para rango'}
</p>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-gradient-to-r ${propertyConfig.color.gradient} text-white`}>
{propertyConfig.label}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-3">
<button
onClick={prevMonth}
className="p-2 rounded-xl bg-stone-100 dark:bg-emerald-900/20 hover:bg-emerald-50 dark:hover:bg-emerald-900/40 text-stone-600 dark:text-emerald-100/80 transition-all duration-300 shadow-sm hover:shadow-md hover:scale-110 border border-stone-200 dark:border-emerald-500/20"
>
<ChevronLeft className="w-4 h-4 md:w-5 md:h-5" />
</button>
<button
onClick={nextMonth}
className="p-2 rounded-xl bg-stone-100 dark:bg-emerald-900/20 hover:bg-emerald-50 dark:hover:bg-emerald-900/40 text-stone-600 dark:text-emerald-100/80 transition-all duration-300 shadow-sm hover:shadow-md hover:scale-110 border border-stone-200 dark:border-emerald-500/20"
>
<ChevronRight className="w-4 h-4 md:w-5 md:h-5" />
</button>
</div>
</div>
{/* Grid container */}
<div className="bg-stone-50 dark:bg-black/20 backdrop-blur-xl rounded-2xl overflow-hidden border border-stone-200 dark:border-emerald-900/30 shadow-2xl shadow-stone-200/50 dark:shadow-black/20 relative">
<div className="w-full flex flex-col">
{/* Days header */}
<div className="grid grid-cols-7 bg-stone-100 dark:bg-emerald-950/30 border-b border-stone-200 dark:border-emerald-900/30 backdrop-blur-sm shrink-0">
{['D', 'L', 'M', 'X', 'J', 'V', 'S'].map((day, i) => (
<div key={i} className="text-center py-1.5 md:py-3 text-[9px] md:text-xs font-black text-stone-400 dark:text-emerald-500/60 uppercase tracking-widest">
<span className="md:hidden">{day}</span>
<span className="hidden md:inline">{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'][i]}</span>
</div>
))}
</div>
{/* Grid body — handles all pointer interactions via coordinates */}
<div
ref={gridBodyRef}
className={`relative ${!viewerMode ? (isDragging ? 'cursor-crosshair' : 'cursor-pointer') : 'cursor-default'}`}
onMouseDown={handleGridMouseDown}
onMouseMove={handleGridMouseMove}
onMouseUp={handleGridMouseUp}
onClick={handleGridClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
// Prevent text selection during drag
style={{ userSelect: isDragging ? 'none' : undefined }}
>
{/* Grid cells — purely visual, no pointer-events needed */}
{weeks.map((week, weekIdx) => (
<div key={weekIdx} className="grid grid-cols-7 border-b border-stone-200 dark:border-emerald-900/30 last:border-b-0">
{week.map((day) => {
const isCurrentMonth = isSameMonth(day, monthStart);
const isOccupied = isDayOccupied(day);
const inSel = isInDragSelection(day);
const isToday = isSameDay(day, new Date());
return (
<div
key={day.toString()}
className={`
relative border-r border-stone-200 dark:border-emerald-900/30 last:border-r-0
p-1 md:p-2
${!isCurrentMonth ? 'bg-stone-100/50 dark:bg-emerald-950/30' : ''}
${inSel && !isOccupied ? 'bg-emerald-50/60 dark:bg-emerald-900/10' : ''}
${isToday && isCurrentMonth ? 'bg-blue-50/60 dark:bg-blue-500/5' : ''}
`}
style={{ height: 'var(--cell-height)' }}
>
<span className={`
inline-flex items-center justify-center w-4 h-4 md:w-7 md:h-7 rounded-md text-[9px] md:text-xs font-bold
transition-all duration-200 relative z-30
${isToday && isCurrentMonth ? 'bg-blue-500 text-white ring-2 ring-blue-400/60 ring-offset-1 ring-offset-white dark:ring-offset-emerald-950 shadow-lg shadow-blue-500/30' : ''}
${!isToday && isCurrentMonth
? isOccupied
? 'text-white drop-shadow-md'
: inSel
? 'text-emerald-700 dark:text-emerald-300'
: 'text-stone-700 dark:text-emerald-100/80'
: !isCurrentMonth ? 'text-stone-300 dark:text-emerald-900/40' : ''
}
`}>
{format(day, 'd')}
</span>
</div>
);
})}
</div>
))}
{/* Reservation color blocks */}
{renderReservationBlocks()}
{/* Drag selection overlay */}
{renderDragSelection()}
</div>
</div>
</div>
{/* Legend */}
<div className="mt-3 flex flex-wrap items-center gap-3 md:gap-5 text-xs text-stone-500 dark:text-emerald-400/60 shrink-0">
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/5 rounded-xl border border-blue-200 dark:border-blue-500/10">
<div className="w-3 h-3 rounded-md bg-gradient-to-br from-blue-400 to-blue-600 shadow-sm shadow-blue-500/50 shrink-0" />
<span className="font-semibold text-stone-700 dark:text-emerald-100/80">Teneriffa</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 dark:bg-amber-500/5 rounded-xl border border-amber-200 dark:border-amber-500/10">
<div className="w-3 h-3 rounded-md bg-gradient-to-br from-amber-400 to-amber-600 shadow-sm shadow-amber-500/50 shrink-0" />
<span className="font-semibold text-stone-700 dark:text-emerald-100/80">Natur</span>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,591 @@
/**
* ChatbotContainer — asistente conversacional con datos reales de Supabase.
*
* Arquitectura lista para IA real: la función processMessage() actualmente
* usa un motor de reglas. Para conectar un LLM, sustituye su cuerpo por
* una llamada a la API (Claude, GPT, etc.) pasando los datos de reservas
* como contexto del sistema.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { MessageCircle, X, Send, Home, RefreshCw } from 'lucide-react';
import { supabase } from '../lib/supabase';
import type { Reservation, Property } from '../types';
import { format, parseISO, isAfter, isBefore, differenceInDays } from 'date-fns';
import { es } from 'date-fns/locale/es';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function fmtDate(d: string) {
return format(parseISO(d), "d MMM yyyy", { locale: es });
}
function fmtShort(d: string) {
return format(parseISO(d), "d MMM", { locale: es });
}
function propertyLabel(p: Property) {
return p === 'los_dragos' ? 'Los Dragos' : 'La Esquinita';
}
function nightsBetween(start: string, end: string) {
return differenceInDays(parseISO(end), parseISO(start));
}
// ─── Response engine ──────────────────────────────────────────────────────────
function guestDetail(r: Reservation): string {
const nights = nightsBetween(r.start_date, r.end_date);
const pax = r.adults_count + r.children_count;
const lines: string[] = [
`👤 **${r.client_name}**`,
`📅 ${fmtDate(r.start_date)}${fmtDate(r.end_date)} (${nights} noche${nights !== 1 ? 's' : ''})`,
`👥 ${pax} persona${pax !== 1 ? 's' : ''} (${r.adults_count} adultos, ${r.children_count} niños)`,
`🏷️ Origen: ${r.origin}`,
];
if (r.government_registration) {
lines.push(`🏛️ Reg. gubernamental: \`${r.government_registration}\``);
} else {
lines.push(`⚠️ Sin registro gubernamental`);
}
if (r.invoice_number) lines.push(`🧾 Factura: ${r.invoice_number}`);
if (r.has_cleaning) lines.push(`🧹 Servicio de limpieza incluido`);
if (r.has_pool_heating) lines.push(`♨️ Calefacción de piscina incluida`);
if (r.has_flies_products) lines.push(`🦟 Productos anti-mosquitos incluidos`);
if (r.is_event) {
lines.push(`🎉 Evento: ${r.event_type ?? ''}${r.event_type_other ? ` (${r.event_type_other})` : ''}`);
if (r.attendees_count) lines.push(` Asistentes: ${r.attendees_count}`);
}
if (r.pricing_snapshot) {
const p = r.pricing_snapshot;
lines.push(`💶 Total: ${p.total.toLocaleString('es-ES', { style: 'currency', currency: 'EUR' })}`);
}
if (r.observations) lines.push(`📝 Notas: ${r.observations}`);
return lines.join('\n');
}
function statsInfo(reservations: Reservation[], property: Property): string {
const now = new Date();
const label = propertyLabel(property);
const total = reservations.length;
const active = reservations.filter(r =>
!isAfter(parseISO(r.start_date), now) && !isBefore(parseISO(r.end_date), now)
);
const upcoming = reservations.filter(r => isAfter(parseISO(r.start_date), now));
const past = reservations.filter(r => isBefore(parseISO(r.end_date), now));
const teneriffa = reservations.filter(r => r.origin === 'Teneriffa2000');
const natur = reservations.filter(r => r.origin === 'Naturcalabacera');
const withReg = reservations.filter(r => r.government_registration);
const withoutReg = reservations.filter(r => !r.government_registration);
const totalNights = reservations.reduce((sum, r) => sum + nightsBetween(r.start_date, r.end_date), 0);
const lines = [
`📊 **Resumen — ${label}**\n`,
`• Total reservas: **${total}**`,
`• Activas ahora: ${active.length}`,
`• Próximas: ${upcoming.length}`,
`• Pasadas: ${past.length}`,
`• Noches totales reservadas: ${totalNights}`,
``,
`📋 **Por origen:**`,
`• Teneriffa2000: ${teneriffa.length}`,
`• Naturcalabacera: ${natur.length}`,
``,
`🏛️ **Registros gubernamentales:**`,
`• Con registro: ${withReg.length}`,
`• Sin registro: ${withoutReg.length}${withoutReg.length > 0 ? ' ⚠️' : ' ✅'}`,
];
return lines.join('\n');
}
function contractsInfo(reservations: Reservation[]): string {
const now = new Date();
const withReg = reservations.filter(r => r.government_registration);
const pendingReg = reservations.filter(
r => !r.government_registration && !isBefore(parseISO(r.end_date), now)
);
const lines: string[] = ['🏛️ **Registros y contratos**\n'];
if (withReg.length > 0) {
lines.push(`✅ **Con registro (${withReg.length}):**`);
withReg.forEach(r => {
lines.push(`${r.client_name}\`${r.government_registration}\``);
if (r.invoice_number) lines.push(` Factura: ${r.invoice_number}`);
lines.push(` ${fmtShort(r.start_date)}${fmtShort(r.end_date)}`);
});
}
if (pendingReg.length > 0) {
lines.push(`\n⚠ **Sin registro (activas/futuras) — ${pendingReg.length}:**`);
pendingReg.forEach(r => {
lines.push(`${r.client_name} (${fmtShort(r.start_date)}${fmtShort(r.end_date)})`);
});
}
if (withReg.length === 0 && pendingReg.length === 0) {
lines.push('No hay reservas con información de contratos disponible.');
}
return lines.join('\n');
}
function upcomingInfo(reservations: Reservation[]): string {
const now = new Date();
const upcoming = reservations
.filter(r => isAfter(parseISO(r.start_date), now))
.sort((a, b) => a.start_date.localeCompare(b.start_date))
.slice(0, 6);
if (upcoming.length === 0) return 'No hay reservas futuras registradas.';
const lines = [`📅 **Próximas reservas (${upcoming.length}):**\n`];
upcoming.forEach(r => {
const daysTo = differenceInDays(parseISO(r.start_date), now);
const nights = nightsBetween(r.start_date, r.end_date);
lines.push(
`**${r.client_name}** — en ${daysTo} día${daysTo !== 1 ? 's' : ''}\n` +
` ${fmtShort(r.start_date)}${fmtShort(r.end_date)} · ${nights} noches · ${r.adults_count + r.children_count} pax\n` +
(r.government_registration ? ` ✅ Reg: \`${r.government_registration}\`` : ` ⚠️ Sin registro`)
);
});
return lines.join('\n');
}
function availabilityInfo(reservations: Reservation[]): string {
const now = new Date();
const future = reservations
.filter(r => !isBefore(parseISO(r.end_date), now))
.sort((a, b) => a.start_date.localeCompare(b.start_date));
if (future.length === 0) {
return '✅ No hay reservas futuras. La propiedad está completamente disponible.';
}
const lines = ['🗓️ **Disponibilidad próxima:**\n'];
// Gap before first reservation
const firstStart = parseISO(future[0].start_date);
const daysToFirst = differenceInDays(firstStart, now);
if (daysToFirst > 0) {
lines.push(`✅ Libre ahora → ${fmtShort(future[0].start_date)} (${daysToFirst} días)`);
}
// Gaps between reservations
for (let i = 0; i < future.length; i++) {
lines.push(`🔴 Ocupado: ${fmtShort(future[i].start_date)}${fmtShort(future[i].end_date)} (${future[i].client_name})`);
if (i < future.length - 1) {
const gapStart = parseISO(future[i].end_date);
const gapEnd = parseISO(future[i + 1].start_date);
const gapDays = differenceInDays(gapEnd, gapStart);
if (gapDays > 0) {
lines.push(`✅ Libre: ${fmtShort(future[i].end_date)}${fmtShort(future[i + 1].start_date)} (${gapDays} días)`);
}
}
}
return lines.join('\n');
}
function allReservationsInfo(reservations: Reservation[]): string {
if (reservations.length === 0) return 'No hay reservas registradas.';
const sorted = [...reservations].sort((a, b) => a.start_date.localeCompare(b.start_date));
const now = new Date();
const lines = [`📋 **Todas las reservas (${sorted.length}):**\n`];
sorted.forEach(r => {
const isPast = isBefore(parseISO(r.end_date), now);
const isActive = !isAfter(parseISO(r.start_date), now) && !isBefore(parseISO(r.end_date), now);
const icon = isPast ? '⬜' : isActive ? '🟢' : '🔵';
lines.push(
`${icon} **${r.client_name}** — ${fmtShort(r.start_date)}${fmtShort(r.end_date)}` +
(r.government_registration ? `` : ` ⚠️`)
);
});
return lines.join('\n');
}
function processMessage(input: string, reservations: Reservation[], property: Property): string {
const lower = input.toLowerCase().trim();
const now = new Date();
const label = propertyLabel(property);
// Greeting
if (/^(hola|buenos|buenas|hey|hi|ey|qué tal|que tal|buen)/.test(lower)) {
const total = reservations.length;
const upcoming = reservations.filter(r => isAfter(parseISO(r.start_date), now)).length;
const active = reservations.filter(r =>
!isAfter(parseISO(r.start_date), now) && !isBefore(parseISO(r.end_date), now)
).length;
return [
`¡Hola! Soy tu asistente para **${label}** 🏡`,
``,
`Estado actual:`,
`${active > 0 ? `🟢 ${active} reserva${active !== 1 ? 's' : ''} activa${active !== 1 ? 's' : ''} ahora mismo` : '⬜ Sin reservas activas hoy'}`,
`• 🔵 ${upcoming} próxima${upcoming !== 1 ? 's' : ''}`,
`• 📋 ${total} reserva${total !== 1 ? 's' : ''} en total`,
``,
`¿Qué necesitas saber?`,
].join('\n');
}
// Stats / summary
if (/estadist|resumen|total|cuántas|cuantas|dato|cifra|número|numeros|summary/.test(lower)) {
return statsInfo(reservations, property);
}
// Contracts / government registration
if (/contrat|registro|viajero|govern|código|codigos|rvtca|factura|invoice|número de registro/.test(lower)) {
return contractsInfo(reservations);
}
// Upcoming
if (/próxim|siguiente|futuras|upcoming|pronto|esta semana|este mes|entran|llegan/.test(lower)) {
return upcomingInfo(reservations);
}
// Availability
if (/disponib|libre|ocup|vac|hueco|cuando|free|gap|abierto/.test(lower)) {
return availabilityInfo(reservations);
}
// List all
if (/lista|todas|todos|ver todo|todas las|show all|all reserv/.test(lower)) {
return allReservationsInfo(reservations);
}
// Search by guest name — scan all words in input against client names
const words = lower.split(/\s+/).filter(w => w.length > 2);
for (const word of words) {
const found = reservations.find(r =>
r.client_name.toLowerCase().includes(word)
);
if (found) return guestDetail(found);
}
// Help / default
return [
`Puedo ayudarte con información de **${label}**. Pregúntame sobre:`,
``,
`• 📋 **Reservas** — "lista todas", "reservas de mayo"`,
`• 📅 **Próximas** — "próximas reservas"`,
`• 🗓️ **Disponibilidad** — "¿cuándo está libre?"`,
`• 🏛️ **Contratos** — "registros gubernamentales", "contratos"`,
`• 👤 **Huésped** — escribe el nombre del cliente`,
`• 📊 **Estadísticas** — "dame un resumen"`,
].join('\n');
}
// ─── Quick-reply suggestions ──────────────────────────────────────────────────
const QUICK_REPLIES = [
{ label: 'Próximas reservas', text: 'próximas reservas' },
{ label: 'Disponibilidad', text: '¿cuándo está libre?' },
{ label: 'Contratos y registros', text: 'registros gubernamentales' },
{ label: 'Resumen estadístico', text: 'dame un resumen' },
{ label: 'Todas las reservas', text: 'lista todas las reservas' },
];
// ─── Message renderer (markdown-lite) ────────────────────────────────────────
function RenderMessage({ content }: { content: string }) {
const lines = content.split('\n');
return (
<div className="space-y-0.5">
{lines.map((line, i) => {
// Bold: **text**
const parts = line.split(/(\*\*[^*]+\*\*)/g).map((part, j) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={j}>{part.slice(2, -2)}</strong>;
}
// Inline code: `text`
return part.split(/(`[^`]+`)/g).map((seg, k) => {
if (seg.startsWith('`') && seg.endsWith('`')) {
return (
<code key={k} className="bg-white/10 px-1 py-0.5 rounded text-xs font-mono">
{seg.slice(1, -1)}
</code>
);
}
return seg;
});
});
if (line === '') return <div key={i} className="h-1.5" />;
return <p key={i} className="leading-snug">{parts}</p>;
})}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export function ChatbotContainer() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [selectedProperty, setSelectedProperty] = useState<Property>('los_dragos');
const [reservationsMap, setReservationsMap] = useState<Record<Property, Reservation[]>>({
los_dragos: [],
la_esquinita: [],
});
const [loadingData, setLoadingData] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Fetch all reservations for both properties
const fetchAll = useCallback(async () => {
setLoadingData(true);
try {
const { data } = await supabase
.from('reservations')
.select('*')
.order('start_date', { ascending: true });
if (data) {
setReservationsMap({
los_dragos: data.filter((r: Reservation) => r.property === 'los_dragos'),
la_esquinita: data.filter((r: Reservation) => r.property === 'la_esquinita'),
});
}
} finally {
setLoadingData(false);
}
}, []);
useEffect(() => {
if (isOpen && messages.length === 0) {
fetchAll();
// Initial greeting
const greeting: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: `¡Hola! 👋 Soy tu asistente de reservas.\n\nSelecciona una propiedad arriba y pregúntame lo que necesites: disponibilidad, contratos, huéspedes, estadísticas…`,
timestamp: new Date(),
};
setMessages([greeting]);
}
}, [isOpen, messages.length, fetchAll]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isTyping]);
const handleSend = useCallback(async (text?: string) => {
const userText = (text ?? input).trim();
if (!userText) return;
const userMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
content: userText,
timestamp: new Date(),
};
setMessages(prev => [...prev, userMsg]);
setInput('');
setIsTyping(true);
// Simulate processing delay for UX
await new Promise(resolve => setTimeout(resolve, 400));
const reservations = reservationsMap[selectedProperty];
const response = processMessage(userText, reservations, selectedProperty);
const botMsg: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: response,
timestamp: new Date(),
};
setIsTyping(false);
setMessages(prev => [...prev, botMsg]);
}, [input, reservationsMap, selectedProperty]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handlePropertySwitch = (p: Property) => {
setSelectedProperty(p);
const switchMsg: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: `Cambiado a **${propertyLabel(p)}**. ¿Qué necesitas saber sobre esta propiedad?`,
timestamp: new Date(),
};
setMessages(prev => [...prev, switchMsg]);
};
const isDragos = selectedProperty === 'los_dragos';
const accentGradient = isDragos
? 'from-emerald-600 to-teal-600'
: 'from-amber-600 to-orange-600';
const accentBorder = isDragos ? 'border-emerald-500/30' : 'border-amber-500/30';
const accentShadow = isDragos ? 'shadow-emerald-500/20' : 'shadow-amber-500/20';
const accentBg = isDragos ? 'bg-emerald-600/20' : 'bg-amber-600/20';
const accentText = isDragos ? 'text-emerald-300' : 'text-amber-300';
const activePropBg = isDragos
? 'bg-gradient-to-r from-emerald-600 to-teal-600 text-white shadow-lg shadow-emerald-500/30'
: 'bg-gradient-to-r from-amber-600 to-orange-600 text-white shadow-lg shadow-amber-500/30';
return (
<>
{/* Floating button */}
<button
onClick={() => setIsOpen(o => !o)}
className={`fixed bottom-20 right-4 md:bottom-6 md:right-6 z-50 w-14 h-14 bg-gradient-to-br ${accentGradient} rounded-full shadow-xl ${accentShadow} flex items-center justify-center text-white transition-all duration-300 hover:scale-110 border border-white/10`}
aria-label="Abrir asistente"
>
{isOpen ? <X className="w-6 h-6" /> : <MessageCircle className="w-6 h-6" />}
</button>
{/* Chat panel */}
{isOpen && (
<div
className={`fixed bottom-36 right-4 md:bottom-24 md:right-6 z-50 w-[calc(100vw-2rem)] md:w-96 h-[520px] md:h-[580px] bg-slate-900 rounded-3xl shadow-2xl ${accentShadow} border ${accentBorder} flex flex-col overflow-hidden transition-all duration-300`}
>
{/* Header */}
<div className={`bg-gradient-to-r ${accentGradient} px-5 py-4 flex items-center justify-between flex-shrink-0`}>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-white/20 rounded-xl flex items-center justify-center">
<MessageCircle className="w-4 h-4 text-white" />
</div>
<div>
<p className="font-bold text-white text-sm">Asistente de Reservas</p>
<p className="text-white/70 text-xs">{propertyLabel(selectedProperty)}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchAll}
disabled={loadingData}
className="p-1.5 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-white"
title="Actualizar datos"
>
<RefreshCw className={`w-3.5 h-3.5 ${loadingData ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setIsOpen(false)}
className="p-1.5 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-white"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Property selector */}
<div className="flex gap-2 px-4 py-3 bg-slate-800/80 border-b border-white/5 flex-shrink-0">
{(['los_dragos', 'la_esquinita'] as Property[]).map(p => (
<button
key={p}
onClick={() => handlePropertySwitch(p)}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-xl text-xs font-semibold transition-all duration-200 ${
selectedProperty === p
? (p === 'los_dragos'
? 'bg-gradient-to-r from-emerald-600 to-teal-600 text-white shadow-lg shadow-emerald-500/30'
: 'bg-gradient-to-r from-amber-600 to-orange-600 text-white shadow-lg shadow-amber-500/30')
: 'bg-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-200'
}`}
>
<Home className="w-3 h-3" />
{p === 'los_dragos' ? 'Los Dragos' : 'La Esquinita'}
</button>
))}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map(msg => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{msg.role === 'assistant' && (
<div className={`w-7 h-7 rounded-xl bg-gradient-to-br ${accentGradient} flex-shrink-0 flex items-center justify-center mr-2 mt-0.5`}>
<MessageCircle className="w-3.5 h-3.5 text-white" />
</div>
)}
<div
className={`max-w-[78%] px-4 py-3 rounded-2xl text-xs leading-relaxed ${
msg.role === 'user'
? `${accentBg} ${accentText} border ${accentBorder} rounded-tr-sm`
: 'bg-slate-800 text-slate-100 rounded-tl-sm border border-white/5'
}`}
>
<RenderMessage content={msg.content} />
<p className="text-[10px] opacity-40 mt-1.5 text-right">
{format(msg.timestamp, 'HH:mm')}
</p>
</div>
</div>
))}
{/* Typing indicator */}
{isTyping && (
<div className="flex justify-start">
<div className={`w-7 h-7 rounded-xl bg-gradient-to-br ${accentGradient} flex-shrink-0 flex items-center justify-center mr-2`}>
<MessageCircle className="w-3.5 h-3.5 text-white" />
</div>
<div className="bg-slate-800 border border-white/5 px-4 py-3 rounded-2xl rounded-tl-sm flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Quick replies */}
{messages.length <= 2 && (
<div className="px-4 pb-2 flex gap-1.5 flex-wrap flex-shrink-0">
{QUICK_REPLIES.map(qr => (
<button
key={qr.text}
onClick={() => handleSend(qr.text)}
className={`px-3 py-1.5 rounded-xl text-[11px] font-medium bg-slate-800 text-slate-300 border border-white/5 hover:border-white/20 hover:text-white transition-all duration-150`}
>
{qr.label}
</button>
))}
</div>
)}
{/* Input */}
<div className="px-4 pb-4 pt-2 flex gap-2 flex-shrink-0 border-t border-white/5">
<input
ref={inputRef}
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe tu pregunta…"
className="flex-1 bg-slate-800 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white placeholder-slate-500 outline-none focus:border-white/25 transition-colors"
/>
<button
onClick={() => handleSend()}
disabled={!input.trim() || isTyping}
className={`w-10 h-10 flex items-center justify-center rounded-xl bg-gradient-to-br ${accentGradient} text-white disabled:opacity-40 hover:opacity-90 transition-all duration-200 flex-shrink-0`}
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,234 @@
import { useEffect, useRef, useState } from 'react';
import { Upload, Trash2, FileText, AlertCircle, Loader2, X, Maximize2 } from 'lucide-react';
import { useFileUpload, type UploadedContract } from '../hooks/useFileUpload';
import { motion, AnimatePresence } from 'framer-motion';
interface Props {
reservationId: string;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const isImage = (mimeType: string) => mimeType.startsWith('image/');
export function ContractUpload({ reservationId }: Props) {
const { uploading, error, uploadFile, fetchContracts, getSignedUrl, deleteContract } = useFileUpload(reservationId);
const [contracts, setContracts] = useState<UploadedContract[]>([]);
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
const [loadingOpen, setLoadingOpen] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
useEffect(() => {
fetchContracts().then(list => {
setContracts(list);
// Pre-fetch signed URLs for image thumbnails
list.filter(c => isImage(c.mime_type)).forEach(async c => {
const url = await getSignedUrl(c.file_path);
if (url) setThumbnails(prev => ({ ...prev, [c.id]: url }));
});
});
}, [fetchContracts, getSignedUrl]);
const handleFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
const result = await uploadFile(file);
if (result) {
setContracts(prev => [result, ...prev]);
// Pre-fetch thumbnail for image
if (isImage(result.mime_type)) {
const url = await getSignedUrl(result.file_path);
if (url) setThumbnails(prev => ({ ...prev, [result.id]: url }));
}
}
}
};
const handleOpen = async (contract: UploadedContract) => {
if (isImage(contract.mime_type) && thumbnails[contract.id]) {
setLightboxUrl(thumbnails[contract.id]);
return;
}
setLoadingOpen(contract.id);
const url = await getSignedUrl(contract.file_path);
setLoadingOpen(null);
if (url) {
if (isImage(contract.mime_type)) {
setLightboxUrl(url);
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
}
};
const handleDelete = async (contract: UploadedContract) => {
setDeletingId(contract.id);
const ok = await deleteContract(contract.id, contract.file_path);
setDeletingId(null);
if (ok) {
setContracts(prev => prev.filter(c => c.id !== contract.id));
setThumbnails(prev => { const n = { ...prev }; delete n[contract.id]; return n; });
}
};
return (
<>
<div className="space-y-3">
{/* Drop zone */}
<div
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={e => { e.preventDefault(); setDragging(false); handleFiles(e.dataTransfer.files); }}
onClick={() => inputRef.current?.click()}
className={`
border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all text-center
${dragging
? 'border-emerald-500 bg-emerald-900/20'
: 'border-slate-600 bg-slate-800/50 hover:border-slate-500 hover:bg-slate-800'
}
`}
>
<input
ref={inputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
multiple
className="sr-only"
onChange={e => handleFiles(e.target.files)}
/>
{uploading ? (
<div className="flex flex-col items-center gap-2 text-slate-400">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-xs font-medium">Subiendo...</span>
</div>
) : (
<div className="flex flex-col items-center gap-1.5 text-slate-400">
<Upload className="w-5 h-5" />
<span className="text-xs font-medium text-slate-300">
Arrastra archivos o pulsa para seleccionar
</span>
<span className="text-[10px] text-slate-500">PDF, JPEG, PNG · máx. 10 MB</span>
</div>
)}
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 px-4 py-3 bg-red-950/40 border border-red-800 rounded-xl text-sm text-red-400">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* File list */}
{contracts.length > 0 && (
<ul className="space-y-2">
{contracts.map(contract => (
<li key={contract.id} className="rounded-xl overflow-hidden border border-slate-700 bg-slate-800">
{/* Image thumbnail */}
{isImage(contract.mime_type) && (
<button
type="button"
onClick={() => handleOpen(contract)}
className="relative w-full block group"
>
{thumbnails[contract.id] ? (
<img
src={thumbnails[contract.id]}
alt={contract.filename}
className="w-full h-32 object-cover"
/>
) : (
<div className="w-full h-32 bg-slate-700 flex items-center justify-center">
<Loader2 className="w-5 h-5 text-slate-500 animate-spin" />
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Maximize2 className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow-lg" />
</div>
</button>
)}
{/* File info row */}
<div className="flex items-center gap-3 px-3 py-2.5">
{!isImage(contract.mime_type) && (
<div className="w-8 h-8 flex items-center justify-center bg-red-900/40 rounded-lg flex-shrink-0">
<FileText className="w-4 h-4 text-red-400" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{contract.filename}</p>
<p className="text-xs text-slate-500">{formatBytes(contract.size_bytes)}</p>
</div>
{!isImage(contract.mime_type) && (
<button
type="button"
onClick={() => handleOpen(contract)}
disabled={loadingOpen === contract.id}
className="p-2 text-slate-500 hover:text-slate-200 transition-colors"
title="Ver archivo"
>
{loadingOpen === contract.id
? <Loader2 className="w-4 h-4 animate-spin" />
: <span className="text-xs font-medium text-slate-400 hover:text-emerald-400">Abrir</span>
}
</button>
)}
<button
type="button"
onClick={() => handleDelete(contract)}
disabled={deletingId === contract.id}
className="p-2 text-slate-500 hover:text-red-400 transition-colors"
title="Eliminar"
>
{deletingId === contract.id
? <Loader2 className="w-4 h-4 animate-spin" />
: <Trash2 className="w-4 h-4" />
}
</button>
</div>
</li>
))}
</ul>
)}
</div>
{/* Lightbox */}
<AnimatePresence>
{lightboxUrl && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setLightboxUrl(null)}
className="fixed inset-0 bg-black/92 z-[100] flex items-center justify-center p-4"
>
<button
type="button"
onClick={() => setLightboxUrl(null)}
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={lightboxUrl}
alt="Contrato"
className="max-w-full max-h-full object-contain rounded-2xl shadow-2xl"
onClick={e => e.stopPropagation()}
/>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,155 @@
import { useState } from 'react';
import {
format, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameMonth, isSameDay, addMonths, subMonths,
isWithinInterval, parseISO, isBefore
} from 'date-fns';
import { es } from 'date-fns/locale/es';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import type { Reservation } from '../types';
interface Props {
reservations: Reservation[];
onSelectRange: (start: Date, end: Date) => void;
onSelectReservation: (reservation: Reservation) => void;
}
export function CustomMobileCalendar({ reservations, onSelectRange, onSelectReservation }: Props) {
const [currentDate, setCurrentDate] = useState(new Date());
const [selectionStart, setSelectionStart] = useState<Date | null>(null);
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(monthStart);
const startDate = startOfWeek(monthStart, { locale: es });
const endDate = endOfWeek(monthEnd, { locale: es });
const days = eachDayOfInterval({ start: startDate, end: endDate });
const nextMonth = () => setCurrentDate(addMonths(currentDate, 1));
const prevMonth = () => setCurrentDate(subMonths(currentDate, 1));
const getReservationForDay = (day: Date) => {
return reservations.find(res =>
isWithinInterval(day, { start: parseISO(res.start_date), end: parseISO(res.end_date) })
);
};
const rangeHasOverlap = (start: Date, end: Date) => {
const rangeDays = eachDayOfInterval({ start, end });
return rangeDays.some(day => getReservationForDay(day));
};
const handleDayClick = (day: Date) => {
const existingRes = getReservationForDay(day);
if (existingRes) {
onSelectReservation(existingRes);
setSelectionStart(null);
return;
}
if (!selectionStart) {
setSelectionStart(day);
return;
}
let start = selectionStart;
let end = day;
if (isBefore(day, selectionStart)) {
start = day;
end = selectionStart;
}
if (rangeHasOverlap(start, end)) {
toast.error("No puedes seleccionar un rango que incluya días ya reservados.");
setSelectionStart(null);
return;
}
onSelectRange(start, end);
setSelectionStart(null);
};
return (
<div className="bg-white rounded-3xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 bg-white border-b border-gray-100">
<h2 className="text-lg font-bold text-gray-900 capitalize">
{format(currentDate, 'MMMM yyyy', { locale: es })}
</h2>
<div className="flex space-x-1">
<button onClick={prevMonth} className="p-2 rounded-lg hover:bg-gray-50">
<ChevronLeft size={20} className="text-gray-600" />
</button>
<button onClick={nextMonth} className="p-2 rounded-lg hover:bg-gray-50">
<ChevronRight size={20} className="text-gray-600" />
</button>
</div>
</div>
{/* Week Days */}
<div className="grid grid-cols-7 px-3 py-2 bg-gray-50">
{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(day => (
<div key={day} className="text-center text-[11px] font-semibold text-gray-500 uppercase tracking-wide py-1">
{day}
</div>
))}
</div>
{/* Days Grid - NUEVO DISEÑO TIPO AIRBNB */}
<div className="grid grid-cols-7 gap-0 p-3 bg-white">
{days.map((day) => {
const dayRes = getReservationForDay(day);
const isSelected = selectionStart && isSameDay(day, selectionStart);
const isCurrentMonth = isSameMonth(day, monthStart);
// Palette de colores según referencia
let bgColor = 'bg-white';
let numberBgColor = '';
let numberTextColor = 'text-gray-400';
if (dayRes) {
if (dayRes.origin === 'Teneriffa2000') {
bgColor = 'bg-blue-100'; // Fondo azul pastel
numberBgColor = 'bg-blue-500'; // Círculo azul intenso
numberTextColor = 'text-white';
} else {
bgColor = 'bg-yellow-100'; // Fondo amarillo pastel
numberBgColor = 'bg-yellow-500'; // Círculo amarillo intenso
numberTextColor = 'text-white';
}
} else if (isSelected) {
bgColor = 'bg-gray-800';
numberBgColor = 'bg-white';
numberTextColor = 'text-gray-800';
} else if (isCurrentMonth) {
numberTextColor = 'text-gray-900';
}
return (
<div
key={day.toString()}
onClick={() => handleDayClick(day)}
className={`
relative h-12 flex items-center justify-center cursor-pointer
transition-all duration-150
${bgColor}
`}
>
<div className={`
w-8 h-8 rounded-full flex items-center justify-center
text-sm font-semibold
${numberBgColor}
${numberTextColor}
`}>
{format(day, 'd')}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,390 @@
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react';
import { Upload, Trash2, FileText, AlertCircle, Loader2, X, Maximize2 } from 'lucide-react';
import {
useFileUpload,
validateFile,
uploadDocumentFile,
type UploadedContract,
type DocumentType,
} from '../hooks/useFileUpload';
import { motion, AnimatePresence } from 'framer-motion';
/**
* Imperativo que expone el componente al padre. Cuando el modal se monta en
* modo "create" no hay reservation_id todavía, así que los archivos quedan en
* `pendingFiles`. Tras crear la reserva, el padre llama a `flushPending(id)`
* para subir todos los pendientes a la nueva reserva.
*/
export interface DocumentUploadHandle {
hasPending: () => boolean;
flushPending: (reservationId: string) => Promise<void>;
}
interface Props {
/** ID de la reserva. Si es undefined, los archivos se acumulan localmente. */
reservationId?: string;
documentType: DocumentType;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const isImage = (mimeType: string) => mimeType.startsWith('image/');
interface PendingFile {
id: string; // local id (uuid-ish)
file: File;
previewUrl: string | null; // para imágenes locales
}
export const DocumentUpload = forwardRef<DocumentUploadHandle, Props>(function DocumentUpload(
{ reservationId, documentType },
ref,
) {
const safeId = reservationId ?? '__pending__';
const { uploading, error: uploadError, uploadFile, fetchContracts, getSignedUrl, deleteContract } =
useFileUpload(safeId, documentType);
const [contracts, setContracts] = useState<UploadedContract[]>([]);
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
const [loadingOpen, setLoadingOpen] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [pending, setPending] = useState<PendingFile[]>([]);
const [localError, setLocalError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const error = localError ?? uploadError;
// Carga lista de archivos remotos solo si hay reservationId
useEffect(() => {
if (!reservationId) {
setContracts([]);
return;
}
fetchContracts().then(list => {
setContracts(list);
list.filter(c => isImage(c.mime_type)).forEach(async c => {
const url = await getSignedUrl(c.file_path);
if (url) setThumbnails(prev => ({ ...prev, [c.id]: url }));
});
});
}, [reservationId, fetchContracts, getSignedUrl]);
// Limpia las URLs de objeto al desmontar
useEffect(() => {
return () => {
pending.forEach(p => p.previewUrl && URL.revokeObjectURL(p.previewUrl));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useImperativeHandle(ref, () => ({
hasPending: () => pending.length > 0,
flushPending: async (newReservationId: string) => {
for (const p of pending) {
try {
await uploadDocumentFile(p.file, newReservationId, documentType);
} catch (err) {
console.error('Error subiendo archivo pendiente:', err);
}
}
pending.forEach(p => p.previewUrl && URL.revokeObjectURL(p.previewUrl));
setPending([]);
},
}), [pending, documentType]);
const handleFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
setLocalError(null);
if (!reservationId) {
// Modo diferido: acumular localmente
const newPending: PendingFile[] = [];
for (const file of Array.from(files)) {
const validationError = validateFile(file);
if (validationError) {
setLocalError(validationError);
continue;
}
newPending.push({
id: `pending-${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
previewUrl: isImage(file.type) ? URL.createObjectURL(file) : null,
});
}
if (newPending.length > 0) {
setPending(prev => [...newPending, ...prev]);
}
return;
}
// Modo directo: subir al servidor
for (const file of Array.from(files)) {
const result = await uploadFile(file);
if (result) {
setContracts(prev => [result, ...prev]);
if (isImage(result.mime_type)) {
const url = await getSignedUrl(result.file_path);
if (url) setThumbnails(prev => ({ ...prev, [result.id]: url }));
}
}
}
};
const handleOpen = async (contract: UploadedContract) => {
if (isImage(contract.mime_type) && thumbnails[contract.id]) {
setLightboxUrl(thumbnails[contract.id]);
return;
}
setLoadingOpen(contract.id);
const url = await getSignedUrl(contract.file_path);
setLoadingOpen(null);
if (url) {
if (isImage(contract.mime_type)) {
setLightboxUrl(url);
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
}
};
const handleDelete = async (contract: UploadedContract) => {
setDeletingId(contract.id);
const ok = await deleteContract(contract.id, contract.file_path);
setDeletingId(null);
if (ok) {
setContracts(prev => prev.filter(c => c.id !== contract.id));
setThumbnails(prev => { const n = { ...prev }; delete n[contract.id]; return n; });
}
};
const handleDeletePending = (id: string) => {
setPending(prev => {
const target = prev.find(p => p.id === id);
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
return prev.filter(p => p.id !== id);
});
};
const handleOpenPending = (p: PendingFile) => {
if (p.previewUrl) {
setLightboxUrl(p.previewUrl);
} else {
// Para PDFs locales, abre en nueva pestaña con object URL
const url = URL.createObjectURL(p.file);
window.open(url, '_blank', 'noopener,noreferrer');
// No revocamos inmediatamente: el navegador lo necesita mientras esté abierto
}
};
return (
<>
<div className="space-y-3">
{/* Drop zone */}
<div
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={e => { e.preventDefault(); setDragging(false); handleFiles(e.dataTransfer.files); }}
onClick={() => inputRef.current?.click()}
className={`
border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all text-center
${dragging
? 'border-emerald-500 bg-emerald-900/20'
: 'border-slate-600 bg-slate-800/50 hover:border-slate-500 hover:bg-slate-800'
}
`}
>
<input
ref={inputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
multiple
className="sr-only"
onChange={e => handleFiles(e.target.files)}
/>
{uploading ? (
<div className="flex flex-col items-center gap-2 text-slate-400">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-xs font-medium">Subiendo...</span>
</div>
) : (
<div className="flex flex-col items-center gap-1.5 text-slate-400">
<Upload className="w-5 h-5" />
<span className="text-xs font-medium text-slate-300">
Arrastra archivos o pulsa para seleccionar
</span>
<span className="text-[10px] text-slate-500">PDF, JPEG, PNG · máx. 10 MB</span>
{!reservationId && (pending.length > 0) && (
<span className="text-[10px] text-amber-400 font-medium mt-0.5">
Se subirán al guardar la reserva
</span>
)}
</div>
)}
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 px-4 py-3 bg-red-950/40 border border-red-800 rounded-xl text-sm text-red-400">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Pending files (modo diferido) */}
{pending.length > 0 && (
<ul className="space-y-2">
{pending.map(p => (
<li key={p.id} className="rounded-xl overflow-hidden border border-amber-700/50 bg-amber-950/20">
{isImage(p.file.type) && p.previewUrl && (
<button
type="button"
onClick={() => handleOpenPending(p)}
className="relative w-full block group"
>
<img src={p.previewUrl} alt={p.file.name} className="w-full h-32 object-cover" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Maximize2 className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow-lg" />
</div>
</button>
)}
<div className="flex items-center gap-3 px-3 py-2.5">
{!isImage(p.file.type) && (
<div className="w-8 h-8 flex items-center justify-center bg-amber-900/40 rounded-lg flex-shrink-0">
<FileText className="w-4 h-4 text-amber-400" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{p.file.name}</p>
<p className="text-xs text-amber-400">{formatBytes(p.file.size)} · pendiente</p>
</div>
{!isImage(p.file.type) && (
<button
type="button"
onClick={() => handleOpenPending(p)}
className="p-2 text-slate-500 hover:text-slate-200 transition-colors"
title="Ver archivo"
>
<span className="text-xs font-medium text-slate-400 hover:text-emerald-400">Abrir</span>
</button>
)}
<button
type="button"
onClick={() => handleDeletePending(p.id)}
className="p-2 text-slate-500 hover:text-red-400 transition-colors"
title="Quitar"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</li>
))}
</ul>
)}
{/* Uploaded files */}
{contracts.length > 0 && (
<ul className="space-y-2">
{contracts.map(contract => (
<li key={contract.id} className="rounded-xl overflow-hidden border border-slate-700 bg-slate-800">
{isImage(contract.mime_type) && (
<button
type="button"
onClick={() => handleOpen(contract)}
className="relative w-full block group"
>
{thumbnails[contract.id] ? (
<img
src={thumbnails[contract.id]}
alt={contract.filename}
className="w-full h-32 object-cover"
/>
) : (
<div className="w-full h-32 bg-slate-700 flex items-center justify-center">
<Loader2 className="w-5 h-5 text-slate-500 animate-spin" />
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Maximize2 className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow-lg" />
</div>
</button>
)}
<div className="flex items-center gap-3 px-3 py-2.5">
{!isImage(contract.mime_type) && (
<div className="w-8 h-8 flex items-center justify-center bg-red-900/40 rounded-lg flex-shrink-0">
<FileText className="w-4 h-4 text-red-400" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{contract.filename}</p>
<p className="text-xs text-slate-500">{formatBytes(contract.size_bytes)}</p>
</div>
{!isImage(contract.mime_type) && (
<button
type="button"
onClick={() => handleOpen(contract)}
disabled={loadingOpen === contract.id}
className="p-2 text-slate-500 hover:text-slate-200 transition-colors"
title="Ver archivo"
>
{loadingOpen === contract.id
? <Loader2 className="w-4 h-4 animate-spin" />
: <span className="text-xs font-medium text-slate-400 hover:text-emerald-400">Abrir</span>
}
</button>
)}
<button
type="button"
onClick={() => handleDelete(contract)}
disabled={deletingId === contract.id}
className="p-2 text-slate-500 hover:text-red-400 transition-colors"
title="Eliminar"
>
{deletingId === contract.id
? <Loader2 className="w-4 h-4 animate-spin" />
: <Trash2 className="w-4 h-4" />
}
</button>
</div>
</li>
))}
</ul>
)}
</div>
{/* Lightbox */}
<AnimatePresence>
{lightboxUrl && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setLightboxUrl(null)}
className="fixed inset-0 bg-black/92 z-[100] flex items-center justify-center p-4"
>
<button
type="button"
onClick={() => setLightboxUrl(null)}
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={lightboxUrl}
alt="Documento"
className="max-w-full max-h-full object-contain rounded-2xl shadow-2xl"
onClick={e => e.stopPropagation()}
/>
</motion.div>
)}
</AnimatePresence>
</>
);
});

View File

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

View File

@@ -0,0 +1,89 @@
import { Calendar, Settings, CalendarDays, Home, Users } from 'lucide-react';
import { useProperty } from '../contexts/PropertyContext';
import { PROPERTY_CONFIG, PROPERTIES } from '@naturcalabacera/shared';
import type { Property } from '@naturcalabacera/shared';
interface Props {
currentView: string;
onNavigate: (view: string) => void;
isViewer?: boolean;
isAdmin?: boolean;
}
export function MobileNavigation({ currentView, onNavigate, isViewer = false, isAdmin = false }: Props) {
const { property, setProperty } = useProperty();
const allMenuItems = [
{ id: 'calendar', label: 'Mensual', icon: Calendar, requires: 'all' as const },
{ id: 'yearly', label: 'Anual', icon: CalendarDays, requires: 'all' as const },
{ id: 'users', label: 'Usuarios', icon: Users, requires: 'admin' as const },
{ id: 'settings', label: 'Ajustes', icon: Settings, requires: 'staff' as const },
];
const menuItems = allMenuItems.filter(item => {
if (item.requires === 'all') return true;
if (item.requires === 'admin') return isAdmin;
if (item.requires === 'staff') return !isViewer;
return false;
});
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">
{/* Selector de propiedad en mobile — fila superior compacta (solo staff) */}
{!isViewer && (
<div className="flex border-b border-stone-100 dark:border-emerald-900/30 px-2 pt-1.5 pb-1 gap-1">
{PROPERTIES.map((p: Property) => {
const config = PROPERTY_CONFIG[p];
const isActive = property === p;
return (
<button
key={p}
onClick={() => setProperty(p)}
className={`
flex-1 flex items-center justify-center gap-1.5 py-1 rounded-lg text-xs font-bold transition-all duration-200
${isActive
? `bg-gradient-to-r ${config.color.gradient} text-white`
: 'text-stone-400 dark:text-stone-500 hover:text-stone-600'
}
`}
>
<Home className="w-3 h-3" />
<span>{config.shortLabel}</span>
</button>
);
})}
</div>
)}
{/* Navegación principal */}
<div className="flex justify-around items-center p-2 pb-safe">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = currentView === item.id;
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={`
flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-300
${isActive
? 'text-emerald-600 dark:text-emerald-400'
: 'text-stone-400 dark:text-emerald-600/40 hover:text-stone-600 dark:hover:text-emerald-400/80'
}
`}
>
<div className={`
p-2 rounded-xl mb-1 transition-all duration-300
${isActive ? 'bg-emerald-100 dark:bg-emerald-900/40' : 'bg-transparent'}
`}>
<Icon className={`w-6 h-6 ${isActive ? 'scale-110' : ''}`} />
</div>
<span className={`text-[10px] font-bold ${isActive ? 'opacity-100' : 'opacity-70'}`}>
{item.label}
</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { calculateNaturPrice, formatPrice, DEFAULT_IGIC_RATE } from '@naturcalabacera/shared';
import { differenceInDays, parseISO } from 'date-fns';
import { Calculator } from 'lucide-react';
import type { Property } from '../types';
interface Props {
property: Property;
startDate: string;
endDate: string;
adults: number;
children: number;
igicRate?: number;
}
/**
* Muestra el desglose de precios para reservas Naturcalabacera vacacional.
* El cálculo es en tiempo real — no requiere guardar para ver los precios.
* Al guardar, el llamador debe congelar el snapshot en pricing_snapshot.
*/
export function PricingSection({ property, startDate, endDate, adults, children, igicRate = DEFAULT_IGIC_RATE }: Props) {
const nights = startDate && endDate
? Math.max(0, differenceInDays(parseISO(endDate), parseISO(startDate)))
: 0;
const totalPersons = (Number(adults) || 0) + (Number(children) || 0);
if (nights <= 0 || totalPersons <= 0) {
return (
<div className="bg-slate-800/50 border border-slate-700 rounded-2xl p-4 text-center">
<Calculator className="w-5 h-5 text-slate-500 mx-auto mb-1.5" />
<p className="text-xs text-slate-500 font-medium">
Introduce fechas y personas para ver el cálculo de precio
</p>
</div>
);
}
const year = startDate ? parseISO(startDate).getFullYear() : new Date().getFullYear();
const pricing = calculateNaturPrice({ property, nights, totalPersons, igicRate, year });
return (
<div className="bg-slate-800/50 border border-slate-700 rounded-2xl p-4 space-y-3">
<div className="flex items-center gap-2 mb-0.5">
<Calculator className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-bold text-emerald-300">Precio Natur</span>
<span className="ml-auto text-[10px] bg-slate-700 text-slate-400 px-2 py-0.5 rounded-full font-bold uppercase tracking-wide">
{nights}n · {totalPersons}p
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between text-slate-300">
<span>Canon base ({nights} noches)</span>
<span className="font-semibold">{formatPrice(pricing.basePrice)}</span>
</div>
{pricing.extraPersons > 0 && (
<div className="flex justify-between text-slate-300">
<span>
+{pricing.extraPersons} persona{pricing.extraPersons !== 1 ? 's' : ''} extra
<span className="text-xs text-slate-500 ml-1">(sobre {pricing.includedPersons} incluidas)</span>
</span>
<span className="font-semibold text-yellow-400">{formatPrice(pricing.extraPersonsFee)}</span>
</div>
)}
<div className="border-t border-slate-700 pt-2 flex justify-between text-slate-300">
<span>Subtotal</span>
<span className="font-semibold">{formatPrice(pricing.subtotal)}</span>
</div>
<div className="flex justify-between text-slate-400">
<span>IGIC ({(igicRate * 100).toFixed(0)}%)</span>
<span className="font-semibold">{formatPrice(pricing.igicAmount)}</span>
</div>
<div className="border-t border-slate-600 pt-2 flex justify-between">
<span className="font-bold text-white text-base">Total</span>
<span className="font-black text-white text-base">{formatPrice(pricing.total)}</span>
</div>
</div>
<p className="text-[10px] text-slate-500 mt-1">
* Precio calculado al vuelo. Se congela al guardar la reserva.
</p>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { useProperty } from '../contexts/PropertyContext';
import { PROPERTY_CONFIG, PROPERTIES } from '@naturcalabacera/shared';
import type { Property } from '@naturcalabacera/shared';
export function PropertySelector() {
const { property, setProperty } = useProperty();
return (
<div className="flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-stone-400 dark:text-emerald-500/50 px-1">
Propiedad
</span>
<div className="flex flex-col gap-1.5">
{PROPERTIES.map((p: Property) => {
const config = PROPERTY_CONFIG[p];
const isActive = property === p;
return (
<button
key={p}
onClick={() => setProperty(p)}
className={`
w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left
transition-all duration-200 border
${isActive
? `bg-gradient-to-r ${config.color.gradient} text-white border-transparent shadow-lg`
: 'bg-stone-100 dark:bg-white/5 text-stone-600 dark:text-stone-300 border-stone-200 dark:border-white/10 hover:bg-stone-200 dark:hover:bg-white/10'
}
`}
>
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-white/80' : `bg-gradient-to-br ${config.color.gradient}`}`} />
<span className="text-sm font-semibold">{config.label}</span>
{isActive && (
<span className="ml-auto text-[10px] bg-white/20 px-2 py-0.5 rounded-full font-bold">
Activa
</span>
)}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,628 @@
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import type { NewReservation, Reservation, Property } from '../types';
import { X, Check, Trash2, AlertCircle, ChevronDown, Zap, Paperclip, Receipt } from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { motion, AnimatePresence } from 'framer-motion';
import { PROPERTY_CONFIG, getExtraPersonRate } from '@naturcalabacera/shared';
import { useProperty } from '../contexts/PropertyContext';
import { PricingSection } from './PricingSection';
import { DocumentUpload, type DocumentUploadHandle } from './DocumentUpload';
interface Props {
isOpen: boolean;
mode: 'create' | 'edit';
initialData?: Partial<Reservation>;
existingReservations?: Reservation[];
onClose: () => void;
onSave: (data: NewReservation) => Promise<Reservation | void>;
onDelete?: (id: string) => Promise<void>;
}
const EVENT_TYPES = ['Boda', 'Comunión', 'Cumpleaños', 'Evento privado', 'Corporativo', 'Otro'] as const;
export function ReservationModal({
isOpen,
mode,
initialData,
existingReservations = [],
onClose,
onSave,
onDelete,
}: Props) {
const { property } = useProperty();
const propertyConfig = PROPERTY_CONFIG[property];
const {
register,
handleSubmit,
watch,
reset,
setValue,
setError,
clearErrors,
formState: { errors },
} = useForm<NewReservation>();
// Event toggle — local state (not a form field, controls section visibility)
const [isEvent, setIsEvent] = useState(false);
// Override manual de la tarifa por persona extra (€/pax/noche).
// null = usar tarifa automática por año.
const [extraRateOverride, setExtraRateOverride] = useState<number | null>(null);
// Refs a los DocumentUpload para hacer flush de archivos pendientes tras crear la reserva.
const contractUploadRef = useRef<DocumentUploadHandle>(null);
const invoiceUploadRef = useRef<DocumentUploadHandle>(null);
useEffect(() => {
if (isOpen) {
reset({
origin: 'Teneriffa2000',
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);
const snapshotOverride = initialData?.pricing_snapshot?.extraPersonRateOverride;
setExtraRateOverride(snapshotOverride ?? null);
clearErrors();
}
}, [isOpen, initialData, reset, clearErrors]);
const startDate = watch('start_date');
const endDate = watch('end_date');
const adults = watch('adults_count');
const children = watch('children_count');
const origin = watch('origin');
const eventType = watch('event_type');
const totalDays = startDate && endDate
? Math.max(0, differenceInDays(parseISO(endDate), parseISO(startDate)))
: 0;
const totalPeople = (Number(adults) || 0) + (Number(children) || 0);
// Auto-correct: if start_date moves ahead of end_date, set end_date = start_date
useEffect(() => {
if (startDate && endDate && parseISO(endDate) < parseISO(startDate)) {
setValue('end_date', startDate);
clearErrors('end_date');
}
}, [startDate, endDate, setValue, clearErrors]);
// Event pricing: uses totalPeople (adults + children) as attendee count.
// La tarifa por persona extra depende del año de start_date (12€ en 2026, 14€ desde 2027)
// y puede sobrescribirse manualmente con extraRateOverride.
const reservationYear = startDate ? parseISO(startDate).getFullYear() : new Date().getFullYear();
const autoExtraRate = getExtraPersonRate(property, reservationYear);
const effectiveExtraRate = extraRateOverride ?? autoExtraRate;
const eventPricing = (() => {
const cfg = PROPERTY_CONFIG[property];
const extra = Math.max(0, totalPeople - cfg.pricing.includedPersons);
const subtotal = cfg.pricing.baseRatePerNight + extra * effectiveExtraRate;
return { extra, subtotal, cfg, extraRate: effectiveExtraRate, isOverride: extraRateOverride !== null };
})();
/**
* Overlap check — intervalos semi-abiertos [start, end).
* El día de salida de una reserva existente SÍ permite entrada ese mismo día:
* si res=[19, 20] y new=[20, 21], no hay solapamiento (check-out y check-in mismo día).
* Fórmula: overlap iff newStart < resEnd AND newEnd > resStart
*/
const checkOverlap = (start: string, end: string, currentProperty: Property): boolean => {
const newStart = parseISO(start);
const newEnd = parseISO(end);
const toCheck = existingReservations.filter(r => {
if (mode === 'edit' && r.id === initialData?.id) return false;
return r.property === currentProperty;
});
return toCheck.some(res => {
const resStart = parseISO(res.start_date);
const resEnd = parseISO(res.end_date);
return newStart < resEnd && newEnd > resStart;
});
};
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, property)) {
setError('start_date', {
type: 'manual',
message: `Las fechas seleccionadas se superponen con otra reserva en ${propertyConfig.label}`,
});
return;
}
// Build a clean payload: only include event fields when actually used,
// to avoid sending unknown columns to Supabase if the migration hasn't run.
const saveData: NewReservation = {
origin: data.origin,
client_name: data.client_name,
start_date: data.start_date,
end_date: data.end_date,
adults_count: Number(data.adults_count) || 0,
children_count: Number(data.children_count) || 0,
has_cleaning: Boolean(data.has_cleaning),
has_pool_heating: Boolean(data.has_pool_heating),
has_flies_products: Boolean(data.has_flies_products),
government_registration: data.government_registration || undefined,
observations: data.observations || undefined,
igic_rate: data.igic_rate,
pricing_snapshot: data.pricing_snapshot,
property,
};
if (isEvent) {
saveData.is_event = true;
if (data.event_type) saveData.event_type = data.event_type;
if (data.event_type === 'Otro' && data.event_type_other) saveData.event_type_other = data.event_type_other;
if (totalPeople > 0) saveData.attendees_count = totalPeople;
// Congelar el cálculo del canon en pricing_snapshot
const cfg = PROPERTY_CONFIG[property].pricing;
const igicRate = data.igic_rate ?? 0.07;
const basePrice = cfg.baseRatePerNight;
const extraPersonsFee = eventPricing.extra * effectiveExtraRate;
const subtotal = basePrice + extraPersonsFee;
const igicAmount = Math.round(subtotal * igicRate * 100) / 100;
const total = Math.round((subtotal + igicAmount) * 100) / 100;
saveData.pricing_snapshot = {
basePrice,
extraPersonsFee,
subtotal,
igicAmount,
total,
calculatedAt: new Date().toISOString(),
extraPersonRate: effectiveExtraRate,
...(extraRateOverride !== null ? { extraPersonRateOverride: extraRateOverride } : {}),
};
}
const result = await onSave(saveData);
if (!result) {
// onSave abortó (error o falta de id en edit). No cerramos el modal.
return;
}
// Si creamos una reserva nueva, sube los documentos pendientes ahora que tenemos id
const newId = typeof result === 'object' && 'id' in result ? (result as Reservation).id : undefined;
if (newId) {
try {
await Promise.all([
contractUploadRef.current?.flushPending(newId),
invoiceUploadRef.current?.flushPending(newId),
]);
} catch (err) {
console.error('Error subiendo documentos pendientes:', err);
}
}
onClose();
};
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<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"
/>
{/* Sheet — always dark */}
<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"
>
{/* 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 className={`inline-flex items-center gap-1.5 mt-2 px-3 py-1 rounded-full text-xs font-bold bg-gradient-to-r ${propertyConfig.color.gradient} text-white`}>
<div className="w-1.5 h-1.5 rounded-full bg-white/70" />
{propertyConfig.label}
</div>
</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. Empresa / 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" />
<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" />
<span className="font-bold text-slate-300 peer-checked:text-yellow-300 text-sm">Natur</span>
</div>
</label>
</div>
</div>
{/* 2. Modo Evento (solo Naturcalabacera) */}
<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 */}
<div className="flex items-center justify-between">
<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>
<button
type="button"
onClick={() => setIsEvent(v => !v)}
className={`w-11 h-6 rounded-full transition-all duration-300 relative flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-yellow-500/50 ${isEvent ? 'bg-yellow-500' : 'bg-slate-600'}`}
aria-pressed={isEvent}
>
<span 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'}`} />
</button>
</div>
{/* Event 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>
{/* Descripción si es Otro */}
{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"
/>
)}
{/* Canon — usa el conteo de huéspedes (adultos + niños) */}
{totalPeople > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-slate-900/70 border border-emerald-800/40 rounded-xl p-4 space-y-2.5"
>
<div className="flex items-center gap-2 mb-0.5">
<div className="w-1.5 h-4 bg-gradient-to-b from-emerald-400 to-emerald-600 rounded-full" />
<p className="text-xs font-black text-emerald-400 uppercase tracking-wider">Cálculo de Canon</p>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-400">Propiedad</span>
<span className="text-slate-200 font-semibold">{eventPricing.cfg.label}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-400">Canon base / noche</span>
<span className="text-slate-200 font-semibold">{eventPricing.cfg.pricing.baseRatePerNight.toLocaleString('es-ES')} </span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-400">Asistentes ({totalPeople} pax)</span>
<span className="text-slate-200 font-semibold">incluye hasta {eventPricing.cfg.pricing.includedPersons}</span>
</div>
{eventPricing.extra > 0 && (
<div className="flex justify-between text-sm">
<span className="text-slate-400">{eventPricing.extra} pax extra × {eventPricing.extraRate} </span>
<span className="text-yellow-400 font-semibold">+{(eventPricing.extra * eventPricing.extraRate).toLocaleString('es-ES')} </span>
</div>
)}
{/* Editor de tarifa por pax extra */}
<div className="border-t border-slate-700 pt-2.5 space-y-1.5">
<div className="flex items-center gap-2">
<label className="text-[10px] text-slate-400 uppercase tracking-wider font-bold flex-1">
Tarifa pax extra (/pax/noche)
</label>
{eventPricing.isOverride && (
<button
type="button"
onClick={() => setExtraRateOverride(null)}
className="text-[10px] text-slate-400 hover:text-emerald-400 underline underline-offset-2"
>
Restablecer ({autoExtraRate} )
</button>
)}
</div>
<input
type="number"
min="0"
step="0.5"
value={effectiveExtraRate}
onChange={e => {
const v = e.target.value;
if (v === '') {
setExtraRateOverride(null);
return;
}
const num = Number(v);
if (Number.isFinite(num) && num >= 0) {
setExtraRateOverride(num === autoExtraRate ? null : num);
}
}}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-medium focus:outline-none focus:ring-2 focus:ring-yellow-500/40 focus:border-yellow-500/40"
/>
<p className="text-[10px] text-slate-500">
{eventPricing.isOverride
? `Tarifa personalizada (auto ${reservationYear}: ${autoExtraRate} €)`
: `Tarifa automática para ${reservationYear}`}
</p>
</div>
<div className="border-t border-slate-700 pt-2.5 flex justify-between">
<span className="text-slate-300 font-bold text-sm">Subtotal</span>
<span className="text-white font-black text-lg">{eventPricing.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>
{/* 3. 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>
{/* 4. 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 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>
{/* 5. 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>
{/* 6. 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' as const, label: 'Limpieza Final' },
{ id: 'has_pool_heating' as const, label: 'Calefacción Piscina' },
{ id: 'has_flies_products' as const, 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:border-slate-600 transition-colors">
<span className="font-medium text-slate-200 text-sm">{extra.label}</span>
<input
type="checkbox"
{...register(extra.id)}
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>
{/* 7. Precios Natur (solo vacacional — no evento) */}
{origin === 'Naturcalabacera' && !isEvent && (
<PricingSection
property={property}
startDate={startDate}
endDate={endDate}
adults={Number(adults) || 0}
children={Number(children) || 0}
igicRate={initialData?.igic_rate ?? 0.07}
/>
)}
{/* 8. Registro Gubernamental */}
<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">
Registro Gubernamental
<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>
<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-emerald-500/40 text-slate-100 placeholder-slate-500 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 */}
<div className="bg-slate-800/50 p-4 rounded-2xl border border-slate-700">
<div className="flex items-center gap-2 mb-3">
<Paperclip className="w-4 h-4 text-slate-400" />
<span className="text-sm font-bold text-slate-300">Contrato</span>
<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>
</div>
<DocumentUpload
ref={contractUploadRef}
reservationId={initialData?.id}
documentType="contract"
/>
</div>
{/* 11. Factura */}
<div className="bg-slate-800/50 p-4 rounded-2xl border border-slate-700">
<div className="flex items-center gap-2 mb-3">
<Receipt className="w-4 h-4 text-slate-400" />
<span className="text-sm font-bold text-slate-300">Factura</span>
<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>
</div>
<DocumentUpload
ref={invoiceUploadRef}
reservationId={initialData?.id}
documentType="invoice"
/>
</div>
<div className="h-2" />
{/* 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>
</>
)}
</AnimatePresence>
);
}

View File

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

View File

@@ -0,0 +1,46 @@
import { Sparkles, Thermometer, Bug } from 'lucide-react';
import type { Reservation } from '../types';
interface Props {
reservation: Reservation;
className?: string;
}
/**
* Muestra iconos compactos para los extras activos de una reserva.
* Solo renderiza los iconos de servicios que están activados.
* Diseñado para encajar en los bloques del calendario (pequeños, con text-shadow).
*/
export function ServiceIcons({ reservation, className = '' }: Props) {
const activeIcons: { icon: React.ElementType; title: string; key: string }[] = [];
if (reservation.has_cleaning) {
activeIcons.push({ icon: Sparkles, title: 'Limpieza final', key: 'cleaning' });
}
if (reservation.has_pool_heating) {
activeIcons.push({ icon: Thermometer, title: 'Calefacción piscina', key: 'pool' });
}
if (reservation.has_flies_products) {
activeIcons.push({ icon: Bug, title: 'Productos moscas', key: 'flies' });
}
if (activeIcons.length === 0) return null;
return (
<div
className={`flex items-center gap-0.5 ${className}`}
role="group"
aria-label="Servicios incluidos"
>
{activeIcons.map(({ icon: Icon, title, key }) => (
<span
key={key}
title={title}
className="drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>
<Icon className="w-3 h-3 text-white" aria-label={title} />
</span>
))}
</div>
);
}

View File

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

View File

@@ -0,0 +1,104 @@
import { Calendar, Settings, CalendarDays, Users } from 'lucide-react';
import { PropertySelector } from './PropertySelector';
import { usePropertyTheme } from '../hooks/usePropertyTheme';
interface Props {
currentView: string;
onNavigate: (view: string) => void;
isViewer?: boolean;
isAdmin?: boolean;
}
export function Sidebar({ currentView, onNavigate, isViewer = false, isAdmin = false }: Props) {
const theme = usePropertyTheme();
const allMenuItems = [
{ id: 'calendar', label: 'Mensual', icon: Calendar, requires: 'all' as const },
{ id: 'yearly', label: 'Anual', icon: CalendarDays, requires: 'all' as const },
{ id: 'users', label: 'Usuarios', icon: Users, requires: 'admin' as const },
{ id: 'settings', label: 'Ajustes', icon: Settings, requires: 'staff' as const },
];
const menuItems = allMenuItems.filter(item => {
if (item.requires === 'all') return true;
if (item.requires === 'admin') return isAdmin;
if (item.requires === 'staff') return !isViewer;
return false;
});
return (
<div className={`hidden md:flex w-72 ${theme.sidebarBg} h-screen flex-col border-r ${theme.sidebarBorder} transition-all duration-500`}>
{/* Logo */}
<div className={`p-8 border-b ${theme.dividerBorder}`}>
<h1 className="text-3xl font-black text-white flex items-center gap-3">
<div className={`p-2 bg-gradient-to-br ${theme.logoIconBg} rounded-xl shadow-lg ${theme.logoIconShadow} transition-all duration-700`}>
<Calendar className="w-6 h-6 text-white" />
</div>
<span className={`bg-gradient-to-r ${theme.logoTextGradient} bg-clip-text text-transparent transition-all duration-700`}>
Reservas
</span>
</h1>
<p className={`text-xs ${theme.subtitleText} mt-2 font-medium transition-all duration-700`}>
Sistema de Gestión
</p>
</div>
{/* Selector de propiedad (solo staff) */}
{!isViewer && (
<div className={`px-6 pt-6 pb-4 border-b ${theme.dividerBorder}`}>
<PropertySelector />
</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 ${theme.navActiveBg} text-white shadow-xl ${theme.navActiveShadow} scale-105`
: `${theme.navInactiveText} ${theme.navHoverBg} ${theme.navHoverText} hover:scale-105`
}
`}
>
{isActive && (
<div className="absolute inset-0 bg-white opacity-5 blur-xl" />
)}
<div className={`
p-2 rounded-xl transition-all duration-300 relative z-10
${isActive ? theme.navActiveIconBg : `${theme.navInactiveIconBg} group-hover:opacity-80`}
`}>
<Icon className="w-5 h-5" />
</div>
<span className="font-semibold relative z-10">{item.label}</span>
</button>
);
})}
</nav>
{/* Footer - Leyenda tipos de reserva */}
<div className={`p-6 border-t ${theme.footerBorder} ${theme.footerBg} backdrop-blur`}>
<p className={`text-xs font-bold ${theme.legendLabel} mb-4 uppercase tracking-wider transition-all duration-700`}>
Tipo de reserva
</p>
<div className="space-y-3">
<div className={`flex items-center gap-3 p-3 bg-blue-500/5 rounded-xl border 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" />
<span className="text-sm font-semibold text-slate-300">Teneriffa</span>
</div>
<div className={`flex items-center gap-3 p-3 bg-amber-500/5 rounded-xl border 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" />
<span className="text-sm font-semibold text-amber-100/70">Naturcalabacera</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,337 @@
import { useEffect, useState } from 'react';
import { Loader2, Trash2, UserPlus, X, Check, AlertCircle, Shield, Eye, Users as UsersIcon } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { supabase } from '../lib/supabase';
import type { UserRole } from '../types';
interface UserProfileRow {
id: string;
email: string;
role: UserRole;
display_name: string | null;
created_at: string;
}
const ROLE_OPTIONS: { value: UserRole; label: string; description: string; icon: typeof Shield }[] = [
{ value: 'admin', label: 'Admin', description: 'Acceso total y gestión de usuarios', icon: Shield },
{ value: 'internal_staff', label: 'Staff', description: 'Gestión completa de reservas', icon: UsersIcon },
{ value: 'external_availability_viewer', label: 'Viewer', description: 'Solo lee disponibilidad', icon: Eye },
];
const apiUrl = import.meta.env.VITE_API_URL;
async function authFetch(path: string, init: RequestInit = {}): Promise<Response> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
const headers = new Headers(init.headers);
headers.set('Content-Type', 'application/json');
if (token) headers.set('Authorization', `Bearer ${token}`);
return fetch(`${apiUrl}${path}`, { ...init, headers });
}
export function UserManagement() {
const [users, setUsers] = useState<UserProfileRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [inviteOpen, setInviteOpen] = useState(false);
const [savingId, setSavingId] = useState<string | null>(null);
const loadUsers = async () => {
setLoading(true);
setError(null);
try {
const res = await authFetch('/api/users');
if (!res.ok) {
const body = await res.json().catch(() => ({} as { error?: string }));
throw new Error(body?.error ?? `Error ${res.status}`);
}
const body = await res.json() as { users: UserProfileRow[] };
setUsers(body.users);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar usuarios');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!apiUrl) {
setError('VITE_API_URL no configurado. La gestión de usuarios necesita el API.');
setLoading(false);
return;
}
loadUsers();
}, []);
const handleRoleChange = async (userId: string, newRole: UserRole) => {
setSavingId(userId);
try {
const res = await authFetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ role: newRole }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({} as { error?: string }));
throw new Error(body?.error ?? 'Error al actualizar rol');
}
setUsers(prev => prev.map(u => u.id === userId ? { ...u, role: newRole } : u));
toast.success('Rol actualizado');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Error');
} finally {
setSavingId(null);
}
};
const handleDelete = async (user: UserProfileRow) => {
if (!confirm(`¿Eliminar a ${user.email}? Esta acción no se puede deshacer.`)) return;
setSavingId(user.id);
try {
const res = await authFetch(`/api/users/${user.id}`, { method: 'DELETE' });
if (!res.ok) {
const body = await res.json().catch(() => ({} as { error?: string }));
throw new Error(body?.error ?? 'Error al eliminar');
}
setUsers(prev => prev.filter(u => u.id !== user.id));
toast.success('Usuario eliminado');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Error');
} finally {
setSavingId(null);
}
};
return (
<div className="max-w-4xl mx-auto p-4 md:p-6">
<header className="mb-6 flex flex-col md:flex-row md:items-end justify-between gap-3">
<div>
<h1 className="text-3xl md:text-4xl font-black text-white tracking-tight mb-1">Usuarios</h1>
<p className="text-slate-400 text-sm">Gestiona accesos y roles del equipo.</p>
</div>
<button
type="button"
onClick={() => setInviteOpen(true)}
className="inline-flex items-center gap-2 px-5 py-3 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-2xl shadow-lg shadow-emerald-900/30 transition-all"
>
<UserPlus className="w-4 h-4" />
Invitar usuario
</button>
</header>
{error && (
<div className="mb-4 flex items-center gap-2 px-4 py-3 bg-red-950/40 border border-red-800 rounded-xl text-sm text-red-400">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-6 h-6 text-slate-500 animate-spin" />
</div>
) : (
<div className="bg-slate-800/40 border border-slate-700/50 rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700/50 text-slate-400 uppercase text-xs tracking-wider">
<th className="px-4 py-3 text-left font-bold">Email / Nombre</th>
<th className="px-4 py-3 text-left font-bold">Rol</th>
<th className="px-4 py-3 text-left font-bold hidden md:table-cell">Creado</th>
<th className="px-4 py-3 text-right font-bold">Acciones</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-12 text-center text-slate-500">
No hay usuarios registrados.
</td>
</tr>
) : users.map(user => (
<tr key={user.id} className="border-b border-slate-800/60 last:border-0 hover:bg-slate-800/30 transition-colors">
<td className="px-4 py-3">
<div className="font-semibold text-slate-100">{user.display_name ?? user.email}</div>
{user.display_name && (
<div className="text-xs text-slate-500">{user.email}</div>
)}
</td>
<td className="px-4 py-3">
<select
value={user.role}
disabled={savingId === user.id}
onChange={e => handleRoleChange(user.id, e.target.value as UserRole)}
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-1.5 text-slate-100 text-xs font-medium focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
>
{ROLE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</td>
<td className="px-4 py-3 text-slate-400 text-xs hidden md:table-cell">
{new Date(user.created_at).toLocaleDateString('es-ES')}
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
disabled={savingId === user.id}
onClick={() => handleDelete(user)}
className="inline-flex p-2 text-slate-500 hover:text-red-400 transition-colors"
title="Eliminar"
>
{savingId === user.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<AnimatePresence>
{inviteOpen && (
<InviteModal
onClose={() => setInviteOpen(false)}
onInvited={(newUser) => {
setUsers(prev => [newUser, ...prev]);
setInviteOpen(false);
toast.success('Invitación enviada');
}}
/>
)}
</AnimatePresence>
</div>
);
}
interface InviteModalProps {
onClose: () => void;
onInvited: (u: UserProfileRow) => void;
}
function InviteModal({ onClose, onInvited }: InviteModalProps) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<UserRole>('internal_staff');
const [displayName, setDisplayName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const res = await authFetch('/api/users/invite', {
method: 'POST',
body: JSON.stringify({ email, role, display_name: displayName || undefined }),
});
const body = await res.json().catch(() => ({} as { error?: string; user?: UserProfileRow }));
if (!res.ok) throw new Error(body?.error ?? 'Error al invitar');
if (body.user) onInvited(body.user);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error');
} finally {
setSubmitting(false);
}
};
return (
<>
<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={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none"
>
<form
onSubmit={handleSubmit}
className="bg-slate-900 border border-slate-700 rounded-3xl p-6 w-full max-w-md pointer-events-auto shadow-2xl space-y-4"
>
<div className="flex items-start justify-between mb-2">
<div>
<h2 className="text-2xl font-bold text-white">Invitar usuario</h2>
<p className="text-sm text-slate-400 mt-0.5">Recibirá un correo con magic link para acceder.</p>
</div>
<button type="button" onClick={onClose} className="p-2 hover:bg-slate-800 rounded-full transition-colors">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div>
<label className="block text-xs font-bold text-slate-400 mb-1.5 uppercase tracking-wider">Email</label>
<input
type="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="usuario@ejemplo.com"
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-400 mb-1.5 uppercase tracking-wider">Nombre (opcional)</label>
<input
type="text"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder="Nombre visible..."
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-400 mb-2 uppercase tracking-wider">Rol</label>
<div className="space-y-2">
{ROLE_OPTIONS.map(opt => {
const Icon = opt.icon;
return (
<label key={opt.value} className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${role === opt.value ? 'border-emerald-500 bg-emerald-900/20' : 'border-slate-700 bg-slate-800 hover:border-slate-600'}`}>
<input
type="radio"
name="role"
value={opt.value}
checked={role === opt.value}
onChange={() => setRole(opt.value)}
className="sr-only"
/>
<Icon className="w-5 h-5 text-emerald-400 flex-shrink-0" />
<div className="flex-1">
<div className="font-bold text-slate-100 text-sm">{opt.label}</div>
<div className="text-xs text-slate-400">{opt.description}</div>
</div>
{role === opt.value && <Check className="w-4 h-4 text-emerald-400" />}
</label>
);
})}
</div>
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-3 bg-red-950/40 border border-red-800 rounded-xl text-sm text-red-400">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<button
type="submit"
disabled={submitting || !email}
className="w-full py-3 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-bold rounded-2xl flex items-center justify-center gap-2 transition-all"
>
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
Enviar invitación
</button>
</form>
</motion.div>
</>
);
}

View File

@@ -0,0 +1,283 @@
import { useEffect, useRef, useState } from 'react';
import {
format, eachDayOfInterval, endOfMonth, startOfMonth,
startOfYear, endOfYear, eachMonthOfInterval, getDay,
isSameDay, isWithinInterval, parseISO, differenceInDays,
} from 'date-fns';
import { es } from 'date-fns/locale';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { Reservation } from '../types';
interface Props {
reservations: Reservation[];
/** Año inicial — por defecto el actual. */
year?: number;
/** Si se proporciona, se llama al confirmar un drag de varios días. */
onSelectRange?: (start: Date, end: Date) => void;
/** Si se proporciona, se llama al hacer click en un día libre. */
onSelectDay?: (day: Date) => void;
/** Si se proporciona, se llama al hacer click en un día ocupado. */
onSelectReservation?: (res: Reservation) => void;
viewerMode?: boolean;
}
const today = () => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
};
export function YearlyCalendar({
reservations = [],
year: yearProp,
onSelectRange,
onSelectDay,
onSelectReservation,
viewerMode = false,
}: Props) {
const currentYear = new Date().getFullYear();
const [year, setYear] = useState(yearProp ?? currentYear);
// Drag-to-select state — sólo dentro de un mismo mes para mantener simple
const [dragStart, setDragStart] = useState<Date | null>(null);
const [dragEnd, setDragEnd] = useState<Date | null>(null);
const [isDragging, setIsDragging] = useState(false);
const mouseMoved = useRef(false);
const dragJustFinished = useRef(false);
const dragMonthRef = useRef<number | null>(null); // mes (0-11) donde empezó el drag
const months = eachMonthOfInterval({
start: startOfYear(new Date(year, 0, 1)),
end: endOfYear(new Date(year, 0, 1)),
});
// Rango de años a mostrar en el selector: 2 atrás, 6 adelante
const yearOptions: number[] = [];
for (let y = currentYear - 2; y <= currentYear + 6; y++) yearOptions.push(y);
if (!yearOptions.includes(year)) {
yearOptions.push(year);
yearOptions.sort((a, b) => a - b);
}
const todayDate = today();
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 = parseISO(res.start_date);
const end = parseISO(res.end_date);
if (isNaN(start.getTime()) || isNaN(end.getTime())) return false;
return isWithinInterval(date, { start, end }) || isSameDay(date, start) || isSameDay(date, end);
});
};
const getReservationForDay = (date: Date): Reservation | undefined => {
return reservations.find(res => {
if (!res.start_date || !res.end_date) return false;
const start = parseISO(res.start_date);
const end = parseISO(res.end_date);
if (isNaN(start.getTime()) || isNaN(end.getTime())) return false;
return isWithinInterval(date, { start, end }) || isSameDay(date, start) || isSameDay(date, end);
});
};
const isInDragSelection = (day: Date, monthIndex: number): boolean => {
if (!dragStart || !dragEnd || dragMonthRef.current !== monthIndex) return false;
const start = dragStart <= dragEnd ? dragStart : dragEnd;
const end = dragStart <= dragEnd ? dragEnd : dragStart;
return day >= start && day <= end;
};
const handleDayMouseDown = (day: Date, occupied: boolean, monthIndex: number) => {
if (viewerMode || occupied) return;
mouseMoved.current = false;
setDragStart(day);
setDragEnd(day);
setIsDragging(true);
dragMonthRef.current = monthIndex;
};
const handleDayMouseEnter = (day: Date, monthIndex: number) => {
if (!isDragging) return;
if (dragMonthRef.current !== monthIndex) return; // no cruza meses
mouseMoved.current = true;
setDragEnd(day);
};
const handleDayMouseUp = () => {
if (!isDragging || !dragStart || !dragEnd) return;
const hasMoved = mouseMoved.current;
const start = dragStart;
const end = dragEnd;
setIsDragging(false);
setDragStart(null);
setDragEnd(null);
dragMonthRef.current = null;
if (hasMoved && !isSameDay(start, end)) {
const s = start <= end ? start : end;
const e = start <= end ? end : start;
dragJustFinished.current = true;
onSelectRange?.(s, e);
}
};
const handleDayClick = (day: Date, occupied: boolean) => {
if (dragJustFinished.current) {
dragJustFinished.current = false;
return;
}
if (viewerMode) return;
if (occupied) {
const res = getReservationForDay(day);
if (res) onSelectReservation?.(res);
} else {
onSelectDay?.(day);
}
};
// Cancelar drag si se suelta fuera
useEffect(() => {
const cancel = () => {
if (isDragging) {
setIsDragging(false);
setDragStart(null);
setDragEnd(null);
dragMonthRef.current = null;
}
};
document.addEventListener('mouseup', cancel);
return () => document.removeEventListener('mouseup', cancel);
}, [isDragging]);
const dragNights = dragStart && dragEnd
? Math.abs(differenceInDays(dragEnd, dragStart))
: 0;
return (
<div className="p-4 md:p-8 max-w-[1600px] mx-auto animate-in fade-in zoom-in duration-500">
<header className="mb-6 md:mb-8 flex flex-col md:flex-row md:justify-between md:items-end gap-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setYear(y => y - 1)}
className="p-2 rounded-xl bg-slate-800/50 hover:bg-slate-800 border border-slate-700 text-slate-300 transition-all"
aria-label="Año anterior"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div>
<div className="flex items-baseline gap-3">
<h1 className="text-3xl md:text-4xl font-black text-white tracking-tighter">Vista Anual</h1>
<select
value={year}
onChange={e => setYear(Number(e.target.value))}
className="text-3xl md:text-4xl font-black bg-transparent text-emerald-400 border-none focus:outline-none cursor-pointer hover:text-emerald-300"
>
{yearOptions.map(y => (
<option key={y} value={y} className="bg-slate-900 text-base">{y}</option>
))}
</select>
{isDragging && dragNights > 0 && (
<span className="text-sm font-bold text-emerald-400 animate-pulse">
{dragNights} noche{dragNights !== 1 ? 's' : ''}
</span>
)}
</div>
<p className="text-slate-400 text-sm md:text-base">
{viewerMode ? 'Panorama global de ocupación.' : 'Clic para crear · Arrastra para rango (dentro de un mes).'}
</p>
</div>
<button
type="button"
onClick={() => setYear(y => y + 1)}
className="p-2 rounded-xl bg-slate-800/50 hover:bg-slate-800 border border-slate-700 text-slate-300 transition-all"
aria-label="Año siguiente"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
<div className="flex gap-3 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 className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500 ring-2 ring-blue-300/40"></div>
<span className="text-slate-300 text-xs font-bold uppercase tracking-wider">Hoy</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, monthIndex) => {
const monthStart = startOfMonth(month);
const monthEnd = endOfMonth(month);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const startDayOfWeek = getDay(monthStart);
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"
onMouseUp={handleDayMouseUp}
style={{ userSelect: isDragging ? 'none' : undefined }}
>
{emptySlots.map((_, i) => (
<div key={`empty-${i}`} />
))}
{days.map((day) => {
const occupied = isDayOccupied(day);
const isToday = isSameDay(day, todayDate);
const inSel = isInDragSelection(day, monthIndex);
return (
<div
key={day.toString()}
onMouseDown={() => handleDayMouseDown(day, occupied, monthIndex)}
onMouseEnter={() => handleDayMouseEnter(day, monthIndex)}
onClick={() => handleDayClick(day, occupied)}
className={`
aspect-square rounded-md flex items-center justify-center text-xs font-medium relative group transition-all duration-200
${!viewerMode && !occupied ? 'cursor-pointer' : ''}
${inSel
? 'bg-emerald-500/40 text-white ring-2 ring-emerald-400/70'
: 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'
}
${isToday ? 'ring-2 ring-blue-400 ring-offset-1 ring-offset-slate-900 font-bold' : ''}
`}
title={format(day, 'dd/MM/yyyy')}
>
{format(day, 'd')}
{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>
);
}

View File

@@ -0,0 +1,47 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import type { Property } from '@naturcalabacera/shared';
const STORAGE_KEY = 'selected_property';
const DEFAULT_PROPERTY: Property = 'los_dragos';
interface PropertyContextValue {
property: Property;
setProperty: (property: Property) => void;
}
const PropertyContext = createContext<PropertyContextValue | null>(null);
export function PropertyProvider({ children }: { children: ReactNode }) {
const [property, setPropertyState] = useState<Property>(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return (saved === 'los_dragos' || saved === 'la_esquinita') ? saved : DEFAULT_PROPERTY;
});
const setProperty = (newProperty: Property) => {
localStorage.setItem(STORAGE_KEY, newProperty);
setPropertyState(newProperty);
};
// Sincronizar en caso de cambio en otra pestaña
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY && (e.newValue === 'los_dragos' || e.newValue === 'la_esquinita')) {
setPropertyState(e.newValue);
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, []);
return (
<PropertyContext.Provider value={{ property, setProperty }}>
{children}
</PropertyContext.Provider>
);
}
export function useProperty(): PropertyContextValue {
const ctx = useContext(PropertyContext);
if (!ctx) throw new Error('useProperty debe usarse dentro de PropertyProvider');
return ctx;
}

View File

@@ -0,0 +1,28 @@
import { createContext, useContext, type ReactNode } from 'react';
import { useUserRole } from '../hooks/useUserRole';
import type { UserRole } from '../types';
interface UserRoleContextValue {
role: UserRole | null;
loading: boolean;
isViewer: boolean;
isAdmin: boolean;
isStaff: boolean;
}
const UserRoleContext = createContext<UserRoleContextValue>({
role: null,
loading: true,
isViewer: false,
isAdmin: false,
isStaff: false,
});
export function UserRoleProvider({ children }: { children: ReactNode }) {
const value = useUserRole();
return <UserRoleContext.Provider value={value}>{children}</UserRoleContext.Provider>;
}
export function useUserRoleContext() {
return useContext(UserRoleContext);
}

View File

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

View File

@@ -0,0 +1,137 @@
import { useState, useCallback } from 'react';
import { supabase } from '../lib/supabase';
export type DocumentType = 'contract' | 'invoice' | 'other';
export interface UploadedContract {
id: string;
reservation_id: string;
file_path: string;
filename: string;
mime_type: string;
size_bytes: number;
uploaded_by: string | null;
created_at: string;
document_type: DocumentType;
}
const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
export function validateFile(file: File): string | null {
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
return 'Solo se permiten archivos PDF, JPEG o PNG';
}
if (file.size > MAX_FILE_SIZE) {
return 'El archivo no puede superar los 10 MB';
}
return null;
}
/**
* Sube un único archivo al bucket de contratos y registra la entrada en BD.
* Usado tanto desde el flujo en tiempo real (reserva existente) como desde
* el flujo diferido (subida tras crear la reserva).
*/
export async function uploadDocumentFile(
file: File,
reservationId: string,
documentType: DocumentType,
): Promise<UploadedContract> {
const ext = file.name.split('.').pop() ?? 'bin';
const uniqueName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const filePath = `contracts/${reservationId}/${documentType}/${uniqueName}`;
const { error: storageError } = await supabase.storage
.from('contracts')
.upload(filePath, file, { contentType: file.type });
if (storageError) throw storageError;
const { data: user } = await supabase.auth.getUser();
const { data, error: dbError } = await supabase
.from('reservation_contracts')
.insert({
reservation_id: reservationId,
file_path: filePath,
filename: file.name,
mime_type: file.type,
size_bytes: file.size,
uploaded_by: user?.user?.id ?? null,
document_type: documentType,
})
.select()
.single();
if (dbError) throw dbError;
return data as UploadedContract;
}
export function useFileUpload(reservationId: string, documentType: DocumentType = 'contract') {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const uploadFile = useCallback(async (file: File): Promise<UploadedContract | null> => {
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return null;
}
setUploading(true);
setError(null);
try {
return await uploadDocumentFile(file, reservationId, documentType);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al subir el archivo');
return null;
} finally {
setUploading(false);
}
}, [reservationId, documentType]);
const fetchContracts = useCallback(async (): Promise<UploadedContract[]> => {
const { data, error: fetchError } = await supabase
.from('reservation_contracts')
.select('*')
.eq('reservation_id', reservationId)
.eq('document_type', documentType)
.order('created_at', { ascending: false });
if (fetchError) {
setError(fetchError.message);
return [];
}
return (data ?? []) as UploadedContract[];
}, [reservationId, documentType]);
const getSignedUrl = useCallback(async (filePath: string): Promise<string | null> => {
const { data, error: urlError } = await supabase.storage
.from('contracts')
.createSignedUrl(filePath, 3600); // 1 hour
if (urlError) {
setError(urlError.message);
return null;
}
return data.signedUrl;
}, []);
const deleteContract = useCallback(async (contractId: string, filePath: string): Promise<boolean> => {
const { error: dbError } = await supabase
.from('reservation_contracts')
.delete()
.eq('id', contractId);
if (dbError) {
setError(dbError.message);
return false;
}
await supabase.storage.from('contracts').remove([filePath]);
return true;
}, []);
return { uploading, error, uploadFile, fetchContracts, getSignedUrl, deleteContract };
}

View File

@@ -0,0 +1,119 @@
import { useProperty } from '../contexts/PropertyContext';
export interface PropertyTheme {
isDragos: boolean;
name: string;
// Backgrounds — always dark/black, never change
rootBg: string;
sidebarBg: string;
sidebarBorder: string;
headerBg: string;
headerBorder: string;
mainBg: string;
// Title / branding accent
titleGradient: string;
titleShadow: string;
// Dot indicator
dotColor: string;
dotShadow: string;
// Primary button
buttonBg: string;
buttonShadow: string;
buttonHoverShadow: string;
// Navigation active state
navActiveBg: string;
navActiveShadow: string;
navHoverBg: string;
navHoverText: string;
navInactiveText: string;
navInactiveIconBg: string;
navActiveIconBg: string;
// Logo icon
logoIconBg: string;
logoIconShadow: string;
logoTextGradient: string;
// Footer / legend
footerBg: string;
footerBorder: string;
legendLabel: string;
// Misc accents
accentText: string;
subtitleText: string;
dividerBorder: string;
}
export function usePropertyTheme(): PropertyTheme {
const { property } = useProperty();
const isDragos = property === 'los_dragos';
// Shared dark backgrounds — identical for both properties
const darkBase = {
rootBg: 'bg-black',
sidebarBg: 'bg-black',
headerBg: 'bg-black/90',
mainBg: 'bg-black',
footerBg: 'bg-zinc-950/80',
};
if (isDragos) {
return {
isDragos: true,
name: 'Los Dragos',
...darkBase,
sidebarBorder: 'border-emerald-900/40',
headerBorder: 'border-emerald-900/40',
footerBorder: 'border-emerald-900/40',
dividerBorder: 'border-emerald-900/40',
titleGradient: 'from-emerald-400 via-teal-300 to-emerald-200',
titleShadow: 'drop-shadow-[0_0_24px_rgba(52,211,153,0.35)]',
dotColor: 'bg-emerald-400',
dotShadow: 'shadow-emerald-400/50',
buttonBg: 'from-emerald-600 to-teal-600',
buttonShadow: 'shadow-emerald-600/25',
buttonHoverShadow: 'hover:shadow-emerald-500/40',
navActiveBg: 'from-emerald-600 to-teal-700',
navActiveShadow: 'shadow-emerald-600/25',
navHoverBg: 'hover:bg-emerald-950/60',
navHoverText: 'hover:text-emerald-300',
navInactiveText: 'text-zinc-400',
navInactiveIconBg: 'bg-zinc-800/60',
navActiveIconBg: 'bg-white/20',
logoIconBg: 'from-emerald-500 to-teal-700',
logoIconShadow: 'shadow-emerald-600/30',
logoTextGradient: 'from-emerald-400 to-teal-300',
legendLabel: 'text-zinc-500',
accentText: 'text-emerald-400',
subtitleText: 'text-emerald-500/60',
};
} else {
return {
isDragos: false,
name: 'La Esquinita',
...darkBase,
sidebarBorder: 'border-orange-900/40',
headerBorder: 'border-orange-900/40',
footerBorder: 'border-orange-900/40',
dividerBorder: 'border-orange-900/40',
titleGradient: 'from-orange-400 via-amber-300 to-orange-200',
titleShadow: 'drop-shadow-[0_0_24px_rgba(251,146,60,0.35)]',
dotColor: 'bg-orange-400',
dotShadow: 'shadow-orange-400/50',
buttonBg: 'from-orange-600 to-amber-600',
buttonShadow: 'shadow-orange-600/25',
buttonHoverShadow: 'hover:shadow-orange-500/40',
navActiveBg: 'from-orange-600 to-amber-700',
navActiveShadow: 'shadow-orange-600/25',
navHoverBg: 'hover:bg-orange-950/60',
navHoverText: 'hover:text-orange-300',
navInactiveText: 'text-zinc-400',
navInactiveIconBg: 'bg-zinc-800/60',
navActiveIconBg: 'bg-white/20',
logoIconBg: 'from-orange-500 to-amber-700',
logoIconShadow: 'shadow-orange-600/30',
logoTextGradient: 'from-orange-400 to-amber-300',
legendLabel: 'text-zinc-500',
accentText: 'text-orange-400',
subtitleText: 'text-orange-500/60',
};
}
}

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import type { Reservation, NewReservation, Property } from '../types';
export function useReservations(property: Property) {
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('*')
.eq('property', property)
.order('start_date', { ascending: true });
if (error) throw error;
setReservations(data || []);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Error desconocido');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchReservations();
// Suscripción realtime filtrada por propiedad
const channel = supabase
.channel(`reservations_${property}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'natur_reservas',
table: 'reservations',
filter: `property=eq.${property}`,
},
() => {
fetchReservations();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [property]);
const createReservation = async (reservation: NewReservation): Promise<Reservation> => {
const { data: authData } = await supabase.auth.getUser();
const payload = {
...reservation,
property,
created_by: authData.user?.id,
};
const { data, error } = await supabase
.from('reservations')
.insert([payload])
.select('id')
.single();
if (error) throw error;
const { data: created, error: fetchError } = await supabase
.from('reservations')
.select('*')
.eq('id', data.id)
.single();
if (fetchError) throw fetchError;
return created as Reservation;
};
const updateReservation = async (id: string, updates: Partial<Reservation>) => {
const { data: authData } = await supabase.auth.getUser();
const payload = {
...updates,
updated_by: authData.user?.id,
};
const { error } = await supabase
.from('reservations')
.update(payload)
.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,
};
}

View File

@@ -0,0 +1,38 @@
import { useState, useEffect } from 'react';
import { supabase } from '../lib/supabase';
import type { UserRole } from '../types';
export function useUserRole() {
const [role, setRole] = useState<UserRole | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function fetchRole() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) { setLoading(false); return; }
const { data } = await supabase
.from('user_profiles')
.select('role')
.eq('id', user.id)
.single();
if (!cancelled) {
// Default to 'admin' if no profile row found (backwards compat)
setRole((data?.role as UserRole) ?? 'admin');
setLoading(false);
}
}
fetchRole();
return () => { cancelled = true; };
}, []);
const isViewer = role === 'external_availability_viewer';
const isAdmin = role === 'admin';
const isStaff = role === 'admin' || role === 'internal_staff';
return { role, loading, isViewer, isAdmin, isStaff };
}

39
apps/web/src/index.css Normal file
View File

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

View File

@@ -0,0 +1,18 @@
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
}
const schema = import.meta.env.VITE_SUPABASE_SCHEMA || 'natur_reservas';
export const supabase = createClient(
supabaseUrl || 'https://placeholder.supabase.co',
supabaseKey || 'placeholder-key',
{ db: { schema } }
);

14
apps/web/src/main.tsx Normal file
View File

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

View File

@@ -0,0 +1,13 @@
// Re-exportamos desde el shared package — fuente única de verdad para tipos
export type {
Reservation,
NewReservation,
ReservationOrigin,
Property,
PricingSnapshot,
WebhookPayload,
UserRole,
UserProfile,
PricingInput,
PricingResult,
} from '@naturcalabacera/shared';

View File

@@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// Asegurar que los colores slate estén disponibles
slate: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@naturcalabacera/shared": ["../../packages/shared/src/index.ts"],
"@naturcalabacera/shared/*": ["../../packages/shared/src/*"]
}
},
"include": ["src"]
}

7
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

17
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// Resuelve el workspace package directamente desde su src/
'@naturcalabacera/shared': path.resolve(__dirname, '../../packages/shared/src'),
},
},
});