import { useState, useEffect, useMemo, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react' import { useTripStore } from '../../store/tripStore' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { useCanDo } from '../../store/permissionsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { budgetApi } from '../../api/client' import { useExchangeRates } from '../../hooks/useExchangeRates' import { useIsMobile } from '../../hooks/useIsMobile' import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants' import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import type { BudgetItem } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' interface CostsPanelProps { tripId: number tripMembers?: TripMember[] } interface Settlement { id: number from_user_id: number to_user_id: number amount: number created_at?: string from_username?: string to_username?: string } interface SettlementData { balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[] flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[] settlements: Settlement[] } // One row in the unified Costs ledger — either an expense or a settle-up payment, // carrying the date used to group it by day. type LedgerEntry = | { kind: 'expense'; date: string; e: BudgetItem } | { kind: 'payment'; date: string; s: Settlement } const round2 = (n: number) => Math.round(n * 100) / 100 const FIELD_H = 40 // shared height for the amount / currency / day row in the modal export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps) { const { trip, budgetItems, deleteBudgetItem, loadBudgetItems } = useTripStore() const me = useAuthStore(s => s.user?.id ?? -1) const can = useCanDo() const canEdit = can('budget_edit', trip) const toast = useToast() const { t, locale } = useTranslation() const isMobile = useIsMobile() // Display/base currency = the user's preferred currency (Settings), falling back // to the trip's own currency. Everything in Costs is converted to and shown in it. const displayCurrency = useSettingsStore(s => s.settings.default_currency) const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() // Pre-rework rows stored currency = NULL, meaning "the trip's own currency". const tripCurrency = (trip?.currency || base).toUpperCase() const { convert } = useExchangeRates(base) const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency]) const [settlement, setSettlement] = useState(null) const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all') const [search, setSearch] = useState('') const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) const [editingSettlement, setEditingSettlement] = useState(null) const [addingPayment, setAddingPayment] = useState(false) const people = tripMembers const personById = useCallback((id: number) => people.find(p => p.id === id), [people]) const personName = useCallback((id: number) => id === me ? t('costs.you') : (personById(id)?.username || '?'), [me, personById, t]) const colorFor = useCallback((id: number) => { const idx = people.findIndex(p => p.id === id) return SPLIT_COLORS[(idx >= 0 ? idx : 0) % SPLIT_COLORS.length].gradient }, [people]) const initial = useCallback((id: number) => id === me ? t('costs.youShort') : (personById(id)?.username || '?').charAt(0).toUpperCase(), [me, personById, t]) const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale]) const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale]) const loadSettlement = useCallback(() => { budgetApi.settlement(tripId, base).then(setSettlement).catch(() => {}) }, [tripId, base]) useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId]) useEffect(() => { loadSettlement() }, [budgetItems.length, base]) // The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense. const [searchParams, setSearchParams] = useSearchParams() useEffect(() => { if (searchParams.get('create') === 'expense') { setEditing(null); setModalOpen(true) setSearchParams(p => { p.delete('create'); return p }, { replace: true }) } }, [searchParams]) // ── derived expense maths (everything converted to the base currency) ──── const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e)) const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0) const myShareOf = (e: BudgetItem) => { const n = (e.members || []).length if (!n || !(e.members || []).some(m => m.user_id === me)) return 0 return baseTotal(e) / n } const totals = useMemo(() => { const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0) const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0) const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0) const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0) const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0) return { totalSpend, myPaid, myShare, owe, owed } }, [budgetItems, settlement, me]) // ── filtering + day grouping ──────────────────────────────────────────── const filtered = useMemo(() => { let list = budgetItems.slice() if (filter === 'mine') list = list.filter(e => myPaidOf(e) > 0) if (filter === 'owed') list = list.filter(e => round2(myPaidOf(e) - myShareOf(e)) > 0) const q = search.trim().toLowerCase() if (q) list = list.filter(e => e.name.toLowerCase().includes(q)) return list }, [budgetItems, filter, search, me]) // Settlements ("payments") shown inline in the ledger. They have no name, so a // text search hides them; they're excluded from the "owed" expense filter and, // under "mine", only show transfers I'm part of. const filteredSettlements = useMemo(() => { if (search.trim()) return [] if (filter === 'owed') return [] let list = settlement?.settlements || [] if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me) return list }, [settlement, filter, search, me]) const dayGroups = useMemo(() => { const entries: LedgerEntry[] = [ ...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })), ...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })), ] const labelOf = (date: string) => { if (!date) return t('costs.noDate') try { return new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return date } } // Newest day first; within a day, expenses before payments (insertion order). const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || '')) const groups: { day: string; entries: LedgerEntry[] }[] = [] for (const en of sorted) { const day = labelOf(en.date) let g = groups.find(x => x.day === day) if (!g) { g = { day, entries: [] }; groups.push(g) } g.entries.push(en) } return groups }, [filtered, filteredSettlements, locale, t]) // ── settle actions ────────────────────────────────────────────────────── const settleFlow = async (fromId: number, toId: number, amount: number) => { try { await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount }) loadSettlement() } catch { toast.error(t('common.unknownError')) } } const undoSettlement = async (id: number) => { try { await budgetApi.deleteSettlement(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) } } const settleAll = async () => { const flows = settlement?.flows || [] if (!flows.length) return try { for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount }) loadSettlement() } catch { toast.error(t('common.unknownError')) } } const dateMeta = useMemo(() => { if (!trip?.start_date || !trip?.end_date) return null try { const s = new Date(trip.start_date + 'T00:00:00Z'), e = new Date(trip.end_date + 'T00:00:00Z') const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1 const opt = { day: 'numeric', month: 'short', timeZone: 'UTC' } as const return { range: `${s.toLocaleDateString(locale, opt)} – ${e.toLocaleDateString(locale, opt)}`, days } } catch { return null } }, [trip?.start_date, trip?.end_date, locale]) const handleDelete = async (id: number) => { try { await deleteBudgetItem(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) } } // ── small presentational helpers ──────────────────────────────────────── const Avatar = ({ id, size = 24 }: { id: number; size?: number }) => { const url = personById(id)?.avatar_url if (url) return return {initial(id)} } const cardCls = 'bg-surface-card border border-edge' const labelCls = 'text-[11px] font-semibold uppercase tracking-[0.12em] text-content-faint' // Big money number with the design's muted symbol/decimals, locale-correct via Intl. const bigMoney = (amount: number, smallSize: number, mutedColor: string) => { let parts: Intl.NumberFormatPart[] | null = null try { const d = currencyDecimals(base) parts = new Intl.NumberFormat(currencyLocale(base), { style: 'currency', currency: base, minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0) } catch { return <>{formatMoney(amount, base, locale)} } const isBig = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign' return <>{parts.map((p, i) => {p.value})} } return (
{isMobile ? : (
{/* ── Header bar ── */}
{dateMeta && ( {dateMeta.range} · {t('costs.daysCount', { count: dateMeta.days })} )} {people.slice(0, 4).map((p, i) => { const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const return p.avatar_url ? : {(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()} })} {t('costs.travelers', { count: people.length })}
{canEdit && (
)}
{/* ── Summary cards ── */}
} tone="owe" foot={totals.owe > 0.01 ? f.from.user_id === me).map(f => f.to.user_id)} lead={t('costs.to')} Avatar={Avatar} name={personName} /> : {t('costs.allSettled')}} /> } tone="owed" foot={totals.owed > 0.01 ? f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} /> : {t('costs.nothingOwed')}} /> } tone="total" foot={{t('costs.yourShare')} · {fmt0(totals.myShare)}{t('costs.youPaid')} · {fmt0(totals.myPaid)}} />
{/* ── Main grid ── */}
{/* expenses */}

{t('costs.expenses')}

setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 13, width: 150, fontFamily: 'inherit' }} />
{(['all', 'mine', 'owed'] as const).map(f => ( ))}
{dayGroups.length === 0 ? (
{search ? t('costs.noMatch') : t('costs.emptyText')}
) : dayGroups.map(g => { const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) return (
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
{g.entries.map(en => en.kind === 'expense' ? : )}
) })}
{/* sidebar */}
{/* settle up */}
{t('costs.settleUp')} · {(settlement?.flows || []).length}
{canEdit && ( )}
{/* balances */}
{t('costs.balances')}
{/* by category */}
{t('costs.byCategory')}
)} {modalOpen && ( setModalOpen(false)} onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} /> )} {(editingSettlement || addingPayment) && ( { setEditingSettlement(null); setAddingPayment(false) }} onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} /> )}
) // ── shared settle-flow list ────────────────────────────────────────────── function SettleFlows() { const flows = settlement?.flows || [] if (flows.length === 0) return (
{t('costs.everyoneSquare')}
{t('costs.nothingOutstanding')}
) return (
{flows.map((f, i) => (
{fmt(f.amount)} {canEdit && }
))}
) } // ── mobile layout (Budget1Mobile.html): single flat column, total card on top ── function MobileBody() { return (
{/* Total card */}
{t('costs.totalSpend')}
{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}
{t('costs.yourShare')} · {fmt0(totals.myShare)} {t('costs.youPaid')} · {fmt0(totals.myPaid)}
{canEdit && ( )}
{/* Owe / Owed */}
{t('costs.youOwe')}
{t('costs.youOweSub')}
{bigMoney(totals.owe, 16, 'var(--c-ink3)')}
{t('costs.youreOwed')}
{t('costs.youreOwedSub')}
{bigMoney(totals.owed, 16, 'var(--c-ink3)')}
{/* Settle up */}
{t('costs.settleUp')} {(settlement?.flows || []).length}
{canEdit && ( )}
{/* Expenses */}
{t('costs.expenses')}
setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 14, width: '100%', fontFamily: 'inherit' }} />
{(['all', 'mine', 'owed'] as const).map(f => ( ))}
{dayGroups.length === 0 ?
{search ? t('costs.noMatch') : t('costs.emptyText')}
: dayGroups.map(g => { const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) return (
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
{g.entries.map(en => en.kind === 'expense' ? : )}
) })}
{/* Balances */}
{t('costs.balances')}
{/* By category */}
{t('costs.byCategory')}
) } // ── inline subcomponents (close over helpers) ──────────────────────────── function ExpenseRow({ e }: { e: BudgetItem }) { const c = catMeta(e.category) const Icon = c.Icon const cur = curOf(e) const payers = (e.payers || []).filter(p => p.amount > 0) const net = round2(myPaidOf(e) - myShareOf(e)) // "Unfinished": a recorded total nobody has paid yet — counts toward the trip // total but stays out of settlements until who-paid is filled in. const isUnfinished = baseTotal(e) > 0 && payers.length === 0 return (
{e.name} {isUnfinished && ( ! {t('costs.unfinished')} )}
{payers.length > 0 && (
{payers.map(p => ( {fmt(convert(p.amount, cur))} ))}
)} {!isMobile && (
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''}
)}
{fmt(baseTotal(e))}
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
0 ? '#16a34a' : '#dc2626' }}> {net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
)}
{canEdit && (
)}
) } // A settle-up payment as a ledger row — visually distinct from an expense, with // inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal. function SettlementRow({ s }: { s: Settlement }) { return (
{t('costs.payment')}
{personName(s.from_user_id)} → {personName(s.to_user_id)}
{fmt(s.amount)}
{canEdit && (
)}
) } function BalancesList({ balances }: { balances: SettlementData['balances'] }) { const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 }) const max = Math.max(1, ...rows.map(r => Math.abs(r.balance))) return (
{rows.map(r => { const pct = Math.min(100, Math.abs(r.balance) / max * 100) const pos = r.balance > 0.01, neg = r.balance < -0.01 return (
{personName(r.user_id)}
{pos && } {neg && }
{pos ? '+' + fmt(r.balance) : neg ? '−' + fmt(-r.balance) : fmt(0)}
) })}
) } function CategoryBreakdown() { const tot: Record = {} for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) } const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0)) if (rows.length === 0) return
{t('costs.noCategories')}
// Bars are scaled relative to the most expensive category (the top row fills the // bar), not to the trip grand total — makes the relative ranking readable. const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0)) return (
{rows.map(c => { const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0 return (
{t(c.labelKey)} {fmt0(v)}
) })}
) } } // ── pure subcomponents ───────────────────────────────────────────────────── function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) { const total = tone === 'total' const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)' // formatToParts keeps the design's "big integer + muted symbol/decimals" styling // while letting Intl place the symbol and pick separators per locale + currency. let parts: Intl.NumberFormatPart[] | null = null try { const d = currencyDecimals(currency) parts = new Intl.NumberFormat(currencyLocale(currency), { style: 'currency', currency: (currency || 'EUR').toUpperCase(), minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0) } catch { parts = null } const big = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign' return (
{icon}
{label}
{sub}
{parts ? parts.map((p, i) => {p.value}) : {formatMoney(amount, currency, locale)}}
{foot}
) } function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string }) { const uniq = Array.from(new Set(ids)) return ( {lead} {uniq.map(id => ( {name(id)} ))} ) } // Add or edit a settle-up payment (from / to / amount). Reachable inline from the // ledger row and from a manual "Add payment" button, so recording "I sent money to // X" works the same whether or not there's an outstanding expense behind it. function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: { tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void }) { const { t } = useTranslation() const toast = useToast() const otherDefault = people.find(p => p.id !== me)?.id ?? me const [fromId, setFromId] = useState(String(editing?.from_user_id ?? me)) const [toId, setToId] = useState(String(editing?.to_user_id ?? otherDefault)) const [amount, setAmount] = useState(editing ? String(editing.amount) : '') const [saving, setSaving] = useState(false) const amt = parseFloat(amount) || 0 const valid = amt > 0 && fromId !== toId const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username })) const save = async () => { if (!valid) return setSaving(true) const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt } try { if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) else await budgetApi.createSettlement(tripId, data) onSaved() } catch { toast.error(t('common.unknownError')) } finally { setSaving(false) } } const inputCls = 'w-full bg-surface-input border border-edge text-content' const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]' return ( }>
setFromId(String(v))} options={opts} style={{ width: '100%' }} />
setToId(String(v))} options={opts} style={{ width: '100%' }} />
setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
) } // ── Add / edit expense modal ─────────────────────────────────────────────── export interface ExpensePrefill { name?: string category?: string amount?: number reservationId?: number } export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: { tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void }) { const { t, locale } = useTranslation() const toast = useToast() const { addBudgetItem, updateBudgetItem } = useTripStore() const { convert } = useExchangeRates(base) const sym = (c: string) => SYMBOLS[c] || (c + ' ') const [name, setName] = useState(editing?.name || prefill?.name || '') const [cat, setCat] = useState(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10)) // One participant list: a person is "in" the split and may have paid an amount. // Entering the total auto-distributes it equally across the non-pinned participants; // touching an amount pins it and the rest rebalance so the paid amounts always sum // back to the total. Leaving every amount blank = an unfinished expense (counts // toward the trip total only, never settlements, until who-paid is filled in). const [total, setTotal] = useState(() => { if (editing) return editing.total_price ? String(editing.total_price) : '' if (prefill?.amount != null) return String(prefill.amount) return '' }) const [participants, setParticipants] = useState>(() => editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id))) const [paid, setPaid] = useState>(() => { const m: Record = {} for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount) return m }) // Amounts the user pinned by typing — kept out of the auto-rebalance. Existing // payer amounts load as pinned so opening an expense never reshuffles them. const [dirty, setDirty] = useState>(() => new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id))) const [saving, setSaving] = useState(false) const totalNum = parseFloat(total) || 0 const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0)) const paidEntered = paidSum > 0 const balanced = Math.abs(paidSum - totalNum) < 0.01 const each = participants.size > 0 ? totalNum / participants.size : 0 const valid = name.trim().length > 0 && totalNum > 0 && participants.size > 0 && (!paidEntered || balanced) // Spread `amount` across `n` people in whole cents so the parts sum back exactly. const splitCents = (amount: number, n: number): number[] => { if (n <= 0) return [] const cents = Math.max(0, Math.round(amount * 100)) const base = Math.floor(cents / n), rem = cents - base * n return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100) } // Recompute the non-pinned participants so every paid amount sums to the total. const rebalance = (paidMap: Record, dirtySet: Set, parts: Set, totalVal: number): Record => { const ids = [...parts] const free = ids.filter(id => !dirtySet.has(id)) if (free.length === 0) return paidMap const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0) const shares = splitCents(totalVal - pinnedSum, free.length) const next = { ...paidMap } free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' }) return next } const onTotalChange = (v: string) => { v = v.replace(',', '.') setTotal(v) setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0)) } const onPaidChange = (id: number, v: string) => { v = v.replace(',', '.') const nextDirty = new Set(dirty); nextDirty.add(id) setDirty(nextDirty) setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum)) } const toggleParticipant = (id: number) => { const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid } if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] } else nextParts.add(id) setParticipants(nextParts); setDirty(nextDirty) setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum)) } const save = async () => { if (!valid) return setSaving(true) const payerList = [...participants] .map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 })) .filter(p => p.amount > 0) const data = { name: name.trim(), category: cat, // Store the actual currency the amounts were entered in; conversion to the // viewer's display currency happens live (real rates), no manual rate. currency, payers: payerList, member_ids: [...participants], expense_date: day || null, // Always record the entered total: the server keeps it as-is for an unfinished // expense (no payers) and otherwise re-derives it from the payer sum (== total). total_price: totalNum, // Link a freshly-created expense to its booking (create-from-booking flow). ...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}), } try { if (editing) await updateBudgetItem(tripId, editing.id, data) else await addBudgetItem(tripId, data) onSaved() } catch { toast.error(t('common.unknownError')) } finally { setSaving(false) } } const inputCls = 'w-full bg-surface-input border border-edge text-content' const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]' return ( }>
setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none' }} />
{sym(currency)} onTotalChange(e.target.value)} className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
setCurrency(String(v))} searchable options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} style={{ width: '100%' }} />
{currency !== base && totalNum > 0 && (
{formatMoney(totalNum, currency, locale)} {formatMoney(convert(totalNum, currency), base, locale)} · {t('costs.liveRate')}
)}
{COST_CATEGORY_LIST.map(c => { const Icon = c.Icon; const on = cat === c.key return ( ) })}
{people.map((p, idx) => { const on = participants.has(p.id) return (
{on ? (
{sym(currency)} onPaidChange(p.id, e.target.value)} className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
) : ( )}
) })}
{participants.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })} {paidEntered ? {sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)} : (totalNum > 0 && {t('costs.unfinishedHint')})}
) }