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) => ( + + {PROJECT_STATUSES[status]} + + ))} + + + {(selectedBranch || searchQuery || selectedStatus) && (