/
Pitonx
/
MyFinance
Обзор
Документация
Войти
/
Pitonx
/
MyFinance
Код
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/components/DataTable.js
443 строки
17 KB
agent
fix(expenses): add currency symbol to return indicator in desktop table
13 июн 2026, 14:58
13 июн 2026, 14:58
0b715db
Код
Авторство
О чём код?
import React, { useState, useRef, useEffect, useCallback } from 'react'; import { TABLE_COLUMNS } from '../config/entities'; import { formatNumber, formatDate, formatList, formatRate, formatPeriod, formatStatus } from '../utils/formatters'; import TagCell from './TagCell'; /** Sentinel-хук для бесконечного скролла */ function useInfiniteScroll(onLoadMore, hasMore, loadingMore) { const sentinelRef = useRef(null); useEffect(() => { if (!onLoadMore || !hasMore) return; const el = sentinelRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasMore && !loadingMore) { onLoadMore(); } }, { root: null, rootMargin: '100px', threshold: 0 } ); observer.observe(el); return () => observer.disconnect(); }, [onLoadMore, hasMore, loadingMore]); return sentinelRef; } function safeRender(value) { if (value == null) return ''; if (typeof value === 'object') return ''; return value; } function CellContent({ column, value, item, lookups, entity }) { const { getAccountName, getCurrencyName, getCreditorName, getDebtorName, getExpenseCategoryName, getIncomeCategoryName, getExpenseSubcategoryName, getIncomeSubcategoryName, } = lookups; switch (column.format) { case 'number': { // Баланс счёта: зелёный (положительный), красный (отрицательный) if (entity === 'accounts' && column.key === 'balance' && typeof value === 'number') { const color = value >= 0 ? '#2e7d32' : '#c62828'; return <span style={{ color, fontWeight: 600 }}>{formatNumber(value)}</span>; } // Возвраты: показываем под суммой если были if (entity === 'expenses' && column.key === 'amount') { const totalReturns = item.totalReturns || item.total_returns || 0; const retCurrency = item.currencySymbol || item.currency_symbol || ''; return ( <div> <div>{formatNumber(value)}</div> {totalReturns > 0 && ( <div style={{ fontSize: 11, color: '#388e3c' }}>Возвращено: {formatNumber(totalReturns)} {retCurrency}</div> )} </div> ); } // Split-payment: показываем общую сумму с иконкой и разделением if (entity === 'expenses' && item.splits && item.splits.length > 0) { const total = item.totalAmount || (Number(item.amount || 0) + item.splits.reduce((s, c) => s + Number(c.amount || 0), 0)); const splitAmount = Number(item.splits[0].amount || 0); return ( <div> <div> <span style={{ fontWeight: 600 }}>{formatNumber(total)}</span> <span style={{ color: '#ff9800', marginLeft: 4, fontSize: 13 }} title="Оплата с двух счетов">÷</span> </div> <div style={{ fontSize: 11, color: '#999' }}> {formatNumber(item.amount)} / {formatNumber(splitAmount)} </div> </div> ); } if (entity === 'transfer' && item.feeType && item.feeType !== 'none' && item.deductFee) { const actual = item.actualAmount || (() => { const fee = item.feeType === 'percentage' ? Number(item.amount || 0) * Number(item.feeValue || 0) / 100 : Number(item.feeValue || 0); return (Number(item.amount || 0) - fee).toFixed(2); })(); return ( <span> <span style={{ textDecoration: 'line-through', color: '#999', marginRight: 4 }}> {formatNumber(value)} </span> <span style={{ color: '#2e7d32', fontWeight: 600 }}>{formatNumber(actual)}</span> </span> ); } return formatNumber(value); } case 'date': return formatDate(value); case 'list': return formatList(value); case 'account': { // Готовое название с сервера (FIELD_MAP: account_name → accountName) const readyName = item.accountName || item.account_name; const name = safeRender(readyName || getAccountName(value)); // Split-payment: второй счёт под основным if (entity === 'expenses' && item.splits && item.splits.length > 0) { const split = item.splits[0]; const splitName = getAccountName(split.account_id || split.account); return ( <div> <div>{name}</div> <div style={{ fontSize: 11, color: '#999' }}>{safeRender(splitName)}</div> </div> ); } return name; } case 'fromAccount': return safeRender(item.fromAccountName || item.from_account_name || getAccountName(value)); case 'toAccount': return safeRender(item.toAccountName || item.to_account_name || getAccountName(value)); case 'accountWithIcon': { const name = safeRender(value); const icon = item?.icon; return icon ? ( <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <img src={`/assets/account-icons/${icon}.png`} alt="" style={{ width: 20, height: 20, objectFit: 'contain' }} /> {name} </span> ) : name; } case 'currency': return safeRender(item.currencyCode || item.currency_code || getCurrencyName(value)); case 'creditor': return safeRender(item.creditorName || item.creditor_name || getCreditorName(value)); case 'debtor': return safeRender(item.debtorName || item.debtor_name || getDebtorName(value)); case 'expenseSubcategory': { const ready = item.subcategoryName || item.subcategory_name; const name = ready || getExpenseSubcategoryName(value); return safeRender(name && name !== value ? name : (ready || item.subcategory_name || '')); } case 'incomeSubcategory': { const ready = item.subcategoryName || item.subcategory_name; const name = ready || getIncomeSubcategoryName(value); return safeRender(name && name !== value ? name : (ready || item.subcategory_name || '')); } case 'expenseCategory': { const ready = item.categoryName || item.category_name; const name = ready || getExpenseCategoryName(value); return safeRender(name && name !== value ? name : (ready || item.category_name || '')); } case 'incomeCategory': { const ready = item.categoryName || item.category_name; const name = ready || getIncomeCategoryName(value); return safeRender(name && name !== value ? name : (ready || item.category_name || '')); } case 'rate': return formatRate(value, item?.rateType); case 'period': return formatPeriod(value); case 'tags': if (!Array.isArray(value) || value.length === 0) return ''; return <TagCell tags={value} />; case 'attachments': { const attCount = item.receiptAttachments?.length || 0; if (attCount === 0) return ''; return <span title={`${attCount} влож.`}>{'📎'.repeat(Math.min(attCount, 3))}</span>; } case 'status': // Для займов: определяем "Частично погашен" по остатку const rowAmount = item.amount || 0; const rowRemaining = item.remainingDebt || 0; return ( <span style={{ fontWeight: 600, color: value === 'closed' ? '#388e3c' : '#d32f2f', }}> {formatStatus(value, rowRemaining, rowAmount)} </span> ); case 'role': return ( <span className={`badge ${value === 'admin' ? 'badge-success' : 'badge-warning'}`}> {value === 'admin' ? 'Администратор' : 'Пользователь'} </span> ); case 'active': return ( <span className={`badge ${value === true || value === 'true' ? 'badge-success' : 'badge-warning'}`}> {value === true || value === 'true' ? 'Да' : 'Нет'} </span> ); case 'boolean': return ( <span style={{ fontWeight: 600, color: value === true || value === 'true' ? '#d32f2f' : '#388e3c', }}> {value === true || value === 'true' ? 'Да' : 'Нет'} </span> ); default: return safeRender(value); } } export default function DataTable({ entity, items, selectedId, onSelect, onDoubleClick, lookups, sortConfig, onSort, onLoadMore, loadingMore, hasMore, highlightId, duplicateId }) { const columns = TABLE_COLUMNS[entity] || []; const lastIndex = columns.length - 1; const parseWidth = (w) => { if (!w) return 120; const num = parseInt(w, 10); return isNaN(num) ? 120 : num; }; const [colWidths, setColWidths] = useState(() => { const key = `finance_col_widths_${entity}`; try { const stored = localStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed) && parsed.length === columns.length) { return parsed.map((w, i) => (i === lastIndex ? null : (typeof w === 'number' && w >= 50 ? w : 120))); } } } catch { /* ignore */ } return columns.map((col, i) => (i === lastIndex ? null : parseWidth(col.width))); }); useEffect(() => { const key = `finance_col_widths_${entity}`; try { const stored = localStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed) && parsed.length === columns.length) { setColWidths(parsed.map((w, i) => (i === lastIndex ? null : (typeof w === 'number' && w >= 50 ? w : 120)))); return; } } } catch { /* ignore */ } setColWidths(columns.map((col, i) => (i === lastIndex ? null : parseWidth(col.width)))); }, [entity, columns, lastIndex]); useEffect(() => { const key = `finance_col_widths_${entity}`; const toStore = colWidths.map((w, i) => (i === lastIndex ? null : w)); try { localStorage.setItem(key, JSON.stringify(toStore)); } catch { /* ignore */ } }, [entity, colWidths, lastIndex]); const colRefs = useRef([]); const resizing = useRef(null); const startResize = useCallback((index, e) => { const isLast = index === lastIndex; const nextIsLast = index + 1 === lastIndex; const col = colRefs.current[index]; const nextCol = colRefs.current[index + 1]; resizing.current = { index, isLast, nextIsLast, startX: e.clientX, startWidth: colWidths[index] || parseWidth(columns[index].width), startNextWidth: (!isLast && !nextIsLast && nextCol) ? (colWidths[index + 1] || parseWidth(columns[index + 1].width)) : null, }; e.preventDefault(); e.stopPropagation(); }, [colWidths, columns, lastIndex]); useEffect(() => { const handleMove = (e) => { if (!resizing.current) return; e.preventDefault(); const { index, isLast, nextIsLast, startX, startWidth, startNextWidth } = resizing.current; const delta = e.clientX - startX; const col = colRefs.current[index]; if (!col) return; if (isLast || nextIsLast) { col.style.width = Math.max(50, startWidth + delta) + 'px'; } else { const nextCol = colRefs.current[index + 1]; const total = startWidth + startNextWidth; let newCurrent = startWidth + delta; let newNext = startNextWidth - delta; if (newCurrent < 50) { newCurrent = 50; newNext = total - 50; } if (newNext < 50) { newNext = 50; newCurrent = total - 50; } col.style.width = newCurrent + 'px'; if (nextCol) nextCol.style.width = newNext + 'px'; } }; const handleUp = () => { if (!resizing.current) return; const { index, isLast, nextIsLast } = resizing.current; const newWidths = colWidths.map((w, i) => { const col = colRefs.current[i]; if (!col) return w; if (i === index && !isLast) { const parsed = parseInt(col.style.width, 10); return isNaN(parsed) ? w : Math.max(50, parsed); } if (i === index + 1 && !isLast && !nextIsLast) { const parsed = parseInt(col.style.width, 10); return isNaN(parsed) ? w : Math.max(50, parsed); } return w; }); setColWidths(newWidths); resizing.current = null; }; window.addEventListener('mousemove', handleMove); window.addEventListener('mouseup', handleUp); return () => { window.removeEventListener('mousemove', handleMove); window.removeEventListener('mouseup', handleUp); }; }, [colWidths, lastIndex]); const sentinelRef = useInfiniteScroll(onLoadMore, hasMore, loadingMore); const getSortInfo = (key) => { if (!sortConfig || sortConfig.length === 0) return { active: false, level: 0, direction: null }; const idx = sortConfig.findIndex(s => s.key === key); if (idx === -1) return { active: false, level: 0, direction: null }; return { active: true, level: idx + 1, direction: sortConfig[idx].direction }; }; const handleHeaderClick = (key, e) => { if (!onSort) return; if (e.target.closest('.resize-handle')) return; const info = getSortInfo(key); let direction = 'asc'; if (info.active && info.direction === 'asc') direction = 'desc'; onSort({ key, direction, isMulti: e.shiftKey || e.ctrlKey || e.metaKey }); }; return ( <div className="table-container"> <table className="data-table"> <colgroup> {columns.map((col, i) => ( <col key={col.key} ref={el => { colRefs.current[i] = el; }} style={i === lastIndex ? undefined : { width: (colWidths[i] || parseWidth(col.width)) + 'px' }} /> ))} </colgroup> <thead style={{ position: 'sticky', top: 0, zIndex: 10 }}> <tr style={{ background: '#f0f0f0' }}> {columns.map((col, i) => { const info = getSortInfo(col.key); return ( <th key={col.key} style={{ cursor: onSort ? 'pointer' : 'default', userSelect: 'none', position: 'relative', background: '#f0f0f0', }} onClick={(e) => handleHeaderClick(col.key, e)} title={onSort ? 'Сортировать (Shift/Ctrl — добавить уровень)' : undefined} > <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}> {col.label} {onSort && ( <span style={{ fontSize: 10, color: info.active ? '#333' : '#bbb', display: 'inline-flex', alignItems: 'center', gap: 2, }}> {info.active ? (info.direction === 'asc' ? '▲' : '▼') : '⇅'} {info.active && <span style={{ fontWeight: 700, fontSize: 9 }}>{info.level}</span>} </span> )} </span> <div className="resize-handle" onMouseDown={(e) => startResize(i, e)} title="Изменить ширину" /> </th> ); })} </tr> </thead> <tbody> <> {items.map(item => ( <tr key={item.id} data-id={item.id} className={`${selectedId === item.id ? 'selected' : ''}${highlightId === item.id ? ' new-record-highlight' : ''}${duplicateId === item.id ? ' duplicate-highlight' : ''}`} onClick={() => onSelect(item)} onDoubleClick={() => onDoubleClick(item)} > {columns.map(col => ( <td key={col.key} style={ (entity === 'loansGiven' || entity === 'loansTaken') && item.status === 'closed' && col.key !== 'status' ? { textDecoration: 'line-through', opacity: 0.6 } : {} } > <CellContent column={col} value={item[col.key]} item={item} lookups={lookups} entity={entity} /> </td> ))} </tr> ))} </> {items.length === 0 && !loadingMore && ( <tr> <td colSpan={columns.length} style={{ textAlign: 'center', padding: 30, color: '#999' }}> Нет данных </td> </tr> )} {/* Sentinel для бесконечного скролла */} {onLoadMore && ( <tr ref={sentinelRef}> <td colSpan={columns.length} style={{ textAlign: 'center', padding: 12, color: '#999', fontSize: 11 }}> {loadingMore ? 'Загрузка...' : hasMore ? '' : items.length > 0 ? 'Все записи загружены' : ''} </td> </tr> )} </tbody> </table> </div> ); }