Initializes the Condopay frontend project using Vite, React, and TypeScript. Includes basic project structure, dependencies, and configuration for Tailwind CSS and React Router.
394 lines
18 KiB
TypeScript
394 lines
18 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 } from '../types';
|
|
import { ArrowLeft, CheckCircle2, AlertCircle, Plus, Calendar, CreditCard, TrendingUp } from 'lucide-react';
|
|
|
|
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 [family, setFamily] = useState<Family | null>(null);
|
|
const [payments, setPayments] = useState<Payment[]>([]);
|
|
const [settings, setSettings] = useState<AppSettings | null>(null);
|
|
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);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [famList, famPayments, appSettings, years] = await Promise.all([
|
|
CondoService.getFamilies(),
|
|
CondoService.getPaymentsByFamily(id),
|
|
CondoService.getSettings(),
|
|
CondoService.getAvailableYears()
|
|
]);
|
|
|
|
const foundFamily = famList.find(f => f.id === id);
|
|
if (foundFamily) {
|
|
setFamily(foundFamily);
|
|
setPayments(famPayments);
|
|
setSettings(appSettings);
|
|
setNewPaymentAmount(appSettings.defaultMonthlyQuota);
|
|
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();
|
|
|
|
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;
|
|
} else if (selectedYear === currentRealYear) {
|
|
if (i < currentRealMonth) status = PaymentStatus.UNPAID;
|
|
else if (i === currentRealMonth) status = PaymentStatus.PENDING;
|
|
else status = PaymentStatus.UPCOMING;
|
|
} else {
|
|
status = PaymentStatus.UPCOMING;
|
|
}
|
|
}
|
|
|
|
return {
|
|
monthIndex: i,
|
|
status,
|
|
payment
|
|
};
|
|
});
|
|
}, [payments, selectedYear]);
|
|
|
|
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));
|
|
const baseline = settings?.defaultMonthlyQuota || 100;
|
|
return max > 0 ? Math.max(max * 1.2, baseline) : baseline;
|
|
}, [chartData, settings]);
|
|
|
|
const handleAddPayment = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!family || !id) return;
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
const payment = await CondoService.addPayment({
|
|
familyId: id,
|
|
amount: newPaymentAmount,
|
|
forMonth: newPaymentMonth,
|
|
forYear: selectedYear,
|
|
datePaid: new Date().toISOString()
|
|
});
|
|
|
|
setPayments([...payments, payment]);
|
|
if (!availableYears.includes(selectedYear)) {
|
|
setAvailableYears([...availableYears, selectedYear].sort((a,b) => b-a));
|
|
}
|
|
|
|
setShowAddModal(false);
|
|
} catch (e) {
|
|
console.error("Failed to add payment", e);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
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>
|
|
</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={() => setShowAddModal(true)}
|
|
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}
|
|
</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' : ''}
|
|
`}
|
|
>
|
|
<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 && (
|
|
<button
|
|
onClick={() => {
|
|
setNewPaymentMonth(month.monthIndex + 1);
|
|
setShowAddModal(true);
|
|
}}
|
|
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>
|
|
|
|
<form onSubmit={handleAddPayment} className="p-6 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>
|
|
</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>
|
|
); |