Merge pull request 'cache filtered summaries and show table direction in header' (#131) from svod-cahe-and-table-direction into test

Reviewed-on: #131
This commit is contained in:
PotapovaA 2026-08-28 12:22:12 +03:00
commit cad325fc89
4 changed files with 147 additions and 26 deletions

View File

@ -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 = () => {
<Route path='/tables/:form/:sheetName' element={<NewFormTablePage />} />
<Route path='/table/form/:formId/form-type/:formType/:sheetName/:direction/:year' element={<NewFormTablePage />} />
<Route path='/project-mock-summary' element={<NavigationProjectPage />} />
<Route path='/svod' element={<SvodPage />} />
<Route path='/svod' element={<SvodProvider><SvodPage /></SvodProvider>} />
</Route>
</Route>
</Routes>

View File

@ -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 = (
<TaskInfoContainer>
<BackButton to={path} />
{sheetName && <NameTask>{SHEET_NAME[sheetName] || sheetName}</NameTask>}
{sheetName && (
<NameTask>
{tableName}
{directionName ? `${directionName}` : ''}
</NameTask>
)}
</TaskInfoContainer>
);
@ -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 && <TableInfo formId={formId} sheetName={sheetName} isProject={isProject} />}
{formId && <TableInfo formId={formId} sheetName={sheetName} direction={normalizedDirection} isProject={isProject} />}
{user && (
<RealtimeProvider
formId={formId}
sheetName={sheetName}
userId={user.id}
direction={direction === 'null' ? null : direction}
direction={normalizedDirection}
isProject={isProject}
year={year}>
<RealtimeTable
formType={formType}
formId={formId}
sheetName={sheetName}
direction={direction === 'null' ? null : direction}
direction={normalizedDirection}
year={year}
/>
</RealtimeProvider>

View File

@ -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 <SvodContext.Provider value={value}>{children}</SvodContext.Provider>;
};
export const useSvod = () => {
const context = useContext(SvodContext);
if (!context) throw new Error('useSvod must be used within SvodProvider');
return context;
};

View File

@ -5,15 +5,15 @@ 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: 'Общий свод' },
@ -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() {
</Tabs>
<Box sx={{ p: '1rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<TextField select size='small' label='Год' value={year}
onChange={(event) => setYear(Number(event.target.value))} sx={{ width: '7.5rem' }}>
onChange={(event) => updateCurrentSheetFilters({ year: Number(event.target.value) })} sx={{ width: '7.5rem' }}>
{getAvailableYears().map((availableYear) => <MenuItem key={availableYear} value={availableYear}>{availableYear}</MenuItem>)}
</TextField>
<Autocomplete
multiple disableCloseOnSelect size='small' options={organizations} loading={organizationsLoading}
value={selectedOrganizations} isOptionEqualToValue={(option, value) => 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 && <CircularProgress size='1.125rem' />}{params.InputProps?.endAdornment}</>,
}} />}
/>
<TextField size='small' value={search} onChange={(event) => setSearch(event.target.value)}
<TextField size='small' value={search} onChange={(event) => updateCurrentSheetFilters({ search: event.target.value })}
placeholder='Код или статья расходов' sx={{ minWidth: '16.25rem', flex: '1 1 17.5rem', maxWidth: '23.75rem' }}
InputProps={{ startAdornment: <InputAdornment position='start'><SearchRoundedIcon sx={{ color: '#99a1af' }} /></InputAdornment> }} />
{!loading && filteredRowsCount !== rows.length && (
@ -344,7 +365,7 @@ export default function SvodPage() {
color='default'
startIcon={<RefreshRoundedIcon />}
disabled={loading}
onClick={() => setRequestVersion((version) => version + 1)}
onClick={refreshSummary}
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
Обновить
</Button>
@ -352,7 +373,7 @@ export default function SvodPage() {
</Paper>
{error ? (
<Alert severity='error' action={<Button color='inherit' size='small'
onClick={() => setRequestVersion((version) => version + 1)}>Повторить</Button>}>{error}</Alert>
onClick={refreshSummary}>Повторить</Button>}>{error}</Alert>
) : <MaterialReactTable table={table} />}
</Box>
);