Files
Condopay/components/Layout.tsx
frakarr 2a6da489aa feat: Refactor API services and UI components
This commit refactors the API service to use a consistent `fetch` wrapper for all requests, improving error handling and authorization logic. It also updates UI components to reflect changes in API endpoints and data structures, particularly around notifications and extraordinary expenses. Docker configurations are removed as they are no longer relevant for this stage of development.
2025-12-09 23:12:47 +01:00

317 lines
15 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { Users, Settings, Building, LogOut, Menu, X, ChevronDown, Check, LayoutDashboard, Megaphone, Info, AlertTriangle, Hammer, Calendar, MessageSquareWarning, PieChart, Briefcase } from 'lucide-react';
import { CondoService } from '../services/mockDb';
import { Condo, Notice, AppSettings } from '../types';
export const Layout: React.FC = () => {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const user = CondoService.getCurrentUser();
const isAdmin = user?.role === 'admin' || user?.role === 'poweruser';
const [condos, setCondos] = useState<Condo[]>([]);
const [activeCondo, setActiveCondo] = useState<Condo | undefined>(undefined);
const [showCondoDropdown, setShowCondoDropdown] = useState(false);
const [settings, setSettings] = useState<AppSettings | null>(null);
// Notifications
const [activeNotice, setActiveNotice] = useState<Notice | null>(null);
const [hasNewExpenses, setHasNewExpenses] = useState(false);
const fetchContext = async () => {
// Fetch global settings to check features
try {
const globalSettings = await CondoService.getSettings();
setSettings(globalSettings);
if (isAdmin && globalSettings.features.multiCondo) {
const list = await CondoService.getCondos();
setCondos(list);
} else if (isAdmin) {
const list = await CondoService.getCondos();
setCondos(list);
}
} catch(e) { console.error("Error fetching settings", e); }
const active = await CondoService.getActiveCondo();
setActiveCondo(active);
// Check for notices & expenses for User
if (!isAdmin && active && user) {
try {
// 1. Check Notices
const unread = await CondoService.getUnreadNoticesForUser(user.id, active.id);
if (unread.length > 0) {
setActiveNotice(unread[0]);
}
// 2. Check New Extraordinary Expenses
const myExpenses = await CondoService.getMyExpenses();
const lastViewed = localStorage.getItem('lastViewedExpensesTime');
const lastViewedTime = lastViewed ? parseInt(lastViewed) : 0;
// Check if any expense was created AFTER the last visit
const hasNew = myExpenses.some((e: any) => new Date(e.createdAt).getTime() > lastViewedTime);
setHasNewExpenses(hasNew);
} catch(e) {}
}
};
useEffect(() => {
fetchContext();
// Listen for updates from Settings or Expense views
const handleUpdate = () => fetchContext();
window.addEventListener('condo-updated', handleUpdate);
window.addEventListener('expenses-viewed', handleUpdate); // Listen for manual trigger when user views page
return () => {
window.removeEventListener('condo-updated', handleUpdate);
window.removeEventListener('expenses-viewed', handleUpdate);
};
}, [isAdmin]);
const handleCondoSwitch = (condoId: string) => {
CondoService.setActiveCondo(condoId);
setShowCondoDropdown(false);
};
const handleReadNotice = async () => {
if (activeNotice && user) {
await CondoService.markNoticeAsRead(activeNotice.id, user.id);
setActiveNotice(null);
}
};
const closeNoticeModal = () => setActiveNotice(null);
const navClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 ${
isActive
? 'bg-blue-600 text-white shadow-md'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-100'
}`;
const closeMenu = () => setIsMobileMenuOpen(false);
const NoticeIcon = ({type}: {type: string}) => {
switch(type) {
case 'warning': return <AlertTriangle className="w-8 h-8 text-amber-500" />;
case 'maintenance': return <Hammer className="w-8 h-8 text-orange-500" />;
case 'event': return <Calendar className="w-8 h-8 text-purple-500" />;
default: return <Info className="w-8 h-8 text-blue-500" />;
}
};
// Check if notices are actually enabled before showing modal
const showNotice = activeNotice && settings?.features.notices;
return (
<div className="flex h-screen bg-slate-50 overflow-hidden">
{/* Active Notice Modal */}
{showNotice && activeNotice && (
<div className="fixed inset-0 bg-black/60 z-[100] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in duration-300">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden transform transition-all scale-100">
<div className={`p-6 ${activeNotice.type === 'warning' ? 'bg-amber-50' : 'bg-blue-50'} border-b border-slate-100 flex items-start gap-4`}>
<div className="p-3 bg-white rounded-full shadow-sm">
<NoticeIcon type={activeNotice.type} />
</div>
<div>
<h3 className="text-xl font-bold text-slate-800 leading-tight">{activeNotice.title}</h3>
<p className="text-sm text-slate-500 mt-1">{new Date(activeNotice.date).toLocaleDateString()}</p>
</div>
</div>
<div className="p-6 text-slate-600 leading-relaxed whitespace-pre-wrap">
{activeNotice.content}
{activeNotice.link && (
<div className="mt-4">
<a href={activeNotice.link} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline font-medium hover:text-blue-800">
Apri Documento / Link
</a>
</div>
)}
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100 flex gap-3">
<button onClick={closeNoticeModal} className="flex-1 py-2.5 px-4 text-slate-600 font-medium hover:bg-slate-200 rounded-lg transition-colors">Chiudi</button>
<button onClick={handleReadNotice} className="flex-1 py-2.5 px-4 bg-blue-600 text-white font-bold rounded-lg hover:bg-blue-700 shadow-md transition-colors flex items-center justify-center gap-2">
<Check className="w-5 h-5" /> Letto
</button>
</div>
</div>
</div>
)}
{/* Mobile Header */}
<div className="lg:hidden fixed top-0 left-0 right-0 h-16 bg-white border-b border-slate-200 flex items-center justify-between px-4 z-40 shadow-sm">
<div className="flex items-center gap-2 overflow-hidden">
<div className="bg-blue-600 p-1.5 rounded-lg flex-shrink-0">
<Building className="text-white w-5 h-5" />
</div>
<div className="flex flex-col min-w-0">
<h1 className="font-bold text-slate-800 leading-tight truncate">CondoPay</h1>
{activeCondo && <p className="text-xs text-slate-500 truncate">{activeCondo.name}</p>}
</div>
</div>
<button onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} className="p-2 text-slate-600 focus:outline-none">
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
{/* Sidebar Overlay for Mobile */}
{isMobileMenuOpen && (
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden backdrop-blur-sm" onClick={closeMenu}></div>
)}
{/* Sidebar Navigation */}
<aside className={`
fixed top-0 left-0 bottom-0 w-72 bg-white border-r border-slate-200 flex flex-col shadow-xl z-50 transform transition-transform duration-300 ease-in-out
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0 lg:static lg:shadow-none lg:z-auto
`}>
{/* Desktop Logo & Condo Switcher */}
<div className="p-6 hidden lg:flex flex-col gap-4 border-b border-slate-100">
<div className="flex items-center gap-3">
<div className="bg-blue-600 p-2 rounded-lg">
<Building className="text-white w-6 h-6" />
</div>
<h1 className="font-bold text-xl text-slate-800 tracking-tight">CondoPay</h1>
</div>
{/* Condo Switcher (Admin Only & MultiCondo Enabled) */}
{isAdmin && settings?.features.multiCondo && (
<div className="relative mt-2">
<div className="flex items-center gap-1.5 mb-2 text-xs font-bold text-slate-400 uppercase tracking-wider">
<LayoutDashboard className="w-3 h-3" />
Condomini
</div>
<button
onClick={() => setShowCondoDropdown(!showCondoDropdown)}
className="w-full flex items-center justify-between px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm text-slate-700 hover:border-blue-300 transition-colors"
>
<span className="font-medium truncate">{activeCondo?.name || 'Seleziona Condominio'}</span>
<ChevronDown className="w-4 h-4 text-slate-400" />
</button>
{showCondoDropdown && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-slate-200 rounded-lg shadow-xl z-50 max-h-60 overflow-y-auto">
{condos.map(c => (
<button
key={c.id}
onClick={() => handleCondoSwitch(c.id)}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-50 flex items-center justify-between group"
>
<span className="truncate text-slate-700 group-hover:text-blue-700">{c.name}</span>
{c.id === activeCondo?.id && <Check className="w-3 h-3 text-blue-600" />}
</button>
))}
</div>
)}
</div>
)}
{/* Static info if not multi-condo or not admin */}
{(!isAdmin || (isAdmin && !settings?.features.multiCondo)) && activeCondo && (
<div className="px-3 py-2 bg-slate-50 border border-slate-100 rounded-lg text-sm text-slate-500 truncate">
{activeCondo.name}
</div>
)}
</div>
{/* Mobile Header inside drawer */}
<div className="lg:hidden p-4 flex items-center justify-between border-b border-slate-100 h-16">
<span className="font-bold text-slate-700 text-lg">Menu</span>
<button onClick={closeMenu} className="p-1 rounded-md hover:bg-slate-100"><X className="w-6 h-6 text-slate-500"/></button>
</div>
{/* Mobile Condo Switcher */}
{isAdmin && settings?.features.multiCondo && (
<div className="lg:hidden px-4 py-2 border-b border-slate-100">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-2">
<LayoutDashboard className="w-3 h-3" />
Condominio Attivo
</p>
<select
value={activeCondo?.id || ''}
onChange={(e) => handleCondoSwitch(e.target.value)}
className="w-full p-2 bg-slate-50 border border-slate-200 rounded-lg text-sm text-slate-700 font-medium outline-none"
>
{condos.map(c => <option key={c.id} value={c.id} className="text-slate-800">{c.name}</option>)}
</select>
</div>
)}
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
<div className="lg:hidden mb-4 px-2 pt-2">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Navigazione</p>
</div>
<NavLink to="/" className={navClass} onClick={closeMenu}>
<Users className="w-5 h-5" />
<span className="font-medium">Famiglie</span>
</NavLink>
{/* New Extraordinary Expenses Link - Conditional */}
{settings?.features.extraordinaryExpenses && (
<NavLink to="/extraordinary" className={navClass} onClick={closeMenu}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<Briefcase className="w-5 h-5" />
<span className="font-medium">{isAdmin ? 'Spese Straordinarie' : 'Le Mie Spese Extra'}</span>
</div>
{hasNewExpenses && (
<span className="bg-red-500 w-2.5 h-2.5 rounded-full animate-pulse"></span>
)}
</div>
</NavLink>
)}
{/* Privileged Links */}
{isAdmin && settings?.features.reports && (
<NavLink to="/reports" className={navClass} onClick={closeMenu}>
<PieChart className="w-5 h-5" />
<span className="font-medium">Reportistica</span>
</NavLink>
)}
{/* Hide Tickets if disabled */}
{settings?.features.tickets && (
<NavLink to="/tickets" className={navClass} onClick={closeMenu}>
<MessageSquareWarning className="w-5 h-5" />
<span className="font-medium">Segnalazioni</span>
</NavLink>
)}
<NavLink to="/settings" className={navClass} onClick={closeMenu}>
<Settings className="w-5 h-5" />
<span className="font-medium">Impostazioni</span>
</NavLink>
</nav>
<div className="p-4 border-t border-slate-100 bg-slate-50/50">
<div className="mb-4 px-2">
<p className="text-sm font-bold text-slate-800 truncate">{user?.name || 'Utente'}</p>
<p className="text-xs text-slate-500 truncate">{user?.email}</p>
<div className="mt-1 inline-block px-2 py-0.5 rounded text-[10px] font-bold bg-slate-200 text-slate-600 uppercase">
{user?.role || 'User'}
</div>
</div>
<button
onClick={() => CondoService.logout()}
className="flex-1 flex items-center gap-3 px-4 py-2.5 w-full text-slate-600 hover:bg-red-50 hover:text-red-600 rounded-lg transition-colors text-sm font-medium border border-slate-200 hover:border-red-200 bg-white"
>
<LogOut className="w-4 h-4" />
Esci
</button>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-slate-50 pt-16 lg:pt-0 w-full">
<div className="max-w-7xl mx-auto p-4 md:p-8 w-full">
<Outlet />
</div>
</main>
</div>
);
};