File size: 9,024 Bytes
766d85d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import type { CSSProperties } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import type { BudgetItem } from '../../types'
import { currencyDecimals } from '../../utils/formatters'
import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
import { PIE_COLORS } from './BudgetPanel.constants'
import type { TripMember } from './BudgetPanelMemberChips'

function useIsDark(): boolean {
  const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
  useEffect(() => {
    if (typeof document === 'undefined') return
    const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
    return () => mo.disconnect()
  }, [])
  return dark
}

export interface EditingCat {
  name: string
  value: string
}

interface SettlementPerson {
  user_id: number
  username: string
  avatar_url: string | null
}

interface SettlementFlow {
  from: SettlementPerson
  to: SettlementPerson
  amount: number
}

interface SettlementBalance {
  user_id: number
  username: string
  avatar_url: string | null
  balance: number
}

export interface SettlementData {
  balances: SettlementBalance[]
  flows: SettlementFlow[]
}

export interface PieSegment {
  name: string
  value: number
  color: string
}

export interface AddItemData {
  name: string
  total_price: number
  persons: number | null
  days: number | null
  note: string | null
  expense_date: string | null
}

export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) {
  const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
  const can = useCanDo()
  const toast = useToast()
  const { t, locale } = useTranslation()
  const isDark = useIsDark()
  const theme = useMemo(() => widgetTheme(isDark), [isDark])
  const [newCategoryName, setNewCategoryName] = useState('')
  const [editingCat, setEditingCat] = useState<EditingCat | null>(null) // { name, value }
  const [settlement, setSettlement] = useState<SettlementData | null>(null)
  const [settlementOpen, setSettlementOpen] = useState(false)
  const currency = trip?.currency || 'EUR'
  const canEdit = can('budget_edit', trip)

  const fmt = (v: number | null | undefined, cur: string) => fmtNum(v, locale, cur)
  const hasMultipleMembers = tripMembers.length > 1

  // Drag state for categories
  const [dragCat, setDragCat] = useState<string | null>(null)
  const [dragOverCat, setDragOverCat] = useState<string | null>(null)
  // Drag state for items within a category
  const [dragItem, setDragItem] = useState<number | null>(null)
  const [dragOverItem, setDragOverItem] = useState<number | null>(null)
  const [dragItemCat, setDragItemCat] = useState<string | null>(null)

  // Load settlement data whenever budget items change
  useEffect(() => {
    if (!hasMultipleMembers) return
    budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
  }, [tripId, budgetItems, hasMultipleMembers])

  const setCurrency = (cur: string) => {
    if (tripId) updateTrip(tripId, { currency: cur })
  }

  useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])

  const grouped = useMemo(() => {
    const map = new Map<string, BudgetItem[]>()
    for (const item of (budgetItems || [])) {
      const cat = item.category || 'Other'
      if (!map.has(cat)) map.set(cat, [])
      map.get(cat)!.push(item)
    }
    return map
  }, [budgetItems])

  const categoryNames = Array.from(grouped.keys())

  // Stable color mapping: assign index-based colors once, never reassign on reorder
  const colorMapRef = useRef(new Map<string, string>())
  const categoryColor = useCallback((cat: string) => {
    const map = colorMapRef.current
    if (!map.has(cat)) {
      map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length])
    }
    return map.get(cat)!
  }, [])
  const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)

  const pieSegments = useMemo<PieSegment[]>(() =>
    categoryNames.map((cat, i) => ({
      name: cat,
      value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0),
      color: categoryColor(cat),
    })).filter(s => s.value > 0)
  , [grouped, categoryNames])

  const handleAddItem = async (category: string, data: AddItemData) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } }
  const handleUpdateField = async (id: number, field: string, value: unknown) => { try { await updateBudgetItem(tripId, id, { [field]: value } as Partial<BudgetItem>) } catch { toast.error(t('common.error')) } }
  const handleDeleteItem = async (id: number) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } }
  const handleDeleteCategory = async (cat: string) => {
    const items = grouped.get(cat) || []
    try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) }
    catch { toast.error(t('common.error')) }
  }
  const handleRenameCategory = async (oldName: string, newName: string) => {
    if (!newName.trim() || newName.trim() === oldName) return
    const items = grouped.get(oldName) || []
    try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) }
    catch { toast.error(t('common.error')) }
  }
  const handleAddCategory = () => {
    if (!newCategoryName.trim()) return
    Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }))
      .catch(() => toast.error(t('common.error')))
    setNewCategoryName('')
  }

  const handleExportCsv = () => {
    const sep = ';'
    const esc = (v: unknown) => { 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.get(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 = ''
    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À-ɏ _-]/g, '').trim()
    a.download = `budget-${safeName}.csv`
    a.click()
    URL.revokeObjectURL(url)
  }

  const th: CSSProperties = { 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: CSSProperties = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }

  return {
    trip, budgetItems,
    setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories,
    t, locale, isDark, theme,
    newCategoryName, setNewCategoryName,
    editingCat, setEditingCat,
    settlement, settlementOpen, setSettlementOpen,
    currency, canEdit, fmt, hasMultipleMembers,
    dragCat, setDragCat, dragOverCat, setDragOverCat,
    dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat,
    setCurrency,
    grouped, categoryNames, categoryColor, grandTotal, pieSegments,
    handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv,
    th, td,
  }
}