393 lines
19 KiB
TypeScript
393 lines
19 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, ReceiptEuro, FileText } 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();
|
|
|
|
// Logic: "isPrivileged" includes Admin AND PowerUser.
|
|
// This allows PowerUsers to see Reports and other admin-like features.
|
|
const isPrivileged = 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 [newExpensesCount, setNewExpensesCount] = useState(0);
|
|
|
|
// Ticket Badges
|
|
const [ticketBadgeCount, setTicketBadgeCount] = useState(0);
|
|
|
|
const fetchContext = async () => {
|
|
// Fetch global settings to check features
|
|
try {
|
|
const globalSettings = await CondoService.getSettings();
|
|
setSettings(globalSettings);
|
|
|
|
if (isPrivileged && globalSettings.features.multiCondo) {
|
|
const list = await CondoService.getCondos();
|
|
setCondos(list);
|
|
} else if (isPrivileged) {
|
|
const list = await CondoService.getCondos();
|
|
setCondos(list);
|
|
}
|
|
} catch(e) { console.error("Error fetching settings", e); }
|
|
|
|
const active = await CondoService.getActiveCondo();
|
|
setActiveCondo(active);
|
|
|
|
// --- NOTIFICATION LOGIC ---
|
|
const lastViewedTicketsStr = localStorage.getItem('lastViewedTickets');
|
|
const lastViewedTickets = lastViewedTicketsStr ? parseInt(lastViewedTicketsStr) : 0;
|
|
|
|
// 1. Tickets Badge Logic
|
|
try {
|
|
if (settings?.features.tickets || true) { // Check features if available or default
|
|
const tickets = await CondoService.getTickets();
|
|
let count = 0;
|
|
|
|
for (const t of tickets) {
|
|
const ticketDate = new Date(t.createdAt).getTime();
|
|
const isTicketNew = ticketDate > lastViewedTickets;
|
|
const isArchived = t.status === 'RESOLVED' || t.status === 'CLOSED';
|
|
|
|
if (isPrivileged) {
|
|
// Admin/PowerUser: Count new unarchived tickets OR tickets with new comments from users
|
|
if (isTicketNew && !isArchived) {
|
|
count++;
|
|
} else {
|
|
const updatedDate = new Date(t.updatedAt).getTime();
|
|
if (updatedDate > lastViewedTickets) {
|
|
const comments = await CondoService.getTicketComments(t.id);
|
|
const hasNewUserReply = comments.some(c => new Date(c.createdAt).getTime() > lastViewedTickets && c.userId !== user?.id);
|
|
if (hasNewUserReply) count++;
|
|
}
|
|
}
|
|
} else {
|
|
// User: Count tickets with new comments from Admin (or others)
|
|
const updatedDate = new Date(t.updatedAt).getTime();
|
|
if (updatedDate > lastViewedTickets) {
|
|
const comments = await CondoService.getTicketComments(t.id);
|
|
const hasNewReply = comments.some(c => new Date(c.createdAt).getTime() > lastViewedTickets && c.userId !== user?.id);
|
|
if (hasNewReply) count++;
|
|
}
|
|
}
|
|
}
|
|
setTicketBadgeCount(count);
|
|
}
|
|
} catch(e) { console.error("Error calc ticket badges", e); }
|
|
|
|
|
|
// Check for notices & expenses for User (non-privileged mostly, but logic works for all if needed)
|
|
if (!isPrivileged && active && user) {
|
|
try {
|
|
// 2. Check Notices
|
|
const unread = await CondoService.getUnreadNoticesForUser(user.id, active.id);
|
|
if (unread.length > 0) {
|
|
setActiveNotice(unread[0]);
|
|
}
|
|
|
|
// 3. Check New Extraordinary Expenses
|
|
const myExpenses = await CondoService.getMyExpenses();
|
|
const lastViewed = localStorage.getItem('lastViewedExpensesTime');
|
|
const lastViewedTime = lastViewed ? parseInt(lastViewed) : 0;
|
|
|
|
// Count expenses created AFTER the last visit
|
|
const count = myExpenses.filter((e: any) => new Date(e.createdAt).getTime() > lastViewedTime).length;
|
|
setNewExpensesCount(count);
|
|
|
|
} 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);
|
|
window.addEventListener('tickets-viewed', handleUpdate); // Listen for ticket view
|
|
return () => {
|
|
window.removeEventListener('condo-updated', handleUpdate);
|
|
window.removeEventListener('expenses-viewed', handleUpdate);
|
|
window.removeEventListener('tickets-viewed', handleUpdate);
|
|
};
|
|
}, [isPrivileged]);
|
|
|
|
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 break-words">
|
|
{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={handleReadNotice} className="w-full 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" /> Conferma Lettura
|
|
</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 (Privileged Only & MultiCondo Enabled) */}
|
|
{isPrivileged && 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 privileged */}
|
|
{(!isPrivileged || (isPrivileged && !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 */}
|
|
{isPrivileged && 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">{isPrivileged ? 'Famiglie' : 'La mia famiglia'}</span>
|
|
</NavLink>
|
|
|
|
{/* Documents Link - NEW */}
|
|
{(settings?.features.documents) && (
|
|
<NavLink to="/documents" className={navClass} onClick={closeMenu}>
|
|
<FileText className="w-5 h-5" />
|
|
<span className="font-medium">Documenti</span>
|
|
</NavLink>
|
|
)}
|
|
|
|
{/* Condo Financials - Admin/PowerUser or Enabled User */}
|
|
{(isPrivileged || settings?.features.condoFinancialsView) && (
|
|
<NavLink to="/financials" className={navClass} onClick={closeMenu}>
|
|
<ReceiptEuro className="w-5 h-5" />
|
|
<span className="font-medium">Spese Condominio</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">{isPrivileged ? 'Spese Straordinarie' : 'Le Mie Spese Extra'}</span>
|
|
</div>
|
|
{newExpensesCount > 0 && (
|
|
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 h-5 min-w-[20px] rounded-full flex items-center justify-center shadow-sm">
|
|
{newExpensesCount > 99 ? '99+' : newExpensesCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</NavLink>
|
|
)}
|
|
|
|
{/* Privileged Links (Admin & PowerUser) */}
|
|
{isPrivileged && settings?.features.reports && (
|
|
<NavLink to="/reports" className={navClass} onClick={closeMenu}>
|
|
<PieChart className="w-5 h-5" />
|
|
<span className="font-medium">Bilancio</span>
|
|
</NavLink>
|
|
)}
|
|
|
|
{/* Hide Tickets if disabled */}
|
|
{settings?.features.tickets && (
|
|
<NavLink to="/tickets" className={navClass} onClick={closeMenu}>
|
|
<div className="flex items-center justify-between w-full">
|
|
<div className="flex items-center gap-3">
|
|
<MessageSquareWarning className="w-5 h-5" />
|
|
<span className="font-medium">Segnalazioni</span>
|
|
</div>
|
|
{ticketBadgeCount > 0 && (
|
|
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 h-5 min-w-[20px] rounded-full flex items-center justify-center shadow-sm">
|
|
{ticketBadgeCount > 99 ? '99+' : ticketBadgeCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</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>
|
|
);
|
|
};
|