Files
Condopay/pages/ExtraordinaryAdmin.tsx
frakarr fa12a8de85 feat(extraordinary-expenses): Add module for extraordinary expenses
Introduces a new module to manage and track extraordinary expenses within condominiums. This includes defining expense items, sharing arrangements, and attaching relevant documents.

The module adds new types for `ExpenseItem`, `ExpenseShare`, and `ExtraordinaryExpense`. Mock database functions are updated to support fetching, creating, and managing these expenses. UI components in `Layout.tsx` and `Settings.tsx` are modified to include navigation and feature toggling for extraordinary expenses. Additionally, new routes are added in `App.tsx` for both administrative and user-facing views of these expenses.
2025-12-09 23:00:05 +01:00

387 lines
24 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 } 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);
// 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) return; // If manually editing, don't auto-calc
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);
// Recalculate shares based on new total
// We need a small delay or effect, but for simplicity let's force recalc next render or manual
};
// Trigger share recalc when total changes (if not manual override mode - implementing simple auto mode here)
useEffect(() => {
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 handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await CondoService.createExpense({
title: formTitle,
description: formDesc,
startDate: formStart,
endDate: formEnd,
contractorName: formContractor,
items: formItems,
shares: formShares,
attachments: formAttachments
});
setShowModal(false);
loadData();
// Reset form
setFormTitle(''); setFormDesc(''); setFormItems([{description:'', amount:0}]); setSelectedFamilyIds([]); setFormShares([]); setFormAttachments([]);
} catch(e) { alert('Errore creazione'); }
};
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={() => setShowModal(true)} 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">
<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"> {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 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">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>
<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 */}
<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>
</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">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>
);
};