diff --git a/web/src/api/summary.js b/web/src/api/summary.js index 6791b04..6b6668c 100644 --- a/web/src/api/summary.js +++ b/web/src/api/summary.js @@ -5,4 +5,10 @@ export const SummaryApi = { const params = orgIds.length > 0 ? { org_ids: orgIds.join(',') } : {}; return api.get(`/svod/${summaryNumber}/${sheet}/${year}`, { params }).then((response) => response.data); }, + getSvod2Page(sheet, year, params, { signal } = {}) { + return api.get(`/svod/2/${sheet}/${year}`, { params, signal }).then((response) => response.data); + }, + getSvod2Facets(sheet, year, params, { signal } = {}) { + return api.get(`/svod/2/${sheet}/${year}/facets`, { params, signal }).then((response) => response.data); + }, }; diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index 932a483..149cceb 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -2,7 +2,7 @@ import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material'; import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import { MRT_Localization_RU } from 'material-react-table/locales/ru'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; @@ -12,14 +12,14 @@ import DenominationSelect from '../../components/common/DenominationSelect'; import useDenomination from '../../components/common/useDenomination'; import { useSvod } from '../SvodPage/SvodContext'; import { SvodFilters } from '../SvodPage/SvodFilters'; +import { EMPTY_FACET_MAP, getSvod2RowId, isPlaceholderRow, shouldShowFound } from './svod2Paging'; import { SVOD2_TABLE_CONTAINER_SX, createColumnClassMap, getEstimateTypeClassName } from './svod2Styles'; +import { useSvod2Pages } from './useSvod2Pages'; const SHEETS = [ { value: 'GO', label: 'ССП' }, { value: 'RF', label: 'РФ' }, ]; -const createInitialTableState = () => - Object.fromEntries(SHEETS.map(({ value }) => [value, { rows: [], loading: false, error: '', loadedQueryKey: '' }])); const QUARTERS = [1, 2, 3, 4]; const tableSize = (rem) => rem * 16; const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }); @@ -280,30 +280,14 @@ const scaleNumericColumns = (columns, divisor) => columns.map((column) => { return { ...column, Cell: ({ cell }) => formatValue(cell.getValue(), divisor) }; }); -const matchesSearch = (row, query) => { - const searchableData = { - ...row.data?.header, - ...row.data?.contract_summary, - ...row.data?.contract_detail, - }; - return Object.values(searchableData).some((value) => - String(value ?? '') - .toLowerCase() - .includes(query), - ); -}; - export default function Svod2Page() { const navigate = useNavigate(); - const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod(); + const { getSheetFilters, updateSheetFilters } = useSvod(); const [sheet, setSheet] = useState('GO'); const denominationStorageKey = `svod2_${sheet}_denomination`; const { denomination, setDenomination: handleDenominationChange, divisor } = useDenomination(denominationStorageKey); - const [tableStateBySheet, setTableStateBySheet] = useState(createInitialTableState); const [columnFiltersBySheet, setColumnFiltersBySheet] = useState(() => Object.fromEntries(SHEETS.map(({ value }) => [value, []]))); - const latestSummaryRequestRef = useRef(Object.fromEntries(SHEETS.map(({ value }) => [value, 0]))); const { year, selectedOrganizations, search } = getSheetFilters(sheet); - const { rows, loading, error, loadedQueryKey } = tableStateBySheet[sheet]; const columnFilters = columnFiltersBySheet[sheet]; const selectedOrganizationIds = useMemo( () => (selectedOrganizations.some(({ id }) => id === '__all__') ? [] : selectedOrganizations.map(({ id }) => id)), @@ -313,7 +297,6 @@ export default function Svod2Page() { () => JSON.stringify([sheet, year, selectedOrganizations.map(({ id }) => String(id)).sort()]), [sheet, year, selectedOrganizations], ); - const hasCurrentData = loadedQueryKey === currentQueryKey; const updateCurrentSheetFilters = useCallback( (changes) => { updateSheetFilters(sheet, changes); @@ -332,69 +315,36 @@ export default function Svod2Page() { [sheet], ); - const loadSummary = useCallback(async () => { - const requestId = latestSummaryRequestRef.current[sheet] + 1; - latestSummaryRequestRef.current[sheet] = requestId; - setTableStateBySheet((current) => ({ - ...current, - [sheet]: { ...current[sheet], loading: true, error: '' }, - })); - try { - const response = await getSummary(sheet, year, selectedOrganizationIds); - if (latestSummaryRequestRef.current[sheet] !== requestId) return; - setTableStateBySheet((current) => ({ - ...current, - [sheet]: { ...current[sheet], rows: response.result || [], loadedQueryKey: currentQueryKey }, - })); - } catch (loadError) { - if (latestSummaryRequestRef.current[sheet] !== requestId) return; - setTableStateBySheet((current) => ({ - ...current, - [sheet]: { - ...current[sheet], - rows: [], - error: loadError.response?.data?.detail || 'Не удалось загрузить свод. Попробуйте ещё раз.', - }, - })); - } finally { - if (latestSummaryRequestRef.current[sheet] === requestId) { - setTableStateBySheet((current) => ({ - ...current, - [sheet]: { ...current[sheet], loading: false }, - })); - } - } - }, [currentQueryKey, getSummary, selectedOrganizationIds, sheet, year]); - - useEffect(() => { - return () => { - SHEETS.forEach(({ value }) => { - latestSummaryRequestRef.current[value] += 1; - }); - }; - }, []); - - const refreshSummary = useCallback(() => { - invalidateSummary(sheet, year, selectedOrganizationIds); - loadSummary(); - }, [invalidateSummary, loadSummary, selectedOrganizationIds, sheet, year]); + const tableRef = useRef(null); + const getScrollTop = useCallback(() => tableRef.current?.refs.tableContainerRef.current?.scrollTop ?? 0, []); + const { state, windowRows, padding, facetsMap, load, onScroll } = useSvod2Pages({ + sheet, + year, + orgIds: selectedOrganizationIds, + queryKey: currentQueryKey, + search, + columnFilters, + rowHeight: ROW_HEIGHT, + getScrollTop, + }); + const { loading, error } = state; + const hasCurrentData = state.loadedQueryKey === currentQueryKey; + const facetsMapRef = useRef(facetsMap); + facetsMapRef.current = facetsMap; + const refreshSummary = useCallback(() => load('refresh'), [load]); const openFilteredTasks = useCallback(() => { const params = new URLSearchParams({ year: String(year), form_type: 'FORM_2' }); if (selectedOrganizationIds.length) params.set('org_units', selectedOrganizationIds.join(',')); navigate(`/tasks?${params.toString()}`); }, [navigate, selectedOrganizationIds, year]); - const filteredRows = useMemo(() => { - if (!hasCurrentData) return []; - const normalizedSearch = search.trim().toLowerCase(); - return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows; - }, [hasCurrentData, rows, search]); const { columns: rawColumns, columnClassNames } = useMemo(() => getColumnConfig(sheet), [sheet]); const columns = useMemo(() => scaleNumericColumns(rawColumns, divisor), [rawColumns, divisor]); const table = useMaterialReactTable({ columns, - data: filteredRows, - getRowId: (row) => `${row.data?.header?.source_form}-${row.data?.header?.source_id}-${row.data?.line_id}`, + data: hasCurrentData ? windowRows : [], + getRowId: getSvod2RowId, + manualFiltering: true, enableColumnActions: false, enableColumnFilters: true, enableFacetedValues: true, @@ -409,7 +359,8 @@ export default function Svod2Page() { enableTopToolbar: false, enableBottomToolbar: false, enableSorting: false, - rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS, + getFacetedUniqueValues: (_table, columnId) => () => facetsMapRef.current.get(columnId) ?? EMPTY_FACET_MAP, + rowVirtualizerOptions: { ...ROW_VIRTUALIZER_OPTIONS, measureElement: () => ROW_HEIGHT, ...padding }, state: { isLoading: loading, columnFilters }, initialState: { columnPinning: { left: ['smeta_type', 'smeta_direction', 'org_name', 'name'] }, @@ -428,7 +379,9 @@ export default function Svod2Page() { flexDirection: 'column', }, }, - muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX }, + muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX, onScroll }, + muiTableBodyRowProps: ({ row }) => + isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '' } : {}, muiTableHeadProps: { sx: { '& tr:not(:last-of-type) > th > .MuiCollapse-root': { @@ -444,6 +397,7 @@ export default function Svod2Page() { noRecordsToDisplay: 'Нет данных для выбранных параметров', }, }); + tableRef.current = table; let content = ; if (!hasCurrentData && !loading && !error) { content = ( @@ -499,9 +453,9 @@ export default function Svod2Page() { emptyOrganizationsPlaceholder='Выберите ССП/РФ' allowSelectAll additionalFilters={}> - {hasCurrentData && !loading && filteredRows.length !== rows.length && ( + {shouldShowFound({ hasCurrentData, loading, facets: state.facets }) && ( - {`Найдено ${filteredRows.length.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`} + {`Найдено ${state.facets.search_total.toLocaleString('ru-RU')} из ${state.facets.total.toLocaleString('ru-RU')} строк`} )} diff --git a/web/src/pages/Svod2Page/svod2Styles.js b/web/src/pages/Svod2Page/svod2Styles.js index 09fffd9..cf70b7a 100644 --- a/web/src/pages/Svod2Page/svod2Styles.js +++ b/web/src/pages/Svod2Page/svod2Styles.js @@ -99,6 +99,13 @@ export const SVOD2_TABLE_CONTAINER_SX = { '& .svod2-badge-oper': { backgroundColor: '#eff6ff', borderColor: '#93c5fd', color: '#1d4ed8' }, '& .svod2-badge-kv': { backgroundColor: '#fff7ed', borderColor: '#fdba74', color: '#c2410c' }, '& .svod2-badge-default': { backgroundColor: '#f3f4f6', borderColor: '#d1d5db', color: '#4b5563' }, + // Строка-заглушка ещё не загруженной страницы: пустой вид без мигающих бейджей. + '& tbody tr.svod2-placeholder td': { color: 'transparent' }, + '& tbody tr.svod2-placeholder .svod2-badge, & tbody tr.svod2-placeholder .svod2-org-badge': { + backgroundColor: 'transparent', + borderColor: 'transparent', + color: 'transparent', + }, '& .svod2-org-badge': { display: 'inline-block', maxWidth: '100%', diff --git a/web/src/pages/Svod2Page/svod2Styles.test.js b/web/src/pages/Svod2Page/svod2Styles.test.js index ffdb98c..13823cf 100644 --- a/web/src/pages/Svod2Page/svod2Styles.test.js +++ b/web/src/pages/Svod2Page/svod2Styles.test.js @@ -65,3 +65,9 @@ test('container styles define cell, band, group-end and pinned rules', () => { assert.ok(SVOD2_TABLE_CONTAINER_SX[key], key); } }); + +test('заглушка свода 2 — прозрачный текст в строке-заглушке', () => { + const rule = SVOD2_TABLE_CONTAINER_SX['& tbody tr.svod2-placeholder td']; + assert.ok(rule, 'нет правила заглушки'); + assert.equal(rule.color, 'transparent'); +}); diff --git a/web/src/pages/Svod2Page/useSvod2Pages.js b/web/src/pages/Svod2Page/useSvod2Pages.js new file mode 100644 index 0000000..4f915a8 --- /dev/null +++ b/web/src/pages/Svod2Page/useSvod2Pages.js @@ -0,0 +1,266 @@ +// Хук серверной постраничной загрузки свода 2: поколения запросов, окно страниц, догрузка при прокрутке. +import axios from 'axios'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'react-toastify'; + +import { SummaryApi } from '../../api/summary'; +import { + PAGE_SIZE, + buildSvod2PageParams, + buildSvod2QueryParams, + buildWindowRows, + createSheetPagingState, + facetsToMaps, + getMissingPages, + getPageCount, + getVirtualizerPadding, + getVisiblePage, + getWindowPages, + getWindowRange, + svod2PagingReducer, +} from './svod2Paging'; + +const SHEET_KEYS = ['GO', 'RF']; +const LOAD_ERROR_FALLBACK = 'Не удалось загрузить свод. Попробуйте ещё раз.'; +const SCROLL_ERROR_MESSAGE = 'Не удалось загрузить строки свода. Прокрутите ещё раз, чтобы повторить.'; +const NO_PADDING = { paddingStart: 0, paddingEnd: 0 }; + +const createInitialState = () => Object.fromEntries(SHEET_KEYS.map((key) => [key, createSheetPagingState()])); +const rootReducer = (state, action) => ({ ...state, [action.sheet]: svod2PagingReducer(state[action.sheet], action) }); + +const getRawPage = (scrollTop, rowHeight) => { + const page = Math.floor(scrollTop / rowHeight / PAGE_SIZE); + return Number.isFinite(page) && page > 0 ? page : 0; +}; + +const isCanceled = (error, ctx) => axios.isCancel(error) || error?.code === 'ERR_CANCELED' || ctx.controller.signal.aborted; + +export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop }) { + // Состояние хранится в ref синхронно (редьюсер вызывается в dispatch), чтобы логика догрузки видела актуальные страницы. + const stateRef = useRef(null); + if (stateRef.current === null) stateRef.current = createInitialState(); + const [state, setState] = useState(stateRef.current); + const [scrollPage, setScrollPage] = useState(0); + + const generationRef = useRef(Object.fromEntries(SHEET_KEYS.map((key) => [key, 0]))); + const ctxRef = useRef({}); + const rafRef = useRef(0); + const mountedRef = useRef(true); + const propsRef = useRef(null); + propsRef.current = { sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop }; + + const dispatch = useCallback((action) => { + const next = rootReducer(stateRef.current, action); + if (next === stateRef.current) return; + stateRef.current = next; + if (mountedRef.current) setState(next); + }, []); + + const fetchPage = useCallback(async (ctx, pageIndex) => { + ctx.inflight.add(pageIndex); + try { + const response = await SummaryApi.getSvod2Page( + ctx.sheet, + ctx.year, + buildSvod2PageParams({ + orgIds: ctx.orgIds, + search: ctx.search, + columnFilters: ctx.columnFilters, + offset: pageIndex * PAGE_SIZE, + }), + { signal: ctx.controller.signal }, + ); + const rows = response.result || []; + return { rows, total: response.total ?? rows.length }; + } finally { + ctx.inflight.delete(pageIndex); + } + }, []); + + const getWindowFor = useCallback((total) => { + const { rowHeight: height, getScrollTop: readScrollTop } = propsRef.current; + const visible = getVisiblePage({ scrollTop: readScrollTop(), rowHeight: height, total }); + return getWindowPages(visible, total); + }, []); + + const start = useCallback( + (mode) => { + const current = propsRef.current; + const sheetKey = current.sheet; + ctxRef.current[sheetKey]?.controller.abort(); + const generation = generationRef.current[sheetKey] + 1; + generationRef.current[sheetKey] = generation; + const ctx = { + sheet: sheetKey, + year: current.year, + orgIds: current.orgIds, + search: current.search, + columnFilters: current.columnFilters, + generation, + controller: new AbortController(), + inflight: new Set(), + ready: false, + }; + ctxRef.current[sheetKey] = ctx; + const candidate = getRawPage(current.getScrollTop(), current.rowHeight); + setScrollPage(candidate); + dispatch({ type: 'begin', sheet: sheetKey, generation, queryKey: current.queryKey, mode: mode === 'refine' ? 'refine' : 'load' }); + + let failed = false; + const fail = (error) => { + if (isCanceled(error, ctx) || failed) return; + failed = true; + dispatch({ + type: 'fail', + sheet: sheetKey, + generation, + mode: mode === 'refine' ? 'refine' : 'load', + message: error?.response?.data?.detail || LOAD_ERROR_FALLBACK, + }); + ctx.controller.abort(); + }; + + const loadFacets = async () => { + try { + const response = await SummaryApi.getSvod2Facets(ctx.sheet, ctx.year, buildSvod2QueryParams(ctx), { + signal: ctx.controller.signal, + }); + if (ctx.controller.signal.aborted) return; + dispatch({ type: 'facets', sheet: sheetKey, generation, facets: response.result }); + } catch (error) { + fail(error); + } + }; + + const loadPages = async () => { + try { + const first = await fetchPage(ctx, candidate); + if (ctx.controller.signal.aborted) return; + const { total } = first; + const pages = getWindowFor(total); + const pageCount = getPageCount(total); + let cache = new Map(); + if (total === 0 || candidate < pageCount) { + const pageIndex = total === 0 ? 0 : candidate; + dispatch({ type: 'page', sheet: sheetKey, generation, pageIndex, rows: first.rows, total, keepPages: pages }); + cache = stateRef.current[sheetKey].pages; + } + ctx.ready = true; + const missing = getMissingPages(pages, cache, ctx.inflight); + await Promise.all( + missing.map(async (pageIndex) => { + try { + const result = await fetchPage(ctx, pageIndex); + if (ctx.controller.signal.aborted) return; + dispatch({ + type: 'page', + sheet: sheetKey, + generation, + pageIndex, + rows: result.rows, + total: result.total, + keepPages: getWindowFor(result.total), + }); + } catch (error) { + fail(error); + } + }), + ); + } catch (error) { + fail(error); + } + }; + + (async () => { + try { + await Promise.all([loadFacets(), loadPages()]); + } finally { + dispatch({ type: 'settled', sheet: sheetKey, generation }); + } + })(); + }, + [dispatch, fetchPage, getWindowFor], + ); + + const load = useCallback(() => start('load'), [start]); + + // Догрузка недостающих страниц окна текущей вкладки в режиме scroll. + const syncWindow = useCallback(() => { + const current = propsRef.current; + setScrollPage(getRawPage(current.getScrollTop(), current.rowHeight)); + const ctx = ctxRef.current[current.sheet]; + const sheetState = stateRef.current[current.sheet]; + if (!ctx || !ctx.ready || ctx.controller.signal.aborted) return; + if (sheetState.generation !== ctx.generation || sheetState.loadedQueryKey !== current.queryKey) return; + const pages = getWindowFor(sheetState.total); + for (const pageIndex of getMissingPages(pages, sheetState.pages, ctx.inflight)) { + fetchPage(ctx, pageIndex) + .then((result) => { + if (ctx.controller.signal.aborted) return; + dispatch({ + type: 'page', + sheet: ctx.sheet, + generation: ctx.generation, + pageIndex, + rows: result.rows, + total: result.total, + keepPages: getWindowFor(result.total), + }); + }) + .catch((error) => { + if (isCanceled(error, ctx)) return; + toast.error(SCROLL_ERROR_MESSAGE, { toastId: 'svod2-scroll-error' }); + }); + } + }, [dispatch, fetchPage, getWindowFor]); + + const onScroll = useCallback(() => { + if (rafRef.current) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = 0; + if (mountedRef.current) syncWindow(); + }); + }, [syncWindow]); + + // Смена поиска или колоночных фильтров на загруженных данных — уточнение (refine) теми же запросами. + const sheetState = state[sheet]; + const hasCurrentData = sheetState.loadedQueryKey === queryKey; + const refineKey = JSON.stringify([search, buildSvod2QueryParams({ columnFilters })]); + const previousRef = useRef({ sheet, refineKey }); + // biome-ignore lint/correctness/useExhaustiveDependencies: реагируем только на смену поиска/фильтров и вкладки + useEffect(() => { + const previous = previousRef.current; + previousRef.current = { sheet, refineKey }; + if (previous.sheet !== sheet) { + syncWindow(); + return; + } + if (previous.refineKey !== refineKey && hasCurrentData) start('refine'); + }, [sheet, refineKey]); + + useEffect(() => { + mountedRef.current = true; + const contexts = ctxRef.current; + return () => { + mountedRef.current = false; + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = 0; + for (const ctx of Object.values(contexts)) ctx.controller.abort(); + }; + }, []); + + const visiblePage = getVisiblePage({ scrollTop: scrollPage * PAGE_SIZE * rowHeight, rowHeight, total: sheetState.total }); + const windowPages = useMemo(() => getWindowPages(visiblePage, sheetState.total), [visiblePage, sheetState.total]); + const windowRows = useMemo( + () => buildWindowRows(windowPages, sheetState.pages, sheetState.total), + [windowPages, sheetState.pages, sheetState.total], + ); + const padding = useMemo(() => { + if (!hasCurrentData) return NO_PADDING; + const { windowStart, windowEnd } = getWindowRange(windowPages, sheetState.total); + return getVirtualizerPadding({ windowStart, windowEnd, total: sheetState.total, rowHeight }); + }, [hasCurrentData, windowPages, sheetState.total, rowHeight]); + const facetsMap = useMemo(() => facetsToMaps(sheetState.facets), [sheetState.facets]); + + return { state: sheetState, windowRows, padding, facetsMap, load, onScroll }; +}