Skip to content

Commit 38c2ac2

Browse files
committed
feat: add per-item currency support with server-side exchange rate conversion
1 parent 71403e6 commit 38c2ac2

File tree

25 files changed

+563
-37
lines changed

25 files changed

+563
-37
lines changed

client/src/api/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export const budgetApi = {
207207
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
208208
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
209209
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
210+
refreshRates: (tripId: number | string) => apiClient.post(`/trips/${tripId}/budget/refresh-rates`).then(r => r.data),
210211
}
211212

212213
export const filesApi = {

client/src/components/Budget/BudgetPanel.tsx

Lines changed: 93 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import DOM from 'react-dom'
44
import { useTripStore } from '../../store/tripStore'
55
import { useCanDo } from '../../store/permissionsStore'
66
import { useTranslation } from '../../i18n'
7-
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
7+
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, RefreshCw } from 'lucide-react'
88
import CustomSelect from '../shared/CustomSelect'
99
import { budgetApi } from '../../api/client'
1010
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -101,22 +101,27 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
101101

102102
// ── Add Item Row ─────────────────────────────────────────────────────────────
103103
interface AddItemRowProps {
104-
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
104+
onAdd: (data: { name: string; total_price: number; item_currency: string; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
105105
t: (key: string) => string
106+
baseCurrency: string
106107
}
107108

108-
function AddItemRow({ onAdd, t }: AddItemRowProps) {
109+
function AddItemRow({ onAdd, t, baseCurrency }: AddItemRowProps) {
109110
const [name, setName] = useState('')
110111
const [price, setPrice] = useState('')
112+
const [itemCur, setItemCur] = useState(baseCurrency)
111113
const [persons, setPersons] = useState('')
112114
const [days, setDays] = useState('')
113115
const [note, setNote] = useState('')
114116
const [expenseDate, setExpenseDate] = useState('')
115117
const nameRef = useRef(null)
116118

119+
// Keep the default currency in sync with the trip's base currency
120+
useEffect(() => { setItemCur(baseCurrency) }, [baseCurrency])
121+
117122
const handleAdd = () => {
118123
if (!name.trim()) return
119-
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
124+
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, item_currency: itemCur, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
120125
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
121126
setTimeout(() => nameRef.current?.focus(), 50)
122127
}
@@ -130,8 +135,20 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
130135
placeholder={t('budget.newEntry')} style={inp} />
131136
</td>
132137
<td style={{ padding: '4px 6px' }}>
133-
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
134-
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
138+
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
139+
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
140+
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center', flex: 1, minWidth: 50 }} />
141+
<div style={{ flexShrink: 0, width: 76 }}>
142+
<CustomSelect
143+
value={itemCur}
144+
onChange={setItemCur}
145+
options={CURRENCIES.map(c => ({ value: c, label: c }))}
146+
searchable
147+
size="sm"
148+
style={{ fontSize: 11 }}
149+
/>
150+
</div>
151+
</div>
135152
</td>
136153
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
137154
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
@@ -422,13 +439,14 @@ interface BudgetPanelProps {
422439
}
423440

424441
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
425-
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
442+
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, refreshBudgetRates } = useTripStore()
426443
const can = useCanDo()
427444
const { t, locale } = useTranslation()
428445
const [newCategoryName, setNewCategoryName] = useState('')
429446
const [editingCat, setEditingCat] = useState(null) // { name, value }
430447
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
431448
const [settlementOpen, setSettlementOpen] = useState(false)
449+
const [refreshingRates, setRefreshingRates] = useState(false)
432450
const currency = trip?.currency || 'EUR'
433451
const canEdit = can('budget_edit', trip)
434452

@@ -441,8 +459,20 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
441459
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
442460
}, [tripId, budgetItems, hasMultipleMembers])
443461

444-
const setCurrency = (cur) => {
445-
if (tripId) updateTrip(tripId, { currency: cur })
462+
const setCurrency = async (cur) => {
463+
if (!tripId) return
464+
setRefreshingRates(true)
465+
try {
466+
await updateTrip(tripId, { currency: cur })
467+
await loadBudgetItems(tripId)
468+
} finally {
469+
setRefreshingRates(false)
470+
}
471+
}
472+
473+
const handleRefreshRates = async () => {
474+
setRefreshingRates(true)
475+
try { await refreshBudgetRates(tripId) } finally { setRefreshingRates(false) }
446476
}
447477

448478
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
@@ -455,17 +485,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
455485
}, {}), [budgetItems])
456486

457487
const categoryNames = Object.keys(grouped)
458-
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
488+
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.converted_price ?? i.total_price ?? 0), 0)
489+
// Check if any item uses a different currency than the trip base
490+
const hasMultipleCurrencies = (budgetItems || []).some(i => i.item_currency && i.item_currency.toUpperCase() !== currency.toUpperCase())
459491

460492
const pieSegments = useMemo(() =>
461493
categoryNames.map((cat, i) => ({
462494
name: cat,
463-
value: grouped[cat].reduce((s, x) => s + (x.total_price || 0), 0),
495+
value: grouped[cat].reduce((s, x) => s + (x.converted_price ?? x.total_price ?? 0), 0),
464496
color: PIE_COLORS[i % PIE_COLORS.length],
465497
})).filter(s => s.value > 0)
466498
, [grouped, categoryNames])
467499

468-
const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} }
500+
const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }); } catch {} }
469501
const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
470502
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
471503
const handleDeleteCategory = async (cat) => {
@@ -564,7 +596,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
564596
<div style={{ flex: 1, minWidth: 0 }}>
565597
{categoryNames.map((cat, ci) => {
566598
const items = grouped[cat]
567-
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
599+
const subtotal = items.reduce((s, x) => s + (x.converted_price ?? x.total_price ?? 0), 0)
568600
const color = PIE_COLORS[ci % PIE_COLORS.length]
569601

570602
return (
@@ -624,9 +656,10 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
624656
</thead>
625657
<tbody>
626658
{items.map(item => {
627-
const pp = calcPP(item.total_price, item.persons)
628-
const pd = calcPD(item.total_price, item.days)
629-
const ppd = calcPPD(item.total_price, item.persons, item.days)
659+
const effectivePrice = item.converted_price ?? item.total_price ?? 0
660+
const pp = calcPP(effectivePrice, item.persons)
661+
const pd = calcPD(effectivePrice, item.days)
662+
const ppd = calcPPD(effectivePrice, item.persons, item.days)
630663
const hasMembers = item.members?.length > 0
631664
return (
632665
<tr key={item.id} style={{ transition: 'background 0.1s' }}
@@ -649,7 +682,32 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
649682
)}
650683
</td>
651684
<td style={{ ...td, textAlign: 'center' }}>
652-
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
685+
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
686+
<div style={{ flex: 1, minWidth: 0 }}>
687+
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(item.item_currency || currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(item.item_currency || currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
688+
{item.item_currency && item.item_currency.toUpperCase() !== currency.toUpperCase() && (
689+
item.converted_price != null ? (
690+
<div style={{ fontSize: 10, color: 'var(--text-faint)', textAlign: 'center', lineHeight: 1.2, marginTop: -2 }}>
691+
{'≈ ' + fmtNum(item.converted_price, locale, currency)}
692+
</div>
693+
) : (
694+
<div style={{ fontSize: 9, color: '#d97706', textAlign: 'center', lineHeight: 1.2, marginTop: -2 }} title={t('budget.noRate')}>
695+
{t('budget.noRate')}
696+
</div>
697+
)
698+
)}
699+
</div>
700+
<div style={{ flexShrink: 0, width: 76 }}>
701+
<CustomSelect
702+
value={item.item_currency || currency}
703+
onChange={v => handleUpdateField(item.id, 'item_currency', v)}
704+
options={CURRENCIES.map(c => ({ value: c, label: c }))}
705+
searchable
706+
size="sm"
707+
style={{ fontSize: 10 }}
708+
/>
709+
</div>
710+
</div>
653711
</td>
654712
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
655713
{hasMultipleMembers ? (
@@ -692,7 +750,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
692750
</tr>
693751
)
694752
})}
695-
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
753+
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} baseCurrency={currency} />}
696754
</tbody>
697755
</table>
698756
</div>
@@ -712,6 +770,23 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
712770
/>
713771
</div>
714772

773+
{hasMultipleCurrencies && (
774+
<button onClick={handleRefreshRates} disabled={refreshingRates}
775+
style={{
776+
display: 'flex', alignItems: 'center', gap: 6, width: '100%', justifyContent: 'center',
777+
padding: '7px 12px', marginBottom: 12, borderRadius: 10, fontSize: 11, fontWeight: 500,
778+
fontFamily: 'inherit', cursor: refreshingRates ? 'wait' : 'pointer',
779+
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
780+
color: 'var(--text-muted)', transition: 'background 0.15s',
781+
}}
782+
onMouseEnter={e => { if (!refreshingRates) e.currentTarget.style.background = 'var(--bg-hover)' }}
783+
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
784+
>
785+
<RefreshCw size={12} style={refreshingRates ? { animation: 'spin 1s linear infinite' } : {}} />
786+
{t('budget.refreshRates')}
787+
</button>
788+
)}
789+
715790
{canEdit && (
716791
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
717792
<input

client/src/i18n/translations/ar.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
964964
'budget.settlement': 'التسوية',
965965
'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
966966
'budget.netBalances': 'الأرصدة الصافية',
967+
'budget.itemCurrency': 'عملة العنصر',
968+
'budget.convertedAmount': 'المبلغ المحول',
969+
'budget.baseCurrency': 'العملة الأساسية',
970+
'budget.refreshRates': 'تحديث أسعار الصرف',
971+
'budget.noRate': 'لا يوجد سعر صرف',
972+
'budget.ratesUpdated': 'تم تحديث الأسعار',
973+
'budget.ratesFailed': 'تعذر جلب أسعار الصرف',
974+
'budget.approximateConversion': '≈ محول من {currency}',
967975

968976
// Files
969977
'files.title': 'الملفات',

client/src/i18n/translations/br.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
945945
'budget.settlement': 'Acerto',
946946
'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.',
947947
'budget.netBalances': 'Saldos líquidos',
948+
'budget.itemCurrency': 'Moeda do item',
949+
'budget.convertedAmount': 'Valor convertido',
950+
'budget.baseCurrency': 'Moeda base',
951+
'budget.refreshRates': 'Atualizar taxas de câmbio',
952+
'budget.noRate': 'Taxa não disponível',
953+
'budget.ratesUpdated': 'Taxas atualizadas',
954+
'budget.ratesFailed': 'Não foi possível obter as taxas de câmbio',
955+
'budget.approximateConversion': '≈ convertido de {currency}',
948956

949957
// Files
950958
'files.title': 'Arquivos',

client/src/i18n/translations/cs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,14 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
964964
'budget.settlement': 'Vyúčtování',
965965
'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ží.',
966966
'budget.netBalances': 'Čisté zůstatky',
967+
'budget.itemCurrency': 'Měna položky',
968+
'budget.convertedAmount': 'Převedená částka',
969+
'budget.baseCurrency': 'Základní měna',
970+
'budget.refreshRates': 'Obnovit směnné kurzy',
971+
'budget.noRate': 'Kurz není k dispozici',
972+
'budget.ratesUpdated': 'Kurzy aktualizovány',
973+
'budget.ratesFailed': 'Nepodařilo se získat směnné kurzy',
974+
'budget.approximateConversion': '≈ převedeno z {currency}',
967975

968976
// Soubory (Files)
969977
'files.title': 'Soubory',

client/src/i18n/translations/de.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
961961
'budget.settlement': 'Ausgleich',
962962
'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.',
963963
'budget.netBalances': 'Netto-Salden',
964+
'budget.itemCurrency': 'Artikelwährung',
965+
'budget.convertedAmount': 'Umgerechneter Betrag',
966+
'budget.baseCurrency': 'Basiswährung',
967+
'budget.refreshRates': 'Wechselkurse aktualisieren',
968+
'budget.noRate': 'Kein Kurs verfügbar',
969+
'budget.ratesUpdated': 'Kurse aktualisiert',
970+
'budget.ratesFailed': 'Wechselkurse konnten nicht abgerufen werden',
971+
'budget.approximateConversion': '≈ umgerechnet von {currency}',
964972

965973
// Files
966974
'files.title': 'Dateien',

client/src/i18n/translations/en.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
958958
'budget.settlement': 'Settlement',
959959
'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.',
960960
'budget.netBalances': 'Net Balances',
961+
'budget.itemCurrency': 'Item Currency',
962+
'budget.convertedAmount': 'Converted Amount',
963+
'budget.baseCurrency': 'Base Currency',
964+
'budget.refreshRates': 'Refresh Exchange Rates',
965+
'budget.noRate': 'No rate available',
966+
'budget.ratesUpdated': 'Rates updated',
967+
'budget.ratesFailed': 'Could not fetch exchange rates',
968+
'budget.approximateConversion': '≈ converted from {currency}',
961969

962970
// Files
963971
'files.title': 'Files',

client/src/i18n/translations/es.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,14 @@ const es: Record<string, string> = {
921921
'budget.settlement': 'Liquidación',
922922
'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.',
923923
'budget.netBalances': 'Saldos netos',
924+
'budget.itemCurrency': 'Moneda del artículo',
925+
'budget.convertedAmount': 'Monto convertido',
926+
'budget.baseCurrency': 'Moneda base',
927+
'budget.refreshRates': 'Actualizar tasas de cambio',
928+
'budget.noRate': 'Tasa no disponible',
929+
'budget.ratesUpdated': 'Tasas actualizadas',
930+
'budget.ratesFailed': 'No se pudieron obtener las tasas de cambio',
931+
'budget.approximateConversion': '≈ convertido de {currency}',
924932

925933
// Files
926934
'files.title': 'Archivos',

client/src/i18n/translations/fr.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,14 @@ const fr: Record<string, string> = {
960960
'budget.settlement': 'Règlement',
961961
'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.',
962962
'budget.netBalances': 'Soldes nets',
963+
'budget.itemCurrency': "Devise de l'article",
964+
'budget.convertedAmount': 'Montant converti',
965+
'budget.baseCurrency': 'Devise de base',
966+
'budget.refreshRates': 'Actualiser les taux de change',
967+
'budget.noRate': 'Taux non disponible',
968+
'budget.ratesUpdated': 'Taux mis à jour',
969+
'budget.ratesFailed': 'Impossible de récupérer les taux de change',
970+
'budget.approximateConversion': '≈ converti de {currency}',
963971

964972
// Files
965973
'files.title': 'Fichiers',

0 commit comments

Comments
 (0)