Caricamento...
;
+
+ return (
+
+
+
Impostazioni
+
Gestisci configurazione, anagrafica e utenti.
+
+
+ {/* Tabs - Scrollable on mobile */}
+
+
+
+
+
+
+ {activeTab === 'general' && (
+
+ {/* General Data Form */}
+
+
+ {/* Fiscal Year Management */}
+
+
+
+ Anno Fiscale
+
+
+
+ Anno corrente: {settings.currentYear}
+
+
+
+
+
+
+
+ Chiudendo l'anno, il sistema passerà al {settings.currentYear + 1}. I dati storici rimarranno consultabili.
+
+
+
+
+
+
+
+ )}
+
+ {activeTab === 'families' && (
+
+
+
+
+
+ {/* Desktop Table */}
+
+
+
+
+ | Nome Famiglia |
+ Interno |
+ Email |
+ Azioni |
+
+
+
+ {families.map(family => (
+
+ | {family.name} |
+ {family.unitNumber} |
+ {family.contactEmail || '-'} |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+
+ {/* Mobile Cards for Families */}
+
+ {families.map(family => (
+
+
+
+
{family.name}
+
Interno: {family.unitNumber}
+
+
+
+
+ {family.contactEmail || 'Nessuna email'}
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {activeTab === 'users' && (
+
+
+
+ {/* Desktop Table */}
+
+
+
+
+ | Utente |
+ Contatti |
+ Ruolo |
+ Famiglia |
+ Azioni |
+
+
+
+ {users.map(user => (
+
+ | {user.name || '-'} |
+
+ {user.email}
+ {user.phone && {user.phone} }
+ |
+
+
+ {user.role}
+
+ |
+ {getFamilyName(user.familyId)} |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+
+ {/* Mobile Cards for Users */}
+
+ {users.map(user => (
+
+
+
+ {user.role}
+
+
+
+
+
+ {user.name || 'Senza Nome'}
+
+
{user.email}
+
+
+
+
+ Famiglia:
+ {getFamilyName(user.familyId)}
+
+ {user.phone && (
+
+ Telefono:
+ {user.phone}
+
+ )}
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Family Modal */}
+ {showFamilyModal && (
+
+
+
+
+ {editingFamily ? 'Modifica Famiglia' : 'Nuova Famiglia'}
+
+
+
+
+
+
+
+ )}
+
+ {/* User Modal */}
+ {showUserModal && (
+
+
+
+
+ {editingUser ? 'Modifica Utente' : 'Nuovo Utente'}
+
+
+
+
+
+
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..e99ebc2
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
\ No newline at end of file
diff --git a/server/Dockerfile b/server/Dockerfile
new file mode 100644
index 0000000..e69de29
diff --git a/server/db.js b/server/db.js
new file mode 100644
index 0000000..0f04565
--- /dev/null
+++ b/server/db.js
@@ -0,0 +1,116 @@
+const mysql = require('mysql2/promise');
+const bcrypt = require('bcryptjs');
+require('dotenv').config();
+
+// Configuration from .env or defaults
+const dbConfig = {
+ host: process.env.DB_HOST || 'localhost',
+ user: process.env.DB_USER || 'root',
+ password: process.env.DB_PASS || '',
+ database: process.env.DB_NAME || 'condominio',
+ port: process.env.DB_PORT || 3306,
+ waitForConnections: true,
+ connectionLimit: 10,
+ queueLimit: 0
+};
+
+const pool = mysql.createPool(dbConfig);
+
+const initDb = async () => {
+ try {
+ const connection = await pool.getConnection();
+ console.log('Database connected successfully.');
+
+ // 1. Settings Table
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS settings (
+ id INT PRIMARY KEY DEFAULT 1,
+ condo_name VARCHAR(255) DEFAULT 'Il Mio Condominio',
+ default_monthly_quota DECIMAL(10, 2) DEFAULT 100.00,
+ current_year INT
+ )
+ `);
+
+ const [rows] = await connection.query('SELECT * FROM settings WHERE id = 1');
+ if (rows.length === 0) {
+ const currentYear = new Date().getFullYear();
+ await connection.query(
+ 'INSERT INTO settings (id, condo_name, default_monthly_quota, current_year) VALUES (1, ?, ?, ?)',
+ ['Condominio Demo', 100.00, currentYear]
+ );
+ }
+
+ // 2. Families Table
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS families (
+ id VARCHAR(36) PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ unit_number VARCHAR(50),
+ contact_email VARCHAR(255),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+
+ // 3. Payments Table
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS payments (
+ id VARCHAR(36) PRIMARY KEY,
+ family_id VARCHAR(36) NOT NULL,
+ amount DECIMAL(10, 2) NOT NULL,
+ date_paid DATETIME NOT NULL,
+ for_month INT NOT NULL,
+ for_year INT NOT NULL,
+ notes TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE CASCADE
+ )
+ `);
+
+ // 4. Users Table
+ await connection.query(`
+ CREATE TABLE IF NOT EXISTS users (
+ id VARCHAR(36) PRIMARY KEY,
+ email VARCHAR(255) UNIQUE NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ name VARCHAR(255),
+ role VARCHAR(20) DEFAULT 'user',
+ phone VARCHAR(20),
+ family_id VARCHAR(36) NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE SET NULL
+ )
+ `);
+
+ // --- MIGRATION: CHECK FOR PHONE COLUMN ---
+ // This ensures existing databases get the new column without dropping the table
+ try {
+ const [columns] = await connection.query("SHOW COLUMNS FROM users LIKE 'phone'");
+ if (columns.length === 0) {
+ console.log('Adding missing "phone" column to users table...');
+ await connection.query("ALTER TABLE users ADD COLUMN phone VARCHAR(20)");
+ }
+ } catch (migError) {
+ console.warn("Migration check failed:", migError.message);
+ }
+
+ // Seed Admin User
+ const [admins] = await connection.query('SELECT * FROM users WHERE email = ?', ['fcarra79@gmail.com']);
+ if (admins.length === 0) {
+ const hashedPassword = await bcrypt.hash('Mr10921.', 10);
+ const { v4: uuidv4 } = require('uuid');
+ await connection.query(
+ 'INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
+ [uuidv4(), 'fcarra79@gmail.com', hashedPassword, 'Amministratore', 'admin']
+ );
+ console.log('Default Admin user created.');
+ }
+
+ console.log('Database tables initialized.');
+ connection.release();
+ } catch (error) {
+ console.error('Database initialization failed:', error);
+ process.exit(1);
+ }
+};
+
+module.exports = { pool, initDb };
\ No newline at end of file
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..9eca765
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "condo-backend",
+ "version": "1.0.0",
+ "description": "Backend API for CondoPay",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "nodemon server.js"
+ },
+ "dependencies": {
+ "bcryptjs": "^2.4.3",
+ "body-parser": "^1.20.2",
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "jsonwebtoken": "^9.0.2",
+ "mysql2": "^3.9.2",
+ "uuid": "^9.0.1"
+ }
+}
\ No newline at end of file
diff --git a/server/server.js b/server/server.js
new file mode 100644
index 0000000..900e023
--- /dev/null
+++ b/server/server.js
@@ -0,0 +1,335 @@
+const express = require('express');
+const cors = require('cors');
+const bodyParser = require('body-parser');
+const bcrypt = require('bcryptjs');
+const jwt = require('jsonwebtoken');
+const { pool, initDb } = require('./db');
+const { v4: uuidv4 } = require('uuid');
+
+const app = express();
+const PORT = process.env.PORT || 3001;
+const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_key_123';
+
+app.use(cors());
+app.use(bodyParser.json());
+
+// --- MIDDLEWARE ---
+
+const authenticateToken = (req, res, next) => {
+ const authHeader = req.headers['authorization'];
+ const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
+
+ if (!token) return res.sendStatus(401);
+
+ jwt.verify(token, JWT_SECRET, (err, user) => {
+ if (err) return res.sendStatus(403);
+ req.user = user;
+ next();
+ });
+};
+
+const requireAdmin = (req, res, next) => {
+ if (req.user && req.user.role === 'admin') {
+ next();
+ } else {
+ res.status(403).json({ message: 'Access denied: Admins only' });
+ }
+};
+
+// --- AUTH ROUTES ---
+
+app.post('/api/auth/login', async (req, res) => {
+ const { email, password } = req.body;
+ try {
+ const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
+ if (users.length === 0) return res.status(401).json({ message: 'Invalid credentials' });
+
+ const user = users[0];
+ const validPassword = await bcrypt.compare(password, user.password_hash);
+ if (!validPassword) return res.status(401).json({ message: 'Invalid credentials' });
+
+ const token = jwt.sign(
+ { id: user.id, email: user.email, role: user.role, familyId: user.family_id },
+ JWT_SECRET,
+ { expiresIn: '24h' }
+ );
+
+ res.json({
+ token,
+ user: {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ role: user.role,
+ familyId: user.family_id
+ }
+ });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+// --- SETTINGS ROUTES ---
+
+app.get('/api/settings', authenticateToken, async (req, res) => {
+ try {
+ const [rows] = await pool.query('SELECT * FROM settings WHERE id = 1');
+ if (rows.length > 0) {
+ res.json({
+ condoName: rows[0].condo_name,
+ defaultMonthlyQuota: parseFloat(rows[0].default_monthly_quota),
+ currentYear: rows[0].current_year
+ });
+ } else {
+ res.status(404).json({ message: 'Settings not found' });
+ }
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.put('/api/settings', authenticateToken, requireAdmin, async (req, res) => {
+ const { condoName, defaultMonthlyQuota, currentYear } = req.body;
+ try {
+ await pool.query(
+ 'UPDATE settings SET condo_name = ?, default_monthly_quota = ?, current_year = ? WHERE id = 1',
+ [condoName, defaultMonthlyQuota, currentYear]
+ );
+ res.json({ success: true });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.get('/api/years', authenticateToken, async (req, res) => {
+ try {
+ const [rows] = await pool.query('SELECT DISTINCT for_year FROM payments ORDER BY for_year DESC');
+ const [settings] = await pool.query('SELECT current_year FROM settings WHERE id = 1');
+
+ const years = new Set(rows.map(r => r.for_year));
+ if (settings.length > 0) {
+ years.add(settings[0].current_year);
+ }
+
+ res.json(Array.from(years).sort((a, b) => b - a));
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+// --- FAMILIES ROUTES ---
+
+app.get('/api/families', authenticateToken, async (req, res) => {
+ try {
+ let query = `
+ SELECT f.*,
+ (SELECT COALESCE(SUM(amount), 0) FROM payments WHERE family_id = f.id) as total_paid
+ FROM families f
+ `;
+ let params = [];
+
+ // Permission Logic: Admin and Poweruser see all. Users see only their own.
+ const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser';
+
+ if (!isPrivileged) {
+ if (!req.user.familyId) return res.json([]); // User not linked to a family
+ query += ' WHERE f.id = ?';
+ params.push(req.user.familyId);
+ }
+
+ const [rows] = await pool.query(query, params);
+
+ const families = rows.map(r => ({
+ id: r.id,
+ name: r.name,
+ unitNumber: r.unit_number,
+ contactEmail: r.contact_email,
+ balance: 0
+ }));
+
+ res.json(families);
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.post('/api/families', authenticateToken, requireAdmin, async (req, res) => {
+ const { name, unitNumber, contactEmail } = req.body;
+ const id = uuidv4();
+ try {
+ await pool.query(
+ 'INSERT INTO families (id, name, unit_number, contact_email) VALUES (?, ?, ?, ?)',
+ [id, name, unitNumber, contactEmail]
+ );
+ res.json({ id, name, unitNumber, contactEmail, balance: 0 });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.put('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => {
+ const { id } = req.params;
+ const { name, unitNumber, contactEmail } = req.body;
+ try {
+ await pool.query(
+ 'UPDATE families SET name = ?, unit_number = ?, contact_email = ? WHERE id = ?',
+ [name, unitNumber, contactEmail, id]
+ );
+ res.json({ id, name, unitNumber, contactEmail });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.delete('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => {
+ const { id } = req.params;
+ try {
+ await pool.query('DELETE FROM families WHERE id = ?', [id]);
+ res.json({ success: true });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+// --- PAYMENTS ROUTES ---
+
+app.get('/api/payments', authenticateToken, async (req, res) => {
+ const { familyId } = req.query;
+ try {
+ // Permission Logic
+ const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser';
+
+ if (!isPrivileged) {
+ if (familyId && familyId !== req.user.familyId) {
+ return res.status(403).json({ message: 'Forbidden' });
+ }
+ if (!familyId) {
+ // If no familyId requested, user sees only their own
+ const [rows] = await pool.query('SELECT * FROM payments WHERE family_id = ?', [req.user.familyId]);
+ return res.json(rows.map(mapPaymentRow));
+ }
+ }
+
+ let query = 'SELECT * FROM payments';
+ let params = [];
+ if (familyId) {
+ query += ' WHERE family_id = ?';
+ params.push(familyId);
+ }
+
+ const [rows] = await pool.query(query, params);
+ res.json(rows.map(mapPaymentRow));
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+function mapPaymentRow(r) {
+ return {
+ id: r.id,
+ familyId: r.family_id,
+ amount: parseFloat(r.amount),
+ datePaid: r.date_paid,
+ forMonth: r.for_month,
+ forYear: r.for_year,
+ notes: r.notes
+ };
+}
+
+app.post('/api/payments', authenticateToken, async (req, res) => {
+ const { familyId, amount, datePaid, forMonth, forYear, notes } = req.body;
+
+ // Basic security:
+ // Admin: Can add for anyone
+ // Poweruser: READ ONLY (cannot add)
+ // User: Cannot add (usually)
+
+ if (req.user.role !== 'admin') {
+ return res.status(403).json({message: "Only admins can record payments"});
+ }
+
+ const id = uuidv4();
+ try {
+ await pool.query(
+ 'INSERT INTO payments (id, family_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [id, familyId, amount, new Date(datePaid), forMonth, forYear, notes]
+ );
+ res.json({ id, familyId, amount, datePaid, forMonth, forYear, notes });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+// --- USERS ROUTES ---
+
+app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ const [rows] = await pool.query('SELECT id, email, name, role, phone, family_id FROM users');
+ res.json(rows.map(r => ({
+ id: r.id,
+ email: r.email,
+ name: r.name,
+ role: r.role,
+ phone: r.phone,
+ familyId: r.family_id
+ })));
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.post('/api/users', authenticateToken, requireAdmin, async (req, res) => {
+ const { email, password, name, role, familyId, phone } = req.body;
+ try {
+ const hashedPassword = await bcrypt.hash(password, 10);
+ const id = uuidv4();
+ await pool.query(
+ 'INSERT INTO users (id, email, password_hash, name, role, family_id, phone) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [id, email, hashedPassword, name, role || 'user', familyId || null, phone]
+ );
+ res.json({ success: true, id });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.put('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => {
+ const { id } = req.params;
+ const { email, role, familyId, name, phone, password } = req.body;
+
+ try {
+ // Prepare update query dynamically based on whether password is being changed
+ let query = 'UPDATE users SET email = ?, role = ?, family_id = ?, name = ?, phone = ?';
+ let params = [email, role, familyId || null, name, phone];
+
+ if (password && password.trim() !== '') {
+ const hashedPassword = await bcrypt.hash(password, 10);
+ query += ', password_hash = ?';
+ params.push(hashedPassword);
+ }
+
+ query += ' WHERE id = ?';
+ params.push(id);
+
+ await pool.query(query, params);
+ res.json({ success: true });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.delete('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => {
+ try {
+ await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]);
+ res.json({ success: true });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+// Start Server
+initDb().then(() => {
+ app.listen(PORT, () => {
+ console.log(`Server running on port ${PORT}`);
+ });
+});
\ No newline at end of file
diff --git a/services/mockDb.ts b/services/mockDb.ts
new file mode 100644
index 0000000..53fe5df
--- /dev/null
+++ b/services/mockDb.ts
@@ -0,0 +1,247 @@
+import { Family, Payment, AppSettings, User, AuthResponse } from '../types';
+
+// In Docker/Production, Nginx proxies /api requests to the backend.
+// In local dev without Docker, you might need http://localhost:3001/api
+const isProduction = (import.meta as any).env?.PROD || window.location.hostname !== 'localhost';
+// If we are in production (Docker), use relative path. If local dev, use full URL.
+// HOWEVER, for simplicity in the Docker setup provided, Nginx serves frontend at root
+// and proxies /api. So a relative path '/api' works perfectly.
+const API_URL = '/api';
+
+const USE_MOCK_FALLBACK = true;
+
+// --- MOCK / OFFLINE UTILS ---
+const STORAGE_KEYS = {
+ SETTINGS: 'condo_settings',
+ FAMILIES: 'condo_families',
+ PAYMENTS: 'condo_payments',
+ TOKEN: 'condo_auth_token',
+ USER: 'condo_user_info'
+};
+
+const getLocal =