добавила страницу с созданием/редактированием для предопрделенных этапов
This commit is contained in:
parent
e1596e8782
commit
e7f0fb665d
26
web/src/api/phase-templates.js
Normal file
26
web/src/api/phase-templates.js
Normal 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);
|
||||
},
|
||||
};
|
||||
@ -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 />} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
},
|
||||
}}>
|
||||
Следующий год
|
||||
|
||||
33
web/src/components/Stages/StageFormTheme.jsx
Normal file
33
web/src/components/Stages/StageFormTheme.jsx
Normal 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>;
|
||||
}
|
||||
@ -31,9 +31,9 @@ export const getStatusColors = (status) => {
|
||||
border: '#BEDBFF',
|
||||
},
|
||||
Активен: {
|
||||
bg: '#DCFCE7',
|
||||
color: '#016630',
|
||||
border: '#B9F8CF',
|
||||
bg: '#EDF8F0',
|
||||
color: '#258141',
|
||||
border: '#6FB246',
|
||||
},
|
||||
Завершен: {
|
||||
bg: '#F3F4F6',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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' },
|
||||
};
|
||||
@ -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' };
|
||||
@ -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'],
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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]);
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user