diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx
index a0cbd3d..e1f117b 100644
--- a/client/src/components/Budget/BudgetPanel.tsx
+++ b/client/src/components/Budget/BudgetPanel.tsx
@@ -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'}>
- handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
+ 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 && (
diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx
index c00780b..bc8c34f 100644
--- a/client/src/components/Planner/ReservationModal.tsx
+++ b/client/src/components/Planner/ReservationModal.tsx
@@ -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 = {
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index d5fad2d..e2bbf30 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -1005,6 +1005,7 @@ const de: Record = {
'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',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index a2c5a4d..e62d70a 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -1024,6 +1024,7 @@ const en: Record = {
'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',
diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts
index 991949f..21b107e 100644
--- a/client/src/store/slices/budgetSlice.ts
+++ b/client/src/store/slices/budgetSlice.ts
@@ -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'))
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index fdbf25b..3336626 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -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) {
diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts
index 57da643..0576386 100644
--- a/server/src/routes/budget.ts
+++ b/server/src/routes/budget.ts
@@ -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);
});
diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts
index 59be95f..05b7503 100644
--- a/server/src/routes/reservations.ts
+++ b/server/src/routes/reservations.ts
@@ -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);
}
}
|