Files
Condopay/pages/FamilyDetail.tsx
2025-12-12 19:25:48 +01:00

569 lines
28 KiB
TypeScript

import React, { useEffect, useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { CondoService } from '../services/mockDb';
import { Family, Payment, AppSettings, MonthStatus, PaymentStatus, Condo } from '../types';
import { ArrowLeft, CheckCircle2, AlertCircle, Plus, Calendar, CreditCard, TrendingUp } from 'lucide-react';
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
const MONTH_NAMES = [
"Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno",
"Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre"
];
export const FamilyDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const currentUser = CondoService.getCurrentUser();
const isPrivileged = currentUser?.role === 'admin' || currentUser?.role === 'poweruser';
const [family, setFamily] = useState<Family | null>(null);
const [payments, setPayments] = useState<Payment[]>([]);
const [settings, setSettings] = useState<AppSettings | null>(null);
const [condo, setCondo] = useState<Condo | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
const [availableYears, setAvailableYears] = useState<number[]>([]);
const [showAddModal, setShowAddModal] = useState(false);
const [newPaymentMonth, setNewPaymentMonth] = useState<number>(new Date().getMonth() + 1);
const [newPaymentAmount, setNewPaymentAmount] = useState<number>(0);
const [isSubmitting, setIsSubmitting] = useState(false);
// Payment Method Selection
const [paymentMethod, setPaymentMethod] = useState<'manual' | 'paypal'>('manual');
const [paypalSuccessMsg, setPaypalSuccessMsg] = useState('');
useEffect(() => {
if (!id) return;
const loadData = async () => {
setLoading(true);
try {
const [famList, famPayments, appSettings, years, activeCondo] = await Promise.all([
CondoService.getFamilies(),
CondoService.getPaymentsByFamily(id),
CondoService.getSettings(),
CondoService.getAvailableYears(),
CondoService.getActiveCondo()
]);
const foundFamily = famList.find(f => f.id === id);
if (foundFamily) {
setFamily(foundFamily);
setPayments(famPayments);
setSettings(appSettings);
setCondo(activeCondo);
// Use Family Custom Quota OR Condo Default
const defaultAmount = foundFamily.customMonthlyQuota ?? activeCondo?.defaultMonthlyQuota ?? 100;
setNewPaymentAmount(defaultAmount);
setAvailableYears(years);
setSelectedYear(appSettings.currentYear);
} else {
navigate('/');
}
} catch (e) {
console.error("Error loading family details", e);
} finally {
setLoading(false);
}
};
loadData();
}, [id, navigate]);
const monthlyStatus: MonthStatus[] = useMemo(() => {
const now = new Date();
const currentRealYear = now.getFullYear();
const currentRealMonth = now.getMonth() + 1; // 1-12
const currentDay = now.getDate();
// Default due day is 10 if not set
const dueDay = condo?.dueDay || 10;
return Array.from({ length: 12 }, (_, i) => {
const monthNum = i + 1;
const payment = payments.find(p => p.forMonth === monthNum && p.forYear === selectedYear);
let status = PaymentStatus.UPCOMING;
if (payment) {
status = PaymentStatus.PAID;
} else {
if (selectedYear < currentRealYear) {
status = PaymentStatus.UNPAID; // Past year unpaid
} else if (selectedYear === currentRealYear) {
if (monthNum < currentRealMonth) {
// Past month in current year -> Unpaid
status = PaymentStatus.UNPAID;
} else if (monthNum === currentRealMonth) {
// Current month
if (currentDay > dueDay) {
status = PaymentStatus.UNPAID; // Passed due date
} else if (currentDay >= (dueDay - 10)) {
status = PaymentStatus.PENDING; // Within 10 days before due
} else {
status = PaymentStatus.UPCOMING; // Early in the month
}
} else {
status = PaymentStatus.UPCOMING; // Future month
}
} else {
status = PaymentStatus.UPCOMING; // Future year
}
}
return {
monthIndex: i,
status,
payment
};
});
}, [payments, selectedYear, condo]);
const chartData = useMemo(() => {
return monthlyStatus.map(m => ({
label: MONTH_NAMES[m.monthIndex].substring(0, 3),
fullLabel: MONTH_NAMES[m.monthIndex],
amount: m.payment ? m.payment.amount : 0,
isPaid: m.status === PaymentStatus.PAID,
isFuture: m.status === PaymentStatus.UPCOMING
}));
}, [monthlyStatus]);
const maxChartValue = useMemo(() => {
const max = Math.max(...chartData.map(d => d.amount));
// Check family specific quota first
const baseline = family?.customMonthlyQuota ?? condo?.defaultMonthlyQuota ?? 100;
return max > 0 ? Math.max(max * 1.2, baseline) : baseline;
}, [chartData, condo, family]);
const handlePaymentSuccess = async (details?: any) => {
if (!family || !id) return;
setIsSubmitting(true);
try {
// Format date to SQL compatible string: YYYY-MM-DD HH:mm:ss
// This is safe for both MySQL (Strict Mode) and PostgreSQL
const now = new Date();
const sqlDate = now.toISOString().slice(0, 19).replace('T', ' ');
const payment = await CondoService.addPayment({
familyId: id,
amount: newPaymentAmount,
forMonth: newPaymentMonth,
forYear: selectedYear,
datePaid: sqlDate,
notes: details ? `Pagato con PayPal (ID: ${details.id})` : ''
});
setPayments([...payments, payment]);
if (!availableYears.includes(selectedYear)) {
setAvailableYears([...availableYears, selectedYear].sort((a,b) => b-a));
}
if (details) {
setPaypalSuccessMsg("Pagamento riuscito!");
setTimeout(() => {
setShowAddModal(false);
setPaypalSuccessMsg("");
}, 2000);
} else {
setShowAddModal(false);
}
} catch (e: any) {
console.error("Failed to add payment", e);
// Clean error message if it's a JSON string from backend
let msg = e.message || "Errore sconosciuto";
try {
const parsed = JSON.parse(msg);
if (parsed.error && parsed.error.includes("Incorrect datetime")) {
msg = "Errore data sistema (DB strict mode). Contatta l'assistenza.";
} else if (parsed.error) {
msg = parsed.error;
}
} catch {}
alert(`Errore durante il salvataggio: ${msg}`);
} finally {
setIsSubmitting(false);
}
};
const handleManualSubmit = (e: React.FormEvent) => {
e.preventDefault();
handlePaymentSuccess();
};
const isPayPalEnabled = condo?.paypalClientId && settings?.features.payPal;
const handleOpenAddModal = (monthIndex?: number) => {
if (monthIndex !== undefined) {
setNewPaymentMonth(monthIndex + 1);
}
// LOGIC:
// Admin -> Defaults to Manual (can switch if PayPal enabled)
// User -> Defaults to PayPal (cannot switch to manual)
if (isPrivileged) {
setPaymentMethod('manual');
} else {
setPaymentMethod('paypal');
}
setShowAddModal(true);
};
if (loading) return <div className="p-8 text-center text-slate-500">Caricamento dettagli...</div>;
if (!family) return <div className="p-8 text-center text-red-500">Famiglia non trovata.</div>;
return (
<div className="space-y-6 md:space-y-8 animate-fade-in pb-20 md:pb-12">
{/* Header Responsive */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/')}
className="p-2 rounded-full hover:bg-slate-200 text-slate-600 transition-colors flex-shrink-0"
>
<ArrowLeft className="w-6 h-6" />
</button>
<div className="min-w-0">
<h2 className="text-2xl md:text-3xl font-bold text-slate-900 truncate">{family.name}</h2>
<div className="flex items-center gap-2 text-slate-500 mt-1 text-sm md:text-base">
<BuildingIcon className="w-4 h-4 flex-shrink-0" />
<span>Interno: {family.unitNumber}</span>
{family.customMonthlyQuota && (
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-bold">
Quota Personalizzata: {family.customMonthlyQuota}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3 w-full md:w-auto">
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="flex-1 md:flex-none border border-slate-300 rounded-lg px-3 py-2.5 bg-white text-slate-700 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{availableYears.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
<button
onClick={() => handleOpenAddModal()}
className="flex-1 md:flex-none flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2.5 rounded-lg shadow-sm font-medium transition-all active:scale-95 whitespace-nowrap"
>
<Plus className="w-5 h-5" />
<span className="hidden sm:inline">Nuovo Pagamento</span>
<span className="sm:hidden">Paga</span>
</button>
</div>
</div>
{/* Stats Summary - Stack on mobile */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm flex items-center gap-4">
<div className="p-3 bg-green-100 rounded-lg text-green-600 flex-shrink-0">
<CheckCircle2 className="w-6 h-6 md:w-8 md:h-8" />
</div>
<div>
<p className="text-xs md:text-sm font-medium text-slate-500 uppercase">Mesi Saldati</p>
<p className="text-xl md:text-2xl font-bold text-slate-800">
{monthlyStatus.filter(m => m.status === PaymentStatus.PAID).length} <span className="text-sm font-normal text-slate-400">/ 12</span>
</p>
</div>
</div>
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm flex items-center gap-4">
<div className="p-3 bg-red-100 rounded-lg text-red-600 flex-shrink-0">
<AlertCircle className="w-6 h-6 md:w-8 md:h-8" />
</div>
<div>
<p className="text-xs md:text-sm font-medium text-slate-500 uppercase">Mesi Insoluti</p>
<p className="text-xl md:text-2xl font-bold text-slate-800">
{monthlyStatus.filter(m => m.status === PaymentStatus.UNPAID).length}
</p>
</div>
</div>
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm flex items-center gap-4">
<div className="p-3 bg-blue-100 rounded-lg text-blue-600 flex-shrink-0">
<CreditCard className="w-6 h-6 md:w-8 md:h-8" />
</div>
<div>
<p className="text-xs md:text-sm font-medium text-slate-500 uppercase">Totale Versato</p>
<p className="text-xl md:text-2xl font-bold text-slate-800">
{payments.filter(p => p.forYear === selectedYear).reduce((acc, curr) => acc + curr.amount, 0).toLocaleString()}
</p>
</div>
</div>
</div>
{/* Monthly Grid */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 bg-slate-50/50">
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Calendar className="w-5 h-5 text-slate-500" />
Dettaglio {selectedYear} <span className="text-xs font-normal text-slate-400 ml-2">(Scadenza: Giorno {condo?.dueDay || 10})</span>
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-0 divide-y sm:divide-y-0 sm:gap-px bg-slate-200 border-collapse">
{monthlyStatus.map((month) => (
<div
key={month.monthIndex}
className={`
p-5 bg-white transition-colors
${month.status === PaymentStatus.UNPAID ? 'bg-red-50/30' : ''}
${month.status === PaymentStatus.PAID ? 'bg-green-50/30' : ''}
${month.status === PaymentStatus.PENDING ? 'bg-yellow-50/30' : ''}
`}
>
<div className="flex justify-between items-center mb-3">
<span className="font-semibold text-slate-700 capitalize">{MONTH_NAMES[month.monthIndex]}</span>
{month.status === PaymentStatus.PAID && (
<span className="bg-green-100 text-green-700 text-[10px] px-2 py-0.5 rounded-full font-bold uppercase tracking-wide">Saldato</span>
)}
{month.status === PaymentStatus.UNPAID && (
<span className="bg-red-100 text-red-700 text-[10px] px-2 py-0.5 rounded-full font-bold uppercase tracking-wide">Insoluto</span>
)}
{month.status === PaymentStatus.PENDING && (
<span className="bg-yellow-100 text-yellow-700 text-[10px] px-2 py-0.5 rounded-full font-bold uppercase tracking-wide">In Scadenza</span>
)}
{month.status === PaymentStatus.UPCOMING && (
<span className="bg-slate-100 text-slate-500 text-[10px] px-2 py-0.5 rounded-full font-bold uppercase tracking-wide">Futuro</span>
)}
</div>
<div className="min-h-[3rem] flex flex-col justify-end">
{month.payment ? (
<div className="text-sm">
<p className="text-slate-600 flex justify-between">
<span>Importo:</span>
<span className="font-bold text-slate-900"> {month.payment.amount}</span>
</p>
<p className="text-slate-400 text-xs mt-1 text-right">
{new Date(month.payment.datePaid).toLocaleDateString()}
</p>
</div>
) : (
<div className="flex items-center gap-2 text-sm mt-auto">
<p className="text-slate-400 italic text-xs">Nessun pagamento</p>
{(month.status === PaymentStatus.UNPAID || month.status === PaymentStatus.PENDING) && (
<button
onClick={() => handleOpenAddModal(month.monthIndex)}
className="ml-auto text-blue-600 hover:text-blue-800 text-xs font-bold uppercase tracking-wide px-2 py-1 rounded hover:bg-blue-50 transition-colors"
>
Paga
</button>
)}
</div>
)}
</div>
</div>
))}
</div>
</div>
{/* Payment Trend Chart (Scrollable) */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-slate-500" />
Andamento
</h3>
<div className="text-xs font-medium text-slate-400 flex items-center gap-3">
<span className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-blue-500"></div>Versato</span>
<span className="flex items-center gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-slate-200"></div>Mancante</span>
</div>
</div>
<div className="p-4 md:p-8 overflow-x-auto">
<div className="h-56 md:h-64 min-w-[600px] w-full flex justify-between gap-3">
{chartData.map((data, index) => {
const heightPercentage = Math.max((data.amount / maxChartValue) * 100, 4);
return (
<div key={index} className="flex-1 flex flex-col items-center gap-2 group relative">
<div className="w-full flex-1 flex flex-col justify-end relative rounded-t-lg overflow-hidden bg-slate-50 hover:bg-slate-100 transition-colors">
{data.amount > 0 && (
<div
style={{ height: `${heightPercentage}%` }}
className={`w-full transition-all duration-500 ease-out rounded-t-sm ${
data.isPaid ? 'bg-blue-500' : 'bg-slate-300'
}`}
></div>
)}
{data.amount === 0 && !data.isFuture && (
<div className="w-full h-1 bg-red-300 absolute bottom-0"></div>
)}
</div>
<span className={`text-[10px] md:text-xs font-bold uppercase tracking-wide ${
data.amount > 0 ? 'text-slate-700' : 'text-slate-400'
}`}>
{data.label}
</span>
</div>
);
})}
</div>
</div>
</div>
{/* Add Payment Modal */}
{showAddModal && (
<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-2xl shadow-xl w-full max-w-sm md:max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="bg-slate-50 px-6 py-4 border-b border-slate-100 flex justify-between items-center">
<h3 className="font-bold text-lg text-slate-800">Registra Pagamento</h3>
<button onClick={() => setShowAddModal(false)} className="text-slate-400 hover:text-slate-600 w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-200"></button>
</div>
<div className="p-6">
{/* Payment Method Switcher - ONLY FOR PRIVILEGED USERS */}
{isPayPalEnabled && isPrivileged && (
<div className="flex bg-slate-100 rounded-lg p-1 mb-6">
<button
onClick={() => setPaymentMethod('manual')}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-all ${paymentMethod === 'manual' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Manuale
</button>
<button
onClick={() => setPaymentMethod('paypal')}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-all ${paymentMethod === 'paypal' ? 'bg-white text-blue-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
PayPal
</button>
</div>
)}
{/* Non-privileged users (regular tenants) without PayPal enabled: Show Block Message */}
{!isPrivileged && !isPayPalEnabled ? (
<div className="text-center py-6">
<AlertCircle className="w-16 h-16 text-slate-300 mx-auto mb-4" />
<h4 className="text-lg font-bold text-slate-700">Pagamenti Online non attivi</h4>
<p className="text-slate-500 mt-2 text-sm">Contatta l'amministratore per saldare la tua rata in contanti o bonifico.</p>
<button
onClick={() => setShowAddModal(false)}
className="mt-6 px-6 py-2 bg-slate-100 text-slate-600 rounded-lg font-medium hover:bg-slate-200"
>
Chiudi
</button>
</div>
) : (
<>
{paymentMethod === 'manual' && isPrivileged ? (
<form onSubmit={handleManualSubmit} className="space-y-5">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-1.5">Mese di Riferimento</label>
<select
value={newPaymentMonth}
onChange={(e) => setNewPaymentMonth(parseInt(e.target.value))}
className="w-full border border-slate-300 rounded-xl p-3 focus:ring-2 focus:ring-blue-500 outline-none bg-white"
>
{MONTH_NAMES.map((name, i) => (
<option key={i} value={i + 1}>{name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-1.5">Importo (€)</label>
<input
type="number"
step="0.01"
required
value={newPaymentAmount}
onChange={(e) => setNewPaymentAmount(parseFloat(e.target.value))}
className="w-full border border-slate-300 rounded-xl p-3 focus:ring-2 focus:ring-blue-500 outline-none text-lg font-medium"
/>
</div>
<div className="pt-2 flex gap-3">
<button
type="button"
onClick={() => setShowAddModal(false)}
className="flex-1 px-4 py-3 border border-slate-300 text-slate-700 rounded-xl hover:bg-slate-50 font-bold text-sm"
>
Annulla
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-3 bg-blue-600 text-white rounded-xl hover:bg-blue-700 font-bold text-sm disabled:opacity-50"
>
{isSubmitting ? '...' : 'Conferma'}
</button>
</div>
</form>
) : (
<div className="space-y-6">
<div className="bg-blue-50 p-4 rounded-xl text-center">
<p className="text-sm text-slate-500 mb-1">Stai per pagare</p>
<p className="text-3xl font-bold text-blue-700"> {newPaymentAmount.toFixed(2)}</p>
<p className="text-sm font-medium text-slate-600 mt-1">{MONTH_NAMES[newPaymentMonth - 1]} {selectedYear}</p>
</div>
{paypalSuccessMsg ? (
<div className="bg-green-100 text-green-700 p-4 rounded-xl text-center font-bold flex items-center justify-center gap-2">
<CheckCircle2 className="w-5 h-5"/>
{paypalSuccessMsg}
</div>
) : (
<div className="min-h-[150px] flex items-center justify-center">
{isPayPalEnabled && (
<PayPalScriptProvider options={{ clientId: condo.paypalClientId!, currency: "EUR" }}>
<PayPalButtons
style={{ layout: "vertical" }}
createOrder={(data, actions) => {
return actions.order.create({
intent: "CAPTURE",
purchase_units: [
{
description: `Quota ${MONTH_NAMES[newPaymentMonth - 1]} ${selectedYear} - Famiglia ${family.name}`,
amount: {
currency_code: "EUR",
value: newPaymentAmount.toFixed(2),
},
},
],
});
}}
onApprove={async (data, actions) => {
if(!actions.order) return;
try {
const details = await actions.order.capture();
await handlePaymentSuccess(details);
} 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>
)}
<p className="text-xs text-center text-slate-400">Il pagamento sarà registrato automaticamente.</p>
</div>
)}
</>
)}
</div>
</div>
</div>
)}
</div>
);
};
const BuildingIcon = ({className}:{className?:string}) => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><rect width="16" height="20" x="4" y="2" rx="2" ry="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01"/><path d="M16 6h.01"/><path d="M8 10h.01"/><path d="M16 10h.01"/><path d="M8 14h.01"/><path d="M16 14h.01"/></svg>
);