Merge pull request 'search-table-scroll' (#114) from search-table-scroll into test

Reviewed-on: #114
This commit is contained in:
PotapovaA 2026-08-21 15:26:22 +03:00
commit 645107cef8
14 changed files with 753 additions and 366 deletions

View File

@ -9,7 +9,7 @@ import FormsPage from '../pages/FormsPage';
import LoginPage from '../pages/LoginPage';
import NewFormTablePage from '../pages/NewFormTablePage.jsx';
import NewTablePage from '../pages/NewTablePage.jsx';
import SummaryPage from '../pages/ProjectPages/SummaryPage.jsx';
import NavigationProjectPage from '../pages/ProjectPages/NavigationProjectPage.jsx';
import ProjectsPage from '../pages/ProjectsPage/ProjectsPage.jsx';
import SvodPage from '../pages/SvodPage/SvodPage.jsx';
import TablePage from '../pages/TablePage';
@ -39,7 +39,7 @@ export const AppRoutes = () => {
<Route element={<PrivateRoute />}>
<Route path='/' element={<TasksPage />} />
<Route path='/tasks' element={<TasksPage />} />
<Route path='/projects' element={<SummaryPage />} />
<Route path='/projects' element={<NavigationProjectPage />} />
<Route path='/forms' element={<FormsPage />} />
<Route path='/task/:taskId' element={<TaskPage />} />
<Route path='/project/:projectId' element={<TaskPage />} />
@ -54,7 +54,7 @@ export const AppRoutes = () => {
<Route path='/tables-new' element={<TablesTest />} />
<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={<SummaryPage />} />
<Route path='/project-mock-summary' element={<NavigationProjectPage />} />
<Route path='/svod' element={<SvodPage />} />
</Route>
</Route>

View File

@ -110,7 +110,7 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) =>
const cleanQueries = searchQueries.filter(Boolean);
if (cleanQueries.length === 0) return displayValue;
const searchStr = String(originalValue ?? displayValue);
const searchStr = String(displayValue ?? originalValue);
const escapedQueries = cleanQueries.map((q) => q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi');

View File

@ -17,7 +17,7 @@ import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal';
import { SelectVspModal } from './Modals/SelectVspModal';
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles } from './constants/tableConfig';
import { useRealtimeActions } from './contexts/RealtimeContext';
import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
import { useColumnSettings } from './hooks/useColumnSettings';
import { useHeaderPortal } from './hooks/useHeaderPortal';
import { useTableScale } from './hooks/useTableScale';
@ -39,15 +39,15 @@ const ROW_VIRTUALIZER_OPTIONS = {
};
const getColumnVirtualizerOptions = ({ table }) => {
const orderedVisibleColumns = [
...table.getLeftVisibleLeafColumns(),
...table.getCenterVisibleLeafColumns(),
...table.getRightVisibleLeafColumns(),
];
const orderedVisibleColumns = table.getVisibleLeafColumns();
const getColumnSize = (index) => orderedVisibleColumns[index]?.getSize() ?? 150;
const scrollPaddingStart = table.getLeftVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0);
const scrollPaddingEnd = table.getRightVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0);
return {
overscan: 10,
scrollPaddingStart,
scrollPaddingEnd,
estimateSize: getColumnSize,
measureElement: (element) => {
if (!element) return 150;
@ -59,6 +59,18 @@ const getColumnVirtualizerOptions = ({ table }) => {
const hasVspDropdown = (columns) =>
columns.some((column) => column.editType === 'vsp_dropdown' || (column.columns?.length && hasVspDropdown(column.columns)));
const normalizeFilterValue = (value) =>
String(value ?? '')
.trim()
.toLocaleLowerCase('ru-RU');
const doesCellMatchFilter = (rawValue, columnDef, query, vspRegistrationById) => {
if (normalizeFilterValue(rawValue).includes(query)) return true;
if (columnDef?.editType !== 'vsp_dropdown') return false;
return vspRegistrationById.get(Number(rawValue))?.includes(query) ?? false;
};
const tableContentStyle = {
position: 'relative',
width: '100%',
@ -79,6 +91,7 @@ const loadingOverlayStyle = {
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const { user } = useAuth();
const vspOptions = useVspOptions();
const userRoleId = user?.role_id;
const {
data,
@ -87,6 +100,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
editingCells: dataEditingCells,
} = useRealtimeData(formId, sheetName, direction, formType, year);
const [globalFilter, setGlobalFilter] = useState('');
const [searchMatchIndex, setSearchMatchIndex] = useState(-1);
const [activeSearchMatch, setActiveSearchMatch] = useState(null);
const [pendingSearchDirection, setPendingSearchDirection] = useState(0);
const [showColumnFilters, setShowColumnFilters] = useState(false);
const [selectedColumnId, setSelectedColumnId] = useState();
const [rowSelection, setRowSelection] = useState({});
@ -120,9 +136,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const handleGlobalFilterChange = useMemo(
() =>
debounce((value) => {
debounce((valueOrUpdater) => {
startTransition(() => {
setGlobalFilter(value);
setGlobalFilter((currentValue) => (typeof valueOrUpdater === 'function' ? valueOrUpdater(currentValue) : valueOrUpdater));
});
}, 300),
[startTransition],
@ -300,6 +316,23 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
[handleUpdateCell],
);
const vspRegistrationById = useMemo(
() => new Map(vspOptions.map((vsp) => [Number(vsp.id), normalizeFilterValue(vsp.registration_number)])),
[vspOptions],
);
const globalFilterFn = useCallback(
(row, columnId, filterValue) => {
const query = normalizeFilterValue(filterValue);
if (!query) return true;
const rawValue = row.getValue(columnId);
const column = row.getAllCells().find((cell) => cell.column.id === columnId)?.column;
return doesCellMatchFilter(rawValue, column?.columnDef, query, vspRegistrationById);
},
[vspRegistrationById],
);
const createTableConfig = ({
columns,
data,
@ -317,8 +350,11 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
setExpanded,
editingCell,
contextStartEditing,
globalFilterFn,
activeSearchMatch,
}) => ({
...BASE_TABLE_CONFIG,
globalFilterFn,
columns,
data,
enableRowVirtualization: true,
@ -355,7 +391,21 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
muiTableHeadCellProps: {
sx: { boxSizing: 'border-box' },
},
muiTableBodyCellProps: getTableBodyCellProps,
muiTableBodyCellProps: (props) => {
const cellProps = getTableBodyCellProps(props);
const isActiveMatch = activeSearchMatch?.rowId === props.row.id && activeSearchMatch?.columnId === props.column.id;
return {
...cellProps,
sx: {
...cellProps.sx,
...(isActiveMatch && {
zIndex: 7,
boxShadow: 'inset 0 0 0 3px #1976d2',
}),
},
};
},
muiTableHeadProps: {
sx: {
display: 'table-header-group',
@ -406,6 +456,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
setExpanded,
editingCell,
contextStartEditing,
globalFilterFn,
activeSearchMatch,
}),
[
columns,
@ -422,6 +474,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
setColumnPinning,
editingCell,
expanded,
globalFilterFn,
activeSearchMatch,
],
);
@ -431,31 +485,108 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const handleNavigateToColumn = useCallback(
(columnId) => {
if (!columnId) return;
console.log(columnId);
console.log(columnId);
console.log(columnId);
console.log(columnId);
setSelectedColumnId(columnId);
const pinnedIds = new Set([...(columnPinning?.left || []), ...(columnPinning?.right || [])]);
if (pinnedIds.has(columnId)) return;
const visibleColumns = table.getVisibleLeafColumns();
const columnIndex = visibleColumns.findIndex((column) => column.id === columnId);
if (columnIndex < 0) return;
const columnVirtualizer = columnVirtualizerRef.current;
const container = containerRef.current;
if (!container) return;
const pinnedIds = new Set(columnPinning?.left || []);
if (pinnedIds.has(columnId)) return;
const centerColumns = table.getVisibleLeafColumns().filter((column) => !pinnedIds.has(column.id));
let offset = 0;
for (const column of centerColumns) {
if (column.id === columnId) break;
offset += column.getSize();
}
const leftPinnedSize = table.getLeftVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0);
const calculatedStart = visibleColumns.slice(0, columnIndex).reduce((offset, column) => offset + column.getSize(), 0);
const measurement = columnVirtualizer?.measurementsCache?.[columnIndex];
const targetStart = measurement?.start ?? calculatedStart;
const targetOffset = Math.max(0, targetStart - leftPinnedSize);
container.scrollTo({
left: Math.max(0, offset - 40),
behavior: 'smooth',
left: targetOffset,
});
},
[columnPinning, containerRef, table],
);
const searchMatches = useMemo(() => {
const query = normalizeFilterValue(globalFilter);
if (!query) return [];
const matches = [];
for (const [rowIndex, row] of table.getRowModel().rows.entries()) {
for (const cell of row.getVisibleCells()) {
if (doesCellMatchFilter(cell.getValue(), cell.column.columnDef, query, vspRegistrationById)) {
matches.push({ rowId: row.id, rowIndex, columnId: cell.column.id });
}
}
}
return matches;
}, [data, globalFilter, table, vspRegistrationById, columnVisibility]);
const navigateToSearchMatch = useCallback(
(direction) => {
if (!searchMatches.length) return;
setSearchMatchIndex((currentIndex) => {
const startIndex = currentIndex < 0 ? (direction > 0 ? -1 : 0) : currentIndex;
const nextIndex = (startIndex + direction + searchMatches.length) % searchMatches.length;
const match = searchMatches[nextIndex];
setActiveSearchMatch(match);
setRowSelection({ [match.rowId]: true });
console.log(match.columnId);
handleNavigateToColumn(match.columnId);
requestAnimationFrame(() => {
rowVirtualizerRef.current?.scrollToIndex?.(match.rowIndex < 5 ? match.rowIndex : match.rowIndex + 5, { align: 'auto', behavior: 'smooth' });
});
return nextIndex;
});
},
[handleNavigateToColumn, searchMatches],
);
const handleSearchKeyDown = useCallback(
(event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
const direction = event.shiftKey ? -1 : 1;
const inputValue = event.target.value;
if (normalizeFilterValue(inputValue) !== normalizeFilterValue(globalFilter)) {
handleGlobalFilterChange.clear();
setGlobalFilter(inputValue);
setPendingSearchDirection(direction);
return;
}
navigateToSearchMatch(direction);
},
[globalFilter, handleGlobalFilterChange, navigateToSearchMatch],
);
useEffect(() => {
setSearchMatchIndex(-1);
setActiveSearchMatch(null);
}, [globalFilter]);
useEffect(() => {
if (!pendingSearchDirection) return;
navigateToSearchMatch(pendingSearchDirection);
setPendingSearchDirection(0);
}, [navigateToSearchMatch, pendingSearchDirection]);
useEffect(() => {
if (!dataEditingCells?.line_id) {
setEditingCell(null);
@ -623,6 +754,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
columnsCurStage={columnsCurStage}
onChangeSizeMult={setSizeMult}
onGlobalFilterChange={handleGlobalFilterChange}
globalFilter={globalFilter}
onSearchKeyDown={handleSearchKeyDown}
onSearchNext={() => navigateToSearchMatch(1)}
onSearchPrevious={() => navigateToSearchMatch(-1)}
searchMatchCount={searchMatches.length}
searchMatchIndex={searchMatchIndex}
sizeMult={sizeMult}
onChangeShowColumnFilters={setShowColumnFilters}
showColumnFilters={showColumnFilters}

View File

@ -32,6 +32,32 @@ const controlSx = {
};
const searchSx = { height: '2.5rem' };
const searchNavigationSx = {
display: 'inline-flex',
alignItems: 'center',
height: '2rem',
border: '1px solid rgba(0, 0, 0, 0.12)',
borderRadius: '0.4rem',
overflow: 'hidden',
backgroundColor: '#fff',
};
const searchNavigationButtonSx = {
width: '1.65rem',
height: '2rem',
minWidth: '1.65rem',
padding: 0,
borderRadius: 0,
fontSize: '0.95rem',
};
const searchMatchCounterSx = {
minWidth: '2.7rem',
padding: '0 0.3rem',
fontSize: '0.7rem',
lineHeight: 1,
textAlign: 'center',
whiteSpace: 'nowrap',
color: 'rgba(0, 0, 0, 0.65)',
};
const exportButtonSx = { height: '2.5rem', minHeight: '2.5rem' };
const SettingPanel = ({
@ -46,6 +72,12 @@ const SettingPanel = ({
columnsCurStage,
onChangeSizeMult,
onGlobalFilterChange,
globalFilter,
onSearchKeyDown,
onSearchNext,
onSearchPrevious,
searchMatchCount,
searchMatchIndex,
sizeMult,
onChangeShowColumnFilters,
showColumnFilters,
@ -218,7 +250,40 @@ const SettingPanel = ({
</GroupByObject>
<Divider orientation='vertical' flexItem />
<GroupByObject title='Поиск'>
<SearchComponent onChange={onGlobalFilterChange} height='2.5rem' sx={searchSx} />
<SearchComponent
value={globalFilter}
onChange={onGlobalFilterChange}
onKeyDown={onSearchKeyDown}
height='2.5rem'
sx={searchSx}
/>
<span style={searchNavigationSx}>
<Tooltip title='Предыдущий результат (Shift+Enter)'>
<span>
<IconButton
onClick={onSearchPrevious}
disabled={!searchMatchCount}
sx={searchNavigationButtonSx}
aria-label='Предыдущий результат поиска'>
</IconButton>
</span>
</Tooltip>
<span style={searchMatchCounterSx}>
{searchMatchCount ? `${Math.max(searchMatchIndex + 1, 1)}/${searchMatchCount}` : '0/0'}
</span>
<Tooltip title='Следующий результат (Enter)'>
<span>
<IconButton
onClick={onSearchNext}
disabled={!searchMatchCount}
sx={searchNavigationButtonSx}
aria-label='Следующий результат поиска'>
</IconButton>
</span>
</Tooltip>
</span>
<Tooltip title='Поиск по колонкам'>
<IconButton
variant='outlined'

View File

@ -44,6 +44,16 @@ export const FORM_TYPE_TRANSLATE = {
};
export const DIRECTION_TRANSLATE = { Support: 'Поддержка', Development: 'Развитие' };
export const PROJECT_TYPES = ['Открытие ВСП', 'Закрытие ВСП', 'Переезд ВСП', 'Реновация РФ', 'Открытие УРМ'];
export const PROJECT_STATUSES = {
created: 'Создан',
agreed: 'Согласован',
approved: 'Согласован',
archived: 'В архиве',
deleted: 'Удалён',
};
export const ORG_UNIT_TYPE_OPTIONS = [
{ value: 'ssp', label: 'ССП' },
{ value: 'rf', label: 'Региональный филиал' },

View File

@ -1,3 +1,5 @@
import { PROJECT_TYPES } from './constants';
export const DEFAULT_PROJECT_CONFIG = {
'ССП/РФ': {
type: 'multiselect',
@ -16,7 +18,7 @@ export const DEFAULT_PROJECT_CONFIG = {
},
'Тип проекта развития': {
type: 'multiselect',
options: ['Открытие ВСП', 'Закрытие ВСП', 'Переезд ВСП', 'Реновация РФ', 'Открытие УРМ'],
options: PROJECT_TYPES,
placeholder: 'Выберите тип проекта',
defaultValue: null,
required_field: true,

View File

@ -8,6 +8,7 @@ import { DEVELOMENT_BLOCK, FUNDING_BY_DECISION } from '../../../ProjectPages/con
import {
DIRECTION_TRANSLATE,
FORM_TYPE_TRANSLATE,
PROJECT_STATUSES,
ROLES_ID_RUSSIAN_NAME,
SHEET_NAME,
STAGE_ROLE_RUSSIAN_NAME,
@ -50,14 +51,6 @@ export const VALUE_TRANSFORMERS = {
'Дата открытия / переезда / закрытия': transformDateChange,
};
const PROJECT_STATUSES = {
created: 'Создан',
agreed: 'Согласован',
approved: 'Согласован',
archived: 'В архиве',
deleted: 'Удалён',
};
const PLACEMENT_TYPES = {
own: 'Собственность',
rent: 'Аренда',

View File

@ -15,6 +15,7 @@ import { NameTask, TaskInfoContainer } from '../components/common/SwitchFormTask
import TableList from '../components/common/TableList/TableList';
import { TableIcon } from '../components/common/icons/icons';
import { Chip } from '../components/styles/StyledChip';
import { PROJECT_TYPES } from '../constants/constants';
const FormInfo = ({ form }) => {
const portalContent = (
@ -123,7 +124,7 @@ export default function FormPage() {
add_projects_data: {
'Тип проекта развития': {
type: 'multiselect',
options: ['Открытие ВСП', 'Закрытие ВСП', 'Переезд ВСП', 'Реновация РФ', 'Открытие УРМ'],
options: PROJECT_TYPES,
placeholder: '',
defaultValue: null,
},

View File

@ -1,5 +1,4 @@
import { Box, CircularProgress, Paper, Typography } from '@mui/material';
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { Box, Typography } from '@mui/material';
import { useEffect, useMemo, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
@ -11,14 +10,15 @@ import { WhitePlus } from '../../components/common/icons/icons';
import Modal from '../../components/common/Modal/Modal';
import { DEFAULT_PROJECT_CONFIG } from '../../constants/projectConfig';
import { ModalCreateProject } from '../ProjectsPage/ModalCreateProject';
import { createFirstColumns, secondColumns } from './columns';
import EditModal from './components/EditModal/EditModal';
import ProjectsTable from './components/ProjectsTable/ProjectsTable';
import ProjectSummaryTable from './components/ProjectSummaryTable/ProjectSummaryTable';
import TableFilters from './components/TableFilters/TableFilters'; // Импортируем компонент фильтров
import { handleNavigateClick } from './utils/tableHandlers';
import { useAuth } from '../../app/context/AuthProvider';
import { ROLES_NAME_ID } from '../../constants/constants';
const SummaryPage = () => {
const NavigationProjectPage = () => {
const navigate = useNavigate();
const [selectedRow, setSelectedRow] = useState(null);
@ -42,6 +42,15 @@ const SummaryPage = () => {
// Состояния для фильтров
const [selectedBranch, setSelectedBranch] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [selectedStatus, setSelectedStatus] = useState('');
const [archivedData, setArchivedData] = useState([]);
const [archivedTotalCount, setArchivedTotalCount] = useState(0);
const [archivedPagination, setArchivedPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const isArchiveMode = selectedStatus === 'archived';
const loadData = useCallback(async () => {
if (!user) return;
@ -94,10 +103,7 @@ const SummaryPage = () => {
const loadProjects = async () => {
setIsProjectsLoading(true);
try {
const response = await ProjectsApi.listWithReports({
limit: 1000,
...(selectedBranch?.id ? { branch_id: selectedBranch.id } : {}),
});
const response = await ProjectsApi.listWithReports({ limit: 1000 });
if (active) setTableData(response.result || []);
} catch (requestError) {
console.error('Error loading projects with reports:', requestError);
@ -110,7 +116,75 @@ const SummaryPage = () => {
return () => {
active = false;
};
}, [user, isLoading, selectedBranch?.id, projectsReloadKey]);
}, [user, isLoading, projectsReloadKey]);
useEffect(() => {
let active = true;
if (!user || isLoading || !isArchiveMode) return undefined;
const loadArchivedProjects = async () => {
setIsProjectsLoading(true);
try {
const offset = archivedPagination.pageIndex * archivedPagination.pageSize;
const params = {
status: 'archived',
offset,
limit: archivedPagination.pageSize,
...(selectedBranch?.id ? { branch_id: selectedBranch.id } : {}),
...(searchQuery.trim() ? { search: searchQuery.trim() } : {}),
};
// TODO: после реализации серверных фильтров и пагинации достаточно
// передать params в listWithReports и использовать response.result/count.
// Пока запрос возвращает полный набор, а код ниже имитирует ответ бэка.
const response = await ProjectsApi.listWithReports(params);
const query = searchQuery.toLowerCase().trim();
const filtered = (response.result || [])
.filter((item) => item.status === 'archived')
.filter((item) => !selectedBranch || item.org_unit_id === selectedBranch.id)
.map((item) => {
if (!query) return item;
return {
...item,
sub_rows: item.sub_rows?.filter((subItem) =>
(subItem.project || '').toLowerCase().includes(query),
) || [],
};
})
.filter((item) => !query
|| (item.name || '').toLowerCase().includes(query)
|| item.sub_rows.length > 0);
if (active) {
setArchivedData(filtered.slice(offset, offset + archivedPagination.pageSize));
setArchivedTotalCount(filtered.length);
}
} catch (requestError) {
console.error('Error loading archived projects:', requestError);
if (active) {
setArchivedData([]);
setArchivedTotalCount(0);
toast.error('Не удалось загрузить архивные проекты');
}
} finally {
if (active) setIsProjectsLoading(false);
}
};
loadArchivedProjects();
return () => {
active = false;
};
}, [
user,
isLoading,
isArchiveMode,
selectedBranch,
searchQuery,
archivedPagination.pageIndex,
archivedPagination.pageSize,
projectsReloadKey,
]);
useEffect(() => {
let active = true;
@ -142,13 +216,22 @@ const SummaryPage = () => {
};
}, [selectedRow?.original?.id]);
const getFilteredData = () => {
if (!selectedBranch && !searchQuery) {
const filteredData = useMemo(() => {
if (isArchiveMode) {
return archivedData;
}
if (!selectedBranch && !searchQuery && !selectedStatus) {
return tableData;
}
const query = searchQuery.toLowerCase().trim();
return tableData
.filter((item) => {
if (selectedStatus && item.status !== selectedStatus) {
return false;
}
// Фильтр по филиалу (только для корневых)
if (selectedBranch) {
return item.org_unit_id === selectedBranch.id;
@ -156,15 +239,10 @@ const SummaryPage = () => {
return true;
})
.map((item) => {
if (!searchQuery) {
if (!query) {
return item;
}
const query = searchQuery.toLowerCase().trim();
// Проверяем, совпадает ли родитель
const parentMatches = (item.name || '').toLowerCase().includes(query);
// Фильтруем дочерние элементы - оставляем только те, что совпадают с поиском
const filteredSubRows = item.sub_rows?.filter((subItem) => (subItem.project || '').toLowerCase().includes(query)) || [];
@ -175,32 +253,18 @@ const SummaryPage = () => {
};
})
.filter((item) => {
if (!searchQuery) {
if (!query) {
return true;
}
const query = searchQuery.toLowerCase().trim();
const parentMatches = (item.name || '').toLowerCase().includes(query);
const hasMatchingChildren = item.sub_rows && item.sub_rows.length > 0;
return parentMatches || hasMatchingChildren;
});
};
const filteredData = getFilteredData();
}, [archivedData, isArchiveMode, searchQuery, selectedBranch, selectedStatus, tableData]);
const handleRowClick = (row) => {
if (row.depth === 0 && row) {
setSelectedRow(row);
}
};
const isChildOfSelectedRow = (row) => {
if (!selectedRow) return false;
const parentRowId = row.parentId;
return parentRowId === selectedRow.id;
};
const onEdit = (row) => {
const onEdit = useCallback((row) => {
const isSmeta = row.depth > 0;
const parentProject = isSmeta ? row.getParentRow()?.original : row.original;
const projectId = parentProject?.id;
@ -211,11 +275,11 @@ const SummaryPage = () => {
_projectFundingByKoDecision: parentProject?.funding_by_ko_decision,
});
setEditModalOpen(true);
};
}, []);
const onNavigate = (row) => {
const onNavigate = useCallback((row) => {
handleNavigateClick(row, navigate);
};
}, [navigate]);
const handleDeleteProject = async () => {
if (!projectToDelete?.id) return;
@ -281,241 +345,24 @@ const SummaryPage = () => {
}
};
const firstColumns = useMemo(
() => createFirstColumns({ onDelete: setProjectToDelete, onEdit, onNavigate, orgUnitNames }),
[orgUnitNames],
);
const tableHeadCellStyles = {
fontWeight: 700,
fontSize: '12px',
lineHeight: 1.15,
textAlign: 'left',
backgroundColor: 'rgb(248, 249, 250)',
borderRight: '1px solid #e0e0e0',
'&:last-child': {
borderRight: 'none',
},
whiteSpace: 'nowrap',
position: 'sticky',
top: 0,
boxShadow: 'none',
padding: '0.5rem 1rem',
'& .MuiTableSortLabel-icon': {
fontSize: '0.875rem',
},
'& .MuiTableSortLabel-iconDirectionDesc': {
fontSize: '0.875rem',
},
'& .MuiTableSortLabel-iconDirectionAsc': {
fontSize: '0.875rem',
},
};
const projectsTableOptions = {
enableColumnActions: false,
enableColumnFilters: false,
enablePagination: false,
enableSorting: true,
enableBottomToolbar: false,
enableTopToolbar: false,
enableExpanding: true,
getSubRows: (row) => row.sub_rows,
muiTableHeadCellProps: {
sx: tableHeadCellStyles,
},
muiExpandButtonProps: {
sx: {
width: '1.5rem',
height: '1.5rem',
minWidth: '1.5rem',
'& .MuiSvgIcon-root': {
fontSize: '0.875rem',
},
},
},
muiTableBodyRowProps: ({ row }) => {
const isSelected = selectedRow && row.id === selectedRow.id;
const isChild = isChildOfSelectedRow(row);
return {
sx: {
backgroundColor: '#ffffff',
'&:hover': {
backgroundColor: 'rgb(248, 250, 252)',
},
'&:hover td::after': {
backgroundColor: 'transparent !important',
},
...(row.depth > 0 && {
backgroundColor: 'rgb(252, 252, 253)',
}),
...((isSelected || isChild) && {
backgroundColor: 'rgb(234, 247, 236)',
'&:hover': {
backgroundColor: 'rgb(225, 242, 229) !important',
},
}),
},
onClick: () => handleRowClick(row),
};
},
muiTableBodyCellProps: ({ row, column }) => ({
sx: {
borderRight: '1px solid #e0e0e0',
'&:last-child': {
borderRight: 'none',
},
'&:hover td::after': {
backgroundColor: 'transparent !important',
},
textAlign: 'left',
fontSize: '0.875rem',
padding: '0.5rem 1rem',
...(column.id === 'mrt-row-expand' && {
width: '1.5rem',
maxWidth: '1.5rem',
minWidth: '1.5rem',
padding: '0.5rem 0.25rem',
}),
...(column.id === 'project' &&
row.depth > 0 && {
pl: `${2 + row.depth * 0.5}rem`,
}),
},
}),
muiTablePaperProps: {
elevation: 0,
sx: {
border: '1px solid #e0e0e0',
borderRadius: '1rem',
overflow: 'hidden',
height: '45vh',
display: 'flex',
flexDirection: 'column',
},
},
muiTableContainerProps: {
sx: {
flex: 1,
overflow: 'auto',
'& thead': {
position: 'sticky',
top: 0,
zIndex: 10,
},
},
},
};
const summaryProjectTableOptions = {
enableColumnActions: false,
enableColumnFilters: false,
enablePagination: false,
enableSorting: true,
enableBottomToolbar: false,
enableTopToolbar: false,
enableExpanding: false,
muiTableBodyRowProps: ({ row }) => ({
sx: {
backgroundColor: '#ffffff',
'&:hover': {
backgroundColor: 'rgb(248, 250, 252)',
},
'&:hover td::after': {
backgroundColor: 'transparent !important',
},
boxShadow: 'none',
},
}),
muiTableHeadCellProps: {
sx: tableHeadCellStyles,
},
muiTableBodyCellProps: ({ column }) => ({
sx: {
borderRight: '1px solid #e0e0e0',
'&:last-child': {
borderRight: 'none',
},
textAlign: 'left',
fontSize: '0.875rem',
padding: '0.5rem 1rem',
},
}),
muiTablePaperProps: {
elevation: 0,
sx: {
border: '1px solid #e0e0e0',
borderRadius: '0 0 1rem 1rem',
overflow: 'hidden',
height: '100%',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
},
},
muiTableContainerProps: {
sx: {
flex: 1,
overflow: 'auto',
'& thead': {
position: 'sticky',
top: 0,
zIndex: 10,
},
},
},
initialState: {
columnPinning: { left: ['project'] },
},
};
const projectsTable = useMaterialReactTable({
columns: firstColumns,
data: filteredData,
...projectsTableOptions,
localization: {
expand: 'Раскрыть',
expandAll: 'Раскрыть все',
collapse: 'Свернуть',
collapseAll: 'Свернуть все',
noRecordsToDisplay: 'Нет данных для отображения',
},
initialState: {
columnPinning: { right: ['actions'], left: ['mrt-row-expand', 'project'] },
},
state: { isLoading: isLoading || isProjectsLoading },
});
const summaryProjectTable = useMaterialReactTable({
columns: secondColumns,
data: summaryData,
...summaryProjectTableOptions,
localization: {
noRecordsToDisplay: 'Нет данных для отображения',
},
initialState: {
columnPinning: { left: ['data.header.name'] },
},
state: { isLoading: isSummaryLoading },
});
const getSelectedProjectTitle = () => {
if (selectedRow?.original.name) {
return selectedRow.original.name;
}
return 'Проект не выбран';
};
// Обработчики фильтров
const handleBranchChange = (value) => {
setSelectedRow(null);
setSummaryData([]);
setSelectedBranch(value);
setArchivedPagination((current) => ({ ...current, pageIndex: 0 }));
};
const handleSearchChange = (value) => {
setSearchQuery(value);
setArchivedPagination((current) => ({ ...current, pageIndex: 0 }));
};
const handleStatusChange = (value) => {
setSelectedRow(null);
setSummaryData([]);
setSelectedStatus(value);
setArchivedPagination((current) => ({ ...current, pageIndex: 0 }));
};
const handleCreateProject = async (formData) => {
@ -559,67 +406,34 @@ const SummaryPage = () => {
onBranchChange={handleBranchChange}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
selectedStatus={selectedStatus}
onStatusChange={handleStatusChange}
/>
<PrimaryButton onClick={() => setCreateModalOpen(true)} startIcon={<WhitePlus />} sx={{ height: '2.5rem' }}>
Создать проект
</PrimaryButton>
</Box>
{/* Первая таблица */}
<Paper
sx={{
mb: '2rem',
overflow: 'hidden',
flex: '0 0 45vh',
borderRadius: '1rem',
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
}}>
<MaterialReactTable table={projectsTable} />
</Paper>
<ProjectsTable
data={filteredData}
isLoading={isLoading || isProjectsLoading}
isArchiveMode={isArchiveMode}
archivedTotalCount={archivedTotalCount}
archivedPagination={archivedPagination}
onPaginationChange={setArchivedPagination}
selectedRow={selectedRow}
onRowSelect={setSelectedRow}
onDelete={setProjectToDelete}
onEdit={onEdit}
onNavigate={onNavigate}
orgUnitNames={orgUnitNames}
/>
{/* Вторая таблица */}
<Paper
sx={{
overflow: 'hidden',
flex: '0 0 35vh',
borderRadius: '1rem',
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
}}>
<Typography
variant='subtitle1'
sx={{
p: '0.75rem 1.5rem',
backgroundColor: 'white',
borderBottom: '1px solid #e0e0e0',
fontWeight: 'bold',
flexShrink: 0,
fontSize: '1.25rem',
}}>
{getSelectedProjectTitle()}
</Typography>
{selectedRow ? (
<Box sx={{ height: 'calc(35vh - 3rem)' }}>
{isSummaryLoading && !summaryData.length ? (
<Box sx={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress size={32} />
</Box>
) : <MaterialReactTable table={summaryProjectTable} />}
</Box>
) : (
<Box
sx={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgb(152, 162, 179)',
fontSize: '1rem',
fontWeight: 600,
}}>
Разверните или выберите проект в верхней таблице
</Box>
)}
</Paper>
<ProjectSummaryTable
data={summaryData}
isLoading={isSummaryLoading}
selectedRow={selectedRow}
/>
{/* Модалка редактирования */}
<EditModal open={editModalOpen} onClose={() => setEditModalOpen(false)} rowData={selectedRowData} onSave={onSaveEdit} isSaving={isSaving} orgUnitNames={orgUnitNames} />
@ -654,4 +468,4 @@ const SummaryPage = () => {
);
};
export default SummaryPage;
export default NavigationProjectPage;

View File

@ -3,9 +3,12 @@ import { Box, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Men
import React from 'react';
import { useAuth } from '../../../../app/context/AuthProvider';
import { DangerOutlinedButton, PrimaryButton } from '../../../../components/common/Buttons/Buttons';
import { PROJECT_STATUSES, PROJECT_TYPES } from '../../../../constants/constants';
import { DEVELOMENT_BLOCK, FUNDING_BY_DECISION } from '../../constants';
import { canEditFirstTableField, hasReserveValuesInChildSmetas, isFirstTableFieldDependencySatisfied } from '../../constants/fieldAccess';
const PROJECT_STATUS_OPTIONS = ['created', 'agreed', 'archived'].map((status) => [status, PROJECT_STATUSES[status]]);
const EditModal = ({ open, onClose, rowData, onSave, isSaving = false, orgUnitNames = {} }) => {
const { user } = useAuth();
const [formData, setFormData] = React.useState(rowData || {});
@ -53,10 +56,10 @@ const EditModal = ({ open, onClose, rowData, onSave, isSaving = false, orgUnitNa
const projectFields = [
{ key: 'name', label: 'Проект' },
{ key: 'status', label: 'Статус', options: [['created', 'Создан'], ['agreed', 'Согласован'], ['archived', 'В архиве']] },
{ key: 'status', label: 'Статус', options: PROJECT_STATUS_OPTIONS },
{ key: 'technical_number', label: 'Технический номер проекта' },
{ key: 'org_unit_id', label: 'ССП/РФ', valueType: 'number', options: Object.entries(orgUnitNames).map(([id, title]) => [Number(id), title]) },
{ key: 'project_type', label: 'Тип проекта', options: ['Открытие ВСП', 'Закрытие ВСП', 'Переезд ВСП', 'Реновация РФ', 'Открытие УРМ'] },
{ key: 'project_type', label: 'Тип проекта', options: PROJECT_TYPES },
{ key: 'vsp_format', label: 'Формат ВСП', options: ['Флагманский', 'Типовой', 'Розничный', 'МСБ', 'Лёгкий', 'Мини', 'Розничный-киоск', 'МБО', 'Офис самообслуживания', 'Другое'] },
{ key: 'placement_type', label: 'Размещение', options: ['Собственность', 'Аренда', 'Субаренда'] },
{ key: 'staff_count', label: 'Количество сотрудников', type: 'number', valueType: 'number', min: 0 },

View File

@ -0,0 +1,148 @@
import { Box, CircularProgress, Paper, Typography } from '@mui/material';
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { secondColumns } from '../../columns';
const tableHeadCellStyles = {
fontWeight: 700,
fontSize: '12px',
lineHeight: 1.15,
textAlign: 'left',
backgroundColor: 'rgb(248, 249, 250)',
borderRight: '1px solid #e0e0e0',
'&:last-child': {
borderRight: 'none',
},
whiteSpace: 'nowrap',
position: 'sticky',
top: 0,
boxShadow: 'none',
padding: '0.5rem 1rem',
'& .MuiTableSortLabel-icon': {
fontSize: '0.875rem',
},
'& .MuiTableSortLabel-iconDirectionDesc': {
fontSize: '0.875rem',
},
'& .MuiTableSortLabel-iconDirectionAsc': {
fontSize: '0.875rem',
},
};
const ProjectSummaryTable = ({ data, isLoading, selectedRow }) => {
const table = useMaterialReactTable({
columns: secondColumns,
data,
enableColumnActions: false,
enableColumnFilters: false,
enablePagination: false,
enableSorting: true,
enableBottomToolbar: false,
enableTopToolbar: false,
enableExpanding: false,
muiTableBodyRowProps: {
sx: {
backgroundColor: '#ffffff',
'&:hover': {
backgroundColor: 'rgb(248, 250, 252)',
},
'&:hover td::after': {
backgroundColor: 'transparent !important',
},
boxShadow: 'none',
},
},
muiTableHeadCellProps: {
sx: tableHeadCellStyles,
},
muiTableBodyCellProps: {
sx: {
borderRight: '1px solid #e0e0e0',
'&:last-child': {
borderRight: 'none',
},
textAlign: 'left',
fontSize: '0.875rem',
padding: '0.5rem 1rem',
},
},
muiTablePaperProps: {
elevation: 0,
sx: {
border: '1px solid #e0e0e0',
borderRadius: '0 0 1rem 1rem',
overflow: 'hidden',
height: '100%',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
},
},
muiTableContainerProps: {
sx: {
flex: 1,
overflow: 'auto',
'& thead': {
position: 'sticky',
top: 0,
zIndex: 10,
},
},
},
localization: {
noRecordsToDisplay: 'Нет данных для отображения',
},
initialState: {
columnPinning: { left: ['data.header.name'] },
},
state: { isLoading },
});
return (
<Paper
sx={{
overflow: 'hidden',
flex: '0 0 35vh',
borderRadius: '1rem',
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
}}>
<Typography
variant='subtitle1'
sx={{
p: '0.75rem 1.5rem',
backgroundColor: 'white',
borderBottom: '1px solid #e0e0e0',
fontWeight: 'bold',
flexShrink: 0,
fontSize: '1.25rem',
}}>
{selectedRow?.original.name || 'Проект не выбран'}
</Typography>
{selectedRow ? (
<Box sx={{ height: 'calc(35vh - 3rem)' }}>
{isLoading && !data.length ? (
<Box sx={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress size={32} />
</Box>
) : (
<MaterialReactTable table={table} />
)}
</Box>
) : (
<Box
sx={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgb(152, 162, 179)',
fontSize: '1rem',
fontWeight: 600,
}}>
Разверните или выберите проект в верхней таблице
</Box>
)}
</Paper>
);
};
export default ProjectSummaryTable;

View File

@ -0,0 +1,189 @@
import { Paper } from '@mui/material';
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { useMemo } from 'react';
import { createFirstColumns } from '../../columns';
const tableHeadCellStyles = {
fontWeight: 700,
fontSize: '12px',
lineHeight: 1.15,
textAlign: 'left',
backgroundColor: 'rgb(248, 249, 250)',
borderRight: '1px solid #e0e0e0',
'&:last-child': {
borderRight: 'none',
},
whiteSpace: 'nowrap',
position: 'sticky',
top: 0,
boxShadow: 'none',
padding: '0.5rem 1rem',
'& .MuiTableSortLabel-icon': {
fontSize: '0.875rem',
},
'& .MuiTableSortLabel-iconDirectionDesc': {
fontSize: '0.875rem',
},
'& .MuiTableSortLabel-iconDirectionAsc': {
fontSize: '0.875rem',
},
};
const ProjectsTable = ({
data,
isLoading,
isArchiveMode,
archivedTotalCount,
archivedPagination,
onPaginationChange,
selectedRow,
onRowSelect,
onDelete,
onEdit,
onNavigate,
orgUnitNames,
}) => {
const columns = useMemo(
() => createFirstColumns({ onDelete, onEdit, onNavigate, orgUnitNames }),
[onDelete, onEdit, onNavigate, orgUnitNames],
);
const table = useMaterialReactTable({
columns,
data,
enableColumnActions: false,
enableColumnFilters: false,
enablePagination: isArchiveMode,
manualPagination: isArchiveMode,
enableSorting: true,
enableBottomToolbar: isArchiveMode,
enableTopToolbar: false,
enableExpanding: true,
getSubRows: (row) => row.sub_rows,
muiTableHeadCellProps: {
sx: tableHeadCellStyles,
},
muiExpandButtonProps: {
sx: {
width: '1.5rem',
height: '1.5rem',
minWidth: '1.5rem',
'& .MuiSvgIcon-root': {
fontSize: '0.875rem',
},
},
},
muiTableBodyRowProps: ({ row }) => {
const isSelected = selectedRow && row.id === selectedRow.id;
const isChild = selectedRow && row.parentId === selectedRow.id;
return {
sx: {
backgroundColor: '#ffffff',
'&:hover': {
backgroundColor: 'rgb(248, 250, 252)',
},
'&:hover td::after': {
backgroundColor: 'transparent !important',
},
...(row.depth > 0 && {
backgroundColor: 'rgb(252, 252, 253)',
}),
...((isSelected || isChild) && {
backgroundColor: 'rgb(234, 247, 236)',
'&:hover': {
backgroundColor: 'rgb(225, 242, 229) !important',
},
}),
},
onClick: () => {
if (row.depth === 0) onRowSelect(row);
},
};
},
muiTableBodyCellProps: ({ row, column }) => ({
sx: {
borderRight: '1px solid #e0e0e0',
'&:last-child': {
borderRight: 'none',
},
'&:hover td::after': {
backgroundColor: 'transparent !important',
},
textAlign: 'left',
fontSize: '0.875rem',
padding: '0.5rem 1rem',
...(column.id === 'mrt-row-expand' && {
width: '1.5rem',
maxWidth: '1.5rem',
minWidth: '1.5rem',
padding: '0.5rem 0.25rem',
}),
...(column.id === 'project' &&
row.depth > 0 && {
pl: `${2 + row.depth * 0.5}rem`,
}),
},
}),
muiTablePaperProps: {
elevation: 0,
sx: {
border: '1px solid #e0e0e0',
borderRadius: '1rem',
overflow: 'hidden',
height: isArchiveMode ? 'auto' : '45vh',
display: 'flex',
flexDirection: 'column',
},
},
muiTableContainerProps: {
sx: {
flex: 1,
overflowX: 'auto',
overflowY: isArchiveMode ? 'visible' : 'auto',
'& thead': {
position: 'sticky',
top: 0,
zIndex: 10,
},
},
},
localization: {
expand: 'Раскрыть',
expandAll: 'Раскрыть все',
collapse: 'Свернуть',
collapseAll: 'Свернуть все',
noRecordsToDisplay: 'Нет данных для отображения',
rowsPerPage: 'Строк на странице',
of: 'из',
},
initialState: {
columnPinning: { right: ['actions'], left: ['mrt-row-expand', 'project'] },
},
...(isArchiveMode
? {
rowCount: archivedTotalCount,
onPaginationChange,
}
: {}),
state: {
isLoading,
...(isArchiveMode ? { pagination: archivedPagination } : {}),
},
});
return (
<Paper
sx={{
mb: '2rem',
overflow: 'hidden',
flex: isArchiveMode ? '0 0 auto' : '0 0 45vh',
borderRadius: '1rem',
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
}}>
<MaterialReactTable table={table} />
</Paper>
);
};
export default ProjectsTable;

View File

@ -1,26 +1,22 @@
import { PROJECT_STATUSES } from '../../../../constants/constants';
import styles from './StatusBadge.module.css';
const StatusBadge = ({ status, className = '', ...props }) => {
const statusMap = {
deleted: {
class: styles.deleted,
label: 'Удален',
},
approved: {
class: styles.approved,
label: 'Согласован',
},
agreed: {
class: styles.approved,
label: 'Согласован',
},
archived: {
class: styles.archived,
label: 'В архиве',
},
created: {
class: styles.created,
label: 'Создан',
},
};
@ -31,7 +27,7 @@ const StatusBadge = ({ status, className = '', ...props }) => {
return (
<span className={`${styles.badge} ${currentStatus.class} ${className}`} {...props}>
{currentStatus.label}
{PROJECT_STATUSES[status]}
</span>
);
};

View File

@ -1,12 +1,21 @@
import { Search } from '@mui/icons-material';
import { Autocomplete, Box, Chip, CircularProgress, Paper, TextField } from '@mui/material';
import { Autocomplete, Box, Chip, CircularProgress, MenuItem, Paper, TextField } from '@mui/material';
import { useEffect, useState, useCallback } from 'react';
import { SspApi } from '../../../../api/ssp';
import { useAuth } from '../../../../app/context/AuthProvider';
import { toast } from 'react-toastify';
import { ROLES_NAME_ID } from '../../../../constants/constants';
import { PROJECT_STATUSES, ROLES_NAME_ID } from '../../../../constants/constants';
const TableFilters = ({ onBranchChange, onSearchChange, selectedBranch, searchQuery }) => {
const FILTER_PROJECT_STATUSES = ['created', 'agreed', 'archived'];
const TableFilters = ({
onBranchChange,
onSearchChange,
onStatusChange,
selectedBranch,
selectedStatus,
searchQuery,
}) => {
const [branches, setBranches] = useState([]);
const [branchInputValue, setBranchInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
@ -138,7 +147,26 @@ const TableFilters = ({ onBranchChange, onSearchChange, selectedBranch, searchQu
}}
/>
{(selectedBranch || searchQuery) && (
<TextField
select
size='small'
label='Статус'
value={selectedStatus}
onChange={(event) => onStatusChange(event.target.value)}
sx={{
minWidth: 180,
maxWidth: 220,
flex: '1 1 auto',
}}>
<MenuItem value=''>Все статусы</MenuItem>
{FILTER_PROJECT_STATUSES.map((status) => (
<MenuItem key={status} value={status}>
{PROJECT_STATUSES[status]}
</MenuItem>
))}
</TextField>
{(selectedBranch || searchQuery || selectedStatus) && (
<Box sx={{ display: 'flex', gap: 1, ml: 'auto' }}>
<Chip
label='Сбросить фильтры'
@ -147,6 +175,7 @@ const TableFilters = ({ onBranchChange, onSearchChange, selectedBranch, searchQu
setBranchInputValue('');
onBranchChange('');
onSearchChange('');
onStatusChange('');
}}
sx={{
cursor: 'pointer',