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:
@@ -633,7 +633,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={td}>
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
|
||||
{/* Mobile: larger chips under name since Persons column is hidden */}
|
||||
{hasMultipleMembers && (
|
||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||
|
||||
@@ -207,13 +207,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
}
|
||||
// Auto-create budget entry if price is set
|
||||
if (form.price && parseFloat(form.price) > 0) {
|
||||
saveData.create_budget_entry = {
|
||||
total_price: parseFloat(form.price),
|
||||
category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other',
|
||||
}
|
||||
}
|
||||
// Auto-create/update budget entry if price is set, or signal removal if cleared
|
||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
// If hotel with place + days, pass hotel data for auto-creation or update
|
||||
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
|
||||
saveData.create_accommodation = {
|
||||
|
||||
@@ -1005,6 +1005,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.totalBudget': 'Gesamtbudget',
|
||||
'budget.byCategory': 'Nach Kategorie',
|
||||
'budget.editTooltip': 'Klicken zum Bearbeiten',
|
||||
'budget.linkedToReservation': 'Verknüpft mit einer Buchung — Name dort bearbeiten',
|
||||
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
|
||||
'budget.deleteCategory': 'Kategorie löschen',
|
||||
'budget.perPerson': 'Pro Person',
|
||||
|
||||
@@ -1024,6 +1024,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.totalBudget': 'Total Budget',
|
||||
'budget.byCategory': 'By Category',
|
||||
'budget.editTooltip': 'Click to edit',
|
||||
'budget.linkedToReservation': 'Linked to a reservation — edit the name there',
|
||||
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
|
||||
'budget.deleteCategory': 'Delete Category',
|
||||
'budget.perPerson': 'Per Person',
|
||||
|
||||
@@ -42,6 +42,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
if (result.item.reservation_id && data.total_price !== undefined) {
|
||||
get().loadReservations(tripId)
|
||||
}
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating budget item'))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user