/
Pitonx
/
MyFinance
Обзор
Документация
Войти
/
Pitonx
/
MyFinance
Код
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/components/MobileListView.js
955 строк
38 KB
agent
fix: entity name 'transfer' not 'transfers' for deposit delete protection
13 июн 2026, 22:51
13 июн 2026, 22:51
39a460c
Код
Авторство
О чём код?
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { formatDate, formatNumber } from '../utils/formatters'; /** Sentinel-хук для бесконечного скролла */ function useInfiniteScroll(onLoadMore, hasMore, loadingMore) { const sentinelRef = useRef(null); const lastLoadTime = useRef(0); const stateRef = useRef({ hasMore, loadingMore }); stateRef.current = { hasMore, loadingMore }; useEffect(() => { if (!onLoadMore) return; const el = sentinelRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { const { hasMore: h, loadingMore: l } = stateRef.current; if (!h || l) return; const now = Date.now(); if (now - lastLoadTime.current < 500) return; lastLoadTime.current = now; onLoadMore(); } }, { root: null, rootMargin: '100px', threshold: 0 } ); observer.observe(el); return () => observer.disconnect(); }, [onLoadMore]); return sentinelRef; } /** Группировка items по дате */ function _groupItemsByDate(items) { const groups = {}; items.forEach(item => { const date = item.date || item.created || ''; if (!groups[date]) groups[date] = []; groups[date].push(item); }); // Сортировка по убыванию даты return Object.entries(groups) .sort(([a], [b]) => (a < b ? 1 : -1)) .map(([date, items]) => ({ date, items })); } /** Автоматическая группировка: дата → (счёт+категория+подкатегория) → записи */ function _autoGroupItems(items, entity) { const dayGroups = {}; items.forEach(item => { const date = item.date || ''; if (!dayGroups[date]) dayGroups[date] = {}; let key; if (entity === 'transfer') { key = `${item.fromAccountId || ''}\x00${item.toAccountId || ''}\x00_\x00_`; } else if (entity === 'returns') { key = `${item.accountId || ''}\x00${item.expenseId || item.expense || ''}\x00_\x00_`; } else { key = `${item.accountId || ''}\x00${item.categoryId || ''}\x00${item.subcategoryId || '_none_'}`; } if (!dayGroups[date][key]) dayGroups[date][key] = []; dayGroups[date][key].push(item); }); return Object.entries(dayGroups) .sort(([a], [b]) => (a < b ? 1 : -1)) .map(([date, keyMap]) => ({ date, subgroups: Object.values(keyMap) .map(subItems => ({ items: subItems, count: subItems.length, total: subItems.reduce((s, it) => s + Number(it.amount || 0), 0), representative: subItems[0], })) .sort((a, b) => (b.count > 1 ? 1 : 0) - (a.count > 1 ? 1 : 0)), })); } /** Отдельная строка списка со свайпом iOS-стиля */ export function MobileListItem({ item, rowProps, entity, isClosed, selectedId, canSwipeLeft, canSwipeRight, onSelect, onDelete, onDuplicate, isActive, onActivate, onDeactivate, isHighlighted, canReorder, onReorder, dragHandleProps, }) { const contentRef = useRef(null); const leftBtnRef = useRef(null); const rightBtnRef = useRef(null); // settled state храним в ref — переживает ререндеры const settledRef = useRef({ open: false, dir: null }); const dragRef = useRef({ startX: 0, startY: 0, isDragging: false }); // Инициализация слоёв кнопок useEffect(() => { if (leftBtnRef.current) { leftBtnRef.current.style.width = '0px'; leftBtnRef.current.style.opacity = '0'; } if (rightBtnRef.current) { rightBtnRef.current.style.width = '0px'; rightBtnRef.current.style.opacity = '0'; } }, []); const animateTo = useCallback((x) => { if (contentRef.current) { contentRef.current.style.transition = 'transform 0.25s cubic-bezier(0.25, 0.1, 0.25, 1)'; contentRef.current.style.transform = `translateX(${x}px)`; } if (leftBtnRef.current) { const w = x < 0 ? Math.min(Math.abs(x), 80) : 0; leftBtnRef.current.style.width = `${w}px`; leftBtnRef.current.style.opacity = w > 2 ? '1' : '0'; } if (rightBtnRef.current) { const w = x > 0 ? Math.min(x, 80) : 0; rightBtnRef.current.style.width = `${w}px`; rightBtnRef.current.style.opacity = w > 2 ? '1' : '0'; } }, []); const applyDirect = useCallback((x) => { if (contentRef.current) { contentRef.current.style.transition = 'none'; contentRef.current.style.transform = `translateX(${x}px)`; } if (leftBtnRef.current) { const w = x < 0 ? Math.min(Math.abs(x), 80) : 0; leftBtnRef.current.style.width = `${w}px`; leftBtnRef.current.style.opacity = w > 2 ? '1' : '0'; } if (rightBtnRef.current) { const w = x > 0 ? Math.min(x, 80) : 0; rightBtnRef.current.style.width = `${w}px`; rightBtnRef.current.style.opacity = w > 2 ? '1' : '0'; } }, []); const snapBack = useCallback(() => { settledRef.current = { open: false, dir: null }; animateTo(0); onDeactivate(); }, [animateTo, onDeactivate]); const snapOpen = useCallback((dir) => { settledRef.current = { open: true, dir }; animateTo(dir === 'left' ? -80 : 80); onActivate(item.id); }, [animateTo, onActivate, item.id]); // Реакция на изменение активного свайпа извне — перенесён ПОСЛЕ объявления animateTo и snapBack useEffect(() => { if (!isActive && settledRef.current.open) { snapBack(); } }, [isActive, snapBack]); const onTouchStart = useCallback((e) => { const touch = e.touches[0]; dragRef.current = { startX: touch.clientX, startY: touch.clientY, isDragging: false }; if (contentRef.current) { contentRef.current.style.transition = 'none'; } }, []); const onTouchMove = useCallback((e) => { const d = dragRef.current; if (!d.startX) return; const touch = e.touches[0]; const dx = touch.clientX - d.startX; const dy = touch.clientY - d.startY; if (!d.isDragging) { if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) { d.isDragging = true; } else if (Math.abs(dy) > 8) { // Вертикальный скролл — отменяем drag d.startX = 0; return; } } if (!d.isDragging) return; let translate = dx; if (settledRef.current.open && settledRef.current.dir === 'left') translate -= 80; if (settledRef.current.open && settledRef.current.dir === 'right') translate += 80; if (translate > 0 && !canSwipeRight) translate = 0; if (translate < 0 && !canSwipeLeft) translate = 0; // Резиновое сопротивление за пределами максимума if (translate > 120) translate = 120 + (translate - 120) * 0.25; if (translate < -120) translate = -120 + (translate + 120) * 0.25; applyDirect(translate); }, [canSwipeLeft, canSwipeRight, applyDirect]); const onTouchEnd = useCallback((e) => { const d = dragRef.current; const touch = e.changedTouches[0]; const dx = touch.clientX - d.startX; const dy = touch.clientY - d.startY; d.isDragging = false; if (!d.startX) return; // Тап — не было драга и мало смещения if (!d.isDragging && Math.abs(dx) < 8 && Math.abs(dy) < 8) { if (settledRef.current.open) { snapBack(); } else { onSelect(item); } return; } let translate = dx; if (settledRef.current.open && settledRef.current.dir === 'left') translate -= 80; if (settledRef.current.open && settledRef.current.dir === 'right') translate += 80; if (Math.abs(translate) < 25) { snapBack(); } else if (translate < -100) { snapBack(); if (onDelete && item?.id) onDelete(item); else if (onDelete) console.error('MobileListItem swipe-delete: item.id missing', item); } else if (translate > 100) { snapBack(); if (onDuplicate && item?.id) onDuplicate(item); else if (onDuplicate) console.error('MobileListItem swipe-duplicate: item.id missing', item); } else if (translate < 0) { snapOpen('left'); } else { snapOpen('right'); } }, [snapBack, snapOpen, onSelect, onDelete, onDuplicate, item]); const handleDeleteBtn = useCallback((e) => { e.stopPropagation(); if (!item?.id) { console.error('MobileListItem: item.id missing, item =', item); alert('Ошибка: не удалось определить ID записи'); return; } snapBack(); if (onDelete) onDelete(item); }, [snapBack, onDelete, item]); const handleDuplicateBtn = useCallback((e) => { e.stopPropagation(); snapBack(); if (onDuplicate) onDuplicate(item); }, [snapBack, onDuplicate, item]); return ( <div style={{ position: 'relative', overflow: 'hidden', touchAction: 'pan-y' }}> {/* Красный слой удаления — справа */} {canSwipeLeft && ( <div ref={leftBtnRef} style={{ position: 'absolute', right: 0, top: 0, bottom: 0, background: '#d32f2f', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1, overflow: 'hidden', }} > <button type="button" onTouchEnd={handleDeleteBtn} onClick={handleDeleteBtn} style={{ color: '#fff', background: 'none', border: 'none', fontSize: 13, cursor: 'pointer', whiteSpace: 'nowrap', padding: '4px 8px', }} > Удалить </button> </div> )} {/* Зеленый слой дублирования — слева */} {canSwipeRight && ( <div ref={rightBtnRef} style={{ position: 'absolute', left: 0, top: 0, bottom: 0, background: '#388e3c', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1, overflow: 'hidden', }} > <button type="button" onTouchEnd={handleDuplicateBtn} onClick={handleDuplicateBtn} style={{ color: '#fff', background: 'none', border: 'none', fontSize: 13, cursor: 'pointer', whiteSpace: 'nowrap', padding: '4px 8px', }} > Дубль </button> </div> )} {/* Контент строки */} <div ref={contentRef} className={`mobile-item ${selectedId === item.id ? 'selected' : ''}${isHighlighted ? ' new-record-highlight' : ''}`} onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} style={{ position: 'relative', zIndex: 2, background: '#fff', touchAction: 'pan-y', userSelect: 'none', WebkitUserSelect: 'none', }} > <div className={`mobile-item-main${rowProps.isPaid ? ' mobile-item-paid' : ''}`}> <span className="mobile-item-account">{rowProps.mainLeft}</span> <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}> {rowProps.mainRightSub ? ( <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}> <span className={`mobile-item-amount${rowProps.isPaid ? ' mobile-item-paid' : ''}`} style={{ whiteSpace: 'nowrap' }}>{rowProps.mainRight}</span> <span style={{ fontSize: 11, color: rowProps.mainRightSubColor || '#888', whiteSpace: 'nowrap' }}>{rowProps.mainRightSub}</span> </div> ) : ( <span className={`mobile-item-amount${rowProps.isPaid ? ' mobile-item-paid' : ''}`} style={{ whiteSpace: 'nowrap' }}>{rowProps.mainRight}</span> )} {/* ☰ Reorder handle (HTML5 DnD indicator) */} {(dragHandleProps || canReorder) && ( <span className="reorder-handle" style={{ fontSize: 18, color: '#bbb', padding: '4px 2px', cursor: 'grab', touchAction: 'none', userSelect: 'none', WebkitUserSelect: 'none', lineHeight: 1, }} >☰</span> )} </div> </div> {(rowProps.detail || rowProps.tags || rowProps.note) && ( <div className="mobile-item-details"> {rowProps.detail && <span>{rowProps.detail}</span>} {rowProps.tags && ( <span className="mobile-item-tags">{rowProps.tags}</span> )} {rowProps.note && <span className="mobile-item-note">{rowProps.note}</span>} </div> )} </div> </div> ); } export default function MobileListView({ items, entity, lookups, selectedId, onSelect, onDoubleClick, onDelete, onDuplicate, onLoadMore, loadingMore, hasMore, groupByDate = true, highlightId, onReorder, }) { const { getAccountName, getCurrencyName, getExpenseCategoryName, getIncomeCategoryName, getExpenseSubcategoryName, getIncomeSubcategoryName, getDebtorName, getCreditorName } = lookups; const groups = useMemo(() => { let sorted = items; if (entity === 'accounts') { sorted = [...items].sort((a, b) => (a.order || 0) - (b.order || 0)); } if (!groupByDate) return [{ date: '_flat', items: sorted }]; // Автоматическая группировка для расходов/доходов/переводов if (entity === 'expenses' || entity === 'incomes' || entity === 'transfer') { return _autoGroupItems(sorted, entity); } return _groupItemsByDate(sorted); }, [items, entity, groupByDate]); const sentinelRef = useInfiniteScroll(onLoadMore, hasMore, loadingMore); // Свёрнутость по дням (по умолчанию все свёрнуты, кроме первой) const [collapsed, setCollapsed] = useState(() => { const map = {}; groups.forEach((g, i) => { map[g.date] = i !== 0; }); return map; }); // Свёрнутость по подгруппам (авто-группировка) — по умолчанию все свёрнуты const [collapsedSub, setCollapsedSub] = useState({}); // Только один открытый свайп в списке const [activeSwipeId, setActiveSwipeId] = useState(null); // Обновляем collapsed при смене групп useEffect(() => { setCollapsed(prev => { const map = { ...prev }; groups.forEach((g, i) => { if (!(g.date in map)) map[g.date] = i !== 0; }); return map; }); }, [groups]); const toggleGroup = (date) => { setCollapsed(prev => ({ ...prev, [date]: !prev[date] })); }; const toggleSub = (key) => { setCollapsedSub(prev => ({ ...prev, [key]: !prev[key] })); }; const isExpense = entity === 'expenses'; const isIncome = entity === 'incomes'; const getCategoryName = (item) => { if (isExpense) return getExpenseCategoryName(item.categoryId); if (isIncome) return getIncomeCategoryName(item.categoryId); return ''; }; const getSubcategoryName = (item) => { if (isExpense) { const fromLookup = getExpenseSubcategoryName(item.subcategoryId); return fromLookup !== item.subcategoryId ? fromLookup : (item.subcategory_name || ''); } if (isIncome) { const fromLookup = getIncomeSubcategoryName(item.subcategoryId); return fromLookup !== item.subcategoryId ? fromLookup : (item.subcategory_name || ''); } return ''; }; // Вспомогательная функция рендера одного item const renderItem = (item) => { const itemCurrency = item.currencySymbol || item.currency_symbol || (item.currencyId ? getCurrencyName(item.currencyId) : '₽'); // Готовые названия с сервера (camelCase от FIELD_MAP) — без мигания ID→name const accountName = item.accountName || item.account_name || getAccountName(item.accountId); const accountIcon = item.accountIcon || lookups.accounts?.find(a => a.id === item.accountId)?.icon; const categoryName = item.categoryName || item.category_name || getCategoryName(item); const subcategoryName = item.subcategoryName || item.subcategory_name || getSubcategoryName(item); const hasCategory = categoryName && categoryName !== item.categoryId; const hasSubcategory = subcategoryName && subcategoryName !== item.subcategoryId; const isReadOnly = entity === 'transfer' && item?.deposit; const canSwipeLeft = onDelete != null && !isReadOnly; const canSwipeRight = onDuplicate != null; const rowProps = (() => { if (entity === 'expenses' || entity === 'incomes') { const tagItems = entity === 'expenses' && item.tags?.length ? item.tags.map(t => typeof t === 'object' ? t : (lookups.tags?.find(tag => tag.id === t) || { name: t, color: '#999' })) : []; const splitAccountName = item.splits && item.splits.length > 0 ? getAccountName(item.splits[0].account_id || item.splits[0].account) : null; const totalReturns = item.totalReturns || item.total_returns || 0; return { mainLeft: ( <span style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}> {accountIcon && <img src={`/assets/account-icons/${accountIcon}.png`} alt="" style={{ width: 18, height: 18, objectFit: 'contain', marginTop: 1 }} />} <span> <div>{accountName}</div> {splitAccountName && <div style={{ fontSize: 11, color: '#999' }}>{splitAccountName}</div>} </span> </span> ), mainRight: item.splits && item.splits.length > 0 ? ( <span style={{ textAlign: 'right' }}> <div>{formatNumber(item.totalAmount || (Number(item.amount) + item.splits.reduce((s, c) => s + Number(c.amount), 0)))} {itemCurrency}</div> <div style={{ fontSize: 11, color: '#999' }}>{formatNumber(item.amount)} / {formatNumber(item.splits[0].amount)}</div> </span> ) : `${formatNumber(item.amount)} ${itemCurrency}`, mainRightSub: totalReturns > 0 ? `Возвращено: ${formatNumber(totalReturns)} ${itemCurrency}` : null, mainRightSubColor: totalReturns > 0 ? '#388e3c' : null, detail: (() => { const catText = hasSubcategory ? (hasCategory ? `${categoryName} | ${subcategoryName}` : subcategoryName) : (hasCategory ? categoryName : null); const attCount = item.receiptAttachments?.length || 0; const attText = attCount > 0 ? ' 📎'.repeat(Math.min(attCount, 3)) : ''; return catText ? catText + attText : (attText || null); })(), tags: tagItems.length > 0 ? ( <span style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}> {tagItems.map(t => ( <span key={t.id || t.name} style={{ display: 'inline-block', padding: '1px 6px', borderRadius: 10, background: t.color ? `${t.color}20` : '#f0f0f0', color: t.color || '#666', fontSize: 11, fontWeight: 500, border: `1px solid ${t.color ? `${t.color}40` : '#ddd'}`, }}>{t.name}</span> ))} </span> ) : null, note: item.note || null, }; } if (entity === 'transfer') { const isConv = item.convertedAmount != null || item.exchangeRate != null; const fromAcc = item.fromAccountName || item.from_account_name || getAccountName(item.fromAccountId); const toAcc = item.toAccountName || item.to_account_name || getAccountName(item.toAccountId); const fromIcon = lookups.accounts?.find(a => a.id === item.fromAccountId)?.icon; const toIcon = lookups.accounts?.find(a => a.id === item.toAccountId)?.icon; const convVal = item.convertedAmount; const hasConversion = Number(convVal) > 0 && Number(convVal) !== Number(item.amount); const displayAmount = hasConversion ? `${formatNumber(item.amount)} → ${formatNumber(convVal)}` : formatNumber(item.amount); return { mainLeft: ( <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 2 }}> {fromIcon && <img src={`/assets/account-icons/${fromIcon}.png`} alt="" style={{ width: 16, height: 16, objectFit: 'contain' }} />} {fromAcc || '→'} </span> <span>→</span> <span style={{ display: 'flex', alignItems: 'center', gap: 2 }}> {toIcon && <img src={`/assets/account-icons/${toIcon}.png`} alt="" style={{ width: 16, height: 16, objectFit: 'contain' }} />} {toAcc || '→'} </span> </span> ), mainRight: ( <span> {displayAmount} {itemCurrency} </span> ), detail: item.feeType !== 'none' ? ( <span style={{ fontSize: 12, color: '#666' }}> Комиссия: {item.feeType === 'percentage' ? `${item.feeValue}%` : `${item.feeValue} ${itemCurrency}`} {item.deductFee ? ' (суммы)' : ''} </span> ) : null, note: item.note, }; } if (entity === 'loansGiven') { const statusText = (() => { if (item.status === 'closed') { const remaining = item.remainingDebt ?? item.remaining_debt ?? 0; if (remaining > 0) return 'Списан'; const closedDate = item.closedDate || item.closed_date; return closedDate ? `Погашен ${closedDate}` : 'Погашен'; } return 'Не погашен'; })(); const statusColor = item.status === 'closed' ? '#388e3c' : '#d32f2f'; const remaining = item.remainingDebt ?? item.remaining_debt ?? 0; const hasRemaining = remaining > 0 && remaining < item.amount; const mainRightText = hasRemaining ? `${formatNumber(item.amount)} / ${formatNumber(remaining)} ${itemCurrency}` : `${formatNumber(item.amount)} ${itemCurrency}`; const attCount = item.receiptAttachments?.length || 0; const attText = attCount > 0 ? ' 📎'.repeat(Math.min(attCount, 3)) : ''; return { mainLeft: ( <span> {item.debtorName || getDebtorName(item.debtorId)} <span style={{ fontSize: 11, color: statusColor, marginLeft: 6 }}>{statusText}</span> </span> ), mainRight: mainRightText, detail: (item.rate ? `Ставка: ${item.rate}%` : '') + attText || null, note: item.name || item.note || null, }; } if (entity === 'loansTaken') { const statusText = (() => { if (item.status === 'closed') { const remaining = item.remainingDebt ?? item.remaining_debt ?? 0; if (remaining > 0) return 'Списан'; return 'Погашен'; } return 'Не погашен'; })(); const statusColor = item.status === 'closed' ? '#388e3c' : '#d32f2f'; const remaining = item.remainingDebt ?? item.remaining_debt ?? 0; const hasRemaining = remaining > 0 && remaining < item.amount; const mainRightText = hasRemaining ? `${formatNumber(item.amount)} / ${formatNumber(remaining)} ${itemCurrency}` : `${formatNumber(item.amount)} ${itemCurrency}`; const attCount = item.receiptAttachments?.length || 0; const attText = attCount > 0 ? ' 📎'.repeat(Math.min(attCount, 3)) : ''; return { mainLeft: ( <span> {item.creditorName || getCreditorName(item.creditorId)} <span style={{ fontSize: 11, color: statusColor, marginLeft: 6 }}>{statusText}</span> </span> ), mainRight: mainRightText, detail: (item.rate ? `Ставка: ${item.rate}%` : '') + attText || null, note: item.name || item.note || null, }; } if (entity === 'deposits') { const statusText = item.status === 'closed' ? 'Закрыт' : 'Активен'; const statusColor = item.status === 'closed' ? '#d32f2f' : '#388e3c'; const currentAmount = item.currentAmount || item.current_amount || item.amount || 0; const interestEarned = item.totalInterestEarned || item.total_interest_earned || 0; return { mainLeft: ( <span> {item.name || 'Вклад'} <span style={{ fontSize: 11, color: statusColor, marginLeft: 6 }}>{statusText}</span> </span> ), mainRight: `${formatNumber(currentAmount)} ${itemCurrency}`, mainRightSub: interestEarned > 0 ? `в т.ч. %: ${formatNumber(interestEarned)} ${itemCurrency}` : null, detail: (item.rate ? `Ставка: ${item.rate}%` : null), note: item.note || null, }; } if (entity === 'accounts') { const accCurrency = item.currencySymbol || item.currency_symbol || (item.currencyId ? getCurrencyName(item.currencyId) : ''); const iconEl = item.icon ? ( <img src={`/assets/account-icons/${item.icon}.png`} alt="" style={{ width: 18, height: 18, objectFit: 'contain' }} /> ) : ( <span style={{ width: 18, height: 18, display: 'inline-block', borderRadius: '50%', background: '#ddd' }} /> ); const orderNum = item.order != null ? `${item.order}.` : ''; return { mainLeft: ( <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <span style={{ fontWeight: 600, color: '#666', minWidth: 24 }}>{orderNum}</span> {iconEl} <span>{item.name || '—'}</span> </span> ), mainRight: ( <span style={{ color: (item.balance || 0) >= 0 ? '#2e7d32' : '#c62828', fontWeight: 600 }}> {item.balance != null ? formatNumber(item.balance) : ''} {accCurrency} </span> ), detail: item.hidden ? <span style={{ color: '#d32f2f', fontSize: 12 }}>Скрытый счёт</span> : (item.note || null), note: null, }; } if (entity === 'currencies') { return { mainLeft: `${item.name || '—'}`, mainRight: item.symbol || '', detail: item.code ? `Код: ${item.code}` : null, note: null, }; } return { mainLeft: item.name || item.code || item.username || item.email || '—', mainRight: item.symbol || (item.balance != null ? formatNumber(item.balance) : '') || (item.hidden ? 'Скрыт' : '') || '', detail: item.note || null, note: null, }; })(); const canReorderEntity = ['accounts', 'budgets', 'budgetsIncome'].includes(entity); return ( <MobileListItem key={item.id} item={item} rowProps={rowProps} entity={entity} isClosed={(entity === 'loansGiven' || entity === 'loansTaken') && item.status === 'closed'} selectedId={selectedId} canSwipeLeft={onDelete != null} canSwipeRight={onDuplicate != null} onSelect={onSelect} onDelete={onDelete} onDuplicate={onDuplicate} isActive={activeSwipeId === item.id} onActivate={setActiveSwipeId} onDeactivate={() => setActiveSwipeId(prev => prev === item.id ? null : prev)} isHighlighted={highlightId === item.id} canReorder={canReorderEntity} onReorder={onReorder} /> ); }; // Для авто-группировки: рендер подгруппы как одна строка с toggle const hasAutoGroup = entity === 'expenses' || entity === 'incomes' || entity === 'transfer' || entity === 'returns'; const renderSubgroup = (sub, dayKey) => { const key = `${dayKey}_${sub.representative.id}`; const rep = sub.representative; // Если 1 запись — показываем как обычную запись (без toggle, без ×1) if (sub.count === 1) { return ( <div key={key}> {renderItem(rep)} </div> ); } // Если >1 записей — группа с toggle const isSubCollapsed = collapsedSub[key] !== false; // по умолчанию свёрнуто // Готовые названия с сервера (camelCase от FIELD_MAP) const accountName = rep.accountName || rep.account_name || getAccountName(rep.accountId); const accountIcon = rep.accountIcon || (lookups.accounts?.find(a => a.id === rep.accountId)?.icon); const categoryName = rep.categoryName || rep.category_name || getCategoryName(rep); const subcategoryName = rep.subcategoryName || rep.subcategory_name || getSubcategoryName(rep); const hasCategory = categoryName && categoryName !== rep.categoryId; const hasSubcategory = subcategoryName && subcategoryName !== rep.subcategoryId; const expenseCatName = entity === 'returns' ? (rep.expenseCategoryName || rep.expense_category_name || '') : ''; const catText = entity === 'returns' && expenseCatName ? expenseCatName : hasSubcategory ? (hasCategory ? `${categoryName} | ${subcategoryName}` : subcategoryName) : (hasCategory ? categoryName : ''); return ( <div key={key} style={{ borderBottom: '1px solid #f0efe9' }}> {/* Шапка подгруппы */} <div onClick={() => toggleSub(key)} style={{ display: 'flex', alignItems: 'center', padding: '10px 12px', background: isSubCollapsed ? '#fff' : '#fafaf7', cursor: 'pointer', }} > <span style={{ fontSize: 12, marginRight: 8, color: '#999' }}> {isSubCollapsed ? '▶\uFE0E' : '▼\uFE0E'} </span> <span style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, gap: 2 }}> {/* Счёт (переводы: откуда → куда) */} {entity === 'transfer' ? ( <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> {(() => { const fromAcc = rep.fromAccountName || rep.from_account_name || getAccountName(rep.fromAccountId); const toAcc = rep.toAccountName || rep.to_account_name || getAccountName(rep.toAccountId); const fromIcon = rep.fromAccountIcon || (lookups.accounts?.find(a => a.id === rep.fromAccountId)?.icon); const toIcon = rep.toAccountIcon || (lookups.accounts?.find(a => a.id === rep.toAccountId)?.icon); return ( <> <span style={{ display: 'flex', alignItems: 'center', gap: 2 }}> {fromIcon && <img src={`/assets/account-icons/${fromIcon}.png`} alt="" style={{ width: 16, height: 16, objectFit: 'contain' }} />} <span style={{ fontSize: 13, color: '#ff9800' }}>{fromAcc}</span> </span> <span style={{ color: '#999' }}>→</span> <span style={{ display: 'flex', alignItems: 'center', gap: 2 }}> {toIcon && <img src={`/assets/account-icons/${toIcon}.png`} alt="" style={{ width: 16, height: 16, objectFit: 'contain' }} />} <span style={{ fontSize: 13, color: '#ff9800' }}>{toAcc}</span> </span> </> ); })()} </span> ) : ( <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> {accountIcon && ( <img src={`/assets/account-icons/${accountIcon}.png`} alt="" style={{ width: 18, height: 18, objectFit: 'contain' }} /> )} <span style={{ fontSize: 13, color: '#ff9800', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> {accountName} </span> </span> )} {/* Категория/подкатегория под счётом (только расходы/доходы) */} {catText && entity !== 'transfer' && ( <span style={{ fontSize: 11, color: '#888', paddingLeft: accountIcon ? 24 : 0 }}> {catText} </span> )} </span> {/* ×N */} <span style={{ fontSize: 11, color: '#888', background: '#f0f0f0', borderRadius: 8, padding: '1px 6px', marginRight: 8, whiteSpace: 'nowrap', }}> ×{sub.count} </span> {/* Сумма */} <span style={{ fontSize: 14, fontWeight: 600, color: '#ff9800', whiteSpace: 'nowrap' }}> {formatNumber(sub.total)} {sub.representative.currencySymbol || sub.representative.currency_symbol || '₽'} </span> </div> {/* Раскрытые записи: компактный вид со свайпом */} {!isSubCollapsed && ( <div style={{ background: '#fafaf7' }}> {sub.items.map(item => { const itCurrency = item.currencySymbol || item.currency_symbol || (item.currencyId ? getCurrencyName(item.currencyId) : '₽'); const attCount = item.receiptAttachments?.length || 0; const tagItems = entity === 'expenses' && item.tags?.length ? item.tags.map(t => typeof t === 'object' ? t : (lookups.tags?.find(tag => tag.id === t) || { name: t, color: '#999' })) : []; const compactRowProps = { mainLeft: ( <span style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden', fontSize: 13, color: '#333', fontWeight: 'normal' }}> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontStyle: 'italic' }}> {item.note || <span style={{ color: '#bbb' }}>—</span>} </span> {attCount > 0 && ( <span title={`${attCount} влож.`}>{'📎'.repeat(Math.min(attCount, 3))}</span> )} </span> ), mainRight: ( <span style={{ fontSize: 13, color: '#333', fontWeight: 'normal' }}> {formatNumber(item.amount)} {itCurrency} </span> ), tags: tagItems.length > 0 ? ( <span style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}> {tagItems.map(t => ( <span key={t.id || t.name} style={{ display: 'inline-block', padding: '1px 6px', borderRadius: 10, background: t.color ? `${t.color}20` : '#f0f0f0', color: t.color || '#666', fontSize: 11, fontWeight: 500, border: `1px solid ${t.color ? `${t.color}40` : '#ddd'}`, }}>{t.name}</span> ))} </span> ) : null, }; return ( <MobileListItem key={item.id} item={item} rowProps={compactRowProps} entity={entity} selectedId={selectedId} canSwipeLeft={onDelete != null} canSwipeRight={onDuplicate != null} onSelect={onSelect} onDelete={onDelete} onDuplicate={onDuplicate} isActive={activeSwipeId === item.id} onActivate={setActiveSwipeId} onDeactivate={() => setActiveSwipeId(prev => prev === item.id ? null : prev)} isHighlighted={highlightId === item.id} /> ); })} </div> )} </div> ); }; return ( <div className="mobile-list-view"> {groups.map(group => { const isCollapsed = collapsed[group.date]; const isLoanEntity = entity === 'loansGiven' || entity === 'loansTaken'; const allItems = group.subgroups ? group.subgroups.flatMap(s => s.items) : group.items; const dayTotal = allItems.reduce((sum, it) => { if (isLoanEntity) { return sum + (Number(it.remainingDebt ?? it.remaining_debt ?? 0) || 0); } return sum + (Number(it.amount) || 0); }, 0); const firstItem = allItems[0]; const currencyCode = firstItem?.currencySymbol || firstItem?.currency_symbol || (firstItem?.currencyId ? getCurrencyName(firstItem.currencyId) : '₽'); return ( <div key={group.date} className="mobile-day-group"> {/* Шапка дня */} {groupByDate && ( <div className="mobile-day-header" onClick={() => toggleGroup(group.date)} > <span className="mobile-day-toggle"> {isCollapsed ? '▼\uFE0E' : '▲\uFE0E'} </span> <span className="mobile-day-date"> {formatDate(group.date)} </span> <span className="mobile-day-total"> {formatNumber(dayTotal)} {currencyCode} </span> </div> )} {/* Операции дня */} {!isCollapsed && ( <div className="mobile-day-items"> {hasAutoGroup && group.subgroups ? group.subgroups.map(sub => renderSubgroup(sub, group.date)) : group.items.map(item => renderItem(item)) } </div> )} </div> ); })} {/* Sentinel для бесконечного скролла */} {onLoadMore && ( <div ref={sentinelRef} className="mobile-sentinel"> {loadingMore ? 'Загрузка...' : hasMore ? '' : items.length > 0 ? 'Все записи загружены' : ''} </div> )} {items.length === 0 && !loadingMore && ( <div className="mobile-empty">Нет данных</div> )} </div> ); }