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 };
+}