465 lines
25 KiB
TypeScript
465 lines
25 KiB
TypeScript
|
|
import React, { useEffect, useState } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
import { CondoService } from '../services/mockDb';
|
|
import { Family, Condo, Notice, AppSettings, Ticket, TicketStatus } from '../types';
|
|
import { Search, ChevronRight, UserCircle, Building, Bell, AlertTriangle, Hammer, Calendar, Info, Link as LinkIcon, Check, Wallet, Briefcase, MessageSquareWarning, ArrowRight, CheckCircle2, ChevronDown, ChevronUp, Eye, Inbox } from 'lucide-react';
|
|
|
|
export const FamilyList: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const [families, setFamilies] = useState<Family[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [activeCondo, setActiveCondo] = useState<Condo | undefined>(undefined);
|
|
const [notices, setNotices] = useState<Notice[]>([]);
|
|
const [userReadIds, setUserReadIds] = useState<string[]>([]);
|
|
const [settings, setSettings] = useState<AppSettings | null>(null);
|
|
|
|
// User Dashboard Data
|
|
const [myTickets, setMyTickets] = useState<Ticket[]>([]);
|
|
const [myExtraExpenses, setMyExtraExpenses] = useState<any[]>([]);
|
|
const [myFamily, setMyFamily] = useState<Family | null>(null);
|
|
|
|
// Payment Status State
|
|
const [regularPaymentStatus, setRegularPaymentStatus] = useState<'OK' | 'PENDING' | 'OVERDUE'>('OK');
|
|
const [regularDebtAmount, setRegularDebtAmount] = useState<number>(0);
|
|
|
|
// UI State for Notices
|
|
const [expandedNoticeId, setExpandedNoticeId] = useState<string | null>(null);
|
|
|
|
const currentUser = CondoService.getCurrentUser();
|
|
const isPrivileged = currentUser?.role === 'admin' || currentUser?.role === 'poweruser';
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
CondoService.seedPayments();
|
|
|
|
// 1. Fetch Global Settings & Active Condo FIRST
|
|
const [appSettings, condo] = await Promise.all([
|
|
CondoService.getSettings(),
|
|
CondoService.getActiveCondo()
|
|
]);
|
|
|
|
setSettings(appSettings);
|
|
setActiveCondo(condo);
|
|
|
|
if (!condo) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// 2. Fetch specific data using condo.id (prevents race conditions)
|
|
const [fams, allNotices] = await Promise.all([
|
|
CondoService.getFamilies(condo.id),
|
|
CondoService.getNotices(condo.id)
|
|
]);
|
|
setFamilies(fams);
|
|
|
|
// --- USER SPECIFIC DASHBOARD DATA ---
|
|
if (currentUser && !isPrivileged && currentUser.familyId) {
|
|
// 1. Find My Family
|
|
const me = fams.find(f => f.id === currentUser.familyId) || null;
|
|
setMyFamily(me);
|
|
|
|
// 2. Fetch Tickets
|
|
const tickets = await CondoService.getTickets(); // Backend filters for user
|
|
setMyTickets(tickets);
|
|
|
|
// 3. Fetch Extra Expenses
|
|
if (appSettings.features.extraordinaryExpenses) {
|
|
const extra = await CondoService.getMyExpenses();
|
|
setMyExtraExpenses(extra);
|
|
}
|
|
|
|
// 4. Calculate Regular Payment Status
|
|
const payments = await CondoService.getPaymentsByFamily(currentUser.familyId);
|
|
const currentYear = appSettings.currentYear;
|
|
const now = new Date();
|
|
const currentRealMonth = now.getMonth() + 1; // 1-12
|
|
const currentDay = now.getDate();
|
|
const dueDay = condo.dueDay || 10;
|
|
const quota = me?.customMonthlyQuota ?? condo.defaultMonthlyQuota;
|
|
|
|
let totalDebt = 0;
|
|
let status: 'OK' | 'PENDING' | 'OVERDUE' = 'OK';
|
|
|
|
// Check previous months first (Always Overdue if unpaid)
|
|
for (let m = 1; m < currentRealMonth; m++) {
|
|
const isPaid = payments.some(p => p.forMonth === m && p.forYear === currentYear);
|
|
if (!isPaid) {
|
|
totalDebt += quota;
|
|
status = 'OVERDUE';
|
|
}
|
|
}
|
|
|
|
// Check current month
|
|
const isCurrentMonthPaid = payments.some(p => p.forMonth === currentRealMonth && p.forYear === currentYear);
|
|
if (!isCurrentMonthPaid) {
|
|
if (currentDay > dueDay) {
|
|
totalDebt += quota;
|
|
status = 'OVERDUE';
|
|
} else if (currentDay >= (dueDay - 10)) {
|
|
totalDebt += quota;
|
|
if (status !== 'OVERDUE') status = 'PENDING';
|
|
}
|
|
}
|
|
|
|
setRegularDebtAmount(totalDebt);
|
|
setRegularPaymentStatus(status);
|
|
}
|
|
|
|
// --- NOTICE LOGIC ---
|
|
if (currentUser && appSettings.features.notices) {
|
|
// Filter Visibility: Admin sees all. User sees Public OR Targeted.
|
|
// Note: We removed the condoId check here because the API returns snake_case (condo_id)
|
|
// but we fetch by ID anyway, so we trust the API context.
|
|
const relevantNotices = allNotices.filter(n => {
|
|
if (!n.active) return false; // Filter inactive notices
|
|
|
|
if (isPrivileged) return true;
|
|
|
|
// Check visibility for regular users
|
|
// Handle case where targetFamilyIds might be null/undefined from backend
|
|
const targets = n.targetFamilyIds || [];
|
|
const isPublic = targets.length === 0;
|
|
const isTargeted = currentUser.familyId && targets.includes(currentUser.familyId);
|
|
|
|
return isPublic || isTargeted;
|
|
});
|
|
|
|
// Sort: Newest first
|
|
relevantNotices.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
setNotices(relevantNotices);
|
|
|
|
// Check read status for current user
|
|
// We use getUnreadNoticesForUser to find what is NOT read, then derive read IDs.
|
|
// This avoids using the Admin-only 'getNoticeReadStatus' endpoint.
|
|
try {
|
|
if (currentUser.id && condo.id) {
|
|
const unreadList = await CondoService.getUnreadNoticesForUser(currentUser.id, condo.id);
|
|
const unreadIds = new Set(unreadList.map(n => n.id));
|
|
|
|
// If it's in relevantNotices but NOT in unreadList, it's read.
|
|
const readIds = relevantNotices
|
|
.filter(n => !unreadIds.has(n.id))
|
|
.map(n => n.id);
|
|
|
|
setUserReadIds(readIds);
|
|
}
|
|
} catch (e) {
|
|
console.warn("Error fetching unread status", e);
|
|
}
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error("Error fetching data", e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchData();
|
|
}, [currentUser?.id, isPrivileged]);
|
|
|
|
const handleMarkAsRead = async (noticeId: string) => {
|
|
if (!currentUser) return;
|
|
try {
|
|
await CondoService.markNoticeAsRead(noticeId, currentUser.id);
|
|
setUserReadIds(prev => [...prev, noticeId]);
|
|
} catch (e) { console.error("Error marking read", e); }
|
|
};
|
|
|
|
const toggleExpandNotice = (id: string) => {
|
|
setExpandedNoticeId(expandedNoticeId === id ? null : id);
|
|
};
|
|
|
|
const filteredFamilies = families.filter(f =>
|
|
f.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
f.unitNumber.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
const NoticeIcon = ({type}: {type: string}) => {
|
|
switch(type) {
|
|
case 'warning': return <AlertTriangle className="w-6 h-6 text-amber-600" />;
|
|
case 'maintenance': return <Hammer className="w-6 h-6 text-orange-600" />;
|
|
case 'event': return <Calendar className="w-6 h-6 text-purple-600" />;
|
|
default: return <Info className="w-6 h-6 text-blue-600" />;
|
|
}
|
|
};
|
|
|
|
const activeTicketsCount = myTickets.filter(t => t.status !== TicketStatus.RESOLVED && t.status !== TicketStatus.CLOSED).length;
|
|
const extraDebt = myExtraExpenses.reduce((acc, exp) => acc + Math.max(0, exp.myShare.amountDue - exp.myShare.amountPaid), 0);
|
|
|
|
if (loading) {
|
|
return <div className="flex justify-center items-center h-64 text-slate-400">Caricamento in corso...</div>;
|
|
}
|
|
|
|
if (!activeCondo) {
|
|
return (
|
|
<div className="text-center p-12 text-slate-500">
|
|
<Building className="w-12 h-12 mx-auto mb-4 text-slate-300" />
|
|
<h2 className="text-xl font-bold text-slate-700">Nessun Condominio Selezionato</h2>
|
|
<p>Seleziona o crea un condominio dalle impostazioni.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8 pb-12 animate-fade-in">
|
|
|
|
{/* 1. BACHECA CONDOMINIALE (Notices) */}
|
|
{/* Visualizzata SEMPRE se la feature è attiva, anche se vuota */}
|
|
{settings?.features.notices && (
|
|
<div className="space-y-4">
|
|
<h3 className="font-bold text-slate-800 text-lg flex items-center gap-2">
|
|
<Bell className="w-5 h-5 text-blue-600" /> Bacheca Condominiale
|
|
</h3>
|
|
|
|
{notices.length === 0 ? (
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6 text-center shadow-sm">
|
|
<Inbox className="w-10 h-10 text-slate-300 mx-auto mb-2" />
|
|
<p className="text-slate-500 text-sm">Nessun avviso in bacheca.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4">
|
|
{notices.map(notice => {
|
|
const isRead = userReadIds.includes(notice.id);
|
|
const isExpanded = expandedNoticeId === notice.id;
|
|
|
|
let containerClass = isRead
|
|
? 'bg-slate-50 border-slate-200'
|
|
: 'bg-white border-blue-200 shadow-sm ring-1 ring-blue-50';
|
|
|
|
if (notice.type === 'warning' && !isRead) {
|
|
containerClass = 'bg-amber-50/50 border-amber-300 shadow-sm ring-1 ring-amber-50';
|
|
}
|
|
|
|
return (
|
|
<div key={notice.id} className={`rounded-xl border p-4 transition-all duration-300 ${containerClass}`}>
|
|
<div className="flex items-start gap-4">
|
|
<div className={`p-2.5 rounded-full flex-shrink-0 ${isRead ? 'bg-slate-200 grayscale opacity-70' : 'bg-white shadow-sm'}`}>
|
|
<NoticeIcon type={notice.type} />
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-2 mb-1.5">
|
|
<div className="flex items-center gap-2 overflow-hidden">
|
|
<h4 className={`font-bold text-base truncate ${isRead ? 'text-slate-500' : 'text-slate-900'}`}>{notice.title}</h4>
|
|
{!isRead && (
|
|
<span className="text-[10px] bg-blue-600 text-white px-2 py-0.5 rounded-full font-bold uppercase tracking-wide animate-pulse">
|
|
Nuovo
|
|
</span>
|
|
)}
|
|
{isRead && (
|
|
<span className="text-[10px] bg-slate-200 text-slate-500 px-2 py-0.5 rounded-full font-bold uppercase tracking-wide flex items-center gap-1">
|
|
<Check className="w-3 h-3"/> Letto
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-slate-400 whitespace-nowrap">{new Date(notice.date).toLocaleDateString()}</span>
|
|
</div>
|
|
|
|
<div className={`text-sm leading-relaxed whitespace-pre-wrap transition-all ${isRead ? 'text-slate-500' : 'text-slate-700'} ${isExpanded ? '' : 'line-clamp-2'}`}>
|
|
{notice.content}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mt-3 pt-2 border-t border-slate-200/50">
|
|
<div className="flex items-center gap-4">
|
|
{(notice.content.length > 120 || notice.content.includes('\n')) && (
|
|
<button
|
|
onClick={() => toggleExpandNotice(notice.id)}
|
|
className="text-xs font-medium text-slate-500 hover:text-blue-600 flex items-center gap-1"
|
|
>
|
|
{isExpanded ? <><ChevronUp className="w-3 h-3"/> Riduci</> : <><ChevronDown className="w-3 h-3"/> Leggi tutto</>}
|
|
</button>
|
|
)}
|
|
{notice.link && (
|
|
<a href={notice.link} target="_blank" rel="noopener noreferrer" className="text-xs font-medium text-blue-600 hover:text-blue-800 hover:underline flex items-center gap-1">
|
|
<LinkIcon className="w-3 h-3"/> Apri Allegato
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
{!isRead && (
|
|
<button
|
|
onClick={() => handleMarkAsRead(notice.id)}
|
|
className="text-xs bg-white border border-slate-200 hover:bg-blue-50 hover:text-blue-700 hover:border-blue-200 text-slate-600 px-3 py-1.5 rounded-lg font-medium transition-all flex items-center gap-1 shadow-sm"
|
|
>
|
|
<CheckCircle2 className="w-3 h-3"/> Segna come letto
|
|
</button>
|
|
)}
|
|
{isRead && (
|
|
<span className="text-[10px] text-slate-400 flex items-center gap-1">
|
|
<Eye className="w-3 h-3"/> Archiviato
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 2. USER DASHBOARD (Widgets) - Only for Regular Users with a linked Family */}
|
|
{!isPrivileged && myFamily && (
|
|
<div className="space-y-3">
|
|
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
|
<UserCircle className="w-5 h-5" /> La Tua Situazione
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
|
|
{/* Regular Payments Widget */}
|
|
<div className={`p-5 rounded-xl border shadow-sm flex flex-col justify-between ${
|
|
regularPaymentStatus === 'OVERDUE' ? 'bg-white border-red-300 ring-1 ring-red-100' :
|
|
regularPaymentStatus === 'PENDING' ? 'bg-white border-yellow-300 ring-1 ring-yellow-100' :
|
|
'bg-white border-slate-200'
|
|
}`}>
|
|
<div>
|
|
<div className="flex justify-between items-start mb-2">
|
|
<div className={`p-2 rounded-lg ${
|
|
regularPaymentStatus === 'OVERDUE' ? 'bg-red-50 text-red-600' :
|
|
regularPaymentStatus === 'PENDING' ? 'bg-yellow-50 text-yellow-600' :
|
|
'bg-green-50 text-green-600'
|
|
}`}>
|
|
<Wallet className="w-6 h-6" />
|
|
</div>
|
|
{regularPaymentStatus === 'OVERDUE' && <span className="bg-red-100 text-red-700 text-[10px] font-bold px-2 py-1 rounded uppercase">Insoluto</span>}
|
|
{regularPaymentStatus === 'PENDING' && <span className="bg-yellow-100 text-yellow-700 text-[10px] font-bold px-2 py-1 rounded uppercase">In Scadenza</span>}
|
|
</div>
|
|
<p className="text-slate-500 text-xs font-bold uppercase tracking-wide">Rate Condominiali {settings?.currentYear}</p>
|
|
<h4 className={`text-2xl font-bold mt-1 ${
|
|
regularPaymentStatus === 'OVERDUE' ? 'text-red-600' :
|
|
regularPaymentStatus === 'PENDING' ? 'text-yellow-600' :
|
|
'text-slate-800'
|
|
}`}>
|
|
{regularDebtAmount > 0 ? `€ -${regularDebtAmount.toFixed(2)}` : 'In Regola'}
|
|
</h4>
|
|
</div>
|
|
<button
|
|
onClick={() => navigate(`/family/${myFamily.id}`)}
|
|
className="mt-4 w-full py-2 bg-slate-50 hover:bg-slate-100 text-slate-700 rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
|
>
|
|
{regularDebtAmount > 0 ? 'Paga Ora' : 'Vedi Storico'} <ArrowRight className="w-4 h-4"/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Extra Expenses Widget */}
|
|
{settings?.features.extraordinaryExpenses && (
|
|
<div className={`p-5 rounded-xl border shadow-sm flex flex-col justify-between ${extraDebt > 0 ? 'bg-white border-orange-200' : 'bg-white border-slate-200'}`}>
|
|
<div>
|
|
<div className="flex justify-between items-start mb-2">
|
|
<div className={`p-2 rounded-lg ${extraDebt > 0 ? 'bg-orange-50 text-orange-600' : 'bg-slate-100 text-slate-500'}`}>
|
|
<Briefcase className="w-6 h-6" />
|
|
</div>
|
|
{extraDebt > 0 && <span className="bg-orange-100 text-orange-700 text-[10px] font-bold px-2 py-1 rounded uppercase">Da Saldare</span>}
|
|
</div>
|
|
<p className="text-slate-500 text-xs font-bold uppercase tracking-wide">Spese Straordinarie</p>
|
|
<h4 className={`text-2xl font-bold mt-1 ${extraDebt > 0 ? 'text-orange-600' : 'text-slate-800'}`}>
|
|
{extraDebt > 0 ? `€ -${extraDebt.toFixed(2)}` : 'Nessuna'}
|
|
</h4>
|
|
</div>
|
|
<button
|
|
onClick={() => navigate('/extraordinary')}
|
|
className="mt-4 w-full py-2 bg-slate-50 hover:bg-slate-100 text-slate-700 rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
|
>
|
|
{extraDebt > 0 ? 'Dettagli & Saldo' : 'Vedi Progetti'} <ArrowRight className="w-4 h-4"/>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tickets Widget */}
|
|
{settings?.features.tickets && (
|
|
<div className="p-5 rounded-xl border border-slate-200 shadow-sm bg-white flex flex-col justify-between">
|
|
<div>
|
|
<div className="flex justify-between items-start mb-2">
|
|
<div className={`p-2 rounded-lg ${activeTicketsCount > 0 ? 'bg-blue-50 text-blue-600' : 'bg-slate-100 text-slate-500'}`}>
|
|
<MessageSquareWarning className="w-6 h-6" />
|
|
</div>
|
|
{activeTicketsCount > 0 && <span className="bg-blue-100 text-blue-700 text-[10px] font-bold px-2 py-1 rounded uppercase">{activeTicketsCount} Attive</span>}
|
|
</div>
|
|
<p className="text-slate-500 text-xs font-bold uppercase tracking-wide">Le tue Segnalazioni</p>
|
|
<h4 className="text-2xl font-bold mt-1 text-slate-800">
|
|
{activeTicketsCount > 0 ? `${activeTicketsCount} in corso` : 'Tutto OK'}
|
|
</h4>
|
|
</div>
|
|
<button
|
|
onClick={() => navigate('/tickets')}
|
|
className="mt-4 w-full py-2 bg-slate-50 hover:bg-slate-100 text-slate-700 rounded-lg text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
|
>
|
|
{activeTicketsCount > 0 ? 'Vedi Risposte' : 'Nuova Segnalazione'} <ArrowRight className="w-4 h-4"/>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 3. DIRECTORY HEADER */}
|
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 pt-4 border-t border-slate-200">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-slate-800">Rubrica Condominiale</h2>
|
|
<p className="text-slate-500 text-sm md:text-base flex items-center gap-1.5">
|
|
<Building className="w-4 h-4" />
|
|
{activeCondo.name}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="relative w-full md:w-80 lg:w-96">
|
|
<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-300 rounded-xl leading-5 bg-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-150 ease-in-out sm:text-sm shadow-sm text-slate-700"
|
|
placeholder="Cerca condomino..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 4. LIST */}
|
|
<div className="bg-white shadow-sm rounded-xl overflow-hidden border border-slate-200">
|
|
<ul className="divide-y divide-slate-100">
|
|
{filteredFamilies.length === 0 ? (
|
|
<li className="p-12 text-center text-slate-500 flex flex-col items-center gap-2">
|
|
<Search className="w-8 h-8 text-slate-300" />
|
|
<span>Nessuna famiglia trovata in questo condominio.</span>
|
|
</li>
|
|
) : (
|
|
filteredFamilies.map((family) => (
|
|
<li key={family.id} className="hover:bg-slate-50 transition-colors active:bg-slate-100">
|
|
<Link to={`/family/${family.id}`} className="block p-4 sm:p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3 sm:gap-4 overflow-hidden">
|
|
<div className="bg-blue-100 p-2 sm:p-2.5 rounded-full flex-shrink-0">
|
|
<UserCircle className="w-6 h-6 sm:w-8 sm:h-8 text-blue-600" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-base sm:text-lg font-semibold text-blue-600 truncate">
|
|
{family.name}
|
|
</p>
|
|
<p className="flex items-center text-sm text-slate-500 truncate">
|
|
Interno: <span className="font-medium text-slate-700 ml-1">{family.unitNumber}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex-shrink-0">
|
|
<ChevronRight className="h-5 w-5 text-slate-400" />
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
))
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|