Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)
This commit is contained in:
15
apps/web/.env.example
Normal file
15
apps/web/.env.example
Normal 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
23
apps/web/eslint.config.js
Normal 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
13
apps/web/index.html
Normal 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
42
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
apps/web/public/vite.svg
Normal file
1
apps/web/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="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
42
apps/web/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
275
apps/web/src/App.tsx
Normal file
275
apps/web/src/App.tsx
Normal 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;
|
||||
1
apps/web/src/assets/react.svg
Normal file
1
apps/web/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
508
apps/web/src/components/CalendarGrid.tsx
Normal file
508
apps/web/src/components/CalendarGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
apps/web/src/components/CalendarGrid.tsx.backup
Normal file
222
apps/web/src/components/CalendarGrid.tsx.backup
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
format, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, isSameMonth, addMonths, subMonths,
|
||||
isSameDay, differenceInDays, parseISO, isWithinInterval
|
||||
} from 'date-fns';
|
||||
import { es } from 'date-fns/locale/es';
|
||||
import { ChevronLeft, ChevronRight, Users, Moon, Ban } from 'lucide-react';
|
||||
import type { Reservation } from '../types';
|
||||
|
||||
interface Props {
|
||||
reservations: Reservation[];
|
||||
onSelectDay: (day: Date) => void;
|
||||
onSelectReservation: (reservation: Reservation) => void;
|
||||
}
|
||||
|
||||
export function CalendarGrid({ reservations, onSelectDay, onSelectReservation }: Props) {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(monthStart);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });
|
||||
|
||||
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
|
||||
|
||||
// Organizar en semanas
|
||||
const weeks: Date[][] = [];
|
||||
for (let i = 0; i < calendarDays.length; i += 7) {
|
||||
weeks.push(calendarDays.slice(i, i + 7));
|
||||
}
|
||||
|
||||
const nextMonth = () => setCurrentDate(addMonths(currentDate, 1));
|
||||
const prevMonth = () => setCurrentDate(subMonths(currentDate, 1));
|
||||
|
||||
// Función para verificar si un día está ocupado
|
||||
const isDayOccupied = (day: Date): boolean => {
|
||||
return reservations.some(res => {
|
||||
const startDate = parseISO(res.start_date);
|
||||
const endDate = parseISO(res.end_date);
|
||||
|
||||
// Un día está ocupado si está dentro del rango [start_date, end_date] (ambos inclusive)
|
||||
return isWithinInterval(day, { start: startDate, end: endDate }) ||
|
||||
isSameDay(day, startDate) ||
|
||||
isSameDay(day, endDate);
|
||||
});
|
||||
};
|
||||
|
||||
// Renderizar bloques de reserva
|
||||
const renderReservationBlocks = () => {
|
||||
return reservations.map((res) => {
|
||||
const startDate = parseISO(res.start_date);
|
||||
const endDate = parseISO(res.end_date);
|
||||
|
||||
const dayIndex = calendarDays.findIndex(day => isSameDay(day, startDate));
|
||||
if (dayIndex === -1) return null;
|
||||
|
||||
const weekIndex = Math.floor(dayIndex / 7);
|
||||
const dayOfWeek = dayIndex % 7;
|
||||
|
||||
const duration = differenceInDays(endDate, startDate) + 1;
|
||||
const nights = duration - 1;
|
||||
|
||||
const isTeneriffa = res.origin === 'Teneriffa2000';
|
||||
const gradient = isTeneriffa
|
||||
? 'from-blue-600/90 via-blue-500/90 to-blue-400/90'
|
||||
: 'from-yellow-600/90 via-yellow-500/90 to-yellow-400/90';
|
||||
|
||||
const borderColor = isTeneriffa ? 'border-blue-400' : 'border-yellow-400';
|
||||
const shadowColor = isTeneriffa ? 'shadow-blue-500/50' : 'shadow-yellow-500/50';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={res.id}
|
||||
onClick={() => onSelectReservation(res)}
|
||||
className={`
|
||||
absolute cursor-pointer group
|
||||
bg-gradient-to-r ${gradient} ${borderColor}
|
||||
border-l-4 rounded-2xl p-3
|
||||
hover:scale-105 transition-all duration-300
|
||||
shadow-2xl ${shadowColor}
|
||||
backdrop-blur-xl
|
||||
z-10
|
||||
`}
|
||||
style={{
|
||||
top: `${weekIndex * 100 + 50}px`,
|
||||
left: `${(dayOfWeek * 100 / 7) + 0.75}%`,
|
||||
width: `${Math.min(duration, 7 - dayOfWeek) * (100 / 7) - 1.5}%`,
|
||||
height: '60px'
|
||||
}}
|
||||
>
|
||||
<div className=\"relative z-10\">
|
||||
<div className=\"text-sm font-bold text-white truncate drop-shadow-lg\">{res.client_name}</div>
|
||||
<div className=\"flex items-center gap-3 mt-1\">
|
||||
< div className =\"flex items-center gap-1 text-white/90\">
|
||||
< Moon className =\"w-3 h-3\" />
|
||||
< span className =\"text-[11px] font-semibold\">{nights}n</span>
|
||||
</div >
|
||||
<div className=\"flex items-center gap-1 text-white/90\">
|
||||
< Users className =\"w-3 h-3\" />
|
||||
< span className =\"text-[11px] font-semibold\">{res.adults_count + res.children_count}p</span>
|
||||
</div >
|
||||
</div >
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className=\"flex-1 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 rounded-3xl p-8 shadow-2xl border border-slate-700/50 backdrop-blur-xl\">
|
||||
{/* Header */ }
|
||||
<div className=\"flex items-center justify-between mb-8\">
|
||||
< div >
|
||||
<h2 className=\"text-4xl font-black text-transparent bg-gradient-to-r from-white to-slate-300 bg-clip-text capitalize\">
|
||||
{ format(currentDate, 'MMMM yyyy', { locale: es }) }
|
||||
</h2 >
|
||||
<p className=\"text-sm text-slate-400 mt-1 font-medium\">Vista mensual de reservas</p>
|
||||
</div >
|
||||
<div className=\"flex items-center gap-3\">
|
||||
< button
|
||||
onClick = { prevMonth }
|
||||
className =\"p-3 rounded-xl bg-gradient-to-br from-slate-700 to-slate-800 hover:from-slate-600 hover:to-slate-700 text-slate-200 transition-all duration-300 shadow-lg hover:shadow-xl hover:scale-110 border border-slate-600/50\"
|
||||
>
|
||||
<ChevronLeft className=\"w-5 h-5\" />
|
||||
</button >
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className=\"p-3 rounded-xl bg-gradient-to-br from-slate-700 to-slate-800 hover:from-slate-600 hover:to-slate-700 text-slate-200 transition-all duration-300 shadow-lg hover:shadow-xl hover:scale-110 border border-slate-600/50\"
|
||||
>
|
||||
<ChevronRight className=\"w-5 h-5\" />
|
||||
</button >
|
||||
</div >
|
||||
</div >
|
||||
|
||||
{/* Calendar */ }
|
||||
< div className =\"bg-slate-800/30 backdrop-blur-xl rounded-2xl overflow-hidden border border-slate-700/50 shadow-2xl\">
|
||||
{/* Days header */ }
|
||||
<div className=\"grid grid-cols-7 bg-gradient-to-r from-slate-800/80 to-slate-700/80 border-b border-slate-600/50 backdrop-blur\">
|
||||
{
|
||||
['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map((day) => (
|
||||
<div key={day} className=\"text-center py-4 text-sm font-black text-slate-300 uppercase tracking-widest\">
|
||||
{ day }
|
||||
</div >
|
||||
))
|
||||
}
|
||||
</div >
|
||||
|
||||
{/* Calendar grid */ }
|
||||
< div className =\"relative\">
|
||||
{
|
||||
weeks.map((week, weekIdx) => (
|
||||
<div key={weekIdx} className=\"grid grid-cols-7 border-b border-slate-700/30 last:border-b-0\">
|
||||
{
|
||||
week.map((day) => {
|
||||
const isCurrentMonth = isSameMonth(day, monthStart);
|
||||
const isOccupied = isDayOccupied(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toString()}
|
||||
onClick={() => {
|
||||
if (!isOccupied) {
|
||||
onSelectDay(day);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
relative h-28 p-3 border-r border-slate-700/30 last:border-r-0
|
||||
transition-all duration-300 group
|
||||
${!isCurrentMonth ? 'bg-slate-900/50' : ''}
|
||||
${isOccupied
|
||||
? 'cursor-not-allowed bg-red-900/10'
|
||||
: 'cursor-pointer hover:bg-gradient-to-br hover:from-slate-700/50 hover:to-slate-600/30'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className={`
|
||||
inline-flex items-center justify-center w-8 h-8 rounded-lg text-sm font-bold
|
||||
transition-all duration-300
|
||||
${isCurrentMonth
|
||||
? isOccupied
|
||||
? 'text-red-400/50'
|
||||
: 'text-slate-100 group-hover:bg-white/10 group-hover:scale-110'
|
||||
: 'text-slate-600'
|
||||
}
|
||||
`}>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
|
||||
{/* Indicador visual de día ocupado */}
|
||||
{isOccupied && isCurrentMonth && (
|
||||
<div className=\"absolute top-2 right-2\">
|
||||
<Ban className=\"w-4 h-4 text-red-400/30\" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
);
|
||||
})
|
||||
}
|
||||
</div >
|
||||
))}
|
||||
|
||||
{/* Reservation blocks */ }
|
||||
{ renderReservationBlocks() }
|
||||
</div >
|
||||
</div >
|
||||
|
||||
{/* Legend */ }
|
||||
< div className =\"mt-6 flex items-center gap-8 text-sm text-slate-400\">
|
||||
< div className =\"flex items-center gap-3 px-4 py-2 bg-gradient-to-r from-blue-500/10 to-transparent rounded-xl border border-blue-500/20\">
|
||||
< div className =\"w-4 h-4 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg shadow-blue-500/50\"></div>
|
||||
< span className =\"font-semibold\">Teneriffa2000</span>
|
||||
</div >
|
||||
<div className=\"flex items-center gap-3 px-4 py-2 bg-gradient-to-r from-yellow-500/10 to-transparent rounded-xl border border-yellow-500/20\">
|
||||
< div className =\"w-4 h-4 rounded-lg bg-gradient-to-br from-yellow-500 to-yellow-600 shadow-lg shadow-yellow-500/50\"></div>
|
||||
< span className =\"font-semibold\">Naturcalabacera</span>
|
||||
</div >
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
}
|
||||
591
apps/web/src/components/ChatbotContainer.tsx
Normal file
591
apps/web/src/components/ChatbotContainer.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
234
apps/web/src/components/ContractUpload.tsx
Normal file
234
apps/web/src/components/ContractUpload.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
155
apps/web/src/components/CustomMobileCalendar.tsx
Normal file
155
apps/web/src/components/CustomMobileCalendar.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
format, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, isSameMonth, isSameDay, addMonths, subMonths,
|
||||
isWithinInterval, parseISO, isBefore
|
||||
} from 'date-fns';
|
||||
import { es } from 'date-fns/locale/es';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Reservation } from '../types';
|
||||
|
||||
interface Props {
|
||||
reservations: Reservation[];
|
||||
onSelectRange: (start: Date, end: Date) => void;
|
||||
onSelectReservation: (reservation: Reservation) => void;
|
||||
}
|
||||
|
||||
export function CustomMobileCalendar({ reservations, onSelectRange, onSelectReservation }: Props) {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectionStart, setSelectionStart] = useState<Date | null>(null);
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(monthStart);
|
||||
const startDate = startOfWeek(monthStart, { locale: es });
|
||||
const endDate = endOfWeek(monthEnd, { locale: es });
|
||||
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
|
||||
const nextMonth = () => setCurrentDate(addMonths(currentDate, 1));
|
||||
const prevMonth = () => setCurrentDate(subMonths(currentDate, 1));
|
||||
|
||||
const getReservationForDay = (day: Date) => {
|
||||
return reservations.find(res =>
|
||||
isWithinInterval(day, { start: parseISO(res.start_date), end: parseISO(res.end_date) })
|
||||
);
|
||||
};
|
||||
|
||||
const rangeHasOverlap = (start: Date, end: Date) => {
|
||||
const rangeDays = eachDayOfInterval({ start, end });
|
||||
return rangeDays.some(day => getReservationForDay(day));
|
||||
};
|
||||
|
||||
const handleDayClick = (day: Date) => {
|
||||
const existingRes = getReservationForDay(day);
|
||||
|
||||
if (existingRes) {
|
||||
onSelectReservation(existingRes);
|
||||
setSelectionStart(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectionStart) {
|
||||
setSelectionStart(day);
|
||||
return;
|
||||
}
|
||||
|
||||
let start = selectionStart;
|
||||
let end = day;
|
||||
|
||||
if (isBefore(day, selectionStart)) {
|
||||
start = day;
|
||||
end = selectionStart;
|
||||
}
|
||||
|
||||
if (rangeHasOverlap(start, end)) {
|
||||
toast.error("No puedes seleccionar un rango que incluya días ya reservados.");
|
||||
setSelectionStart(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectRange(start, end);
|
||||
setSelectionStart(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-3xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 bg-white border-b border-gray-100">
|
||||
<h2 className="text-lg font-bold text-gray-900 capitalize">
|
||||
{format(currentDate, 'MMMM yyyy', { locale: es })}
|
||||
</h2>
|
||||
<div className="flex space-x-1">
|
||||
<button onClick={prevMonth} className="p-2 rounded-lg hover:bg-gray-50">
|
||||
<ChevronLeft size={20} className="text-gray-600" />
|
||||
</button>
|
||||
<button onClick={nextMonth} className="p-2 rounded-lg hover:bg-gray-50">
|
||||
<ChevronRight size={20} className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Week Days */}
|
||||
<div className="grid grid-cols-7 px-3 py-2 bg-gray-50">
|
||||
{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(day => (
|
||||
<div key={day} className="text-center text-[11px] font-semibold text-gray-500 uppercase tracking-wide py-1">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days Grid - NUEVO DISEÑO TIPO AIRBNB */}
|
||||
<div className="grid grid-cols-7 gap-0 p-3 bg-white">
|
||||
{days.map((day) => {
|
||||
const dayRes = getReservationForDay(day);
|
||||
const isSelected = selectionStart && isSameDay(day, selectionStart);
|
||||
const isCurrentMonth = isSameMonth(day, monthStart);
|
||||
|
||||
// Palette de colores según referencia
|
||||
let bgColor = 'bg-white';
|
||||
let numberBgColor = '';
|
||||
let numberTextColor = 'text-gray-400';
|
||||
|
||||
if (dayRes) {
|
||||
if (dayRes.origin === 'Teneriffa2000') {
|
||||
bgColor = 'bg-blue-100'; // Fondo azul pastel
|
||||
numberBgColor = 'bg-blue-500'; // Círculo azul intenso
|
||||
numberTextColor = 'text-white';
|
||||
} else {
|
||||
bgColor = 'bg-yellow-100'; // Fondo amarillo pastel
|
||||
numberBgColor = 'bg-yellow-500'; // Círculo amarillo intenso
|
||||
numberTextColor = 'text-white';
|
||||
}
|
||||
} else if (isSelected) {
|
||||
bgColor = 'bg-gray-800';
|
||||
numberBgColor = 'bg-white';
|
||||
numberTextColor = 'text-gray-800';
|
||||
} else if (isCurrentMonth) {
|
||||
numberTextColor = 'text-gray-900';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toString()}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={`
|
||||
relative h-12 flex items-center justify-center cursor-pointer
|
||||
transition-all duration-150
|
||||
${bgColor}
|
||||
`}
|
||||
>
|
||||
<div className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center
|
||||
text-sm font-semibold
|
||||
${numberBgColor}
|
||||
${numberTextColor}
|
||||
`}>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
390
apps/web/src/components/DocumentUpload.tsx
Normal file
390
apps/web/src/components/DocumentUpload.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
108
apps/web/src/components/LoginPage.tsx
Normal file
108
apps/web/src/components/LoginPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { Calendar, Loader2, Lock, Mail, ArrowRight } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error('Error al iniciar sesión: ' + error.message);
|
||||
} else {
|
||||
toast.success('¡Bienvenido de nuevo!');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Ocurrió un error inesperado');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50 dark:bg-black flex items-center justify-center p-4 transition-colors duration-500">
|
||||
<div className="w-full max-w-md bg-white dark:bg-emerald-950/10 backdrop-blur-xl border border-stone-200 dark:border-emerald-900/30 rounded-3xl shadow-2xl shadow-stone-200/50 dark:shadow-emerald-900/10 overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-8 text-center border-b border-stone-100 dark:border-emerald-900/30 bg-gradient-to-b from-stone-50/50 to-transparent dark:from-emerald-900/5">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-emerald-500 to-emerald-700 rounded-2xl mx-auto flex items-center justify-center shadow-lg shadow-emerald-500/30 mb-6 transform rotate-3 hover:rotate-6 transition-transform duration-300">
|
||||
<Calendar className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-stone-900 dark:text-white mb-2 tracking-tight">
|
||||
Naturcalabacera
|
||||
</h1>
|
||||
<p className="text-stone-500 dark:text-emerald-400/60 font-medium">
|
||||
Sistema de Gestión de Reservas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleLogin} className="p-8 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-stone-400 dark:text-emerald-500/60 uppercase tracking-wider ml-1">Email Corporativo</label>
|
||||
<div className="relative group">
|
||||
<Mail className="absolute left-4 top-3.5 w-5 h-5 text-stone-400 dark:text-emerald-600/60 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-stone-50 dark:bg-black/40 border border-stone-200 dark:border-emerald-900/30 rounded-xl py-3 pl-12 pr-4 text-stone-900 dark:text-white placeholder:text-stone-400 dark:placeholder:text-emerald-800/40 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all font-medium"
|
||||
placeholder="nombre@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-stone-400 dark:text-emerald-500/60 uppercase tracking-wider ml-1">Contraseña</label>
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-4 top-3.5 w-5 h-5 text-stone-400 dark:text-emerald-600/60 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-stone-50 dark:bg-black/40 border border-stone-200 dark:border-emerald-900/30 rounded-xl py-3 pl-12 pr-4 text-stone-900 dark:text-white placeholder:text-stone-400 dark:placeholder:text-emerald-800/40 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all font-medium"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 text-white font-bold py-4 rounded-xl shadow-xl shadow-emerald-500/20 hover:shadow-emerald-500/40 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 flex items-center justify-center gap-2 group disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Iniciar Sesión
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 bg-stone-50/50 dark:bg-emerald-950/20 border-t border-stone-100 dark:border-emerald-900/30 text-center">
|
||||
<p className="text-xs text-stone-400 dark:text-emerald-600/40 font-medium">
|
||||
Protegido por autenticación segura
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/web/src/components/MobileNavigation.tsx
Normal file
89
apps/web/src/components/MobileNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/web/src/components/PricingSection.tsx
Normal file
87
apps/web/src/components/PricingSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/components/PropertySelector.tsx
Normal file
43
apps/web/src/components/PropertySelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
628
apps/web/src/components/ReservationModal.tsx
Normal file
628
apps/web/src/components/ReservationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/web/src/components/SearchBar.tsx
Normal file
30
apps/web/src/components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/web/src/components/ServiceIcons.tsx
Normal file
46
apps/web/src/components/ServiceIcons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
apps/web/src/components/SettingsPage.tsx
Normal file
156
apps/web/src/components/SettingsPage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Save, Moon, Bell, LogOut, User } from 'lucide-react';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { user, signOut } = useAuth();
|
||||
const [darkMode, setDarkMode] = useState(true);
|
||||
const [soundEnabled, setSoundEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Initialize state from local storage or system preference
|
||||
useEffect(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
setDarkMode(isDark);
|
||||
|
||||
const storedSound = localStorage.getItem('soundEnabled') === 'true';
|
||||
setSoundEnabled(storedSound || false);
|
||||
}, []);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
const newMode = !darkMode;
|
||||
setDarkMode(newMode);
|
||||
if (newMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSound = () => {
|
||||
const newSound = !soundEnabled;
|
||||
setSoundEnabled(newSound);
|
||||
localStorage.setItem('soundEnabled', String(newSound));
|
||||
if (newSound) {
|
||||
toast.success("Sonido activado (simulado)");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await signOut();
|
||||
toast.success("Has cerrado sesión");
|
||||
} catch (error) {
|
||||
console.error("Error signing out:", error);
|
||||
toast.error("Error al cerrar sesión");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
toast.success("Preferencias guardadas correctamente");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-black text-stone-900 dark:text-white tracking-tight mb-2">Ajustes</h1>
|
||||
<p className="text-stone-500 dark:text-emerald-400/60 text-lg">Configura el comportamiento y apariencia de la aplicación.</p>
|
||||
</header>
|
||||
|
||||
{/* 1. Apariencia y Notificaciones */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-emerald-900/10 backdrop-blur-xl border border-stone-200 dark:border-emerald-800/30 rounded-3xl p-8 shadow-xl shadow-stone-200/50 dark:shadow-black/20">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="p-3 bg-purple-500/10 rounded-2xl">
|
||||
<Moon className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-stone-900 dark:text-white">Apariencia</h2>
|
||||
<p className="text-stone-500 dark:text-emerald-400/60 text-sm">Personaliza la interfaz</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-between p-4 bg-stone-100 dark:bg-black/40 rounded-2xl border border-stone-200 dark:border-emerald-800/30 cursor-pointer hover:border-emerald-500/50 transition-colors"
|
||||
onClick={toggleDarkMode}
|
||||
>
|
||||
<span className="font-medium text-stone-700 dark:text-stone-200">Modo Oscuro</span>
|
||||
<div className={`w-12 h-6 rounded-full relative transition-colors duration-300 ${darkMode ? 'bg-emerald-500' : 'bg-stone-300'}`}>
|
||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow-md transition-all duration-300 ${darkMode ? 'right-1' : 'left-1'}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-emerald-900/10 backdrop-blur-xl border border-stone-200 dark:border-emerald-800/30 rounded-3xl p-8 shadow-xl shadow-stone-200/50 dark:shadow-black/20">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="p-3 bg-amber-500/10 rounded-2xl">
|
||||
<Bell className="w-6 h-6 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-stone-900 dark:text-white">Notificaciones</h2>
|
||||
<p className="text-stone-500 dark:text-emerald-400/60 text-sm">Alertas y sonidos</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-between p-4 bg-stone-100 dark:bg-black/40 rounded-2xl border border-stone-200 dark:border-emerald-800/30 cursor-pointer hover:border-emerald-500/50 transition-colors"
|
||||
onClick={toggleSound}
|
||||
>
|
||||
<span className="font-medium text-stone-700 dark:text-stone-200">Sonido de Toasts</span>
|
||||
<div className={`w-12 h-6 rounded-full relative transition-colors duration-300 ${soundEnabled ? 'bg-amber-500' : 'bg-stone-300'}`}>
|
||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow-md transition-all duration-300 ${soundEnabled ? 'right-1' : 'left-1'}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 2. Zona de Usuario / Sesión */}
|
||||
<section className="bg-white dark:bg-emerald-900/10 backdrop-blur-xl border border-stone-200 dark:border-emerald-800/30 rounded-3xl p-8 shadow-xl shadow-stone-200/50 dark:shadow-black/20">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="p-3 bg-red-500/10 rounded-2xl">
|
||||
<User className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-stone-900 dark:text-white">Cuenta</h2>
|
||||
<p className="text-stone-500 dark:text-emerald-400/60 text-sm">Gestionar la sesión actual</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-500 to-amber-500 flex items-center justify-center font-bold text-white shadow-lg shadow-emerald-500/20">
|
||||
{user?.email?.[0].toUpperCase() || 'U'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-stone-900 dark:text-white font-bold">Usuario Identificado</p>
|
||||
<p className="text-stone-500 dark:text-stone-400 text-xs">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 rounded-xl transition-all border border-red-200 dark:border-red-500/20 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
{loading ? 'Saliendo...' : 'Cerrar Sesión'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-8 py-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-2xl shadow-xl shadow-emerald-500/20 hover:scale-105 active:scale-95 transition-all"
|
||||
>
|
||||
<Save size={20} />
|
||||
Guardar Cambios
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
apps/web/src/components/Sidebar.tsx
Normal file
104
apps/web/src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
337
apps/web/src/components/UserManagement.tsx
Normal file
337
apps/web/src/components/UserManagement.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
283
apps/web/src/components/YearlyCalendar.tsx
Normal file
283
apps/web/src/components/YearlyCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/web/src/contexts/PropertyContext.tsx
Normal file
47
apps/web/src/contexts/PropertyContext.tsx
Normal 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;
|
||||
}
|
||||
28
apps/web/src/contexts/UserRoleContext.tsx
Normal file
28
apps/web/src/contexts/UserRoleContext.tsx
Normal 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);
|
||||
}
|
||||
38
apps/web/src/hooks/useAuth.ts
Normal file
38
apps/web/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
|
||||
export function useAuth() {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 1. Get initial session
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// 2. Listen for changes
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const signOut = async () => {
|
||||
await supabase.auth.signOut();
|
||||
};
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
loading,
|
||||
signOut
|
||||
};
|
||||
}
|
||||
137
apps/web/src/hooks/useFileUpload.ts
Normal file
137
apps/web/src/hooks/useFileUpload.ts
Normal 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 };
|
||||
}
|
||||
119
apps/web/src/hooks/usePropertyTheme.ts
Normal file
119
apps/web/src/hooks/usePropertyTheme.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
103
apps/web/src/hooks/useReservations.ts
Normal file
103
apps/web/src/hooks/useReservations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
38
apps/web/src/hooks/useUserRole.ts
Normal file
38
apps/web/src/hooks/useUserRole.ts
Normal 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
39
apps/web/src/index.css
Normal file
@@ -0,0 +1,39 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-teneriffa: #60a5fa;
|
||||
/* Keep brand colors for now, or shift if requested */
|
||||
--color-natur: #facc15;
|
||||
}
|
||||
|
||||
/* Default to Light Mode */
|
||||
html,
|
||||
body {
|
||||
background-color: #fafaf9;
|
||||
/* stone-50 */
|
||||
color: #1c1917;
|
||||
/* stone-900 */
|
||||
}
|
||||
|
||||
/* Dark Mode Overrides */
|
||||
.dark html,
|
||||
.dark body {
|
||||
background-color: #020617;
|
||||
/* slate-950 / black */
|
||||
color: #f1f5f9;
|
||||
/* slate-100 */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
@apply bg-stone-50 dark:bg-black transition-colors duration-500;
|
||||
}
|
||||
}
|
||||
18
apps/web/src/lib/supabase.ts
Normal file
18
apps/web/src/lib/supabase.ts
Normal 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
14
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
console.log("Main.tsx: App version starting");
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
console.log("Main.tsx: Wrapped App in StrictMode and rendered");
|
||||
|
||||
13
apps/web/src/types/index.ts
Normal file
13
apps/web/src/types/index.ts
Normal 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';
|
||||
29
apps/web/tailwind.config.js
Normal file
29
apps/web/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
32
apps/web/tsconfig.app.json
Normal file
32
apps/web/tsconfig.app.json
Normal 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
7
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
apps/web/tsconfig.node.json
Normal file
26
apps/web/tsconfig.node.json
Normal 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
17
apps/web/vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user