feat(budget): bidirectional sync between reservations and budget items

- Link budget items to reservations via reservation_id column
- Update budget entry when reservation price changes (not create duplicate)
- Delete budget entry when reservation price is cleared
- Sync price back to reservation when edited in budget panel
- Lock budget item name when linked to a reservation
- Add migration 73 for reservation_id on budget_items
This commit is contained in:
Maurice
2026-04-05 18:16:02 +02:00
parent cd5a6c7491
commit 38206883ff
8 changed files with 65 additions and 17 deletions

View File

@@ -822,6 +822,10 @@ function runMigrations(db: Database.Database): void {
() => {
db.exec('DROP TABLE IF EXISTS notification_preferences;');
},
// Migration 73: Add reservation_id to budget_items for linking budget entries to reservations
() => {
try { db.exec('ALTER TABLE budget_items ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
];
if (currentVersion < migrations.length) {

View File

@@ -3,6 +3,7 @@ import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import { db } from '../db/database';
import {
verifyTripAccess,
listBudgetItems,
@@ -68,6 +69,22 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const updated = updateBudgetItem(id, tripId, req.body);
if (!updated) return res.status(404).json({ error: 'Budget item not found' });
// Sync price back to linked reservation
if (updated.reservation_id && req.body.total_price !== undefined) {
try {
const reservation = db.prepare('SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?').get(updated.reservation_id, tripId) as { id: number; metadata: string | null } | undefined;
if (reservation) {
const meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
meta.price = String(updated.total_price);
db.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), reservation.id);
const updatedRes = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservation.id);
broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, req.headers['x-socket-id'] as string);
}
} catch (err) {
console.error('[budget] Failed to sync price to reservation:', err);
}
}
res.json({ item: updated });
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id'] as string);
});

View File

@@ -59,6 +59,8 @@ router.post('/', authenticate, (req: Request, res: Response) => {
category: create_budget_entry.category || type || 'Other',
total_price: create_budget_entry.total_price,
});
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservation.id, budgetItem.id);
budgetItem.reservation_id = reservation.id;
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
} catch (err) {
console.error('[reservations] Failed to create budget entry:', err);
@@ -119,18 +121,41 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
broadcast(tripId, 'accommodation:updated', {}, req.headers['x-socket-id'] as string);
}
// Auto-create budget entry if price was provided
// Remove linked budget entry if price was cleared
if (!create_budget_entry || !create_budget_entry.total_price) {
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (linked) {
const { deleteBudgetItem } = require('../services/budgetService');
deleteBudgetItem(linked.id, tripId);
broadcast(tripId, 'budget:deleted', { id: linked.id }, req.headers['x-socket-id'] as string);
}
}
// Auto-create or update budget entry if price was provided
if (create_budget_entry && create_budget_entry.total_price > 0) {
try {
const { createBudgetItem } = require('../services/budgetService');
const budgetItem = createBudgetItem(tripId, {
name: title || current.title,
category: create_budget_entry.category || type || current.type || 'Other',
total_price: create_budget_entry.total_price,
});
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
const { createBudgetItem, updateBudgetItem } = require('../services/budgetService');
const itemName = title || current.title;
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (existing) {
const updated = updateBudgetItem(existing.id, tripId, {
name: itemName,
category: create_budget_entry.category || type || current.type || 'Other',
total_price: create_budget_entry.total_price,
});
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id'] as string);
} else {
const budgetItem = createBudgetItem(tripId, {
name: itemName,
category: create_budget_entry.category || type || current.type || 'Other',
total_price: create_budget_entry.total_price,
});
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, budgetItem.id);
budgetItem.reservation_id = Number(id);
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
}
} catch (err) {
console.error('[reservations] Failed to create budget entry:', err);
console.error('[reservations] Failed to create/update budget entry:', err);
}
}