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:
211
server/server.js
211
server/server.js
@@ -235,7 +235,7 @@ app.post('/api/condos', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await pool.query(
|
||||
'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 });
|
||||
} 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 }); }
|
||||
});
|
||||
|
||||
// --- 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(() => {
|
||||
app.listen(PORT, () => {
|
||||
|
||||
Reference in New Issue
Block a user