feat(expenses): Add delete expense endpoint and functionality

Implements the ability to delete an expense, including its associated items and shares. Also refactors the expense update logic to correctly handle share updates and adds the corresponding API endpoint and mock DB function.
This commit is contained in:
2025-12-09 23:25:06 +01:00
parent 0d54da1cdf
commit 8a43143ead
7 changed files with 163 additions and 147 deletions

Binary file not shown.

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,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

@@ -47,7 +47,7 @@ export const ExtraordinaryAdmin: React.FC = () => {
const totalAmount = formItems.reduce((acc, item) => acc + (item.amount || 0), 0); const totalAmount = formItems.reduce((acc, item) => acc + (item.amount || 0), 0);
const recalculateShares = (selectedIds: string[], manualMode = false) => { const recalculateShares = (selectedIds: string[], manualMode = false) => {
if (manualMode || isEditing) return; // Don't auto-calc shares in Edit mode to prevent messing up existing complex logic visually, backend handles logic if (manualMode) return;
const count = selectedIds.length; const count = selectedIds.length;
if (count === 0) { if (count === 0) {
@@ -56,13 +56,27 @@ export const ExtraordinaryAdmin: React.FC = () => {
} }
const percentage = 100 / count; const percentage = 100 / count;
const newShares: ExpenseShare[] = selectedIds.map(fid => ({ const newShares: ExpenseShare[] = selectedIds.map(fid => {
familyId: fid, // Preserve existing data if available
percentage: parseFloat(percentage.toFixed(2)), const existing = formShares.find(s => s.familyId === fid);
amountDue: parseFloat(((totalAmount * percentage) / 100).toFixed(2)), if (existing) {
amountPaid: 0, // If editing and we just toggled someone else, re-calc percentages evenly?
status: 'UNPAID' // Or keep manual adjustments?
})); // For simplicity: auto-recalc resets percentages evenly.
return {
...existing,
percentage: parseFloat(percentage.toFixed(2)),
amountDue: parseFloat(((totalAmount * percentage) / 100).toFixed(2))
};
}
return {
familyId: fid,
percentage: parseFloat(percentage.toFixed(2)),
amountDue: parseFloat(((totalAmount * percentage) / 100).toFixed(2)),
amountPaid: 0,
status: 'UNPAID'
};
});
// Adjust rounding error on last item // Adjust rounding error on last item
if (newShares.length > 0) { if (newShares.length > 0) {
@@ -99,9 +113,11 @@ export const ExtraordinaryAdmin: React.FC = () => {
setFormItems(newItems); setFormItems(newItems);
}; };
// Trigger share recalc when total changes (if not manual/editing) // Trigger share recalc when total changes (if not manual)
// We only trigger auto-recalc if not editing existing complex shares,
// OR if editing but user hasn't manually messed with them yet (simplification: always recalc on total change for now)
useEffect(() => { useEffect(() => {
if (!isEditing) { if (selectedFamilyIds.length > 0) {
recalculateShares(selectedFamilyIds); recalculateShares(selectedFamilyIds);
} }
}, [totalAmount]); // eslint-disable-line react-hooks/exhaustive-deps }, [totalAmount]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -131,7 +147,7 @@ export const ExtraordinaryAdmin: React.FC = () => {
}; };
const openEditModal = async (exp: ExtraordinaryExpense) => { const openEditModal = async (exp: ExtraordinaryExpense) => {
// Fetch full details first to get items // Fetch full details first to get items and shares
try { try {
const detail = await CondoService.getExpenseDetails(exp.id); const detail = await CondoService.getExpenseDetails(exp.id);
setIsEditing(true); setIsEditing(true);
@@ -142,15 +158,27 @@ export const ExtraordinaryAdmin: React.FC = () => {
setFormEnd(detail.endDate ? new Date(detail.endDate).toISOString().split('T')[0] : ''); setFormEnd(detail.endDate ? new Date(detail.endDate).toISOString().split('T')[0] : '');
setFormContractor(detail.contractorName); setFormContractor(detail.contractorName);
setFormItems(detail.items || []); setFormItems(detail.items || []);
// Shares and attachments are not fully editable in this simple view to avoid conflicts
// We only allow editing Header Info + Items. Shares will be auto-recalculated by backend based on new total. // Populate shares for editing
setFormShares([]); const currentShares = detail.shares || [];
setFormShares(currentShares);
setSelectedFamilyIds(currentShares.map(s => s.familyId));
// Attachments (Cannot edit attachments in this simple view for now, cleared)
setFormAttachments([]); setFormAttachments([]);
setSelectedFamilyIds([]);
setShowModal(true); setShowModal(true);
} catch(e) { alert("Errore caricamento dettagli"); } } catch(e) { alert("Errore caricamento dettagli"); }
}; };
const handleDeleteExpense = async (id: string) => {
if (!confirm("Sei sicuro di voler eliminare questo progetto? Questa azione è irreversibile e cancellerà anche lo storico dei pagamenti associati.")) return;
try {
await CondoService.deleteExpense(id);
loadData();
} catch (e) { alert("Errore eliminazione progetto"); }
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
@@ -161,8 +189,8 @@ export const ExtraordinaryAdmin: React.FC = () => {
startDate: formStart, startDate: formStart,
endDate: formEnd, endDate: formEnd,
contractorName: formContractor, contractorName: formContractor,
items: formItems items: formItems,
// Attachments and shares handled by backend logic to keep safe shares: formShares // Now we send shares to sync
}); });
} else { } else {
await CondoService.createExpense({ await CondoService.createExpense({
@@ -217,17 +245,26 @@ export const ExtraordinaryAdmin: React.FC = () => {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{expenses.map(exp => ( {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 relative group"> <div key={exp.id} className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-all relative group">
<button <div className="absolute top-4 right-4 flex gap-2">
onClick={(e) => { e.stopPropagation(); openEditModal(exp); }} <button
className="absolute top-4 right-4 p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-colors z-10" onClick={(e) => { e.stopPropagation(); openEditModal(exp); }}
title="Modifica" className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-colors"
> title="Modifica"
<Pencil className="w-4 h-4"/> >
</button> <Pencil className="w-4 h-4"/>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteExpense(exp.id); }}
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors"
title="Elimina"
>
<Trash2 className="w-4 h-4"/>
</button>
</div>
<div className="flex justify-between items-start mb-3"> <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="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 pr-8"> {exp.totalAmount.toLocaleString()}</span> <span className="font-bold text-slate-800 pr-20"> {exp.totalAmount.toLocaleString()}</span>
</div> </div>
<h3 className="font-bold text-lg text-slate-800 mb-1">{exp.title}</h3> <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> <p className="text-sm text-slate-500 mb-4 line-clamp-2">{exp.description}</p>
@@ -292,43 +329,41 @@ export const ExtraordinaryAdmin: React.FC = () => {
</button> </button>
</div> </div>
{/* Distribution - HIDDEN IN EDIT MODE TO AVOID COMPLEXITY */} {/* Distribution - Visible in BOTH Edit and Create */}
{!isEditing && ( <div>
<div> <h4 className="font-bold text-slate-700 mb-2">Ripartizione Famiglie</h4>
<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">
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y"> {families.map(fam => {
{families.map(fam => { const share = formShares.find(s => s.familyId === fam.id);
const share = formShares.find(s => s.familyId === fam.id); return (
return ( <div key={fam.id} className="flex items-center justify-between p-3 hover:bg-slate-50">
<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">
<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"/>
<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>
<span className="text-sm font-medium text-slate-700">{fam.name} <span className="text-slate-400 font-normal">({fam.unitNumber})</span></span> </label>
</label> {share && (
{share && ( <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div className="relative">
<div className="relative"> <input
<input type="number"
type="number" className="w-16 border rounded p-1 text-right text-sm"
className="w-16 border rounded p-1 text-right text-sm" value={share.percentage}
value={share.percentage} onChange={e => handleShareChange(formShares.indexOf(share), 'percentage', parseFloat(e.target.value))}
onChange={e => handleShareChange(formShares.indexOf(share), 'percentage', parseFloat(e.target.value))} />
/> <span className="absolute right-6 top-1 text-xs text-slate-400">%</span>
<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 className="w-24 text-right text-sm font-bold text-slate-700"> {share.amountDue.toFixed(2)}</div>
</div> </div>
); )}
})} </div>
</div> );
})}
</div> </div>
)} </div>
{isEditing && ( {isEditing && (
<div className="bg-amber-50 p-3 rounded text-amber-800 text-sm border border-amber-200 flex items-start gap-2"> <div className="bg-amber-50 p-3 rounded text-amber-800 text-sm border border-amber-200 flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 flex-shrink-0"/> <CheckCircle2 className="w-5 h-5 flex-shrink-0"/>
<p>In modifica le quote delle famiglie vengono ricalcolate automaticamente in proporzione al nuovo totale. I pagamenti già effettuati restano salvati.</p> <p>Nota: Modificando le quote, lo stato dei pagamenti verrà aggiornato in base agli importi già versati dalle famiglie.</p>
</div> </div>
)} )}
</form> </form>

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

@@ -867,7 +867,7 @@ app.post('/api/expenses', authenticateToken, requireAdmin, async (req, res) => {
// Update Expense // Update Expense
app.put('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) => { app.put('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) => {
const { title, description, startDate, endDate, contractorName, items } = req.body; const { title, description, startDate, endDate, contractorName, items, shares } = req.body;
const expenseId = req.params.id; const expenseId = req.params.id;
const connection = await pool.getConnection(); const connection = await pool.getConnection();
@@ -892,28 +892,59 @@ app.put('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) =
); );
} }
// 4. Update Shares (Recalculate Due Amount based on stored percentage vs new Total) // 4. Update Shares (Complex Logic: Sync requested shares with DB)
// We do NOT reset paid amount. We check if new due is covered by paid. // If shares are provided in the update:
if (shares && shares.length > 0) {
// Get current DB shares to delete removed ones
const [currentDbShares] = await connection.query('SELECT family_id FROM expense_shares WHERE expense_id = ?', [expenseId]);
const currentFamilyIds = currentDbShares.map(s => s.family_id);
const newFamilyIds = shares.map(s => s.familyId);
// This query updates amount_due based on percentage and new total. // A. Delete shares for families removed from list
// Then updates status: const toDelete = currentFamilyIds.filter(fid => !newFamilyIds.includes(fid));
// - If paid >= due -> PAID if (toDelete.length > 0) {
// - If paid > 0 but < due -> PARTIAL // Construct placeholder string (?,?,?)
// - Else -> UNPAID const placeholders = toDelete.map(() => '?').join(',');
await connection.query(`DELETE FROM expense_shares WHERE expense_id = ? AND family_id IN (${placeholders})`, [expenseId, ...toDelete]);
}
const updateSharesQuery = ` // B. Upsert (Update existing or Insert new)
UPDATE expense_shares // We use ON DUPLICATE KEY UPDATE logic manually or loop since we need to respect 'amount_paid'
SET for (const share of shares) {
amount_due = (percentage * ? / 100), // Check if exists
status = CASE const [existing] = await connection.query('SELECT amount_paid FROM expense_shares WHERE expense_id = ? AND family_id = ?', [expenseId, share.familyId]);
WHEN amount_paid >= (percentage * ? / 100) - 0.01 THEN 'PAID'
WHEN amount_paid > 0 THEN 'PARTIAL'
ELSE 'UNPAID'
END
WHERE expense_id = ?
`;
await connection.query(updateSharesQuery, [newTotalAmount, newTotalAmount, expenseId]); if (existing.length > 0) {
// Update
const currentPaid = parseFloat(existing[0].amount_paid);
let newStatus = 'UNPAID';
if (currentPaid >= share.amountDue - 0.01) newStatus = 'PAID';
else if (currentPaid > 0) newStatus = 'PARTIAL';
await connection.query(
'UPDATE expense_shares SET percentage = ?, amount_due = ?, status = ? WHERE expense_id = ? AND family_id = ?',
[share.percentage, share.amountDue, newStatus, expenseId, share.familyId]
);
} else {
// Insert
await connection.query(
'INSERT INTO expense_shares (id, expense_id, family_id, percentage, amount_due, amount_paid, status) VALUES (?, ?, ?, ?, ?, 0, ?)',
[uuidv4(), expenseId, share.familyId, share.percentage, share.amountDue, 'UNPAID']
);
}
}
} else {
// If no shares provided (empty list), maybe we should clear all?
// Or maybe it means "don't touch shares".
// Based on frontend logic, empty list means "remove all assignments".
// But usually we don't send empty list if we just edited header.
// Assuming the frontend sends the full current state of shares.
// If explicit empty array is sent, we delete all.
// If undefined/null, we do nothing (backward compatibility).
if (Array.isArray(shares)) {
await connection.query('DELETE FROM expense_shares WHERE expense_id = ?', [expenseId]);
}
}
await connection.commit(); await connection.commit();
res.json({ success: true }); res.json({ success: true });
@@ -927,6 +958,15 @@ app.put('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) =
} }
}); });
// Delete Expense
app.delete('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM extraordinary_expenses WHERE id = ?', [req.params.id]);
// Foreign keys set to ON DELETE CASCADE should handle children (items, shares, attachments)
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/expenses/:id/attachments/:attachmentId', authenticateToken, async (req, res) => { app.get('/api/expenses/:id/attachments/:attachmentId', authenticateToken, async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM expense_attachments WHERE id = ? AND expense_id = ?', [req.params.attachmentId, req.params.id]); const [rows] = await pool.query('SELECT * FROM expense_attachments WHERE id = ? AND expense_id = ?', [req.params.attachmentId, req.params.id]);

View File

@@ -1,3 +1,4 @@
import { import {
Condo, Family, Payment, AppSettings, User, AuthResponse, Condo, Family, Payment, AppSettings, User, AuthResponse,
Ticket, TicketComment, ExtraordinaryExpense, Notice, Ticket, TicketComment, ExtraordinaryExpense, Notice,
@@ -327,6 +328,12 @@ export const CondoService = {
}); });
}, },
deleteExpense: async (id: string): Promise<void> => {
return request(`/expenses/${id}`, {
method: 'DELETE'
});
},
getExpenseAttachment: async (expenseId: string, attachmentId: string): Promise<any> => { getExpenseAttachment: async (expenseId: string, attachmentId: string): Promise<any> => {
return request(`/expenses/${expenseId}/attachments/${attachmentId}`); return request(`/expenses/${expenseId}/attachments/${attachmentId}`);
}, },