feat: Enhance condo and family data models

Adds new fields for detailed address information and notes to the Condo and Family types.
Updates database schema and server API endpoints to support these new fields, improving data richness for location and specific family/condo details.
This commit is contained in:
2025-12-07 16:10:33 +01:00
parent 28148ee550
commit fd107c1ef8
9 changed files with 422 additions and 294 deletions

Binary file not shown.

View File

@@ -1,22 +0,0 @@
# Stage 1: Build dell'applicazione React
FROM node:20-alpine as build
WORKDIR /app
# Copia i file di dipendenze
COPY package*.json ./
# Usa npm install per installare le dipendenze (più sicuro di ci se manca lockfile)
RUN npm install
# Copia tutto il codice sorgente
COPY . .
# Esegue la build di produzione (crea la cartella dist)
RUN npm run build
# Stage 2: Server Nginx per servire i file statici
FROM nginx:alpine
# Copia i file compilati dallo stage precedente alla cartella di Nginx
COPY --from=build /app/dist /usr/share/nginx/html
# Copia la configurazione custom di Nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Espone la porta 80
EXPOSE 80
# Avvia Nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,28 +1 @@
server { <EFBFBD><EFBFBD><EFBFBD>z
listen 80;
# Serve i file statici del frontend React
location / {
root /usr/share/nginx/html;
index index.html index.htm;
# Fondamentale per il routing client-side (Single Page Application)
# Se il file non esiste, serve index.html invece di dare 404
try_files $uri $uri/ /index.html;
}
# Proxy per le chiamate API verso il container del backend
location /api {
# 'backend' deve corrispondere al nome del servizio nel docker-compose.yml
proxy_pass http://backend:3001;
# Configurazioni standard per proxy HTTP 1.1 e WebSocket
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
# IMPORTANTE: Assicura che l'header Authorization (il token JWT) passi al backend
proxy_set_header Authorization $http_authorization;
}
}

View File

@@ -2,7 +2,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { CondoService } from '../services/mockDb'; import { CondoService } from '../services/mockDb';
import { AppSettings, Family, User, AlertDefinition, Condo, Notice, NoticeIconType, NoticeRead } from '../types'; import { AppSettings, Family, User, AlertDefinition, Condo, Notice, NoticeIconType, NoticeRead } from '../types';
import { Save, Building, Coins, Plus, Pencil, Trash2, X, CalendarCheck, AlertTriangle, User as UserIcon, Server, Bell, Clock, FileText, Lock, Megaphone, CheckCircle2, Info, Hammer, Link as LinkIcon, Eye, Calendar, List, UserCog, Mail, Power } from 'lucide-react'; import { Save, Building, Coins, Plus, Pencil, Trash2, X, CalendarCheck, AlertTriangle, User as UserIcon, Server, Bell, Clock, FileText, Lock, Megaphone, CheckCircle2, Info, Hammer, Link as LinkIcon, Eye, Calendar, List, UserCog, Mail, Power, MapPin } from 'lucide-react';
export const SettingsPage: React.FC = () => { export const SettingsPage: React.FC = () => {
const currentUser = CondoService.getCurrentUser(); const currentUser = CondoService.getCurrentUser();
@@ -32,7 +32,16 @@ export const SettingsPage: React.FC = () => {
const [condos, setCondos] = useState<Condo[]>([]); const [condos, setCondos] = useState<Condo[]>([]);
const [showCondoModal, setShowCondoModal] = useState(false); const [showCondoModal, setShowCondoModal] = useState(false);
const [editingCondo, setEditingCondo] = useState<Condo | null>(null); const [editingCondo, setEditingCondo] = useState<Condo | null>(null);
const [condoForm, setCondoForm] = useState({ name: '', address: '', defaultMonthlyQuota: 100 }); const [condoForm, setCondoForm] = useState({
name: '',
address: '',
streetNumber: '',
city: '',
province: '',
zipCode: '',
notes: '',
defaultMonthlyQuota: 100
});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [successMsg, setSuccessMsg] = useState(''); const [successMsg, setSuccessMsg] = useState('');
@@ -44,9 +53,12 @@ export const SettingsPage: React.FC = () => {
const [familyForm, setFamilyForm] = useState<{ const [familyForm, setFamilyForm] = useState<{
name: string; name: string;
unitNumber: string; unitNumber: string;
stair: string;
floor: string;
notes: string;
contactEmail: string; contactEmail: string;
customMonthlyQuota: string; // Use string for input handling, parse to number on save customMonthlyQuota: string;
}>({ name: '', unitNumber: '', contactEmail: '', customMonthlyQuota: '' }); }>({ name: '', unitNumber: '', stair: '', floor: '', notes: '', contactEmail: '', customMonthlyQuota: '' });
// Users State // Users State
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
@@ -104,30 +116,42 @@ export const SettingsPage: React.FC = () => {
const fetchData = async () => { const fetchData = async () => {
try { try {
if (isAdmin) { if (isAdmin) {
const [condoList, activeC, gSettings, fams, usrs, alrts, allNotices] = await Promise.all([ // First fetch global/structural data
CondoService.getCondos(), const condoList = await CondoService.getCondos();
CondoService.getActiveCondo(), const activeC = await CondoService.getActiveCondo();
CondoService.getSettings(), const gSettings = await CondoService.getSettings();
CondoService.getFamilies(),
CondoService.getUsers(),
CondoService.getAlerts(),
CondoService.getNotices()
]);
setCondos(condoList); setCondos(condoList);
setActiveCondo(activeC); setActiveCondo(activeC);
setGlobalSettings(gSettings); setGlobalSettings(gSettings);
setFamilies(fams);
setUsers(usrs);
setAlerts(alrts);
setNotices(allNotices);
// Fetch read stats for notices // Fetch condo-specific data ONLY if there is an active condo
const stats: Record<string, NoticeRead[]> = {}; if (activeC) {
for (const n of allNotices) { const [fams, usrs, alrts, allNotices] = await Promise.all([
const reads = await CondoService.getNoticeReadStatus(n.id); CondoService.getFamilies(activeC.id),
stats[n.id] = reads; CondoService.getUsers(activeC.id),
CondoService.getAlerts(activeC.id),
CondoService.getNotices(activeC.id)
]);
setFamilies(fams);
setUsers(usrs);
setAlerts(alrts);
setNotices(allNotices);
// Fetch read stats for notices
const stats: Record<string, NoticeRead[]> = {};
for (const n of allNotices) {
const reads = await CondoService.getNoticeReadStatus(n.id);
stats[n.id] = reads;
}
setNoticeReadStats(stats);
} else {
setFamilies([]);
setUsers([]);
setAlerts([]);
setNotices([]);
} }
setNoticeReadStats(stats);
} else { } else {
const activeC = await CondoService.getActiveCondo(); const activeC = await CondoService.getActiveCondo();
@@ -211,24 +235,37 @@ export const SettingsPage: React.FC = () => {
// --- Condo Management Handlers --- // --- Condo Management Handlers ---
const openAddCondoModal = () => { const openAddCondoModal = () => {
setEditingCondo(null); setEditingCondo(null);
setCondoForm({ name: '', address: '', defaultMonthlyQuota: 100 }); setCondoForm({ name: '', address: '', streetNumber: '', city: '', province: '', zipCode: '', notes: '', defaultMonthlyQuota: 100 });
setShowCondoModal(true); setShowCondoModal(true);
}; };
const openEditCondoModal = (c: Condo) => { const openEditCondoModal = (c: Condo) => {
setEditingCondo(c); setEditingCondo(c);
setCondoForm({ name: c.name, address: c.address || '', defaultMonthlyQuota: c.defaultMonthlyQuota }); setCondoForm({
name: c.name,
address: c.address || '',
streetNumber: c.streetNumber || '',
city: c.city || '',
province: c.province || '',
zipCode: c.zipCode || '',
notes: c.notes || '',
defaultMonthlyQuota: c.defaultMonthlyQuota
});
setShowCondoModal(true); setShowCondoModal(true);
}; };
const handleCondoSubmit = async (e: React.FormEvent) => { const handleCondoSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
// If editingCondo exists, use its ID. If not, empty string tells service to create new.
const payload: Condo = { const payload: Condo = {
id: editingCondo ? editingCondo.id : '', id: editingCondo ? editingCondo.id : '',
name: condoForm.name, name: condoForm.name,
address: condoForm.address, address: condoForm.address,
streetNumber: condoForm.streetNumber,
city: condoForm.city,
province: condoForm.province,
zipCode: condoForm.zipCode,
notes: condoForm.notes,
defaultMonthlyQuota: condoForm.defaultMonthlyQuota defaultMonthlyQuota: condoForm.defaultMonthlyQuota
}; };
@@ -265,7 +302,7 @@ export const SettingsPage: React.FC = () => {
// --- Family Handlers --- // --- Family Handlers ---
const openAddFamilyModal = () => { const openAddFamilyModal = () => {
setEditingFamily(null); setEditingFamily(null);
setFamilyForm({ name: '', unitNumber: '', contactEmail: '', customMonthlyQuota: '' }); setFamilyForm({ name: '', unitNumber: '', stair: '', floor: '', notes: '', contactEmail: '', customMonthlyQuota: '' });
setShowFamilyModal(true); setShowFamilyModal(true);
}; };
@@ -274,6 +311,9 @@ export const SettingsPage: React.FC = () => {
setFamilyForm({ setFamilyForm({
name: family.name, name: family.name,
unitNumber: family.unitNumber, unitNumber: family.unitNumber,
stair: family.stair || '',
floor: family.floor || '',
notes: family.notes || '',
contactEmail: family.contactEmail || '', contactEmail: family.contactEmail || '',
customMonthlyQuota: family.customMonthlyQuota ? family.customMonthlyQuota.toString() : '' customMonthlyQuota: family.customMonthlyQuota ? family.customMonthlyQuota.toString() : ''
}); });
@@ -296,23 +336,22 @@ export const SettingsPage: React.FC = () => {
? parseFloat(familyForm.customMonthlyQuota) ? parseFloat(familyForm.customMonthlyQuota)
: undefined; : undefined;
const payload: any = {
name: familyForm.name,
unitNumber: familyForm.unitNumber,
stair: familyForm.stair,
floor: familyForm.floor,
notes: familyForm.notes,
contactEmail: familyForm.contactEmail,
customMonthlyQuota: quota
};
if (editingFamily) { if (editingFamily) {
const updatedFamily = { const updatedFamily = { ...editingFamily, ...payload };
...editingFamily,
name: familyForm.name,
unitNumber: familyForm.unitNumber,
contactEmail: familyForm.contactEmail,
customMonthlyQuota: quota
};
await CondoService.updateFamily(updatedFamily); await CondoService.updateFamily(updatedFamily);
setFamilies(families.map(f => f.id === updatedFamily.id ? updatedFamily : f)); setFamilies(families.map(f => f.id === updatedFamily.id ? updatedFamily : f));
} else { } else {
const newFamily = await CondoService.addFamily({ const newFamily = await CondoService.addFamily(payload);
name: familyForm.name,
unitNumber: familyForm.unitNumber,
contactEmail: familyForm.contactEmail,
customMonthlyQuota: quota
});
setFamilies([...families, newFamily]); setFamilies([...families, newFamily]);
} }
setShowFamilyModal(false); setShowFamilyModal(false);
@@ -349,7 +388,8 @@ export const SettingsPage: React.FC = () => {
} else { } else {
await CondoService.createUser(userForm); await CondoService.createUser(userForm);
} }
setUsers(await CondoService.getUsers()); // Refresh user list for active condo
setUsers(await CondoService.getUsers(activeCondo?.id));
setShowUserModal(false); setShowUserModal(false);
} catch (e) { alert("Errore nel salvataggio utente"); } } catch (e) { alert("Errore nel salvataggio utente"); }
}; };
@@ -379,7 +419,8 @@ export const SettingsPage: React.FC = () => {
date: editingNotice ? editingNotice.date : new Date().toISOString() date: editingNotice ? editingNotice.date : new Date().toISOString()
}; };
await CondoService.saveNotice(payload); await CondoService.saveNotice(payload);
setNotices(await CondoService.getNotices()); // Refresh notices for active condo
setNotices(await CondoService.getNotices(activeCondo?.id));
setShowNoticeModal(false); setShowNoticeModal(false);
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
}; };
@@ -409,6 +450,7 @@ export const SettingsPage: React.FC = () => {
e.preventDefault(); e.preventDefault();
try { try {
const payload: AlertDefinition = { id: editingAlert ? editingAlert.id : '', subject: alertForm.subject!, body: alertForm.body!, daysOffset: Number(alertForm.daysOffset), offsetType: alertForm.offsetType as any, sendHour: Number(alertForm.sendHour), active: alertForm.active! }; const payload: AlertDefinition = { id: editingAlert ? editingAlert.id : '', subject: alertForm.subject!, body: alertForm.body!, daysOffset: Number(alertForm.daysOffset), offsetType: alertForm.offsetType as any, sendHour: Number(alertForm.sendHour), active: alertForm.active! };
// Save alert with current condoId
const saved = await CondoService.saveAlert(payload); const saved = await CondoService.saveAlert(payload);
setAlerts(editingAlert ? alerts.map(a => a.id === saved.id ? saved : a) : [...alerts, saved]); setAlerts(editingAlert ? alerts.map(a => a.id === saved.id ? saved : a) : [...alerts, saved]);
setShowAlertModal(false); setShowAlertModal(false);
@@ -488,7 +530,39 @@ export const SettingsPage: React.FC = () => {
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2"><Building className="w-5 h-5 text-blue-600" /> Dati Condominio Corrente</h3> <h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2"><Building className="w-5 h-5 text-blue-600" /> Dati Condominio Corrente</h3>
<form onSubmit={handleGeneralSubmit} className="space-y-5"> <form onSubmit={handleGeneralSubmit} className="space-y-5">
<input type="text" value={activeCondo.name} onChange={(e) => setActiveCondo({ ...activeCondo, name: e.target.value })} className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Nome" required /> <input type="text" value={activeCondo.name} onChange={(e) => setActiveCondo({ ...activeCondo, name: e.target.value })} className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Nome" required />
<input type="text" value={activeCondo.address || ''} onChange={(e) => setActiveCondo({ ...activeCondo, address: e.target.value })} className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Indirizzo" />
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Indirizzo</label>
<input type="text" value={activeCondo.address || ''} onChange={(e) => setActiveCondo({ ...activeCondo, address: e.target.value })} className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Via/Piazza..." required/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Civico</label>
<input type="text" value={activeCondo.streetNumber || ''} onChange={(e) => setActiveCondo({ ...activeCondo, streetNumber: e.target.value })} className="w-full border p-2.5 rounded-lg text-slate-700" required/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">CAP</label>
<input type="text" value={activeCondo.zipCode || ''} onChange={(e) => setActiveCondo({ ...activeCondo, zipCode: e.target.value })} className="w-full border p-2.5 rounded-lg text-slate-700"/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Città</label>
<input type="text" value={activeCondo.city || ''} onChange={(e) => setActiveCondo({ ...activeCondo, city: e.target.value })} className="w-full border p-2.5 rounded-lg text-slate-700" required/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Provincia</label>
<input type="text" value={activeCondo.province || ''} onChange={(e) => setActiveCondo({ ...activeCondo, province: e.target.value })} className="w-full border p-2.5 rounded-lg text-slate-700" required/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Note (Opzionali)</label>
<textarea value={activeCondo.notes || ''} onChange={(e) => setActiveCondo({ ...activeCondo, notes: e.target.value })} className="w-full border p-2.5 rounded-lg text-slate-700 h-24"></textarea>
</div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1">Quota Mensile Standard ()</label> <label className="block text-sm font-medium text-slate-700 mb-1">Quota Mensile Standard ()</label>
<input type="number" value={activeCondo.defaultMonthlyQuota} onChange={(e) => setActiveCondo({ ...activeCondo, defaultMonthlyQuota: parseFloat(e.target.value) })} className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Quota Default" required /> <input type="number" value={activeCondo.defaultMonthlyQuota} onChange={(e) => setActiveCondo({ ...activeCondo, defaultMonthlyQuota: parseFloat(e.target.value) })} className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Quota Default" required />
@@ -519,7 +593,10 @@ export const SettingsPage: React.FC = () => {
<div key={condo.id} className={`bg-white p-5 rounded-xl border shadow-sm relative ${activeCondo?.id === condo.id ? 'border-blue-500 ring-1 ring-blue-500' : 'border-slate-200'}`}> <div key={condo.id} className={`bg-white p-5 rounded-xl border shadow-sm relative ${activeCondo?.id === condo.id ? 'border-blue-500 ring-1 ring-blue-500' : 'border-slate-200'}`}>
{activeCondo?.id === condo.id && <div className="absolute top-3 right-3 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded font-bold uppercase">Attivo</div>} {activeCondo?.id === condo.id && <div className="absolute top-3 right-3 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded font-bold uppercase">Attivo</div>}
<h4 className="font-bold text-slate-800 text-lg mb-1">{condo.name}</h4> <h4 className="font-bold text-slate-800 text-lg mb-1">{condo.name}</h4>
<p className="text-sm text-slate-500 mb-3">{condo.address || 'Nessun indirizzo'}</p> <div className="text-sm text-slate-500 mb-3 space-y-1">
<p className="flex items-center gap-1"><MapPin className="w-3 h-3"/> {condo.address} {condo.streetNumber}</p>
{condo.city && <p className="pl-4">{condo.zipCode} {condo.city} ({condo.province})</p>}
</div>
<div className="border-t pt-3 flex gap-2"> <div className="border-t pt-3 flex gap-2">
<button onClick={() => openEditCondoModal(condo)} className="flex-1 py-1.5 bg-slate-50 text-slate-700 rounded text-sm font-medium hover:bg-slate-100">Modifica</button> <button onClick={() => openEditCondoModal(condo)} className="flex-1 py-1.5 bg-slate-50 text-slate-700 rounded text-sm font-medium hover:bg-slate-100">Modifica</button>
<button onClick={() => handleDeleteCondo(condo.id)} className="flex-1 py-1.5 bg-red-50 text-red-600 rounded text-sm font-medium hover:bg-red-100">Elimina</button> <button onClick={() => handleDeleteCondo(condo.id)} className="flex-1 py-1.5 bg-red-50 text-red-600 rounded text-sm font-medium hover:bg-red-100">Elimina</button>
@@ -545,12 +622,22 @@ export const SettingsPage: React.FC = () => {
</div> </div>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden"> <div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<table className="w-full text-left text-sm text-slate-600"> <table className="w-full text-left text-sm text-slate-600">
<thead className="bg-slate-50 text-slate-700 font-semibold border-b"><tr><th className="px-6 py-4">Nome</th><th className="px-6 py-4">Interno</th><th className="px-6 py-4">Email</th><th className="px-6 py-4">Quota</th><th className="px-6 py-4 text-right">Azioni</th></tr></thead> <thead className="bg-slate-50 text-slate-700 font-semibold border-b"><tr><th className="px-6 py-4">Nome</th><th className="px-6 py-4">Dettagli</th><th className="px-6 py-4">Email</th><th className="px-6 py-4">Quota</th><th className="px-6 py-4 text-right">Azioni</th></tr></thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{families.map(family => ( {families.map(family => (
<tr key={family.id} className="hover:bg-slate-50"> <tr key={family.id} className="hover:bg-slate-50">
<td className="px-6 py-4 font-medium text-slate-900">{family.name}</td> <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-xs">
<div className="space-y-0.5">
<span className="block text-slate-700 font-medium">Int: {family.unitNumber || '-'}</span>
{(family.stair || family.floor) && (
<span className="block text-slate-500">
{family.stair ? `Scala: ${family.stair} ` : ''}
{family.floor ? `Piano: ${family.floor}` : ''}
</span>
)}
</div>
</td>
<td className="px-6 py-4 text-slate-400">{family.contactEmail}</td> <td className="px-6 py-4 text-slate-400">{family.contactEmail}</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
{family.customMonthlyQuota ? ( {family.customMonthlyQuota ? (
@@ -654,152 +741,6 @@ export const SettingsPage: React.FC = () => {
</div> </div>
)} )}
{/* Alerts Tab */}
{isAdmin && activeTab === 'alerts' && (
<div className="space-y-4 animate-fade-in">
<div className="flex justify-between items-center">
<h3 className="font-bold text-slate-800">Avvisi Automatici Email</h3>
<button onClick={openAddAlertModal} className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 transition-colors"><Plus className="w-4 h-4"/> Nuovo</button>
</div>
{alerts.map(a => (
<div key={a.id} className="bg-white p-4 rounded-xl border flex justify-between items-center">
<div><h4 className="font-bold text-slate-800">{a.subject}</h4><p className="text-sm text-slate-500">{a.body}</p></div>
<div className="flex gap-2"><button onClick={() => openEditAlertModal(a)} className="text-blue-600 p-2 hover:bg-blue-50 rounded"><Pencil className="w-4 h-4"/></button><button onClick={() => handleDeleteAlert(a.id)} className="text-red-600 p-2 hover:bg-red-50 rounded"><Trash2 className="w-4 h-4"/></button></div>
</div>
))}
</div>
)}
{isAdmin && activeTab === 'smtp' && (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 max-w-2xl animate-fade-in">
<h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2"><Server className="w-5 h-5"/> Server SMTP Globale</h3>
{globalSettings && (
<form onSubmit={handleSmtpSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<input type="text" placeholder="Host" value={globalSettings.smtpConfig?.host} onChange={e => setGlobalSettings({...globalSettings, smtpConfig: {...globalSettings.smtpConfig!, host: e.target.value}})} className="border p-2 rounded text-slate-700"/>
<input type="number" placeholder="Port" value={globalSettings.smtpConfig?.port} onChange={e => setGlobalSettings({...globalSettings, smtpConfig: {...globalSettings.smtpConfig!, port: parseInt(e.target.value)}})} className="border p-2 rounded text-slate-700"/>
</div>
<div className="grid grid-cols-2 gap-4">
<input type="text" placeholder="User" value={globalSettings.smtpConfig?.user} onChange={e => setGlobalSettings({...globalSettings, smtpConfig: {...globalSettings.smtpConfig!, user: e.target.value}})} className="border p-2 rounded text-slate-700"/>
<input type="password" placeholder="Pass" value={globalSettings.smtpConfig?.pass} onChange={e => setGlobalSettings({...globalSettings, smtpConfig: {...globalSettings.smtpConfig!, pass: e.target.value}})} className="border p-2 rounded text-slate-700"/>
</div>
<button type="submit" className="bg-blue-600 text-white px-6 py-2 rounded-lg w-full">Salva Configurazione SMTP</button>
</form>
)}
</div>
)}
{/* MODALS */}
{/* CONDO MODAL */}
{showCondoModal && (
<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-xl shadow-xl w-full max-w-md p-6 animate-in fade-in zoom-in duration-200">
<h3 className="font-bold text-lg mb-4 text-slate-800">{editingCondo ? 'Modifica Condominio' : 'Nuovo Condominio'}</h3>
<form onSubmit={handleCondoSubmit} className="space-y-4">
<input className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Nome Condominio" value={condoForm.name} onChange={e => setCondoForm({...condoForm, name: e.target.value})} required />
<input className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Indirizzo" value={condoForm.address} onChange={e => setCondoForm({...condoForm, address: e.target.value})} />
<div className="flex items-center gap-2"><span className="text-sm font-medium text-slate-600">Quota Default </span><input type="number" className="border p-2 rounded w-24 text-slate-700" value={condoForm.defaultMonthlyQuota} onChange={e => setCondoForm({...condoForm, defaultMonthlyQuota: parseFloat(e.target.value)})} /></div>
<div className="flex gap-2 pt-2">
<button type="button" onClick={() => setShowCondoModal(false)} className="flex-1 border p-2.5 rounded-lg text-slate-600 hover:bg-slate-50">Annulla</button>
<button type="submit" className="flex-1 bg-blue-600 text-white p-2.5 rounded-lg hover:bg-blue-700">Salva</button>
</div>
</form>
</div>
</div>
)}
{/* NOTICE MODAL */}
{showNoticeModal && (
<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-xl shadow-xl w-full max-w-lg p-6 animate-in fade-in zoom-in duration-200">
<h3 className="font-bold text-lg mb-4 text-slate-800 flex items-center gap-2">
<Megaphone className="w-5 h-5 text-blue-600"/>
{editingNotice ? 'Modifica Avviso' : 'Nuovo Avviso'}
</h3>
<form onSubmit={handleNoticeSubmit} className="space-y-4">
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Titolo</label>
<input className="w-full border p-2.5 rounded-lg text-slate-700" value={noticeForm.title} onChange={e => setNoticeForm({...noticeForm, title: e.target.value})} required />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Tipo</label>
<select className="w-full border p-2.5 rounded-lg text-slate-700 bg-white" value={noticeForm.type} onChange={e => setNoticeForm({...noticeForm, type: e.target.value as any})}>
<option value="info">Informazione</option>
<option value="warning">Avviso / Pericolo</option>
<option value="maintenance">Manutenzione</option>
<option value="event">Evento</option>
</select>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Condominio</label>
<select className="w-full border p-2.5 rounded-lg text-slate-700 bg-white" value={noticeForm.condoId} onChange={e => setNoticeForm({...noticeForm, condoId: e.target.value})}>
{condos.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Contenuto</label>
<textarea className="w-full border p-2.5 rounded-lg text-slate-700 h-24" value={noticeForm.content} onChange={e => setNoticeForm({...noticeForm, content: e.target.value})} required />
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase">Link Documento (Opzionale)</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-3 w-4 h-4 text-slate-400"/>
<input className="w-full border p-2.5 pl-9 rounded-lg text-slate-700" placeholder="https://..." value={noticeForm.link} onChange={e => setNoticeForm({...noticeForm, link: e.target.value})} />
</div>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" checked={noticeForm.active} onChange={e => setNoticeForm({...noticeForm, active: e.target.checked})} className="w-4 h-4 text-blue-600"/>
<span className="text-sm font-medium text-slate-700">Visibile ai condomini</span>
</div>
<div className="flex gap-2 pt-2">
<button type="button" onClick={() => setShowNoticeModal(false)} className="flex-1 border p-2.5 rounded-lg text-slate-600 hover:bg-slate-50">Annulla</button>
<button type="submit" className="flex-1 bg-blue-600 text-white p-2.5 rounded-lg hover:bg-blue-700">Pubblica</button>
</div>
</form>
</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 p-6">
<h3 className="font-bold text-lg mb-4 text-slate-800">{editingFamily ? 'Modifica Famiglia' : 'Nuova Famiglia'}</h3>
<form onSubmit={handleFamilySubmit} className="space-y-4">
<input type="text" required value={familyForm.name} onChange={(e) => setFamilyForm({...familyForm, name: e.target.value})} className="w-full border rounded-lg p-3 text-slate-700" placeholder="Nome Famiglia"/>
<input type="text" required value={familyForm.unitNumber} onChange={(e) => setFamilyForm({...familyForm, unitNumber: e.target.value})} className="w-full border rounded-lg p-3 text-slate-700" placeholder="Interno"/>
<input type="email" value={familyForm.contactEmail} onChange={(e) => setFamilyForm({...familyForm, contactEmail: e.target.value})} className="w-full border rounded-lg p-3 text-slate-700" placeholder="Email"/>
<div className="pt-2 border-t border-slate-100">
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Quota Mensile Personalizzata</label>
<p className="text-xs text-slate-400 mb-2">Lasciare vuoto per usare il default del condominio ( {activeCondo?.defaultMonthlyQuota})</p>
<input
type="number"
step="0.01"
value={familyForm.customMonthlyQuota}
onChange={(e) => setFamilyForm({...familyForm, customMonthlyQuota: e.target.value})}
className="w-full border rounded-lg p-3 text-slate-700"
placeholder="Es. 120.00"
/>
</div>
<div className="flex gap-3 pt-4">
<button type="button" onClick={() => setShowFamilyModal(false)} className="flex-1 p-3 border rounded-lg text-slate-600">Annulla</button>
<button type="submit" className="flex-1 p-3 bg-blue-600 text-white rounded-lg">Salva</button>
</div>
</form>
</div>
</div>
)}
{/* ALERT MODAL */} {/* ALERT MODAL */}
{showAlertModal && ( {showAlertModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
@@ -918,6 +859,123 @@ export const SettingsPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* CONDO MODAL */}
{showCondoModal && (
<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-xl shadow-xl w-full max-w-lg p-6 animate-in fade-in zoom-in duration-200">
<h3 className="font-bold text-lg mb-4 text-slate-800">{editingCondo ? 'Modifica Condominio' : 'Nuovo Condominio'}</h3>
<form onSubmit={handleCondoSubmit} className="space-y-4">
{/* Name */}
<div>
<input className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Nome Condominio" value={condoForm.name} onChange={e => setCondoForm({...condoForm, name: e.target.value})} required />
</div>
{/* Address Line 1 */}
<div className="grid grid-cols-12 gap-3">
<div className="col-span-9">
<input className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Via/Piazza..." value={condoForm.address} onChange={e => setCondoForm({...condoForm, address: e.target.value})} required />
</div>
<div className="col-span-3">
<input className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Civico" value={condoForm.streetNumber} onChange={e => setCondoForm({...condoForm, streetNumber: e.target.value})} required />
</div>
</div>
{/* Address Line 2 */}
<div className="grid grid-cols-12 gap-3">
<div className="col-span-3">
<input className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="CAP" value={condoForm.zipCode} onChange={e => setCondoForm({...condoForm, zipCode: e.target.value})} />
</div>
<div className="col-span-5">
<input className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Città" value={condoForm.city} onChange={e => setCondoForm({...condoForm, city: e.target.value})} required />
</div>
<div className="col-span-4">
<input className="w-full border p-2.5 rounded-lg text-slate-700" placeholder="Provincia" value={condoForm.province} onChange={e => setCondoForm({...condoForm, province: e.target.value})} required />
</div>
</div>
{/* Notes */}
<div>
<textarea className="w-full border p-2.5 rounded-lg text-slate-700 h-20" placeholder="Note (opzionali)" value={condoForm.notes} onChange={e => setCondoForm({...condoForm, notes: e.target.value})} />
</div>
{/* Quota */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-600">Quota Default </span>
<input type="number" className="border p-2 rounded w-24 text-slate-700" value={condoForm.defaultMonthlyQuota} onChange={e => setCondoForm({...condoForm, defaultMonthlyQuota: parseFloat(e.target.value)})} />
</div>
<div className="flex gap-2 pt-2">
<button type="button" onClick={() => setShowCondoModal(false)} className="flex-1 border p-2.5 rounded-lg text-slate-600 hover:bg-slate-50">Annulla</button>
<button type="submit" className="flex-1 bg-blue-600 text-white p-2.5 rounded-lg hover:bg-blue-700">Salva</button>
</div>
</form>
</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-lg p-6">
<h3 className="font-bold text-lg mb-4 text-slate-800">{editingFamily ? 'Modifica Famiglia' : 'Nuova Famiglia'}</h3>
<form onSubmit={handleFamilySubmit} className="space-y-4">
{/* Name & Email */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Nome Famiglia</label>
<input type="text" required value={familyForm.name} onChange={(e) => setFamilyForm({...familyForm, name: e.target.value})} className="w-full border rounded-lg p-2.5 text-slate-700" placeholder="Es. Rossi"/>
</div>
<div className="md:col-span-2">
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Email Contatto (Obbligatoria)</label>
<input type="email" required value={familyForm.contactEmail} onChange={(e) => setFamilyForm({...familyForm, contactEmail: e.target.value})} className="w-full border rounded-lg p-2.5 text-slate-700" placeholder="email@esempio.it"/>
</div>
</div>
{/* Location Details */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Interno</label>
<input type="text" value={familyForm.unitNumber} onChange={(e) => setFamilyForm({...familyForm, unitNumber: e.target.value})} className="w-full border rounded-lg p-2.5 text-slate-700" />
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Scala</label>
<input type="text" value={familyForm.stair} onChange={(e) => setFamilyForm({...familyForm, stair: e.target.value})} className="w-full border rounded-lg p-2.5 text-slate-700" />
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Piano</label>
<input type="text" value={familyForm.floor} onChange={(e) => setFamilyForm({...familyForm, floor: e.target.value})} className="w-full border rounded-lg p-2.5 text-slate-700" />
</div>
</div>
{/* Notes */}
<div>
<label className="text-xs font-bold text-slate-500 uppercase mb-1 block">Note</label>
<textarea value={familyForm.notes} onChange={(e) => setFamilyForm({...familyForm, notes: e.target.value})} className="w-full border rounded-lg p-2.5 text-slate-700 h-16" placeholder="Note opzionali..."></textarea>
</div>
<div className="pt-2 border-t border-slate-100">
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Quota Mensile Personalizzata</label>
<p className="text-xs text-slate-400 mb-2">Lasciare vuoto per usare il default del condominio ( {activeCondo?.defaultMonthlyQuota})</p>
<input
type="number"
step="0.01"
value={familyForm.customMonthlyQuota}
onChange={(e) => setFamilyForm({...familyForm, customMonthlyQuota: e.target.value})}
className="w-full border rounded-lg p-2.5 text-slate-700"
placeholder="Es. 120.00"
/>
</div>
<div className="flex gap-3 pt-4">
<button type="button" onClick={() => setShowFamilyModal(false)} className="flex-1 p-3 border rounded-lg text-slate-600">Annulla</button>
<button type="submit" className="flex-1 p-3 bg-blue-600 text-white rounded-lg">Salva</button>
</div>
</form>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -1,13 +0,0 @@
# Usa Node.js 20 come base
FROM node:20-alpine
WORKDIR /app
# Copia i file di dipendenze del server
COPY package*.json ./
# Installa le dipendenze
RUN npm install
# Copia il codice sorgente del server
COPY . .
# Espone la porta del backend
EXPOSE 3001
# Avvia il server
CMD ["npm", "start"]

View File

@@ -76,6 +76,11 @@ const initDb = async () => {
id VARCHAR(36) PRIMARY KEY, id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
address VARCHAR(255), address VARCHAR(255),
street_number VARCHAR(20),
city VARCHAR(100),
province VARCHAR(100),
zip_code VARCHAR(20),
notes TEXT,
iban VARCHAR(50), iban VARCHAR(50),
default_monthly_quota DECIMAL(10, 2) DEFAULT 100.00, default_monthly_quota DECIMAL(10, 2) DEFAULT 100.00,
image VARCHAR(255), image VARCHAR(255),
@@ -83,6 +88,28 @@ const initDb = async () => {
) )
`); `);
// Migration for condos: Add new address fields
try {
let hasCity = false;
if (DB_CLIENT === 'postgres') {
const [cols] = await connection.query("SELECT column_name FROM information_schema.columns WHERE table_name='condos'");
hasCity = cols.some(c => c.column_name === 'city');
} else {
const [cols] = await connection.query("SHOW COLUMNS FROM condos");
hasCity = cols.some(c => c.Field === 'city');
}
if (!hasCity) {
console.log('Migrating: Adding address fields to condos...');
await connection.query("ALTER TABLE condos ADD COLUMN street_number VARCHAR(20)");
await connection.query("ALTER TABLE condos ADD COLUMN city VARCHAR(100)");
await connection.query("ALTER TABLE condos ADD COLUMN province VARCHAR(100)");
await connection.query("ALTER TABLE condos ADD COLUMN zip_code VARCHAR(20)");
await connection.query("ALTER TABLE condos ADD COLUMN notes TEXT");
}
} catch(e) { console.warn("Condos migration warning:", e.message); }
// 2. Families Table // 2. Families Table
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS families ( CREATE TABLE IF NOT EXISTS families (
@@ -90,6 +117,9 @@ const initDb = async () => {
condo_id VARCHAR(36), condo_id VARCHAR(36),
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
unit_number VARCHAR(50), unit_number VARCHAR(50),
stair VARCHAR(50),
floor VARCHAR(50),
notes TEXT,
contact_email VARCHAR(255), contact_email VARCHAR(255),
custom_monthly_quota DECIMAL(10, 2) NULL, custom_monthly_quota DECIMAL(10, 2) NULL,
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP, created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
@@ -97,18 +127,22 @@ const initDb = async () => {
) )
`); `);
// Migration for families: Add condo_id and custom_monthly_quota if missing // Migration for families: Add condo_id, custom_monthly_quota, stair, floor, notes
try { try {
let hasCondoId = false; let hasCondoId = false;
let hasQuota = false; let hasQuota = false;
let hasStair = false;
if (DB_CLIENT === 'postgres') { if (DB_CLIENT === 'postgres') {
const [cols] = await connection.query("SELECT column_name FROM information_schema.columns WHERE table_name='families'"); const [cols] = await connection.query("SELECT column_name FROM information_schema.columns WHERE table_name='families'");
hasCondoId = cols.some(c => c.column_name === 'condo_id'); hasCondoId = cols.some(c => c.column_name === 'condo_id');
hasQuota = cols.some(c => c.column_name === 'custom_monthly_quota'); hasQuota = cols.some(c => c.column_name === 'custom_monthly_quota');
hasStair = cols.some(c => c.column_name === 'stair');
} else { } else {
const [cols] = await connection.query("SHOW COLUMNS FROM families"); const [cols] = await connection.query("SHOW COLUMNS FROM families");
hasCondoId = cols.some(c => c.Field === 'condo_id'); hasCondoId = cols.some(c => c.Field === 'condo_id');
hasQuota = cols.some(c => c.Field === 'custom_monthly_quota'); hasQuota = cols.some(c => c.Field === 'custom_monthly_quota');
hasStair = cols.some(c => c.Field === 'stair');
} }
if (!hasCondoId) { if (!hasCondoId) {
@@ -119,6 +153,13 @@ const initDb = async () => {
console.log('Migrating: Adding custom_monthly_quota to families...'); console.log('Migrating: Adding custom_monthly_quota to families...');
await connection.query("ALTER TABLE families ADD COLUMN custom_monthly_quota DECIMAL(10, 2) NULL"); await connection.query("ALTER TABLE families ADD COLUMN custom_monthly_quota DECIMAL(10, 2) NULL");
} }
if (!hasStair) {
console.log('Migrating: Adding extended fields to families...');
await connection.query("ALTER TABLE families ADD COLUMN stair VARCHAR(50)");
await connection.query("ALTER TABLE families ADD COLUMN floor VARCHAR(50)");
await connection.query("ALTER TABLE families ADD COLUMN notes TEXT");
}
} catch(e) { console.warn("Families migration warning:", e.message); } } catch(e) { console.warn("Families migration warning:", e.message); }
// 3. Payments Table // 3. Payments Table
@@ -156,6 +197,7 @@ const initDb = async () => {
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS alerts ( CREATE TABLE IF NOT EXISTS alerts (
id VARCHAR(36) PRIMARY KEY, id VARCHAR(36) PRIMARY KEY,
condo_id VARCHAR(36) NULL,
subject VARCHAR(255) NOT NULL, subject VARCHAR(255) NOT NULL,
body TEXT, body TEXT,
days_offset INT DEFAULT 1, days_offset INT DEFAULT 1,
@@ -163,10 +205,29 @@ const initDb = async () => {
send_hour INT DEFAULT 9, send_hour INT DEFAULT 9,
active BOOLEAN DEFAULT TRUE, active BOOLEAN DEFAULT TRUE,
last_sent ${TIMESTAMP_TYPE} NULL, last_sent ${TIMESTAMP_TYPE} NULL,
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (condo_id) REFERENCES condos(id) ON DELETE CASCADE
) )
`); `);
// Migration for alerts: Add condo_id if missing
try {
let hasCondoId = false;
if (DB_CLIENT === 'postgres') {
const [cols] = await connection.query("SELECT column_name FROM information_schema.columns WHERE table_name='alerts'");
hasCondoId = cols.some(c => c.column_name === 'condo_id');
} else {
const [cols] = await connection.query("SHOW COLUMNS FROM alerts");
hasCondoId = cols.some(c => c.Field === 'condo_id');
}
if (!hasCondoId) {
console.log('Migrating: Adding condo_id to alerts...');
await connection.query("ALTER TABLE alerts ADD COLUMN condo_id VARCHAR(36)");
await connection.query("ALTER TABLE alerts ADD CONSTRAINT fk_alerts_condo FOREIGN KEY (condo_id) REFERENCES condos(id) ON DELETE CASCADE");
}
} catch(e) { console.warn("Alerts migration warning:", e.message); }
// 6. Notices Table // 6. Notices Table
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS notices ( CREATE TABLE IF NOT EXISTS notices (

View File

@@ -144,22 +144,38 @@ app.get('/api/condos', authenticateToken, async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM condos'); const [rows] = await pool.query('SELECT * FROM condos');
res.json(rows.map(r => ({ res.json(rows.map(r => ({
id: r.id, name: r.name, address: r.address, iban: r.iban, defaultMonthlyQuota: parseFloat(r.default_monthly_quota), image: r.image id: r.id,
name: r.name,
address: r.address,
streetNumber: r.street_number,
city: r.city,
province: r.province,
zipCode: r.zip_code,
notes: r.notes,
iban: r.iban,
defaultMonthlyQuota: parseFloat(r.default_monthly_quota),
image: r.image
}))); })));
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
app.post('/api/condos', authenticateToken, requireAdmin, async (req, res) => { app.post('/api/condos', authenticateToken, requireAdmin, async (req, res) => {
const { name, address, defaultMonthlyQuota } = req.body; const { name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota } = req.body;
const id = uuidv4(); const id = uuidv4();
try { try {
await pool.query('INSERT INTO condos (id, name, address, default_monthly_quota) VALUES (?, ?, ?, ?)', [id, name, address, defaultMonthlyQuota]); await pool.query(
res.json({ id, name, address, defaultMonthlyQuota }); 'INSERT INTO condos (id, name, address, street_number, city, province, zip_code, notes, default_monthly_quota) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota]
);
res.json({ id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
app.put('/api/condos/:id', authenticateToken, requireAdmin, async (req, res) => { app.put('/api/condos/:id', authenticateToken, requireAdmin, async (req, res) => {
const { name, address, defaultMonthlyQuota } = req.body; const { name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota } = req.body;
try { try {
await pool.query('UPDATE condos SET name = ?, address = ?, default_monthly_quota = ? WHERE id = ?', [name, address, defaultMonthlyQuota, req.params.id]); await pool.query(
'UPDATE condos SET name = ?, address = ?, street_number = ?, city = ?, province = ?, zip_code = ?, notes = ?, default_monthly_quota = ? WHERE id = ?',
[name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, req.params.id]
);
res.json({ success: true }); res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
@@ -172,32 +188,58 @@ app.delete('/api/condos/:id', authenticateToken, requireAdmin, async (req, res)
// --- FAMILIES --- // --- FAMILIES ---
app.get('/api/families', authenticateToken, async (req, res) => { app.get('/api/families', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try { try {
let query = `SELECT f.* FROM families f`; let query = `SELECT f.* FROM families f`;
let params = []; let params = [];
// Authorization/Filtering logic
if (req.user.role !== 'admin' && req.user.role !== 'poweruser') { if (req.user.role !== 'admin' && req.user.role !== 'poweruser') {
// Regular user: can only see their own family
if (!req.user.familyId) return res.json([]); if (!req.user.familyId) return res.json([]);
query += ' WHERE f.id = ?'; query += ' WHERE f.id = ?';
params.push(req.user.familyId); params.push(req.user.familyId);
} else {
// Admin: If condoId provided, filter by it.
if (condoId) {
query += ' WHERE f.condo_id = ?';
params.push(condoId);
}
} }
const [rows] = await pool.query(query, params); const [rows] = await pool.query(query, params);
res.json(rows.map(r => ({ res.json(rows.map(r => ({
id: r.id, condoId: r.condo_id, name: r.name, unitNumber: r.unit_number, contactEmail: r.contact_email, customMonthlyQuota: r.custom_monthly_quota ? parseFloat(r.custom_monthly_quota) : undefined, balance: 0 id: r.id,
condoId: r.condo_id,
name: r.name,
unitNumber: r.unit_number,
stair: r.stair,
floor: r.floor,
notes: r.notes,
contactEmail: r.contact_email,
customMonthlyQuota: r.custom_monthly_quota ? parseFloat(r.custom_monthly_quota) : undefined,
balance: 0
}))); })));
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
app.post('/api/families', authenticateToken, requireAdmin, async (req, res) => { app.post('/api/families', authenticateToken, requireAdmin, async (req, res) => {
const { name, unitNumber, contactEmail, condoId, customMonthlyQuota } = req.body; const { name, unitNumber, stair, floor, notes, contactEmail, condoId, customMonthlyQuota } = req.body;
const id = uuidv4(); const id = uuidv4();
try { try {
await pool.query('INSERT INTO families (id, condo_id, name, unit_number, contact_email, custom_monthly_quota) VALUES (?, ?, ?, ?, ?, ?)', [id, condoId, name, unitNumber, contactEmail, customMonthlyQuota || null]); await pool.query(
res.json({ id, condoId, name, unitNumber, contactEmail, customMonthlyQuota }); 'INSERT INTO families (id, condo_id, name, unit_number, stair, floor, notes, contact_email, custom_monthly_quota) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, condoId, name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota || null]
);
res.json({ id, condoId, name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
app.put('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => { app.put('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => {
const { name, unitNumber, contactEmail, customMonthlyQuota } = req.body; const { name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota } = req.body;
try { try {
await pool.query('UPDATE families SET name = ?, unit_number = ?, contact_email = ?, custom_monthly_quota = ? WHERE id = ?', [name, unitNumber, contactEmail, customMonthlyQuota || null, req.params.id]); await pool.query(
'UPDATE families SET name = ?, unit_number = ?, stair = ?, floor = ?, notes = ?, contact_email = ?, custom_monthly_quota = ? WHERE id = ?',
[name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota || null, req.params.id]
);
res.json({ success: true }); res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
@@ -305,8 +347,19 @@ app.post('/api/payments', authenticateToken, async (req, res) => {
// --- USERS --- // --- USERS ---
app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => { app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => {
const { condoId } = req.query;
try { try {
const [rows] = await pool.query('SELECT id, email, name, role, phone, family_id, receive_alerts FROM users'); let query = 'SELECT u.id, u.email, u.name, u.role, u.phone, u.family_id, u.receive_alerts FROM users u';
let params = [];
// Filter users by condo.
// Logic: Users belong to families, families belong to condos.
if (condoId) {
query += ' LEFT JOIN families f ON u.family_id = f.id WHERE f.condo_id = ?';
params.push(condoId);
}
const [rows] = await pool.query(query, params);
res.json(rows.map(r => ({ id: r.id, email: r.email, name: r.name, role: r.role, phone: r.phone, familyId: r.family_id, receiveAlerts: !!r.receive_alerts }))); res.json(rows.map(r => ({ id: r.id, email: r.email, name: r.name, role: r.role, phone: r.phone, familyId: r.family_id, receiveAlerts: !!r.receive_alerts })));
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
@@ -344,17 +397,24 @@ app.delete('/api/users/:id', authenticateToken, requireAdmin, async (req, res) =
// --- ALERTS --- // --- ALERTS ---
app.get('/api/alerts', authenticateToken, requireAdmin, async (req, res) => { app.get('/api/alerts', authenticateToken, requireAdmin, async (req, res) => {
const { condoId } = req.query;
try { try {
const [rows] = await pool.query('SELECT * FROM alerts'); let query = 'SELECT * FROM alerts';
res.json(rows.map(r => ({ id: r.id, subject: r.subject, body: r.body, daysOffset: r.days_offset, offsetType: r.offset_type, sendHour: r.send_hour, active: !!r.active, lastSent: r.last_sent }))); let params = [];
if (condoId) {
query += ' WHERE condo_id = ?';
params.push(condoId);
}
const [rows] = await pool.query(query, params);
res.json(rows.map(r => ({ id: r.id, condoId: r.condo_id, subject: r.subject, body: r.body, daysOffset: r.days_offset, offsetType: r.offset_type, sendHour: r.send_hour, active: !!r.active, lastSent: r.last_sent })));
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
app.post('/api/alerts', authenticateToken, requireAdmin, async (req, res) => { app.post('/api/alerts', authenticateToken, requireAdmin, async (req, res) => {
const { subject, body, daysOffset, offsetType, sendHour, active } = req.body; const { condoId, subject, body, daysOffset, offsetType, sendHour, active } = req.body;
const id = uuidv4(); const id = uuidv4();
try { try {
await pool.query('INSERT INTO alerts (id, subject, body, days_offset, offset_type, send_hour, active) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, subject, body, daysOffset, offsetType, sendHour, active]); await pool.query('INSERT INTO alerts (id, condo_id, subject, body, days_offset, offset_type, send_hour, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, condoId, subject, body, daysOffset, offsetType, sendHour, active]);
res.json({ id, subject, body, daysOffset, offsetType, sendHour, active }); res.json({ id, condoId, subject, body, daysOffset, offsetType, sendHour, active });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
app.put('/api/alerts/:id', authenticateToken, requireAdmin, async (req, res) => { app.put('/api/alerts/:id', authenticateToken, requireAdmin, async (req, res) => {

View File

@@ -92,7 +92,8 @@ export const CondoService = {
getNotices: async (condoId?: string): Promise<Notice[]> => { getNotices: async (condoId?: string): Promise<Notice[]> => {
let url = '/notices'; let url = '/notices';
if (condoId) url += `?condoId=${condoId}`; const activeId = condoId || CondoService.getActiveCondoId();
if (activeId) url += `?condoId=${activeId}`;
return request<Notice[]>(url); return request<Notice[]>(url);
}, },
@@ -142,11 +143,8 @@ export const CondoService = {
// Set active condo if user belongs to a family // Set active condo if user belongs to a family
if (data.user.familyId) { if (data.user.familyId) {
// We need to fetch family to get condoId.
// For simplicity, we trust the flow or fetch families next.
// In a real app, login might return condoId directly.
try { try {
const families = await CondoService.getFamilies(); // This will filter by user perms const families = await CondoService.getFamilies(); // This will filter by user perms automatically on server
const fam = families.find(f => f.id === data.user.familyId); const fam = families.find(f => f.id === data.user.familyId);
if (fam) { if (fam) {
localStorage.setItem(STORAGE_KEYS.ACTIVE_CONDO_ID, fam.condoId); localStorage.setItem(STORAGE_KEYS.ACTIVE_CONDO_ID, fam.condoId);
@@ -194,13 +192,11 @@ export const CondoService = {
// --- FAMILIES --- // --- FAMILIES ---
getFamilies: async (): Promise<Family[]> => { getFamilies: async (condoId?: string): Promise<Family[]> => {
const activeCondoId = CondoService.getActiveCondoId(); let url = '/families';
// Pass condoId to filter on server if needed, or filter client side. const activeId = condoId || CondoService.getActiveCondoId();
// The server `getFamilies` endpoint handles filtering based on user role. if (activeId) url += `?condoId=${activeId}`;
// However, if we are admin, we want ALL families, but usually filtered by the UI for the active condo. return request<Family[]>(url);
// Let's get all allowed families from server.
return request<Family[]>('/families');
}, },
addFamily: async (familyData: Omit<Family, 'id' | 'balance' | 'condoId'>): Promise<Family> => { addFamily: async (familyData: Omit<Family, 'id' | 'balance' | 'condoId'>): Promise<Family> => {
@@ -239,8 +235,11 @@ export const CondoService = {
// --- USERS --- // --- USERS ---
getUsers: async (): Promise<User[]> => { getUsers: async (condoId?: string): Promise<User[]> => {
return request<User[]>('/users'); let url = '/users';
const activeId = condoId || CondoService.getActiveCondoId();
if (activeId) url += `?condoId=${activeId}`;
return request<User[]>(url);
}, },
createUser: async (userData: any) => { createUser: async (userData: any) => {
@@ -263,15 +262,19 @@ export const CondoService = {
// --- ALERTS --- // --- ALERTS ---
getAlerts: async (): Promise<AlertDefinition[]> => { getAlerts: async (condoId?: string): Promise<AlertDefinition[]> => {
return request<AlertDefinition[]>('/alerts'); let url = '/alerts';
const activeId = condoId || CondoService.getActiveCondoId();
if (activeId) url += `?condoId=${activeId}`;
return request<AlertDefinition[]>(url);
}, },
saveAlert: async (alert: AlertDefinition): Promise<AlertDefinition> => { saveAlert: async (alert: AlertDefinition & { condoId?: string }): Promise<AlertDefinition> => {
const activeCondoId = CondoService.getActiveCondoId();
if (!alert.id) { if (!alert.id) {
return request<AlertDefinition>('/alerts', { return request<AlertDefinition>('/alerts', {
method: 'POST', method: 'POST',
body: JSON.stringify(alert) body: JSON.stringify({ ...alert, condoId: activeCondoId })
}); });
} else { } else {
return request<AlertDefinition>(`/alerts/${alert.id}`, { return request<AlertDefinition>(`/alerts/${alert.id}`, {

View File

@@ -3,6 +3,11 @@ export interface Condo {
id: string; id: string;
name: string; name: string;
address?: string; address?: string;
streetNumber?: string; // Civico
city?: string; // Città
province?: string; // Provincia
zipCode?: string; // CAP
notes?: string; // Note
iban?: string; iban?: string;
defaultMonthlyQuota: number; defaultMonthlyQuota: number;
image?: string; // Optional placeholder for logo image?: string; // Optional placeholder for logo
@@ -12,7 +17,10 @@ export interface Family {
id: string; id: string;
condoId: string; // Link to specific condo condoId: string; // Link to specific condo
name: string; name: string;
unitNumber: string; // Internal apartment number unitNumber: string; // Internal apartment number (Interno)
stair?: string; // Scala
floor?: string; // Piano
notes?: string; // Note
contactEmail?: string; contactEmail?: string;
balance: number; // Calculated balance (positive = credit, negative = debt) balance: number; // Calculated balance (positive = credit, negative = debt)
customMonthlyQuota?: number; // Optional override for default quota customMonthlyQuota?: number; // Optional override for default quota