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); } }