P8-Panels/app/components/p8p_gantt.js

520 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Парус 8 - Панели мониторинга
Компонент: Диаграмма Ганта
*/
//---------------------
//Подключение библиотек
//---------------------
import React, { useEffect, useState, useCallback, useRef } from "react"; //Классы React
import PropTypes from "prop-types"; //Контроль свойств компонента
import {
Box,
IconButton,
Icon,
Typography,
Dialog,
DialogActions,
DialogContent,
TextField,
Button,
List,
ListItem,
ListItemText,
Divider,
Slider,
Link
} from "@mui/material"; //Интерфейсные компоненты
import { P8PAppInlineError } from "./p8p_app_message"; //Встраиваемое сообщение об ошибке
//---------
//Константы
//---------
//Уровни масштаба
const P8P_GANTT_ZOOM = [0, 1, 2, 3, 4];
//Уровни масштаба (строковые наименования в терминах библиотеки)
const P8P_GANTT_ZOOM_VIEW_MODES = {
0: "Quarter Day",
1: "Half Day",
2: "Day",
3: "Week",
4: "Month"
};
//Структура задачи
const P8P_GANTT_TASK_SHAPE = PropTypes.shape({
id: PropTypes.string.isRequired,
rn: PropTypes.number.isRequired,
numb: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
fullName: PropTypes.string.isRequired,
start: PropTypes.string.isRequired,
end: PropTypes.string.isRequired,
progress: PropTypes.number,
dependencies: PropTypes.array,
readOnly: PropTypes.bool,
readOnlyDates: PropTypes.bool,
readOnlyProgress: PropTypes.bool,
bgColor: PropTypes.string,
textColor: PropTypes.string,
bgProgressColor: PropTypes.string
});
//Структура динамического атрибута задачи
const P8P_GANTT_TASK_ATTRIBUTE_SHAPE = PropTypes.shape({
name: PropTypes.string.isRequired,
caption: PropTypes.string.isRequired,
visible: PropTypes.bool.isRequired
});
//Структура описания цвета задачи
const P8P_GANTT_TASK_COLOR_SHAPE = PropTypes.shape({
bgColor: PropTypes.string,
textColor: PropTypes.string,
bgProgressColor: PropTypes.string,
desc: PropTypes.string.isRequired
});
//Высота заголовка
const TITLE_HEIGHT = "44px";
//Высота панели масштабирования
const ZOOM_HEIGHT = "56px";
//Стили
const STYLES = {
TASK_EDITOR_CONTENT: { minWidth: 400, overflowX: "auto" },
TASK_EDITOR_LIST: { width: "100%", minWidth: 300, maxWidth: 700, bgcolor: "background.paper" },
GANTT_TITLE: { height: TITLE_HEIGHT },
GANTT_ZOOM: { height: ZOOM_HEIGHT },
GANTT: (noData, title, zoomBar) => ({
height: `calc(100% - ${zoomBar ? ZOOM_HEIGHT : "0px"} - ${title ? TITLE_HEIGHT : "0px"})`,
display: noData ? "none" : ""
})
};
//--------------------------------
//Вспомогательные классы и функции
//--------------------------------
//Проверка существования значения
const hasValue = value => typeof value !== "undefined" && value !== null && value !== "";
//Формирование описания для легенды
const taskLegendDesc = ({ task, taskColors }) => {
if (Array.isArray(taskColors) && taskColors.length > 0) {
const colorDesc = taskColors.find(
color => task.bgColor === color.bgColor && task.textColor === color.textColor && task.bgProgressColor === color.bgProgressColor
);
if (colorDesc)
return {
text: colorDesc.desc,
style: {
...(colorDesc.bgProgressColor
? {
background: `linear-gradient(to right, ${colorDesc.bgProgressColor} ,${
colorDesc.bgColor ? colorDesc.bgColor : "transparent"
})`
}
: colorDesc.bgColor
? { backgroundColor: colorDesc.bgColor }
: {}),
...(colorDesc.textColor ? { color: colorDesc.textColor } : {})
}
};
else return null;
} else return null;
};
//Редактор задачи
const P8PGanttTaskEditor = ({
task,
taskAttributes,
taskColors,
onOk,
onCancel,
taskAttributeRenderer,
taskDialogRenderer,
numbCaption,
nameCaption,
startCaption,
endCaption,
progressCaption,
legendCaption,
okBtnCaption,
cancelBtnCaption
}) => {
//Собственное состояние
const [state, setState] = useState({
start: task.start,
end: task.end,
progress: task.progress
});
//Отображаемые атрибуты
const dispTaskAttributes =
Array.isArray(taskAttributes) && taskAttributes.length > 0 ? taskAttributes.filter(attr => attr.visible && hasValue(task[attr.name])) : [];
//При сохранении
const handleOk = () => (onOk && state.start && state.end ? onOk({ task, start: state.start, end: state.end, progress: state.progress }) : null);
//При отмене
const handleCancel = () => (onCancel ? onCancel() : null);
//При изменении сроков
const handlePeriodChanged = e => setState(prev => ({ ...prev, [e.target.name]: e.target.value }));
//При изменении прогресса
const handleProgressChanged = (e, newValue) => setState(prev => ({ ...prev, progress: newValue }));
//Описание легенды для задачи
const legendDesc = taskLegendDesc({ task, taskColors });
let legend = legendDesc ? (
<ListItemText
secondaryTypographyProps={{
p: 1,
sx: legendDesc.style
}}
primary={legendCaption}
secondary={legendDesc.text}
/>
) : null;
//Генерация содержимого
return (
<Dialog open onClose={handleCancel}>
{taskDialogRenderer ? (
taskDialogRenderer({ task, taskAttributes, taskColors, close: handleCancel })
) : (
<>
<DialogContent sx={STYLES.TASK_EDITOR_CONTENT}>
<List sx={STYLES.TASK_EDITOR_LIST}>
<ListItem alignItems="flex-start">
<ListItemText primary={numbCaption} secondary={task.numb} />
</ListItem>
<Divider component="li" />
<ListItem alignItems="flex-start">
<ListItemText primary={nameCaption} secondary={task.fullName} />
</ListItem>
<Divider component="li" />
<ListItem alignItems="flex-start">
<ListItemText
secondaryTypographyProps={{ component: "span" }}
primary={startCaption}
secondary={
<TextField
error={!state.start}
disabled={task.readOnly === true || task.readOnlyDates === true}
name="start"
fullWidth
required
InputLabelProps={{ shrink: true }}
type={"date"}
value={state.start}
onChange={handlePeriodChanged}
variant="standard"
size="small"
margin="normal"
/>
}
/>
</ListItem>
<Divider component="li" />
<ListItem alignItems="flex-start">
<ListItemText
secondaryTypographyProps={{ component: "span" }}
primary={endCaption}
secondary={
<TextField
error={!state.end}
disabled={task.readOnly === true || task.readOnlyDates === true}
name="end"
fullWidth
required
InputLabelProps={{ shrink: true }}
type={"date"}
value={state.end}
onChange={handlePeriodChanged}
variant="standard"
size="small"
margin="normal"
/>
}
/>
</ListItem>
{hasValue(task.progress) || legend || dispTaskAttributes.length > 0 ? <Divider component="li" /> : null}
{hasValue(task.progress) ? (
<>
<ListItem alignItems="flex-start">
<ListItemText
secondaryTypographyProps={{ component: "span" }}
primary={`${progressCaption}${
task.readOnly === true || task.readOnlyProgress === true ? ` (${task.progress}%)` : ""
}`}
secondary={
<Slider
disabled={task.readOnly === true || task.readOnlyProgress === true}
defaultValue={task.progress}
valueLabelDisplay="auto"
onChange={handleProgressChanged}
/>
}
/>
</ListItem>
{legend || dispTaskAttributes.length > 0 ? <Divider component="li" /> : null}
</>
) : null}
{legend ? (
<>
<ListItem alignItems="flex-start">{legend}</ListItem>
{dispTaskAttributes.length > 0 ? <Divider component="li" /> : null}
</>
) : null}
{dispTaskAttributes.length > 0
? dispTaskAttributes.map((attr, i) => {
const defaultView = task[attr.name];
const customView = taskAttributeRenderer ? taskAttributeRenderer({ task, attribute: attr }) : null;
return (
<React.Fragment key={i}>
<ListItem alignItems="flex-start">
<ListItemText
primary={attr.caption}
secondaryTypographyProps={{ component: "span" }}
secondary={customView ? customView : defaultView}
/>
</ListItem>
{i < dispTaskAttributes.length - 1 ? <Divider component="li" /> : null}
</React.Fragment>
);
})
: null}
</List>
</DialogContent>
<DialogActions>
<Button disabled={!state.start || !state.end || task.readOnly} onClick={handleOk}>
{okBtnCaption}
</Button>
<Button onClick={handleCancel}>{cancelBtnCaption}</Button>
</DialogActions>
</>
)}
</Dialog>
);
};
//Контроль свойств - Редактор задачи
P8PGanttTaskEditor.propTypes = {
task: P8P_GANTT_TASK_SHAPE,
taskAttributes: PropTypes.arrayOf(P8P_GANTT_TASK_ATTRIBUTE_SHAPE),
taskColors: PropTypes.arrayOf(P8P_GANTT_TASK_COLOR_SHAPE),
onOk: PropTypes.func,
onCancel: PropTypes.func,
taskAttributeRenderer: PropTypes.func,
taskDialogRenderer: PropTypes.func,
numbCaption: PropTypes.string.isRequired,
nameCaption: PropTypes.string.isRequired,
startCaption: PropTypes.string.isRequired,
endCaption: PropTypes.string.isRequired,
progressCaption: PropTypes.string.isRequired,
legendCaption: PropTypes.string.isRequired,
okBtnCaption: PropTypes.string.isRequired,
cancelBtnCaption: PropTypes.string.isRequired
};
//-----------
//Тело модуля
//-----------
//Диаграмма Ганта
const P8PGantt = ({
containerStyle,
title,
titleStyle,
onTitleClick,
zoomBar,
readOnly,
readOnlyDates,
readOnlyProgress,
zoom,
tasks,
taskAttributes,
taskColors,
onTaskDatesChange,
onTaskProgressChange,
taskAttributeRenderer,
taskDialogRenderer,
noDataFoundText,
numbTaskEditorCaption,
nameTaskEditorCaption,
startTaskEditorCaption,
endTaskEditorCaption,
progressTaskEditorCaption,
legendTaskEditorCaption,
okTaskEditorBtnCaption,
cancelTaskEditorBtnCaption
}) => {
//Собственное состояние
const [state, setState] = useState({
noData: true,
gantt: null,
zoom: P8P_GANTT_ZOOM.includes(zoom) ? zoom : 3,
editTask: null
});
//Ссылки на DOM
const svgContainerRef = useRef(null);
//Отображение диаграммы
const showGantt = useCallback(() => {
if (!state.gantt) {
// eslint-disable-next-line no-undef
const gantt = new Gantt("#__gantt__", tasks, {
view_mode: P8P_GANTT_ZOOM_VIEW_MODES[state.zoom],
date_format: "YYYY-MM-DD",
language: "ru",
readOnly,
readOnlyDates,
readOnlyProgress,
on_date_change: (task, start, end, isMain) => (onTaskDatesChange ? onTaskDatesChange({ task, start, end, isMain }) : null),
on_progress_change: (task, progress) => (onTaskProgressChange ? onTaskProgressChange({ task, progress }) : null),
on_click: openTaskEditor
});
setState(pv => ({ ...pv, gantt, noData: false }));
} else {
state.gantt.refresh(tasks);
setState(pv => ({ ...pv, noData: false }));
}
}, [state.gantt, state.zoom, readOnly, readOnlyDates, readOnlyProgress, tasks, onTaskDatesChange, onTaskProgressChange]);
//Обновление масштаба диаграммы
const handleZoomChange = direction =>
setState(pv => ({
...pv,
zoom: pv.zoom + direction < 0 ? 0 : pv.zoom + direction >= P8P_GANTT_ZOOM.length ? P8P_GANTT_ZOOM.length - 1 : pv.zoom + direction
}));
//Открытие редактора задачи
const openTaskEditor = task => setState(pv => ({ ...pv, editTask: { ...task } }));
//При сохранении задачи в редакторе
const handleTaskEditorSave = ({ task, start, end, progress }) => {
setState(pv => ({ ...pv, editTask: null }));
if (onTaskDatesChange && (task.start != start || task.end != end)) onTaskDatesChange({ task, start, end, isMain: true });
if (onTaskProgressChange && task.progress != progress) onTaskProgressChange({ task, progress });
};
//При закрытии редактора задачи без сохранения
const handleTaskEditorCancel = () => setState(pv => ({ ...pv, editTask: null }));
//При изменении масштаба
useEffect(() => {
if (state.gantt) state.gantt.change_view_mode(P8P_GANTT_ZOOM_VIEW_MODES[state.zoom]);
}, [state.gantt, state.zoom]);
//При изменении списка задач
useEffect(() => {
if (Array.isArray(tasks) && tasks.length > 0) showGantt();
else setState(pv => ({ ...pv, noData: true }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tasks]);
//При подключении компонента к старице
useEffect(() => {
svgContainerRef.current.children[0].classList.add("scroll");
}, []);
//Генерация содержимого
return (
<div style={{ ...(containerStyle ? containerStyle : {}) }}>
{state.gantt && state.noData ? <P8PAppInlineError text={noDataFoundText} /> : null}
{state.gantt && !state.noData && title ? (
<Typography
p={1}
sx={{ ...STYLES.GANTT_TITLE, ...(titleStyle ? titleStyle : {}) }}
align="center"
color="textSecondary"
variant="subtitle1"
>
{onTitleClick ? (
<Link component="button" variant="body2" underline="hover" onClick={() => onTitleClick()}>
{title}
</Link>
) : (
title
)}
</Typography>
) : null}
{state.gantt && !state.noData && zoomBar ? (
<Box p={1} sx={STYLES.GANTT_ZOOM}>
<IconButton onClick={() => handleZoomChange(-1)} disabled={state.zoom == 0}>
<Icon>zoom_in</Icon>
</IconButton>
<IconButton onClick={() => handleZoomChange(1)} disabled={state.zoom == P8P_GANTT_ZOOM.length - 1}>
<Icon>zoom_out</Icon>
</IconButton>
</Box>
) : null}
{state.editTask ? (
<P8PGanttTaskEditor
task={state.editTask}
taskAttributes={taskAttributes}
taskColors={taskColors}
onOk={handleTaskEditorSave}
onCancel={handleTaskEditorCancel}
taskAttributeRenderer={taskAttributeRenderer}
taskDialogRenderer={taskDialogRenderer}
numbCaption={numbTaskEditorCaption}
nameCaption={nameTaskEditorCaption}
startCaption={startTaskEditorCaption}
endCaption={endTaskEditorCaption}
progressCaption={progressTaskEditorCaption}
legendCaption={legendTaskEditorCaption}
okBtnCaption={okTaskEditorBtnCaption}
cancelBtnCaption={cancelTaskEditorBtnCaption}
/>
) : null}
<div style={STYLES.GANTT(state.noData, title, zoomBar)} ref={svgContainerRef}>
<svg id="__gantt__" width="100%"></svg>
</div>
</div>
);
};
//Контроль свойств - Диаграмма Ганта
P8PGantt.propTypes = {
containerStyle: PropTypes.object,
title: PropTypes.string,
titleStyle: PropTypes.object,
onTitleClick: PropTypes.func,
zoomBar: PropTypes.bool,
readOnly: PropTypes.bool,
readOnlyDates: PropTypes.bool,
readOnlyProgress: PropTypes.bool,
zoom: PropTypes.number,
tasks: PropTypes.arrayOf(P8P_GANTT_TASK_SHAPE).isRequired,
taskAttributes: PropTypes.arrayOf(P8P_GANTT_TASK_ATTRIBUTE_SHAPE),
taskColors: PropTypes.arrayOf(P8P_GANTT_TASK_COLOR_SHAPE),
onTaskDatesChange: PropTypes.func,
onTaskProgressChange: PropTypes.func,
taskAttributeRenderer: PropTypes.func,
taskDialogRenderer: PropTypes.func,
noDataFoundText: PropTypes.string.isRequired,
numbTaskEditorCaption: PropTypes.string.isRequired,
nameTaskEditorCaption: PropTypes.string.isRequired,
startTaskEditorCaption: PropTypes.string.isRequired,
endTaskEditorCaption: PropTypes.string.isRequired,
progressTaskEditorCaption: PropTypes.string.isRequired,
legendTaskEditorCaption: PropTypes.string.isRequired,
okTaskEditorBtnCaption: PropTypes.string.isRequired,
cancelTaskEditorBtnCaption: PropTypes.string.isRequired
};
//----------------
//Интерфейс модуля
//----------------
export { P8P_GANTT_TASK_SHAPE, P8P_GANTT_TASK_ATTRIBUTE_SHAPE, P8P_GANTT_TASK_COLOR_SHAPE, taskLegendDesc, P8PGantt };