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 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({
title: '', type: 'other', status: 'pending',
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) {
saveData.create_budget_entry = {
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
@@ -643,15 +650,23 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<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)}
<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"
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} />
<CustomSelect
value={form.budget_category}
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>
{form.price && parseFloat(form.price) > 0 && (

View File

@@ -938,6 +938,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'ربط بخطة اليوم',
'reservations.pickAssignment': 'اختر عنصرًا من خطتك...',
'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.arrivalDate': 'الوصول',
'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.pickAssignment': 'Selecione uma atribuição do seu plano...',
'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.arrivalDate': 'Chegada',
'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.pickAssignment': 'Vyberte přiřazení z vašeho plánu...',
'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.arrivalDate': 'Přílet',
'reservations.departureTime': 'Čas odletu',

View File

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

View File

@@ -935,6 +935,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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': 'Departure',
'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.pickAssignment': 'Selecciona una asignación de tu plan...',
'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.arrivalDate': 'Llegada',
'reservations.departureTime': 'Hora salida',

View File

@@ -934,6 +934,11 @@ const fr: Record<string, string> = {
'reservations.linkAssignment': 'Lier à l\'affectation du jour',
'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan…',
'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.arrivalDate': 'Arrivée',
'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.pickAssignment': 'Válassz hozzárendelést a tervedből...',
'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.arrivalDate': 'Érkezés',
'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.pickAssignment': 'Seleziona un\'assegnazione dal tuo programma...',
'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.arrivalDate': 'Arrivo',
'reservations.departureTime': 'Ora part.',

View File

@@ -934,6 +934,11 @@ const nl: Record<string, string> = {
'reservations.linkAssignment': 'Koppelen aan dagtoewijzing',
'reservations.pickAssignment': 'Selecteer een toewijzing uit je plan...',
'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.arrivalDate': 'Aankomst',
'reservations.departureTime': 'Vertrektijd',

View File

@@ -890,6 +890,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'reservations.linkAssignment': 'Przypisz do miejsca',
'reservations.pickAssignment': 'Wybierz miejsce z planu...',
'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.arrivalDate': 'Przylot',
'reservations.departureTime': 'Godz. wylotu',

View File

@@ -934,6 +934,11 @@ const ru: Record<string, string> = {
'reservations.linkAssignment': 'Привязать к назначению дня',
'reservations.pickAssignment': 'Выберите назначение из вашего плана...',
'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.arrivalDate': 'Прилёт',
'reservations.departureTime': 'Время вылета',

View File

@@ -934,6 +934,11 @@ const zh: Record<string, string> = {
'reservations.linkAssignment': '关联日程分配',
'reservations.pickAssignment': '从计划中选择一个分配...',
'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.arrivalDate': '到达',
'reservations.departureTime': '出发时间',

View File

@@ -60,7 +60,9 @@ router.post('/', authenticate, (req: Request, res: Response) => {
total_price: create_budget_entry.total_price,
});
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 });
@@ -96,7 +98,7 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
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);
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);
}
// 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 });
broadcast(tripId, 'reservation:updated', { reservation }, req.headers['x-socket-id'] as string);