Files
Condopay/pages/FamilyDetail.tsx
frakarr 919be985c9 feat: Introduce app feature flags
This commit refactors the application settings to include a new `AppFeatures` interface. This allows for granular control over which features are enabled for the application.

The `AppFeatures` object includes boolean flags for:
- `multiCondo`: Enables or disables the multi-condominium management feature.
- `tickets`: Placeholder for future ticket system integration.
- `payPal`: Enables or disables PayPal payment gateway integration.
- `notices`: Enables or disables the display and management of notices.

These flags are now fetched and stored in the application state, influencing UI elements and logic across various pages to conditionally render features based on their enabled status. For example, the multi-condo selection in `Layout.tsx` and the notice display in `FamilyList.tsx` are now gated by these feature flags. The `FamilyDetail.tsx` page also uses the `payPal` flag to conditionally enable the PayPal payment option.

The `SettingsPage.tsx` has been updated to include a new 'features' tab for managing these flags.
2025-12-07 20:21:01 +01:00

499 lines
24 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 [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();
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));
// 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 {
const payment = await CondoService.addPayment({
familyId: id,
amount: newPaymentAmount,
forMonth: newPaymentMonth,
forYear: selectedYear,
datePaid: new Date().toISOString(),
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) {
console.error("Failed to add payment", e);
} finally {
setIsSubmitting(false);
}
};
const handleManualSubmit = (e: React.FormEvent) => {
e.preventDefault();
handlePaymentSuccess();
};
const isPayPalEnabled = condo?.paypalClientId && settings?.features.payPal;
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={() => {
setPaymentMethod(isPayPalEnabled ? 'paypal' : 'manual');
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);
setPaymentMethod(isPayPalEnabled ? 'paypal' : 'manual');
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>
<div className="p-6">
{/* Payment Method Switcher */}
{isPayPalEnabled && (
<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>
)}
{paymentMethod === 'manual' ? (
<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.toString(),
},
},
],
});
}}
onApprove={(data, actions) => {
if(!actions.order) return Promise.resolve();
return actions.order.capture().then((details) => {
handlePaymentSuccess(details);
});
}}
/>
</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>
);