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:
BIN
.dockerignore
BIN
.dockerignore
Binary file not shown.
22
App.tsx
22
App.tsx
@@ -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>
|
||||||
|
|
||||||
@@ -45,4 +63,4 @@ const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
15
Dockerfile
15
Dockerfile
@@ -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;"]
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
38
nginx.conf
38
nginx.conf
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
387
pages/ExtraordinaryAdmin.tsx
Normal file
387
pages/ExtraordinaryAdmin.tsx
Normal 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
148
pages/ExtraordinaryUser.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
|
|||||||
62
server/db.js
62
server/db.js
@@ -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) {
|
||||||
|
|||||||
211
server/server.js
211
server/server.js
@@ -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, () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
34
types.ts
34
types.ts
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user