diff --git a/web/src/api/phase-templates.js b/web/src/api/phase-templates.js
new file mode 100644
index 0000000..bbb9085
--- /dev/null
+++ b/web/src/api/phase-templates.js
@@ -0,0 +1,26 @@
+import api from './client';
+
+export const PhaseTemplatesApi = {
+ getAll() {
+ return api.get('/stages-templates/').then((response) => response.data);
+ },
+ create(payload) {
+ return api.post('/stages-templates/', payload).then((response) => response.data);
+ },
+ delete(template) {
+ const path = [template.form_type, template.sheet, template.phase_code].map(encodeURIComponent).join('/');
+ return api
+ .delete(`/stages-templates/${path}`, {
+ params: template.direction ? { direction: template.direction } : {},
+ })
+ .then((response) => response.data);
+ },
+ update(template, payload) {
+ const path = [template.form_type, template.sheet, template.phase_code].map(encodeURIComponent).join('/');
+ return api
+ .patch(`/stages-templates/${path}`, payload, {
+ params: template.direction ? { direction: template.direction } : {},
+ })
+ .then((response) => response.data);
+ },
+};
diff --git a/web/src/app/Routes.jsx b/web/src/app/Routes.jsx
index 57b4861..91d2aed 100644
--- a/web/src/app/Routes.jsx
+++ b/web/src/app/Routes.jsx
@@ -3,6 +3,7 @@ import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
import AuditLogsPage from '../pages/AdminPanel/AuditLogsPage/AuditLogsPage.jsx';
import SspsPage from '../pages/AdminPanel/SppsPage/SppsPage';
import UsersPage from '../pages/AdminPanel/UsersPage/UsersPage';
+import PhaseTemplatesPage from '../pages/AdminPanel/PhaseTemplatesPage/PhaseTemplatesPage';
import DictVspPage from '../pages/DictVspPage/DictVspPage.jsx';
import DictsNavigatorPage from '../pages/DictsNavigatorPage/DictsNavigatorPage.jsx';
import FormsPage from '../pages/FormsPage';
@@ -48,6 +49,7 @@ export const AppRoutes = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/web/src/components/AdminPanel/SwitchUserSsp.jsx b/web/src/components/AdminPanel/SwitchUserSsp.jsx
index 43763bc..93f7b17 100644
--- a/web/src/components/AdminPanel/SwitchUserSsp.jsx
+++ b/web/src/components/AdminPanel/SwitchUserSsp.jsx
@@ -6,6 +6,7 @@ export function SwitchUserSsp() {
Пользователи
ССП/Региональные филиалы
+ Предопределенные этапы
Журнал аудита
diff --git a/web/src/components/Stages/AddStagesModal.jsx b/web/src/components/Stages/AddStagesModal.jsx
index 56b7754..21c2088 100644
--- a/web/src/components/Stages/AddStagesModal.jsx
+++ b/web/src/components/Stages/AddStagesModal.jsx
@@ -1,4 +1,6 @@
-import { Box, Button, Divider, FormControl, FormLabel, IconButton, MenuItem, Select, Stack, TextField } from '@mui/material';
+import { Box, Divider, FormControl, FormLabel, IconButton, MenuItem, Select, Stack, TextField } from '@mui/material';
+import { PrimaryButton, PrimaryOutlinedButton } from '../common/Buttons/Buttons';
+import StageFormTheme from './StageFormTheme';
import { useEffect, useState } from 'react';
import { STAGE_ROLE_RUSSIAN_NAME, DIRECTION_TRANSLATE } from '../../constants/constants';
import { formatDateForApi } from '../../utils/formatDate';
@@ -150,6 +152,7 @@ export const AddStagesModal = ({
const isFormValid = startDate && endDate && stageName.trim() && selectedSheet && (selectedDirection || !showDirectionSelect);
return (
+
@@ -291,20 +294,21 @@ export const AddStagesModal = ({
marginTop: '1.5rem',
width: '100%',
}}>
-
-
+
+
);
};
diff --git a/web/src/components/Stages/Calendar/Calendar.styles.jsx b/web/src/components/Stages/Calendar/Calendar.styles.jsx
index 9fe8183..10f68ab 100644
--- a/web/src/components/Stages/Calendar/Calendar.styles.jsx
+++ b/web/src/components/Stages/Calendar/Calendar.styles.jsx
@@ -51,13 +51,16 @@ export const CalendarDay = styled(Box, {
transition: all 0.2s ease;
&:hover {
- background: #dcfce7;
+ background: #edf8f0;
+ color: #258141;
}
${(props) =>
props.isToday &&
`
- background: var(--GreyLight, rgba(243, 243, 245, 1));
+ background: #edf8f0;
+ color: #258141;
+ box-shadow: inset 0 0 0 1px #6fb246;
`}
${(props) =>
@@ -65,6 +68,7 @@ export const CalendarDay = styled(Box, {
`
background: #258141;
color: white;
+ box-shadow: none;
&:hover {
background: #258141;
diff --git a/web/src/components/Stages/Calendar/CalendarHeader.jsx b/web/src/components/Stages/Calendar/CalendarHeader.jsx
index d9cf04d..0d96ea7 100644
--- a/web/src/components/Stages/Calendar/CalendarHeader.jsx
+++ b/web/src/components/Stages/Calendar/CalendarHeader.jsx
@@ -7,13 +7,13 @@ import { monthNames } from './constants';
export const CalendarHeader = ({ month, year, onPrevMonth, onNextMonth, isPrevMonthDisabled }) => {
return (
-
+
-
+
{monthNames[month]} {year}
-
+
diff --git a/web/src/components/Stages/Calendar/YearTabs.jsx b/web/src/components/Stages/Calendar/YearTabs.jsx
index a604eba..d44e6b2 100644
--- a/web/src/components/Stages/Calendar/YearTabs.jsx
+++ b/web/src/components/Stages/Calendar/YearTabs.jsx
@@ -19,11 +19,13 @@ export const YearTabs = ({ selectedYear, onYearChange }) => {
flex: 1,
textTransform: 'none',
fontSize: '0.875rem',
- color: selectedYear === currentYear ? '#4A5660' : '#99A1AF',
+ color: selectedYear === currentYear ? '#258141' : '#99A1AF',
+ fontWeight: selectedYear === currentYear ? 600 : 400,
+ borderBottom: selectedYear === currentYear ? '2px solid #258141' : '2px solid transparent',
borderRadius: 0,
padding: '0.5rem',
'&:hover': {
- backgroundColor: 'var(--GreyLight, rgba(243, 243, 245, 1))',
+ backgroundColor: '#EDF8F0',
},
}}>
Текущий год
@@ -35,11 +37,13 @@ export const YearTabs = ({ selectedYear, onYearChange }) => {
flex: 1,
textTransform: 'none',
fontSize: '0.875rem',
- color: selectedYear === currentYear + 1 ? '#4A5660' : '#99A1AF',
+ color: selectedYear === currentYear + 1 ? '#258141' : '#99A1AF',
+ fontWeight: selectedYear === currentYear + 1 ? 600 : 400,
+ borderBottom: selectedYear === currentYear + 1 ? '2px solid #258141' : '2px solid transparent',
borderRadius: 0,
padding: '0.5rem',
'&:hover': {
- backgroundColor: 'var(--GreyLight, rgba(243, 243, 245, 1))',
+ backgroundColor: '#EDF8F0',
},
}}>
Следующий год
diff --git a/web/src/components/Stages/StageFormTheme.jsx b/web/src/components/Stages/StageFormTheme.jsx
new file mode 100644
index 0000000..ac20860
--- /dev/null
+++ b/web/src/components/Stages/StageFormTheme.jsx
@@ -0,0 +1,33 @@
+import { ThemeProvider, createTheme, useTheme } from '@mui/material/styles';
+import { useMemo } from 'react';
+
+export default function StageFormTheme({ children }) {
+ const baseTheme = useTheme();
+ const theme = useMemo(
+ () =>
+ createTheme(baseTheme, {
+ palette: { primary: { main: '#45881c', light: '#edf8f0', dark: '#30660f', contrastText: '#ffffff' } },
+ components: {
+ MuiOutlinedInput: {
+ styleOverrides: {
+ root: {
+ '&.Mui-focused:not(.Mui-error) .MuiOutlinedInput-notchedOutline': { borderColor: '#6fb246' },
+ },
+ },
+ },
+ MuiAlert: {
+ styleOverrides: {
+ standardInfo: {
+ color: '#30660f',
+ backgroundColor: '#edf8f0',
+ border: '1px solid #c9e4ba',
+ '& .MuiAlert-icon': { color: '#45881c' },
+ },
+ },
+ },
+ },
+ }),
+ [baseTheme],
+ );
+ return {children};
+}
diff --git a/web/src/components/Stages/StagesTable/utils.js b/web/src/components/Stages/StagesTable/utils.js
index daabe8c..67b31b0 100644
--- a/web/src/components/Stages/StagesTable/utils.js
+++ b/web/src/components/Stages/StagesTable/utils.js
@@ -31,9 +31,9 @@ export const getStatusColors = (status) => {
border: '#BEDBFF',
},
Активен: {
- bg: '#DCFCE7',
- color: '#016630',
- border: '#B9F8CF',
+ bg: '#EDF8F0',
+ color: '#258141',
+ border: '#6FB246',
},
Завершен: {
bg: '#F3F4F6',
diff --git a/web/src/components/common/CollapsibleTree.jsx b/web/src/components/common/CollapsibleTree.jsx
index 05f6357..147cbbf 100644
--- a/web/src/components/common/CollapsibleTree.jsx
+++ b/web/src/components/common/CollapsibleTree.jsx
@@ -18,6 +18,7 @@ const CollapsibleTree = ({
onSelectNode,
onSelectAllNodes,
forStage = false,
+ popoverSx,
}) => {
const [expandedItems, setExpandedItems] = useState([]);
const [anchorEl, setAnchorEl] = useState(null);
@@ -203,7 +204,7 @@ const CollapsibleTree = ({
open={open}
anchorEl={anchorEl}
onClose={handleClose}
- sx={forStage ? { zIndex: 70 } : undefined}
+ sx={{ ...(forStage ? { zIndex: 70 } : {}), ...popoverSx }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/PhaseTemplatesPage.jsx b/web/src/pages/AdminPanel/PhaseTemplatesPage/PhaseTemplatesPage.jsx
new file mode 100644
index 0000000..4fc0ae8
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/PhaseTemplatesPage.jsx
@@ -0,0 +1,104 @@
+import { Alert, Box, Button } from '@mui/material';
+import { useAuth } from '../../../app/context/AuthProvider';
+import { SwitchUserSsp } from '../../../components/AdminPanel/SwitchUserSsp';
+import { HeaderSwitch } from '../../../components/HeaderMenu/HeaderSwitch';
+import { PrimaryButton } from '../../../components/common/Buttons/Buttons';
+import { ROLES_NAME_ID } from '../../../constants/constants';
+import { PageContainer } from '../../StyledForPage';
+import DeletePhaseTemplateDialog from './components/DeletePhaseTemplateDialog';
+import PhaseTemplateDialog from './components/PhaseTemplateDialog';
+import PhaseTemplatesFilters from './components/PhaseTemplatesFilters';
+import PhaseTemplatesTable from './components/PhaseTemplatesTable';
+import { usePhaseTemplates } from './hooks/usePhaseTemplates';
+
+export default function PhaseTemplatesPage() {
+ const { user } = useAuth();
+ const isAdmin = Number(user?.role_id) === ROLES_NAME_ID.admin;
+ const {
+ filteredRows,
+ loading,
+ error,
+ filters,
+ pageSize,
+ currentPage,
+ sheets,
+ emptyMessage,
+ dialog,
+ saving,
+ saveError,
+ deleteTarget,
+ deleting,
+ deleteError,
+ load,
+ changeFilter,
+ openDialog,
+ save,
+ confirmDelete,
+ setPage,
+ setPageSize,
+ closeDialog,
+ openDeleteDialog,
+ closeDeleteDialog,
+ } = usePhaseTemplates(isAdmin);
+
+ if (!isAdmin) return Страница доступна только администратору.;
+ return (
+
+
+
+
+ openDialog()}>Добавить этап
+
+
+
+ {error && (
+
+ Повторить
+
+ }>
+ {error}
+
+ )}
+ {
+ setPageSize(value);
+ setPage(0);
+ }}
+ onEdit={openDialog}
+ onDelete={openDeleteDialog}
+ emptyMessage={emptyMessage}
+ />
+
+ {dialog && }
+
+ );
+}
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/components/DeletePhaseTemplateDialog.jsx b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/DeletePhaseTemplateDialog.jsx
new file mode 100644
index 0000000..836276c
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/DeletePhaseTemplateDialog.jsx
@@ -0,0 +1,37 @@
+import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material';
+import { DIRECTION_TRANSLATE, SHEET_NAME } from '../../../../constants/constants';
+import { TEMPLATE_FORMS } from '../constants/templates';
+import { DangerOutlinedButton, SecondaryButton } from '../../../../components/common/Buttons/Buttons';
+
+export default function DeletePhaseTemplateDialog({ deleteTarget, deleting, deleteError, onClose, onConfirm }) {
+ return (
+
+ );
+}
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/components/FormBadge.jsx b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/FormBadge.jsx
new file mode 100644
index 0000000..ae88107
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/FormBadge.jsx
@@ -0,0 +1,33 @@
+import { Box } from '@mui/material';
+import { DEFAULT_FORM_STYLE, FORM_STYLES } from '../constants/formStyles';
+import { TEMPLATE_FORMS } from '../constants/templates';
+
+export default function FormBadge({ formType }) {
+ if (!formType) return '—';
+ const style = FORM_STYLES[formType] || DEFAULT_FORM_STYLE;
+ return (
+
+ {style.label || TEMPLATE_FORMS[formType] || formType}
+
+ );
+}
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/components/PhaseTemplateDialog.jsx b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/PhaseTemplateDialog.jsx
new file mode 100644
index 0000000..6d86a1d
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/PhaseTemplateDialog.jsx
@@ -0,0 +1,295 @@
+import {
+ Alert,
+ Box,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ Divider,
+ FormControl,
+ FormLabel,
+ IconButton,
+ MenuItem,
+ TextField,
+ Typography,
+} from '@mui/material';
+import { useEffect, useState } from 'react';
+import { filterColumnTree, getColumnTreeLeafKeys } from '../../../../components/RealtimeTable/utils/columnUtils';
+import { HeaderContainer, ModalTitle } from '../../../../components/Stages/Modal.styles';
+import StageFormTheme from '../../../../components/Stages/StageFormTheme';
+import { stageColumnsHiddenFromPicker } from '../../../../components/Stages/constants';
+import { PrimaryButton, PrimaryOutlinedButton } from '../../../../components/common/Buttons/Buttons';
+import CollapsibleTree from '../../../../components/common/CollapsibleTree';
+import { Cancel } from '../../../../components/common/icons/icons';
+import { DIRECTION_TRANSLATE, SHEET_NAME, STAGE_ROLE_RUSSIAN_NAME } from '../../../../constants/constants';
+import { SHEETS, TEMPLATE_FORMS } from '../constants/templates';
+import { loadTemplateColumnTree } from '../utils/templateColumns';
+import { parseTemplateDate, serializeTemplateDate, templateDateNumber } from '../utils/templateDates';
+import TemplateDateInput from './TemplateDateInput';
+
+const fieldProps = { size: 'small', fullWidth: true };
+const emptyDate = () => ({
+ year: '0',
+ month: '01',
+ day: '01',
+ time: '00:00:00',
+});
+
+export default function PhaseTemplateDialog({ template, onClose, onSave, saving, error }) {
+ const [form, setForm] = useState(() => ({
+ form_type: 'FORM_1',
+ sheet: '',
+ direction: '',
+ phase_code: '',
+ role: 'DFIP',
+ column_keys: [],
+ ...template,
+ }));
+ const [opens, setOpens] = useState(() => parseTemplateDate(template?.opens_at) || emptyDate());
+ const [closes, setCloses] = useState(
+ () =>
+ parseTemplateDate(template?.closes_at) || {
+ ...emptyDate(),
+ month: '12',
+ day: '31',
+ time: '23:59:59',
+ },
+ );
+ const [columns, setColumns] = useState([]);
+ const [columnsLoading, setColumnsLoading] = useState(false);
+ const [columnsError, setColumnsError] = useState('');
+ const [validationError, setValidationError] = useState('');
+ const invalidStoredDate = template && (!parseTemplateDate(template.opens_at) || !parseTemplateDate(template.closes_at));
+ const sheetOptions = [...new Set([...(SHEETS[form.form_type] || []), ...(form.sheet ? [form.sheet] : [])])];
+ const showDirection = template ? Boolean(template.direction) : form.form_type === 'FORM_1' && ['AHR', 'CAP'].includes(form.sheet);
+
+ useEffect(() => {
+ let cancelled = false;
+ setColumns([]);
+ setColumnsError('');
+ if (!form.sheet) return;
+ setColumnsLoading(true);
+ loadTemplateColumnTree(form.form_type, form.sheet)
+ .then((options) => {
+ if (!cancelled) setColumns(options);
+ })
+ .catch(() => {
+ if (!cancelled) setColumnsError('Не удалось загрузить список колонок. Закройте окно и попробуйте снова.');
+ })
+ .finally(() => {
+ if (!cancelled) setColumnsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [form.form_type, form.sheet]);
+
+ const opensNumber = templateDateNumber(opens);
+ const closesNumber = templateDateNumber(closes);
+ const datesValid = Number.isFinite(opensNumber) && Number.isFinite(closesNumber);
+ const dateOrderError = datesValid && closesNumber <= opensNumber ? 'Дата окончания должна быть позже даты начала.' : '';
+
+ const submit = (event) => {
+ event.preventDefault();
+ if (saving) return;
+ const keys = [...new Set(form.column_keys)];
+ if (!form.sheet || !form.phase_code.trim() || !form.role || !keys.length) {
+ setValidationError('Укажите лист, название, роль и хотя бы одну колонку.');
+ return;
+ }
+ if (!Number.isFinite(templateDateNumber(opens)) || !Number.isFinite(templateDateNumber(closes))) {
+ setValidationError('Проверьте день, месяц и сдвиг года.');
+ return;
+ }
+ if (templateDateNumber(closes) <= templateDateNumber(opens)) {
+ setValidationError('Дата окончания должна быть позже даты начала.');
+ return;
+ }
+ setValidationError('');
+ onSave({
+ ...form,
+ phase_code: template ? form.phase_code : form.phase_code.trim(),
+ direction: form.direction || null,
+ column_keys: keys,
+ opens_at: serializeTemplateDate(opens),
+ closes_at: serializeTemplateDate(closes),
+ });
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/components/PhaseTemplatesFilters.jsx b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/PhaseTemplatesFilters.jsx
new file mode 100644
index 0000000..2e6eceb
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/PhaseTemplatesFilters.jsx
@@ -0,0 +1,43 @@
+import { Box, MenuItem, TextField } from '@mui/material';
+import { PrimaryOutlinedButton } from '../../../../components/common/Buttons/Buttons';
+import { DIRECTION_TRANSLATE, SHEET_NAME, STAGE_ROLE_RUSSIAN_NAME } from '../../../../constants/constants';
+import { TEMPLATE_FORMS } from '../constants/templates';
+
+export default function PhaseTemplatesFilters({ filters, sheets, changeFilter, load, loading, saving, deleting }) {
+ return (
+
+ changeFilter('search', e.target.value)}
+ sx={{ minWidth: 270, flex: 1 }}
+ />
+ {[
+ ['form_type', 'Форма', TEMPLATE_FORMS],
+ ['sheet', 'Лист', Object.fromEntries(sheets.map((sheet) => [sheet, SHEET_NAME[sheet] || sheet]))],
+ ['direction', 'Направление', { none: 'Без направления', ...DIRECTION_TRANSLATE }],
+ ['role', 'Роль', STAGE_ROLE_RUSSIAN_NAME],
+ ].map(([key, label, options]) => (
+ changeFilter(key, e.target.value)}
+ sx={{ minWidth: 170 }}>
+
+ {Object.entries(options).map(([value, text]) => (
+
+ ))}
+
+ ))}
+
+ Обновить
+
+
+ );
+}
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/components/PhaseTemplatesTable.jsx b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/PhaseTemplatesTable.jsx
new file mode 100644
index 0000000..5160f84
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/PhaseTemplatesTable.jsx
@@ -0,0 +1,150 @@
+import { Box, Chip, IconButton, Tooltip, Typography } from '@mui/material';
+import { useMemo } from 'react';
+import { EditPenSvg, TrashSvg } from '../../../../components/common/icons/icons';
+import { DIRECTION_TRANSLATE, STAGE_ROLE_RUSSIAN_NAME } from '../../../../constants/constants';
+import StyledTable from '../../components/StyledTable/StyledTable';
+import { formatTemplateDate, parseTemplateDate, templateKey } from '../utils/templateDates';
+import TemplateColumnsCell from './TemplateColumnsCell';
+import SheetBadge from './SheetBadge';
+import FormBadge from './FormBadge';
+
+const DateCell = ({ value }) => {
+ const date = parseTemplateDate(value);
+ if (!date) return value || '—';
+ const year = formatTemplateDate(value).split(' (')[1]?.slice(0, -1);
+ return (
+
+
+ {date.day}.{date.month}
+
+
+ {year}
+
+
+ );
+};
+
+export default function PhaseTemplatesTable({
+ rows,
+ loading,
+ page,
+ pageSize,
+ onPageChange,
+ onPageSizeChange,
+ onEdit,
+ onDelete,
+ emptyMessage,
+}) {
+ const columns = useMemo(
+ () =>
+ [
+ { accessorKey: 'phase_code', header: 'Название этапа', size: 220 },
+ {
+ accessorKey: 'form_type',
+ header: 'Форма',
+ size: 220,
+ Cell: ({ cell }) => ,
+ },
+ {
+ accessorKey: 'sheet',
+ header: 'Лист',
+ size: 220,
+ Cell: ({ cell }) => ,
+ },
+ {
+ accessorKey: 'direction',
+ header: 'Направление',
+ size: 140,
+ Cell: ({ cell }) => DIRECTION_TRANSLATE[cell.getValue()] || cell.getValue() || null,
+ },
+ {
+ accessorKey: 'role',
+ header: 'Роль',
+ size: 190,
+ Cell: ({ cell }) => (
+
+ ),
+ },
+ {
+ id: 'columns',
+ header: 'Колонки',
+ size: 100,
+ Cell: ({ row }) => ,
+ },
+ {
+ accessorKey: 'opens_at',
+ header: 'Начало',
+ size: 160,
+ Cell: ({ cell }) => ,
+ },
+ {
+ accessorKey: 'closes_at',
+ header: 'Окончание',
+ size: 160,
+ Cell: ({ cell }) => ,
+ },
+ ]
+ .map((column) => ({ ...column, enableColumnActions: false })),
+ [],
+ );
+ return (
+ (
+
+
+ onEdit(row.original)}
+ aria-label={`Редактировать этап ${row.original.phase_code}`}
+ sx={{
+ margin: '0.25rem',
+ padding: 0,
+ width: '1rem',
+ height: '1rem',
+ }}>
+
+
+
+
+ onDelete(row.original)} aria-label={`Удалить этап ${row.original.phase_code}`} size='small'>
+
+
+
+
+ )}
+ tableOptions={{
+ getRowId: templateKey,
+ localization: { noRecordsToDisplay: emptyMessage },
+ displayColumnDefOptions: {
+ 'mrt-row-actions': { header: 'Действия', size: 110 },
+ },
+ }}
+ />
+ );
+}
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/components/SheetBadge.jsx b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/SheetBadge.jsx
new file mode 100644
index 0000000..d532a68
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/SheetBadge.jsx
@@ -0,0 +1,62 @@
+import AccountBalanceWalletOutlinedIcon from '@mui/icons-material/AccountBalanceWalletOutlined';
+import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
+import CalculateOutlinedIcon from '@mui/icons-material/CalculateOutlined';
+import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
+import ForestOutlinedIcon from '@mui/icons-material/ForestOutlined';
+import LightbulbOutlinedIcon from '@mui/icons-material/LightbulbOutlined';
+import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
+import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
+import SpeedOutlinedIcon from '@mui/icons-material/SpeedOutlined';
+import SquareFootOutlinedIcon from '@mui/icons-material/SquareFootOutlined';
+import TrendingUpOutlinedIcon from '@mui/icons-material/TrendingUpOutlined';
+import VpnKeyOutlinedIcon from '@mui/icons-material/VpnKeyOutlined';
+import { Box } from '@mui/material';
+import { SHEET_NAME } from '../../../../constants/constants';
+import { DEFAULT_SHEET_STYLE, SHEET_STYLES } from '../constants/sheetStyles';
+
+const SHEET_ICONS = {
+ building: ApartmentOutlinedIcon,
+ growth: TrendingUpOutlinedIcon,
+ settings: SettingsOutlinedIcon,
+ gauge: SpeedOutlinedIcon,
+ key: VpnKeyOutlinedIcon,
+ light: LightbulbOutlinedIcon,
+ shield: ShieldOutlinedIcon,
+ calculator: CalculateOutlinedIcon,
+ tree: ForestOutlinedIcon,
+ wallet: AccountBalanceWalletOutlinedIcon,
+ ruler: SquareFootOutlinedIcon,
+ report: DescriptionOutlinedIcon,
+};
+
+export default function SheetBadge({ sheet }) {
+ if (!sheet) return '—';
+ const style = SHEET_STYLES[sheet] || DEFAULT_SHEET_STYLE;
+ const Icon = SHEET_ICONS[style.icon];
+ return (
+
+
+
+ {SHEET_NAME[sheet] || sheet}
+
+
+ );
+}
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/components/TemplateColumnsCell.jsx b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/TemplateColumnsCell.jsx
new file mode 100644
index 0000000..42df356
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/TemplateColumnsCell.jsx
@@ -0,0 +1,88 @@
+import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
+import { Alert, Box, Button, CircularProgress, Popover, Typography } from '@mui/material';
+import { useEffect, useId, useState } from 'react';
+import { loadTemplateColumns } from '../utils/templateColumns';
+
+export default function TemplateColumnsCell({ template }) {
+ const [anchor, setAnchor] = useState(null);
+ const [columns, setColumns] = useState(null);
+ const [error, setError] = useState(false);
+ const id = useId();
+ const open = Boolean(anchor);
+
+ useEffect(() => {
+ if (!open) return;
+ let cancelled = false;
+ setColumns(null);
+ setError(false);
+ loadTemplateColumns(template.form_type, template.sheet)
+ .then((options) => {
+ if (!cancelled) setColumns(new Map(options.map(({ key, label }) => [key, label])));
+ })
+ .catch(() => {
+ if (!cancelled) setError(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [open, template.form_type, template.sheet]);
+
+ if (!template.column_keys.length) return '—';
+ return (
+ <>
+ }
+ aria-label={`Показать колонки этапа ${template.phase_code}: ${template.column_keys.length}`}
+ onClick={(event) => setAnchor(event.currentTarget)}
+ aria-haspopup='dialog'
+ aria-expanded={open}
+ aria-controls={open ? id : undefined}
+ sx={{ whiteSpace: 'nowrap' }}>
+ {template.column_keys.length}
+
+ setAnchor(null)}
+ slotProps={{
+ paper: {
+ role: 'dialog',
+ 'aria-label': `Колонки этапа ${template.phase_code}`,
+ },
+ }}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}>
+
+
+ Колонки этапа
+
+
+ {!columns && !error && }
+ {error && Не удалось загрузить названия колонок.}
+ {(columns || error) && (
+
+ {template.column_keys.map((key) => (
+
+ {columns?.get(key) || key}
+
+ ))}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/components/TemplateDateInput.jsx b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/TemplateDateInput.jsx
new file mode 100644
index 0000000..6342843
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/components/TemplateDateInput.jsx
@@ -0,0 +1,68 @@
+import { Box, InputAdornment, TextField, Typography } from '@mui/material';
+import { useState } from 'react';
+import { CalendarIcon } from '../../../../components/common/icons/icons';
+import { templateDateNumber } from '../utils/templateDates';
+
+export default function TemplateDateInput({ title, value, onChange, error = '' }) {
+ const [text, setText] = useState(() => `${value.day.padStart(2, '0')}.${value.month.padStart(2, '0')}`);
+ const [touched, setTouched] = useState(false);
+ const yearValid = /^-?\d+$/.test(value.year);
+ const dateValid = /^\d{2}\.\d{2}$/.test(text) && Number.isFinite(templateDateNumber({ ...value, year: yearValid ? value.year : '0' }));
+ const dateError = touched && !dateValid ? 'Введите существующую дату в формате ДД.ММ.' : error;
+ return (
+
+ {title}
+
+ {
+ const digits = event.target.value.replace(/\D/g, '').slice(0, 4);
+ const next = digits.length > 2 ? `${digits.slice(0, 2)}.${digits.slice(2)}` : digits;
+ setText(next);
+ onChange({
+ ...value,
+ day: digits.slice(0, 2),
+ month: digits.length === 4 ? digits.slice(2) : '',
+ });
+ }}
+ onBlur={() => setTouched(true)}
+ error={Boolean(dateError)}
+ helperText={dateError || ''}
+ slotProps={{
+ htmlInput: { inputMode: 'numeric', maxLength: 5 },
+ inputLabel: { shrink: true },
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ },
+ }}
+ />
+ onChange({ ...value, year: event.target.value })}
+ error={!yearValid}
+ helperText={yearValid ? '0 — год бюджета, −1 — предыдущий, 1 — следующий' : 'Укажите целое число.'}
+ slotProps={{ htmlInput: { step: 1 } }}
+ />
+
+
+ );
+}
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/constants/formStyles.js b/web/src/pages/AdminPanel/PhaseTemplatesPage/constants/formStyles.js
new file mode 100644
index 0000000..ca1ba3c
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/constants/formStyles.js
@@ -0,0 +1,7 @@
+export const DEFAULT_FORM_STYLE = { color: '#424a40', background: '#f1f0ef', border: '#e5e5e5' };
+
+export const FORM_STYLES = {
+ FORM_1: { color: '#8d6c00', background: '#fff5ce', border: '#ffe58b' },
+ FORM_3: { color: '#4d7f0e', background: '#f6fce8', border: '#b7ef60', label: 'Проекты развития' },
+ FORM_4: { color: '#1d4ed8', background: '#eff6ff', border: '#bfdcff' },
+};
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/constants/sheetStyles.js b/web/src/pages/AdminPanel/PhaseTemplatesPage/constants/sheetStyles.js
new file mode 100644
index 0000000..58c1cef
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/constants/sheetStyles.js
@@ -0,0 +1,16 @@
+export const SHEET_STYLES = {
+ AHR: { color: '#1d4ed8', background: '#eff6ff', border: '#a5caff', icon: 'building' },
+ CAP: { color: '#218b25', background: '#edfded', border: '#86df88', icon: 'growth' },
+ OPER: { color: '#bc6800', background: '#fff5e3', border: '#ffcc80', icon: 'settings' },
+ AHR_LIMIT: { color: '#dc2626', background: '#fff0f0', border: '#ffa2a8', icon: 'gauge' },
+ AHR_RENT: { color: '#7c3aed', background: '#f5edff', border: '#cba6ff', icon: 'key' },
+ AHR_UTILITY: { color: '#987600', background: '#fff9df', border: '#ffdf68', icon: 'light' },
+ AHR_SECURITY: { color: '#008579', background: '#e3faf6', border: '#61d5cd', icon: 'shield' },
+ SMETA: { color: '#db2777', background: '#fff0f6', border: '#ff9bcd', icon: 'calculator' },
+ STRUCTURE: { color: '#4338ca', background: '#efedff', border: '#afa7ff', icon: 'tree' },
+ CURRENT_EXPENSES: { color: '#4d870f', background: '#f2ffe5', border: '#b1ed60', icon: 'wallet' },
+ LIMIT: { color: '#d34b22', background: '#fff0e8', border: '#ffad90', icon: 'ruler' },
+ OTCH9F: { color: '#475569', background: '#f1f5f9', border: '#cbd5e1', icon: 'report' },
+};
+
+export const DEFAULT_SHEET_STYLE = { color: '#475569', background: '#f1f5f9', border: '#cbd5e1', icon: 'report' };
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/constants/templates.js b/web/src/pages/AdminPanel/PhaseTemplatesPage/constants/templates.js
new file mode 100644
index 0000000..cd24299
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/constants/templates.js
@@ -0,0 +1,12 @@
+export const TEMPLATE_FORMS = {
+ FORM_1: 'Смета ССП',
+ FORM_2: 'Смета РФ',
+ FORM_3: 'Проекты РФ Развитие',
+ FORM_4: 'Проектная деятельность',
+};
+export const SHEETS = {
+ FORM_1: ['AHR', 'CAP', 'OPER', 'SMETA'],
+ FORM_2: ['AHR', 'CAP', 'OPER', 'SMETA', 'AHR_LIMIT', 'AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY', 'OTCH9F'],
+ FORM_3: ['CURRENT_EXPENSES', 'LIMIT'],
+ FORM_4: ['AHR', 'CAP', 'OPER', 'SMETA', 'STRUCTURE'],
+};
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/hooks/usePhaseTemplates.js b/web/src/pages/AdminPanel/PhaseTemplatesPage/hooks/usePhaseTemplates.js
new file mode 100644
index 0000000..300dc16
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/hooks/usePhaseTemplates.js
@@ -0,0 +1,169 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { toast } from 'react-toastify';
+import { PhaseTemplatesApi } from '../../../../api/phase-templates';
+import { errorMessage } from '../utils/errorMessage';
+import { templateKey } from '../utils/templateDates';
+
+export function usePhaseTemplates(isAdmin) {
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [filters, setFilters] = useState({
+ form_type: '',
+ sheet: '',
+ direction: '',
+ role: '',
+ search: '',
+ });
+ const [page, setPage] = useState(0);
+ const [pageSize, setPageSize] = useState(25);
+ const [dialog, setDialog] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState('');
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+ const [deleteError, setDeleteError] = useState('');
+ const deleteInFlight = useRef(false);
+ const requestId = useRef(0);
+ const saveInFlight = useRef(false);
+
+ const load = useCallback(async () => {
+ const id = ++requestId.current;
+ setLoading(true);
+ setError('');
+ try {
+ const data = await PhaseTemplatesApi.getAll();
+ if (data.success === false) throw new Error(data.message || 'Не удалось загрузить этапы');
+ if (id === requestId.current) setRows(data.result || []);
+ } catch (cause) {
+ if (id === requestId.current) setError(errorMessage(cause, 'Не удалось загрузить этапы'));
+ } finally {
+ if (id === requestId.current) setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (isAdmin) load();
+ return () => {
+ requestId.current += 1;
+ };
+ }, [isAdmin, load]);
+
+ const filteredRows = useMemo(
+ () =>
+ rows
+ .filter((row) => {
+ if (filters.form_type && row.form_type !== filters.form_type) return false;
+ if (filters.sheet && row.sheet !== filters.sheet) return false;
+ if (filters.role && row.role !== filters.role) return false;
+ if (filters.direction && (row.direction || 'none') !== filters.direction) return false;
+ const text = [row.phase_code, ...(row.column_keys || [])].join(' ').toLocaleLowerCase('ru');
+ return text.includes(filters.search.trim().toLocaleLowerCase('ru'));
+ })
+ .sort((a, b) => templateKey(a).localeCompare(templateKey(b), 'ru')),
+ [rows, filters],
+ );
+ const sheets = [
+ ...new Set(rows.filter((row) => !filters.form_type || row.form_type === filters.form_type).map((row) => row.sheet)),
+ ].sort();
+ const currentPage = Math.min(page, Math.max(0, Math.ceil(filteredRows.length / pageSize) - 1));
+ let emptyMessage = rows.length ? 'По выбранным фильтрам этапы не найдены' : 'Предопределённых этапов пока нет';
+ if (error) emptyMessage = 'Данные не загружены';
+ const changeFilter = (key, value) => {
+ setFilters((current) => ({
+ ...current,
+ [key]: value,
+ ...(key === 'form_type' ? { sheet: '' } : {}),
+ }));
+ setPage(0);
+ };
+ const openDialog = (template = null) => {
+ setSaveError('');
+ setDialog({ template });
+ };
+ const confirmDelete = async () => {
+ if (!deleteTarget || deleteInFlight.current) return;
+ deleteInFlight.current = true;
+ setDeleting(true);
+ setDeleteError('');
+ try {
+ const data = await PhaseTemplatesApi.delete(deleteTarget);
+ if (data?.success === false) throw new Error(data.message || 'Не удалось удалить этап');
+ requestId.current += 1;
+ setLoading(false);
+ setRows((current) => current.filter((row) => templateKey(row) !== templateKey(deleteTarget)));
+ setDeleteTarget(null);
+ toast.success('Предопределённый этап удалён');
+ } catch (cause) {
+ setDeleteError(errorMessage(cause, 'Не удалось удалить этап'));
+ } finally {
+ deleteInFlight.current = false;
+ setDeleting(false);
+ }
+ };
+ const save = async (payload) => {
+ if (saveInFlight.current) return;
+ saveInFlight.current = true;
+ setSaving(true);
+ setSaveError('');
+ try {
+ const { role, column_keys, opens_at, closes_at } = payload;
+ const data = dialog.template
+ ? await PhaseTemplatesApi.update(dialog.template, {
+ role,
+ column_keys,
+ opens_at,
+ closes_at,
+ })
+ : await PhaseTemplatesApi.create(payload);
+ if (data.success === false || !data.result) throw new Error(data.message || 'Не удалось сохранить этап');
+ requestId.current += 1;
+ setLoading(false);
+ setRows((current) => [...current.filter((row) => templateKey(row) !== templateKey(data.result)), data.result]);
+ setDialog(null);
+ toast.success(dialog.template ? 'Этап обновлён' : 'Этап добавлен');
+ } catch (cause) {
+ setSaveError(errorMessage(cause, 'Не удалось сохранить этап'));
+ } finally {
+ saveInFlight.current = false;
+ setSaving(false);
+ }
+ };
+
+ const closeDialog = () => {
+ if (!saveInFlight.current) setDialog(null);
+ };
+ const openDeleteDialog = (row) => {
+ setDeleteError('');
+ setDeleteTarget(row);
+ };
+ const closeDeleteDialog = () => {
+ if (!deleteInFlight.current) setDeleteTarget(null);
+ };
+ return {
+ filteredRows,
+ loading,
+ error,
+ filters,
+ pageSize,
+ currentPage,
+ sheets,
+ emptyMessage,
+ dialog,
+ saving,
+ saveError,
+ deleteTarget,
+ deleting,
+ deleteError,
+ load,
+ changeFilter,
+ openDialog,
+ save,
+ confirmDelete,
+ setPage,
+ setPageSize,
+ closeDialog,
+ openDeleteDialog,
+ closeDeleteDialog,
+ };
+}
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/utils/errorMessage.js b/web/src/pages/AdminPanel/PhaseTemplatesPage/utils/errorMessage.js
new file mode 100644
index 0000000..b10e8d0
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/utils/errorMessage.js
@@ -0,0 +1,7 @@
+export const errorMessage = (error, fallback) => {
+ const data = error?.response?.data;
+ const detail = data?.message || data?.detail;
+ if (typeof detail === 'string') return detail;
+ if (Array.isArray(detail)) return detail.map((item) => item.msg || 'Некорректное значение').join('; ');
+ return error?.message || fallback;
+};
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/utils/templateColumns.js b/web/src/pages/AdminPanel/PhaseTemplatesPage/utils/templateColumns.js
new file mode 100644
index 0000000..5910d66
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/utils/templateColumns.js
@@ -0,0 +1,27 @@
+import { filterColumnTree } from '../../../../components/RealtimeTable/utils/columnUtils';
+import { formatHeaderYear } from '../../../../components/RealtimeTable/utils/headerYearUtils';
+
+export async function loadTemplateColumnTree(formType, sheet) {
+ const configForm = formType === 'FORM_3' ? 'PROJECT' : formType;
+ const { config } = await import(`../../../../components/RealtimeTable/constants/${configForm}/${sheet}.js`);
+ return config.config.columns;
+}
+
+export async function loadTemplateColumns(formType, sheet, excludedKeys = []) {
+ const tree = await loadTemplateColumnTree(formType, sheet);
+ const options = [];
+ const collect = (nodes, parents = []) => {
+ for (const node of nodes) {
+ const header = typeof node.header === 'string' ? formatHeaderYear(node.header) : '';
+ const path = [...parents, header].filter(Boolean);
+ if (node.columns?.length) collect(node.columns, path);
+ else if (node.accessorKey?.startsWith('data.'))
+ options.push({
+ key: node.accessorKey.slice(5),
+ label: path.join(' → '),
+ });
+ }
+ };
+ collect(filterColumnTree(tree, excludedKeys));
+ return options;
+}
diff --git a/web/src/pages/AdminPanel/PhaseTemplatesPage/utils/templateDates.js b/web/src/pages/AdminPanel/PhaseTemplatesPage/utils/templateDates.js
new file mode 100644
index 0000000..42598f6
--- /dev/null
+++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/utils/templateDates.js
@@ -0,0 +1,51 @@
+// PostgreSQL uses 1 BC for offset zero; there is no year zero in its timestamp syntax.
+export const parseTemplateDate = (value) => {
+ const match = /^(\d+)-(\d{2})-(\d{2})[T ](\d{2}:\d{2}:\d{2}(?:\.\d+)?)( BC)?$/.exec(value || '');
+ if (!match) return null;
+ return {
+ year: String(match[5] ? 1 - Number(match[1]) : Number(match[1])),
+ month: match[2],
+ day: match[3],
+ time: match[4],
+ };
+};
+
+export const serializeTemplateDate = ({ year, month, day, time }) => {
+ const offset = Number(year);
+ const dateYear = String(offset <= 0 ? 1 - offset : offset).padStart(4, '0');
+ return `${dateYear}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${time.length === 5 ? `${time}:00` : time}${offset <= 0 ? ' BC' : ''}`;
+};
+
+export const templateDateNumber = (value) => {
+ if (!value || !/^-?\d+$/.test(value.year) || !Number.isSafeInteger(Number(value.year))) return Number.NaN;
+ const { month, day, time } = value;
+ if (!Number.isInteger(Number(month)) || !Number.isInteger(Number(day)) || Number(month) < 1 || Number(month) > 12 || Number(day) < 1)
+ return Number.NaN;
+ if (!/^\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?$/.test(time)) return Number.NaN;
+ const [hours, minutes, seconds = 0] = time.split(':').map(Number);
+ const year = 2000 + Number(value.year);
+ const date = new Date(0);
+ date.setUTCFullYear(year, Number(month) - 1, Number(day));
+ date.setUTCHours(hours, minutes, Math.floor(seconds), 0);
+ if (
+ date.getUTCFullYear() !== year ||
+ date.getUTCMonth() !== Number(month) - 1 ||
+ date.getUTCDate() !== Number(day) ||
+ hours > 23 ||
+ minutes > 59 ||
+ seconds >= 60
+ )
+ return Number.NaN;
+ return date.getTime() + (seconds % 1) * 1000;
+};
+
+export const formatTemplateDate = (value) => {
+ const date = parseTemplateDate(value);
+ if (!date) return value || '—';
+ const offset = Number(date.year);
+ const year = offset === 0 ? 'год бюджета' : `год бюджета ${offset > 0 ? '+' : '−'} ${Math.abs(offset)}`;
+ return `${date.day}.${date.month} (${year})`;
+};
+
+export const templateKey = (template) =>
+ JSON.stringify([template.form_type, template.sheet, template.direction ?? null, template.phase_code]);
diff --git a/web/src/pages/AdminPanel/components/StyledTable/StyledTable.jsx b/web/src/pages/AdminPanel/components/StyledTable/StyledTable.jsx
index 55557bf..fe3027e 100644
--- a/web/src/pages/AdminPanel/components/StyledTable/StyledTable.jsx
+++ b/web/src/pages/AdminPanel/components/StyledTable/StyledTable.jsx
@@ -35,16 +35,18 @@ const DEFAULT_TABLE_STYLES = {
color: '#364153',
},
bodyRow: {
- '&:hover': {
- backgroundColor: 'white !important',
+ '&:nth-of-type(odd), &:nth-of-type(odd):hover': {
+ backgroundColor: '#FFFFFF',
+ '& > .MuiTableCell-root': { backgroundColor: '#FFFFFF' },
+ },
+ '&:nth-of-type(even), &:nth-of-type(even):hover': {
+ backgroundColor: '#F9FAFB',
+ '& > .MuiTableCell-root': { backgroundColor: '#F9FAFB' },
},
'&:hover td::after': {
opacity: '0 !important',
backgroundColor: 'transparent !important',
},
- '&:hover .MuiTableCell-root': {
- backgroundColor: 'white !important',
- },
},
};