import ReactDOM from 'react-dom' import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import DOM from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useTranslation } from '../../i18n' import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import { budgetApi } from '../../api/client' 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'] const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' } 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 }) { 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 (
{ setEditValue(value ?? ''); setEditing(true) }} title={editTooltip} style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, 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 => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => 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 }) => 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 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 }) setName(''); setPrice(''); setPersons(''); setDays(''); setNote('') 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: 50, margin: '0 auto' }} /> setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, 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 } function ChipWithTooltip({ label, avatarUrl, size = 20 }: 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) } return ( <>
setHover(false)} style={{ width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0, }}> {avatarUrl ? : label?.[0]?.toUpperCase() }
{hover && ReactDOM.createPortal(
{label}
, document.body )} ) } // ── Budget Member Chips (for Persons column) ──────────────────────────────── interface BudgetMemberChipsProps { members?: BudgetMember[] tripMembers?: TripMember[] onSetMembers: (memberIds: number[]) => void compact?: boolean } function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: 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 => ( ))} {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 } = useTripStore() const { t, locale } = useTranslation() const [newCategoryName, setNewCategoryName] = useState('') const [editingCat, setEditingCat] = useState(null) // { name, value } const currency = trip?.currency || 'EUR' const fmt = (v, cur) => fmtNum(v, locale, cur) const hasMultipleMembers = tripMembers.length > 1 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 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')}

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 (
{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} )}
{fmt(subtotal, currency)}
{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'}> ) })} 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.note')}
handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} /> {/* Mobile: larger chips under name since Persons column is hidden */} {hasMultipleMembers && (
setBudgetItemMembers(tripId, item.id, userIds)} compact={false} />
)}
handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} /> {hasMultipleMembers ? ( setBudgetItemMembers(tripId, item.id, userIds)} /> ) : ( handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} /> )} handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} /> {pp != null ? fmt(pp, currency) : '-'} {pd != null ? fmt(pd, currency) : '-'} {ppd != null ? fmt(ppd, currency) : '-'} handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} />
) })}
({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))} searchable />
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) && ( )}
{pieSegments.length > 0 && (
{t('budget.byCategory')}
{pieSegments.map(seg => { const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0' return (
{seg.name} {pct}%
) })}
{pieSegments.map(seg => (
{seg.name} {fmt(seg.value, currency)}
))}
)}
) }