Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export const budgetApi = {
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
refreshRates: (tripId: number | string) => apiClient.post(`/trips/${tripId}/budget/refresh-rates`).then(r => r.data),
}

export const filesApi = {
Expand Down
131 changes: 100 additions & 31 deletions client/src/components/Budget/BudgetPanel.tsx

Large diffs are not rendered by default.

41 changes: 27 additions & 14 deletions client/src/components/Planner/ReservationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
import { CURRENCIES } from '../../utils/formatters'

const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
Expand Down Expand Up @@ -74,6 +75,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p

const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const tripCurrency = useTripStore(s => s.trip?.currency || 'EUR')
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
Expand All @@ -84,13 +86,12 @@ 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: '',
meta_check_in_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
})
price: '', budget_category: '', budget_currency: '',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mixed indentation in the form state

meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_departure_timezone: '', meta_arrival_timezone: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
meta_check_in_time: '', meta_check_out_time: '',
})
const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState([])
Expand Down Expand Up @@ -142,13 +143,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
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 && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
budget_currency: meta.budget_currency || '',
})
} 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: '',
price: '', budget_category: '', budget_currency: '',
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
meta_departure_timezone: '', meta_arrival_timezone: '',
meta_train_number: '', meta_platform: '', meta_seat: '',
Expand Down Expand Up @@ -201,6 +203,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
if (form.budget_currency) metadata.budget_currency = form.budget_currency
}
const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status,
Expand All @@ -214,7 +217,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
// Auto-create/update budget entry if price is set, or signal removal if cleared
if (isBudgetEnabled) {
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: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other', item_currency: form.budget_currency || undefined }
: { total_price: 0 }
}
// If hotel with place + days, pass hotel data for auto-creation or update
Expand Down Expand Up @@ -649,16 +652,26 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>

{/* Price + Budget Category — only shown when budget addon is enabled */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label>
<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 style={{ display: 'flex', gap: 4 }}>
<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, flex: 1, minWidth: 0 }} />
<div style={{ width: 76, flexShrink: 0 }}>
<CustomSelect
value={form.budget_currency || tripCurrency}
onChange={v => set('budget_currency', v === tripCurrency ? '' : v)}
options={CURRENCIES.map(c => ({ value: c, label: c }))}
searchable
size="sm"
/>
</div>
</div>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'التسوية',
'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
'budget.netBalances': 'الأرصدة الصافية',
'budget.refreshRates': 'تحديث أسعار الصرف',
'budget.noRate': 'لا يوجد سعر صرف',
'budget.itemCurrency': 'عملة العنصر',
'budget.baseCurrency': 'العملة الأساسية',
'budget.convertedAmount': 'المبلغ المحول',
'budget.approximateConversion': '≈ محول من {currency}',
'budget.ratesUpdated': 'تم تحديث الأسعار',
'budget.ratesFailed': 'تعذر جلب أسعار الصرف',

// Files
'files.title': 'الملفات',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/br.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'Acerto',
'budget.settlementInfo': 'Clique no avatar de um membro em um item do orçamento para marcá-lo em verde — significa que ele pagou. O acerto mostra quem deve quanto a quem.',
'budget.netBalances': 'Saldos líquidos',
'budget.refreshRates': 'Atualizar taxas de câmbio',
'budget.noRate': 'Taxa não disponível',
'budget.itemCurrency': 'Moeda do item',
'budget.baseCurrency': 'Moeda base',
'budget.convertedAmount': 'Valor convertido',
'budget.approximateConversion': '≈ convertido de {currency}',
'budget.ratesUpdated': 'Taxas atualizadas',
'budget.ratesFailed': 'Não foi possível obter taxas de câmbio',

// Files
'files.title': 'Arquivos',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,14 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'Vyúčtování',
'budget.settlementInfo': 'Klikněte na avatar člena u rozpočtové položky pro zelené označení – to znamená, že zaplatil. Vyúčtování pak ukazuje, kdo komu a kolik dluží.',
'budget.netBalances': 'Čisté zůstatky',
'budget.refreshRates': 'Obnovit směnné kurzy',
'budget.noRate': 'Kurz není k dispozici',
'budget.itemCurrency': 'Měna položky',
'budget.baseCurrency': 'Základní měna',
'budget.convertedAmount': 'Převedená částka',
'budget.approximateConversion': '≈ převedeno z {currency}',
'budget.ratesUpdated': 'Kurzy aktualizovány',
'budget.ratesFailed': 'Nepodařilo se načíst směnné kurzy',

// Soubory (Files)
'files.title': 'Soubory',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'Ausgleich',
'budget.settlementInfo': 'Klicke auf ein Mitglied-Bild bei einem Eintrag, um es grün zu markieren — das bedeutet, diese Person hat bezahlt. Der Ausgleich zeigt dann, wer wem wie viel schuldet.',
'budget.netBalances': 'Netto-Salden',
'budget.refreshRates': 'Wechselkurse aktualisieren',
'budget.noRate': 'Kein Kurs verfügbar',
'budget.itemCurrency': 'Artikelwährung',
'budget.baseCurrency': 'Basiswährung',
'budget.convertedAmount': 'Umgerechneter Betrag',
'budget.approximateConversion': '≈ umgerechnet von {currency}',
'budget.ratesUpdated': 'Kurse aktualisiert',
'budget.ratesFailed': 'Wechselkurse konnten nicht abgerufen werden',

// Files
'files.title': 'Dateien',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'Settlement',
'budget.settlementInfo': 'Click a member avatar on a budget item to mark them green — this means they paid. The settlement then shows who owes whom and how much.',
'budget.netBalances': 'Net Balances',
'budget.itemCurrency': 'Item Currency',
'budget.convertedAmount': 'Converted Amount',
'budget.baseCurrency': 'Base Currency',
'budget.refreshRates': 'Refresh Exchange Rates',
'budget.noRate': 'No rate available',
'budget.ratesUpdated': 'Rates updated',
'budget.ratesFailed': 'Could not fetch exchange rates',
'budget.approximateConversion': '≈ converted from {currency}',

// Files
'files.title': 'Files',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,14 @@ const es: Record<string, string> = {
'budget.settlement': 'Liquidación',
'budget.settlementInfo': 'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.',
'budget.netBalances': 'Saldos netos',
'budget.refreshRates': 'Actualizar tasas de cambio',
'budget.noRate': 'Tasa no disponible',
'budget.itemCurrency': 'Moneda del artículo',
'budget.baseCurrency': 'Moneda base',
'budget.convertedAmount': 'Monto convertido',
'budget.approximateConversion': '≈ convertido de {currency}',
'budget.ratesUpdated': 'Tasas actualizadas',
'budget.ratesFailed': 'No se pudieron obtener las tasas de cambio',

// Files
'files.title': 'Archivos',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,14 @@ const fr: Record<string, string> = {
'budget.settlement': 'Règlement',
'budget.settlementInfo': 'Cliquez sur l\'avatar d\'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu\'il a payé. Le règlement indique ensuite qui doit combien à qui.',
'budget.netBalances': 'Soldes nets',
'budget.refreshRates': 'Actualiser les taux de change',
'budget.noRate': 'Taux non disponible',
'budget.itemCurrency': 'Devise de l\'article',
'budget.baseCurrency': 'Devise de base',
'budget.convertedAmount': 'Montant converti',
'budget.approximateConversion': '≈ converti de {currency}',
'budget.ratesUpdated': 'Taux mis à jour',
'budget.ratesFailed': 'Impossible de récupérer les taux de change',

// Files
'files.title': 'Fichiers',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/hu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,14 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'Elszámolás',
'budget.settlementInfo': 'Kattints egy tag avatárjára egy költségvetési tételen a zöld jelöléshez — ez azt jelenti, hogy fizetett. Az elszámolás ezután mutatja, ki kinek mennyivel tartozik.',
'budget.netBalances': 'Nettó egyenlegek',
'budget.refreshRates': 'Árfolyamok frissítése',
'budget.noRate': 'Nincs elérhető árfolyam',
'budget.itemCurrency': 'Tétel pénzneme',
'budget.baseCurrency': 'Alap pénznem',
'budget.convertedAmount': 'Átváltott összeg',
'budget.approximateConversion': '≈ átváltva innen: {currency}',
'budget.ratesUpdated': 'Árfolyamok frissítve',
'budget.ratesFailed': 'Nem sikerült lekérni az árfolyamokat',

// Fájlok
'files.title': 'Fájlok',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,14 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'Regolamento',
'budget.settlementInfo': 'Clicca sull\'avatar di un membro su una voce di budget per contrassegnarlo in verde — significa che ha pagato. Il regolamento mostra poi chi deve quanto a chi.',
'budget.netBalances': 'Saldi netti',
'budget.refreshRates': 'Aggiorna tassi di cambio',
'budget.noRate': 'Tasso non disponibile',
'budget.itemCurrency': 'Valuta dell\'articolo',
'budget.baseCurrency': 'Valuta base',
'budget.convertedAmount': 'Importo convertito',
'budget.approximateConversion': '≈ convertito da {currency}',
'budget.ratesUpdated': 'Tassi aggiornati',
'budget.ratesFailed': 'Impossibile recuperare i tassi di cambio',

// Files
'files.title': 'File',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,14 @@ const nl: Record<string, string> = {
'budget.settlement': 'Afrekening',
'budget.settlementInfo': 'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.',
'budget.netBalances': 'Nettosaldi',
'budget.refreshRates': 'Wisselkoersen vernieuwen',
'budget.noRate': 'Geen wisselkoers beschikbaar',
'budget.itemCurrency': 'Artikelvaluta',
'budget.baseCurrency': 'Basisvaluta',
'budget.convertedAmount': 'Omgerekend bedrag',
'budget.approximateConversion': '≈ omgerekend van {currency}',
'budget.ratesUpdated': 'Koersen bijgewerkt',
'budget.ratesFailed': 'Kon wisselkoersen niet ophalen',

// Files
'files.title': 'Bestanden',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,14 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'Rozliczenie',
'budget.settlementInfo': 'Kliknij avatar członka przy pozycji w budżecie, aby oznaczyć go na zielono — oznacza to, że zapłacił. Rozliczenie pokaże, kto komu i ile jest winien.',
'budget.netBalances': 'Bilans',
'budget.refreshRates': 'Odśwież kursy walut',
'budget.noRate': 'Brak dostępnego kursu',
'budget.itemCurrency': 'Waluta pozycji',
'budget.baseCurrency': 'Waluta bazowa',
'budget.convertedAmount': 'Kwota przeliczona',
'budget.approximateConversion': '≈ przeliczone z {currency}',
'budget.ratesUpdated': 'Kursy zaktualizowane',
'budget.ratesFailed': 'Nie udało się pobrać kursów walut',

// Files
'files.title': 'Pliki',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,14 @@ const ru: Record<string, string> = {
'budget.settlement': 'Взаиморасчёт',
'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
'budget.netBalances': 'Чистые балансы',
'budget.refreshRates': 'Обновить курсы валют',
'budget.noRate': 'Курс недоступен',
'budget.itemCurrency': 'Валюта позиции',
'budget.baseCurrency': 'Базовая валюта',
'budget.convertedAmount': 'Конвертированная сумма',
'budget.approximateConversion': '≈ конвертировано из {currency}',
'budget.ratesUpdated': 'Курсы обновлены',
'budget.ratesFailed': 'Не удалось получить курсы валют',

// Files
'files.title': 'Файлы',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,14 @@ const zh: Record<string, string> = {
'budget.settlement': '结算',
'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
'budget.netBalances': '净余额',
'budget.refreshRates': '刷新汇率',
'budget.noRate': '无可用汇率',
'budget.itemCurrency': '项目货币',
'budget.baseCurrency': '基础货币',
'budget.convertedAmount': '换算金额',
'budget.approximateConversion': '≈ 从 {currency} 换算',
'budget.ratesUpdated': '汇率已更新',
'budget.ratesFailed': '无法获取汇率',

// Files
'files.title': '文件',
Expand Down
8 changes: 8 additions & 0 deletions client/src/i18n/translations/zhTw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,14 @@ const zhTw: Record<string, string> = {
'budget.settlement': '結算',
'budget.settlementInfo': '點選預算專案上的成員頭像將其標記為綠色——表示該成員已付款。結算會顯示誰欠誰多少。',
'budget.netBalances': '淨餘額',
'budget.refreshRates': '重新整理匯率',
'budget.noRate': '無可用匯率',
'budget.itemCurrency': '項目貨幣',
'budget.baseCurrency': '基礎貨幣',
'budget.convertedAmount': '換算金額',
'budget.approximateConversion': '≈ 從 {currency} 換算',
'budget.ratesUpdated': '匯率已更新',
'budget.ratesFailed': '無法取得匯率',

// Files
'files.title': '檔案',
Expand Down
14 changes: 13 additions & 1 deletion client/src/store/slices/budgetSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface BudgetSlice {
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetMember[]; item: BudgetItem }>
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
refreshBudgetRates: (tripId: number | string) => Promise<void>
}

export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({
Expand Down Expand Up @@ -42,7 +43,7 @@ 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) {
if (result.item.reservation_id && (data.total_price !== undefined || data.item_currency !== undefined)) {
get().loadReservations(tripId)
}
return result.item
Expand Down Expand Up @@ -82,4 +83,15 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
)
}));
},

refreshBudgetRates: async (tripId) => {
try {
const data = await budgetApi.refreshRates(tripId)
if (data.items) {
set({ budgetItems: data.items })
}
} catch (err: unknown) {
console.error('Failed to refresh budget rates:', err)
}
},
})
Loading