fix(reservations): budget category dropdown, localized auto-category, price input cleanup

- Budget category uses dropdown with existing categories instead of freetext
- Auto category uses translated booking type names (e.g. "Volo" in Italian)
- Remove number input spinner arrows, use decimal inputMode
- Add budget entry creation to PUT handler (update), not just POST (create)
- Error logging for failed budget entry creation
- i18n keys for all 13 languages
This commit is contained in:
mauriceboe
2026-04-05 00:13:07 +02:00
parent aa244dd548
commit b8058a2755
15 changed files with 96 additions and 7 deletions

View File

@@ -71,6 +71,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const [form, setForm] = useState({ const [form, setForm] = useState({
title: '', type: 'other', status: 'pending', title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
@@ -204,7 +211,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.price && parseFloat(form.price) > 0) { if (form.price && parseFloat(form.price) > 0) {
saveData.create_budget_entry = { saveData.create_budget_entry = {
total_price: parseFloat(form.price), total_price: parseFloat(form.price),
category: form.budget_category || form.type || 'Other', category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other',
} }
} }
// If hotel with place + days, pass hotel data for auto-creation or update // If hotel with place + days, pass hotel data for auto-creation or update
@@ -643,15 +650,23 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label> <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)} <input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
placeholder="0.00" placeholder="0.00"
style={inputStyle} /> style={inputStyle} />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label> <label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<input type="text" value={form.budget_category} onChange={e => set('budget_category', e.target.value)} <CustomSelect
placeholder={t('reservations.budgetCategoryPlaceholder')} value={form.budget_category}
style={inputStyle} /> onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div> </div>
</div> </div>
{form.price && parseFloat(form.price) > 0 && ( {form.price && parseFloat(form.price) > 0 && (

View File

@@ -938,6 +938,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'ربط بخطة اليوم', 'reservations.linkAssignment': 'ربط بخطة اليوم',
'reservations.pickAssignment': 'اختر عنصرًا من خطتك...', 'reservations.pickAssignment': 'اختر عنصرًا من خطتك...',
'reservations.noAssignment': 'بلا ربط', 'reservations.noAssignment': 'بلا ربط',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'المغادرة', 'reservations.departureDate': 'المغادرة',
'reservations.arrivalDate': 'الوصول', 'reservations.arrivalDate': 'الوصول',
'reservations.departureTime': 'وقت المغادرة', 'reservations.departureTime': 'وقت المغادرة',

View File

@@ -919,6 +919,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Vincular à atribuição do dia', 'reservations.linkAssignment': 'Vincular à atribuição do dia',
'reservations.pickAssignment': 'Selecione uma atribuição do seu plano...', 'reservations.pickAssignment': 'Selecione uma atribuição do seu plano...',
'reservations.noAssignment': 'Sem vínculo (avulsa)', 'reservations.noAssignment': 'Sem vínculo (avulsa)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Partida', 'reservations.departureDate': 'Partida',
'reservations.arrivalDate': 'Chegada', 'reservations.arrivalDate': 'Chegada',
'reservations.departureTime': 'Hora partida', 'reservations.departureTime': 'Hora partida',

View File

@@ -936,6 +936,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Propojit s přiřazením dne', 'reservations.linkAssignment': 'Propojit s přiřazením dne',
'reservations.pickAssignment': 'Vyberte přiřazení z vašeho plánu...', 'reservations.pickAssignment': 'Vyberte přiřazení z vašeho plánu...',
'reservations.noAssignment': 'Bez propojení (samostatné)', 'reservations.noAssignment': 'Bez propojení (samostatné)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Odlet', 'reservations.departureDate': 'Odlet',
'reservations.arrivalDate': 'Přílet', 'reservations.arrivalDate': 'Přílet',
'reservations.departureTime': 'Čas odletu', 'reservations.departureTime': 'Čas odletu',

View File

@@ -938,6 +938,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'reservations.price': 'Preis', 'reservations.price': 'Preis',
'reservations.budgetCategory': 'Budgetkategorie', 'reservations.budgetCategory': 'Budgetkategorie',
'reservations.budgetCategoryPlaceholder': 'z.B. Transport, Unterkunft', 'reservations.budgetCategoryPlaceholder': 'z.B. Transport, Unterkunft',
'reservations.budgetCategoryAuto': 'Auto (aus Buchungstyp)',
'reservations.budgetHint': 'Beim Speichern wird automatisch ein Budgeteintrag erstellt.', 'reservations.budgetHint': 'Beim Speichern wird automatisch ein Budgeteintrag erstellt.',
'reservations.departureDate': 'Abflug', 'reservations.departureDate': 'Abflug',
'reservations.arrivalDate': 'Ankunft', 'reservations.arrivalDate': 'Ankunft',

View File

@@ -935,6 +935,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'reservations.price': 'Price', 'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category', 'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation', 'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.', 'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Departure', 'reservations.departureDate': 'Departure',
'reservations.arrivalDate': 'Arrival', 'reservations.arrivalDate': 'Arrival',

View File

@@ -895,6 +895,11 @@ const es: Record<string, string> = {
'reservations.linkAssignment': 'Vincular a una asignación del día', 'reservations.linkAssignment': 'Vincular a una asignación del día',
'reservations.pickAssignment': 'Selecciona una asignación de tu plan...', 'reservations.pickAssignment': 'Selecciona una asignación de tu plan...',
'reservations.noAssignment': 'Sin vínculo (independiente)', 'reservations.noAssignment': 'Sin vínculo (independiente)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Salida', 'reservations.departureDate': 'Salida',
'reservations.arrivalDate': 'Llegada', 'reservations.arrivalDate': 'Llegada',
'reservations.departureTime': 'Hora salida', 'reservations.departureTime': 'Hora salida',

View File

@@ -934,6 +934,11 @@ const fr: Record<string, string> = {
'reservations.linkAssignment': 'Lier à l\'affectation du jour', 'reservations.linkAssignment': 'Lier à l\'affectation du jour',
'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan…', 'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan…',
'reservations.noAssignment': 'Aucun lien (autonome)', 'reservations.noAssignment': 'Aucun lien (autonome)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Départ', 'reservations.departureDate': 'Départ',
'reservations.arrivalDate': 'Arrivée', 'reservations.arrivalDate': 'Arrivée',
'reservations.departureTime': 'Heure dép.', 'reservations.departureTime': 'Heure dép.',

View File

@@ -935,6 +935,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Összekapcsolás napi tervvel', 'reservations.linkAssignment': 'Összekapcsolás napi tervvel',
'reservations.pickAssignment': 'Válassz hozzárendelést a tervedből...', 'reservations.pickAssignment': 'Válassz hozzárendelést a tervedből...',
'reservations.noAssignment': 'Nincs összekapcsolás (önálló)', 'reservations.noAssignment': 'Nincs összekapcsolás (önálló)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Indulás', 'reservations.departureDate': 'Indulás',
'reservations.arrivalDate': 'Érkezés', 'reservations.arrivalDate': 'Érkezés',
'reservations.departureTime': 'Indulási idő', 'reservations.departureTime': 'Indulási idő',

View File

@@ -935,6 +935,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Collega all\'assegnazione del giorno', 'reservations.linkAssignment': 'Collega all\'assegnazione del giorno',
'reservations.pickAssignment': 'Seleziona un\'assegnazione dal tuo programma...', 'reservations.pickAssignment': 'Seleziona un\'assegnazione dal tuo programma...',
'reservations.noAssignment': 'Nessun collegamento (autonomo)', 'reservations.noAssignment': 'Nessun collegamento (autonomo)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Partenza', 'reservations.departureDate': 'Partenza',
'reservations.arrivalDate': 'Arrivo', 'reservations.arrivalDate': 'Arrivo',
'reservations.departureTime': 'Ora part.', 'reservations.departureTime': 'Ora part.',

View File

@@ -934,6 +934,11 @@ const nl: Record<string, string> = {
'reservations.linkAssignment': 'Koppelen aan dagtoewijzing', 'reservations.linkAssignment': 'Koppelen aan dagtoewijzing',
'reservations.pickAssignment': 'Selecteer een toewijzing uit je plan...', 'reservations.pickAssignment': 'Selecteer een toewijzing uit je plan...',
'reservations.noAssignment': 'Geen koppeling (zelfstandig)', 'reservations.noAssignment': 'Geen koppeling (zelfstandig)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Vertrek', 'reservations.departureDate': 'Vertrek',
'reservations.arrivalDate': 'Aankomst', 'reservations.arrivalDate': 'Aankomst',
'reservations.departureTime': 'Vertrektijd', 'reservations.departureTime': 'Vertrektijd',

View File

@@ -890,6 +890,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Przypisz do miejsca', 'reservations.linkAssignment': 'Przypisz do miejsca',
'reservations.pickAssignment': 'Wybierz miejsce z planu...', 'reservations.pickAssignment': 'Wybierz miejsce z planu...',
'reservations.noAssignment': 'Brak przypisania (samodzielna)', 'reservations.noAssignment': 'Brak przypisania (samodzielna)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Wylot', 'reservations.departureDate': 'Wylot',
'reservations.arrivalDate': 'Przylot', 'reservations.arrivalDate': 'Przylot',
'reservations.departureTime': 'Godz. wylotu', 'reservations.departureTime': 'Godz. wylotu',

View File

@@ -934,6 +934,11 @@ const ru: Record<string, string> = {
'reservations.linkAssignment': 'Привязать к назначению дня', 'reservations.linkAssignment': 'Привязать к назначению дня',
'reservations.pickAssignment': 'Выберите назначение из вашего плана...', 'reservations.pickAssignment': 'Выберите назначение из вашего плана...',
'reservations.noAssignment': 'Без привязки (самостоятельное)', 'reservations.noAssignment': 'Без привязки (самостоятельное)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': 'Вылет', 'reservations.departureDate': 'Вылет',
'reservations.arrivalDate': 'Прилёт', 'reservations.arrivalDate': 'Прилёт',
'reservations.departureTime': 'Время вылета', 'reservations.departureTime': 'Время вылета',

View File

@@ -934,6 +934,11 @@ const zh: Record<string, string> = {
'reservations.linkAssignment': '关联日程分配', 'reservations.linkAssignment': '关联日程分配',
'reservations.pickAssignment': '从计划中选择一个分配...', 'reservations.pickAssignment': '从计划中选择一个分配...',
'reservations.noAssignment': '无关联(独立)', 'reservations.noAssignment': '无关联(独立)',
'reservations.price': 'Price',
'reservations.budgetCategory': 'Budget category',
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
'reservations.departureDate': '出发', 'reservations.departureDate': '出发',
'reservations.arrivalDate': '到达', 'reservations.arrivalDate': '到达',
'reservations.departureTime': '出发时间', 'reservations.departureTime': '出发时间',

View File

@@ -60,7 +60,9 @@ router.post('/', authenticate, (req: Request, res: Response) => {
total_price: create_budget_entry.total_price, total_price: create_budget_entry.total_price,
}); });
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string); broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
} catch {} } catch (err) {
console.error('[reservations] Failed to create budget entry:', err);
}
} }
res.status(201).json({ reservation }); res.status(201).json({ reservation });
@@ -96,7 +98,7 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
router.put('/:id', authenticate, (req: Request, res: Response) => { router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId, id } = req.params; const { tripId, id } = 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); const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -117,6 +119,21 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
broadcast(tripId, 'accommodation:updated', {}, req.headers['x-socket-id'] as string); broadcast(tripId, 'accommodation:updated', {}, 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 || 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);
} catch (err) {
console.error('[reservations] Failed to create budget entry:', err);
}
}
res.json({ reservation }); res.json({ reservation });
broadcast(tripId, 'reservation:updated', { reservation }, req.headers['x-socket-id'] as string); broadcast(tripId, 'reservation:updated', { reservation }, req.headers['x-socket-id'] as string);