feat(reservations): add price field with automatic budget entry creation

- Optional price and budget category fields on the reservation form
- When a price is set, a budget entry is automatically created on save
- Price and category stored in reservation metadata for reference
- Hint text shown when price is entered
- i18n keys for EN and DE
This commit is contained in:
mauriceboe
2026-04-04 23:59:30 +02:00
parent 33d8953554
commit aa244dd548
4 changed files with 56 additions and 1 deletions

View File

@@ -75,6 +75,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', accommodation_id: '',
price: '', budget_category: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_departure_timezone: '', meta_arrival_timezone: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
@@ -130,12 +131,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
price: meta.price || '',
budget_category: meta.budget_category || '',
})
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '', accommodation_id: '',
price: '', budget_category: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_departure_timezone: '', meta_arrival_timezone: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
@@ -185,6 +189,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
}
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.reservation_time, reservation_end_time: combinedEndTime,
@@ -194,6 +200,13 @@ 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 || form.type || 'Other',
}
}
// 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 = {
@@ -626,6 +639,27 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Price + Budget Category */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="number" step="0.01" min="0" value={form.price} onChange={e => set('price', e.target.value)}
placeholder="0.00"
style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<input type="text" value={form.budget_category} onChange={e => set('budget_category', e.target.value)}
placeholder={t('reservations.budgetCategoryPlaceholder')}
style={inputStyle} />
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>

View File

@@ -935,6 +935,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
'reservations.noAssignment': 'Keine Verknüpfung',
'reservations.price': 'Preis',
'reservations.budgetCategory': 'Budgetkategorie',
'reservations.budgetCategoryPlaceholder': 'z.B. Transport, Unterkunft',
'reservations.budgetHint': 'Beim Speichern wird automatisch ein Budgeteintrag erstellt.',
'reservations.departureDate': 'Abflug',
'reservations.arrivalDate': 'Ankunft',
'reservations.departureTime': 'Abflugzeit',

View File

@@ -932,6 +932,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Link to day assignment',
'reservations.pickAssignment': 'Select an assignment from your plan...',
'reservations.noAssignment': 'No link (standalone)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Departure',
'reservations.arrivalDate': 'Arrival',
'reservations.departureTime': 'Dep. time',

View File

@@ -30,7 +30,7 @@ router.get('/', authenticate, (req: Request, res: Response) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation } = req.body;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -50,6 +50,19 @@ router.post('/', authenticate, (req: Request, res: Response) => {
broadcast(tripId, 'accommodation:created', {}, req.headers['x-socket-id'] as string);
}
// Auto-create 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,
category: create_budget_entry.category || type || 'Other',
total_price: create_budget_entry.total_price,
});
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
} catch {}
}
res.status(201).json({ reservation });
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);