Spaces:
Sleeping
Sleeping
| import type { CSSProperties, Dispatch, SetStateAction } from 'react' | |
| import { Trash2, Pencil, GripVertical } from 'lucide-react' | |
| import type { BudgetItem } from '../../types' | |
| import { currencyDecimals } from '../../utils/formatters' | |
| import { CustomDatePicker } from '../shared/CustomDateTimePicker' | |
| import { calcPP, calcPD, calcPPD } from './BudgetPanel.helpers' | |
| import InlineEditCell from './BudgetPanelInlineEditCell' | |
| import AddItemRow from './BudgetPanelAddItemRow' | |
| import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips' | |
| import type { EditingCat, AddItemData } from './useBudgetPanel' | |
| interface BudgetCategoryTableProps { | |
| cat: string | |
| grouped: Map<string, BudgetItem[]> | |
| categoryColor: (cat: string) => string | |
| canEdit: boolean | |
| editingCat: EditingCat | null | |
| setEditingCat: Dispatch<SetStateAction<EditingCat | null>> | |
| dragCat: string | null | |
| setDragCat: Dispatch<SetStateAction<string | null>> | |
| dragOverCat: string | null | |
| setDragOverCat: Dispatch<SetStateAction<string | null>> | |
| dragItem: number | null | |
| setDragItem: Dispatch<SetStateAction<number | null>> | |
| dragOverItem: number | null | |
| setDragOverItem: Dispatch<SetStateAction<number | null>> | |
| dragItemCat: string | null | |
| setDragItemCat: Dispatch<SetStateAction<string | null>> | |
| categoryNames: string[] | |
| reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void> | |
| reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void> | |
| handleRenameCategory: (oldName: string, newName: string) => Promise<void> | |
| handleDeleteCategory: (cat: string) => Promise<void> | |
| handleDeleteItem: (id: number) => Promise<void> | |
| handleUpdateField: (id: number, field: string, value: unknown) => Promise<void> | |
| handleAddItem: (category: string, data: AddItemData) => Promise<void> | |
| tripId: number | |
| currency: string | |
| locale: string | |
| t: (key: string) => string | |
| fmt: (v: number | null | undefined, cur: string) => string | |
| hasMultipleMembers: boolean | |
| tripMembers: TripMember[] | |
| setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: unknown; item: unknown }> | |
| toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void> | |
| th: CSSProperties | |
| td: CSSProperties | |
| } | |
| export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat, | |
| dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem, | |
| dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems, | |
| handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem, | |
| tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) { | |
| const items = grouped.get(cat) || [] | |
| const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) | |
| const color = categoryColor(cat) | |
| return ( | |
| <div key={cat} data-drag-cat={cat} style={{ | |
| marginBottom: 16, opacity: dragCat === cat ? 0.4 : 1, | |
| transition: 'opacity 0.15s', | |
| position: 'relative', | |
| }} | |
| onDragOver={e => { | |
| if (!dragCat || dragCat === cat || dragItem) return | |
| e.preventDefault(); e.dataTransfer.dropEffect = 'move' | |
| setDragOverCat(cat) | |
| }} | |
| onDragLeave={e => { | |
| if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null) | |
| }} | |
| onDrop={e => { | |
| e.preventDefault() | |
| if (dragCat && dragCat !== cat) { | |
| const newOrder = [...categoryNames] | |
| const fromIdx = newOrder.indexOf(dragCat) | |
| const toIdx = newOrder.indexOf(cat) | |
| newOrder.splice(fromIdx, 1) | |
| newOrder.splice(toIdx, 0, dragCat) | |
| reorderBudgetCategories(tripId, newOrder) | |
| } | |
| setDragCat(null); setDragOverCat(null) | |
| }} | |
| > | |
| {dragOverCat === cat && <div style={{ position: 'absolute', top: -2, left: 0, right: 0, height: 4, background: 'var(--accent)', borderRadius: 2, zIndex: 10 }} />} | |
| <div style={{ | |
| display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', | |
| borderRadius: '10px 10px 0 0', padding: '9px 14px', | |
| }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}> | |
| {canEdit && ( | |
| <div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }} | |
| onDragEnd={() => { setDragCat(null); setDragOverCat(null) }} | |
| style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}> | |
| <GripVertical size={14} /> | |
| </div> | |
| )} | |
| <div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} /> | |
| {canEdit && editingCat?.name === cat ? ( | |
| <input | |
| autoFocus | |
| value={editingCat.value} | |
| onChange={e => 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%' }} | |
| /> | |
| ) : ( | |
| <> | |
| <span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span> | |
| {canEdit && ( | |
| <button onClick={() => setEditingCat({ name: cat, value: cat })} | |
| style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }} | |
| onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}> | |
| <Pencil size={10} /> | |
| </button> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> | |
| <span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span> | |
| {canEdit && ( | |
| <button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')} | |
| style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }} | |
| onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}> | |
| <Trash2 size={13} /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| <div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }} | |
| onDragOver={e => { if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}> | |
| <table style={{ width: '100%', borderCollapse: 'collapse' }}> | |
| <thead> | |
| <tr> | |
| <th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th> | |
| <th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th> | |
| <th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th> | |
| <th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th> | |
| <th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th> | |
| <th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th> | |
| <th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th> | |
| <th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th> | |
| <th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th> | |
| <th style={{ ...th, width: 36 }}></th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {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) > 0 | |
| return ( | |
| <tr key={item.id} | |
| style={{ | |
| transition: 'background 0.1s, opacity 0.15s', | |
| opacity: dragItem === item.id ? 0.4 : 1, | |
| boxShadow: dragOverItem === item.id ? 'inset 4px 0 0 0 var(--accent)' : 'none', | |
| }} | |
| onDragOver={e => { | |
| if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return } | |
| if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) } | |
| }} | |
| onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }} | |
| onDrop={e => { | |
| if (dragItem && dragItemCat === cat && dragItem !== item.id) { | |
| e.preventDefault(); e.stopPropagation() | |
| const ids = items.map(i => i.id) | |
| const fromIdx = ids.indexOf(dragItem) | |
| const toIdx = ids.indexOf(item.id) | |
| ids.splice(fromIdx, 1) | |
| ids.splice(toIdx, 0, dragItem) | |
| reorderBudgetItems(tripId, ids) | |
| setDragItem(null); setDragOverItem(null); setDragItemCat(null) | |
| } | |
| }} | |
| onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} | |
| onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> | |
| <td style={td}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> | |
| {canEdit && ( | |
| <div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }} | |
| onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }} | |
| style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}> | |
| <GripVertical size={12} /> | |
| </div> | |
| )} | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} /> | |
| {hasMultipleMembers && ( | |
| <div className="sm:hidden" style={{ marginTop: 4 }}> | |
| <BudgetMemberChips | |
| members={item.members || []} | |
| tripMembers={tripMembers} | |
| onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)} | |
| onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} | |
| compact={false} | |
| readOnly={!canEdit} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </td> | |
| <td style={{ ...td, textAlign: 'center' }}> | |
| <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} /> | |
| </td> | |
| <td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}> | |
| {hasMultipleMembers ? ( | |
| <BudgetMemberChips | |
| members={item.members || []} | |
| tripMembers={tripMembers} | |
| onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)} | |
| onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} | |
| readOnly={!canEdit} | |
| /> | |
| ) : ( | |
| <InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> | |
| )} | |
| </td> | |
| <td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}> | |
| <InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> | |
| </td> | |
| <td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td> | |
| <td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td> | |
| <td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td> | |
| <td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}> | |
| {canEdit ? ( | |
| <div style={{ maxWidth: 90, margin: '0 auto' }}> | |
| <CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless /> | |
| </div> | |
| ) : ( | |
| <span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span> | |
| )} | |
| </td> | |
| <td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td> | |
| <td style={{ ...td, textAlign: 'center' }}> | |
| {canEdit && ( | |
| <button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')} | |
| style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }} | |
| onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}> | |
| <Trash2 size={14} /> | |
| </button> | |
| )} | |
| </td> | |
| </tr> | |
| ) | |
| })} | |
| {canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ) | |
| } | |