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/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index e03d971..ea9c8b6 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -219,9 +219,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { ); const handleClickRowCell = useCallback((rowId) => { - setRowSelection((prev) => ({ - [rowId]: !prev[rowId], - })); + setRowSelection((prev) => (prev[rowId] ? {} : { [rowId]: true })); }, []); const handleColumnSelect = useCallback((columnId) => { @@ -358,7 +356,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { enableRowSelection: true, onRowSelectionChange: setRowSelection, muiTableBodyRowProps: ({ row }) => ({ - onClick: () => setRowSelection({ [row.id]: true }), + onClick: () => handleClickRowCell(row.id), hover: false, sx: { '& > td::after': { diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index 134326c..caf5a9e 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -248,7 +248,8 @@ export const getTableColumns = ({ size: 50, enableEditing: false, Cell: ({ cell, row }) => { - const handleClick = useCallback(() => { + const handleClick = useCallback((event) => { + event.stopPropagation(); onCellNumberClick(row.id); }, [row.id, onCellNumberClick]); return ; 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 ( + + Удалить предопределённый этап? + + {deleteTarget?.phase_code} + + {TEMPLATE_FORMS[deleteTarget?.form_type]} · {SHEET_NAME[deleteTarget?.sheet] || deleteTarget?.sheet} + {deleteTarget?.direction ? ` · ${DIRECTION_TRANSLATE[deleteTarget.direction] || deleteTarget.direction}` : ''} + + + Шаблон больше не будет использоваться при создании новых форм и отчётов. + + {deleteError && ( + + {deleteError} + + )} + + + + Отмена + + + + Удалить + + + + + ); +} 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 ( + + + + + {template ? 'Редактирование этапа' : 'Добавление этапа'} + + + + + + + + {(error || validationError) && {error || validationError}} + {invalidStoredDate && Формат сохранённых дат не распознан. Сохранение недоступно.} + setForm({ ...form, phase_code: e.target.value })} + /> + + { + setForm({ + ...form, + form_type: e.target.value, + sheet: '', + direction: '', + column_keys: [], + }); + }}> + {Object.entries(TEMPLATE_FORMS).map(([key, label]) => ( + + {label} + + ))} + + { + setForm({ + ...form, + sheet: e.target.value, + direction: '', + column_keys: [], + }); + }}> + {sheetOptions.map((sheet) => ( + + {SHEET_NAME[sheet] || sheet} + + ))} + + {showDirection && ( + setForm({ ...form, direction: e.target.value })}> + Без направления + {Object.entries(DIRECTION_TRANSLATE).map(([key, label]) => ( + + {label} + + ))} + + )} + setForm({ ...form, role: e.target.value })}> + {Object.entries(STAGE_ROLE_RUSSIAN_NAME) + .filter(([key]) => key !== 'ADMIN') + .map(([key, label]) => ( + + {label} + + ))} + + + {columnsError && {columnsError}} + {columnsLoading && Загрузка колонок…} + {form.sheet && !columnsLoading && columns.length > 0 && ( + + Выбор колонок + `data.${key}`)} + forStage + onSelectNode={(column, isSelected) => { + if (saving) return; + const keys = getColumnTreeLeafKeys(filterColumnTree([column], stageColumnsHiddenFromPicker)).map((key) => + key.slice(5), + ); + setForm((current) => ({ + ...current, + column_keys: isSelected + ? current.column_keys.filter((key) => !keys.includes(key)) + : [...new Set([...current.column_keys, ...keys])], + })); + }} + onSelectAllNodes={(keys, isSelected) => { + if (saving) return; + const values = keys.map((key) => key.slice(5)); + setForm((current) => ({ + ...current, + column_keys: isSelected + ? current.column_keys.filter((key) => !values.includes(key)) + : [...new Set([...current.column_keys, ...values])], + })); + }} + /> + + )} + Даты задаются относительно года бюджета. Сдвиг 0 — год бюджета, −1 — предыдущий, 1 — следующий. + + + + + + + Отмена + + + Сохранить + + + + + + ); +} 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]) => ( + + {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 ( + <> + + 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..1bad731 --- /dev/null +++ b/web/src/pages/AdminPanel/PhaseTemplatesPage/utils/templateDates.js @@ -0,0 +1,50 @@ +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', - }, }, }; diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index 59db4b6..99a1f8a 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -34,6 +34,7 @@ const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.dat const formatValue = (value) => { if (value === null || value === undefined || value === '') return '—'; const numericValue = typeof value === 'number' ? value : Number(value); + if (numericValue === 0) return '-'; return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue); }; diff --git a/web/src/pages/SvodPage/SvodPage.jsx b/web/src/pages/SvodPage/SvodPage.jsx index f88df85..d844994 100644 --- a/web/src/pages/SvodPage/SvodPage.jsx +++ b/web/src/pages/SvodPage/SvodPage.jsx @@ -41,6 +41,7 @@ const tableSize = (rem) => rem * 16; const formatValue = (value) => { if (value === null || value === undefined || value === '') return '—'; const numericValue = typeof value === 'number' ? value : Number(value); + if (numericValue === 0) return '-'; return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue); };