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.
449 lines
28 KiB
TypeScript
449 lines
28 KiB
TypeScript
|
|
import React, { useEffect, useState } from 'react';
|
|
import { CondoService } from '../services/mockDb';
|
|
import { ExtraordinaryExpense, Family, ExpenseItem, ExpenseShare } from '../types';
|
|
import { Plus, Calendar, FileText, CheckCircle2, Clock, Users, X, Save, Paperclip, Euro, Trash2, Eye, Briefcase, Pencil } from 'lucide-react';
|
|
|
|
export const ExtraordinaryAdmin: React.FC = () => {
|
|
const [expenses, setExpenses] = useState<ExtraordinaryExpense[]>([]);
|
|
const [families, setFamilies] = useState<Family[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
|
const [selectedExpense, setSelectedExpense] = useState<ExtraordinaryExpense | null>(null);
|
|
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
|
|
// Form State
|
|
const [formTitle, setFormTitle] = useState('');
|
|
const [formDesc, setFormDesc] = useState('');
|
|
const [formStart, setFormStart] = useState('');
|
|
const [formEnd, setFormEnd] = useState('');
|
|
const [formContractor, setFormContractor] = useState('');
|
|
const [formItems, setFormItems] = useState<ExpenseItem[]>([{ description: '', amount: 0 }]);
|
|
const [formShares, setFormShares] = useState<ExpenseShare[]>([]); // Working state for shares
|
|
const [formAttachments, setFormAttachments] = useState<{fileName: string, fileType: string, data: string}[]>([]);
|
|
const [selectedFamilyIds, setSelectedFamilyIds] = useState<string[]>([]); // Helper to track checkboxes
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [expList, famList] = await Promise.all([
|
|
CondoService.getExpenses(),
|
|
CondoService.getFamilies()
|
|
]);
|
|
setExpenses(expList);
|
|
setFamilies(famList);
|
|
} catch(e) { console.error(e); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
// Calculation Helpers
|
|
const totalAmount = formItems.reduce((acc, item) => acc + (item.amount || 0), 0);
|
|
|
|
const recalculateShares = (selectedIds: string[], manualMode = false) => {
|
|
if (manualMode || isEditing) return; // Don't auto-calc shares in Edit mode to prevent messing up existing complex logic visually, backend handles logic
|
|
|
|
const count = selectedIds.length;
|
|
if (count === 0) {
|
|
setFormShares([]);
|
|
return;
|
|
}
|
|
|
|
const percentage = 100 / count;
|
|
const newShares: ExpenseShare[] = selectedIds.map(fid => ({
|
|
familyId: fid,
|
|
percentage: parseFloat(percentage.toFixed(2)),
|
|
amountDue: parseFloat(((totalAmount * percentage) / 100).toFixed(2)),
|
|
amountPaid: 0,
|
|
status: 'UNPAID'
|
|
}));
|
|
|
|
// Adjust rounding error on last item
|
|
if (newShares.length > 0) {
|
|
const sumDue = newShares.reduce((a, b) => a + b.amountDue, 0);
|
|
const diff = totalAmount - sumDue;
|
|
if (diff !== 0) {
|
|
newShares[newShares.length - 1].amountDue += diff;
|
|
}
|
|
}
|
|
|
|
setFormShares(newShares);
|
|
};
|
|
|
|
const handleFamilyToggle = (familyId: string) => {
|
|
const newSelected = selectedFamilyIds.includes(familyId)
|
|
? selectedFamilyIds.filter(id => id !== familyId)
|
|
: [...selectedFamilyIds, familyId];
|
|
|
|
setSelectedFamilyIds(newSelected);
|
|
recalculateShares(newSelected);
|
|
};
|
|
|
|
const handleShareChange = (index: number, field: 'percentage', value: number) => {
|
|
const newShares = [...formShares];
|
|
newShares[index].percentage = value;
|
|
newShares[index].amountDue = (totalAmount * value) / 100;
|
|
setFormShares(newShares);
|
|
};
|
|
|
|
const handleItemChange = (index: number, field: keyof ExpenseItem, value: any) => {
|
|
const newItems = [...formItems];
|
|
// @ts-ignore
|
|
newItems[index][field] = value;
|
|
setFormItems(newItems);
|
|
};
|
|
|
|
// Trigger share recalc when total changes (if not manual/editing)
|
|
useEffect(() => {
|
|
if (!isEditing) {
|
|
recalculateShares(selectedFamilyIds);
|
|
}
|
|
}, [totalAmount]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
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 });
|
|
}
|
|
setFormAttachments([...formAttachments, ...newAtts]);
|
|
}
|
|
};
|
|
|
|
const openCreateModal = () => {
|
|
setIsEditing(false);
|
|
setEditingId(null);
|
|
setFormTitle(''); setFormDesc(''); setFormStart(''); setFormEnd(''); setFormContractor('');
|
|
setFormItems([{description:'', amount:0}]); setFormShares([]); setFormAttachments([]); setSelectedFamilyIds([]);
|
|
setShowModal(true);
|
|
};
|
|
|
|
const openEditModal = async (exp: ExtraordinaryExpense) => {
|
|
// Fetch full details first to get items
|
|
try {
|
|
const detail = await CondoService.getExpenseDetails(exp.id);
|
|
setIsEditing(true);
|
|
setEditingId(exp.id);
|
|
setFormTitle(detail.title);
|
|
setFormDesc(detail.description);
|
|
setFormStart(detail.startDate ? new Date(detail.startDate).toISOString().split('T')[0] : '');
|
|
setFormEnd(detail.endDate ? new Date(detail.endDate).toISOString().split('T')[0] : '');
|
|
setFormContractor(detail.contractorName);
|
|
setFormItems(detail.items || []);
|
|
// Shares and attachments are not fully editable in this simple view to avoid conflicts
|
|
// We only allow editing Header Info + Items. Shares will be auto-recalculated by backend based on new total.
|
|
setFormShares([]);
|
|
setFormAttachments([]);
|
|
setSelectedFamilyIds([]);
|
|
setShowModal(true);
|
|
} catch(e) { alert("Errore caricamento dettagli"); }
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
if (isEditing && editingId) {
|
|
await CondoService.updateExpense(editingId, {
|
|
title: formTitle,
|
|
description: formDesc,
|
|
startDate: formStart,
|
|
endDate: formEnd,
|
|
contractorName: formContractor,
|
|
items: formItems
|
|
// Attachments and shares handled by backend logic to keep safe
|
|
});
|
|
} else {
|
|
await CondoService.createExpense({
|
|
title: formTitle,
|
|
description: formDesc,
|
|
startDate: formStart,
|
|
endDate: formEnd,
|
|
contractorName: formContractor,
|
|
items: formItems,
|
|
shares: formShares,
|
|
attachments: formAttachments
|
|
});
|
|
}
|
|
setShowModal(false);
|
|
loadData();
|
|
} catch(e) { alert('Errore salvataggio'); }
|
|
};
|
|
|
|
const openDetails = async (expense: ExtraordinaryExpense) => {
|
|
const fullDetails = await CondoService.getExpenseDetails(expense.id);
|
|
setSelectedExpense(fullDetails);
|
|
setShowDetailsModal(true);
|
|
};
|
|
|
|
const openAttachment = async (expenseId: string, attId: string) => {
|
|
try {
|
|
const file = await CondoService.getExpenseAttachment(expenseId, 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 file"); }
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 pb-20 animate-fade-in">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-slate-800">Spese Straordinarie</h2>
|
|
<p className="text-slate-500 text-sm">Gestione lavori e appalti</p>
|
|
</div>
|
|
<button onClick={openCreateModal} className="bg-blue-600 text-white px-4 py-2 rounded-lg font-medium flex gap-2 hover:bg-blue-700">
|
|
<Plus className="w-5 h-5"/> Nuova Spesa
|
|
</button>
|
|
</div>
|
|
|
|
{/* List */}
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{expenses.map(exp => (
|
|
<div key={exp.id} className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-all relative group">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); openEditModal(exp); }}
|
|
className="absolute top-4 right-4 p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-colors z-10"
|
|
title="Modifica"
|
|
>
|
|
<Pencil className="w-4 h-4"/>
|
|
</button>
|
|
|
|
<div className="flex justify-between items-start mb-3">
|
|
<span className="bg-purple-100 text-purple-700 text-xs font-bold px-2 py-1 rounded uppercase">Lavori</span>
|
|
<span className="font-bold text-slate-800 pr-8">€ {exp.totalAmount.toLocaleString()}</span>
|
|
</div>
|
|
<h3 className="font-bold text-lg text-slate-800 mb-1">{exp.title}</h3>
|
|
<p className="text-sm text-slate-500 mb-4 line-clamp-2">{exp.description}</p>
|
|
|
|
<div className="space-y-2 text-sm text-slate-600 mb-4">
|
|
<div className="flex items-center gap-2"><Briefcase className="w-4 h-4 text-slate-400"/> {exp.contractorName}</div>
|
|
<div className="flex items-center gap-2"><Calendar className="w-4 h-4 text-slate-400"/> {new Date(exp.startDate).toLocaleDateString()}</div>
|
|
</div>
|
|
|
|
<button onClick={() => openDetails(exp)} className="w-full py-2 border border-slate-200 rounded-lg text-slate-600 font-medium hover:bg-slate-50 flex items-center justify-center gap-2">
|
|
<Eye className="w-4 h-4"/> Dettagli & Pagamenti
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* CREATE/EDIT 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-4xl p-6 animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h3 className="font-bold text-xl text-slate-800">{isEditing ? 'Modifica Progetto' : 'Crea Progetto Straordinario'}</h3>
|
|
<button onClick={() => setShowModal(false)}><X className="w-6 h-6 text-slate-400"/></button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto pr-2">
|
|
<form id="createForm" onSubmit={handleSubmit} className="space-y-6">
|
|
{/* General Info */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<input className="border p-2 rounded" placeholder="Titolo Lavori" value={formTitle} onChange={e => setFormTitle(e.target.value)} required />
|
|
<input className="border p-2 rounded" placeholder="Azienda Appaltatrice" value={formContractor} onChange={e => setFormContractor(e.target.value)} required />
|
|
<div className="col-span-2">
|
|
<textarea className="w-full border p-2 rounded h-20" placeholder="Descrizione..." value={formDesc} onChange={e => setFormDesc(e.target.value)} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 col-span-2 md:col-span-1">
|
|
<div><label className="text-xs font-bold text-slate-500">Inizio</label><input type="date" className="w-full border p-2 rounded" value={formStart} onChange={e => setFormStart(e.target.value)} required /></div>
|
|
<div><label className="text-xs font-bold text-slate-500">Fine (Prevista)</label><input type="date" className="w-full border p-2 rounded" value={formEnd} onChange={e => setFormEnd(e.target.value)} /></div>
|
|
</div>
|
|
{!isEditing && (
|
|
<div>
|
|
<label className="text-xs font-bold text-slate-500 block mb-1">Allegati (Preventivi, Contratti)</label>
|
|
<input type="file" multiple onChange={handleFileChange} className="w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Items */}
|
|
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
|
|
<h4 className="font-bold text-slate-700 mb-2 flex justify-between">
|
|
Voci di Spesa
|
|
<span className="text-blue-600">Totale: € {totalAmount.toLocaleString()}</span>
|
|
</h4>
|
|
{formItems.map((item, idx) => (
|
|
<div key={idx} className="flex gap-2 mb-2">
|
|
<input className="flex-1 border p-2 rounded text-sm" placeholder="Descrizione voce..." value={item.description} onChange={e => handleItemChange(idx, 'description', e.target.value)} required />
|
|
<input type="number" className="w-32 border p-2 rounded text-sm" placeholder="Importo" value={item.amount} onChange={e => handleItemChange(idx, 'amount', parseFloat(e.target.value))} required />
|
|
{idx > 0 && <button type="button" onClick={() => setFormItems(formItems.filter((_, i) => i !== idx))} className="text-red-500"><X className="w-4 h-4"/></button>}
|
|
</div>
|
|
))}
|
|
<button type="button" onClick={() => setFormItems([...formItems, {description:'', amount:0}])} className="text-sm text-blue-600 font-medium flex items-center gap-1 mt-2">
|
|
<Plus className="w-3 h-3"/> Aggiungi Voce
|
|
</button>
|
|
</div>
|
|
|
|
{/* Distribution - HIDDEN IN EDIT MODE TO AVOID COMPLEXITY */}
|
|
{!isEditing && (
|
|
<div>
|
|
<h4 className="font-bold text-slate-700 mb-2">Ripartizione Famiglie</h4>
|
|
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
|
|
{families.map(fam => {
|
|
const share = formShares.find(s => s.familyId === fam.id);
|
|
return (
|
|
<div key={fam.id} className="flex items-center justify-between p-3 hover:bg-slate-50">
|
|
<label className="flex items-center gap-2 cursor-pointer flex-1">
|
|
<input type="checkbox" checked={selectedFamilyIds.includes(fam.id)} onChange={() => handleFamilyToggle(fam.id)} className="rounded text-blue-600"/>
|
|
<span className="text-sm font-medium text-slate-700">{fam.name} <span className="text-slate-400 font-normal">({fam.unitNumber})</span></span>
|
|
</label>
|
|
{share && (
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<input
|
|
type="number"
|
|
className="w-16 border rounded p-1 text-right text-sm"
|
|
value={share.percentage}
|
|
onChange={e => handleShareChange(formShares.indexOf(share), 'percentage', parseFloat(e.target.value))}
|
|
/>
|
|
<span className="absolute right-6 top-1 text-xs text-slate-400">%</span>
|
|
</div>
|
|
<div className="w-24 text-right text-sm font-bold text-slate-700">€ {share.amountDue.toFixed(2)}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{isEditing && (
|
|
<div className="bg-amber-50 p-3 rounded text-amber-800 text-sm border border-amber-200 flex items-start gap-2">
|
|
<CheckCircle2 className="w-5 h-5 flex-shrink-0"/>
|
|
<p>In modifica le quote delle famiglie vengono ricalcolate automaticamente in proporzione al nuovo totale. I pagamenti già effettuati restano salvati.</p>
|
|
</div>
|
|
)}
|
|
</form>
|
|
</div>
|
|
<div className="pt-4 border-t mt-4 flex justify-end gap-2">
|
|
<button onClick={() => setShowModal(false)} className="px-4 py-2 text-slate-600 border rounded-lg hover:bg-slate-50">Annulla</button>
|
|
<button type="submit" form="createForm" className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-bold">{isEditing ? 'Aggiorna Progetto' : 'Crea Progetto'}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* DETAILS MODAL */}
|
|
{showDetailsModal && selectedExpense && (
|
|
<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-5xl p-6 animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]">
|
|
<div className="flex justify-between items-center mb-6 border-b pb-4">
|
|
<div>
|
|
<h3 className="font-bold text-xl text-slate-800">{selectedExpense.title}</h3>
|
|
<p className="text-sm text-slate-500">Totale: € {selectedExpense.totalAmount.toLocaleString()}</p>
|
|
</div>
|
|
<button onClick={() => setShowDetailsModal(false)}><X className="w-6 h-6 text-slate-400"/></button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Left: Info & Items */}
|
|
<div className="lg:col-span-1 space-y-6">
|
|
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
|
|
<h4 className="font-bold text-sm text-slate-700 mb-2 uppercase">Dettagli</h4>
|
|
<p className="text-sm text-slate-600 mb-2">{selectedExpense.description}</p>
|
|
<div className="space-y-1 text-sm">
|
|
<p><strong>Appaltatore:</strong> {selectedExpense.contractorName}</p>
|
|
<p><strong>Inizio:</strong> {new Date(selectedExpense.startDate).toLocaleDateString()}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="font-bold text-sm text-slate-700 mb-2 uppercase">Voci di Spesa</h4>
|
|
<ul className="divide-y border rounded-lg">
|
|
{selectedExpense.items.map((item, i) => (
|
|
<li key={i} className="p-3 text-sm flex justify-between">
|
|
<span>{item.description}</span>
|
|
<span className="font-medium">€ {item.amount}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{selectedExpense.attachments && selectedExpense.attachments.length > 0 && (
|
|
<div>
|
|
<h4 className="font-bold text-sm text-slate-700 mb-2 uppercase">Documenti</h4>
|
|
<div className="flex flex-wrap gap-2">
|
|
{selectedExpense.attachments.map(att => (
|
|
<button key={att.id} onClick={() => openAttachment(selectedExpense.id, att.id)} className="flex items-center gap-1 bg-white border px-3 py-1 rounded text-xs hover:bg-slate-50">
|
|
<Paperclip className="w-3 h-3"/> {att.fileName}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Shares & Payments */}
|
|
<div className="lg:col-span-2">
|
|
<h4 className="font-bold text-sm text-slate-700 mb-4 uppercase flex items-center gap-2">
|
|
<Euro className="w-4 h-4"/> Stato Pagamenti Famiglie
|
|
</h4>
|
|
<div className="bg-white border rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="bg-slate-100 text-slate-600 font-semibold">
|
|
<tr>
|
|
<th className="p-3">Famiglia</th>
|
|
<th className="p-3 text-right">Quota (%)</th>
|
|
<th className="p-3 text-right">Da Pagare</th>
|
|
<th className="p-3 text-right">Versato</th>
|
|
<th className="p-3 text-center">Stato</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{selectedExpense.shares?.map(share => (
|
|
<tr key={share.id}>
|
|
<td className="p-3 font-medium">{share.familyName}</td>
|
|
<td className="p-3 text-right">{share.percentage}%</td>
|
|
<td className="p-3 text-right font-bold">€ {share.amountDue.toFixed(2)}</td>
|
|
<td className="p-3 text-right text-blue-600">€ {share.amountPaid.toFixed(2)}</td>
|
|
<td className="p-3 text-center">
|
|
<span className={`px-2 py-1 rounded-full text-[10px] font-bold uppercase ${
|
|
share.status === 'PAID' ? 'bg-green-100 text-green-700' :
|
|
share.status === 'PARTIAL' ? 'bg-yellow-100 text-yellow-700' :
|
|
'bg-red-100 text-red-700'
|
|
}`}>
|
|
{share.status === 'PAID' ? 'Saldato' : share.status === 'PARTIAL' ? 'Parziale' : 'Insoluto'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot className="bg-slate-50 font-bold text-slate-700">
|
|
<tr>
|
|
<td colSpan={2} className="p-3 text-right">Totale</td>
|
|
<td className="p-3 text-right">€ {selectedExpense.totalAmount.toLocaleString()}</td>
|
|
<td className="p-3 text-right text-blue-600">
|
|
€ {selectedExpense.shares?.reduce((a,b) => a + b.amountPaid, 0).toLocaleString()}
|
|
</td>
|
|
<td></td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|