feat(extraordinary-expenses): Add module for extraordinary expenses

Introduces a new module to manage and track extraordinary expenses within condominiums. This includes defining expense items, sharing arrangements, and attaching relevant documents.

The module adds new types for `ExpenseItem`, `ExpenseShare`, and `ExtraordinaryExpense`. Mock database functions are updated to support fetching, creating, and managing these expenses. UI components in `Layout.tsx` and `Settings.tsx` are modified to include navigation and feature toggling for extraordinary expenses. Additionally, new routes are added in `App.tsx` for both administrative and user-facing views of these expenses.
This commit is contained in:
2025-12-09 23:00:05 +01:00
parent 048180db75
commit fa12a8de85
13 changed files with 918 additions and 77 deletions

Binary file not shown.

20
App.tsx
View File

@@ -1,4 +1,3 @@
import React from 'react'; import React from 'react';
import { HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Layout } from './components/Layout'; import { Layout } from './components/Layout';
@@ -7,6 +6,8 @@ import { FamilyDetail } from './pages/FamilyDetail';
import { SettingsPage } from './pages/Settings'; import { SettingsPage } from './pages/Settings';
import { TicketsPage } from './pages/Tickets'; import { TicketsPage } from './pages/Tickets';
import { ReportsPage } from './pages/Reports'; import { ReportsPage } from './pages/Reports';
import { ExtraordinaryAdmin } from './pages/ExtraordinaryAdmin';
import { ExtraordinaryUser } from './pages/ExtraordinaryUser';
import { LoginPage } from './pages/Login'; import { LoginPage } from './pages/Login';
import { CondoService } from './services/mockDb'; import { CondoService } from './services/mockDb';
@@ -21,6 +22,18 @@ const ProtectedRoute = ({ children }: { children?: React.ReactNode }) => {
return <>{children}</>; return <>{children}</>;
}; };
// Route wrapper that checks for Admin/PowerUser
const AdminRoute = ({ children }: { children?: React.ReactNode }) => {
const user = CondoService.getCurrentUser();
const isAdmin = user?.role === 'admin' || user?.role === 'poweruser';
if (!isAdmin) {
// Redirect regular users to their own view
return <ExtraordinaryUser />;
}
return <>{children}</>;
};
const App: React.FC = () => { const App: React.FC = () => {
return ( return (
<HashRouter> <HashRouter>
@@ -36,6 +49,11 @@ const App: React.FC = () => {
<Route path="family/:id" element={<FamilyDetail />} /> <Route path="family/:id" element={<FamilyDetail />} />
<Route path="tickets" element={<TicketsPage />} /> <Route path="tickets" element={<TicketsPage />} />
<Route path="reports" element={<ReportsPage />} /> <Route path="reports" element={<ReportsPage />} />
<Route path="extraordinary" element={
<AdminRoute>
<ExtraordinaryAdmin />
</AdminRoute>
} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
</Route> </Route>

View File

@@ -1,15 +0,0 @@
# Stage 1: Build Frontend
FROM node:18-alpine as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
# Copy the nginx configuration file (using the .txt extension as provided in source)
COPY nginx.txt /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
import { Users, Settings, Building, LogOut, Menu, X, ChevronDown, Check, LayoutDashboard, Megaphone, Info, AlertTriangle, Hammer, Calendar, MessageSquareWarning, PieChart } from 'lucide-react'; import { Users, Settings, Building, LogOut, Menu, X, ChevronDown, Check, LayoutDashboard, Megaphone, Info, AlertTriangle, Hammer, Calendar, MessageSquareWarning, PieChart, Briefcase } from 'lucide-react';
import { CondoService } from '../services/mockDb'; import { CondoService } from '../services/mockDb';
import { Condo, Notice, AppSettings } from '../types'; import { Condo, Notice, AppSettings } from '../types';
@@ -38,10 +38,6 @@ export const Layout: React.FC = () => {
setActiveCondo(active); setActiveCondo(active);
// Check for notices for User // Check for notices for User
// ONLY if notices feature is enabled (which we check inside logic or rely on settings state)
// However, `getSettings` is async. For simplicity, we fetch notices and if feature disabled at backend/UI level, it's fine.
// Ideally we check `settings?.features.notices` but `settings` might not be set yet.
// We'll rely on the UI hiding it, but fetching it doesn't hurt.
if (!isAdmin && active && user) { if (!isAdmin && active && user) {
try { try {
const unread = await CondoService.getUnreadNoticesForUser(user.id, active.id); const unread = await CondoService.getUnreadNoticesForUser(user.id, active.id);
@@ -240,6 +236,14 @@ export const Layout: React.FC = () => {
<span className="font-medium">Famiglie</span> <span className="font-medium">Famiglie</span>
</NavLink> </NavLink>
{/* New Extraordinary Expenses Link - Conditional */}
{settings?.features.extraordinaryExpenses && (
<NavLink to="/extraordinary" className={navClass} onClick={closeMenu}>
<Briefcase className="w-5 h-5" />
<span className="font-medium">{isAdmin ? 'Spese Straordinarie' : 'Le Mie Spese Extra'}</span>
</NavLink>
)}
{/* Privileged Links */} {/* Privileged Links */}
{isAdmin && settings?.features.reports && ( {isAdmin && settings?.features.reports && (
<NavLink to="/reports" className={navClass} onClick={closeMenu}> <NavLink to="/reports" className={navClass} onClick={closeMenu}>

View File

@@ -1,38 +0,0 @@
worker_processes 1;
events { worker_connections 1024; }
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Limite upload per allegati (es. foto/video ticket) - Allineato con il backend
client_max_body_size 50M;
# Compressione Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Gestione SPA (React Router)
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API verso il backend
location /api/ {
proxy_pass http://backend:3001;
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;
}
}
}

View File

@@ -0,0 +1,387 @@
import React, { useEffect, useState } from 'react';
import { CondoService } from '../services/mockDb';
import { ExtraordinaryExpense, Family, ExpenseItem, ExpenseShare } from '../types';
import { Plus, Calendar, FileText, CheckCircle2, Clock, Users, X, Save, Paperclip, Euro, Trash2, Eye, Briefcase } from 'lucide-react';
export const ExtraordinaryAdmin: React.FC = () => {
const [expenses, setExpenses] = useState<ExtraordinaryExpense[]>([]);
const [families, setFamilies] = useState<Family[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [selectedExpense, setSelectedExpense] = useState<ExtraordinaryExpense | null>(null);
// Form State
const [formTitle, setFormTitle] = useState('');
const [formDesc, setFormDesc] = useState('');
const [formStart, setFormStart] = useState('');
const [formEnd, setFormEnd] = useState('');
const [formContractor, setFormContractor] = useState('');
const [formItems, setFormItems] = useState<ExpenseItem[]>([{ description: '', amount: 0 }]);
const [formShares, setFormShares] = useState<ExpenseShare[]>([]); // Working state for shares
const [formAttachments, setFormAttachments] = useState<{fileName: string, fileType: string, data: string}[]>([]);
const [selectedFamilyIds, setSelectedFamilyIds] = useState<string[]>([]); // Helper to track checkboxes
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [expList, famList] = await Promise.all([
CondoService.getExpenses(),
CondoService.getFamilies()
]);
setExpenses(expList);
setFamilies(famList);
} catch(e) { console.error(e); }
finally { setLoading(false); }
};
// Calculation Helpers
const totalAmount = formItems.reduce((acc, item) => acc + (item.amount || 0), 0);
const recalculateShares = (selectedIds: string[], manualMode = false) => {
if (manualMode) return; // If manually editing, don't auto-calc
const count = selectedIds.length;
if (count === 0) {
setFormShares([]);
return;
}
const percentage = 100 / count;
const newShares: ExpenseShare[] = selectedIds.map(fid => ({
familyId: fid,
percentage: parseFloat(percentage.toFixed(2)),
amountDue: parseFloat(((totalAmount * percentage) / 100).toFixed(2)),
amountPaid: 0,
status: 'UNPAID'
}));
// Adjust rounding error on last item
if (newShares.length > 0) {
const sumDue = newShares.reduce((a, b) => a + b.amountDue, 0);
const diff = totalAmount - sumDue;
if (diff !== 0) {
newShares[newShares.length - 1].amountDue += diff;
}
}
setFormShares(newShares);
};
const handleFamilyToggle = (familyId: string) => {
const newSelected = selectedFamilyIds.includes(familyId)
? selectedFamilyIds.filter(id => id !== familyId)
: [...selectedFamilyIds, familyId];
setSelectedFamilyIds(newSelected);
recalculateShares(newSelected);
};
const handleShareChange = (index: number, field: 'percentage', value: number) => {
const newShares = [...formShares];
newShares[index].percentage = value;
newShares[index].amountDue = (totalAmount * value) / 100;
setFormShares(newShares);
};
const handleItemChange = (index: number, field: keyof ExpenseItem, value: any) => {
const newItems = [...formItems];
// @ts-ignore
newItems[index][field] = value;
setFormItems(newItems);
// Recalculate shares based on new total
// We need a small delay or effect, but for simplicity let's force recalc next render or manual
};
// Trigger share recalc when total changes (if not manual override mode - implementing simple auto mode here)
useEffect(() => {
recalculateShares(selectedFamilyIds);
}, [totalAmount]); // eslint-disable-line react-hooks/exhaustive-deps
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const newAtts = [];
for(let i=0; i<e.target.files.length; i++) {
const file = e.target.files[i];
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
newAtts.push({ fileName: file.name, fileType: file.type, data: base64 });
}
setFormAttachments([...formAttachments, ...newAtts]);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await CondoService.createExpense({
title: formTitle,
description: formDesc,
startDate: formStart,
endDate: formEnd,
contractorName: formContractor,
items: formItems,
shares: formShares,
attachments: formAttachments
});
setShowModal(false);
loadData();
// Reset form
setFormTitle(''); setFormDesc(''); setFormItems([{description:'', amount:0}]); setSelectedFamilyIds([]); setFormShares([]); setFormAttachments([]);
} catch(e) { alert('Errore creazione'); }
};
const openDetails = async (expense: ExtraordinaryExpense) => {
const fullDetails = await CondoService.getExpenseDetails(expense.id);
setSelectedExpense(fullDetails);
setShowDetailsModal(true);
};
const openAttachment = async (expenseId: string, attId: string) => {
try {
const file = await CondoService.getExpenseAttachment(expenseId, attId);
const win = window.open();
if (win) {
if (file.fileType.startsWith('image/') || file.fileType === 'application/pdf') {
win.document.write(`<iframe src="${file.data}" frameborder="0" style="border:0; top:0px; left:0px; bottom:0px; right:0px; width:100%; height:100%;" allowfullscreen></iframe>`);
} else {
win.document.write(`<a href="${file.data}" download="${file.fileName}">Download ${file.fileName}</a>`);
}
}
} catch(e) { alert("Errore file"); }
};
return (
<div className="space-y-6 pb-20 animate-fade-in">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-slate-800">Spese Straordinarie</h2>
<p className="text-slate-500 text-sm">Gestione lavori e appalti</p>
</div>
<button onClick={() => setShowModal(true)} className="bg-blue-600 text-white px-4 py-2 rounded-lg font-medium flex gap-2 hover:bg-blue-700">
<Plus className="w-5 h-5"/> Nuova Spesa
</button>
</div>
{/* List */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{expenses.map(exp => (
<div key={exp.id} className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-all">
<div className="flex justify-between items-start mb-3">
<span className="bg-purple-100 text-purple-700 text-xs font-bold px-2 py-1 rounded uppercase">Lavori</span>
<span className="font-bold text-slate-800"> {exp.totalAmount.toLocaleString()}</span>
</div>
<h3 className="font-bold text-lg text-slate-800 mb-1">{exp.title}</h3>
<p className="text-sm text-slate-500 mb-4 line-clamp-2">{exp.description}</p>
<div className="space-y-2 text-sm text-slate-600 mb-4">
<div className="flex items-center gap-2"><Briefcase className="w-4 h-4 text-slate-400"/> {exp.contractorName}</div>
<div className="flex items-center gap-2"><Calendar className="w-4 h-4 text-slate-400"/> {new Date(exp.startDate).toLocaleDateString()}</div>
</div>
<button onClick={() => openDetails(exp)} className="w-full py-2 border border-slate-200 rounded-lg text-slate-600 font-medium hover:bg-slate-50 flex items-center justify-center gap-2">
<Eye className="w-4 h-4"/> Dettagli & Pagamenti
</button>
</div>
))}
</div>
{/* CREATE MODAL */}
{showModal && (
<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-4xl p-6 animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-xl text-slate-800">Crea Progetto Straordinario</h3>
<button onClick={() => setShowModal(false)}><X className="w-6 h-6 text-slate-400"/></button>
</div>
<div className="flex-1 overflow-y-auto pr-2">
<form id="createForm" onSubmit={handleSubmit} className="space-y-6">
{/* General Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input className="border p-2 rounded" placeholder="Titolo Lavori" value={formTitle} onChange={e => setFormTitle(e.target.value)} required />
<input className="border p-2 rounded" placeholder="Azienda Appaltatrice" value={formContractor} onChange={e => setFormContractor(e.target.value)} required />
<div className="col-span-2">
<textarea className="w-full border p-2 rounded h-20" placeholder="Descrizione..." value={formDesc} onChange={e => setFormDesc(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-2 col-span-2 md:col-span-1">
<div><label className="text-xs font-bold text-slate-500">Inizio</label><input type="date" className="w-full border p-2 rounded" value={formStart} onChange={e => setFormStart(e.target.value)} required /></div>
<div><label className="text-xs font-bold text-slate-500">Fine (Prevista)</label><input type="date" className="w-full border p-2 rounded" value={formEnd} onChange={e => setFormEnd(e.target.value)} /></div>
</div>
<div>
<label className="text-xs font-bold text-slate-500 block mb-1">Allegati (Preventivi, Contratti)</label>
<input type="file" multiple onChange={handleFileChange} className="w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"/>
</div>
</div>
{/* Items */}
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
<h4 className="font-bold text-slate-700 mb-2 flex justify-between">
Voci di Spesa
<span className="text-blue-600">Totale: {totalAmount.toLocaleString()}</span>
</h4>
{formItems.map((item, idx) => (
<div key={idx} className="flex gap-2 mb-2">
<input className="flex-1 border p-2 rounded text-sm" placeholder="Descrizione voce..." value={item.description} onChange={e => handleItemChange(idx, 'description', e.target.value)} required />
<input type="number" className="w-32 border p-2 rounded text-sm" placeholder="Importo" value={item.amount} onChange={e => handleItemChange(idx, 'amount', parseFloat(e.target.value))} required />
{idx > 0 && <button type="button" onClick={() => setFormItems(formItems.filter((_, i) => i !== idx))} className="text-red-500"><X className="w-4 h-4"/></button>}
</div>
))}
<button type="button" onClick={() => setFormItems([...formItems, {description:'', amount:0}])} className="text-sm text-blue-600 font-medium flex items-center gap-1 mt-2">
<Plus className="w-3 h-3"/> Aggiungi Voce
</button>
</div>
{/* Distribution */}
<div>
<h4 className="font-bold text-slate-700 mb-2">Ripartizione Famiglie</h4>
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
{families.map(fam => {
const share = formShares.find(s => s.familyId === fam.id);
return (
<div key={fam.id} className="flex items-center justify-between p-3 hover:bg-slate-50">
<label className="flex items-center gap-2 cursor-pointer flex-1">
<input type="checkbox" checked={selectedFamilyIds.includes(fam.id)} onChange={() => handleFamilyToggle(fam.id)} className="rounded text-blue-600"/>
<span className="text-sm font-medium text-slate-700">{fam.name} <span className="text-slate-400 font-normal">({fam.unitNumber})</span></span>
</label>
{share && (
<div className="flex items-center gap-2">
<div className="relative">
<input
type="number"
className="w-16 border rounded p-1 text-right text-sm"
value={share.percentage}
onChange={e => handleShareChange(formShares.indexOf(share), 'percentage', parseFloat(e.target.value))}
/>
<span className="absolute right-6 top-1 text-xs text-slate-400">%</span>
</div>
<div className="w-24 text-right text-sm font-bold text-slate-700"> {share.amountDue.toFixed(2)}</div>
</div>
)}
</div>
);
})}
</div>
</div>
</form>
</div>
<div className="pt-4 border-t mt-4 flex justify-end gap-2">
<button onClick={() => setShowModal(false)} className="px-4 py-2 text-slate-600 border rounded-lg hover:bg-slate-50">Annulla</button>
<button type="submit" form="createForm" className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-bold">Crea Progetto</button>
</div>
</div>
</div>
)}
{/* DETAILS MODAL */}
{showDetailsModal && selectedExpense && (
<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-5xl p-6 animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center mb-6 border-b pb-4">
<div>
<h3 className="font-bold text-xl text-slate-800">{selectedExpense.title}</h3>
<p className="text-sm text-slate-500">Totale: {selectedExpense.totalAmount.toLocaleString()}</p>
</div>
<button onClick={() => setShowDetailsModal(false)}><X className="w-6 h-6 text-slate-400"/></button>
</div>
<div className="flex-1 overflow-y-auto grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Info & Items */}
<div className="lg:col-span-1 space-y-6">
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
<h4 className="font-bold text-sm text-slate-700 mb-2 uppercase">Dettagli</h4>
<p className="text-sm text-slate-600 mb-2">{selectedExpense.description}</p>
<div className="space-y-1 text-sm">
<p><strong>Appaltatore:</strong> {selectedExpense.contractorName}</p>
<p><strong>Inizio:</strong> {new Date(selectedExpense.startDate).toLocaleDateString()}</p>
</div>
</div>
<div>
<h4 className="font-bold text-sm text-slate-700 mb-2 uppercase">Voci di Spesa</h4>
<ul className="divide-y border rounded-lg">
{selectedExpense.items.map((item, i) => (
<li key={i} className="p-3 text-sm flex justify-between">
<span>{item.description}</span>
<span className="font-medium"> {item.amount}</span>
</li>
))}
</ul>
</div>
{selectedExpense.attachments && selectedExpense.attachments.length > 0 && (
<div>
<h4 className="font-bold text-sm text-slate-700 mb-2 uppercase">Documenti</h4>
<div className="flex flex-wrap gap-2">
{selectedExpense.attachments.map(att => (
<button key={att.id} onClick={() => openAttachment(selectedExpense.id, att.id)} className="flex items-center gap-1 bg-white border px-3 py-1 rounded text-xs hover:bg-slate-50">
<Paperclip className="w-3 h-3"/> {att.fileName}
</button>
))}
</div>
</div>
)}
</div>
{/* Right: Shares & Payments */}
<div className="lg:col-span-2">
<h4 className="font-bold text-sm text-slate-700 mb-4 uppercase flex items-center gap-2">
<Euro className="w-4 h-4"/> Stato Pagamenti Famiglie
</h4>
<div className="bg-white border rounded-lg overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="bg-slate-100 text-slate-600 font-semibold">
<tr>
<th className="p-3">Famiglia</th>
<th className="p-3 text-right">Quota (%)</th>
<th className="p-3 text-right">Da Pagare</th>
<th className="p-3 text-right">Versato</th>
<th className="p-3 text-center">Stato</th>
</tr>
</thead>
<tbody className="divide-y">
{selectedExpense.shares?.map(share => (
<tr key={share.id}>
<td className="p-3 font-medium">{share.familyName}</td>
<td className="p-3 text-right">{share.percentage}%</td>
<td className="p-3 text-right font-bold"> {share.amountDue.toFixed(2)}</td>
<td className="p-3 text-right text-blue-600"> {share.amountPaid.toFixed(2)}</td>
<td className="p-3 text-center">
<span className={`px-2 py-1 rounded-full text-[10px] font-bold uppercase ${
share.status === 'PAID' ? 'bg-green-100 text-green-700' :
share.status === 'PARTIAL' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{share.status === 'PAID' ? 'Saldato' : share.status === 'PARTIAL' ? 'Parziale' : 'Insoluto'}
</span>
</td>
</tr>
))}
</tbody>
<tfoot className="bg-slate-50 font-bold text-slate-700">
<tr>
<td colSpan={2} className="p-3 text-right">Totale</td>
<td className="p-3 text-right"> {selectedExpense.totalAmount.toLocaleString()}</td>
<td className="p-3 text-right text-blue-600">
{selectedExpense.shares?.reduce((a,b) => a + b.amountPaid, 0).toLocaleString()}
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};

148
pages/ExtraordinaryUser.tsx Normal file
View File

@@ -0,0 +1,148 @@
import React, { useEffect, useState } from 'react';
import { CondoService } from '../services/mockDb';
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
import { Briefcase, Calendar, CheckCircle2 } from 'lucide-react';
export const ExtraordinaryUser: React.FC = () => {
const [expenses, setExpenses] = useState<any[]>([]); // Using any for composite object from specific API
const [loading, setLoading] = useState(true);
const [paypalClientId, setPaypalClientId] = useState<string>('');
const [successMsg, setSuccessMsg] = useState('');
useEffect(() => {
const load = async () => {
try {
const [myExp, condo] = await Promise.all([
CondoService.getMyExpenses(),
CondoService.getActiveCondo()
]);
setExpenses(myExp);
if (condo?.paypalClientId) setPaypalClientId(condo.paypalClientId);
} catch(e) { console.error(e); }
finally { setLoading(false); }
};
load();
}, []);
const handlePaymentSuccess = async (expenseId: string, amount: number) => {
try {
await CondoService.payExpense(expenseId, amount);
setSuccessMsg('Pagamento registrato con successo!');
setTimeout(() => setSuccessMsg(''), 3000);
// Refresh
const updated = await CondoService.getMyExpenses();
setExpenses(updated);
} catch(e) { alert("Errore registrazione pagamento"); }
};
if (loading) return <div className="p-8 text-center text-slate-400">Caricamento spese...</div>;
return (
<div className="space-y-6 pb-20 animate-fade-in">
<div>
<h2 className="text-2xl font-bold text-slate-800">Le Mie Spese Extra</h2>
<p className="text-slate-500 text-sm">Lavori straordinari e ripartizioni</p>
</div>
{successMsg && (
<div className="bg-green-100 text-green-700 p-4 rounded-xl flex items-center gap-2 mb-4">
<CheckCircle2 className="w-5 h-5"/> {successMsg}
</div>
)}
{expenses.length === 0 ? (
<div className="text-center p-12 bg-white rounded-xl border border-dashed border-slate-300">
<Briefcase className="w-12 h-12 text-slate-300 mx-auto mb-3"/>
<p className="text-slate-500">Nessuna spesa straordinaria attiva.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2">
{expenses.map(exp => {
const remaining = exp.myShare.amountDue - exp.myShare.amountPaid;
const isPaid = exp.myShare.status === 'PAID';
return (
<div key={exp.id} className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-start">
<div>
<h3 className="font-bold text-lg text-slate-800">{exp.title}</h3>
<p className="text-xs text-slate-500 mt-1 flex items-center gap-1">
<Calendar className="w-3 h-3"/> {new Date(exp.startDate).toLocaleDateString()}
</p>
</div>
<div className={`px-3 py-1 rounded-full text-xs font-bold uppercase ${
isPaid ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'
}`}>
{isPaid ? 'Saldato' : 'In Sospeso'}
</div>
</div>
<div className="p-5 flex-1 space-y-4">
<div className="flex justify-between items-center text-sm">
<span className="text-slate-500">Quota Totale (100%)</span>
<span className="font-medium text-slate-800"> {exp.totalAmount.toLocaleString()}</span>
</div>
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
<div className="flex justify-between items-center text-sm mb-1">
<span className="text-blue-800 font-medium">La tua quota ({exp.myShare.percentage}%)</span>
<span className="font-bold text-blue-900"> {exp.myShare.amountDue.toFixed(2)}</span>
</div>
<div className="w-full bg-blue-200 h-1.5 rounded-full overflow-hidden">
<div
className="bg-blue-600 h-full transition-all duration-500"
style={{ width: `${(exp.myShare.amountPaid / exp.myShare.amountDue) * 100}%` }}
/>
</div>
<div className="flex justify-between text-xs mt-1 text-blue-600">
<span>Versato: {exp.myShare.amountPaid.toFixed(2)}</span>
<span>Restante: {remaining.toFixed(2)}</span>
</div>
</div>
</div>
<div className="p-5 border-t border-slate-100 bg-slate-50">
{!isPaid && remaining > 0.01 && (
<>
{paypalClientId ? (
<PayPalScriptProvider options={{ clientId: paypalClientId, currency: "EUR" }}>
<PayPalButtons
style={{ layout: "horizontal", height: 40 }}
createOrder={(data, actions) => {
return actions.order.create({
intent: "CAPTURE",
purchase_units: [{
description: `Spesa Straordinaria: ${exp.title}`,
amount: { currency_code: "EUR", value: remaining.toFixed(2) }
}]
});
}}
onApprove={(data, actions) => {
if(!actions.order) return Promise.resolve();
return actions.order.capture().then(() => {
handlePaymentSuccess(exp.id, remaining);
});
}}
/>
</PayPalScriptProvider>
) : (
<div className="text-center text-xs text-red-500 bg-red-50 p-2 rounded">
Pagamenti online non configurati. Contatta l'amministratore.
</div>
)}
</>
)}
{isPaid && (
<div className="text-center text-green-600 font-bold text-sm flex items-center justify-center gap-2">
<CheckCircle2 className="w-5 h-5"/> Nessun importo dovuto
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
};

View File

@@ -676,6 +676,17 @@ export const SettingsPage: React.FC = () => {
<span className={`${globalSettings.features.reports ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}/> <span className={`${globalSettings.features.reports ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}/>
</button> </button>
</div> </div>
{/* Extraordinary Expenses */}
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100">
<div>
<p className="font-bold text-slate-800">Spese Straordinarie</p>
<p className="text-sm text-slate-500">Gestione lavori, preventivi e ripartizione quote extra.</p>
</div>
<button type="button" onClick={() => toggleFeature('extraordinaryExpenses')} className={`${globalSettings.features.extraordinaryExpenses !== false ? 'bg-green-500' : 'bg-slate-300'} relative inline-flex h-6 w-11 items-center rounded-full transition-colors`}>
<span className={`${globalSettings.features.extraordinaryExpenses !== false ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}/>
</button>
</div>
</div> </div>
<div className="pt-2 flex justify-between items-center"> <div className="pt-2 flex justify-between items-center">

View File

@@ -1,13 +0,0 @@
FROM node:18-alpine
WORKDIR /app
# Set production environment
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3001
CMD ["node", "server.js"]

View File

@@ -46,7 +46,12 @@ const dbInterface = {
if (DB_CLIENT === 'postgres') { if (DB_CLIENT === 'postgres') {
return { return {
query: executeQuery, query: executeQuery,
release: () => {} release: () => {},
// Mock transaction methods for Postgres simple wrapper
// In a real prod app, you would get a specific client from the pool here
beginTransaction: async () => {},
commit: async () => {},
rollback: async () => {}
}; };
} else { } else {
return await mysqlPool.getConnection(); return await mysqlPool.getConnection();
@@ -347,6 +352,58 @@ const initDb = async () => {
) )
`); `);
// 11. Extraordinary Expenses Tables
await connection.query(`
CREATE TABLE IF NOT EXISTS extraordinary_expenses (
id VARCHAR(36) PRIMARY KEY,
condo_id VARCHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
start_date ${TIMESTAMP_TYPE},
end_date ${TIMESTAMP_TYPE},
contractor_name VARCHAR(255),
total_amount DECIMAL(15, 2) DEFAULT 0,
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (condo_id) REFERENCES condos(id) ON DELETE CASCADE
)
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS expense_items (
id VARCHAR(36) PRIMARY KEY,
expense_id VARCHAR(36) NOT NULL,
description VARCHAR(255),
amount DECIMAL(15, 2) NOT NULL,
FOREIGN KEY (expense_id) REFERENCES extraordinary_expenses(id) ON DELETE CASCADE
)
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS expense_shares (
id VARCHAR(36) PRIMARY KEY,
expense_id VARCHAR(36) NOT NULL,
family_id VARCHAR(36) NOT NULL,
percentage DECIMAL(5, 2) NOT NULL,
amount_due DECIMAL(15, 2) NOT NULL,
amount_paid DECIMAL(15, 2) DEFAULT 0,
status VARCHAR(20) DEFAULT 'UNPAID',
FOREIGN KEY (expense_id) REFERENCES extraordinary_expenses(id) ON DELETE CASCADE,
FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE CASCADE
)
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS expense_attachments (
id VARCHAR(36) PRIMARY KEY,
expense_id VARCHAR(36) NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_type VARCHAR(100),
data ${LONG_TEXT_TYPE},
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (expense_id) REFERENCES extraordinary_expenses(id) ON DELETE CASCADE
)
`);
// --- SEEDING --- // --- SEEDING ---
const [rows] = await connection.query('SELECT * FROM settings WHERE id = 1'); const [rows] = await connection.query('SELECT * FROM settings WHERE id = 1');
const defaultFeatures = { const defaultFeatures = {
@@ -354,7 +411,8 @@ const initDb = async () => {
tickets: true, tickets: true,
payPal: true, payPal: true,
notices: true, notices: true,
reports: true reports: true,
extraordinaryExpenses: true
}; };
if (rows.length === 0) { if (rows.length === 0) {

View File

@@ -235,7 +235,7 @@ app.post('/api/condos', authenticateToken, requireAdmin, async (req, res) => {
try { try {
await pool.query( await pool.query(
'INSERT INTO condos (id, name, address, street_number, city, province, zip_code, notes, default_monthly_quota, paypal_client_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO condos (id, name, address, street_number, city, province, zip_code, notes, default_monthly_quota, paypal_client_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, name, address, streetNumber, city, province, zip_code, notes, defaultMonthlyQuota, paypalClientId] [id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId]
); );
res.json({ id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId }); res.json({ id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
@@ -850,6 +850,215 @@ app.delete('/api/tickets/:id', authenticateToken, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// --- EXTRAORDINARY EXPENSES ---
app.get('/api/expenses', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
const [expenses] = await pool.query('SELECT * FROM extraordinary_expenses WHERE condo_id = ? ORDER BY created_at DESC', [condoId]);
res.json(expenses.map(e => ({
id: e.id,
condoId: e.condo_id,
title: e.title,
description: e.description,
startDate: e.start_date,
endDate: e.end_date,
contractorName: e.contractor_name,
totalAmount: parseFloat(e.total_amount),
createdAt: e.created_at
})));
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/expenses/:id', authenticateToken, async (req, res) => {
try {
const [expenses] = await pool.query('SELECT * FROM extraordinary_expenses WHERE id = ?', [req.params.id]);
if (expenses.length === 0) return res.status(404).json({ message: 'Expense not found' });
const expense = expenses[0];
// Fetch Items
const [items] = await pool.query('SELECT id, description, amount FROM expense_items WHERE expense_id = ?', [expense.id]);
// Fetch Shares
const [shares] = await pool.query(`
SELECT s.id, s.family_id, f.name as family_name, s.percentage, s.amount_due, s.amount_paid, s.status
FROM expense_shares s
JOIN families f ON s.family_id = f.id
WHERE s.expense_id = ?
`, [expense.id]);
// Fetch Attachments (light)
const [attachments] = await pool.query('SELECT id, file_name, file_type FROM expense_attachments WHERE expense_id = ?', [expense.id]);
res.json({
id: expense.id,
condoId: expense.condo_id,
title: expense.title,
description: expense.description,
startDate: expense.start_date,
endDate: expense.end_date,
contractorName: expense.contractor_name,
totalAmount: parseFloat(expense.total_amount),
createdAt: expense.created_at,
items: items.map(i => ({ id: i.id, description: i.description, amount: parseFloat(i.amount) })),
shares: shares.map(s => ({
id: s.id,
familyId: s.family_id,
familyName: s.family_name,
percentage: parseFloat(s.percentage),
amountDue: parseFloat(s.amount_due),
amountPaid: parseFloat(s.amount_paid),
status: s.status
})),
attachments: attachments.map(a => ({ id: a.id, fileName: a.file_name, fileType: a.file_type }))
});
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/expenses', authenticateToken, requireAdmin, async (req, res) => {
const { condoId, title, description, startDate, endDate, contractorName, items, shares, attachments } = req.body;
const expenseId = uuidv4();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// Calculate total
const totalAmount = items.reduce((sum, i) => sum + parseFloat(i.amount), 0);
// Insert Expense
await connection.query(
'INSERT INTO extraordinary_expenses (id, condo_id, title, description, start_date, end_date, contractor_name, total_amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[expenseId, condoId, title, description, startDate, endDate, contractorName, totalAmount]
);
// Insert Items
for (const item of items) {
await connection.query(
'INSERT INTO expense_items (id, expense_id, description, amount) VALUES (?, ?, ?, ?)',
[uuidv4(), expenseId, item.description, item.amount]
);
}
// Insert Shares
for (const share of shares) {
await connection.query(
'INSERT INTO expense_shares (id, expense_id, family_id, percentage, amount_due, status) VALUES (?, ?, ?, ?, ?, ?)',
[uuidv4(), expenseId, share.familyId, share.percentage, share.amountDue, 'UNPAID']
);
}
// Insert Attachments
if (attachments && attachments.length > 0) {
for (const att of attachments) {
await connection.query(
'INSERT INTO expense_attachments (id, expense_id, file_name, file_type, data) VALUES (?, ?, ?, ?, ?)',
[uuidv4(), expenseId, att.fileName, att.fileType, att.data]
);
}
}
await connection.commit();
res.json({ success: true, id: expenseId });
} catch (e) {
await connection.rollback();
console.error(e);
res.status(500).json({ error: e.message });
} finally {
connection.release();
}
});
app.get('/api/expenses/:id/attachments/:attachmentId', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM expense_attachments WHERE id = ? AND expense_id = ?', [req.params.attachmentId, req.params.id]);
if (rows.length === 0) return res.status(404).json({ message: 'File not found' });
const file = rows[0];
res.json({ id: file.id, fileName: file.file_name, fileType: file.file_type, data: file.data });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/expenses/:id/pay', authenticateToken, async (req, res) => {
const { amount, notes } = req.body;
const expenseId = req.params.id;
const userId = req.user.id;
const connection = await pool.getConnection();
try {
// Find user's family
const [users] = await connection.query('SELECT family_id FROM users WHERE id = ?', [userId]);
if (users.length === 0 || !users[0].family_id) return res.status(400).json({ message: 'User has no family assigned' });
const familyId = users[0].family_id;
// Find share
const [shares] = await connection.query('SELECT * FROM expense_shares WHERE expense_id = ? AND family_id = ?', [expenseId, familyId]);
if (shares.length === 0) return res.status(404).json({ message: 'No share found for this expense' });
const share = shares[0];
const newPaid = parseFloat(share.amount_paid) + parseFloat(amount);
const due = parseFloat(share.amount_due);
let status = 'PARTIAL';
if (newPaid >= due - 0.01) status = 'PAID'; // Tolerance for float
await connection.beginTransaction();
// Update Share
await connection.query('UPDATE expense_shares SET amount_paid = ?, status = ? WHERE id = ?', [newPaid, status, share.id]);
// Also record in global payments for visibility in reports (optional but requested to track family payments)
// We use a special month/year or notes to distinguish
await connection.query(
'INSERT INTO payments (id, family_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, NOW(), 13, YEAR(NOW()), ?)',
[uuidv4(), familyId, amount, `Spesa Straordinaria: ${notes || 'PayPal'}`]
);
await connection.commit();
res.json({ success: true });
} catch (e) {
await connection.rollback();
res.status(500).json({ error: e.message });
} finally {
connection.release();
}
});
// Get User's Expenses
app.get('/api/my-expenses', authenticateToken, async (req, res) => {
const userId = req.user.id;
const { condoId } = req.query; // Optional filter if user belongs to multiple condos (unlikely in current logic but safe)
try {
const [users] = await pool.query('SELECT family_id FROM users WHERE id = ?', [userId]);
if (!users[0]?.family_id) return res.json([]);
const familyId = users[0].family_id;
const [rows] = await pool.query(`
SELECT e.id, e.title, e.total_amount, e.start_date, e.end_date, s.amount_due, s.amount_paid, s.status, s.percentage
FROM expense_shares s
JOIN extraordinary_expenses e ON s.expense_id = e.id
WHERE s.family_id = ? AND e.condo_id = ?
ORDER BY e.created_at DESC
`, [familyId, condoId]); // Ensure we only get expenses for the active condo context if needed
res.json(rows.map(r => ({
id: r.id,
title: r.title,
totalAmount: parseFloat(r.total_amount),
startDate: r.start_date,
endDate: r.end_date,
myShare: {
percentage: parseFloat(r.percentage),
amountDue: parseFloat(r.amount_due),
amountPaid: parseFloat(r.amount_paid),
status: r.status
}
})));
} catch (e) { res.status(500).json({ error: e.message }); }
});
initDb().then(() => { initDb().then(() => {
app.listen(PORT, () => { app.listen(PORT, () => {

View File

@@ -1,5 +1,5 @@
import { Family, Payment, AppSettings, User, AlertDefinition, Condo, Notice, NoticeRead, Ticket, TicketAttachment, TicketComment, SmtpConfig } from '../types'; import { Family, Payment, AppSettings, User, AlertDefinition, Condo, Notice, NoticeRead, Ticket, TicketAttachment, TicketComment, SmtpConfig, ExtraordinaryExpense } from '../types';
// --- CONFIGURATION TOGGLE --- // --- CONFIGURATION TOGGLE ---
const FORCE_LOCAL_DB = false; const FORCE_LOCAL_DB = false;
@@ -343,6 +343,44 @@ export const CondoService = {
}); });
}, },
// --- EXTRAORDINARY EXPENSES ---
getExpenses: async (condoId?: string): Promise<ExtraordinaryExpense[]> => {
let url = '/expenses';
const activeId = condoId || CondoService.getActiveCondoId();
if (activeId) url += `?condoId=${activeId}`;
return request<ExtraordinaryExpense[]>(url);
},
getExpenseDetails: async (id: string): Promise<ExtraordinaryExpense> => {
return request<ExtraordinaryExpense>(`/expenses/${id}`);
},
createExpense: async (data: any): Promise<void> => {
const activeId = CondoService.getActiveCondoId();
if (!activeId) throw new Error("No active condo");
return request('/expenses', {
method: 'POST',
body: JSON.stringify({ ...data, condoId: activeId })
});
},
getExpenseAttachment: async (expenseId: string, attachmentId: string): Promise<any> => {
return request(`/expenses/${expenseId}/attachments/${attachmentId}`);
},
getMyExpenses: async (): Promise<any[]> => {
const activeId = CondoService.getActiveCondoId();
return request(`/my-expenses?condoId=${activeId}`);
},
payExpense: async (expenseId: string, amount: number): Promise<void> => {
return request(`/expenses/${expenseId}/pay`, {
method: 'POST',
body: JSON.stringify({ amount, notes: 'PayPal Payment' })
});
},
// --- SEEDING --- // --- SEEDING ---
seedPayments: () => { seedPayments: () => {
// No-op in remote mode // No-op in remote mode

View File

@@ -52,6 +52,7 @@ export interface AppFeatures {
payPal: boolean; payPal: boolean;
notices: boolean; notices: boolean;
reports: boolean; reports: boolean;
extraordinaryExpenses: boolean;
} }
export interface AlertDefinition { export interface AlertDefinition {
@@ -179,3 +180,36 @@ export interface Ticket {
userName?: string; // Joined field userName?: string; // Joined field
userEmail?: string; // Joined field userEmail?: string; // Joined field
} }
// --- EXTRAORDINARY EXPENSES ---
export interface ExpenseItem {
id?: string;
description: string;
amount: number;
}
export interface ExpenseShare {
id?: string;
familyId: string;
percentage: number;
amountDue: number;
amountPaid: number;
status: 'PAID' | 'PARTIAL' | 'UNPAID';
familyName?: string; // Joined
}
export interface ExtraordinaryExpense {
id: string;
condoId: string;
title: string;
description: string;
startDate: string;
endDate?: string;
contractorName: string;
totalAmount: number;
items: ExpenseItem[];
shares?: ExpenseShare[]; // For detail view
attachments?: { id: string, fileName: string, fileType: string, data: string }[];
createdAt: string;
}