From 816ca21c681db0b85a3dda07b8bfaf5955da3486 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Fri, 28 Aug 2026 12:20:15 +0300 Subject: [PATCH] cache filtered summaries and show table direction in header --- web/src/app/Routes.jsx | 3 +- web/src/pages/NewFormTablePage.jsx | 24 ++++--- web/src/pages/SvodPage/SvodContext.jsx | 91 ++++++++++++++++++++++++++ web/src/pages/SvodPage/SvodPage.jsx | 55 +++++++++++----- 4 files changed, 147 insertions(+), 26 deletions(-) create mode 100644 web/src/pages/SvodPage/SvodContext.jsx diff --git a/web/src/app/Routes.jsx b/web/src/app/Routes.jsx index 464298c..43808a4 100644 --- a/web/src/app/Routes.jsx +++ b/web/src/app/Routes.jsx @@ -12,6 +12,7 @@ import NewTablePage from '../pages/NewTablePage.jsx'; import NavigationProjectPage from '../pages/ProjectPages/NavigationProjectPage.jsx'; import ProjectsPage from '../pages/ProjectsPage/ProjectsPage.jsx'; import SvodPage from '../pages/SvodPage/SvodPage.jsx'; +import { SvodProvider } from '../pages/SvodPage/SvodContext.jsx'; import TablePage from '../pages/TablePage'; import TableTempPage from '../pages/TableTempPage/TableTempPage'; import TablesTest from '../pages/TablesTest.jsx'; @@ -55,7 +56,7 @@ export const AppRoutes = () => { } /> } /> } /> - } /> + } /> diff --git a/web/src/pages/NewFormTablePage.jsx b/web/src/pages/NewFormTablePage.jsx index a97adcf..4a28f8a 100644 --- a/web/src/pages/NewFormTablePage.jsx +++ b/web/src/pages/NewFormTablePage.jsx @@ -5,14 +5,21 @@ import RealtimeTable from '../components/RealtimeTable'; import { RealtimeProvider } from '../components/RealtimeTable/contexts/RealtimeContext'; import { BackButton } from '../components/common/Buttons/BackButton'; import { NameTask, TaskInfoContainer } from '../components/common/SwitchFormTask/SwitchFormTask.style'; -import { SHEET_NAME } from '../constants/constants'; +import { DIRECTION_TRANSLATE, SHEET_NAME } from '../constants/constants'; -const TableInfo = ({ formId, sheetName, isProject = false }) => { - const path = (isProject ? `/project/` : '/task/') + formId; +const TableInfo = ({ formId, sheetName, direction, isProject = false }) => { + const path = (isProject ? '/project/' : '/task/') + formId; + const tableName = SHEET_NAME[sheetName] || sheetName; + const directionName = DIRECTION_TRANSLATE[direction] || direction; const portalContent = ( - {sheetName && {SHEET_NAME[sheetName] || sheetName}} + {sheetName && ( + + {tableName} + {directionName ? ` — ${directionName}` : ''} + + )} ); @@ -26,24 +33,25 @@ const TableInfo = ({ formId, sheetName, isProject = false }) => { export default function NewTablePage() { const { formId, formType, sheetName, direction, year } = useParams(); const { user } = useAuth(); - const isProject = formType == 'PROJECT'; + const isProject = formType === 'PROJECT'; + const normalizedDirection = direction === 'null' ? null : direction; return ( <> - {formId && } + {formId && } {user && ( diff --git a/web/src/pages/SvodPage/SvodContext.jsx b/web/src/pages/SvodPage/SvodContext.jsx new file mode 100644 index 0000000..7c8fd99 --- /dev/null +++ b/web/src/pages/SvodPage/SvodContext.jsx @@ -0,0 +1,91 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { SummaryApi } from '../../api/summary'; + +const SvodContext = createContext(null); + +const SHEET_VALUES = ['MAIN', 'FORM_1', 'FORM_2', 'FORM_3', 'FORM_4']; + +const createDefaultFilters = () => ({ + year: new Date().getFullYear(), + selectedOrganizations: [], + search: '', +}); + +const createInitialFilters = () => Object.fromEntries(SHEET_VALUES.map((sheet) => [sheet, createDefaultFilters()])); + +const createCacheKey = (year, organizationIds) => JSON.stringify([year, organizationIds.map(String).sort()]); + +const getSheetCache = (cache, sheet) => { + if (!cache.has(sheet)) cache.set(sheet, new Map()); + return cache.get(sheet); +}; + +export const SvodProvider = ({ children }) => { + const [filtersBySheet, setFiltersBySheet] = useState(createInitialFilters); + const summaryCacheRef = useRef(new Map()); + + useEffect( + () => () => { + summaryCacheRef.current.clear(); + }, + [], + ); + + const getSheetFilters = useCallback((sheet) => filtersBySheet[sheet] || createDefaultFilters(), [filtersBySheet]); + + const updateSheetFilters = useCallback((sheet, changes) => { + setFiltersBySheet((currentFilters) => ({ + ...currentFilters, + [sheet]: { + ...(currentFilters[sheet] || createDefaultFilters()), + ...changes, + }, + })); + }, []); + + const getSummary = useCallback((sheet, year, organizationIds = []) => { + const sheetCache = getSheetCache(summaryCacheRef.current, sheet); + const cacheKey = createCacheKey(year, organizationIds); + const cachedRequest = sheetCache.get(cacheKey); + if (cachedRequest) return cachedRequest; + + const request = SummaryApi.get(sheet, year, organizationIds) + .then((response) => { + if (sheetCache.get(cacheKey) === request) { + sheetCache.set(cacheKey, Promise.resolve(response)); + } + return response; + }) + .catch((error) => { + if (sheetCache.get(cacheKey) === request) { + sheetCache.delete(cacheKey); + } + throw error; + }); + + sheetCache.set(cacheKey, request); + return request; + }, []); + + const invalidateSummary = useCallback((sheet, year, organizationIds = []) => { + summaryCacheRef.current.get(sheet)?.delete(createCacheKey(year, organizationIds)); + }, []); + + const value = useMemo( + () => ({ + getSheetFilters, + updateSheetFilters, + getSummary, + invalidateSummary, + }), + [getSheetFilters, getSummary, invalidateSummary, updateSheetFilters], + ); + + return {children}; +}; + +export const useSvod = () => { + const context = useContext(SvodContext); + if (!context) throw new Error('useSvod must be used within SvodProvider'); + return context; +}; diff --git a/web/src/pages/SvodPage/SvodPage.jsx b/web/src/pages/SvodPage/SvodPage.jsx index 51b18b9..0243125 100644 --- a/web/src/pages/SvodPage/SvodPage.jsx +++ b/web/src/pages/SvodPage/SvodPage.jsx @@ -5,18 +5,18 @@ import { MenuItem, Paper, Tab, Tabs, TextField, Typography, } from '@mui/material'; import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import { SspApi } from '../../api/ssp'; -import { SummaryApi } from '../../api/summary'; import { useAuth } from '../../app/context/AuthProvider'; import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; import { blueColumn } from '../../components/RealtimeTable/constants/columnColors'; import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig'; import { ROLES_NAME_ID } from '../../constants/constants'; +import { useSvod } from './SvodContext'; const SHEETS = [ - { value: 'MAIN', label: 'Общий свод'}, + { value: 'MAIN', label: 'Общий свод' }, { value: 'FORM_1', label: 'Смета ГО' }, { value: 'FORM_2', label: 'Смета РФ' }, { value: 'FORM_3', label: 'Проекты РФ Развитие' }, @@ -169,16 +169,18 @@ const getAvailableYears = () => { export default function SvodPage() { const { user } = useAuth(); + const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod(); const [sheet, setSheet] = useState('MAIN'); - const [year, setYear] = useState(new Date().getFullYear()); const [organizations, setOrganizations] = useState([]); - const [selectedOrganizations, setSelectedOrganizations] = useState([]); const [rows, setRows] = useState([]); - const [search, setSearch] = useState(''); const [loading, setLoading] = useState(true); const [organizationsLoading, setOrganizationsLoading] = useState(false); const [error, setError] = useState(''); - const [requestVersion, setRequestVersion] = useState(0); + const latestSummaryRequestRef = useRef(0); + const { year, selectedOrganizations, search } = getSheetFilters(sheet); + const updateCurrentSheetFilters = useCallback((changes) => { + updateSheetFilters(sheet, changes); + }, [sheet, updateSheetFilters]); useEffect(() => { if (!user) return; @@ -202,20 +204,34 @@ export default function SvodPage() { }, [user]); const loadSummary = useCallback(async () => { + const requestId = latestSummaryRequestRef.current + 1; + latestSummaryRequestRef.current = requestId; setLoading(true); setError(''); try { - const response = await SummaryApi.get(sheet, year, selectedOrganizations.map(({ id }) => id)); + const response = await getSummary(sheet, year, selectedOrganizations.map(({ id }) => id)); + if (latestSummaryRequestRef.current !== requestId) return; setRows([...(response.result || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))); } catch (loadError) { + if (latestSummaryRequestRef.current !== requestId) return; setRows([]); setError(loadError.response?.data?.detail || 'Не удалось загрузить свод. Попробуйте ещё раз.'); } finally { - setLoading(false); + if (latestSummaryRequestRef.current === requestId) setLoading(false); } - }, [selectedOrganizations, sheet, year]); + }, [getSummary, selectedOrganizations, sheet, year]); - useEffect(() => { loadSummary(); }, [loadSummary, requestVersion]); + useEffect(() => { + loadSummary(); + return () => { + latestSummaryRequestRef.current += 1; + }; + }, [loadSummary]); + + const refreshSummary = useCallback(() => { + invalidateSummary(sheet, year, selectedOrganizations.map(({ id }) => id)); + loadSummary(); + }, [invalidateSummary, loadSummary, selectedOrganizations, sheet, year]); const filteredRows = useMemo(() => { const normalizedSearch = search.trim().toLowerCase(); @@ -281,10 +297,15 @@ export default function SvodPage() { position: 'sticky', top: 0, boxShadow: 'none', - backgroundColor: '#f8f9fa', borderRight: '0.0625rem solid #e5e7eb', borderBottom: '0.0625rem solid #dfe3e7', + backgroundColor: '#f8f9fa', borderRight: '0.0625rem solid #e5e7eb', color: '#4a5565', fontSize: '0.75rem', fontWeight: 700, lineHeight: 1.2, px: '0.75rem', py: '0.5rem', whiteSpace: 'nowrap', }, }, + muiTableHeadRowProps: { + sx: { + boxShadow: 'none', + }, + }, muiTableBodyCellProps: ({ row, column }) => { const backgroundColor = getSummaryRowColor(row); return { @@ -312,13 +333,13 @@ export default function SvodPage() { setYear(Number(event.target.value))} sx={{ width: '7.5rem' }}> + onChange={(event) => updateCurrentSheetFilters({ year: Number(event.target.value) })} sx={{ width: '7.5rem' }}> {getAvailableYears().map((availableYear) => {availableYear})} option.id === value.id} - getOptionLabel={(option) => option.title || ''} onChange={(_event, value) => setSelectedOrganizations(value)} + getOptionLabel={(option) => option.title || ''} onChange={(_event, value) => updateCurrentSheetFilters({ selectedOrganizations: value })} sx={{ minWidth: '18.75rem', flex: '1 1 22.5rem', maxWidth: '32.5rem' }} renderTags={(value, getTagProps) => value.slice(0, 2).map((option, index) => { const tagProps = getTagProps({ index }); @@ -331,7 +352,7 @@ export default function SvodPage() { endAdornment: <>{organizationsLoading && }{params.InputProps?.endAdornment}, }} />} /> - setSearch(event.target.value)} + updateCurrentSheetFilters({ search: event.target.value })} placeholder='Код или статья расходов' sx={{ minWidth: '16.25rem', flex: '1 1 17.5rem', maxWidth: '23.75rem' }} InputProps={{ startAdornment: }} /> {!loading && filteredRowsCount !== rows.length && ( @@ -344,7 +365,7 @@ export default function SvodPage() { color='default' startIcon={} disabled={loading} - onClick={() => setRequestVersion((version) => version + 1)} + onClick={refreshSummary} sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}> Обновить @@ -352,7 +373,7 @@ export default function SvodPage() { {error ? ( setRequestVersion((version) => version + 1)}>Повторить}>{error} + onClick={refreshSummary}>Повторить}>{error} ) : } ); -- 2.47.2