380 lines
24 KiB
TypeScript
380 lines
24 KiB
TypeScript
|
|
import React, { useEffect, useState } from 'react';
|
|
import { CondoService } from '../services/mockDb';
|
|
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
|
|
import { Briefcase, Calendar, CheckCircle2, AlertCircle, Eye, Paperclip, X, FileText, Download, Users, Euro } from 'lucide-react';
|
|
import { ExtraordinaryExpense } from '../types';
|
|
|
|
export const ExtraordinaryUser: React.FC = () => {
|
|
const [expenses, setExpenses] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [paypalClientId, setPaypalClientId] = useState<string>('');
|
|
const [successMsg, setSuccessMsg] = useState('');
|
|
const [hasFamily, setHasFamily] = useState(true);
|
|
const [userFamilyId, setUserFamilyId] = useState<string | null>(null);
|
|
|
|
// Details Modal State
|
|
const [showDetails, setShowDetails] = useState(false);
|
|
const [selectedExpense, setSelectedExpense] = useState<ExtraordinaryExpense | null>(null);
|
|
const [loadingDetails, setLoadingDetails] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
try {
|
|
const user = CondoService.getCurrentUser();
|
|
if (!user?.familyId) {
|
|
setHasFamily(false);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setUserFamilyId(user.familyId);
|
|
|
|
const [myExp, condo] = await Promise.all([
|
|
CondoService.getMyExpenses(),
|
|
CondoService.getActiveCondo()
|
|
]);
|
|
setExpenses(myExp);
|
|
if (condo?.paypalClientId) setPaypalClientId(condo.paypalClientId);
|
|
|
|
// Update "Last Viewed" timestamp to clear notification
|
|
localStorage.setItem('lastViewedExpensesTime', Date.now().toString());
|
|
// Trigger event to update Sidebar immediately
|
|
window.dispatchEvent(new Event('expenses-viewed'));
|
|
|
|
} catch(e) { console.error(e); }
|
|
finally { setLoading(false); }
|
|
};
|
|
load();
|
|
}, []);
|
|
|
|
const handlePaymentSuccess = async (expenseId: string, amount: number) => {
|
|
try {
|
|
await CondoService.payExpense(expenseId, amount);
|
|
setSuccessMsg('Pagamento registrato con successo!');
|
|
setTimeout(() => setSuccessMsg(''), 3000);
|
|
// Refresh logic
|
|
const updatedList = await CondoService.getMyExpenses();
|
|
setExpenses(updatedList);
|
|
// Also update selected expense if modal is open
|
|
if (selectedExpense) {
|
|
const updatedDetails = await CondoService.getExpenseDetails(expenseId);
|
|
setSelectedExpense(updatedDetails);
|
|
}
|
|
} catch(e: any) {
|
|
alert(`Errore registrazione pagamento: ${e.message || "Errore sconosciuto"}`);
|
|
}
|
|
};
|
|
|
|
const handleCardClick = async (expenseId: string) => {
|
|
setLoadingDetails(true);
|
|
try {
|
|
// Fetch full details including attachments
|
|
const fullDetails = await CondoService.getExpenseDetails(expenseId);
|
|
setSelectedExpense(fullDetails);
|
|
setShowDetails(true);
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert("Impossibile caricare i dettagli.");
|
|
} finally {
|
|
setLoadingDetails(false);
|
|
}
|
|
};
|
|
|
|
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 apertura file"); }
|
|
};
|
|
|
|
if (loading) return <div className="p-8 text-center text-slate-400">Caricamento spese...</div>;
|
|
|
|
if (!hasFamily) {
|
|
return (
|
|
<div className="p-8 text-center bg-amber-50 rounded-xl border border-amber-200 text-amber-800">
|
|
<AlertCircle className="w-12 h-12 mx-auto mb-3 text-amber-500" />
|
|
<h3 className="text-lg font-bold mb-2">Nessuna Famiglia Associata</h3>
|
|
<p>Il tuo utente (probabilmente Admin) non è collegato ad alcuna famiglia, quindi non ci sono spese personali da mostrare.</p>
|
|
<p className="text-sm mt-2 text-amber-700">Vai in Impostazioni > Utenti e associa il tuo account a una famiglia per testare questa vista.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 pb-20 animate-fade-in">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-slate-800">Le Mie Spese Extra</h2>
|
|
<p className="text-slate-500 text-sm">Lavori straordinari e ripartizioni</p>
|
|
</div>
|
|
|
|
{successMsg && (
|
|
<div className="bg-green-100 text-green-700 p-4 rounded-xl flex items-center gap-2 mb-4">
|
|
<CheckCircle2 className="w-5 h-5"/> {successMsg}
|
|
</div>
|
|
)}
|
|
|
|
{expenses.length === 0 ? (
|
|
<div className="text-center p-12 bg-white rounded-xl border border-dashed border-slate-300">
|
|
<Briefcase className="w-12 h-12 text-slate-300 mx-auto mb-3"/>
|
|
<p className="text-slate-500">Nessuna spesa straordinaria attiva.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
{expenses.map(exp => {
|
|
const isPaid = exp.myShare.status === 'PAID';
|
|
|
|
return (
|
|
<div
|
|
key={exp.id}
|
|
onClick={() => handleCardClick(exp.id)}
|
|
className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden flex flex-col cursor-pointer hover:shadow-md transition-all group"
|
|
>
|
|
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-start group-hover:bg-blue-50/30 transition-colors">
|
|
<div>
|
|
<h3 className="font-bold text-lg text-slate-800 group-hover:text-blue-700 transition-colors flex items-center gap-2">
|
|
{exp.title} <Eye className="w-4 h-4 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity"/>
|
|
</h3>
|
|
<p className="text-xs text-slate-500 mt-1 flex items-center gap-1">
|
|
<Calendar className="w-3 h-3"/> {new Date(exp.startDate).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<div className={`px-3 py-1 rounded-full text-xs font-bold uppercase ${
|
|
isPaid ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'
|
|
}`}>
|
|
{isPaid ? 'Saldato' : 'In Sospeso'}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-5 flex-1 space-y-4">
|
|
<div className="flex justify-between items-center text-sm">
|
|
<span className="text-slate-500">Quota Totale (100%)</span>
|
|
<span className="font-medium text-slate-800">€ {exp.totalAmount.toLocaleString()}</span>
|
|
</div>
|
|
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
|
|
<div className="flex justify-between items-center text-sm mb-1">
|
|
<span className="text-blue-800 font-medium">La tua quota ({exp.myShare.percentage}%)</span>
|
|
<span className="font-bold text-blue-900">€ {exp.myShare.amountDue.toFixed(2)}</span>
|
|
</div>
|
|
<div className="w-full bg-blue-200 h-1.5 rounded-full overflow-hidden">
|
|
<div
|
|
className="bg-blue-600 h-full transition-all duration-500"
|
|
style={{ width: `${Math.min(100, (exp.myShare.amountPaid / exp.myShare.amountDue) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-xs mt-1 text-blue-600">
|
|
<span>Versato: € {exp.myShare.amountPaid.toFixed(2)}</span>
|
|
<span>Restante: € {(exp.myShare.amountDue - exp.myShare.amountPaid).toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 border-t border-slate-100 bg-slate-50 text-center text-xs text-blue-600 font-medium">
|
|
Clicca per dettagli e pagamenti
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* DETAILS MODAL */}
|
|
{showDetails && selectedExpense && (
|
|
<div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
|
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden">
|
|
<div className="flex justify-between items-center p-5 border-b border-slate-100 bg-slate-50">
|
|
<div>
|
|
<h3 className="font-bold text-xl text-slate-800">{selectedExpense.title}</h3>
|
|
<p className="text-xs text-slate-500 uppercase tracking-wider mt-1">{selectedExpense.contractorName}</p>
|
|
</div>
|
|
<button onClick={() => setShowDetails(false)} className="p-2 hover:bg-slate-200 rounded-full text-slate-500 transition-colors">
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6 overflow-y-auto space-y-8">
|
|
{/* Top Section: Info & Dates */}
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h4 className="text-sm font-bold text-slate-700 mb-2 flex items-center gap-2"><FileText className="w-4 h-4"/> Descrizione Lavori</h4>
|
|
<p className="text-slate-600 text-sm leading-relaxed bg-slate-50 p-4 rounded-lg border border-slate-100">
|
|
{selectedExpense.description}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="bg-blue-50 p-3 rounded-lg border border-blue-100">
|
|
<p className="text-xs text-blue-600 font-bold uppercase mb-1">Inizio Lavori</p>
|
|
<p className="text-sm font-medium text-slate-700">
|
|
{new Date(selectedExpense.startDate).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<div className="bg-green-50 p-3 rounded-lg border border-green-100">
|
|
<p className="text-xs text-green-600 font-bold uppercase mb-1">Costo Totale Progetto</p>
|
|
<p className="text-sm font-medium text-slate-700">
|
|
€ {selectedExpense.totalAmount.toLocaleString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Middle Section: Items and Distribution Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Items List */}
|
|
<div>
|
|
<h4 className="text-sm font-bold text-slate-700 mb-2 border-b pb-1">Voci di Spesa (Totale)</h4>
|
|
<div className="bg-slate-50 rounded-lg overflow-hidden border border-slate-200">
|
|
<table className="w-full text-sm text-left">
|
|
<tbody className="divide-y divide-slate-200">
|
|
{selectedExpense.items.map((item, i) => (
|
|
<tr key={i}>
|
|
<td className="p-3 text-slate-600 text-xs">{item.description}</td>
|
|
<td className="p-3 text-right text-slate-900 font-medium text-xs">€ {item.amount.toLocaleString()}</td>
|
|
</tr>
|
|
))}
|
|
<tr className="bg-slate-100 font-bold">
|
|
<td className="p-3 text-xs">Totale</td>
|
|
<td className="p-3 text-right text-xs">€ {selectedExpense.totalAmount.toLocaleString()}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Shares Table (Piano di Ripartizione) */}
|
|
<div>
|
|
<h4 className="text-sm font-bold text-slate-700 mb-2 border-b pb-1 flex items-center gap-2">
|
|
<Users className="w-4 h-4"/> Piano di Ripartizione
|
|
</h4>
|
|
<div className="bg-white rounded-lg overflow-hidden border border-slate-200 h-full max-h-60 overflow-y-auto">
|
|
<table className="w-full text-xs text-left">
|
|
<thead className="bg-slate-50 text-slate-500 font-semibold sticky top-0">
|
|
<tr>
|
|
<th className="p-2">Condomino</th>
|
|
<th className="p-2 text-right">%</th>
|
|
<th className="p-2 text-right">Quota</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{selectedExpense.shares?.map((share, i) => {
|
|
const isMe = share.familyId === userFamilyId;
|
|
return (
|
|
<tr key={i} className={isMe ? "bg-blue-50" : ""}>
|
|
<td className="p-2 font-medium text-slate-700 truncate max-w-[100px]">
|
|
{share.familyName}
|
|
{isMe && <span className="ml-1 text-[9px] bg-blue-100 text-blue-700 px-1 rounded">TU</span>}
|
|
</td>
|
|
<td className="p-2 text-right text-slate-500">{share.percentage}%</td>
|
|
<td className={`p-2 text-right font-bold ${isMe ? 'text-blue-700' : 'text-slate-700'}`}>
|
|
€ {share.amountDue.toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Attachments */}
|
|
{selectedExpense.attachments && selectedExpense.attachments.length > 0 && (
|
|
<div>
|
|
<h4 className="text-sm font-bold text-slate-700 mb-2 flex items-center gap-2"><Paperclip className="w-4 h-4"/> Documenti & Allegati</h4>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
{selectedExpense.attachments.map(att => (
|
|
<button
|
|
key={att.id}
|
|
onClick={() => openAttachment(selectedExpense.id, att.id)}
|
|
className="flex items-center justify-between p-3 border rounded-lg hover:border-blue-400 hover:bg-blue-50 transition-all text-left group"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<div className="bg-slate-100 p-2 rounded group-hover:bg-white transition-colors">
|
|
<FileText className="w-4 h-4 text-blue-600"/>
|
|
</div>
|
|
<span className="text-sm font-medium text-slate-700 truncate">{att.fileName}</span>
|
|
</div>
|
|
<Download className="w-4 h-4 text-slate-400 group-hover:text-blue-600"/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Payment Footer - Only visible here now */}
|
|
<div className="p-4 border-t border-slate-100 bg-slate-50">
|
|
{(() => {
|
|
const myShare = selectedExpense.shares?.find(s => s.familyId === userFamilyId);
|
|
if (!myShare) return null;
|
|
const remaining = Math.max(0, myShare.amountDue - myShare.amountPaid);
|
|
|
|
if (remaining < 0.01) {
|
|
return (
|
|
<div className="flex justify-end items-center gap-4">
|
|
<span className="text-green-600 font-bold flex items-center gap-2 text-sm"><CheckCircle2 className="w-5 h-5"/> Quota Saldata</span>
|
|
<button onClick={() => setShowDetails(false)} className="px-6 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 font-medium text-sm">Chiudi</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center text-sm">
|
|
<span className="font-bold text-slate-700">Da Saldare:</span>
|
|
<span className="font-bold text-red-600 text-lg">€ {remaining.toFixed(2)}</span>
|
|
</div>
|
|
{paypalClientId ? (
|
|
<PayPalScriptProvider options={{ clientId: paypalClientId, currency: "EUR" }}>
|
|
<PayPalButtons
|
|
style={{ layout: "horizontal", height: 45 }}
|
|
createOrder={(data, actions) => {
|
|
return actions.order.create({
|
|
intent: "CAPTURE",
|
|
purchase_units: [{
|
|
description: `Spesa Straordinaria: ${selectedExpense.title}`,
|
|
amount: { currency_code: "EUR", value: remaining.toFixed(2) }
|
|
}]
|
|
});
|
|
}}
|
|
onApprove={async (data, actions) => {
|
|
if(!actions.order) return;
|
|
try {
|
|
await actions.order.capture();
|
|
await handlePaymentSuccess(selectedExpense.id, remaining);
|
|
} catch (err) {
|
|
console.error("PayPal Capture Error", err);
|
|
alert("Errore durante il completamento del pagamento PayPal.");
|
|
}
|
|
}}
|
|
onError={(err) => {
|
|
console.error("PayPal Button Error", err);
|
|
alert("Errore caricamento PayPal: " + err.toString());
|
|
}}
|
|
/>
|
|
</PayPalScriptProvider>
|
|
) : (
|
|
<div className="bg-red-50 text-red-600 p-2 rounded text-center text-xs">
|
|
Pagamenti online non configurati.
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end">
|
|
<button onClick={() => setShowDetails(false)} className="w-full sm:w-auto px-6 py-2 border border-slate-300 text-slate-600 rounded-lg hover:bg-slate-100 font-medium text-sm">Chiudi</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|