/
Pitonx
/
MyFinance
Обзор
Документация
Войти
/
Pitonx
/
MyFinance
Код
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/components/EntityList.js
741 строка
34 KB
agent
v15.5.1 — fix: Сбросить фильтры вызывает handleSearch вместо setLocalFilters
14 июн 2026, 20:56
14 июн 2026, 20:56
7646ca5
Код
Авторство
О чём код?
import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { useEntityData } from '../hooks/useEntityData'; import { useLookupData, invalidateLookupsCache } from '../hooks/useLookupData'; import { showToast } from '../components/Toast'; import { ICONS } from '../config/entities'; import { refreshIdempotencyKey, restApi as api } from '../services/restApi'; import { cacheApi } from '../services/cacheApi'; import DataTable from './DataTable'; import SearchForm from './SearchForm'; import EntityModal from './EntityModal'; import CategoryManager from './CategoryManager'; import MobileListView from './MobileListView'; import DraggableMobileList from './DraggableMobileList'; /** Локальная фильтрация (для справочников — клиентская) */ function applyLocalFilters(items, filters) { return items.filter(item => { if (filters.dateFrom && item.date < filters.dateFrom) return false; if (filters.dateTo && item.date > filters.dateTo) return false; if (filters.amountFrom && Number(item.amount) < Number(filters.amountFrom)) return false; if (filters.amountTo && Number(item.amount) > Number(filters.amountTo)) return false; if (filters.accountId && item.accountId !== filters.accountId) return false; if (filters.categoryId && item.categoryId !== filters.categoryId) return false; if (filters.categoryName && (item.categoryName || item.category_name) !== filters.categoryName) return false; if (filters.subcategoryId && item.subcategoryId !== filters.subcategoryId) return false; if (filters.status && item.status !== filters.status) return false; if (filters.creditorId && item.creditorId !== filters.creditorId) return false; if (filters.debtorId && item.debtorId !== filters.debtorId) return false; if (filters.login && !item.login?.toLowerCase().includes(filters.login.toLowerCase())) return false; if (filters.name && !item.name?.toLowerCase().includes(filters.name.toLowerCase())) return false; if (filters.role && item.role !== filters.role) return false; if (filters.active && String(item.active) !== filters.active) return false; return true; }); } function normalizeValue(raw) { if (raw == null) return null; const str = String(raw); if (/^\d{4}-\d{2}-\d{2}/.test(str)) { const ts = new Date(raw).getTime(); if (!isNaN(ts)) return ts; } const num = parseFloat(str); if (!isNaN(num) && /^-?\d+(\.\d+)?$/.test(str)) return num; return str.toLowerCase(); } function compareValues(a, b, direction) { if (a === null && b === null) return 0; if (a === null) return direction === 'asc' ? -1 : 1; if (b === null) return direction === 'asc' ? 1 : -1; if (typeof a !== typeof b) { a = String(a); b = String(b); } if (typeof a === 'number') { if (a < b) return direction === 'asc' ? -1 : 1; if (a > b) return direction === 'asc' ? 1 : -1; return 0; } if (typeof a === 'string') { const cmp = a.localeCompare(b, 'ru'); return direction === 'asc' ? cmp : -cmp; } if (a < b) return direction === 'asc' ? -1 : 1; if (a > b) return direction === 'asc' ? 1 : -1; return 0; } function applySort(items, sortConfigs) { if (!sortConfigs || sortConfigs.length === 0) return items; const indexed = items.map((item, index) => ({ item, index })); indexed.sort((a, b) => { for (const { key, direction } of sortConfigs) { const cmp = compareValues(normalizeValue(a.item[key]), normalizeValue(b.item[key]), direction); if (cmp !== 0) return cmp; } return a.index - b.index; }); return indexed.map(({ item }) => item); } export default function EntityList({ entity, onClose, operationalDate, onCreateReturn, drilldownFilters, onDrilldownApplied }) { const { items, setItems, create, update, remove, loading, loadingMore, error, totalCount, hasMore, loadMore, applyFilters, filters: serverFilters, totals, refresh, } = useEntityData(entity); const lookups = useLookupData(); const [selected, setSelected] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [modalItem, setModalItem] = useState(null); const [modalDefaults, setModalDefaults] = useState(null); const [sortConfig, setSortConfig] = useState(null); const [localFilters, setLocalFilters] = useState({}); // Применяем drilldown-фильтры из отчётов useEffect(() => { if (drilldownFilters) { setLocalFilters(prev => ({ ...prev, categoryId: drilldownFilters.categoryId || prev.categoryId, dateFrom: drilldownFilters.dateFrom, dateTo: drilldownFilters.dateTo, })); setShowFilters(false); // фильтр свернут } }, [drilldownFilters]); const [showFilters, setShowFilters] = useState(false); const [isMobile, setIsMobile] = useState(() => window.matchMedia('(max-width: 768px)').matches); // groupByCategory удалён — группировка теперь автоматическая в MobileListView // ID только что созданной записи — для переливающейся рамки const [highlightId, setHighlightId] = useState(null); const [duplicateId, setDuplicateId] = useState(null); const highlightTimerRef = React.useRef(null); // SubmitGuard: блокировка UI на время любой CRUD-операции const [isProcessing, setIsProcessing] = useState(false); const [elapsed, setElapsed] = useState(0); // Таймер ожидания ответа от сервера useEffect(() => { if (!isProcessing) { setElapsed(0); return; } const timer = setInterval(() => setElapsed(s => s + 1), 1000); return () => clearInterval(timer); }, [isProcessing]); useEffect(() => { const mq = window.matchMedia('(max-width: 768px)'); const handler = (e) => setIsMobile(e.matches); mq.addEventListener('change', handler); return () => mq.removeEventListener('change', handler); }, []); const isDocumentsEntity = ['expenses', 'incomes', 'transfer', 'loansGiven', 'deposits', 'loansTaken'].includes(entity); const useMobileView = isMobile; const isCategory = entity === 'expenseCategories' || entity === 'incomeCategories' || entity === 'expenseSubcategories' || entity === 'incomeSubcategories'; const catType = entity === 'expenseCategories' || entity === 'expenseSubcategories' ? 'expense' : 'income'; const isSmallEntity = ['currencies', 'accounts', 'expenseCategories', 'incomeCategories', 'expenseSubcategories', 'incomeSubcategories', 'creditors', 'debtors', 'users'].includes(entity); const displayItems = useMemo(() => { if (isSmallEntity) { const filtered = applyLocalFilters(items, localFilters); return applySort(filtered, sortConfig); } return applySort(items, sortConfig); }, [items, localFilters, sortConfig, isSmallEntity]); // Суммы по валютам из бэкенда (totals) — не меняются при скролле const totalsSorted = useMemo(() => { const entries = Object.entries(totals || {}); // Маппинг code → symbol через справочник валют const withSymbols = entries.map(([code, sum]) => { const symbol = lookups.currencies?.find(c => c.code === code)?.symbol || code; return [symbol, sum]; }); // ₽ всегда первый, остальные по убыванию суммы const rub = withSymbols.find(([sym]) => sym === '₽'); const others = withSymbols.filter(([sym]) => sym !== '₽').sort((a, b) => b[1] - a[1]); return rub ? [rub, ...others] : others; }, [totals, lookups.currencies]); const handleSearch = useCallback((newFilters) => { setLocalFilters(newFilters); if (!isSmallEntity) applyFilters(newFilters); }, [applyFilters, isSmallEntity]); const handleSort = useCallback((newSort) => { setSortConfig(prev => { const prevArr = prev || []; if (!newSort.isMulti) return [{ key: newSort.key, direction: newSort.direction }]; const existingIndex = prevArr.findIndex(s => s.key === newSort.key); if (existingIndex >= 0) { const next = [...prevArr]; if (next[existingIndex].direction === newSort.direction) next.splice(existingIndex, 1); else next[existingIndex] = { key: newSort.key, direction: newSort.direction }; return next.length ? next : null; } const next = [...prevArr, { key: newSort.key, direction: newSort.direction }]; if (next.length > 3) next.shift(); return next; }); }, []); const handleAdd = useCallback(() => { setModalItem(null); setModalDefaults(null); setModalOpen(true); }, []); const handleEdit = useCallback((item) => { setSelected(item || selected); setModalItem(item || selected); setModalDefaults(null); setModalOpen(true); }, [selected]); const handleDuplicate = useCallback((item) => { if (!item) return; const today = new Date().toISOString().split('T')[0]; const data = { ...item }; delete data.id; delete data.created; delete data.modified; if (data.date !== today) { data.date = today; } if (data.tags && !data.tagIds) { data.tagIds = data.tags.map(t => t.id); } delete data.tags; setSelected(null); setModalItem(null); setModalDefaults(data); setModalOpen(true); }, []); const handleDelete = useCallback(async (itemOrEvent) => { if (isProcessing) return; const target = (itemOrEvent && itemOrEvent.id) ? itemOrEvent : selected; if (!target) return; if (!target.id) { console.error('handleDelete: target.id is missing. target =', target, 'itemOrEvent =', itemOrEvent, 'selected =', selected); alert('Ошибка: не удалось определить ID записи для удаления'); return; } if (entity === 'transfer' && target?.deposit) { alert('🔒 Этот перевод создан из операции вклада. Удаление недоступно — управляйте операциями через форму вклада.'); return; } const isTrashEntity = ['expenses', 'incomes', 'transfer', 'loansGiven', 'loansTaken', 'deposits', 'returns', 'budgets', 'budgetsIncome'].includes(entity); const msg = entity === 'transfer' ? 'Удалить перевод? Будет также удалена связанная операция расхода на комиссию.' : isTrashEntity ? 'Переместить в корзину? Операцию можно будет восстановить.' : 'Удалить выбранную запись?'; if (window.confirm(msg)) { setIsProcessing(true); try { // Причина удаления (опционально) let deleteReason = ''; if (isTrashEntity) { deleteReason = window.prompt('Причина удаления (необязательно):') || ''; } await remove(target.id, deleteReason); if (!itemOrEvent) setSelected(null); if (isTrashEntity) { showToast('✅ Операция перемещена в корзину. Восстановить можно из раздела «Корзина».'); } else { showToast('✅ Запись удалена.'); } } catch (err) { if (err.message?.includes('409') || err.message?.includes('Conflict')) { if (entity === 'tags') { alert('⚠️ Невозможно удалить тег, так как он используется в расходах.\n\nЧтобы удалить тег:\n1. Найдите все расходы с этим тегом (фильтр по тегу)\n2. Удалите тег из каждого расхода (редактирование)\n3. Повторите удаление'); } else { alert('Невозможно удалить, так как по этому счету есть операции. Удалите сначала все операции.'); } } else { alert('Ошибка при удалении: ' + err.message); } } finally { setIsProcessing(false); } } }, [selected, remove, entity, isProcessing]); // Ручной порядок: ↑ ↓ для accounts/budgets const { canMoveUp, canMoveDown } = useMemo(() => { if (!selected || !displayItems.length) return { canMoveUp: false, canMoveDown: false }; const idx = displayItems.findIndex(i => i.id === selected.id); return { canMoveUp: idx > 0, canMoveDown: idx >= 0 && idx < displayItems.length - 1 }; }, [selected, displayItems]); const handleReorder = useCallback(async (fromIndex, toIndex) => { if (isProcessing) return; if (fromIndex < 0 || toIndex < 0) return; if (fromIndex >= displayItems.length || toIndex >= displayItems.length) return; // Меняем местами локально const next = [...displayItems]; [next[fromIndex], next[toIndex]] = [next[toIndex], next[fromIndex]]; setItems(next); setSelected(next[toIndex]); // Отправляем новый порядок на сервер const orderList = next.map(i => i.id); try { const apiName = entity === 'accounts' ? 'accounts' : entity === 'budgets' ? 'budgets' : 'budgetIncomes'; await api[apiName].reorder(orderList); } catch (e) { console.error('[reorder] failed:', e); showToast('Ошибка сохранения порядка'); refresh(); } }, [displayItems, isProcessing, entity, setItems, refresh]); // Мобильный тап → сразу редактирование (для документов и справочников) const handleMobileSelect = useCallback((item) => { setSelected(item); if (isMobile && (isDocumentsEntity || isSmallEntity)) { setModalItem(item); setModalDefaults(null); setModalOpen(true); } }, [isMobile, isDocumentsEntity, isSmallEntity]); const triggerHighlight = useCallback((id) => { if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); setHighlightId(id); highlightTimerRef.current = setTimeout(() => setHighlightId(null), 4000); }, []); const handleSave = useCallback(async (formData) => { if (isProcessing) return; setIsProcessing(true); try { // Автоподстановка currencyId из accountId (для расходов/доходов/переводов) const data = { ...formData }; if (!data.currencyId && data.accountId && lookups.accounts) { const account = lookups.accounts.find(a => a.id === data.accountId); if (account && account.currencyId) { data.currencyId = account.currencyId; } } if (!data.currencyId && data.fromAccountId && lookups.accounts) { const account = lookups.accounts.find(a => a.id === data.fromAccountId); if (account && account.currencyId) { data.currencyId = account.currencyId; } } let result; if (modalItem) { result = await update(modalItem.id, data); } else { result = await create(data); if (result?.id) { // Проверка на дубликат: сегодня + тот же счёт + сумма + кат + подкат const today = new Date().toISOString().split('T')[0]; const isDup = items.some(it => it.id !== result.id && it.date === today && it.accountId === result.accountId && String(it.amount) === String(result.amount) && it.categoryId === result.categoryId && it.subcategoryId === result.subcategoryId ); if (isDup) { setDuplicateId(result.id); setTimeout(() => setDuplicateId(null), 4000); } else { triggerHighlight(result.id); } } // Инвалидируем кэш расходов при создании перевода (комиссия → автоматический расход) if (entity === 'transfers' && result?.fee > 0) { cacheApi.invalidate('expenses'); } } // Загрузка/удаление фото чека (expenses + loans) const hasReceipts = ['expenses', 'loansGiven', 'loansTaken'].includes(entity); if (hasReceipts && result?.id) { const _receiptFiles = formData._receiptFiles || formData.receiptFiles; const _fileNotes = formData._fileNotes || formData.fileNotes; const _deletedReceiptPhotos = formData._deletedReceiptPhotos || formData.deletedReceiptPhotos; const apiName = entity === 'loansGiven' ? 'loansGiven' : entity === 'loansTaken' ? 'loansTaken' : 'expenses'; if (_deletedReceiptPhotos?.length) { for (const pid of _deletedReceiptPhotos) { try { await api[apiName].deleteReceipt(result.id, pid); } catch (e) {} } } if (_receiptFiles?.length) { const notes = _receiptFiles.map((_, i) => _fileNotes?.[i] || ''); try { await api[apiName].uploadReceipt(result.id, _receiptFiles, notes); } catch (e) {} } } setModalOpen(false); setModalItem(null); } catch (err) { const msg = err?.detail || err?.message || String(err); showToast('Ошибка сохранения: ' + msg, 'error', 8000); throw err; } finally { setIsProcessing(false); } }, [modalItem, create, update, lookups.accounts, triggerHighlight, isProcessing]); const handleSaveOnly = useCallback(async (formData, options = {}) => { if (isProcessing) return; setIsProcessing(true); try { let result; // Сохранить без закрытия модалки const data = { ...formData }; if (!data.currencyId && data.accountId && lookups.accounts) { const account = lookups.accounts.find(a => a.id === data.accountId); if (account && account.currencyId) { data.currencyId = account.currencyId; } } // Для вкладов: убираем deprecated поля, валидируем amount if (entity === 'deposits') { delete data.sourceAccountId; delete data.targetAccountId; if (!data.amount || String(data.amount).trim() === '' || Number(data.amount) <= 0) { alert('Введите сумму вклада'); setIsProcessing(false); return; } } // Если id есть — обновляем существующую запись (повторное нажатие) if (data.id) { const updated = await update(data.id, data); refreshIdempotencyKey(); // Загрузка/удаление фото чека (expenses + loans) const hasReceipts = ['expenses', 'loansGiven', 'loansTaken'].includes(entity); if (hasReceipts && updated?.id) { const _receiptFiles = formData._receiptFiles || formData.receiptFiles; const _fileNotes = formData._fileNotes || formData.fileNotes; const _deletedReceiptPhotos = formData._deletedReceiptPhotos || formData.deletedReceiptPhotos; const apiName = entity === 'loansGiven' ? 'loansGiven' : entity === 'loansTaken' ? 'loansTaken' : 'expenses'; let needRefresh = false; if (_deletedReceiptPhotos?.length) { for (const pid of _deletedReceiptPhotos) { try { await api[apiName].deleteReceipt(updated.id, pid); needRefresh = true; } catch (e) {} } } if (_receiptFiles?.length) { const notes = _receiptFiles.map((_, i) => _fileNotes?.[i] || ''); try { await api[apiName].uploadReceipt(updated.id, _receiptFiles, notes); needRefresh = true; } catch (e) {} } if (needRefresh) { try { const fresh = await api[apiName].getById(updated.id); if (fresh) { const updatedItem = { ...updated, receiptAttachments: fresh.receiptAttachments || [] }; setItems(prev => prev.map(i => i.id === updatedItem.id ? updatedItem : i)); setModalItem(updatedItem); } } catch (e) {} refresh(); } else if (updated && !options.addAnother) { setModalItem(updated); } } else if (updated && !options.addAnother) { setModalItem(updated); } return updated; } // Иначе создаём новую const newItem = await create(data); if (newItem && newItem.id) { // Загрузка/удаление фото чека (expenses + loans) let needRefresh = false; const hasReceiptsCreate = ['expenses', 'loansGiven', 'loansTaken'].includes(entity); if (hasReceiptsCreate) { const _receiptFiles = formData._receiptFiles || formData.receiptFiles; const _fileNotes = formData._fileNotes || formData.fileNotes; const _deletedReceiptPhotos = formData._deletedReceiptPhotos || formData.deletedReceiptPhotos; const apiName = entity === 'loansGiven' ? 'loansGiven' : entity === 'loansTaken' ? 'loansTaken' : 'expenses'; if (_deletedReceiptPhotos?.length) { for (const pid of _deletedReceiptPhotos) { try { await api[apiName].deleteReceipt(newItem.id, pid); needRefresh = true; } catch (e) {} } } if (_receiptFiles?.length) { const notes = _receiptFiles.map((_, i) => _fileNotes?.[i] || ''); try { await api[apiName].uploadReceipt(newItem.id, _receiptFiles, notes); needRefresh = true; } catch (e) {} } } if (needRefresh) { // Для CREATE refresh() НЕ вызываем — useEntityData.create уже // добавил newItem в items локально. refresh() перезаписал бы // items с сервера (race condition → запись пропадает). // Вместо этого подтягиваем вложения через getById и обновляем // items иммутабельно через setItems (newItem.receiptAttachments= // мутация — React не видит изменение). const apiName = entity === 'loansGiven' ? 'loansGiven' : entity === 'loansTaken' ? 'loansTaken' : 'expenses'; try { const fresh = await api[apiName].getById(newItem.id); if (fresh) { const updatedItem = { ...newItem, receiptAttachments: fresh.receiptAttachments || [] }; setItems(prev => prev.map(i => i.id === updatedItem.id ? updatedItem : i)); if (options.addAnother) { triggerHighlight(updatedItem.id); } else { setModalItem(updatedItem); } } } catch (e) {} } else { if (options.addAnother) { // "Сохранить и добавить ещё" const { id, amount, subcategoryId, note, tagIds, tags, ...preserved } = data; setModalItem(null); setModalDefaults(preserved); } else { setModalItem(newItem); } triggerHighlight(newItem.id); } // Новый ключ для следующей операции (важно для "Сохранить и добавить ещё") refreshIdempotencyKey(); invalidateLookupsCache(); return newItem; } } catch (err) { const msg = err?.detail || err?.message || String(err); showToast('Ошибка сохранения: ' + msg, 'error', 8000); throw err; } finally { setIsProcessing(false); } }, [create, update, lookups.accounts, triggerHighlight, isProcessing, refresh, setItems]); return ( <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'hidden' }}> {isProcessing && ( <div style={{ position: 'fixed', inset: 0, background: 'rgba(255,255,255,0.7)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', zIndex: 100 }}> <div style={{ width: 36, height: 36, border: '3px solid #ff9800', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 1s linear infinite' }} /> <span style={{ marginTop: 8, fontSize: 13, color: '#666' }}>Ожидание… {elapsed} с</span> </div> )} {/* Toolbar */} {!(isCategory && isMobile) && ( <div className="toolbar"> <button className="btn btn-success" onClick={handleAdd} disabled={isProcessing}> <span style={{ fontSize: 14 }}>{ICONS.create}</span> Создать </button> {/* Импорт перенесен в Настройки */} {/* Мобильная версия документов: только Создать + Фильтры */} {/* Мобильная версия справочников: только Создать (тап = редактирование, свайп = удаление) */} {isMobile && isDocumentsEntity && ( <button className="btn" onClick={() => setShowFilters(!showFilters)} disabled={isProcessing}> <span style={{ fontSize: 14 }}>{ICONS.search}</span> Фильтры {showFilters ? '▲' : '▼'} </button> )} {(!isMobile || !isDocumentsEntity) && ( <> <button className="btn" onClick={() => selected && handleEdit(selected)} disabled={!selected || isProcessing}> <span style={{ fontSize: 14 }}>{ICONS.edit}</span> Изменить </button> <button className="btn btn-danger" onClick={handleDelete} disabled={!selected || isProcessing}> <span style={{ fontSize: 14 }}>{ICONS.delete}</span> Удалить </button> </> )} {/* Reorder buttons for accounts/budgets — desktop only */} {!isMobile && ['accounts', 'budgets', 'budgetsIncome'].includes(entity) && ( <> <button className="btn" title="Вверх" onClick={() => { const idx = displayItems.findIndex(i => i.id === selected?.id); if (idx > 0) handleReorder(idx, idx - 1); }} disabled={!selected || isProcessing || !canMoveUp}> <span style={{ fontSize: 14 }}>↑</span> </button> <button className="btn" title="Вниз" onClick={() => { const idx = displayItems.findIndex(i => i.id === selected?.id); if (idx >= 0 && idx < displayItems.length - 1) handleReorder(idx, idx + 1); }} disabled={!selected || isProcessing || !canMoveDown}> <span style={{ fontSize: 14 }}>↓</span> </button> </> )} {/* Итого */} <span style={{ marginLeft: 'auto', fontSize: 11, color: '#666', display: 'flex', alignItems: 'center', gap: 6 }}> {totalsSorted.length > 0 && ( <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}> {totalsSorted.map(([code, sum], idx) => ( <React.Fragment key={code}> {idx > 0 && <span style={{ color: '#bbb' }}>|</span>} <span style={{ fontWeight: 600, color: '#333' }}> {Number(sum).toLocaleString('ru-RU')} <span style={{ color: '#666', fontWeight: 400 }}>{code}</span> </span> </React.Fragment> ))} </span> )} <span style={{ color: '#999' }}>·</span> <span>{displayItems.length} из {isSmallEntity ? displayItems.length : totalCount}</span> </span> </div> )} {/* Активные фильтры — чипы (когда фильтр свернут) */} {!showFilters && !isCategory && ( <ActiveFilterChips filters={localFilters} lookups={lookups} entity={entity} onClear={() => handleSearch({})} onShowFilters={() => setShowFilters(true)} /> )} {/* Search — в мобильной документы показывается только по кнопке Фильтры */} {!isCategory && !(isMobile && isDocumentsEntity && !showFilters) && ( <SearchForm entity={entity} onSearch={handleSearch} filters={localFilters} /> )} {/* Table or CategoryManager or MobileListView */} {isCategory && isMobile ? ( <div style={{ flex: 1, overflow: 'auto', padding: 0 }}> <CategoryManager type={catType} onClose={onClose || (() => {})} /> </div> ) : useMobileView ? ( <div style={{ flex: 1, overflow: 'auto' }}> {['accounts', 'budgets', 'budgetsIncome'].includes(entity) ? ( <DraggableMobileList entity={entity} items={displayItems} selectedId={selected?.id} onSelect={handleMobileSelect} onReorder={handleReorder} onDelete={handleDelete} onDuplicate={undefined} lookups={lookups} highlightId={highlightId} /> ) : ( <MobileListView entity={entity} items={displayItems} selectedId={selected?.id} onSelect={handleMobileSelect} onDoubleClick={handleEdit} onDelete={handleDelete} onDuplicate={entity === 'expenses' ? handleDuplicate : undefined} lookups={lookups} groupByDate={isDocumentsEntity} onLoadMore={isSmallEntity ? null : loadMore} loadingMore={loadingMore} hasMore={hasMore} highlightId={highlightId} duplicateId={duplicateId} /> )} {loading && displayItems.length === 0 && ( <div style={{ textAlign: 'center', padding: 20, color: '#999', fontSize: 12 }}>Загрузка...</div> )} {error && ( <div style={{ textAlign: 'center', padding: 20, color: '#c00', fontSize: 12 }}> {error?.includes('Сессия истекла') || error?.includes('401') ? 'Сессия истекла или токен недействителен. Пожалуйста, авторизуйтесь заново.' : `Ошибка: ${error}`} </div> )} </div> ) : ( <> <DataTable entity={entity} items={displayItems} selectedId={selected?.id} onSelect={setSelected} onDoubleClick={handleEdit} lookups={lookups} sortConfig={sortConfig} onSort={handleSort} onLoadMore={isSmallEntity ? null : loadMore} loadingMore={loadingMore} hasMore={hasMore} highlightId={highlightId} duplicateId={duplicateId} /> {loading && displayItems.length === 0 && ( <div style={{ textAlign: 'center', padding: 20, color: '#999', fontSize: 12 }}>Загрузка...</div> )} {error && ( <div style={{ textAlign: 'center', padding: 20, color: '#c00', fontSize: 12 }}> {error?.includes('Сессия истекла') || error?.includes('401') ? 'Сессия истекла или токен недействителен. Пожалуйста, авторизуйтесь заново.' : `Ошибка: ${error}`} </div> )} </> )} {/* Modal */} {modalOpen && !(isCategory && isMobile) && ( <EntityModal operationalDate={operationalDate} entity={entity} item={modalItem} defaults={modalDefaults} onClose={() => { setModalOpen(false); setModalItem(null); setModalDefaults(null); }} onSave={handleSave} onSaveOnly={handleSaveOnly} onCreateReturn={onCreateReturn} /> )} </div> ); } // ── ActiveFilterChips — показывает активные фильтры чипами ── function ActiveFilterChips({ filters, lookups, entity, onClear, onShowFilters }) { const chips = []; // Категория if (filters.categoryId) { const catList = entity === 'incomes' ? lookups.incomeCategories : lookups.expenseCategories; const name = catList?.find(c => String(c.id) === String(filters.categoryId))?.name || filters.categoryId; chips.push({ label: `Категория: ${name}`, key: 'categoryId' }); } // Счёт if (filters.accountId) { const name = lookups.accounts?.find(a => String(a.id) === String(filters.accountId))?.name || filters.accountId; chips.push({ label: `Счёт: ${name}`, key: 'accountId' }); } // Даты if (filters.dateFrom || filters.dateTo) { const from = filters.dateFrom || ''; const to = filters.dateTo || ''; chips.push({ label: `Дата: ${from} — ${to}`, key: 'dates' }); } // Сумма if (filters.amountFrom || filters.amountTo) { const from = filters.amountFrom || ''; const to = filters.amountTo || ''; chips.push({ label: `Сумма: ${from} — ${to}`, key: 'amount' }); } if (chips.length === 0) return null; return ( <div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 16px', background: '#fff3e0', borderBottom: '1px solid #ffe0b2', flexWrap: 'wrap' }}> <span style={{ fontSize: 12, color: '#e65100' }}>🔍</span> {chips.map(chip => ( <span key={chip.key} style={{ fontSize: 11, padding: '2px 8px', background: '#fff', border: '1px solid #ffb74d', borderRadius: 12, color: '#e65100', whiteSpace: 'nowrap', }}> {chip.label} </span> ))} <button onClick={onClear} style={{ fontSize: 11, padding: '2px 8px', background: 'transparent', border: '1px solid #ccc', borderRadius: 12, color: '#666', cursor: 'pointer', marginLeft: 4, }}> Сбросить </button> <button onClick={onShowFilters} style={{ fontSize: 11, padding: '2px 8px', background: 'transparent', border: 'none', color: '#1565c0', cursor: 'pointer', textDecoration: 'underline', }}> Показать фильтр </button> </div> ); }