добавила страницу с созданием/редактированием для предопрделенных этапов

This commit is contained in:
PotapovaA 2026-09-15 12:39:43 +03:00
parent e1596e8782
commit e7f0fb665d
27 changed files with 1269 additions and 23 deletions

View File

@ -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);
},
};

View File

@ -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 = () => {
<Route path='/table-temp/:tableId' element={<TableTempPage />} />
<Route path='/admin_panel/users' element={<UsersPage />} />
<Route path='/admin_panel/ssps' element={<SspsPage />} />
<Route path='/admin_panel/default-stage' element={<PhaseTemplatesPage />} />
<Route path='/admin_panel/audit-log' element={<AuditLogsPage />} />
<Route path='/dicts/dict-vsp' element={<DictVspPage />} />
<Route path='/dicts-navigator' element={<DictsNavigatorPage />} />

View File

@ -6,6 +6,7 @@ export function SwitchUserSsp() {
<Buttons>
<StyledNavLink to='/admin_panel/users'>Пользователи</StyledNavLink>
<StyledNavLink to='/admin_panel/ssps'>ССП/Региональные филиалы</StyledNavLink>
<StyledNavLink to='/admin_panel/default-stage'>Предопределенные этапы</StyledNavLink>
<StyledNavLink to='/admin_panel/audit-log'>Журнал аудита</StyledNavLink>
</Buttons>
</Switch>

View File

@ -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 (
<StageFormTheme>
<ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}>
<ModalContainer>
<HeaderContainer>
@ -291,20 +294,21 @@ export const AddStagesModal = ({
marginTop: '1.5rem',
width: '100%',
}}>
<Button variant='outlined' color='default' onClick={handleCancel} style={{ height: '2.5rem' }} disabled={isSaving}>
<PrimaryOutlinedButton onClick={handleCancel} style={{ height: '2.5rem' }} disabled={isSaving}>
Отмена
</Button>
<Button
</PrimaryOutlinedButton>
<PrimaryButton
variant='contained'
onClick={handleSave}
style={{ height: '2.5rem' }}
disabled={!isFormValid || isSaving}
loading={isSaving}>
Сохранить
</Button>
</PrimaryButton>
</Stack>
</Box>
</ModalContainer>
</ModalBackdrop>
</StageFormTheme>
);
};

View File

@ -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;

View File

@ -7,13 +7,13 @@ import { monthNames } from './constants';
export const CalendarHeader = ({ month, year, onPrevMonth, onNextMonth, isPrevMonthDisabled }) => {
return (
<StyledCalendarHeader>
<IconButton size='small' onClick={onPrevMonth} disabled={isPrevMonthDisabled}>
<IconButton size='small' color='primary' onClick={onPrevMonth} disabled={isPrevMonthDisabled}>
<ChevronLeftIcon />
</IconButton>
<Typography variant='body1'>
<Typography variant='body1' sx={{ color: '#258141', fontWeight: 600 }}>
{monthNames[month]} {year}
</Typography>
<IconButton size='small' onClick={onNextMonth}>
<IconButton size='small' color='primary' onClick={onNextMonth}>
<ChevronRightIcon />
</IconButton>
</StyledCalendarHeader>

View File

@ -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',
},
}}>
Следующий год

View File

@ -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 <ThemeProvider theme={theme}>{children}</ThemeProvider>;
}

View File

@ -31,9 +31,9 @@ export const getStatusColors = (status) => {
border: '#BEDBFF',
},
Активен: {
bg: '#DCFCE7',
color: '#016630',
border: '#B9F8CF',
bg: '#EDF8F0',
color: '#258141',
border: '#6FB246',
},
Завершен: {
bg: '#F3F4F6',

View File

@ -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',

View File

@ -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 <Alert severity='warning'>Страница доступна только администратору.</Alert>;
return (
<PageContainer>
<HeaderSwitch />
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
alignItems: 'center',
}}>
<SwitchUserSsp />
<PrimaryButton onClick={() => openDialog()}>Добавить этап</PrimaryButton>
</Box>
<PhaseTemplatesFilters
filters={filters}
sheets={sheets}
changeFilter={changeFilter}
load={load}
loading={loading}
saving={saving}
deleting={deleting}
/>
{error && (
<Alert
severity='error'
action={
<Button onClick={load} disabled={loading}>
Повторить
</Button>
}>
{error}
</Alert>
)}
<PhaseTemplatesTable
rows={filteredRows}
loading={loading}
page={currentPage}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(value) => {
setPageSize(value);
setPage(0);
}}
onEdit={openDialog}
onDelete={openDeleteDialog}
emptyMessage={emptyMessage}
/>
<DeletePhaseTemplateDialog
deleteTarget={deleteTarget}
deleting={deleting}
deleteError={deleteError}
onClose={closeDeleteDialog}
onConfirm={confirmDelete}
/>
{dialog && <PhaseTemplateDialog template={dialog.template} onClose={closeDialog} onSave={save} saving={saving} error={saveError} />}
</PageContainer>
);
}

View File

@ -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 (
<Dialog open={Boolean(deleteTarget)} onClose={onClose} fullWidth maxWidth='xs' aria-labelledby='delete-template-title'>
<DialogTitle id='delete-template-title'>Удалить предопределённый этап?</DialogTitle>
<DialogContent>
<Typography fontWeight={600}>{deleteTarget?.phase_code}</Typography>
<Typography variant='body2' color='text.secondary' sx={{ mt: 1 }}>
{TEMPLATE_FORMS[deleteTarget?.form_type]} · {SHEET_NAME[deleteTarget?.sheet] || deleteTarget?.sheet}
{deleteTarget?.direction ? ` · ${DIRECTION_TRANSLATE[deleteTarget.direction] || deleteTarget.direction}` : ''}
</Typography>
<Typography variant='body2' sx={{ mt: 2 }}>
Шаблон больше не будет использоваться при создании новых форм и отчётов.
</Typography>
{deleteError && (
<Alert severity='error' sx={{ mt: 2 }}>
{deleteError}
</Alert>
)}
</DialogContent>
<DialogActions>
<SecondaryButton onClick={onClose} disabled={deleting}>
Отмена
</SecondaryButton>
<DangerOutlinedButton onClick={onConfirm} disabled={deleting} loading={deleting}>
Удалить
</DangerOutlinedButton>
</DialogActions>
</Dialog>
);
}

View File

@ -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 (
<Box
component='span'
sx={{
display: 'inline-flex',
alignItems: 'center',
maxWidth: '100%',
minHeight: '1.75rem',
boxSizing: 'border-box',
py: '0.25rem',
px: '0.65rem',
borderRadius: '0.35rem',
border: `1px solid ${style.border}`,
color: style.color,
backgroundColor: style.background,
fontSize: '0.75rem',
fontWeight: 700,
lineHeight: 1.4,
textTransform: 'uppercase',
whiteSpace: 'normal',
overflowWrap: 'anywhere',
}}>
{style.label || TEMPLATE_FORMS[formType] || formType}
</Box>
);
}

View File

@ -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 (
<StageFormTheme>
<Dialog
open
onClose={saving ? undefined : onClose}
fullWidth
maxWidth='sm'
aria-labelledby='template-dialog-title'
slotProps={{
paper: {
sx: {
borderRadius: '1rem',
border: '1px solid rgba(0,0,0,0.1)',
p: '1.25rem',
boxShadow: '0 2rem 4rem -2rem rgba(0,0,0,0.1), 0 4rem 6rem -1rem rgba(0,0,0,0.1)',
},
},
}}>
<Box component='form' onSubmit={submit}>
<HeaderContainer>
<ModalTitle id='template-dialog-title'>{template ? 'Редактирование этапа' : 'Добавление этапа'}</ModalTitle>
<IconButton size='small' onClick={onClose} disabled={saving} aria-label='Закрыть'>
<Cancel />
</IconButton>
</HeaderContainer>
<Divider sx={{ my: '0.75rem' }} />
<DialogContent sx={{ p: 0 }}>
<Box component='fieldset' disabled={saving} sx={{ border: 0, p: 0, m: 0, pt: 1, display: 'grid', gap: 1.5 }}>
{(error || validationError) && <Alert severity='error'>{error || validationError}</Alert>}
{invalidStoredDate && <Alert severity='error'>Формат сохранённых дат не распознан. Сохранение недоступно.</Alert>}
<TextField
{...fieldProps}
label='Название этапа'
required
disabled={!!template}
value={form.phase_code}
onChange={(e) => setForm({ ...form, phase_code: e.target.value })}
/>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
gap: 1.5,
}}>
<TextField
{...fieldProps}
select
label='Форма'
disabled={!!template}
value={form.form_type}
onChange={(e) => {
setForm({
...form,
form_type: e.target.value,
sheet: '',
direction: '',
column_keys: [],
});
}}>
{Object.entries(TEMPLATE_FORMS).map(([key, label]) => (
<MenuItem key={key} value={key}>
{label}
</MenuItem>
))}
</TextField>
<TextField
{...fieldProps}
select
label='Лист'
required
disabled={!!template}
value={form.sheet}
onChange={(e) => {
setForm({
...form,
sheet: e.target.value,
direction: '',
column_keys: [],
});
}}>
{sheetOptions.map((sheet) => (
<MenuItem key={sheet} value={sheet}>
{SHEET_NAME[sheet] || sheet}
</MenuItem>
))}
</TextField>
{showDirection && (
<TextField
{...fieldProps}
select
label='Направление'
disabled={!!template}
value={form.direction || ''}
onChange={(e) => setForm({ ...form, direction: e.target.value })}>
<MenuItem value=''>Без направления</MenuItem>
{Object.entries(DIRECTION_TRANSLATE).map(([key, label]) => (
<MenuItem key={key} value={key}>
{label}
</MenuItem>
))}
</TextField>
)}
<TextField
{...fieldProps}
select
label='Роль'
required
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value })}>
{Object.entries(STAGE_ROLE_RUSSIAN_NAME)
.filter(([key]) => key !== 'ADMIN')
.map(([key, label]) => (
<MenuItem key={key} value={key}>
{label}
</MenuItem>
))}
</TextField>
</Box>
{columnsError && <Alert severity='warning'>{columnsError}</Alert>}
{columnsLoading && <Typography variant='body2'>Загрузка колонок…</Typography>}
{form.sheet && !columnsLoading && columns.length > 0 && (
<FormControl fullWidth>
<FormLabel>Выбор колонок</FormLabel>
<CollapsibleTree
popoverSx={{ zIndex: 1400 }}
columnTree={columns}
excludeColumnKeys={stageColumnsHiddenFromPicker}
placeholder='Выбрать столбцы'
placeholderSelect='Выбрано столбцов: '
selectedIds={form.column_keys.map((key) => `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])],
}));
}}
/>
</FormControl>
)}
<Alert severity='info'>Даты задаются относительно года бюджета. Сдвиг 0 — год бюджета, −1 — предыдущий, 1 — следующий.</Alert>
<TemplateDateInput title='Начало этапа' value={opens} onChange={setOpens} />
<TemplateDateInput error={dateOrderError} title='Окончание этапа' value={closes} onChange={setCloses} />
</Box>
</DialogContent>
<DialogActions sx={{ p: 0, mt: '1.5rem', gap: '0.75rem' }}>
<PrimaryOutlinedButton onClick={onClose} disabled={saving} sx={{ height: '2.5rem' }}>
Отмена
</PrimaryOutlinedButton>
<PrimaryButton
type='submit'
disabled={saving || invalidStoredDate || columnsLoading || !!columnsError || !datesValid || !!dateOrderError}
loading={saving}
sx={{ height: '2.5rem' }}>
Сохранить
</PrimaryButton>
</DialogActions>
</Box>
</Dialog>
</StageFormTheme>
);
}

View File

@ -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 (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField
size='small'
label='Поиск по названию или колонкам'
value={filters.search}
onChange={(e) => 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]) => (
<TextField
key={key}
select
size='small'
label={label}
value={filters[key]}
onChange={(e) => changeFilter(key, e.target.value)}
sx={{ minWidth: 170 }}>
<MenuItem value=''>Все</MenuItem>
{Object.entries(options).map(([value, text]) => (
<MenuItem key={value} value={value}>
{text}
</MenuItem>
))}
</TextField>
))}
<PrimaryOutlinedButton sx={{height: '100%'}} onClick={load} disabled={loading || saving || deleting}>
Обновить
</PrimaryOutlinedButton>
</Box>
);
}

View File

@ -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 (
<Box sx={{ whiteSpace: 'nowrap' }}>
<Typography variant='body2'>
{date.day}.{date.month}
</Typography>
<Typography variant='caption' color='text.secondary'>
{year}
</Typography>
</Box>
);
};
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 }) => <FormBadge formType={cell.getValue()} />,
},
{
accessorKey: 'sheet',
header: 'Лист',
size: 220,
Cell: ({ cell }) => <SheetBadge sheet={cell.getValue()} />,
},
{
accessorKey: 'direction',
header: 'Направление',
size: 140,
Cell: ({ cell }) => DIRECTION_TRANSLATE[cell.getValue()] || cell.getValue() || null,
},
{
accessorKey: 'role',
header: 'Роль',
size: 190,
Cell: ({ cell }) => (
<Chip
label={STAGE_ROLE_RUSSIAN_NAME[cell.getValue()] || cell.getValue()}
sx={{
border: '0.53px solid #D1D5DC',
borderRadius: '0.5rem',
padding: 0,
height: '1.69rem',
backgroundColor: '#F3F4F6',
'& .MuiChip-label': {
padding: '0 0.5rem',
fontSize: '0.75rem',
lineHeight: '150%',
color: '#364153',
},
}}
/>
),
},
{
id: 'columns',
header: 'Колонки',
size: 100,
Cell: ({ row }) => <TemplateColumnsCell template={row.original} />,
},
{
accessorKey: 'opens_at',
header: 'Начало',
size: 160,
Cell: ({ cell }) => <DateCell value={cell.getValue()} />,
},
{
accessorKey: 'closes_at',
header: 'Окончание',
size: 160,
Cell: ({ cell }) => <DateCell value={cell.getValue()} />,
},
]
.map((column) => ({ ...column, enableColumnActions: false })),
[],
);
return (
<StyledTable
columns={columns}
data={rows.slice(page * pageSize, (page + 1) * pageSize)}
loading={loading}
page={page}
rowsPerPage={pageSize}
totalCount={rows.length}
onPageChange={onPageChange}
onRowsPerPageChange={onPageSizeChange}
rowsPerPageOptions={[25, 50, 100]}
containerHeight='calc(100vh - 19rem)'
minHeight='300px'
enableRowActions
renderRowActions={({ row }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title='Редактировать'>
<IconButton
onClick={() => onEdit(row.original)}
aria-label={`Редактировать этап ${row.original.phase_code}`}
sx={{
margin: '0.25rem',
padding: 0,
width: '1rem',
height: '1rem',
}}>
<EditPenSvg />
</IconButton>
</Tooltip>
<Tooltip title='Удалить этап'>
<IconButton onClick={() => onDelete(row.original)} aria-label={`Удалить этап ${row.original.phase_code}`} size='small'>
<TrashSvg fill='#b91c1c' size={18} />
</IconButton>
</Tooltip>
</Box>
)}
tableOptions={{
getRowId: templateKey,
localization: { noRecordsToDisplay: emptyMessage },
displayColumnDefOptions: {
'mrt-row-actions': { header: 'Действия', size: 110 },
},
}}
/>
);
}

View File

@ -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 (
<Box
component='span'
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
maxWidth: '100%',
minHeight: '1.75rem',
boxSizing: 'border-box',
py: '0.2rem',
px: '0.65rem',
borderRadius: '1.5rem',
border: `1px solid ${style.border}`,
color: style.color,
backgroundColor: style.background,
fontSize: '0.75rem',
fontWeight: 500,
lineHeight: 1.4,
}}>
<Icon sx={{ fontSize: '1rem', flexShrink: 0 }} />
<Box component='span' sx={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>
{SHEET_NAME[sheet] || sheet}
</Box>
</Box>
);
}

View File

@ -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 (
<>
<Button
startIcon={<VisibilityOutlinedIcon fontSize='small' />}
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}
</Button>
<Popover
id={id}
open={open}
anchorEl={anchor}
onClose={() => setAnchor(null)}
slotProps={{
paper: {
role: 'dialog',
'aria-label': `Колонки этапа ${template.phase_code}`,
},
}}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}>
<Box
sx={{
p: 2,
width: 460,
maxWidth: '80vw',
maxHeight: '50vh',
overflowY: 'auto',
}}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}>
<Typography fontWeight={600}>Колонки этапа</Typography>
<Button onClick={() => setAnchor(null)}>Скрыть</Button>
</Box>
{!columns && !error && <CircularProgress size={24} aria-label='Загрузка названий колонок' />}
{error && <Alert severity='warning'>Не удалось загрузить названия колонок.</Alert>}
{(columns || error) && (
<Box component='ul' sx={{ pl: 2, mb: 0 }}>
{template.column_keys.map((key) => (
<Typography component='li' key={key} variant='body2' sx={{ mb: 1, overflowWrap: 'anywhere' }}>
{columns?.get(key) || key}
</Typography>
))}
</Box>
)}
</Box>
</Popover>
</>
);
}

View File

@ -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 (
<Box>
<Typography sx={{ mb: 1 }}>{title}</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
gap: 1.5,
}}>
<TextField
size='small'
fullWidth
required
label='Дата'
placeholder='ДД.ММ'
value={text}
onChange={(event) => {
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: (
<InputAdornment position='start'>
<CalendarIcon />
</InputAdornment>
),
},
}}
/>
<TextField
size='small'
fullWidth
required
label='Сдвиг от года бюджета'
type='number'
value={value.year}
onChange={(event) => onChange({ ...value, year: event.target.value })}
error={!yearValid}
helperText={yearValid ? '0 — год бюджета, −1 — предыдущий, 1 — следующий' : 'Укажите целое число.'}
slotProps={{ htmlInput: { step: 1 } }}
/>
</Box>
</Box>
);
}

View File

@ -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' },
};

View File

@ -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' };

View File

@ -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'],
};

View File

@ -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,
};
}

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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]);

View File

@ -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',
},
},
};