import ReactDOM from 'react-dom' import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import DOM from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useTranslation } from '../../i18n' import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import { budgetApi } from '../../api/client' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import type { BudgetItem, BudgetMember } from '../../types' import { currencyDecimals } from '../../utils/formatters' interface TripMember { id: number username: string avatar_url?: string | null } interface PieSegment { label: string value: number color: string } interface PerPersonSummaryEntry { user_id: number username: string avatar_url: string | null total_assigned: number } // ── Helpers ────────────────────────────────────────────────────────────────── const CURRENCIES = [ 'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR', 'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS', 'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT', 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS', ] const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$', NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM', PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$', ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD', HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽', UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$', PEN: 'S/.', ARS: 'AR$', } const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7'] const fmtNum = (v, locale, cur) => { if (v == null || isNaN(v)) return '-' const d = currencyDecimals(cur) return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur) } const calcPP = (p, n) => (n > 0 ? p / n : null) const calcPD = (p, d) => (d > 0 ? p / d : null) const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null) // ── Inline Edit Cell ───────────────────────────────────────────────────────── function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) { const [editing, setEditing] = useState(false) const [editValue, setEditValue] = useState(value ?? '') const inputRef = useRef(null) useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing]) const save = () => { setEditing(false) let v = editValue if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p } if (v !== value) onSave(v) } if (editing) { return setEditValue(e.target.value)} onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }} style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }} placeholder={placeholder} /> } const display = type === 'number' && value != null ? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) : (value || '') return (
{ if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip} style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center', justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s', color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }} onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}> {display || placeholder || '-'}
) } // ── Add Item Row ───────────────────────────────────────────────────────────── interface AddItemRowProps { onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void t: (key: string) => string } function AddItemRow({ onAdd, t }: AddItemRowProps) { const [name, setName] = useState('') const [price, setPrice] = useState('') const [persons, setPersons] = useState('') const [days, setDays] = useState('') const [note, setNote] = useState('') const [expenseDate, setExpenseDate] = useState('') const nameRef = useRef(null) const handleAdd = () => { if (!name.trim()) return 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 }) setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('') setTimeout(() => nameRef.current?.focus(), 50) } const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' } return ( setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.newEntry')} style={inp} /> setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} /> setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> - - -
setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} /> ) } // ── Chip with custom tooltip ───────────────────────────────────────────────── interface ChipWithTooltipProps { label: string avatarUrl: string | null size?: number paid?: boolean onClick?: () => void } function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) { const [hover, setHover] = useState(false) const [pos, setPos] = useState({ top: 0, left: 0 }) const ref = useRef(null) const onEnter = () => { if (ref.current) { const rect = ref.current.getBoundingClientRect() setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 }) } setHover(true) } const borderColor = paid ? '#22c55e' : 'var(--border-primary)' const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)' return ( <>
setHover(false)} onClick={onClick} style={{ width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', transition: 'border-color 0.15s, background 0.15s', }}> {avatarUrl ? : label?.[0]?.toUpperCase() }
{hover && ReactDOM.createPortal(
{label} {paid && ( Paid )}
, document.body )} ) } // ── Budget Member Chips (for Persons column) ──────────────────────────────── interface BudgetMemberChipsProps { members?: BudgetMember[] tripMembers?: TripMember[] onSetMembers: (memberIds: number[]) => void onTogglePaid?: (userId: number, paid: boolean) => void compact?: boolean readOnly?: boolean } function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) { const chipSize = compact ? 20 : 30 const btnSize = compact ? 18 : 28 const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14) const [showDropdown, setShowDropdown] = useState(false) const [dropPos, setDropPos] = useState({ top: 0, left: 0 }) const btnRef = useRef(null) const dropRef = useRef(null) const openDropdown = useCallback(() => { if (btnRef.current) { const rect = btnRef.current.getBoundingClientRect() setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 }) } setShowDropdown(v => !v) }, []) useEffect(() => { if (!showDropdown) return const close = (e) => { if (dropRef.current && dropRef.current.contains(e.target)) return if (btnRef.current && btnRef.current.contains(e.target)) return setShowDropdown(false) } document.addEventListener('mousedown', close) return () => document.removeEventListener('mousedown', close) }, [showDropdown]) const memberIds = members.map(m => m.user_id) const toggleMember = (userId) => { const newIds = memberIds.includes(userId) ? memberIds.filter(id => id !== userId) : [...memberIds, userId] onSetMembers(newIds) } return (
{members.map(m => ( onTogglePaid(m.user_id, !m.paid) : undefined} /> ))} {!readOnly && ( )} {showDropdown && ReactDOM.createPortal(
{tripMembers.map(tm => { const isActive = memberIds.includes(tm.id) return ( ) })}
, document.body )}
) } // ── Per-Person Inline (inside total card) ──────────────────────────────────── interface PerPersonInlineProps { tripId: number budgetItems: BudgetItem[] currency: string locale: string } function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) { const [data, setData] = useState(null) const fmt = (v) => fmtNum(v, locale, currency) useEffect(() => { budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {}) }, [tripId, budgetItems]) if (!data || data.length === 0) return null return (
{data.map(person => (
{person.avatar_url ? : person.username?.[0]?.toUpperCase() }
{person.username} {fmt(person.total_assigned)}
))}
) } // ── Pie Chart (pure CSS conic-gradient) ────────────────────────────────────── interface PieChartProps { segments: PieSegment[] size?: number totalLabel: string } function PieChart({ segments, size = 200, totalLabel }: PieChartProps) { if (!segments.length) return null const total = segments.reduce((s, x) => s + x.value, 0) if (total === 0) return null let cumDeg = 0 const stops = segments.map(seg => { const start = cumDeg const deg = (seg.value / total) * 360 cumDeg += deg return `${seg.color} ${start}deg ${start + deg}deg` }).join(', ') return (
{totalLabel}
) } // ── Main Component ─────────────────────────────────────────────────────────── interface BudgetPanelProps { tripId: number tripMembers?: TripMember[] } export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) { const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore() const can = useCanDo() const { t, locale } = useTranslation() const [newCategoryName, setNewCategoryName] = useState('') const [editingCat, setEditingCat] = useState(null) // { name, value } const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null) const [settlementOpen, setSettlementOpen] = useState(false) const currency = trip?.currency || 'EUR' const canEdit = can('budget_edit', trip) const fmt = (v, cur) => fmtNum(v, locale, cur) const hasMultipleMembers = tripMembers.length > 1 // Load settlement data whenever budget items change useEffect(() => { if (!hasMultipleMembers) return budgetApi.settlement(tripId).then(setSettlement).catch(() => {}) }, [tripId, budgetItems, hasMultipleMembers]) const setCurrency = (cur) => { if (tripId) updateTrip(tripId, { currency: cur }) } useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId]) const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => { const cat = item.category || 'Other' if (!acc[cat]) acc[cat] = [] acc[cat].push(item) return acc }, {}), [budgetItems]) const categoryNames = Object.keys(grouped) const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0) const pieSegments = useMemo(() => categoryNames.map((cat, i) => ({ name: cat, value: grouped[cat].reduce((s, x) => s + (x.total_price || 0), 0), color: PIE_COLORS[i % PIE_COLORS.length], })).filter(s => s.value > 0) , [grouped, categoryNames]) const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} } const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} } const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} } const handleDeleteCategory = async (cat) => { const items = grouped[cat] || [] for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) } const handleRenameCategory = async (oldName, newName) => { if (!newName.trim() || newName.trim() === oldName) return const items = grouped[oldName] || [] for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) } const handleAddCategory = () => { if (!newCategoryName.trim()) return addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }) setNewCategoryName('') } const handleExportCsv = () => { const sep = ';' const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s } const d = currencyDecimals(currency) const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : '' const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note'] const rows = [header.join(sep)] for (const cat of categoryNames) { for (const item of (grouped[cat] || [])) { const pp = calcPP(item.total_price, item.persons) const pd = calcPD(item.total_price, item.days) const ppd = calcPPD(item.total_price, item.persons, item.days) rows.push([ esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')), fmtPrice(item.total_price), item.persons ?? '', item.days ?? '', fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd), esc(item.note || ''), ].join(sep)) } } const bom = '\uFEFF' const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim() a.download = `budget-${safeName}.csv` a.click() URL.revokeObjectURL(url) } const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' } const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' } // ── Empty State ────────────────────────────────────────────────────────── if (!budgetItems || budgetItems.length === 0) { return (

{t('budget.emptyTitle')}

{t('budget.emptyText')}

{canEdit && (
setNewCategoryName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAddCategory()} placeholder={t('budget.emptyPlaceholder')} style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
)}
) } // ── Main Layout ────────────────────────────────────────────────────────── return (

{t('budget.title')}

{categoryNames.map((cat, ci) => { const items = grouped[cat] const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) const color = PIE_COLORS[ci % PIE_COLORS.length] return (
{canEdit && editingCat?.name === cat ? ( setEditingCat({ ...editingCat, value: e.target.value })} onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }} onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }} style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }} /> ) : ( <> {cat} {canEdit && ( )} )}
{fmt(subtotal, currency)} {canEdit && ( )}
{items.map(item => { const pp = calcPP(item.total_price, item.persons) const pd = calcPD(item.total_price, item.days) const ppd = calcPPD(item.total_price, item.persons, item.days) const hasMembers = item.members?.length > 0 return ( e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> ) })} {canEdit && handleAddItem(cat, data)} t={t} />}
{t('budget.table.name')} {t('budget.table.total')} {t('budget.table.persons')} {t('budget.table.days')} {t('budget.table.perPerson')} {t('budget.table.perDay')} {t('budget.table.perPersonDay')} {t('budget.table.date')} {t('budget.table.note')}
handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> {/* Mobile: larger chips under name since Persons column is hidden */} {hasMultipleMembers && (
setBudgetItemMembers(tripId, item.id, userIds)} onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} compact={false} readOnly={!canEdit} />
)}
handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> {hasMultipleMembers ? ( setBudgetItemMembers(tripId, item.id, userIds)} onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} readOnly={!canEdit} /> ) : ( handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> )} handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> {pp != null ? fmt(pp, currency) : '-'} {pd != null ? fmt(pd, currency) : '-'} {ppd != null ? fmt(ppd, currency) : '-'} {canEdit ? (
handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
) : ( {item.expense_date || '—'} )}
handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> {canEdit && ( )}
) })}
({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))} searchable />
{canEdit && (
setNewCategoryName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }} placeholder={t('budget.categoryName')} style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }} />
)}
{t('budget.totalBudget')}
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
{SYMBOLS[currency] || currency} {currency}
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && ( )} {/* Settlement dropdown inside the total card */} {hasMultipleMembers && settlement && settlement.flows.length > 0 && (
{settlementOpen && (
{settlement.flows.map((flow, i) => (
{fmt(flow.amount, currency)}
))} {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
{t('budget.netBalances')}
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
{b.avatar_url ? : b.username?.[0]?.toUpperCase() }
{b.username} 0 ? '#4ade80' : '#f87171', }}> {b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
))}
)}
)}
)}
{pieSegments.length > 0 && (
{t('budget.byCategory')}
{pieSegments.map(seg => { const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0' return (
{seg.name} {fmt(seg.value, currency)} {pct}%
) })}
)}
) }