diff --git a/.dockerignore b/.dockerignore index 21edd64..276bbec 100644 Binary files a/.dockerignore and b/.dockerignore differ diff --git a/Dockerfile b/Dockerfile index bb87fa8..e69de29 100644 --- a/Dockerfile +++ b/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;"] diff --git a/nginx.conf b/nginx.conf index f8625d9..e69de29 100644 --- a/nginx.conf +++ b/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; - } - } -} diff --git a/pages/ExtraordinaryAdmin.tsx b/pages/ExtraordinaryAdmin.tsx index fa306ff..13aff04 100644 --- a/pages/ExtraordinaryAdmin.tsx +++ b/pages/ExtraordinaryAdmin.tsx @@ -47,7 +47,7 @@ export const ExtraordinaryAdmin: React.FC = () => { const totalAmount = formItems.reduce((acc, item) => acc + (item.amount || 0), 0); 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; if (count === 0) { @@ -56,13 +56,27 @@ export const ExtraordinaryAdmin: React.FC = () => { } 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' - })); + const newShares: ExpenseShare[] = selectedIds.map(fid => { + // Preserve existing data if available + const existing = formShares.find(s => s.familyId === fid); + if (existing) { + // If editing and we just toggled someone else, re-calc percentages evenly? + // 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 if (newShares.length > 0) { @@ -99,9 +113,11 @@ export const ExtraordinaryAdmin: React.FC = () => { 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(() => { - if (!isEditing) { + if (selectedFamilyIds.length > 0) { recalculateShares(selectedFamilyIds); } }, [totalAmount]); // eslint-disable-line react-hooks/exhaustive-deps @@ -131,7 +147,7 @@ export const ExtraordinaryAdmin: React.FC = () => { }; const openEditModal = async (exp: ExtraordinaryExpense) => { - // Fetch full details first to get items + // Fetch full details first to get items and shares try { const detail = await CondoService.getExpenseDetails(exp.id); setIsEditing(true); @@ -142,15 +158,27 @@ export const ExtraordinaryAdmin: React.FC = () => { setFormEnd(detail.endDate ? new Date(detail.endDate).toISOString().split('T')[0] : ''); setFormContractor(detail.contractorName); 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. - setFormShares([]); + + // Populate shares for editing + const currentShares = detail.shares || []; + setFormShares(currentShares); + setSelectedFamilyIds(currentShares.map(s => s.familyId)); + + // Attachments (Cannot edit attachments in this simple view for now, cleared) setFormAttachments([]); - setSelectedFamilyIds([]); + setShowModal(true); } 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) => { e.preventDefault(); try { @@ -161,8 +189,8 @@ export const ExtraordinaryAdmin: React.FC = () => { startDate: formStart, endDate: formEnd, contractorName: formContractor, - items: formItems - // Attachments and shares handled by backend logic to keep safe + items: formItems, + shares: formShares // Now we send shares to sync }); } else { await CondoService.createExpense({ @@ -217,17 +245,26 @@ export const ExtraordinaryAdmin: React.FC = () => {
{expenses.map(exp => (
- +
+ + +
Lavori - € {exp.totalAmount.toLocaleString()} + € {exp.totalAmount.toLocaleString()}

{exp.title}

{exp.description}

@@ -292,43 +329,41 @@ export const ExtraordinaryAdmin: React.FC = () => {
- {/* Distribution - HIDDEN IN EDIT MODE TO AVOID COMPLEXITY */} - {!isEditing && ( -
-

Ripartizione Famiglie

-
- {families.map(fam => { - const share = formShares.find(s => s.familyId === fam.id); - return ( -
- - {share && ( -
-
- handleShareChange(formShares.indexOf(share), 'percentage', parseFloat(e.target.value))} - /> - % -
-
€ {share.amountDue.toFixed(2)}
+ {/* Distribution - Visible in BOTH Edit and Create */} +
+

Ripartizione Famiglie

+
+ {families.map(fam => { + const share = formShares.find(s => s.familyId === fam.id); + return ( +
+ + {share && ( +
+
+ handleShareChange(formShares.indexOf(share), 'percentage', parseFloat(e.target.value))} + /> + %
- )} -
- ); - })} -
+
€ {share.amountDue.toFixed(2)}
+
+ )} +
+ ); + })}
- )} +
{isEditing && (
-

In modifica le quote delle famiglie vengono ricalcolate automaticamente in proporzione al nuovo totale. I pagamenti già effettuati restano salvati.

+

Nota: Modificando le quote, lo stato dei pagamenti verrà aggiornato in base agli importi già versati dalle famiglie.

)} diff --git a/server/Dockerfile b/server/Dockerfile index 9a6d3cb..e69de29 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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"] diff --git a/server/server.js b/server/server.js index 04dc37c..205d9c8 100644 --- a/server/server.js +++ b/server/server.js @@ -867,7 +867,7 @@ app.post('/api/expenses', authenticateToken, requireAdmin, async (req, res) => { // Update Expense 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 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) - // We do NOT reset paid amount. We check if new due is covered by paid. - - // This query updates amount_due based on percentage and new total. - // Then updates status: - // - If paid >= due -> PAID - // - If paid > 0 but < due -> PARTIAL - // - Else -> UNPAID - - const updateSharesQuery = ` - UPDATE expense_shares - SET - amount_due = (percentage * ? / 100), - status = CASE - 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]); + // 4. Update Shares (Complex Logic: Sync requested shares with DB) + // 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); + + // A. Delete shares for families removed from list + const toDelete = currentFamilyIds.filter(fid => !newFamilyIds.includes(fid)); + if (toDelete.length > 0) { + // Construct placeholder string (?,?,?) + const placeholders = toDelete.map(() => '?').join(','); + await connection.query(`DELETE FROM expense_shares WHERE expense_id = ? AND family_id IN (${placeholders})`, [expenseId, ...toDelete]); + } + + // B. Upsert (Update existing or Insert new) + // We use ON DUPLICATE KEY UPDATE logic manually or loop since we need to respect 'amount_paid' + for (const share of shares) { + // Check if exists + const [existing] = await connection.query('SELECT amount_paid FROM expense_shares WHERE expense_id = ? AND family_id = ?', [expenseId, share.familyId]); + + 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(); 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) => { try { const [rows] = await pool.query('SELECT * FROM expense_attachments WHERE id = ? AND expense_id = ?', [req.params.attachmentId, req.params.id]); diff --git a/services/mockDb.ts b/services/mockDb.ts index de0f3f5..8d72fe8 100644 --- a/services/mockDb.ts +++ b/services/mockDb.ts @@ -1,3 +1,4 @@ + import { Condo, Family, Payment, AppSettings, User, AuthResponse, Ticket, TicketComment, ExtraordinaryExpense, Notice, @@ -327,6 +328,12 @@ export const CondoService = { }); }, + deleteExpense: async (id: string): Promise => { + return request(`/expenses/${id}`, { + method: 'DELETE' + }); + }, + getExpenseAttachment: async (expenseId: string, attachmentId: string): Promise => { return request(`/expenses/${expenseId}/attachments/${attachmentId}`); }, @@ -342,4 +349,4 @@ export const CondoService = { body: JSON.stringify({ amount, notes: 'PayPal Payment' }) }); } -}; \ No newline at end of file +};