perf(web): свод 2 — строки страницами с сервера, поиск, фильтры и варианты через API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
325c1c5f15
commit
1aa138a839
@ -5,4 +5,10 @@ export const SummaryApi = {
|
|||||||
const params = orgIds.length > 0 ? { org_ids: orgIds.join(',') } : {};
|
const params = orgIds.length > 0 ? { org_ids: orgIds.join(',') } : {};
|
||||||
return api.get(`/svod/${summaryNumber}/${sheet}/${year}`, { params }).then((response) => response.data);
|
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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
|
|||||||
import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material';
|
import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material';
|
||||||
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
|
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
|
||||||
import { MRT_Localization_RU } from 'material-react-table/locales/ru';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
||||||
@ -12,14 +12,14 @@ import DenominationSelect from '../../components/common/DenominationSelect';
|
|||||||
import useDenomination from '../../components/common/useDenomination';
|
import useDenomination from '../../components/common/useDenomination';
|
||||||
import { useSvod } from '../SvodPage/SvodContext';
|
import { useSvod } from '../SvodPage/SvodContext';
|
||||||
import { SvodFilters } from '../SvodPage/SvodFilters';
|
import { SvodFilters } from '../SvodPage/SvodFilters';
|
||||||
|
import { EMPTY_FACET_MAP, getSvod2RowId, isPlaceholderRow, shouldShowFound } from './svod2Paging';
|
||||||
import { SVOD2_TABLE_CONTAINER_SX, createColumnClassMap, getEstimateTypeClassName } from './svod2Styles';
|
import { SVOD2_TABLE_CONTAINER_SX, createColumnClassMap, getEstimateTypeClassName } from './svod2Styles';
|
||||||
|
import { useSvod2Pages } from './useSvod2Pages';
|
||||||
|
|
||||||
const SHEETS = [
|
const SHEETS = [
|
||||||
{ value: 'GO', label: 'ССП' },
|
{ value: 'GO', label: 'ССП' },
|
||||||
{ value: 'RF', label: 'РФ' },
|
{ value: 'RF', label: 'РФ' },
|
||||||
];
|
];
|
||||||
const createInitialTableState = () =>
|
|
||||||
Object.fromEntries(SHEETS.map(({ value }) => [value, { rows: [], loading: false, error: '', loadedQueryKey: '' }]));
|
|
||||||
const QUARTERS = [1, 2, 3, 4];
|
const QUARTERS = [1, 2, 3, 4];
|
||||||
const tableSize = (rem) => rem * 16;
|
const tableSize = (rem) => rem * 16;
|
||||||
const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 });
|
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) };
|
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() {
|
export default function Svod2Page() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
|
const { getSheetFilters, updateSheetFilters } = useSvod();
|
||||||
const [sheet, setSheet] = useState('GO');
|
const [sheet, setSheet] = useState('GO');
|
||||||
const denominationStorageKey = `svod2_${sheet}_denomination`;
|
const denominationStorageKey = `svod2_${sheet}_denomination`;
|
||||||
const { denomination, setDenomination: handleDenominationChange, divisor } = useDenomination(denominationStorageKey);
|
const { denomination, setDenomination: handleDenominationChange, divisor } = useDenomination(denominationStorageKey);
|
||||||
const [tableStateBySheet, setTableStateBySheet] = useState(createInitialTableState);
|
|
||||||
const [columnFiltersBySheet, setColumnFiltersBySheet] = useState(() => Object.fromEntries(SHEETS.map(({ value }) => [value, []])));
|
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 { year, selectedOrganizations, search } = getSheetFilters(sheet);
|
||||||
const { rows, loading, error, loadedQueryKey } = tableStateBySheet[sheet];
|
|
||||||
const columnFilters = columnFiltersBySheet[sheet];
|
const columnFilters = columnFiltersBySheet[sheet];
|
||||||
const selectedOrganizationIds = useMemo(
|
const selectedOrganizationIds = useMemo(
|
||||||
() => (selectedOrganizations.some(({ id }) => id === '__all__') ? [] : selectedOrganizations.map(({ id }) => id)),
|
() => (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()]),
|
() => JSON.stringify([sheet, year, selectedOrganizations.map(({ id }) => String(id)).sort()]),
|
||||||
[sheet, year, selectedOrganizations],
|
[sheet, year, selectedOrganizations],
|
||||||
);
|
);
|
||||||
const hasCurrentData = loadedQueryKey === currentQueryKey;
|
|
||||||
const updateCurrentSheetFilters = useCallback(
|
const updateCurrentSheetFilters = useCallback(
|
||||||
(changes) => {
|
(changes) => {
|
||||||
updateSheetFilters(sheet, changes);
|
updateSheetFilters(sheet, changes);
|
||||||
@ -332,69 +315,36 @@ export default function Svod2Page() {
|
|||||||
[sheet],
|
[sheet],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadSummary = useCallback(async () => {
|
const tableRef = useRef(null);
|
||||||
const requestId = latestSummaryRequestRef.current[sheet] + 1;
|
const getScrollTop = useCallback(() => tableRef.current?.refs.tableContainerRef.current?.scrollTop ?? 0, []);
|
||||||
latestSummaryRequestRef.current[sheet] = requestId;
|
const { state, windowRows, padding, facetsMap, load, onScroll } = useSvod2Pages({
|
||||||
setTableStateBySheet((current) => ({
|
sheet,
|
||||||
...current,
|
year,
|
||||||
[sheet]: { ...current[sheet], loading: true, error: '' },
|
orgIds: selectedOrganizationIds,
|
||||||
}));
|
queryKey: currentQueryKey,
|
||||||
try {
|
search,
|
||||||
const response = await getSummary(sheet, year, selectedOrganizationIds);
|
columnFilters,
|
||||||
if (latestSummaryRequestRef.current[sheet] !== requestId) return;
|
rowHeight: ROW_HEIGHT,
|
||||||
setTableStateBySheet((current) => ({
|
getScrollTop,
|
||||||
...current,
|
});
|
||||||
[sheet]: { ...current[sheet], rows: response.result || [], loadedQueryKey: currentQueryKey },
|
const { loading, error } = state;
|
||||||
}));
|
const hasCurrentData = state.loadedQueryKey === currentQueryKey;
|
||||||
} catch (loadError) {
|
const facetsMapRef = useRef(facetsMap);
|
||||||
if (latestSummaryRequestRef.current[sheet] !== requestId) return;
|
facetsMapRef.current = facetsMap;
|
||||||
setTableStateBySheet((current) => ({
|
const refreshSummary = useCallback(() => load('refresh'), [load]);
|
||||||
...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 openFilteredTasks = useCallback(() => {
|
const openFilteredTasks = useCallback(() => {
|
||||||
const params = new URLSearchParams({ year: String(year), form_type: 'FORM_2' });
|
const params = new URLSearchParams({ year: String(year), form_type: 'FORM_2' });
|
||||||
if (selectedOrganizationIds.length) params.set('org_units', selectedOrganizationIds.join(','));
|
if (selectedOrganizationIds.length) params.set('org_units', selectedOrganizationIds.join(','));
|
||||||
navigate(`/tasks?${params.toString()}`);
|
navigate(`/tasks?${params.toString()}`);
|
||||||
}, [navigate, selectedOrganizationIds, year]);
|
}, [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: rawColumns, columnClassNames } = useMemo(() => getColumnConfig(sheet), [sheet]);
|
||||||
const columns = useMemo(() => scaleNumericColumns(rawColumns, divisor), [rawColumns, divisor]);
|
const columns = useMemo(() => scaleNumericColumns(rawColumns, divisor), [rawColumns, divisor]);
|
||||||
const table = useMaterialReactTable({
|
const table = useMaterialReactTable({
|
||||||
columns,
|
columns,
|
||||||
data: filteredRows,
|
data: hasCurrentData ? windowRows : [],
|
||||||
getRowId: (row) => `${row.data?.header?.source_form}-${row.data?.header?.source_id}-${row.data?.line_id}`,
|
getRowId: getSvod2RowId,
|
||||||
|
manualFiltering: true,
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
enableColumnFilters: true,
|
enableColumnFilters: true,
|
||||||
enableFacetedValues: true,
|
enableFacetedValues: true,
|
||||||
@ -409,7 +359,8 @@ export default function Svod2Page() {
|
|||||||
enableTopToolbar: false,
|
enableTopToolbar: false,
|
||||||
enableBottomToolbar: false,
|
enableBottomToolbar: false,
|
||||||
enableSorting: 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 },
|
state: { isLoading: loading, columnFilters },
|
||||||
initialState: {
|
initialState: {
|
||||||
columnPinning: { left: ['smeta_type', 'smeta_direction', 'org_name', 'name'] },
|
columnPinning: { left: ['smeta_type', 'smeta_direction', 'org_name', 'name'] },
|
||||||
@ -428,7 +379,9 @@ export default function Svod2Page() {
|
|||||||
flexDirection: 'column',
|
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: {
|
muiTableHeadProps: {
|
||||||
sx: {
|
sx: {
|
||||||
'& tr:not(:last-of-type) > th > .MuiCollapse-root': {
|
'& tr:not(:last-of-type) > th > .MuiCollapse-root': {
|
||||||
@ -444,6 +397,7 @@ export default function Svod2Page() {
|
|||||||
noRecordsToDisplay: 'Нет данных для выбранных параметров',
|
noRecordsToDisplay: 'Нет данных для выбранных параметров',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
tableRef.current = table;
|
||||||
let content = <MaterialReactTable table={table} />;
|
let content = <MaterialReactTable table={table} />;
|
||||||
if (!hasCurrentData && !loading && !error) {
|
if (!hasCurrentData && !loading && !error) {
|
||||||
content = (
|
content = (
|
||||||
@ -499,9 +453,9 @@ export default function Svod2Page() {
|
|||||||
emptyOrganizationsPlaceholder='Выберите ССП/РФ'
|
emptyOrganizationsPlaceholder='Выберите ССП/РФ'
|
||||||
allowSelectAll
|
allowSelectAll
|
||||||
additionalFilters={<DenominationSelect value={denomination} onChange={handleDenominationChange} minWidth={135} />}>
|
additionalFilters={<DenominationSelect value={denomination} onChange={handleDenominationChange} minWidth={135} />}>
|
||||||
{hasCurrentData && !loading && filteredRows.length !== rows.length && (
|
{shouldShowFound({ hasCurrentData, loading, facets: state.facets }) && (
|
||||||
<Typography variant='caption' color='text.secondary' sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
|
<Typography variant='caption' color='text.secondary' sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||||
{`Найдено ${filteredRows.length.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`}
|
{`Найдено ${state.facets.search_total.toLocaleString('ru-RU')} из ${state.facets.total.toLocaleString('ru-RU')} строк`}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<PrimaryButton variant='contained' onClick={openFilteredTasks} sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
|
<PrimaryButton variant='contained' onClick={openFilteredTasks} sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
|
||||||
|
|||||||
@ -99,6 +99,13 @@ export const SVOD2_TABLE_CONTAINER_SX = {
|
|||||||
'& .svod2-badge-oper': { backgroundColor: '#eff6ff', borderColor: '#93c5fd', color: '#1d4ed8' },
|
'& .svod2-badge-oper': { backgroundColor: '#eff6ff', borderColor: '#93c5fd', color: '#1d4ed8' },
|
||||||
'& .svod2-badge-kv': { backgroundColor: '#fff7ed', borderColor: '#fdba74', color: '#c2410c' },
|
'& .svod2-badge-kv': { backgroundColor: '#fff7ed', borderColor: '#fdba74', color: '#c2410c' },
|
||||||
'& .svod2-badge-default': { backgroundColor: '#f3f4f6', borderColor: '#d1d5db', color: '#4b5563' },
|
'& .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': {
|
'& .svod2-org-badge': {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
|
|||||||
@ -65,3 +65,9 @@ test('container styles define cell, band, group-end and pinned rules', () => {
|
|||||||
assert.ok(SVOD2_TABLE_CONTAINER_SX[key], key);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
266
web/src/pages/Svod2Page/useSvod2Pages.js
Normal file
266
web/src/pages/Svod2Page/useSvod2Pages.js
Normal file
@ -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 };
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user