Merge pull request 'add-stage-front' (#159) from add-stage-front into test
Reviewed-on: #159
This commit is contained in:
commit
5effd7b6f4
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 AuditLogsPage from '../pages/AdminPanel/AuditLogsPage/AuditLogsPage.jsx';
|
||||||
import SspsPage from '../pages/AdminPanel/SppsPage/SppsPage';
|
import SspsPage from '../pages/AdminPanel/SppsPage/SppsPage';
|
||||||
import UsersPage from '../pages/AdminPanel/UsersPage/UsersPage';
|
import UsersPage from '../pages/AdminPanel/UsersPage/UsersPage';
|
||||||
|
import PhaseTemplatesPage from '../pages/AdminPanel/PhaseTemplatesPage/PhaseTemplatesPage';
|
||||||
import DictVspPage from '../pages/DictVspPage/DictVspPage.jsx';
|
import DictVspPage from '../pages/DictVspPage/DictVspPage.jsx';
|
||||||
import DictsNavigatorPage from '../pages/DictsNavigatorPage/DictsNavigatorPage.jsx';
|
import DictsNavigatorPage from '../pages/DictsNavigatorPage/DictsNavigatorPage.jsx';
|
||||||
import FormsPage from '../pages/FormsPage';
|
import FormsPage from '../pages/FormsPage';
|
||||||
@ -48,6 +49,7 @@ export const AppRoutes = () => {
|
|||||||
<Route path='/table-temp/:tableId' element={<TableTempPage />} />
|
<Route path='/table-temp/:tableId' element={<TableTempPage />} />
|
||||||
<Route path='/admin_panel/users' element={<UsersPage />} />
|
<Route path='/admin_panel/users' element={<UsersPage />} />
|
||||||
<Route path='/admin_panel/ssps' element={<SspsPage />} />
|
<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='/admin_panel/audit-log' element={<AuditLogsPage />} />
|
||||||
<Route path='/dicts/dict-vsp' element={<DictVspPage />} />
|
<Route path='/dicts/dict-vsp' element={<DictVspPage />} />
|
||||||
<Route path='/dicts-navigator' element={<DictsNavigatorPage />} />
|
<Route path='/dicts-navigator' element={<DictsNavigatorPage />} />
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export function SwitchUserSsp() {
|
|||||||
<Buttons>
|
<Buttons>
|
||||||
<StyledNavLink to='/admin_panel/users'>Пользователи</StyledNavLink>
|
<StyledNavLink to='/admin_panel/users'>Пользователи</StyledNavLink>
|
||||||
<StyledNavLink to='/admin_panel/ssps'>ССП/Региональные филиалы</StyledNavLink>
|
<StyledNavLink to='/admin_panel/ssps'>ССП/Региональные филиалы</StyledNavLink>
|
||||||
|
<StyledNavLink to='/admin_panel/default-stage'>Предопределенные этапы</StyledNavLink>
|
||||||
<StyledNavLink to='/admin_panel/audit-log'>Журнал аудита</StyledNavLink>
|
<StyledNavLink to='/admin_panel/audit-log'>Журнал аудита</StyledNavLink>
|
||||||
</Buttons>
|
</Buttons>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@ -219,9 +219,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleClickRowCell = useCallback((rowId) => {
|
const handleClickRowCell = useCallback((rowId) => {
|
||||||
setRowSelection((prev) => ({
|
setRowSelection((prev) => (prev[rowId] ? {} : { [rowId]: true }));
|
||||||
[rowId]: !prev[rowId],
|
|
||||||
}));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleColumnSelect = useCallback((columnId) => {
|
const handleColumnSelect = useCallback((columnId) => {
|
||||||
@ -358,7 +356,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
muiTableBodyRowProps: ({ row }) => ({
|
muiTableBodyRowProps: ({ row }) => ({
|
||||||
onClick: () => setRowSelection({ [row.id]: true }),
|
onClick: () => handleClickRowCell(row.id),
|
||||||
hover: false,
|
hover: false,
|
||||||
sx: {
|
sx: {
|
||||||
'& > td::after': {
|
'& > td::after': {
|
||||||
|
|||||||
@ -248,7 +248,8 @@ export const getTableColumns = ({
|
|||||||
size: 50,
|
size: 50,
|
||||||
enableEditing: false,
|
enableEditing: false,
|
||||||
Cell: ({ cell, row }) => {
|
Cell: ({ cell, row }) => {
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback((event) => {
|
||||||
|
event.stopPropagation();
|
||||||
onCellNumberClick(row.id);
|
onCellNumberClick(row.id);
|
||||||
}, [row.id, onCellNumberClick]);
|
}, [row.id, onCellNumberClick]);
|
||||||
return <button type='button' onClick={handleClick}>{`${cell.getValue()} `}</button>;
|
return <button type='button' onClick={handleClick}>{`${cell.getValue()} `}</button>;
|
||||||
|
|||||||
@ -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 { useEffect, useState } from 'react';
|
||||||
import { STAGE_ROLE_RUSSIAN_NAME, DIRECTION_TRANSLATE } from '../../constants/constants';
|
import { STAGE_ROLE_RUSSIAN_NAME, DIRECTION_TRANSLATE } from '../../constants/constants';
|
||||||
import { formatDateForApi } from '../../utils/formatDate';
|
import { formatDateForApi } from '../../utils/formatDate';
|
||||||
@ -150,6 +152,7 @@ export const AddStagesModal = ({
|
|||||||
const isFormValid = startDate && endDate && stageName.trim() && selectedSheet && (selectedDirection || !showDirectionSelect);
|
const isFormValid = startDate && endDate && stageName.trim() && selectedSheet && (selectedDirection || !showDirectionSelect);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<StageFormTheme>
|
||||||
<ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}>
|
<ModalBackdrop isOpen={isOpen} onClick={handleBackdropClick}>
|
||||||
<ModalContainer>
|
<ModalContainer>
|
||||||
<HeaderContainer>
|
<HeaderContainer>
|
||||||
@ -291,20 +294,21 @@ export const AddStagesModal = ({
|
|||||||
marginTop: '1.5rem',
|
marginTop: '1.5rem',
|
||||||
width: '100%',
|
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>
|
</PrimaryOutlinedButton>
|
||||||
<Button
|
<PrimaryButton
|
||||||
variant='contained'
|
variant='contained'
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
style={{ height: '2.5rem' }}
|
style={{ height: '2.5rem' }}
|
||||||
disabled={!isFormValid || isSaving}
|
disabled={!isFormValid || isSaving}
|
||||||
loading={isSaving}>
|
loading={isSaving}>
|
||||||
Сохранить
|
Сохранить
|
||||||
</Button>
|
</PrimaryButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalContainer>
|
</ModalContainer>
|
||||||
</ModalBackdrop>
|
</ModalBackdrop>
|
||||||
|
</StageFormTheme>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -51,13 +51,16 @@ export const CalendarDay = styled(Box, {
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #dcfce7;
|
background: #edf8f0;
|
||||||
|
color: #258141;
|
||||||
}
|
}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.isToday &&
|
props.isToday &&
|
||||||
`
|
`
|
||||||
background: var(--GreyLight, rgba(243, 243, 245, 1));
|
background: #edf8f0;
|
||||||
|
color: #258141;
|
||||||
|
box-shadow: inset 0 0 0 1px #6fb246;
|
||||||
`}
|
`}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
@ -65,6 +68,7 @@ export const CalendarDay = styled(Box, {
|
|||||||
`
|
`
|
||||||
background: #258141;
|
background: #258141;
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #258141;
|
background: #258141;
|
||||||
|
|||||||
@ -7,13 +7,13 @@ import { monthNames } from './constants';
|
|||||||
export const CalendarHeader = ({ month, year, onPrevMonth, onNextMonth, isPrevMonthDisabled }) => {
|
export const CalendarHeader = ({ month, year, onPrevMonth, onNextMonth, isPrevMonthDisabled }) => {
|
||||||
return (
|
return (
|
||||||
<StyledCalendarHeader>
|
<StyledCalendarHeader>
|
||||||
<IconButton size='small' onClick={onPrevMonth} disabled={isPrevMonthDisabled}>
|
<IconButton size='small' color='primary' onClick={onPrevMonth} disabled={isPrevMonthDisabled}>
|
||||||
<ChevronLeftIcon />
|
<ChevronLeftIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant='body1'>
|
<Typography variant='body1' sx={{ color: '#258141', fontWeight: 600 }}>
|
||||||
{monthNames[month]} {year}
|
{monthNames[month]} {year}
|
||||||
</Typography>
|
</Typography>
|
||||||
<IconButton size='small' onClick={onNextMonth}>
|
<IconButton size='small' color='primary' onClick={onNextMonth}>
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</StyledCalendarHeader>
|
</StyledCalendarHeader>
|
||||||
|
|||||||
@ -19,11 +19,13 @@ export const YearTabs = ({ selectedYear, onYearChange }) => {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
fontSize: '0.875rem',
|
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,
|
borderRadius: 0,
|
||||||
padding: '0.5rem',
|
padding: '0.5rem',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: 'var(--GreyLight, rgba(243, 243, 245, 1))',
|
backgroundColor: '#EDF8F0',
|
||||||
},
|
},
|
||||||
}}>
|
}}>
|
||||||
Текущий год
|
Текущий год
|
||||||
@ -35,11 +37,13 @@ export const YearTabs = ({ selectedYear, onYearChange }) => {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
fontSize: '0.875rem',
|
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,
|
borderRadius: 0,
|
||||||
padding: '0.5rem',
|
padding: '0.5rem',
|
||||||
'&:hover': {
|
'&: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',
|
border: '#BEDBFF',
|
||||||
},
|
},
|
||||||
Активен: {
|
Активен: {
|
||||||
bg: '#DCFCE7',
|
bg: '#EDF8F0',
|
||||||
color: '#016630',
|
color: '#258141',
|
||||||
border: '#B9F8CF',
|
border: '#6FB246',
|
||||||
},
|
},
|
||||||
Завершен: {
|
Завершен: {
|
||||||
bg: '#F3F4F6',
|
bg: '#F3F4F6',
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const CollapsibleTree = ({
|
|||||||
onSelectNode,
|
onSelectNode,
|
||||||
onSelectAllNodes,
|
onSelectAllNodes,
|
||||||
forStage = false,
|
forStage = false,
|
||||||
|
popoverSx,
|
||||||
}) => {
|
}) => {
|
||||||
const [expandedItems, setExpandedItems] = useState([]);
|
const [expandedItems, setExpandedItems] = useState([]);
|
||||||
const [anchorEl, setAnchorEl] = useState(null);
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
@ -203,7 +204,7 @@ const CollapsibleTree = ({
|
|||||||
open={open}
|
open={open}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
sx={forStage ? { zIndex: 70 } : undefined}
|
sx={{ ...(forStage ? { zIndex: 70 } : {}), ...popoverSx }}
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
vertical: 'bottom',
|
vertical: 'bottom',
|
||||||
horizontal: 'left',
|
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,50 @@
|
|||||||
|
export const parseTemplateDate = (value) => {
|
||||||
|
const match = /^(\d+)-(\d{2})-(\d{2})[T ](\d{2}:\d{2}:\d{2}(?:\.\d+)?)( BC)?$/.exec(value || '');
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
year: String(match[5] ? 1 - Number(match[1]) : Number(match[1])),
|
||||||
|
month: match[2],
|
||||||
|
day: match[3],
|
||||||
|
time: match[4],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeTemplateDate = ({ year, month, day, time }) => {
|
||||||
|
const offset = Number(year);
|
||||||
|
const dateYear = String(offset <= 0 ? 1 - offset : offset).padStart(4, '0');
|
||||||
|
return `${dateYear}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${time.length === 5 ? `${time}:00` : time}${offset <= 0 ? ' BC' : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const templateDateNumber = (value) => {
|
||||||
|
if (!value || !/^-?\d+$/.test(value.year) || !Number.isSafeInteger(Number(value.year))) return Number.NaN;
|
||||||
|
const { month, day, time } = value;
|
||||||
|
if (!Number.isInteger(Number(month)) || !Number.isInteger(Number(day)) || Number(month) < 1 || Number(month) > 12 || Number(day) < 1)
|
||||||
|
return Number.NaN;
|
||||||
|
if (!/^\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?$/.test(time)) return Number.NaN;
|
||||||
|
const [hours, minutes, seconds = 0] = time.split(':').map(Number);
|
||||||
|
const year = 2000 + Number(value.year);
|
||||||
|
const date = new Date(0);
|
||||||
|
date.setUTCFullYear(year, Number(month) - 1, Number(day));
|
||||||
|
date.setUTCHours(hours, minutes, Math.floor(seconds), 0);
|
||||||
|
if (
|
||||||
|
date.getUTCFullYear() !== year ||
|
||||||
|
date.getUTCMonth() !== Number(month) - 1 ||
|
||||||
|
date.getUTCDate() !== Number(day) ||
|
||||||
|
hours > 23 ||
|
||||||
|
minutes > 59 ||
|
||||||
|
seconds >= 60
|
||||||
|
)
|
||||||
|
return Number.NaN;
|
||||||
|
return date.getTime() + (seconds % 1) * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTemplateDate = (value) => {
|
||||||
|
const date = parseTemplateDate(value);
|
||||||
|
if (!date) return value || '—';
|
||||||
|
const offset = Number(date.year);
|
||||||
|
const year = offset === 0 ? 'год бюджета' : `год бюджета ${offset > 0 ? '+' : '−'} ${Math.abs(offset)}`;
|
||||||
|
return `${date.day}.${date.month} (${year})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const templateKey = (template) =>
|
||||||
|
JSON.stringify([template.form_type, template.sheet, template.direction ?? null, template.phase_code]);
|
||||||
@ -35,16 +35,18 @@ const DEFAULT_TABLE_STYLES = {
|
|||||||
color: '#364153',
|
color: '#364153',
|
||||||
},
|
},
|
||||||
bodyRow: {
|
bodyRow: {
|
||||||
'&:hover': {
|
'&:nth-of-type(odd), &:nth-of-type(odd):hover': {
|
||||||
backgroundColor: 'white !important',
|
backgroundColor: '#FFFFFF',
|
||||||
|
'& > .MuiTableCell-root': { backgroundColor: '#FFFFFF' },
|
||||||
|
},
|
||||||
|
'&:nth-of-type(even), &:nth-of-type(even):hover': {
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
'& > .MuiTableCell-root': { backgroundColor: '#F9FAFB' },
|
||||||
},
|
},
|
||||||
'&:hover td::after': {
|
'&:hover td::after': {
|
||||||
opacity: '0 !important',
|
opacity: '0 !important',
|
||||||
backgroundColor: 'transparent !important',
|
backgroundColor: 'transparent !important',
|
||||||
},
|
},
|
||||||
'&:hover .MuiTableCell-root': {
|
|
||||||
backgroundColor: 'white !important',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.dat
|
|||||||
const formatValue = (value) => {
|
const formatValue = (value) => {
|
||||||
if (value === null || value === undefined || value === '') return '—';
|
if (value === null || value === undefined || value === '') return '—';
|
||||||
const numericValue = typeof value === 'number' ? value : Number(value);
|
const numericValue = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (numericValue === 0) return '-';
|
||||||
return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue);
|
return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,7 @@ const tableSize = (rem) => rem * 16;
|
|||||||
const formatValue = (value) => {
|
const formatValue = (value) => {
|
||||||
if (value === null || value === undefined || value === '') return '—';
|
if (value === null || value === undefined || value === '') return '—';
|
||||||
const numericValue = typeof value === 'number' ? value : Number(value);
|
const numericValue = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (numericValue === 0) return '-';
|
||||||
return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue);
|
return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user