diff --git a/web/src/app/Routes.jsx b/web/src/app/Routes.jsx
index f86c37a..464298c 100644
--- a/web/src/app/Routes.jsx
+++ b/web/src/app/Routes.jsx
@@ -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 = () => {
}>
} />
} />
- } />
+ } />
} />
} />
} />
@@ -54,7 +54,7 @@ export const AppRoutes = () => {
} />
} />
} />
- } />
+ } />
} />
diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx
index 96970b1..f92fbd3 100644
--- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx
+++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx
@@ -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');
diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx
index c55aed6..4ca025c 100644
--- a/web/src/components/RealtimeTable/RealtimeTable.jsx
+++ b/web/src/components/RealtimeTable/RealtimeTable.jsx
@@ -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}
diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx
index 092195e..57025c7 100644
--- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx
+++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx
@@ -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 = ({
-
+
+
+
+
+
+ ↑
+
+
+
+
+ {searchMatchCount ? `${Math.max(searchMatchIndex + 1, 1)}/${searchMatchCount}` : '0/0'}
+
+
+
+
+ ↓
+
+
+
+
{
const portalContent = (
@@ -123,7 +124,7 @@ export default function FormPage() {
add_projects_data: {
'Тип проекта развития': {
type: 'multiselect',
- options: ['Открытие ВСП', 'Закрытие ВСП', 'Переезд ВСП', 'Реновация РФ', 'Открытие УРМ'],
+ options: PROJECT_TYPES,
placeholder: '',
defaultValue: null,
},
diff --git a/web/src/pages/ProjectPages/SummaryPage.jsx b/web/src/pages/ProjectPages/NavigationProjectPage.jsx
similarity index 60%
rename from web/src/pages/ProjectPages/SummaryPage.jsx
rename to web/src/pages/ProjectPages/NavigationProjectPage.jsx
index 6eb7390..64c1817 100644
--- a/web/src/pages/ProjectPages/SummaryPage.jsx
+++ b/web/src/pages/ProjectPages/NavigationProjectPage.jsx
@@ -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}
/>
setCreateModalOpen(true)} startIcon={} sx={{ height: '2.5rem' }}>
Создать проект
- {/* Первая таблица */}
-
-
-
+
- {/* Вторая таблица */}
-
-
- {getSelectedProjectTitle()}
-
- {selectedRow ? (
-
- {isSummaryLoading && !summaryData.length ? (
-
-
-
- ) : }
-
- ) : (
-
- Разверните или выберите проект в верхней таблице
-
- )}
-
+
{/* Модалка редактирования */}
setEditModalOpen(false)} rowData={selectedRowData} onSave={onSaveEdit} isSaving={isSaving} orgUnitNames={orgUnitNames} />
@@ -654,4 +468,4 @@ const SummaryPage = () => {
);
};
-export default SummaryPage;
+export default NavigationProjectPage;
diff --git a/web/src/pages/ProjectPages/components/EditModal/EditModal.jsx b/web/src/pages/ProjectPages/components/EditModal/EditModal.jsx
index ec2948c..6fc4d82 100644
--- a/web/src/pages/ProjectPages/components/EditModal/EditModal.jsx
+++ b/web/src/pages/ProjectPages/components/EditModal/EditModal.jsx
@@ -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 },
diff --git a/web/src/pages/ProjectPages/components/ProjectSummaryTable/ProjectSummaryTable.jsx b/web/src/pages/ProjectPages/components/ProjectSummaryTable/ProjectSummaryTable.jsx
new file mode 100644
index 0000000..1280031
--- /dev/null
+++ b/web/src/pages/ProjectPages/components/ProjectSummaryTable/ProjectSummaryTable.jsx
@@ -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 (
+
+
+ {selectedRow?.original.name || 'Проект не выбран'}
+
+ {selectedRow ? (
+
+ {isLoading && !data.length ? (
+
+
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ Разверните или выберите проект в верхней таблице
+
+ )}
+
+ );
+};
+
+export default ProjectSummaryTable;
diff --git a/web/src/pages/ProjectPages/components/ProjectsTable/ProjectsTable.jsx b/web/src/pages/ProjectPages/components/ProjectsTable/ProjectsTable.jsx
new file mode 100644
index 0000000..a56ae40
--- /dev/null
+++ b/web/src/pages/ProjectPages/components/ProjectsTable/ProjectsTable.jsx
@@ -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 (
+
+
+
+ );
+};
+
+export default ProjectsTable;
diff --git a/web/src/pages/ProjectPages/components/StatusBadge/StatusBadge.jsx b/web/src/pages/ProjectPages/components/StatusBadge/StatusBadge.jsx
index 80ecfc5..4d27dc2 100644
--- a/web/src/pages/ProjectPages/components/StatusBadge/StatusBadge.jsx
+++ b/web/src/pages/ProjectPages/components/StatusBadge/StatusBadge.jsx
@@ -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 (
- {currentStatus.label}
+ {PROJECT_STATUSES[status]}
);
};
diff --git a/web/src/pages/ProjectPages/components/TableFilters/TableFilters.jsx b/web/src/pages/ProjectPages/components/TableFilters/TableFilters.jsx
index 94ae4c1..75624db 100644
--- a/web/src/pages/ProjectPages/components/TableFilters/TableFilters.jsx
+++ b/web/src/pages/ProjectPages/components/TableFilters/TableFilters.jsx
@@ -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) && (
+ onStatusChange(event.target.value)}
+ sx={{
+ minWidth: 180,
+ maxWidth: 220,
+ flex: '1 1 auto',
+ }}>
+
+ {FILTER_PROJECT_STATUSES.map((status) => (
+
+ ))}
+
+
+ {(selectedBranch || searchQuery || selectedStatus) && (