feat: Setup project with Vite and React
Initializes the Condopay frontend project using Vite, React, and TypeScript. Includes basic project structure, dependencies, and configuration for Tailwind CSS and React Router.
This commit is contained in:
394
pages/FamilyDetail.tsx
Normal file
394
pages/FamilyDetail.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
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>
|
||||
);
|
||||
102
pages/FamilyList.tsx
Normal file
102
pages/FamilyList.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CondoService } from '../services/mockDb';
|
||||
import { Family, AppSettings } from '../types';
|
||||
import { Search, ChevronRight, UserCircle } from 'lucide-react';
|
||||
|
||||
export const FamilyList: React.FC = () => {
|
||||
const [families, setFamilies] = useState<Family[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
CondoService.seedPayments();
|
||||
const [fams, sets] = await Promise.all([
|
||||
CondoService.getFamilies(),
|
||||
CondoService.getSettings()
|
||||
]);
|
||||
setFamilies(fams);
|
||||
setSettings(sets);
|
||||
} catch (e) {
|
||||
console.error("Error fetching data", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const filteredFamilies = families.filter(f =>
|
||||
f.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
f.unitNumber.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center items-center h-64 text-slate-400">Caricamento in corso...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Responsive Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">Elenco Condomini</h2>
|
||||
<p className="text-slate-500 text-sm md:text-base">{settings?.condoName || 'Gestione Condominiale'}</p>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full md:w-80 lg:w-96">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-slate-300 rounded-xl leading-5 bg-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition duration-150 ease-in-out sm:text-sm shadow-sm"
|
||||
placeholder="Cerca nome o interno..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="bg-white shadow-sm rounded-xl overflow-hidden border border-slate-200">
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{filteredFamilies.length === 0 ? (
|
||||
<li className="p-8 text-center text-slate-500 flex flex-col items-center gap-2">
|
||||
<Search className="w-8 h-8 text-slate-300" />
|
||||
<span>Nessuna famiglia trovata.</span>
|
||||
</li>
|
||||
) : (
|
||||
filteredFamilies.map((family) => (
|
||||
<li key={family.id} className="hover:bg-slate-50 transition-colors active:bg-slate-100">
|
||||
<Link to={`/family/${family.id}`} className="block p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 sm:gap-4 overflow-hidden">
|
||||
<div className="bg-blue-100 p-2 sm:p-2.5 rounded-full flex-shrink-0">
|
||||
<UserCircle className="w-6 h-6 sm:w-8 sm:h-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-base sm:text-lg font-semibold text-blue-600 truncate">
|
||||
{family.name}
|
||||
</p>
|
||||
<p className="flex items-center text-sm text-slate-500 truncate">
|
||||
Interno: <span className="font-medium text-slate-700 ml-1">{family.unitNumber}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<ChevronRight className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
98
pages/Login.tsx
Normal file
98
pages/Login.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CondoService } from '../services/mockDb';
|
||||
import { Building, Lock, Mail, AlertCircle } from 'lucide-react';
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await CondoService.login(email, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError('Credenziali non valide o errore di connessione.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm sm:max-w-md overflow-hidden">
|
||||
<div className="bg-blue-600 p-8 text-center">
|
||||
<div className="inline-flex p-3 bg-white/20 rounded-xl mb-4">
|
||||
<Building className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">CondoPay</h1>
|
||||
<p className="text-blue-100 mt-2">Gestione Condominiale Semplice</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 sm:p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Email</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-slate-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
|
||||
placeholder="admin@condominio.it"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Password</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-slate-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-70 transition-colors"
|
||||
>
|
||||
{loading ? 'Accesso in corso...' : 'Accedi'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-xs text-slate-400">
|
||||
© 2024 CondoPay Manager
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
646
pages/Settings.tsx
Normal file
646
pages/Settings.tsx
Normal file
@@ -0,0 +1,646 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { CondoService } from '../services/mockDb';
|
||||
import { AppSettings, Family, User } from '../types';
|
||||
import { Save, Building, Coins, Plus, Pencil, Trash2, X, CalendarCheck, AlertTriangle, UserCog, Mail, Phone, Lock, Shield, User as UserIcon } from 'lucide-react';
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'families' | 'users'>('general');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// General Settings State
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
defaultMonthlyQuota: 0,
|
||||
condoName: '',
|
||||
currentYear: new Date().getFullYear()
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
|
||||
// Families State
|
||||
const [families, setFamilies] = useState<Family[]>([]);
|
||||
const [showFamilyModal, setShowFamilyModal] = useState(false);
|
||||
const [editingFamily, setEditingFamily] = useState<Family | null>(null);
|
||||
const [familyForm, setFamilyForm] = useState({ name: '', unitNumber: '', contactEmail: '' });
|
||||
|
||||
// Users State
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [showUserModal, setShowUserModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [userForm, setUserForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
phone: '',
|
||||
role: 'user',
|
||||
familyId: ''
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [s, f, u] = await Promise.all([
|
||||
CondoService.getSettings(),
|
||||
CondoService.getFamilies(),
|
||||
CondoService.getUsers()
|
||||
]);
|
||||
setSettings(s);
|
||||
setFamilies(f);
|
||||
setUsers(u);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// --- Settings Handlers ---
|
||||
|
||||
const handleSettingsSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setSuccessMsg('');
|
||||
try {
|
||||
await CondoService.updateSettings(settings);
|
||||
setSuccessMsg('Impostazioni salvate con successo!');
|
||||
setTimeout(() => setSuccessMsg(''), 3000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewYear = async () => {
|
||||
const nextYear = settings.currentYear + 1;
|
||||
if (window.confirm(`Sei sicuro di voler chiudere l'anno ${settings.currentYear} e aprire il ${nextYear}? \n\nI dati vecchi non verranno cancellati, ma la visualizzazione di default passerà al nuovo anno con saldi azzerati.`)) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const newSettings = { ...settings, currentYear: nextYear };
|
||||
await CondoService.updateSettings(newSettings);
|
||||
setSettings(newSettings);
|
||||
setSuccessMsg(`Anno ${nextYear} aperto con successo!`);
|
||||
setTimeout(() => setSuccessMsg(''), 3000);
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Family Handlers ---
|
||||
|
||||
const openAddFamilyModal = () => {
|
||||
setEditingFamily(null);
|
||||
setFamilyForm({ name: '', unitNumber: '', contactEmail: '' });
|
||||
setShowFamilyModal(true);
|
||||
};
|
||||
|
||||
const openEditFamilyModal = (family: Family) => {
|
||||
setEditingFamily(family);
|
||||
setFamilyForm({
|
||||
name: family.name,
|
||||
unitNumber: family.unitNumber,
|
||||
contactEmail: family.contactEmail || ''
|
||||
});
|
||||
setShowFamilyModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteFamily = async (id: string) => {
|
||||
if (!window.confirm('Sei sicuro di voler eliminare questa famiglia? Tutti i dati e lo storico pagamenti verranno persi.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await CondoService.deleteFamily(id);
|
||||
setFamilies(families.filter(f => f.id !== id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFamilySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingFamily) {
|
||||
const updatedFamily = {
|
||||
...editingFamily,
|
||||
name: familyForm.name,
|
||||
unitNumber: familyForm.unitNumber,
|
||||
contactEmail: familyForm.contactEmail
|
||||
};
|
||||
await CondoService.updateFamily(updatedFamily);
|
||||
setFamilies(families.map(f => f.id === updatedFamily.id ? updatedFamily : f));
|
||||
} else {
|
||||
const newFamily = await CondoService.addFamily(familyForm);
|
||||
setFamilies([...families, newFamily]);
|
||||
}
|
||||
setShowFamilyModal(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
// --- User Handlers ---
|
||||
|
||||
const openAddUserModal = () => {
|
||||
setEditingUser(null);
|
||||
setUserForm({ name: '', email: '', password: '', phone: '', role: 'user', familyId: '' });
|
||||
setShowUserModal(true);
|
||||
};
|
||||
|
||||
const openEditUserModal = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setUserForm({
|
||||
name: user.name || '',
|
||||
email: user.email,
|
||||
password: '',
|
||||
phone: user.phone || '',
|
||||
role: user.role || 'user',
|
||||
familyId: user.familyId || ''
|
||||
});
|
||||
setShowUserModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (id: string) => {
|
||||
if(!window.confirm("Sei sicuro di voler eliminare questo utente?")) return;
|
||||
try {
|
||||
await CondoService.deleteUser(id);
|
||||
setUsers(users.filter(u => u.id !== id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingUser) {
|
||||
await CondoService.updateUser(editingUser.id, userForm);
|
||||
const updatedUsers = await CondoService.getUsers();
|
||||
setUsers(updatedUsers);
|
||||
} else {
|
||||
await CondoService.createUser(userForm);
|
||||
const updatedUsers = await CondoService.getUsers();
|
||||
setUsers(updatedUsers);
|
||||
}
|
||||
setShowUserModal(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Errore nel salvataggio utente");
|
||||
}
|
||||
};
|
||||
|
||||
const getFamilyName = (id: string | null | undefined) => {
|
||||
if (!id) return '-';
|
||||
return families.find(f => f.id === id)?.name || 'Sconosciuta';
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-slate-400">Caricamento...</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-20">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">Impostazioni</h2>
|
||||
<p className="text-slate-500 text-sm md:text-base">Gestisci configurazione, anagrafica e utenti.</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs - Scrollable on mobile */}
|
||||
<div className="flex border-b border-slate-200 overflow-x-auto no-scrollbar pb-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('general')}
|
||||
className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${
|
||||
activeTab === 'general' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Generale
|
||||
{activeTab === 'general' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('families')}
|
||||
className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${
|
||||
activeTab === 'families' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Famiglie
|
||||
{activeTab === 'families' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${
|
||||
activeTab === 'users' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Utenti
|
||||
{activeTab === 'users' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* General Data Form */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 md:p-8 max-w-2xl">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-4">Dati Generali</h3>
|
||||
<form onSubmit={handleSettingsSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<Building className="w-4 h-4" />
|
||||
Nome Condominio
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.condoName}
|
||||
onChange={(e) => setSettings({ ...settings, condoName: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||||
placeholder="Es. Condominio Roma"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<Coins className="w-4 h-4" />
|
||||
Quota Mensile (€)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={settings.defaultMonthlyQuota}
|
||||
onChange={(e) => setSettings({ ...settings, defaultMonthlyQuota: parseFloat(e.target.value) })}
|
||||
className="w-full border border-slate-300 rounded-lg px-4 py-2.5 pl-8 focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||||
required
|
||||
/>
|
||||
<span className="absolute left-3 top-2.5 text-slate-400">€</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex flex-col-reverse md:flex-row items-center justify-between gap-4">
|
||||
<span className={`text-sm font-medium h-5 ${successMsg ? 'text-green-600' : ''}`}>{successMsg}</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full md:w-auto flex items-center justify-center gap-2 bg-blue-600 text-white px-6 py-2.5 rounded-lg font-medium hover:bg-blue-700 transition-all disabled:opacity-70"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '...' : 'Salva'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Fiscal Year Management */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 md:p-8 max-w-2xl">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-2 flex items-center gap-2">
|
||||
<CalendarCheck className="w-5 h-5 text-slate-600" />
|
||||
Anno Fiscale
|
||||
</h3>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-slate-600 text-sm">
|
||||
Anno corrente: <span className="font-bold text-slate-900 text-lg">{settings.currentYear}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3 bg-amber-50 p-3 rounded-lg border border-amber-100">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-amber-800">
|
||||
Chiudendo l'anno, il sistema passerà al <strong>{settings.currentYear + 1}</strong>. I dati storici rimarranno consultabili.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewYear}
|
||||
disabled={saving}
|
||||
className="w-full md:w-auto self-end bg-slate-800 text-white px-4 py-2.5 rounded-lg font-medium hover:bg-slate-900 transition-all disabled:opacity-70 text-sm"
|
||||
>
|
||||
Chiudi {settings.currentYear} e apri {settings.currentYear + 1}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'families' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={openAddFamilyModal}
|
||||
className="w-full md:w-auto flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg font-medium shadow-sm transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Aggiungi Famiglia
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<table className="w-full text-left text-sm text-slate-600">
|
||||
<thead className="bg-slate-50 text-slate-700 font-semibold border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Nome Famiglia</th>
|
||||
<th className="px-6 py-4">Interno</th>
|
||||
<th className="px-6 py-4">Email</th>
|
||||
<th className="px-6 py-4 text-right">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{families.map(family => (
|
||||
<tr key={family.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-slate-900">{family.name}</td>
|
||||
<td className="px-6 py-4">{family.unitNumber}</td>
|
||||
<td className="px-6 py-4 text-slate-400">{family.contactEmail || '-'}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => openEditFamilyModal(family)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg"><Pencil className="w-4 h-4" /></button>
|
||||
<button onClick={() => handleDeleteFamily(family.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards for Families */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{families.map(family => (
|
||||
<div key={family.id} className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-800 text-lg">{family.name}</h4>
|
||||
<p className="text-sm text-slate-500 font-medium">Interno: {family.unitNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mb-4 flex items-center gap-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
{family.contactEmail || 'Nessuna email'}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-slate-100 pt-3">
|
||||
<button onClick={() => openEditFamilyModal(family)} className="flex items-center justify-center gap-2 py-2 text-blue-600 bg-blue-50 rounded-lg text-sm font-bold">
|
||||
<Pencil className="w-4 h-4" /> Modifica
|
||||
</button>
|
||||
<button onClick={() => handleDeleteFamily(family.id)} className="flex items-center justify-center gap-2 py-2 text-red-600 bg-red-50 rounded-lg text-sm font-bold">
|
||||
<Trash2 className="w-4 h-4" /> Elimina
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={openAddUserModal}
|
||||
className="w-full md:w-auto flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg font-medium shadow-sm transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Nuovo Utente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<table className="w-full text-left text-sm text-slate-600">
|
||||
<thead className="bg-slate-50 text-slate-700 font-semibold border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Utente</th>
|
||||
<th className="px-6 py-4">Contatti</th>
|
||||
<th className="px-6 py-4">Ruolo</th>
|
||||
<th className="px-6 py-4">Famiglia</th>
|
||||
<th className="px-6 py-4 text-right">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{users.map(user => (
|
||||
<tr key={user.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-slate-900">{user.name || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium">{user.email}</div>
|
||||
{user.phone && <div className="text-xs text-slate-400 mt-0.5">{user.phone}</div>}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-bold uppercase
|
||||
${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : ''}
|
||||
${user.role === 'poweruser' ? 'bg-orange-100 text-orange-700' : ''}
|
||||
${user.role === 'user' ? 'bg-green-100 text-green-700' : ''}
|
||||
`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">{getFamilyName(user.familyId)}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => openEditUserModal(user)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg"><Pencil className="w-4 h-4" /></button>
|
||||
<button onClick={() => handleDeleteUser(user.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards for Users */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{users.map(user => (
|
||||
<div key={user.id} className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4">
|
||||
<span className={`px-2 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider
|
||||
${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : ''}
|
||||
${user.role === 'poweruser' ? 'bg-orange-100 text-orange-700' : ''}
|
||||
${user.role === 'user' ? 'bg-green-100 text-green-700' : ''}
|
||||
`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-3 pr-16">
|
||||
<h4 className="font-bold text-slate-800 text-lg flex items-center gap-2">
|
||||
<UserIcon className="w-4 h-4 text-slate-400"/>
|
||||
{user.name || 'Senza Nome'}
|
||||
</h4>
|
||||
<p className="text-sm text-slate-500 mt-1">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Famiglia:</span>
|
||||
<span className="font-medium">{getFamilyName(user.familyId)}</span>
|
||||
</div>
|
||||
{user.phone && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Telefono:</span>
|
||||
<span className="font-medium">{user.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button onClick={() => openEditUserModal(user)} className="flex items-center justify-center gap-2 py-2 text-blue-600 bg-blue-50 rounded-lg text-sm font-bold">
|
||||
<Pencil className="w-4 h-4" /> Modifica
|
||||
</button>
|
||||
<button onClick={() => handleDeleteUser(user.id)} className="flex items-center justify-center gap-2 py-2 text-red-600 bg-red-50 rounded-lg text-sm font-bold">
|
||||
<Trash2 className="w-4 h-4" /> Elimina
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Family Modal */}
|
||||
{showFamilyModal && (
|
||||
<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-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">
|
||||
{editingFamily ? 'Modifica Famiglia' : 'Nuova Famiglia'}
|
||||
</h3>
|
||||
<button onClick={() => setShowFamilyModal(false)} className="text-slate-400 hover:text-slate-600 p-1">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleFamilySubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Nome Famiglia</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={familyForm.name}
|
||||
onChange={(e) => setFamilyForm({...familyForm, name: e.target.value})}
|
||||
className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="Es. Famiglia Rossi"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Interno / Scala</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={familyForm.unitNumber}
|
||||
onChange={(e) => setFamilyForm({...familyForm, unitNumber: e.target.value})}
|
||||
className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={familyForm.contactEmail}
|
||||
onChange={(e) => setFamilyForm({...familyForm, contactEmail: e.target.value})}
|
||||
className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button type="button" onClick={() => setShowFamilyModal(false)} className="flex-1 px-4 py-3 border border-slate-300 rounded-lg font-medium text-slate-700 hover:bg-slate-50">Annulla</button>
|
||||
<button type="submit" className="flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700">Salva</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Modal */}
|
||||
{showUserModal && (
|
||||
<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-md overflow-hidden overflow-y-auto max-h-[90vh] 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 sticky top-0">
|
||||
<h3 className="font-bold text-lg text-slate-800">
|
||||
{editingUser ? 'Modifica Utente' : 'Nuovo Utente'}
|
||||
</h3>
|
||||
<button onClick={() => setShowUserModal(false)} className="text-slate-400 hover:text-slate-600 p-1">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUserSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Nome / Username</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={userForm.name}
|
||||
onChange={(e) => setUserForm({...userForm, name: e.target.value})}
|
||||
className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Email (Login)</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={userForm.email}
|
||||
onChange={(e) => setUserForm({...userForm, email: e.target.value})}
|
||||
className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Telefono</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={userForm.phone}
|
||||
onChange={(e) => setUserForm({...userForm, phone: e.target.value})}
|
||||
className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required={!editingUser}
|
||||
value={userForm.password}
|
||||
onChange={(e) => setUserForm({...userForm, password: e.target.value})}
|
||||
className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder={editingUser ? "Lascia vuoto per non cambiare" : "Inserisci password"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Ruolo</label>
|
||||
<select
|
||||
value={userForm.role}
|
||||
onChange={(e) => setUserForm({...userForm, role: e.target.value})}
|
||||
className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none bg-white"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="poweruser">Poweruser</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Famiglia Collegata</label>
|
||||
<select
|
||||
value={userForm.familyId}
|
||||
onChange={(e) => setUserForm({...userForm, familyId: e.target.value})}
|
||||
className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none bg-white"
|
||||
>
|
||||
<option value="">Nessuna</option>
|
||||
{families.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button type="button" onClick={() => setShowUserModal(false)} className="flex-1 px-4 py-3 border border-slate-300 rounded-lg font-medium text-slate-700">Annulla</button>
|
||||
<button type="submit" className="flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg font-medium">Salva</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user