366 lines
21 KiB
TypeScript
366 lines
21 KiB
TypeScript
|
|
import React, { useEffect, useState, useMemo } from 'react';
|
|
import { CondoService } from '../services/mockDb';
|
|
import { CondoExpense, Condo } from '../types';
|
|
import { Plus, Search, Filter, Paperclip, X, Save, FileText, Download, Euro, Trash2, Pencil, Briefcase } from 'lucide-react';
|
|
|
|
export const CondoFinancialsPage: React.FC = () => {
|
|
const user = CondoService.getCurrentUser();
|
|
const isPrivileged = user?.role === 'admin' || user?.role === 'poweruser';
|
|
|
|
const [expenses, setExpenses] = useState<CondoExpense[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeCondo, setActiveCondo] = useState<Condo | undefined>(undefined);
|
|
|
|
// Filters
|
|
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
|
|
const [availableYears, setAvailableYears] = useState<number[]>([]);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID' | 'SUSPENDED'>('ALL');
|
|
|
|
// Modal State
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingExpense, setEditingExpense] = useState<CondoExpense | null>(null);
|
|
|
|
// Form
|
|
const [formData, setFormData] = useState<{
|
|
description: string;
|
|
supplierName: string;
|
|
amount: string;
|
|
paymentDate: string;
|
|
status: 'PAID' | 'UNPAID' | 'SUSPENDED';
|
|
paymentMethod: string;
|
|
invoiceNumber: string;
|
|
notes: string;
|
|
attachments: {fileName: string, fileType: string, data: string}[];
|
|
}>({ description: '', supplierName: '', amount: '', paymentDate: '', status: 'UNPAID', paymentMethod: '', invoiceNumber: '', notes: '', attachments: [] });
|
|
|
|
// Distinct Suppliers for Datalist
|
|
const suppliers = useMemo(() => {
|
|
return Array.from(new Set(expenses.map(e => e.supplierName).filter(Boolean)));
|
|
}, [expenses]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [selectedYear]);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const condo = await CondoService.getActiveCondo();
|
|
setActiveCondo(condo);
|
|
|
|
const [data, years] = await Promise.all([
|
|
CondoService.getCondoExpenses(selectedYear),
|
|
CondoService.getAvailableYears()
|
|
]);
|
|
|
|
setExpenses(data);
|
|
if (!availableYears.includes(selectedYear) && years.includes(selectedYear)) {
|
|
setAvailableYears(years);
|
|
} else if (years.length > 0) {
|
|
setAvailableYears(prev => Array.from(new Set([...prev, ...years, new Date().getFullYear()])).sort((a,b)=>b-a));
|
|
}
|
|
} catch(e) { console.error(e); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
const newAtts = [];
|
|
for (let i = 0; i < e.target.files.length; i++) {
|
|
const file = e.target.files[i];
|
|
const base64 = await new Promise<string>((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.readAsDataURL(file);
|
|
});
|
|
newAtts.push({ fileName: file.name, fileType: file.type, data: base64 });
|
|
}
|
|
setFormData(prev => ({ ...prev, attachments: [...prev.attachments, ...newAtts] }));
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
const payload = {
|
|
id: editingExpense?.id,
|
|
description: formData.description,
|
|
supplierName: formData.supplierName,
|
|
amount: parseFloat(formData.amount),
|
|
paymentDate: formData.paymentDate || null,
|
|
status: formData.status,
|
|
paymentMethod: formData.paymentMethod,
|
|
invoiceNumber: formData.invoiceNumber,
|
|
notes: formData.notes,
|
|
attachments: formData.attachments
|
|
};
|
|
|
|
await CondoService.saveCondoExpense(payload);
|
|
setShowModal(false);
|
|
loadData();
|
|
} catch(e) { alert("Errore salvataggio spesa"); }
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm("Eliminare questa spesa?")) return;
|
|
try {
|
|
await CondoService.deleteCondoExpense(id);
|
|
setExpenses(expenses.filter(e => e.id !== id));
|
|
} catch(e) { alert("Errore eliminazione"); }
|
|
};
|
|
|
|
const openModal = (exp?: CondoExpense) => {
|
|
if (exp) {
|
|
setEditingExpense(exp);
|
|
setFormData({
|
|
description: exp.description,
|
|
supplierName: exp.supplierName,
|
|
amount: exp.amount.toString(),
|
|
paymentDate: exp.paymentDate ? new Date(exp.paymentDate).toISOString().split('T')[0] : '',
|
|
status: exp.status,
|
|
paymentMethod: exp.paymentMethod || '',
|
|
invoiceNumber: exp.invoiceNumber || '',
|
|
notes: exp.notes || '',
|
|
attachments: [] // We don't load attachments content back for editing, only new ones
|
|
});
|
|
} else {
|
|
setEditingExpense(null);
|
|
setFormData({ description: '', supplierName: '', amount: '', paymentDate: '', status: 'UNPAID', paymentMethod: '', invoiceNumber: '', notes: '', attachments: [] });
|
|
}
|
|
setShowModal(true);
|
|
};
|
|
|
|
const openAttachment = async (expId: string, attId: string) => {
|
|
try {
|
|
const file = await CondoService.getCondoExpenseAttachment(expId, attId);
|
|
const win = window.open();
|
|
if (win) {
|
|
if (file.fileType.startsWith('image/') || file.fileType === 'application/pdf') {
|
|
win.document.write(`<iframe src="${file.data}" frameborder="0" style="border:0; top:0px; left:0px; bottom:0px; right:0px; width:100%; height:100%;" allowfullscreen></iframe>`);
|
|
} else {
|
|
win.document.write(`<a href="${file.data}" download="${file.fileName}">Download ${file.fileName}</a>`);
|
|
}
|
|
}
|
|
} catch(e) { alert("Errore apertura file"); }
|
|
};
|
|
|
|
const filtered = expenses.filter(e => {
|
|
const matchesSearch = e.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
e.supplierName.toLowerCase().includes(searchTerm.toLowerCase());
|
|
const matchesStatus = statusFilter === 'ALL' || e.status === statusFilter;
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
|
|
const totalSpent = filtered.reduce((acc, curr) => acc + curr.amount, 0);
|
|
|
|
return (
|
|
<div className="space-y-6 pb-20 animate-fade-in">
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-slate-800">Spese Condominiali (Uscite)</h2>
|
|
<p className="text-slate-500 text-sm">Registro fatture e pagamenti fornitori per {activeCondo?.name}</p>
|
|
</div>
|
|
{isPrivileged && (
|
|
<button onClick={() => openModal()} className="bg-blue-600 text-white px-4 py-2 rounded-lg font-medium flex items-center gap-2 hover:bg-blue-700">
|
|
<Plus className="w-5 h-5"/> Registra Spesa
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex flex-col md:flex-row gap-4">
|
|
<div className="w-32">
|
|
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Anno</label>
|
|
<select
|
|
value={selectedYear}
|
|
onChange={e => setSelectedYear(parseInt(e.target.value))}
|
|
className="w-full border p-2 rounded-lg text-slate-700 bg-slate-50 font-medium"
|
|
>
|
|
{availableYears.map(y => <option key={y} value={y}>{y}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="flex-1">
|
|
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Cerca</label>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Descrizione o Fornitore..."
|
|
value={searchTerm}
|
|
onChange={e => setSearchTerm(e.target.value)}
|
|
className="w-full border p-2 pl-9 rounded-lg text-slate-700 bg-slate-50"
|
|
/>
|
|
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-2.5"/>
|
|
</div>
|
|
</div>
|
|
<div className="w-40">
|
|
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Stato</label>
|
|
<select className="w-full border p-2 rounded-lg text-slate-700 bg-slate-50" value={statusFilter} onChange={(e:any) => setStatusFilter(e.target.value)}>
|
|
<option value="ALL">Tutti</option>
|
|
<option value="PAID">Saldati</option>
|
|
<option value="UNPAID">Insoluti</option>
|
|
<option value="SUSPENDED">Sospesi</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<div className="bg-blue-50 px-4 py-2 rounded-lg border border-blue-100">
|
|
<p className="text-xs font-bold text-blue-600 uppercase">Totale</p>
|
|
<p className="font-bold text-lg text-slate-800">€ {totalSpent.toLocaleString()}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
|
<table className="w-full text-left text-sm text-slate-600">
|
|
<thead className="bg-slate-50 text-slate-700 font-semibold border-b border-slate-200">
|
|
<tr>
|
|
<th className="px-6 py-3">Data / Scadenza</th>
|
|
<th className="px-6 py-3">Fornitore & Descrizione</th>
|
|
<th className="px-6 py-3">Importo</th>
|
|
<th className="px-6 py-3">Stato</th>
|
|
<th className="px-6 py-3">Dettagli</th>
|
|
{isPrivileged && <th className="px-6 py-3 text-right">Azioni</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{loading ? (
|
|
<tr><td colSpan={6} className="p-8 text-center text-slate-400">Caricamento...</td></tr>
|
|
) : filtered.length === 0 ? (
|
|
<tr><td colSpan={6} className="p-8 text-center text-slate-400">Nessuna spesa registrata.</td></tr>
|
|
) : (
|
|
filtered.map(exp => (
|
|
<tr key={exp.id} className="hover:bg-slate-50 transition-colors">
|
|
<td className="px-6 py-4">
|
|
<div className="font-medium text-slate-700">
|
|
{exp.paymentDate ? new Date(exp.paymentDate).toLocaleDateString() : '-'}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="font-bold text-slate-800">{exp.supplierName}</div>
|
|
<div className="text-xs text-slate-500">{exp.description}</div>
|
|
</td>
|
|
<td className="px-6 py-4 font-bold text-slate-800">
|
|
€ {exp.amount.toFixed(2)}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<span className={`px-2 py-1 rounded-full text-[10px] font-bold uppercase ${
|
|
exp.status === 'PAID' ? 'bg-green-100 text-green-700' :
|
|
exp.status === 'SUSPENDED' ? 'bg-amber-100 text-amber-700' :
|
|
'bg-red-100 text-red-700'
|
|
}`}>
|
|
{exp.status === 'PAID' ? 'Saldato' : exp.status === 'SUSPENDED' ? 'Sospeso' : 'Insoluto'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-xs space-y-1">
|
|
{exp.invoiceNumber && <div className="text-slate-500">Fatt. {exp.invoiceNumber}</div>}
|
|
{exp.paymentMethod && <div className="text-slate-400">{exp.paymentMethod}</div>}
|
|
{exp.attachments && exp.attachments.length > 0 && (
|
|
<div className="flex gap-1 flex-wrap">
|
|
{exp.attachments.map(att => (
|
|
<button key={att.id} onClick={() => openAttachment(exp.id, att.id)} className="text-blue-600 hover:underline flex items-center gap-0.5">
|
|
<Paperclip className="w-3 h-3"/> {att.fileName}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</td>
|
|
{isPrivileged && (
|
|
<td className="px-6 py-4 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<button onClick={() => openModal(exp)} className="text-blue-600 hover:bg-blue-50 p-1 rounded"><Pencil className="w-4 h-4"/></button>
|
|
<button onClick={() => handleDelete(exp.id)} className="text-red-600 hover:bg-red-50 p-1 rounded"><Trash2 className="w-4 h-4"/></button>
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* MODAL */}
|
|
{showModal && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 animate-in fade-in zoom-in duration-200 max-h-[90vh] overflow-y-auto">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h3 className="font-bold text-lg text-slate-800">{editingExpense ? 'Modifica Spesa' : 'Nuova Spesa'}</h3>
|
|
<button onClick={() => setShowModal(false)}><X className="w-6 h-6 text-slate-400"/></button>
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Descrizione</label>
|
|
<input className="w-full border p-2 rounded-lg text-slate-700" value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} required placeholder="Es. Pulizia scale Gennaio" />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Fornitore</label>
|
|
<input
|
|
list="suppliers-list"
|
|
className="w-full border p-2 rounded-lg text-slate-700"
|
|
value={formData.supplierName}
|
|
onChange={e => setFormData({...formData, supplierName: e.target.value})}
|
|
placeholder="Seleziona o scrivi nuovo..."
|
|
required
|
|
/>
|
|
<datalist id="suppliers-list">
|
|
{suppliers.map((s, i) => <option key={i} value={s} />)}
|
|
</datalist>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Importo (€)</label>
|
|
<input type="number" step="0.01" className="w-full border p-2 rounded-lg text-slate-700" value={formData.amount} onChange={e => setFormData({...formData, amount: e.target.value})} required />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Rif. Fattura</label>
|
|
<input className="w-full border p-2 rounded-lg text-slate-700" value={formData.invoiceNumber} onChange={e => setFormData({...formData, invoiceNumber: e.target.value})} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Stato</label>
|
|
<select className="w-full border p-2 rounded-lg text-slate-700 bg-white" value={formData.status} onChange={(e:any) => setFormData({...formData, status: e.target.value})}>
|
|
<option value="UNPAID">Insoluto</option>
|
|
<option value="PAID">Saldato</option>
|
|
<option value="SUSPENDED">Sospeso</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Data Pagamento</label>
|
|
<input type="date" className="w-full border p-2 rounded-lg text-slate-700" value={formData.paymentDate} onChange={e => setFormData({...formData, paymentDate: e.target.value})} />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Metodo Pagamento</label>
|
|
<input className="w-full border p-2 rounded-lg text-slate-700" value={formData.paymentMethod} onChange={e => setFormData({...formData, paymentMethod: e.target.value})} placeholder="Es. Bonifico, RID..." />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Allegati (Fattura)</label>
|
|
<input type="file" multiple onChange={handleFileChange} className="w-full text-sm text-slate-500"/>
|
|
{formData.attachments.length > 0 && <p className="text-xs text-green-600 mt-1">{formData.attachments.length} file pronti per upload</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Note</label>
|
|
<textarea className="w-full border p-2 rounded-lg text-slate-700 h-20" value={formData.notes} onChange={e => setFormData({...formData, notes: e.target.value})} />
|
|
</div>
|
|
|
|
<div className="pt-2 flex gap-2">
|
|
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border p-2 rounded-lg text-slate-600">Annulla</button>
|
|
<button type="submit" className="flex-1 bg-blue-600 text-white p-2 rounded-lg font-bold">Salva</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|