diff --git a/packages/core/client/src/block-provider/GanttBlockProvider.tsx b/packages/core/client/src/block-provider/GanttBlockProvider.tsx new file mode 100644 index 0000000000..660ced1f9b --- /dev/null +++ b/packages/core/client/src/block-provider/GanttBlockProvider.tsx @@ -0,0 +1,54 @@ +import { ArrayField } from '@formily/core'; +import { useField } from '@formily/react'; +import React, { createContext, useContext, useEffect } from 'react'; +import { BlockProvider, useBlockRequestContext } from './BlockProvider'; + +export const GanttBlockContext = createContext({}); + +const InternalGanttBlockProvider = (props) => { + const { fieldNames, timeRange } = props; + const field = useField(); + const { resource, service } = useBlockRequestContext(); + // if (service.loading) { + // return ; + // } + return ( + + {props.children} + + ); +}; + +export const GanttBlockProvider = (props) => { + return ( + + + + ); +}; + +export const useGanttBlockContext = () => { + return useContext(GanttBlockContext); +}; + +export const useGanttBlockProps = () => { + const ctx = useGanttBlockContext(); + const field = useField(); + useEffect(() => { + if (!ctx?.service?.loading) { + field.componentProps.dataSource = ctx?.service?.data?.data; + } + }, [ctx?.service?.loading]); + return { + fieldNames: ctx.fieldNames, + timeRange: ctx.timeRange, + }; +}; diff --git a/packages/core/client/src/block-provider/index.tsx b/packages/core/client/src/block-provider/index.tsx index 6d7e3aff1b..0925972ba2 100644 --- a/packages/core/client/src/block-provider/index.tsx +++ b/packages/core/client/src/block-provider/index.tsx @@ -8,4 +8,5 @@ export * from './TableBlockProvider'; export * from './TableFieldProvider'; export * from './TableSelectorProvider'; export * from './FormFieldProvider'; +export * from './GanttBlockProvider' diff --git a/packages/core/client/src/schema-component/antd/gantt/Gantt.Designer.tsx b/packages/core/client/src/schema-component/antd/gantt/Gantt.Designer.tsx new file mode 100644 index 0000000000..e59be64143 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/Gantt.Designer.tsx @@ -0,0 +1,162 @@ +import { ISchema, useField, useFieldSchema } from '@formily/react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useCompile, useDesignable } from '../..'; +import { useCalendarBlockContext } from '../../../block-provider'; +import { useCollection } from '../../../collection-manager'; +import { useCollectionFilterOptions } from '../../../collection-manager/action-hooks'; +import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; +import { useSchemaTemplate } from '../../../schema-templates'; + +const useOptions = (type = 'string') => { + const compile = useCompile(); + const { fields } = useCollection(); + const options = fields + ?.filter((field) => field.type === type) + ?.map((field) => { + return { + value: field.name, + label: compile(field?.uiSchema?.title), + }; + }); + return options; +}; + +export const GanttDesigner = () => { + const field = useField(); + const fieldSchema = useFieldSchema(); + const { name, title, fields } = useCollection(); + const dataSource = useCollectionFilterOptions(name); + const { service } = useCalendarBlockContext(); + const { dn } = useDesignable(); + const compile = useCompile(); + const { t } = useTranslation(); + const template = useSchemaTemplate(); + const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {}; + const fieldNames = fieldSchema?.['x-decorator-props']?.['fieldNames'] || {}; + const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + return ( + + + { + const fieldNames = field.decoratorProps.fieldNames || {}; + fieldNames['title'] = title; + field.decoratorProps.params = fieldNames; + fieldSchema['x-decorator-props']['params'] = fieldNames; + // Select切换option后value未按照预期切换,固增加以下代码 + fieldSchema['x-decorator-props']['fieldNames'] = fieldNames; + service.refresh(); + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': field.decoratorProps, + }, + }); + dn.refresh(); + }} + /> + { + field.decoratorProps.showLunar = v; + fieldSchema['x-decorator-props']['showLunar'] = v; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': field.decoratorProps, + }, + }); + dn.refresh(); + }} + /> + { + const fieldNames = field.decoratorProps.fieldNames || {}; + fieldNames['start'] = start; + field.decoratorProps.fieldNames = fieldNames; + fieldSchema['x-decorator-props']['fieldNames'] = fieldNames; + service.refresh(); + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': field.decoratorProps, + }, + }); + dn.refresh(); + }} + /> + { + const fieldNames = field.decoratorProps.fieldNames || {}; + fieldNames['end'] = end; + field.decoratorProps.fieldNames = fieldNames; + fieldSchema['x-decorator-props']['fieldNames'] = fieldNames; + service.refresh(); + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': field.decoratorProps, + }, + }); + dn.refresh(); + }} + /> + { + const params = field.decoratorProps.params || {}; + params.filter = filter; + field.decoratorProps.params = params; + fieldSchema['x-decorator-props']['params'] = params; + service.run({ ...service?.params?.[0], filter }); + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': field.decoratorProps, + }, + }); + }} + /> + + + + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/calendar/calendar.module.css b/packages/core/client/src/schema-component/antd/gantt/components/calendar/calendar.module.css new file mode 100644 index 0000000000..1be99d3f65 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/calendar/calendar.module.css @@ -0,0 +1,31 @@ +.calendarBottomText { + text-anchor: middle; + fill: #333; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; +} + +.calendarTopTick { + stroke: #e6e4e4; +} + +.calendarTopText { + text-anchor: middle; + fill: #555; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; +} + +.calendarHeader { + fill: #ffffff; + stroke: #e0e0e0; + stroke-width: 1.4; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/calendar/calendar.tsx b/packages/core/client/src/schema-component/antd/gantt/components/calendar/calendar.tsx new file mode 100644 index 0000000000..a5860db524 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/calendar/calendar.tsx @@ -0,0 +1,395 @@ +import React, { ReactChild } from "react"; +import { ViewMode } from "../../types/public-types"; +import { TopPartOfCalendar } from "./top-part-of-calendar"; +import { + getCachedDateTimeFormat, + getDaysInMonth, + getLocalDayOfWeek, + getLocaleMonth, + getWeekNumberISO8601, +} from "../../helpers/date-helper"; +import { DateSetup } from "../../types/date-setup"; +import styles from "./calendar.module.css"; + +export type CalendarProps = { + dateSetup: DateSetup; + locale: string; + viewMode: ViewMode; + rtl: boolean; + headerHeight: number; + columnWidth: number; + fontFamily: string; + fontSize: string; +}; + +export const Calendar: React.FC = ({ + dateSetup, + locale, + viewMode, + rtl, + headerHeight, + columnWidth, + fontFamily, + fontSize, +}) => { + const getCalendarValuesForYear = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const topDefaultHeight = headerHeight * 0.5; + for (let i = 0; i < dateSetup.dates.length; i++) { + const date = dateSetup.dates[i]; + const bottomValue = date.getFullYear(); + bottomValues.push( + + {bottomValue} + + ); + if ( + i === 0 || + date.getFullYear() !== dateSetup.dates[i - 1].getFullYear() + ) { + const topValue = date.getFullYear().toString(); + let xText: number; + if (rtl) { + xText = (6 + i + date.getFullYear() + 1) * columnWidth; + } else { + xText = (6 + i - date.getFullYear()) * columnWidth; + } + topValues.push( + + ); + } + } + return [topValues, bottomValues]; + }; + + const getCalendarValuesForQuarterYear = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const topDefaultHeight = headerHeight * 0.5; + for (let i = 0; i < dateSetup.dates.length; i++) { + const date = dateSetup.dates[i]; + // const bottomValue = getLocaleMonth(date, locale); + const quarter = "Q" + Math.floor((date.getMonth() + 3) / 3); + bottomValues.push( + + {quarter} + + ); + if ( + i === 0 || + date.getFullYear() !== dateSetup.dates[i - 1].getFullYear() + ) { + const topValue = date.getFullYear().toString(); + let xText: number; + if (rtl) { + xText = (6 + i + date.getMonth() + 1) * columnWidth; + } else { + xText = (6 + i - date.getMonth()) * columnWidth; + } + topValues.push( + + ); + } + } + return [topValues, bottomValues]; + }; + + const getCalendarValuesForMonth = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const topDefaultHeight = headerHeight * 0.5; + for (let i = 0; i < dateSetup.dates.length; i++) { + const date = dateSetup.dates[i]; + const bottomValue = getLocaleMonth(date, locale); + bottomValues.push( + + {bottomValue} + + ); + if ( + i === 0 || + date.getFullYear() !== dateSetup.dates[i - 1].getFullYear() + ) { + const topValue = date.getFullYear().toString(); + let xText: number; + if (rtl) { + xText = (6 + i + date.getMonth() + 1) * columnWidth; + } else { + xText = (6 + i - date.getMonth()) * columnWidth; + } + topValues.push( + + ); + } + } + return [topValues, bottomValues]; + }; + + const getCalendarValuesForWeek = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + let weeksCount: number = 1; + const topDefaultHeight = headerHeight * 0.5; + const dates = dateSetup.dates; + for (let i = dates.length - 1; i >= 0; i--) { + const date = dates[i]; + let topValue = ""; + if (i === 0 || date.getMonth() !== dates[i - 1].getMonth()) { + // top + topValue = `${getLocaleMonth(date, locale)}, ${date.getFullYear()}`; + } + // bottom + const bottomValue = `W${getWeekNumberISO8601(date)}`; + + bottomValues.push( + + {bottomValue} + + ); + + if (topValue) { + // if last day is new month + if (i !== dates.length - 1) { + topValues.push( + + ); + } + weeksCount = 0; + } + weeksCount++; + } + return [topValues, bottomValues]; + }; + + const getCalendarValuesForDay = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const topDefaultHeight = headerHeight * 0.5; + const dates = dateSetup.dates; + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + const bottomValue = `${getLocalDayOfWeek(date, locale, "short")}, ${date + .getDate() + .toString()}`; + + bottomValues.push( + + {bottomValue} + + ); + if ( + i + 1 !== dates.length && + date.getMonth() !== dates[i + 1].getMonth() + ) { + const topValue = getLocaleMonth(date, locale); + + topValues.push( + + ); + } + } + return [topValues, bottomValues]; + }; + + const getCalendarValuesForPartOfDay = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const ticks = viewMode === ViewMode.HalfDay ? 2 : 4; + const topDefaultHeight = headerHeight * 0.5; + const dates = dateSetup.dates; + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + const bottomValue = getCachedDateTimeFormat(locale, { + hour: "numeric", + }).format(date); + + bottomValues.push( + + {bottomValue} + + ); + if (i === 0 || date.getDate() !== dates[i - 1].getDate()) { + const topValue = `${getLocalDayOfWeek( + date, + locale, + "short" + )}, ${date.getDate()} ${getLocaleMonth(date, locale)}`; + topValues.push( + + ); + } + } + + return [topValues, bottomValues]; + }; + + const getCalendarValuesForHour = () => { + const topValues: ReactChild[] = []; + const bottomValues: ReactChild[] = []; + const topDefaultHeight = headerHeight * 0.5; + const dates = dateSetup.dates; + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + const bottomValue = getCachedDateTimeFormat(locale, { + hour: "numeric", + }).format(date); + + bottomValues.push( + + {bottomValue} + + ); + if (i !== 0 && date.getDate() !== dates[i - 1].getDate()) { + const displayDate = dates[i - 1]; + const topValue = `${getLocalDayOfWeek( + displayDate, + locale, + "long" + )}, ${displayDate.getDate()} ${getLocaleMonth(displayDate, locale)}`; + const topPosition = (date.getHours() - 24) / 2; + topValues.push( + + ); + } + } + + return [topValues, bottomValues]; + }; + + let topValues: ReactChild[] = []; + let bottomValues: ReactChild[] = []; + switch (dateSetup.viewMode) { + case ViewMode.Year: + [topValues, bottomValues] = getCalendarValuesForYear(); + break; + case ViewMode.QuarterYear: + [topValues, bottomValues] = getCalendarValuesForQuarterYear(); + break; + case ViewMode.Month: + [topValues, bottomValues] = getCalendarValuesForMonth(); + break; + case ViewMode.Week: + [topValues, bottomValues] = getCalendarValuesForWeek(); + break; + case ViewMode.Day: + [topValues, bottomValues] = getCalendarValuesForDay(); + break; + case ViewMode.QuarterDay: + case ViewMode.HalfDay: + [topValues, bottomValues] = getCalendarValuesForPartOfDay(); + break; + case ViewMode.Hour: + [topValues, bottomValues] = getCalendarValuesForHour(); + } + return ( + + + {bottomValues} {topValues} + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/calendar/top-part-of-calendar.tsx b/packages/core/client/src/schema-component/antd/gantt/components/calendar/top-part-of-calendar.tsx new file mode 100644 index 0000000000..d24f376a3d --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/calendar/top-part-of-calendar.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import styles from "./calendar.module.css"; + +type TopPartOfCalendarProps = { + value: string; + x1Line: number; + y1Line: number; + y2Line: number; + xText: number; + yText: number; +}; + +export const TopPartOfCalendar: React.FC = ({ + value, + x1Line, + y1Line, + y2Line, + xText, + yText, +}) => { + return ( + + + + {value} + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/gantt/gantt.module.css b/packages/core/client/src/schema-component/antd/gantt/components/gantt/gantt.module.css new file mode 100644 index 0000000000..8169a19cee --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/gantt/gantt.module.css @@ -0,0 +1,21 @@ +.ganttVerticalContainer { + overflow: hidden; + font-size: 0; + margin: 0; + padding: 0; +} + +.horizontalContainer { + margin: 0; + padding: 0; + overflow: hidden; +} + +.wrapper { + display: flex; + padding: 0; + margin: 0; + list-style: none; + outline: none; + position: relative; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/gantt/gantt.tsx b/packages/core/client/src/schema-component/antd/gantt/components/gantt/gantt.tsx new file mode 100644 index 0000000000..d36e95305e --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/gantt/gantt.tsx @@ -0,0 +1,603 @@ +import React, { + useState, + SyntheticEvent, + useRef, + useEffect, + useMemo, +} from "react"; +import { ViewMode, GanttProps, Task } from "../../types/public-types"; +import { GridProps } from "../grid/grid"; +import { ganttDateRange, seedDates } from "../../helpers/date-helper"; +import { CalendarProps } from "../calendar/calendar"; +import { TaskGanttContentProps } from "./task-gantt-content"; +import { TaskListHeaderDefault } from "../task-list/task-list-header"; +import { TaskListTableDefault } from "../task-list/task-list-table"; +import { StandardTooltipContent, Tooltip } from "../other/tooltip"; +import { VerticalScroll } from "../other/vertical-scroll"; +import { TaskListProps, TaskList } from "../task-list/task-list"; +import { TaskGantt } from "./task-gantt"; +import { BarTask } from "../../types/bar-task"; +import { convertToBarTasks } from "../../helpers/bar-helper"; +import { GanttEvent } from "../../types/gantt-task-actions"; +import { DateSetup } from "../../types/date-setup"; +import { HorizontalScroll } from "../other/horizontal-scroll"; +import { removeHiddenTasks, sortTasks } from "../../helpers/other-helper"; +import styles from "./gantt.module.css"; + + +export function initTasks() { + const currentDate = new Date(); + const tasks: Task[] = [ + { + start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 1), + end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 15), + name: "Some Project", + id: "ProjectSample", + progress: 25, + type: "project", + hideChildren: false, + displayOrder: 1, + }, + { + start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 1), + end: new Date( + currentDate.getFullYear(), + currentDate.getMonth(), + 2, + 12, + 28 + ), + name: "Idea", + id: "Task 0", + progress: 45, + type: "task", + project: "ProjectSample", + displayOrder: 2, + }, + { + start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 2), + end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 4, 0, 0), + name: "Research", + id: "Task 1", + progress: 25, + dependencies: ["Task 0"], + type: "task", + project: "ProjectSample", + displayOrder: 3, + }, + { + start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 4), + end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 8, 0, 0), + name: "Discussion with team", + id: "Task 2", + progress: 10, + dependencies: ["Task 1"], + type: "task", + project: "ProjectSample", + displayOrder: 4, + }, + { + start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 8), + end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 9, 0, 0), + name: "Developing", + id: "Task 3", + progress: 2, + dependencies: ["Task 2"], + type: "task", + project: "ProjectSample", + displayOrder: 5, + }, + { + start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 8), + end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 10), + name: "Review", + id: "Task 4", + type: "task", + progress: 70, + dependencies: ["Task 2"], + project: "ProjectSample", + displayOrder: 6, + }, + { + start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 15), + end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 15), + name: "Release", + id: "Task 6", + progress: currentDate.getMonth(), + type: "milestone", + dependencies: ["Task 4"], + project: "ProjectSample", + displayOrder: 7, + }, + { + start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 18), + end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 19), + name: "Party Time", + id: "Task 9", + progress: 0, + isDisabled: true, + type: "task", + }, + ]; + return tasks; +} +export const Gantt: any = ({ + tasks=initTasks(), + headerHeight = 50, + columnWidth = 60, + listCellWidth = "155px", + rowHeight = 50, + ganttHeight = 0, + viewMode = ViewMode.Day, + preStepsCount = 1, + locale = "en-GB", + barFill = 60, + barCornerRadius = 3, + barProgressColor = "#a3a3ff", + barProgressSelectedColor = "#8282f5", + barBackgroundColor = "#b8c2cc", + barBackgroundSelectedColor = "#aeb8c2", + projectProgressColor = "#7db59a", + projectProgressSelectedColor = "#59a985", + projectBackgroundColor = "#fac465", + projectBackgroundSelectedColor = "#f7bb53", + milestoneBackgroundColor = "#f1c453", + milestoneBackgroundSelectedColor = "#f29e4c", + rtl = false, + handleWidth = 8, + timeStep = 300000, + arrowColor = "grey", + fontFamily = "Arial, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue", + fontSize = "14px", + arrowIndent = 20, + todayColor = "rgba(252, 248, 227, 0.5)", + viewDate, + TooltipContent = StandardTooltipContent, + TaskListHeader = TaskListHeaderDefault, + TaskListTable = TaskListTableDefault, + onDateChange, + onProgressChange, + onDoubleClick, + onClick, + onDelete, + onSelect, + onExpanderClick, +}) => { + const wrapperRef = useRef(null); + const taskListRef = useRef(null); + const [dateSetup, setDateSetup] = useState(() => { + const [startDate, endDate] = ganttDateRange(tasks, viewMode, preStepsCount); + return { viewMode, dates: seedDates(startDate, endDate, viewMode) }; + }); + const [currentViewDate, setCurrentViewDate] = useState( + undefined + ); + + const [taskListWidth, setTaskListWidth] = useState(0); + const [svgContainerWidth, setSvgContainerWidth] = useState(0); + const [svgContainerHeight, setSvgContainerHeight] = useState(ganttHeight); + const [barTasks, setBarTasks] = useState([]); + const [ganttEvent, setGanttEvent] = useState({ + action: "", + }); + const taskHeight = useMemo( + () => (rowHeight * barFill) / 100, + [rowHeight, barFill] + ); + + const [selectedTask, setSelectedTask] = useState(); + const [failedTask, setFailedTask] = useState(null); + + const svgWidth = dateSetup.dates.length * columnWidth; + const ganttFullHeight = barTasks.length * rowHeight; + + const [scrollY, setScrollY] = useState(0); + const [scrollX, setScrollX] = useState(-1); + const [ignoreScrollEvent, setIgnoreScrollEvent] = useState(false); + + // task change events + useEffect(() => { + let filteredTasks: Task[]; + if (onExpanderClick) { + filteredTasks = removeHiddenTasks(tasks); + } else { + filteredTasks = tasks; + } + filteredTasks = filteredTasks.sort(sortTasks); + console.log(4) + const [startDate, endDate] = ganttDateRange( + filteredTasks, + viewMode, + preStepsCount + ); + let newDates = seedDates(startDate, endDate, viewMode); + if (rtl) { + newDates = newDates.reverse(); + if (scrollX === -1) { + setScrollX(newDates.length * columnWidth); + } + } + setDateSetup({ dates: newDates, viewMode }); + setBarTasks( + convertToBarTasks( + filteredTasks, + newDates, + columnWidth, + rowHeight, + taskHeight, + barCornerRadius, + handleWidth, + rtl, + barProgressColor, + barProgressSelectedColor, + barBackgroundColor, + barBackgroundSelectedColor, + projectProgressColor, + projectProgressSelectedColor, + projectBackgroundColor, + projectBackgroundSelectedColor, + milestoneBackgroundColor, + milestoneBackgroundSelectedColor + ) + ); + }, [ + // tasks, + viewMode, + preStepsCount, + rowHeight, + barCornerRadius, + columnWidth, + taskHeight, + handleWidth, + barProgressColor, + barProgressSelectedColor, + barBackgroundColor, + barBackgroundSelectedColor, + projectProgressColor, + projectProgressSelectedColor, + projectBackgroundColor, + projectBackgroundSelectedColor, + milestoneBackgroundColor, + milestoneBackgroundSelectedColor, + rtl, + scrollX, + onExpanderClick, + ]); + + useEffect(() => { + if ( + viewMode === dateSetup.viewMode && + ((viewDate && !currentViewDate) || + (viewDate && currentViewDate?.valueOf() !== viewDate.valueOf())) + ) { + const dates = dateSetup.dates; + const index = dates.findIndex( + (d, i) => + viewDate.valueOf() >= d.valueOf() && + i + 1 !== dates.length && + viewDate.valueOf() < dates[i + 1].valueOf() + ); + if (index === -1) { + return; + } + setCurrentViewDate(viewDate); + setScrollX(columnWidth * index); + } + }, [ + viewDate, + columnWidth, + dateSetup.dates, + dateSetup.viewMode, + viewMode, + currentViewDate, + setCurrentViewDate, + ]); + + useEffect(() => { + const { changedTask, action } = ganttEvent; + if (changedTask) { + if (action === "delete") { + setGanttEvent({ action: "" }); + setBarTasks(barTasks.filter(t => t.id !== changedTask.id)); + } else if ( + action === "move" || + action === "end" || + action === "start" || + action === "progress" + ) { + const prevStateTask = barTasks.find(t => t.id === changedTask.id); + if ( + prevStateTask && + (prevStateTask.start.getTime() !== changedTask.start.getTime() || + prevStateTask.end.getTime() !== changedTask.end.getTime() || + prevStateTask.progress !== changedTask.progress) + ) { + // actions for change + const newTaskList = barTasks.map(t => + t.id === changedTask.id ? changedTask : t + ); + setBarTasks(newTaskList); + } + } + } + }, [ganttEvent, barTasks]); + + useEffect(() => { + if (failedTask) { + setBarTasks(barTasks.map(t => (t.id !== failedTask.id ? t : failedTask))); + setFailedTask(null); + } + }, [failedTask, barTasks]); + + useEffect(() => { + if (!listCellWidth) { + setTaskListWidth(0); + } + if (taskListRef.current) { + setTaskListWidth(taskListRef.current.offsetWidth); + } + }, [taskListRef, listCellWidth]); + + useEffect(() => { + if (wrapperRef.current) { + setSvgContainerWidth(wrapperRef.current.offsetWidth - taskListWidth); + } + }, [wrapperRef, taskListWidth]); + + useEffect(() => { + if (ganttHeight) { + setSvgContainerHeight(ganttHeight + headerHeight); + } else { + setSvgContainerHeight(tasks.length * rowHeight + headerHeight); + } + }, [ganttHeight, tasks, headerHeight, rowHeight]); + + // scroll events + useEffect(() => { + const handleWheel = (event: WheelEvent) => { + if (event.shiftKey || event.deltaX) { + const scrollMove = event.deltaX ? event.deltaX : event.deltaY; + let newScrollX = scrollX + scrollMove; + if (newScrollX < 0) { + newScrollX = 0; + } else if (newScrollX > svgWidth) { + newScrollX = svgWidth; + } + setScrollX(newScrollX); + event.preventDefault(); + } else if (ganttHeight) { + let newScrollY = scrollY + event.deltaY; + if (newScrollY < 0) { + newScrollY = 0; + } else if (newScrollY > ganttFullHeight - ganttHeight) { + newScrollY = ganttFullHeight - ganttHeight; + } + if (newScrollY !== scrollY) { + setScrollY(newScrollY); + event.preventDefault(); + } + } + + setIgnoreScrollEvent(true); + }; + + // subscribe if scroll is necessary + wrapperRef.current?.addEventListener("wheel", handleWheel, { + passive: false, + }); + return () => { + wrapperRef.current?.removeEventListener("wheel", handleWheel); + }; + }, [ + wrapperRef, + scrollY, + scrollX, + ganttHeight, + svgWidth, + rtl, + ganttFullHeight, + ]); + + const handleScrollY = (event: SyntheticEvent) => { + if (scrollY !== event.currentTarget.scrollTop && !ignoreScrollEvent) { + setScrollY(event.currentTarget.scrollTop); + setIgnoreScrollEvent(true); + } else { + setIgnoreScrollEvent(false); + } + }; + + const handleScrollX = (event: SyntheticEvent) => { + if (scrollX !== event.currentTarget.scrollLeft && !ignoreScrollEvent) { + setScrollX(event.currentTarget.scrollLeft); + setIgnoreScrollEvent(true); + } else { + setIgnoreScrollEvent(false); + } + }; + + /** + * Handles arrow keys events and transform it to new scroll + */ + const handleKeyDown = (event: React.KeyboardEvent) => { + event.preventDefault(); + let newScrollY = scrollY; + let newScrollX = scrollX; + let isX = true; + switch (event.key) { + case "Down": // IE/Edge specific value + case "ArrowDown": + newScrollY += rowHeight; + isX = false; + break; + case "Up": // IE/Edge specific value + case "ArrowUp": + newScrollY -= rowHeight; + isX = false; + break; + case "Left": + case "ArrowLeft": + newScrollX -= columnWidth; + break; + case "Right": // IE/Edge specific value + case "ArrowRight": + newScrollX += columnWidth; + break; + } + if (isX) { + if (newScrollX < 0) { + newScrollX = 0; + } else if (newScrollX > svgWidth) { + newScrollX = svgWidth; + } + setScrollX(newScrollX); + } else { + if (newScrollY < 0) { + newScrollY = 0; + } else if (newScrollY > ganttFullHeight - ganttHeight) { + newScrollY = ganttFullHeight - ganttHeight; + } + setScrollY(newScrollY); + } + setIgnoreScrollEvent(true); + }; + + /** + * Task select event + */ + const handleSelectedTask = (taskId: string) => { + const newSelectedTask = barTasks.find(t => t.id === taskId); + const oldSelectedTask = barTasks.find( + t => !!selectedTask && t.id === selectedTask.id + ); + if (onSelect) { + if (oldSelectedTask) { + onSelect(oldSelectedTask, false); + } + if (newSelectedTask) { + onSelect(newSelectedTask, true); + } + } + setSelectedTask(newSelectedTask); + }; + const handleExpanderClick = (task: Task) => { + if (onExpanderClick && task.hideChildren !== undefined) { + onExpanderClick({ ...task, hideChildren: !task.hideChildren }); + } + }; + const gridProps: GridProps = { + columnWidth, + svgWidth, + tasks: tasks, + rowHeight, + dates: dateSetup.dates, + todayColor, + rtl, + }; + const calendarProps: CalendarProps = { + dateSetup, + locale, + viewMode, + headerHeight, + columnWidth, + fontFamily, + fontSize, + rtl, + }; + const barProps: TaskGanttContentProps = { + tasks: barTasks, + dates: dateSetup.dates, + ganttEvent, + selectedTask, + rowHeight, + taskHeight, + columnWidth, + arrowColor, + timeStep, + fontFamily, + fontSize, + arrowIndent, + svgWidth, + rtl, + setGanttEvent, + setFailedTask, + setSelectedTask: handleSelectedTask, + onDateChange, + onProgressChange, + onDoubleClick, + onClick, + onDelete, + }; + + const tableProps: TaskListProps = { + rowHeight, + rowWidth: listCellWidth, + fontFamily, + fontSize, + tasks: barTasks, + locale, + headerHeight, + scrollY, + ganttHeight, + horizontalContainerClass: styles.horizontalContainer, + selectedTask, + taskListRef, + setSelectedTask: handleSelectedTask, + onExpanderClick: handleExpanderClick, + TaskListHeader, + TaskListTable, + }; + return ( +
+
+ {listCellWidth && } + + {ganttEvent.changedTask && ( + + )} + +
+ +
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt-content.tsx b/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt-content.tsx new file mode 100644 index 0000000000..33326df926 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt-content.tsx @@ -0,0 +1,302 @@ +import React, { useEffect, useState } from "react"; +import { EventOption } from "../../types/public-types"; +import { BarTask } from "../../types/bar-task"; +import { Arrow } from "../other/arrow"; +import { handleTaskBySVGMouseEvent } from "../../helpers/bar-helper"; +import { isKeyboardEvent } from "../../helpers/other-helper"; +import { TaskItem } from "../task-item/task-item"; +import { + BarMoveAction, + GanttContentMoveAction, + GanttEvent, +} from "../../types/gantt-task-actions"; + +export type TaskGanttContentProps = { + tasks: BarTask[]; + dates: Date[]; + ganttEvent: GanttEvent; + selectedTask: BarTask | undefined; + rowHeight: number; + columnWidth: number; + timeStep: number; + svg?: React.RefObject; + svgWidth: number; + taskHeight: number; + arrowColor: string; + arrowIndent: number; + fontSize: string; + fontFamily: string; + rtl: boolean; + setGanttEvent: (value: GanttEvent) => void; + setFailedTask: (value: BarTask | null) => void; + setSelectedTask: (taskId: string) => void; +} & EventOption; + +export const TaskGanttContent: React.FC = ({ + tasks, + dates, + ganttEvent, + selectedTask, + rowHeight, + columnWidth, + timeStep, + svg, + taskHeight, + arrowColor, + arrowIndent, + fontFamily, + fontSize, + rtl, + setGanttEvent, + setFailedTask, + setSelectedTask, + onDateChange, + onProgressChange, + onDoubleClick, + onClick, + onDelete, +}) => { + const point = svg?.current?.createSVGPoint(); + const [xStep, setXStep] = useState(0); + const [initEventX1Delta, setInitEventX1Delta] = useState(0); + const [isMoving, setIsMoving] = useState(false); + + // create xStep + useEffect(() => { + const dateDelta = + dates[1].getTime() - + dates[0].getTime() - + dates[1].getTimezoneOffset() * 60 * 1000 + + dates[0].getTimezoneOffset() * 60 * 1000; + const newXStep = (timeStep * columnWidth) / dateDelta; + setXStep(newXStep); + }, [columnWidth, dates, timeStep]); + + useEffect(() => { + const handleMouseMove = async (event: MouseEvent) => { + if (!ganttEvent.changedTask || !point || !svg?.current) return; + event.preventDefault(); + + point.x = event.clientX; + const cursor = point.matrixTransform( + svg?.current.getScreenCTM()?.inverse() + ); + + const { isChanged, changedTask } = handleTaskBySVGMouseEvent( + cursor.x, + ganttEvent.action as BarMoveAction, + ganttEvent.changedTask, + xStep, + timeStep, + initEventX1Delta, + rtl + ); + if (isChanged) { + setGanttEvent({ action: ganttEvent.action, changedTask }); + } + }; + + const handleMouseUp = async (event: MouseEvent) => { + const { action, originalSelectedTask, changedTask } = ganttEvent; + if (!changedTask || !point || !svg?.current || !originalSelectedTask) + return; + event.preventDefault(); + + point.x = event.clientX; + const cursor = point.matrixTransform( + svg?.current.getScreenCTM()?.inverse() + ); + const { changedTask: newChangedTask } = handleTaskBySVGMouseEvent( + cursor.x, + action as BarMoveAction, + changedTask, + xStep, + timeStep, + initEventX1Delta, + rtl + ); + + const isNotLikeOriginal = + originalSelectedTask.start !== newChangedTask.start || + originalSelectedTask.end !== newChangedTask.end || + originalSelectedTask.progress !== newChangedTask.progress; + + // remove listeners + svg.current.removeEventListener("mousemove", handleMouseMove); + svg.current.removeEventListener("mouseup", handleMouseUp); + setGanttEvent({ action: "" }); + setIsMoving(false); + + // custom operation start + let operationSuccess = true; + if ( + (action === "move" || action === "end" || action === "start") && + onDateChange && + isNotLikeOriginal + ) { + try { + const result = await onDateChange( + newChangedTask, + newChangedTask.barChildren + ); + if (result !== undefined) { + operationSuccess = result; + } + } catch (error) { + operationSuccess = false; + } + } else if (onProgressChange && isNotLikeOriginal) { + try { + const result = await onProgressChange( + newChangedTask, + newChangedTask.barChildren + ); + if (result !== undefined) { + operationSuccess = result; + } + } catch (error) { + operationSuccess = false; + } + } + + // If operation is failed - return old state + if (!operationSuccess) { + setFailedTask(originalSelectedTask); + } + }; + + if ( + !isMoving && + (ganttEvent.action === "move" || + ganttEvent.action === "end" || + ganttEvent.action === "start" || + ganttEvent.action === "progress") && + svg?.current + ) { + svg.current.addEventListener("mousemove", handleMouseMove); + svg.current.addEventListener("mouseup", handleMouseUp); + setIsMoving(true); + } + }, [ + ganttEvent, + xStep, + initEventX1Delta, + onProgressChange, + timeStep, + onDateChange, + svg, + isMoving, + point, + rtl, + setFailedTask, + setGanttEvent, + ]); + + /** + * Method is Start point of task change + */ + const handleBarEventStart = async ( + action: GanttContentMoveAction, + task: BarTask, + event?: React.MouseEvent | React.KeyboardEvent + ) => { + if (!event) { + if (action === "select") { + setSelectedTask(task.id); + } + } + // Keyboard events + else if (isKeyboardEvent(event)) { + if (action === "delete") { + if (onDelete) { + try { + const result = await onDelete(task); + if (result !== undefined && result) { + setGanttEvent({ action, changedTask: task }); + } + } catch (error) { + console.error("Error on Delete. " + error); + } + } + } + } + // Mouse Events + else if (action === "mouseenter") { + if (!ganttEvent.action) { + setGanttEvent({ + action, + changedTask: task, + originalSelectedTask: task, + }); + } + } else if (action === "mouseleave") { + if (ganttEvent.action === "mouseenter") { + setGanttEvent({ action: "" }); + } + } else if (action === "dblclick") { + !!onDoubleClick && onDoubleClick(task); + } else if (action === "click") { + !!onClick && onClick(task); + } + // Change task event start + else if (action === "move") { + if (!svg?.current || !point) return; + point.x = event.clientX; + const cursor = point.matrixTransform( + svg.current.getScreenCTM()?.inverse() + ); + setInitEventX1Delta(cursor.x - task.x1); + setGanttEvent({ + action, + changedTask: task, + originalSelectedTask: task, + }); + } else { + setGanttEvent({ + action, + changedTask: task, + originalSelectedTask: task, + }); + } + }; + + return ( + + + {tasks.map(task => { + return task.barChildren.map(child => { + return ( + + ); + }); + })} + + + {tasks.map(task => { + return ( + + ); + })} + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt.tsx b/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt.tsx new file mode 100644 index 0000000000..73a76688f5 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/gantt/task-gantt.tsx @@ -0,0 +1,76 @@ +import React, { useRef, useEffect } from "react"; +import { GridProps, Grid } from "../grid/grid"; +import { CalendarProps, Calendar } from "../calendar/calendar"; +import { TaskGanttContentProps, TaskGanttContent } from "./task-gantt-content"; +import styles from "./gantt.module.css"; + +export type TaskGanttProps = { + gridProps: GridProps; + calendarProps: CalendarProps; + barProps: TaskGanttContentProps; + ganttHeight: number; + scrollY: number; + scrollX: number; +}; +export const TaskGantt: React.FC = ({ + gridProps, + calendarProps, + barProps, + ganttHeight, + scrollY, + scrollX, +}) => { + const ganttSVGRef = useRef(null); + const horizontalContainerRef = useRef(null); + const verticalGanttContainerRef = useRef(null); + const newBarProps = { ...barProps, svg: ganttSVGRef }; + + useEffect(() => { + if (horizontalContainerRef.current) { + horizontalContainerRef.current.scrollTop = scrollY; + } + }, [scrollY]); + + useEffect(() => { + if (verticalGanttContainerRef.current) { + verticalGanttContainerRef.current.scrollLeft = scrollX; + } + }, [scrollX]); + + return ( +
+ + + +
+ + + + +
+
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/grid/grid-body.tsx b/packages/core/client/src/schema-component/antd/gantt/components/grid/grid-body.tsx new file mode 100644 index 0000000000..18e6f2b00d --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/grid/grid-body.tsx @@ -0,0 +1,127 @@ +import React, { ReactChild } from "react"; +import { Task } from "../../types/public-types"; +import { addToDate } from "../../helpers/date-helper"; +import styles from "./grid.module.css"; + +export type GridBodyProps = { + tasks: Task[]; + dates: Date[]; + svgWidth: number; + rowHeight: number; + columnWidth: number; + todayColor: string; + rtl: boolean; +}; +export const GridBody: React.FC = ({ + tasks, + dates, + rowHeight, + svgWidth, + columnWidth, + todayColor, + rtl, +}) => { + let y = 0; + const gridRows: ReactChild[] = []; + const rowLines: ReactChild[] = [ + , + ]; + for (const task of tasks) { + gridRows.push( + + ); + rowLines.push( + + ); + y += rowHeight; + } + + const now = new Date(); + let tickX = 0; + const ticks: ReactChild[] = []; + let today: ReactChild = ; + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + ticks.push( + + ); + if ( + (i + 1 !== dates.length && + date.getTime() < now.getTime() && + dates[i + 1].getTime() >= now.getTime()) || + // if current date is last + (i !== 0 && + i + 1 === dates.length && + date.getTime() < now.getTime() && + addToDate( + date, + date.getTime() - dates[i - 1].getTime(), + "millisecond" + ).getTime() >= now.getTime()) + ) { + today = ( + + ); + } + // rtl for today + if ( + rtl && + i + 1 !== dates.length && + date.getTime() >= now.getTime() && + dates[i + 1].getTime() < now.getTime() + ) { + today = ( + + ); + } + tickX += columnWidth; + } + return ( + + {gridRows} + {rowLines} + {ticks} + {today} + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/grid/grid.module.css b/packages/core/client/src/schema-component/antd/gantt/components/grid/grid.module.css new file mode 100644 index 0000000000..964303f22b --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/grid/grid.module.css @@ -0,0 +1,15 @@ +.gridRow { + fill: #fff; +} + +.gridRow:nth-child(even) { + fill: #f5f5f5; +} + +.gridRowLine { + stroke: #ebeff2; +} + +.gridTick { + stroke: #e6e4e4; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/grid/grid.tsx b/packages/core/client/src/schema-component/antd/gantt/components/grid/grid.tsx new file mode 100644 index 0000000000..488cfa3fe0 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/grid/grid.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { GridBody, GridBodyProps } from "./grid-body"; + +export type GridProps = GridBodyProps; +export const Grid: React.FC = props => { + return ( + + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/arrow.tsx b/packages/core/client/src/schema-component/antd/gantt/components/other/arrow.tsx new file mode 100644 index 0000000000..52e8f28b47 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/arrow.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { BarTask } from "../../types/bar-task"; + +type ArrowProps = { + taskFrom: BarTask; + taskTo: BarTask; + rowHeight: number; + taskHeight: number; + arrowIndent: number; + rtl: boolean; +}; +export const Arrow: React.FC = ({ + taskFrom, + taskTo, + rowHeight, + taskHeight, + arrowIndent, + rtl, +}) => { + let path: string; + let trianglePoints: string; + if (rtl) { + [path, trianglePoints] = drownPathAndTriangleRTL( + taskFrom, + taskTo, + rowHeight, + taskHeight, + arrowIndent + ); + } else { + [path, trianglePoints] = drownPathAndTriangle( + taskFrom, + taskTo, + rowHeight, + taskHeight, + arrowIndent + ); + } + + return ( + + + + + ); +}; + +const drownPathAndTriangle = ( + taskFrom: BarTask, + taskTo: BarTask, + rowHeight: number, + taskHeight: number, + arrowIndent: number +) => { + const indexCompare = taskFrom.index > taskTo.index ? -1 : 1; + const taskToEndPosition = taskTo.y + taskHeight / 2; + const taskFromEndPosition = taskFrom.x2 + arrowIndent * 2; + const taskFromHorizontalOffsetValue = + taskFromEndPosition < taskTo.x1 ? "" : `H ${taskTo.x1 - arrowIndent}`; + const taskToHorizontalOffsetValue = + taskFromEndPosition > taskTo.x1 + ? arrowIndent + : taskTo.x1 - taskFrom.x2 - arrowIndent; + + const path = `M ${taskFrom.x2} ${taskFrom.y + taskHeight / 2} + h ${arrowIndent} + v ${(indexCompare * rowHeight) / 2} + ${taskFromHorizontalOffsetValue} + V ${taskToEndPosition} + h ${taskToHorizontalOffsetValue}`; + + const trianglePoints = `${taskTo.x1},${taskToEndPosition} + ${taskTo.x1 - 5},${taskToEndPosition - 5} + ${taskTo.x1 - 5},${taskToEndPosition + 5}`; + return [path, trianglePoints]; +}; + +const drownPathAndTriangleRTL = ( + taskFrom: BarTask, + taskTo: BarTask, + rowHeight: number, + taskHeight: number, + arrowIndent: number +) => { + const indexCompare = taskFrom.index > taskTo.index ? -1 : 1; + const taskToEndPosition = taskTo.y + taskHeight / 2; + const taskFromEndPosition = taskFrom.x1 - arrowIndent * 2; + const taskFromHorizontalOffsetValue = + taskFromEndPosition > taskTo.x2 ? "" : `H ${taskTo.x2 + arrowIndent}`; + const taskToHorizontalOffsetValue = + taskFromEndPosition < taskTo.x2 + ? -arrowIndent + : taskTo.x2 - taskFrom.x1 + arrowIndent; + + const path = `M ${taskFrom.x1} ${taskFrom.y + taskHeight / 2} + h ${-arrowIndent} + v ${(indexCompare * rowHeight) / 2} + ${taskFromHorizontalOffsetValue} + V ${taskToEndPosition} + h ${taskToHorizontalOffsetValue}`; + + const trianglePoints = `${taskTo.x2},${taskToEndPosition} + ${taskTo.x2 + 5},${taskToEndPosition + 5} + ${taskTo.x2 + 5},${taskToEndPosition - 5}`; + return [path, trianglePoints]; +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/horizontal-scroll.module.css b/packages/core/client/src/schema-component/antd/gantt/components/other/horizontal-scroll.module.css new file mode 100644 index 0000000000..dcf787e526 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/horizontal-scroll.module.css @@ -0,0 +1,33 @@ +.scrollWrapper { + overflow: auto; + max-width: 100%; + /*firefox*/ + scrollbar-width: thin; + /*iPad*/ + height: 1.2rem; +} +.scrollWrapper::-webkit-scrollbar { + width: 1.1rem; + height: 1.1rem; +} +.scrollWrapper::-webkit-scrollbar-corner { + background: transparent; +} +.scrollWrapper::-webkit-scrollbar-thumb { + border: 6px solid transparent; + background: rgba(0, 0, 0, 0.2); + background: var(--palette-black-alpha-20, rgba(0, 0, 0, 0.2)); + border-radius: 10px; + background-clip: padding-box; +} +.scrollWrapper::-webkit-scrollbar-thumb:hover { + border: 4px solid transparent; + background: rgba(0, 0, 0, 0.3); + background: var(--palette-black-alpha-30, rgba(0, 0, 0, 0.3)); + background-clip: padding-box; +} +@media only screen and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) { +} +.scroll { + height: 1px; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/horizontal-scroll.tsx b/packages/core/client/src/schema-component/antd/gantt/components/other/horizontal-scroll.tsx new file mode 100644 index 0000000000..5426460f38 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/horizontal-scroll.tsx @@ -0,0 +1,34 @@ +import React, { SyntheticEvent, useRef, useEffect } from "react"; +import styles from "./horizontal-scroll.module.css"; + +export const HorizontalScroll: React.FC<{ + scroll: number; + svgWidth: number; + taskListWidth: number; + rtl: boolean; + onScroll: (event: SyntheticEvent) => void; +}> = ({ scroll, svgWidth, taskListWidth, rtl, onScroll }) => { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollLeft = scroll; + } + }, [scroll]); + + return ( +
+
+
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/tooltip.module.css b/packages/core/client/src/schema-component/antd/gantt/components/other/tooltip.module.css new file mode 100644 index 0000000000..d5793ef14c --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/tooltip.module.css @@ -0,0 +1,30 @@ +.tooltipDefaultContainer { + background: #fff; + padding: 12px; + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); +} + +.tooltipDefaultContainerParagraph { + font-size: 12px; + margin-bottom: 6px; + color: #666; +} + +.tooltipDetailsContainer { + position: absolute; + display: flex; + flex-shrink: 0; + pointer-events: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.tooltipDetailsContainerHidden { + visibility: hidden; + position: absolute; + display: flex; + pointer-events: none; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/tooltip.tsx b/packages/core/client/src/schema-component/antd/gantt/components/other/tooltip.tsx new file mode 100644 index 0000000000..7542a14eb3 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/tooltip.tsx @@ -0,0 +1,145 @@ +import React, { useRef, useEffect, useState } from "react"; +import { Task } from "../../types/public-types"; +import { BarTask } from "../../types/bar-task"; +import styles from "./tooltip.module.css"; + +export type TooltipProps = { + task: BarTask; + arrowIndent: number; + rtl: boolean; + svgContainerHeight: number; + svgContainerWidth: number; + svgWidth: number; + headerHeight: number; + taskListWidth: number; + scrollX: number; + scrollY: number; + rowHeight: number; + fontSize: string; + fontFamily: string; + TooltipContent: React.FC<{ + task: Task; + fontSize: string; + fontFamily: string; + }>; +}; +export const Tooltip: React.FC = ({ + task, + rowHeight, + rtl, + svgContainerHeight, + svgContainerWidth, + scrollX, + scrollY, + arrowIndent, + fontSize, + fontFamily, + headerHeight, + taskListWidth, + TooltipContent, +}) => { + const tooltipRef = useRef(null); + const [relatedY, setRelatedY] = useState(0); + const [relatedX, setRelatedX] = useState(0); + useEffect(() => { + if (tooltipRef.current) { + const tooltipHeight = tooltipRef.current.offsetHeight * 1.1; + const tooltipWidth = tooltipRef.current.offsetWidth * 1.1; + + let newRelatedY = task.index * rowHeight - scrollY + headerHeight; + let newRelatedX: number; + if (rtl) { + newRelatedX = task.x1 - arrowIndent * 1.5 - tooltipWidth - scrollX; + if (newRelatedX < 0) { + newRelatedX = task.x2 + arrowIndent * 1.5 - scrollX; + } + const tooltipLeftmostPoint = tooltipWidth + newRelatedX; + if (tooltipLeftmostPoint > svgContainerWidth) { + newRelatedX = svgContainerWidth - tooltipWidth; + newRelatedY += rowHeight; + } + } else { + newRelatedX = task.x2 + arrowIndent * 1.5 + taskListWidth - scrollX; + const tooltipLeftmostPoint = tooltipWidth + newRelatedX; + const fullChartWidth = taskListWidth + svgContainerWidth; + if (tooltipLeftmostPoint > fullChartWidth) { + newRelatedX = + task.x1 + + taskListWidth - + arrowIndent * 1.5 - + scrollX - + tooltipWidth; + } + if (newRelatedX < taskListWidth) { + newRelatedX = svgContainerWidth + taskListWidth - tooltipWidth; + newRelatedY += rowHeight; + } + } + + const tooltipLowerPoint = tooltipHeight + newRelatedY - scrollY; + if (tooltipLowerPoint > svgContainerHeight - scrollY) { + newRelatedY = svgContainerHeight - tooltipHeight; + } + setRelatedY(newRelatedY); + setRelatedX(newRelatedX); + } + }, [ + tooltipRef, + task, + arrowIndent, + scrollX, + scrollY, + headerHeight, + taskListWidth, + rowHeight, + svgContainerHeight, + svgContainerWidth, + rtl, + ]); + + return ( +
+ +
+ ); +}; + +export const StandardTooltipContent: React.FC<{ + task: Task; + fontSize: string; + fontFamily: string; +}> = ({ task, fontSize, fontFamily }) => { + const style = { + fontSize, + fontFamily, + }; + return ( +
+ {`${ + task.name + }: ${task.start.getDate()}-${ + task.start.getMonth() + 1 + }-${task.start.getFullYear()} - ${task.end.getDate()}-${ + task.end.getMonth() + 1 + }-${task.end.getFullYear()}`} + {task.end.getTime() - task.start.getTime() !== 0 && ( +

{`Duration: ${~~( + (task.end.getTime() - task.start.getTime()) / + (1000 * 60 * 60 * 24) + )} day(s)`}

+ )} + +

+ {!!task.progress && `Progress: ${task.progress} %`} +

+
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/vertical-scroll.module.css b/packages/core/client/src/schema-component/antd/gantt/components/other/vertical-scroll.module.css new file mode 100644 index 0000000000..da55a2e2e6 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/vertical-scroll.module.css @@ -0,0 +1,27 @@ +.scroll { + overflow: hidden auto; + width: 1rem; + flex-shrink: 0; + /*firefox*/ + scrollbar-width: thin; +} +.scroll::-webkit-scrollbar { + width: 1.1rem; + height: 1.1rem; +} +.scroll::-webkit-scrollbar-corner { + background: transparent; +} +.scroll::-webkit-scrollbar-thumb { + border: 6px solid transparent; + background: rgba(0, 0, 0, 0.2); + background: var(--palette-black-alpha-20, rgba(0, 0, 0, 0.2)); + border-radius: 10px; + background-clip: padding-box; +} +.scroll::-webkit-scrollbar-thumb:hover { + border: 4px solid transparent; + background: rgba(0, 0, 0, 0.3); + background: var(--palette-black-alpha-30, rgba(0, 0, 0, 0.3)); + background-clip: padding-box; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/other/vertical-scroll.tsx b/packages/core/client/src/schema-component/antd/gantt/components/other/vertical-scroll.tsx new file mode 100644 index 0000000000..d01d46ec95 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/other/vertical-scroll.tsx @@ -0,0 +1,41 @@ +import React, { SyntheticEvent, useRef, useEffect } from "react"; +import styles from "./vertical-scroll.module.css"; + +export const VerticalScroll: React.FC<{ + scroll: number; + ganttHeight: number; + ganttFullHeight: number; + headerHeight: number; + rtl: boolean; + onScroll: (event: SyntheticEvent) => void; +}> = ({ + scroll, + ganttHeight, + ganttFullHeight, + headerHeight, + rtl, + onScroll, +}) => { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scroll; + } + }, [scroll]); + + return ( +
+
+
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-date-handle.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-date-handle.tsx new file mode 100644 index 0000000000..3794239578 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-date-handle.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import styles from "./bar.module.css"; + +type BarDateHandleProps = { + x: number; + y: number; + width: number; + height: number; + barCornerRadius: number; + onMouseDown: (event: React.MouseEvent) => void; +}; +export const BarDateHandle: React.FC = ({ + x, + y, + width, + height, + barCornerRadius, + onMouseDown, +}) => { + return ( + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-display.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-display.tsx new file mode 100644 index 0000000000..174e1ed177 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-display.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import style from "./bar.module.css"; + +type BarDisplayProps = { + x: number; + y: number; + width: number; + height: number; + isSelected: boolean; + /* progress start point */ + progressX: number; + progressWidth: number; + barCornerRadius: number; + styles: { + backgroundColor: string; + backgroundSelectedColor: string; + progressColor: string; + progressSelectedColor: string; + }; + onMouseDown: (event: React.MouseEvent) => void; +}; +export const BarDisplay: React.FC = ({ + x, + y, + width, + height, + isSelected, + progressX, + progressWidth, + barCornerRadius, + styles, + onMouseDown, +}) => { + const getProcessColor = () => { + return isSelected ? styles.progressSelectedColor : styles.progressColor; + }; + + const getBarColor = () => { + return isSelected ? styles.backgroundSelectedColor : styles.backgroundColor; + }; + + return ( + + + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-progress-handle.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-progress-handle.tsx new file mode 100644 index 0000000000..75168b4662 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-progress-handle.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import styles from "./bar.module.css"; + +type BarProgressHandleProps = { + progressPoint: string; + onMouseDown: (event: React.MouseEvent) => void; +}; +export const BarProgressHandle: React.FC = ({ + progressPoint, + onMouseDown, +}) => { + return ( + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-small.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-small.tsx new file mode 100644 index 0000000000..56f43432fe --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar-small.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { getProgressPoint } from "../../../helpers/bar-helper"; +import { BarDisplay } from "./bar-display"; +import { BarProgressHandle } from "./bar-progress-handle"; +import { TaskItemProps } from "../task-item"; +import styles from "./bar.module.css"; + +export const BarSmall: React.FC = ({ + task, + isProgressChangeable, + isDateChangeable, + onEventStart, + isSelected, +}) => { + const progressPoint = getProgressPoint( + task.progressWidth + task.x1, + task.y, + task.height + ); + return ( + + { + isDateChangeable && onEventStart("move", task, e); + }} + /> + + {isProgressChangeable && ( + { + onEventStart("progress", task, e); + }} + /> + )} + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar.module.css b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar.module.css new file mode 100644 index 0000000000..7ff4926f84 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar.module.css @@ -0,0 +1,21 @@ +.barWrapper { + cursor: pointer; + outline: none; +} + +.barWrapper:hover .barHandle { + visibility: visible; + opacity: 1; +} + +.barHandle { + fill: #ddd; + cursor: ew-resize; + opacity: 0; + visibility: hidden; +} + +.barBackground { + user-select: none; + stroke-width: 0; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar.tsx new file mode 100644 index 0000000000..7e6ce5b960 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/bar/bar.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { getProgressPoint } from "../../../helpers/bar-helper"; +import { BarDisplay } from "./bar-display"; +import { BarDateHandle } from "./bar-date-handle"; +import { BarProgressHandle } from "./bar-progress-handle"; +import { TaskItemProps } from "../task-item"; +import styles from "./bar.module.css"; + +export const Bar: React.FC = ({ + task, + isProgressChangeable, + isDateChangeable, + rtl, + onEventStart, + isSelected, +}) => { + const progressPoint = getProgressPoint( + +!rtl * task.progressWidth + task.progressX, + task.y, + task.height + ); + const handleHeight = task.height - 2; + return ( + + { + isDateChangeable && onEventStart("move", task, e); + }} + /> + + {isDateChangeable && ( + + {/* left */} + { + onEventStart("start", task, e); + }} + /> + {/* right */} + { + onEventStart("end", task, e); + }} + /> + + )} + {isProgressChangeable && ( + { + onEventStart("progress", task, e); + }} + /> + )} + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/milestone.module.css b/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/milestone.module.css new file mode 100644 index 0000000000..f8766ba4d3 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/milestone.module.css @@ -0,0 +1,8 @@ +.milestoneWrapper { + cursor: pointer; + outline: none; +} + +.milestoneBackground { + user-select: none; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/milestone.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/milestone.tsx new file mode 100644 index 0000000000..a8e3922cef --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/milestone/milestone.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { TaskItemProps } from "../task-item"; +import styles from "./milestone.module.css"; + +export const Milestone: React.FC = ({ + task, + isDateChangeable, + onEventStart, + isSelected, +}) => { + const transform = `rotate(45 ${task.x1 + task.height * 0.356} + ${task.y + task.height * 0.85})`; + const getBarColor = () => { + return isSelected + ? task.styles.backgroundSelectedColor + : task.styles.backgroundColor; + }; + + return ( + + { + isDateChangeable && onEventStart("move", task, e); + }} + /> + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/project.module.css b/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/project.module.css new file mode 100644 index 0000000000..4fa67c2fab --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/project.module.css @@ -0,0 +1,13 @@ +.projectWrapper { + cursor: pointer; + outline: none; +} + +.projectBackground { + user-select: none; + opacity: 0.6; +} + +.projectTop { + user-select: none; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/project.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/project.tsx new file mode 100644 index 0000000000..5a47ba90bd --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/project/project.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { TaskItemProps } from "../task-item"; +import styles from "./project.module.css"; + +export const Project: React.FC = ({ task, isSelected }) => { + const barColor = isSelected + ? task.styles.backgroundSelectedColor + : task.styles.backgroundColor; + const processColor = isSelected + ? task.styles.progressSelectedColor + : task.styles.progressColor; + const projectWith = task.x2 - task.x1; + + const projectLeftTriangle = [ + task.x1, + task.y + task.height / 2 - 1, + task.x1, + task.y + task.height, + task.x1 + 15, + task.y + task.height / 2 - 1, + ].join(","); + const projectRightTriangle = [ + task.x2, + task.y + task.height / 2 - 1, + task.x2, + task.y + task.height, + task.x2 - 15, + task.y + task.height / 2 - 1, + ].join(","); + + return ( + + + + + + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/task-item.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-item/task-item.tsx new file mode 100644 index 0000000000..cf06284986 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/task-item.tsx @@ -0,0 +1,125 @@ +import React, { useEffect, useRef, useState } from "react"; +import { BarTask } from "../../types/bar-task"; +import { GanttContentMoveAction } from "../../types/gantt-task-actions"; +import { Bar } from "./bar/bar"; +import { BarSmall } from "./bar/bar-small"; +import { Milestone } from "./milestone/milestone"; +import { Project } from "./project/project"; +import style from "./task-list.module.css"; + +export type TaskItemProps = { + task: BarTask; + arrowIndent: number; + taskHeight: number; + isProgressChangeable: boolean; + isDateChangeable: boolean; + isDelete: boolean; + isSelected: boolean; + rtl: boolean; + onEventStart: ( + action: GanttContentMoveAction, + selectedTask: BarTask, + event?: React.MouseEvent | React.KeyboardEvent + ) => any; +}; + +export const TaskItem: React.FC = props => { + const { + task, + arrowIndent, + isDelete, + taskHeight, + isSelected, + rtl, + onEventStart, + } = { + ...props, + }; + const textRef = useRef(null); + const [taskItem, setTaskItem] = useState(
); + const [isTextInside, setIsTextInside] = useState(true); + + useEffect(() => { + switch (task.typeInternal) { + case "milestone": + setTaskItem(); + break; + case "project": + setTaskItem(); + break; + case "smalltask": + setTaskItem(); + break; + default: + setTaskItem(); + break; + } + }, [task, isSelected]); + + useEffect(() => { + if (textRef.current) { + setIsTextInside(textRef.current.getBBox().width < task.x2 - task.x1); + } + }, [textRef, task]); + + const getX = () => { + const width = task.x2 - task.x1; + const hasChild = task.barChildren.length > 0; + if (isTextInside) { + return task.x1 + width * 0.5; + } + if (rtl && textRef.current) { + return ( + task.x1 - + textRef.current.getBBox().width - + arrowIndent * +hasChild - + arrowIndent * 0.2 + ); + } else { + return task.x1 + width + arrowIndent * +hasChild + arrowIndent * 0.2; + } + }; + + return ( + { + switch (e.key) { + case "Delete": { + if (isDelete) onEventStart("delete", task, e); + break; + } + } + e.stopPropagation(); + }} + onMouseEnter={e => { + onEventStart("mouseenter", task, e); + }} + onMouseLeave={e => { + onEventStart("mouseleave", task, e); + }} + onDoubleClick={e => { + onEventStart("dblclick", task, e); + }} + onClick={e => { + onEventStart("click", task, e); + }} + onFocus={() => { + onEventStart("select", task); + }} + > + {taskItem} + + {task.name} + + + ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-item/task-list.module.css b/packages/core/client/src/schema-component/antd/gantt/components/task-item/task-list.module.css new file mode 100644 index 0000000000..2eec4204a9 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-item/task-list.module.css @@ -0,0 +1,23 @@ +.barLabel { + fill: #fff; + text-anchor: middle; + font-weight: lighter; + dominant-baseline: central; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; +} + +.barLabelOutside { + fill: #555; + text-anchor: start; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-header.module.css b/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-header.module.css new file mode 100644 index 0000000000..c250354011 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-header.module.css @@ -0,0 +1,23 @@ +.ganttTable { + display: table; + border-bottom: #e6e4e4 1px solid; + border-top: #e6e4e4 1px solid; + border-left: #e6e4e4 1px solid; +} + +.ganttTable_Header { + display: table-row; + list-style: none; +} + +.ganttTable_HeaderSeparator { + border-right: 1px solid rgb(196, 196, 196); + opacity: 1; + margin-left: -2px; +} + +.ganttTable_HeaderItem { + display: table-cell; + vertical-align: -webkit-baseline-middle; + vertical-align: middle; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-header.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-header.tsx new file mode 100644 index 0000000000..4e8cdb66bd --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-header.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import styles from "./task-list-header.module.css"; + +export const TaskListHeaderDefault: React.FC<{ + headerHeight: number; + rowWidth: string; + fontFamily: string; + fontSize: string; +}> = ({ headerHeight, fontFamily, fontSize, rowWidth }) => { + return ( +
+
+
+  Name +
+
+
+  From +
+
+
+  To +
+
+
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-table.module.css b/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-table.module.css new file mode 100644 index 0000000000..7f57268e89 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-table.module.css @@ -0,0 +1,38 @@ +.taskListWrapper { + display: table; + border-bottom: #e6e4e4 1px solid; + border-left: #e6e4e4 1px solid; +} + +.taskListTableRow { + display: table-row; + text-overflow: ellipsis; +} + +.taskListTableRow:nth-of-type(even) { + background-color: #f5f5f5; +} + +.taskListCell { + display: table-cell; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.taskListNameWrapper { + display: flex; +} + +.taskListExpander { + color: rgb(86 86 86); + font-size: 0.6rem; + padding: 0.15rem 0.2rem 0rem 0.2rem; + user-select: none; + cursor: pointer; +} +.taskListEmptyExpander { + font-size: 0.6rem; + padding-left: 1rem; + user-select: none; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-table.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-table.tsx new file mode 100644 index 0000000000..b165f6002c --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list-table.tsx @@ -0,0 +1,115 @@ +import React, { useMemo } from "react"; +import styles from "./task-list-table.module.css"; +import { Task } from "../../types/public-types"; + +const localeDateStringCache = {}; +const toLocaleDateStringFactory = + (locale: string) => + (date: Date, dateTimeOptions: Intl.DateTimeFormatOptions) => { + const key = date.toString(); + let lds = localeDateStringCache[key]; + if (!lds) { + lds = date.toLocaleDateString(locale, dateTimeOptions); + localeDateStringCache[key] = lds; + } + return lds; + }; +const dateTimeOptions: Intl.DateTimeFormatOptions = { + weekday: "short", + year: "numeric", + month: "long", + day: "numeric", +}; + +export const TaskListTableDefault: React.FC<{ + rowHeight: number; + rowWidth: string; + fontFamily: string; + fontSize: string; + locale: string; + tasks: Task[]; + selectedTaskId: string; + setSelectedTask: (taskId: string) => void; + onExpanderClick: (task: Task) => void; +}> = ({ + rowHeight, + rowWidth, + tasks, + fontFamily, + fontSize, + locale, + onExpanderClick, +}) => { + const toLocaleDateString = useMemo( + () => toLocaleDateStringFactory(locale), + [locale] + ); + + return ( +
+ {tasks.map(t => { + let expanderSymbol = ""; + if (t.hideChildren === false) { + expanderSymbol = "▼"; + } else if (t.hideChildren === true) { + expanderSymbol = "▶"; + } + + return ( +
+
+
+
onExpanderClick(t)} + > + {expanderSymbol} +
+
{t.name}
+
+
+
+  {toLocaleDateString(t.start, dateTimeOptions)} +
+
+  {toLocaleDateString(t.end, dateTimeOptions)} +
+
+ ); + })} +
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list.tsx b/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list.tsx new file mode 100644 index 0000000000..d86b16b822 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/components/task-list/task-list.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useRef } from 'react'; +import { BarTask } from '../../types/bar-task'; +import { Task } from '../../types/public-types'; + +export type TaskListProps = { + headerHeight: number; + rowWidth: string; + fontFamily: string; + fontSize: string; + rowHeight: number; + ganttHeight: number; + scrollY: number; + locale: string; + tasks: Task[]; + taskListRef: React.RefObject; + horizontalContainerClass?: string; + selectedTask: BarTask | undefined; + setSelectedTask: (task: string) => void; + onExpanderClick: (task: Task) => void; + TaskListHeader: React.FC<{ + headerHeight: number; + rowWidth: string; + fontFamily: string; + fontSize: string; + }>; + TaskListTable: React.FC<{ + rowHeight: number; + rowWidth: string; + fontFamily: string; + fontSize: string; + locale: string; + tasks: Task[]; + selectedTaskId: string; + setSelectedTask: (taskId: string) => void; + onExpanderClick: (task: Task) => void; + }>; +}; + +export const TaskList: React.FC = ({ + headerHeight, + fontFamily, + fontSize, + rowWidth, + rowHeight, + scrollY, + tasks, + selectedTask, + setSelectedTask, + onExpanderClick, + locale, + ganttHeight, + taskListRef, + horizontalContainerClass, + TaskListHeader, + TaskListTable, +}) => { + const horizontalContainerRef = useRef(null); + useEffect(() => { + if (horizontalContainerRef.current) { + horizontalContainerRef.current.scrollTop = scrollY; + } + }, [scrollY]); + + const headerProps = { + headerHeight, + fontFamily, + fontSize, + rowWidth, + }; + const selectedTaskId = selectedTask ? selectedTask.id : ''; + const tableProps = { + rowHeight, + rowWidth, + fontFamily, + fontSize, + tasks, + locale, + selectedTaskId: selectedTaskId, + setSelectedTask, + onExpanderClick, + }; + + return ( +
+ +
+ +
+
+ ); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/helpers/bar-helper.ts b/packages/core/client/src/schema-component/antd/gantt/helpers/bar-helper.ts new file mode 100644 index 0000000000..ba5f987c9a --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/helpers/bar-helper.ts @@ -0,0 +1,589 @@ +import { Task } from "../types/public-types"; +import { BarTask, TaskTypeInternal } from "../types/bar-task"; +import { BarMoveAction } from "../types/gantt-task-actions"; + +export const convertToBarTasks = ( + tasks: Task[], + dates: Date[], + columnWidth: number, + rowHeight: number, + taskHeight: number, + barCornerRadius: number, + handleWidth: number, + rtl: boolean, + barProgressColor: string, + barProgressSelectedColor: string, + barBackgroundColor: string, + barBackgroundSelectedColor: string, + projectProgressColor: string, + projectProgressSelectedColor: string, + projectBackgroundColor: string, + projectBackgroundSelectedColor: string, + milestoneBackgroundColor: string, + milestoneBackgroundSelectedColor: string +) => { + let barTasks = tasks.map((t, i) => { + return convertToBarTask( + t, + i, + dates, + columnWidth, + rowHeight, + taskHeight, + barCornerRadius, + handleWidth, + rtl, + barProgressColor, + barProgressSelectedColor, + barBackgroundColor, + barBackgroundSelectedColor, + projectProgressColor, + projectProgressSelectedColor, + projectBackgroundColor, + projectBackgroundSelectedColor, + milestoneBackgroundColor, + milestoneBackgroundSelectedColor + ); + }); + + // set dependencies + barTasks = barTasks.map(task => { + const dependencies = task.dependencies || []; + for (let j = 0; j < dependencies.length; j++) { + const dependence = barTasks.findIndex( + value => value.id === dependencies[j] + ); + if (dependence !== -1) barTasks[dependence].barChildren.push(task); + } + return task; + }); + + return barTasks; +}; + +const convertToBarTask = ( + task: Task, + index: number, + dates: Date[], + columnWidth: number, + rowHeight: number, + taskHeight: number, + barCornerRadius: number, + handleWidth: number, + rtl: boolean, + barProgressColor: string, + barProgressSelectedColor: string, + barBackgroundColor: string, + barBackgroundSelectedColor: string, + projectProgressColor: string, + projectProgressSelectedColor: string, + projectBackgroundColor: string, + projectBackgroundSelectedColor: string, + milestoneBackgroundColor: string, + milestoneBackgroundSelectedColor: string +): BarTask => { + let barTask: BarTask; + switch (task.type) { + case "milestone": + barTask = convertToMilestone( + task, + index, + dates, + columnWidth, + rowHeight, + taskHeight, + barCornerRadius, + handleWidth, + milestoneBackgroundColor, + milestoneBackgroundSelectedColor + ); + break; + case "project": + barTask = convertToBar( + task, + index, + dates, + columnWidth, + rowHeight, + taskHeight, + barCornerRadius, + handleWidth, + rtl, + projectProgressColor, + projectProgressSelectedColor, + projectBackgroundColor, + projectBackgroundSelectedColor + ); + break; + default: + barTask = convertToBar( + task, + index, + dates, + columnWidth, + rowHeight, + taskHeight, + barCornerRadius, + handleWidth, + rtl, + barProgressColor, + barProgressSelectedColor, + barBackgroundColor, + barBackgroundSelectedColor + ); + break; + } + return barTask; +}; + +const convertToBar = ( + task: Task, + index: number, + dates: Date[], + columnWidth: number, + rowHeight: number, + taskHeight: number, + barCornerRadius: number, + handleWidth: number, + rtl: boolean, + barProgressColor: string, + barProgressSelectedColor: string, + barBackgroundColor: string, + barBackgroundSelectedColor: string +): BarTask => { + let x1: number; + let x2: number; + if (rtl) { + x2 = taskXCoordinateRTL(task.start, dates, columnWidth); + x1 = taskXCoordinateRTL(task.end, dates, columnWidth); + } else { + x1 = taskXCoordinate(task.start, dates, columnWidth); + x2 = taskXCoordinate(task.end, dates, columnWidth); + } + let typeInternal: TaskTypeInternal = task.type; + if (typeInternal === "task" && x2 - x1 < handleWidth * 2) { + typeInternal = "smalltask"; + x2 = x1 + handleWidth * 2; + } + + const [progressWidth, progressX] = progressWithByParams( + x1, + x2, + task.progress, + rtl + ); + const y = taskYCoordinate(index, rowHeight, taskHeight); + const hideChildren = task.type === "project" ? task.hideChildren : undefined; + + const styles = { + backgroundColor: barBackgroundColor, + backgroundSelectedColor: barBackgroundSelectedColor, + progressColor: barProgressColor, + progressSelectedColor: barProgressSelectedColor, + ...task.styles, + }; + return { + ...task, + typeInternal, + x1, + x2, + y, + index, + progressX, + progressWidth, + barCornerRadius, + handleWidth, + hideChildren, + height: taskHeight, + barChildren: [], + styles, + }; +}; + +const convertToMilestone = ( + task: Task, + index: number, + dates: Date[], + columnWidth: number, + rowHeight: number, + taskHeight: number, + barCornerRadius: number, + handleWidth: number, + milestoneBackgroundColor: string, + milestoneBackgroundSelectedColor: string +): BarTask => { + const x = taskXCoordinate(task.start, dates, columnWidth); + const y = taskYCoordinate(index, rowHeight, taskHeight); + + const x1 = x - taskHeight * 0.5; + const x2 = x + taskHeight * 0.5; + + const rotatedHeight = taskHeight / 1.414; + const styles = { + backgroundColor: milestoneBackgroundColor, + backgroundSelectedColor: milestoneBackgroundSelectedColor, + progressColor: "", + progressSelectedColor: "", + ...task.styles, + }; + return { + ...task, + end: task.start, + x1, + x2, + y, + index, + progressX: 0, + progressWidth: 0, + barCornerRadius, + handleWidth, + typeInternal: task.type, + progress: 0, + height: rotatedHeight, + hideChildren: undefined, + barChildren: [], + styles, + }; +}; + +const taskXCoordinate = (xDate: Date, dates: Date[], columnWidth: number) => { + const index = dates.findIndex(d => d.getTime() >= xDate.getTime()) - 1; + + const remainderMillis = xDate.getTime() - dates[index].getTime(); + const percentOfInterval = + remainderMillis / (dates[index + 1].getTime() - dates[index].getTime()); + const x = index * columnWidth + percentOfInterval * columnWidth; + return x; +}; +const taskXCoordinateRTL = ( + xDate: Date, + dates: Date[], + columnWidth: number +) => { + let x = taskXCoordinate(xDate, dates, columnWidth); + x += columnWidth; + return x; +}; +const taskYCoordinate = ( + index: number, + rowHeight: number, + taskHeight: number +) => { + const y = index * rowHeight + (rowHeight - taskHeight) / 2; + return y; +}; + +export const progressWithByParams = ( + taskX1: number, + taskX2: number, + progress: number, + rtl: boolean +) => { + const progressWidth = (taskX2 - taskX1) * progress * 0.01; + let progressX: number; + if (rtl) { + progressX = taskX2 - progressWidth; + } else { + progressX = taskX1; + } + return [progressWidth, progressX]; +}; + +export const progressByProgressWidth = ( + progressWidth: number, + barTask: BarTask +) => { + const barWidth = barTask.x2 - barTask.x1; + const progressPercent = Math.round((progressWidth * 100) / barWidth); + if (progressPercent >= 100) return 100; + else if (progressPercent <= 0) return 0; + else return progressPercent; +}; + +const progressByX = (x: number, task: BarTask) => { + if (x >= task.x2) return 100; + else if (x <= task.x1) return 0; + else { + const barWidth = task.x2 - task.x1; + const progressPercent = Math.round(((x - task.x1) * 100) / barWidth); + return progressPercent; + } +}; +const progressByXRTL = (x: number, task: BarTask) => { + if (x >= task.x2) return 0; + else if (x <= task.x1) return 100; + else { + const barWidth = task.x2 - task.x1; + const progressPercent = Math.round(((task.x2 - x) * 100) / barWidth); + return progressPercent; + } +}; + +export const getProgressPoint = ( + progressX: number, + taskY: number, + taskHeight: number +) => { + const point = [ + progressX - 5, + taskY + taskHeight, + progressX + 5, + taskY + taskHeight, + progressX, + taskY + taskHeight - 8.66, + ]; + return point.join(","); +}; + +const startByX = (x: number, xStep: number, task: BarTask) => { + if (x >= task.x2 - task.handleWidth * 2) { + x = task.x2 - task.handleWidth * 2; + } + const steps = Math.round((x - task.x1) / xStep); + const additionalXValue = steps * xStep; + const newX = task.x1 + additionalXValue; + return newX; +}; + +const endByX = (x: number, xStep: number, task: BarTask) => { + if (x <= task.x1 + task.handleWidth * 2) { + x = task.x1 + task.handleWidth * 2; + } + const steps = Math.round((x - task.x2) / xStep); + const additionalXValue = steps * xStep; + const newX = task.x2 + additionalXValue; + return newX; +}; + +const moveByX = (x: number, xStep: number, task: BarTask) => { + const steps = Math.round((x - task.x1) / xStep); + const additionalXValue = steps * xStep; + const newX1 = task.x1 + additionalXValue; + const newX2 = newX1 + task.x2 - task.x1; + return [newX1, newX2]; +}; + +const dateByX = ( + x: number, + taskX: number, + taskDate: Date, + xStep: number, + timeStep: number +) => { + let newDate = new Date(((x - taskX) / xStep) * timeStep + taskDate.getTime()); + newDate = new Date( + newDate.getTime() + + (newDate.getTimezoneOffset() - taskDate.getTimezoneOffset()) * 60000 + ); + return newDate; +}; + +/** + * Method handles event in real time(mousemove) and on finish(mouseup) + */ +export const handleTaskBySVGMouseEvent = ( + svgX: number, + action: BarMoveAction, + selectedTask: BarTask, + xStep: number, + timeStep: number, + initEventX1Delta: number, + rtl: boolean +): { isChanged: boolean; changedTask: BarTask } => { + let result: { isChanged: boolean; changedTask: BarTask }; + switch (selectedTask.type) { + case "milestone": + result = handleTaskBySVGMouseEventForMilestone( + svgX, + action, + selectedTask, + xStep, + timeStep, + initEventX1Delta + ); + break; + default: + result = handleTaskBySVGMouseEventForBar( + svgX, + action, + selectedTask, + xStep, + timeStep, + initEventX1Delta, + rtl + ); + break; + } + return result; +}; + +const handleTaskBySVGMouseEventForBar = ( + svgX: number, + action: BarMoveAction, + selectedTask: BarTask, + xStep: number, + timeStep: number, + initEventX1Delta: number, + rtl: boolean +): { isChanged: boolean; changedTask: BarTask } => { + const changedTask: BarTask = { ...selectedTask }; + let isChanged = false; + switch (action) { + case "progress": + if (rtl) { + changedTask.progress = progressByXRTL(svgX, selectedTask); + } else { + changedTask.progress = progressByX(svgX, selectedTask); + } + isChanged = changedTask.progress !== selectedTask.progress; + if (isChanged) { + const [progressWidth, progressX] = progressWithByParams( + changedTask.x1, + changedTask.x2, + changedTask.progress, + rtl + ); + changedTask.progressWidth = progressWidth; + changedTask.progressX = progressX; + } + break; + case "start": { + const newX1 = startByX(svgX, xStep, selectedTask); + changedTask.x1 = newX1; + isChanged = changedTask.x1 !== selectedTask.x1; + if (isChanged) { + if (rtl) { + changedTask.end = dateByX( + newX1, + selectedTask.x1, + selectedTask.end, + xStep, + timeStep + ); + } else { + changedTask.start = dateByX( + newX1, + selectedTask.x1, + selectedTask.start, + xStep, + timeStep + ); + } + const [progressWidth, progressX] = progressWithByParams( + changedTask.x1, + changedTask.x2, + changedTask.progress, + rtl + ); + changedTask.progressWidth = progressWidth; + changedTask.progressX = progressX; + } + break; + } + case "end": { + const newX2 = endByX(svgX, xStep, selectedTask); + changedTask.x2 = newX2; + isChanged = changedTask.x2 !== selectedTask.x2; + if (isChanged) { + if (rtl) { + changedTask.start = dateByX( + newX2, + selectedTask.x2, + selectedTask.start, + xStep, + timeStep + ); + } else { + changedTask.end = dateByX( + newX2, + selectedTask.x2, + selectedTask.end, + xStep, + timeStep + ); + } + const [progressWidth, progressX] = progressWithByParams( + changedTask.x1, + changedTask.x2, + changedTask.progress, + rtl + ); + changedTask.progressWidth = progressWidth; + changedTask.progressX = progressX; + } + break; + } + case "move": { + const [newMoveX1, newMoveX2] = moveByX( + svgX - initEventX1Delta, + xStep, + selectedTask + ); + isChanged = newMoveX1 !== selectedTask.x1; + if (isChanged) { + changedTask.start = dateByX( + newMoveX1, + selectedTask.x1, + selectedTask.start, + xStep, + timeStep + ); + changedTask.end = dateByX( + newMoveX2, + selectedTask.x2, + selectedTask.end, + xStep, + timeStep + ); + changedTask.x1 = newMoveX1; + changedTask.x2 = newMoveX2; + const [progressWidth, progressX] = progressWithByParams( + changedTask.x1, + changedTask.x2, + changedTask.progress, + rtl + ); + changedTask.progressWidth = progressWidth; + changedTask.progressX = progressX; + } + break; + } + } + return { isChanged, changedTask }; +}; + +const handleTaskBySVGMouseEventForMilestone = ( + svgX: number, + action: BarMoveAction, + selectedTask: BarTask, + xStep: number, + timeStep: number, + initEventX1Delta: number +): { isChanged: boolean; changedTask: BarTask } => { + const changedTask: BarTask = { ...selectedTask }; + let isChanged = false; + switch (action) { + case "move": { + const [newMoveX1, newMoveX2] = moveByX( + svgX - initEventX1Delta, + xStep, + selectedTask + ); + isChanged = newMoveX1 !== selectedTask.x1; + if (isChanged) { + changedTask.start = dateByX( + newMoveX1, + selectedTask.x1, + selectedTask.start, + xStep, + timeStep + ); + changedTask.end = changedTask.start; + changedTask.x1 = newMoveX1; + changedTask.x2 = newMoveX2; + } + break; + } + } + return { isChanged, changedTask }; +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/helpers/date-helper.ts b/packages/core/client/src/schema-component/antd/gantt/helpers/date-helper.ts new file mode 100644 index 0000000000..f595cd4534 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/helpers/date-helper.ts @@ -0,0 +1,201 @@ +import { Task, ViewMode } from '../types/public-types'; + +const DateTimeFormat = Intl.DateTimeFormat; +type DateTimeFormat = typeof DateTimeFormat; +const DateTimeFormatOptions = Intl.DateTimeFormatOptions; +type DateTimeFormatOptions = typeof DateTimeFormatOptions; +type DateHelperScales = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'millisecond'; + +const intlDTCache = {}; +export const getCachedDateTimeFormat = ( + locString: string | string[], + opts: DateTimeFormatOptions = {}, +): DateTimeFormat => { + const key = JSON.stringify([locString, opts]); + let dtf = intlDTCache[key]; + if (!dtf) { + dtf = new Intl.DateTimeFormat(locString, opts); + intlDTCache[key] = dtf; + } + return dtf; +}; + +export const addToDate = (date: Date, quantity: number, scale: DateHelperScales) => { + const newDate = new Date( + date.getFullYear() + (scale === 'year' ? quantity : 0), + date.getMonth() + (scale === 'month' ? quantity : 0), + date.getDate() + (scale === 'day' ? quantity : 0), + date.getHours() + (scale === 'hour' ? quantity : 0), + date.getMinutes() + (scale === 'minute' ? quantity : 0), + date.getSeconds() + (scale === 'second' ? quantity : 0), + date.getMilliseconds() + (scale === 'millisecond' ? quantity : 0), + ); + return newDate; +}; + +export const startOfDate = (date: Date, scale: DateHelperScales) => { + const scores = ['millisecond', 'second', 'minute', 'hour', 'day', 'month', 'year']; + + const shouldReset = (_scale: DateHelperScales) => { + const maxScore = scores.indexOf(scale); + return scores.indexOf(_scale) <= maxScore; + }; + const newDate = new Date( + date.getFullYear(), + shouldReset('year') ? 0 : date.getMonth(), + shouldReset('month') ? 1 : date.getDate(), + shouldReset('day') ? 0 : date.getHours(), + shouldReset('hour') ? 0 : date.getMinutes(), + shouldReset('minute') ? 0 : date.getSeconds(), + shouldReset('second') ? 0 : date.getMilliseconds(), + ); + return newDate; +}; + +export const ganttDateRange = (tasks: Task[], viewMode: ViewMode, preStepsCount: number) => { + console.log(tasks); + let newStartDate: Date = tasks[0]?.start || new Date(); + let newEndDate: Date = tasks[0]?.start || new Date() ; + for (const task of tasks) { + if (task.start < newStartDate) { + newStartDate = task.start; + } + if (task.end > newEndDate) { + newEndDate = task.end; + } + } + switch (viewMode) { + case ViewMode.Year: + newStartDate = addToDate(newStartDate, -1, 'year'); + newStartDate = startOfDate(newStartDate, 'year'); + newEndDate = addToDate(newEndDate, 1, 'year'); + newEndDate = startOfDate(newEndDate, 'year'); + break; + case ViewMode.QuarterYear: + newStartDate = addToDate(newStartDate, -3, 'month'); + newStartDate = startOfDate(newStartDate, 'month'); + newEndDate = addToDate(newEndDate, 3, 'year'); + newEndDate = startOfDate(newEndDate, 'year'); + break; + case ViewMode.Month: + newStartDate = addToDate(newStartDate, -1 * preStepsCount, 'month'); + newStartDate = startOfDate(newStartDate, 'month'); + newEndDate = addToDate(newEndDate, 1, 'year'); + newEndDate = startOfDate(newEndDate, 'year'); + break; + case ViewMode.Week: + newStartDate = startOfDate(newStartDate, 'day'); + newStartDate = addToDate(getMonday(newStartDate), -7 * preStepsCount, 'day'); + newEndDate = startOfDate(newEndDate, 'day'); + newEndDate = addToDate(newEndDate, 1.5, 'month'); + break; + case ViewMode.Day: + newStartDate = startOfDate(newStartDate, 'day'); + newStartDate = addToDate(newStartDate, -1 * preStepsCount, 'day'); + newEndDate = startOfDate(newEndDate, 'day'); + newEndDate = addToDate(newEndDate, 19, 'day'); + break; + case ViewMode.QuarterDay: + newStartDate = startOfDate(newStartDate, 'day'); + newStartDate = addToDate(newStartDate, -1 * preStepsCount, 'day'); + newEndDate = startOfDate(newEndDate, 'day'); + newEndDate = addToDate(newEndDate, 66, 'hour'); // 24(1 day)*3 - 6 + break; + case ViewMode.HalfDay: + newStartDate = startOfDate(newStartDate, 'day'); + newStartDate = addToDate(newStartDate, -1 * preStepsCount, 'day'); + newEndDate = startOfDate(newEndDate, 'day'); + newEndDate = addToDate(newEndDate, 108, 'hour'); // 24(1 day)*5 - 12 + break; + case ViewMode.Hour: + newStartDate = startOfDate(newStartDate, 'hour'); + newStartDate = addToDate(newStartDate, -1 * preStepsCount, 'hour'); + newEndDate = startOfDate(newEndDate, 'day'); + newEndDate = addToDate(newEndDate, 1, 'day'); + break; + } + return [newStartDate, newEndDate]; +}; + +export const seedDates = (startDate: Date, endDate: Date, viewMode: ViewMode) => { + let currentDate: Date = new Date(startDate); + const dates: Date[] = [currentDate]; + while (currentDate < endDate) { + switch (viewMode) { + case ViewMode.Year: + currentDate = addToDate(currentDate, 1, 'year'); + break; + case ViewMode.QuarterYear: + currentDate = addToDate(currentDate, 3, 'month'); + break; + case ViewMode.Month: + currentDate = addToDate(currentDate, 1, 'month'); + break; + case ViewMode.Week: + currentDate = addToDate(currentDate, 7, 'day'); + break; + case ViewMode.Day: + currentDate = addToDate(currentDate, 1, 'day'); + break; + case ViewMode.HalfDay: + currentDate = addToDate(currentDate, 12, 'hour'); + break; + case ViewMode.QuarterDay: + currentDate = addToDate(currentDate, 6, 'hour'); + break; + case ViewMode.Hour: + currentDate = addToDate(currentDate, 1, 'hour'); + break; + } + dates.push(currentDate); + } + return dates; +}; + +export const getLocaleMonth = (date: Date, locale: string) => { + let bottomValue = getCachedDateTimeFormat(locale, { + month: 'long', + }).format(date); + bottomValue = bottomValue.replace(bottomValue[0], bottomValue[0].toLocaleUpperCase()); + return bottomValue; +}; + +export const getLocalDayOfWeek = (date: Date, locale: string, format?: 'long' | 'short' | 'narrow' | undefined) => { + let bottomValue = getCachedDateTimeFormat(locale, { + weekday: format, + }).format(date); + bottomValue = bottomValue.replace(bottomValue[0], bottomValue[0].toLocaleUpperCase()); + return bottomValue; +}; + +/** + * Returns monday of current week + * @param date date for modify + */ +const getMonday = (date: Date) => { + const day = date.getDay(); + const diff = date.getDate() - day + (day === 0 ? -6 : 1); // adjust when day is sunday + return new Date(date.setDate(diff)); +}; + +export const getWeekNumberISO8601 = (date: Date) => { + const tmpDate = new Date(date.valueOf()); + const dayNumber = (tmpDate.getDay() + 6) % 7; + tmpDate.setDate(tmpDate.getDate() - dayNumber + 3); + const firstThursday = tmpDate.valueOf(); + tmpDate.setMonth(0, 1); + if (tmpDate.getDay() !== 4) { + tmpDate.setMonth(0, 1 + ((4 - tmpDate.getDay() + 7) % 7)); + } + const weekNumber = (1 + Math.ceil((firstThursday - tmpDate.valueOf()) / 604800000)).toString(); + + if (weekNumber.length === 1) { + return `0${weekNumber}`; + } else { + return weekNumber; + } +}; + +export const getDaysInMonth = (month: number, year: number) => { + return new Date(year, month + 1, 0).getDate(); +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/helpers/other-helper.ts b/packages/core/client/src/schema-component/antd/gantt/helpers/other-helper.ts new file mode 100644 index 0000000000..afd5a362e9 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/helpers/other-helper.ts @@ -0,0 +1,61 @@ +import { BarTask } from "../types/bar-task"; +import { Task } from "../types/public-types"; + +export function isKeyboardEvent( + event: React.MouseEvent | React.KeyboardEvent | React.FocusEvent +): event is React.KeyboardEvent { + return (event as React.KeyboardEvent).key !== undefined; +} + +export function isMouseEvent( + event: React.MouseEvent | React.KeyboardEvent | React.FocusEvent +): event is React.MouseEvent { + return (event as React.MouseEvent).clientX !== undefined; +} + +export function isBarTask(task: Task | BarTask): task is BarTask { + return (task as BarTask).x1 !== undefined; +} + +export function removeHiddenTasks(tasks: Task[]) { + const groupedTasks = tasks.filter( + t => t.hideChildren && t.type === "project" + ); + if (groupedTasks.length > 0) { + for (let i = 0; groupedTasks.length > i; i++) { + const groupedTask = groupedTasks[i]; + const children = getChildren(tasks, groupedTask); + tasks = tasks.filter(t => children.indexOf(t) === -1); + } + } + return tasks; +} + +function getChildren(taskList: Task[], task: Task) { + let tasks: Task[] = []; + if (task.type !== "project") { + tasks = taskList.filter( + t => t.dependencies && t.dependencies.indexOf(task.id) !== -1 + ); + } else { + tasks = taskList.filter(t => t.project && t.project === task.id); + } + var taskChildren: Task[] = []; + tasks.forEach(t => { + taskChildren.push(...getChildren(taskList, t)); + }) + tasks = tasks.concat(tasks, taskChildren); + return tasks; +} + +export const sortTasks = (taskA: Task, taskB: Task) => { + const orderA = taskA.displayOrder || Number.MAX_VALUE; + const orderB = taskB.displayOrder || Number.MAX_VALUE; + if (orderA > orderB) { + return 1; + } else if (orderA < orderB) { + return -1; + } else { + return 0; + } +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/index.ts b/packages/core/client/src/schema-component/antd/gantt/index.ts new file mode 100644 index 0000000000..d21dcf720e --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/index.ts @@ -0,0 +1,25 @@ +// import DeleteEvent from './DeleteEvent'; +import { ActionBar } from '../action'; +import { Gantt } from './components/gantt/gantt'; +import { GanttDesigner } from './Gantt.Designer'; +// import { Event } from './Event'; +// import { Nav } from './Nav'; +// import './style.less'; +// import { Title } from './Title'; +// import { Today } from './Today'; +// import { ViewSelect } from './ViewSelect'; + +import { ViewMode } from './types/public-types'; + +Gantt.ActionBar = ActionBar; +// Calendar.Event = Event; +// Calendar.DeleteEvent = DeleteEvent; +// Calendar.Title = Title; +// Calendar.Today = Today; +Gantt.ViewMode = ViewMode; +// Gantt.ViewSelect = ViewSelect; +Gantt.Designer = GanttDesigner; + +// const GanttV2 = Gantt; + +export { Gantt }; diff --git a/packages/core/client/src/schema-component/antd/gantt/test/date-helper.test.tsx b/packages/core/client/src/schema-component/antd/gantt/test/date-helper.test.tsx new file mode 100644 index 0000000000..c90f239d73 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/test/date-helper.test.tsx @@ -0,0 +1,73 @@ +import { + seedDates, + addToDate, + getWeekNumberISO8601, +} from "../helpers/date-helper"; +import { ViewMode } from "../types/public-types"; + +describe("seed date", () => { + test("daily", () => { + expect( + seedDates(new Date(2020, 5, 28), new Date(2020, 6, 2), ViewMode.Day) + ).toEqual([ + new Date(2020, 5, 28), + new Date(2020, 5, 29), + new Date(2020, 5, 30), + new Date(2020, 6, 1), + new Date(2020, 6, 2), + ]); + }); + + test("weekly", () => { + expect( + seedDates(new Date(2020, 5, 28), new Date(2020, 6, 19), ViewMode.Week) + ).toEqual([ + new Date(2020, 5, 28), + new Date(2020, 6, 5), + new Date(2020, 6, 12), + new Date(2020, 6, 19), + ]); + }); + + test("monthly", () => { + expect( + seedDates(new Date(2020, 5, 28), new Date(2020, 6, 19), ViewMode.Month) + ).toEqual([new Date(2020, 5, 28), new Date(2020, 6, 28)]); + }); + + test("quarterly", () => { + expect( + seedDates( + new Date(2020, 5, 28), + new Date(2020, 5, 29), + ViewMode.QuarterDay + ) + ).toEqual([ + new Date(2020, 5, 28, 0, 0), + new Date(2020, 5, 28, 6, 0), + new Date(2020, 5, 28, 12, 0), + new Date(2020, 5, 28, 18, 0), + new Date(2020, 5, 29, 0, 0), + ]); + }); +}); + +describe("add to date", () => { + test("add month", () => { + expect(addToDate(new Date(2020, 0, 1), 40, "month")).toEqual( + new Date(2023, 4, 1) + ); + }); + + test("add day", () => { + expect(addToDate(new Date(2020, 0, 1), 40, "day")).toEqual( + new Date(2020, 1, 10) + ); + }); +}); + +test("get week number", () => { + expect(getWeekNumberISO8601(new Date(2019, 11, 31))).toEqual("01"); + expect(getWeekNumberISO8601(new Date(2021, 0, 1))).toEqual("53"); + expect(getWeekNumberISO8601(new Date(2020, 6, 20))).toEqual("30"); +}); diff --git a/packages/core/client/src/schema-component/antd/gantt/test/gant.test.tsx b/packages/core/client/src/schema-component/antd/gantt/test/gant.test.tsx new file mode 100644 index 0000000000..75b2e21426 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/test/gant.test.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { Gantt } from "../index"; + +describe("gantt", () => { + it("renders without crashing", () => { + const div = document.createElement("div"); + const root = createRoot(div); + root.render( + + ); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/gantt/types/bar-task.ts b/packages/core/client/src/schema-component/antd/gantt/types/bar-task.ts new file mode 100644 index 0000000000..1be75e84be --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/types/bar-task.ts @@ -0,0 +1,23 @@ +import { Task, TaskType } from "./public-types"; + +export interface BarTask extends Task { + index: number; + typeInternal: TaskTypeInternal; + x1: number; + x2: number; + y: number; + height: number; + progressX: number; + progressWidth: number; + barCornerRadius: number; + handleWidth: number; + barChildren: BarTask[]; + styles: { + backgroundColor: string; + backgroundSelectedColor: string; + progressColor: string; + progressSelectedColor: string; + }; +} + +export type TaskTypeInternal = TaskType | "smalltask"; diff --git a/packages/core/client/src/schema-component/antd/gantt/types/date-setup.ts b/packages/core/client/src/schema-component/antd/gantt/types/date-setup.ts new file mode 100644 index 0000000000..81115ece0c --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/types/date-setup.ts @@ -0,0 +1,6 @@ +import { ViewMode } from "./public-types"; + +export interface DateSetup { + dates: Date[]; + viewMode: ViewMode; +} diff --git a/packages/core/client/src/schema-component/antd/gantt/types/gantt-task-actions.ts b/packages/core/client/src/schema-component/antd/gantt/types/gantt-task-actions.ts new file mode 100644 index 0000000000..01e1292cb4 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/types/gantt-task-actions.ts @@ -0,0 +1,18 @@ +import { BarTask } from "./bar-task"; + +export type BarMoveAction = "progress" | "end" | "start" | "move"; +export type GanttContentMoveAction = + | "mouseenter" + | "mouseleave" + | "delete" + | "dblclick" + | "click" + | "select" + | "" + | BarMoveAction; + +export type GanttEvent = { + changedTask?: BarTask; + originalSelectedTask?: BarTask; + action: GanttContentMoveAction; +}; diff --git a/packages/core/client/src/schema-component/antd/gantt/types/public-types.ts b/packages/core/client/src/schema-component/antd/gantt/types/public-types.ts new file mode 100644 index 0000000000..cc44ff17c0 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/gantt/types/public-types.ts @@ -0,0 +1,145 @@ +export enum ViewMode { + Hour = "Hour", + QuarterDay = "Quarter Day", + HalfDay = "Half Day", + Day = "Day", + /** ISO-8601 week */ + Week = "Week", + Month = "Month", + QuarterYear = "QuarterYear", + Year = "Year", +} +export type TaskType = "task" | "milestone" | "project"; +export interface Task { + id: string; + type: TaskType; + name: string; + start: Date; + end: Date; + /** + * From 0 to 100 + */ + progress: number; + styles?: { + backgroundColor?: string; + backgroundSelectedColor?: string; + progressColor?: string; + progressSelectedColor?: string; + }; + isDisabled?: boolean; + project?: string; + dependencies?: string[]; + hideChildren?: boolean; + displayOrder?: number; +} + +export interface EventOption { + /** + * Time step value for date changes. + */ + timeStep?: number; + /** + * Invokes on bar select on unselect. + */ + onSelect?: (task: Task, isSelected: boolean) => void; + /** + * Invokes on bar double click. + */ + onDoubleClick?: (task: Task) => void; + /** + * Invokes on bar click. + */ + onClick?: (task: Task) => void; + /** + * Invokes on end and start time change. Chart undoes operation if method return false or error. + */ + onDateChange?: ( + task: Task, + children: Task[] + ) => void | boolean | Promise | Promise; + /** + * Invokes on progress change. Chart undoes operation if method return false or error. + */ + onProgressChange?: ( + task: Task, + children: Task[] + ) => void | boolean | Promise | Promise; + /** + * Invokes on delete selected task. Chart undoes operation if method return false or error. + */ + onDelete?: (task: Task) => void | boolean | Promise | Promise; + /** + * Invokes on expander on task list + */ + onExpanderClick?: (task: Task) => void; +} + +export interface DisplayOption { + viewMode?: ViewMode; + viewDate?: Date; + preStepsCount?: number; + /** + * Specifies the month name language. Able formats: ISO 639-2, Java Locale + */ + locale?: string; + rtl?: boolean; +} + +export interface StylingOption { + headerHeight?: number; + columnWidth?: number; + listCellWidth?: string; + rowHeight?: number; + ganttHeight?: number; + barCornerRadius?: number; + handleWidth?: number; + fontFamily?: string; + fontSize?: string; + /** + * How many of row width can be taken by task. + * From 0 to 100 + */ + barFill?: number; + barProgressColor?: string; + barProgressSelectedColor?: string; + barBackgroundColor?: string; + barBackgroundSelectedColor?: string; + projectProgressColor?: string; + projectProgressSelectedColor?: string; + projectBackgroundColor?: string; + projectBackgroundSelectedColor?: string; + milestoneBackgroundColor?: string; + milestoneBackgroundSelectedColor?: string; + arrowColor?: string; + arrowIndent?: number; + todayColor?: string; + TooltipContent?: React.FC<{ + task: Task; + fontSize: string; + fontFamily: string; + }>; + TaskListHeader?: React.FC<{ + headerHeight: number; + rowWidth: string; + fontFamily: string; + fontSize: string; + }>; + TaskListTable?: React.FC<{ + rowHeight: number; + rowWidth: string; + fontFamily: string; + fontSize: string; + locale: string; + tasks: Task[]; + selectedTaskId: string; + /** + * Sets selected task by id + */ + setSelectedTask: (taskId: string) => void; + onExpanderClick: (task: Task) => void; + }>; +} + +export interface GanttProps extends EventOption, DisplayOption, StylingOption { + tasks: Task[]; +} diff --git a/packages/core/client/src/schema-component/antd/index.ts b/packages/core/client/src/schema-component/antd/index.ts index 9ed8b810b9..764c852df8 100644 --- a/packages/core/client/src/schema-component/antd/index.ts +++ b/packages/core/client/src/schema-component/antd/index.ts @@ -36,4 +36,5 @@ export * from './tabs'; export * from './time-picker'; export * from './tree-select'; export * from './upload'; +export * from './gantt' import './index.less'; diff --git a/packages/core/client/src/schema-initializer/SchemaInitializer.tsx b/packages/core/client/src/schema-initializer/SchemaInitializer.tsx index c65cd6bca3..e8c9c5f159 100644 --- a/packages/core/client/src/schema-initializer/SchemaInitializer.tsx +++ b/packages/core/client/src/schema-initializer/SchemaInitializer.tsx @@ -161,7 +161,6 @@ SchemaInitializer.Item = (props: SchemaInitializerItemProps) => { return ; } if (item.type === 'itemGroup') { - console.log(item.children); return ( { + const { items = [] } = props; + const { t } = useTranslation(); + const associatedFields = useAssociatedTableColumnInitializerFields(); + const inheritFields = useInheritsTableColumnInitializerFields(); + const compile = useCompile(); + const fieldItems: any[] = [ + { + type: 'itemGroup', + title: t('Display fields'), + children: useTableColumnInitializerFields(), + }, + ]; + if (inheritFields?.length > 0) { + inheritFields.forEach((inherit) => { + Object.values(inherit)[0].length && + fieldItems.push( + { + type: 'divider', + }, + { + type: 'itemGroup', + title: t(`Parent collection fields`) + '(' + compile(`${Object.keys(inherit)[0]}`) + ')', + children: Object.values(inherit)[0], + }, + ); + }); + } + if (associatedFields?.length > 0) { + fieldItems.push( + { + type: 'divider', + }, + { + type: 'itemGroup', + title: t('Display association fields'), + children: associatedFields, + }, + ); + } + fieldItems.push( + { + type: 'divider', + }, + { + type: 'item', + title: t('Action column'), + component: 'TableActionColumnInitializer', + }, + ); + console.log(5) + return ( + { + console.log(s) + if (s['x-action-column']) { + return s; + } + return { + type: 'void', + 'x-decorator': 'TableV2.Column.Decorator', + 'x-designer': 'TableV2.Column.Designer', + 'x-component': 'TableV2.Column', + properties: { + [s.name]: { + ...s, + }, + }, + }; + }} + items={itemsMerge(fieldItems, items)} + > + {t('Configure columns')} + + ); +}; diff --git a/packages/core/client/src/schema-initializer/buttons/index.ts b/packages/core/client/src/schema-initializer/buttons/index.ts index 7a62f77cd8..34abd1221e 100644 --- a/packages/core/client/src/schema-initializer/buttons/index.ts +++ b/packages/core/client/src/schema-initializer/buttons/index.ts @@ -19,5 +19,6 @@ export * from './TableActionInitializers'; export * from './TableColumnInitializers'; export * from './TableSelectorInitializers'; export * from './TabPaneInitializers'; +export * from './GanttColumnInitializers' // association filter export * from '../../schema-component/antd/association-filter/AssociationFilter'; diff --git a/packages/core/client/src/schema-initializer/items/GanttBlockInitializer.tsx b/packages/core/client/src/schema-initializer/items/GanttBlockInitializer.tsx new file mode 100644 index 0000000000..d8abe80970 --- /dev/null +++ b/packages/core/client/src/schema-initializer/items/GanttBlockInitializer.tsx @@ -0,0 +1,103 @@ +import React, { useContext } from 'react'; +import { FormDialog, FormLayout } from '@formily/antd'; +import { FormOutlined } from '@ant-design/icons'; +import { SchemaOptionsContext } from '@formily/react'; +import { useTranslation } from 'react-i18next'; + +import { useCollectionManager } from '../../collection-manager'; +import { SchemaComponent, SchemaComponentOptions } from '../../schema-component'; +import { createGanttBlockSchema } from '../utils'; +import { DataBlockInitializer } from './DataBlockInitializer'; + +export const GanttBlockInitializer = (props) => { + const { insert } = props; + const { t } = useTranslation(); + const { getCollectionFields } = useCollectionManager(); + const options = useContext(SchemaOptionsContext); + return ( + } + onCreateBlockSchema={async ({ item }) => { + const collectionFields = getCollectionFields(item.name); + const stringFields = collectionFields + ?.filter((field) => field.type === 'string') + ?.map((field) => { + return { + label: field?.uiSchema?.title, + value: field.name, + }; + }); + const dateFields = collectionFields + ?.filter((field) => field.type === 'date') + ?.map((field) => { + return { + label: field?.uiSchema?.title, + value: field.name, + }; + }); + const values = await FormDialog(t('Create gantt block'), () => { + return ( + + + + + + ); + }).open({ + initialValues: {}, + }); + insert( + createGanttBlockSchema({ + collection: item.name, + fieldNames: { + ...values, + }, + }), + ); + }} + /> + ); +}; diff --git a/packages/core/client/src/schema-initializer/items/index.tsx b/packages/core/client/src/schema-initializer/items/index.tsx index 3c8843a152..dae534252e 100644 --- a/packages/core/client/src/schema-initializer/items/index.tsx +++ b/packages/core/client/src/schema-initializer/items/index.tsx @@ -37,6 +37,8 @@ export * from './TableSelectorInitializer'; export * from './UpdateActionInitializer'; export * from './UpdateSubmitActionInitializer'; export * from './ViewActionInitializer'; +export * from './GanttBlockInitializer' + // association filter export * from '../../schema-component/antd/association-filter/AssociationFilter'; export * from '../../schema-component/antd/association-filter/ActionBarAssociationFilterAction'; diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index 4c6ca59d1f..904842ae6e 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -1016,6 +1016,96 @@ export const createCalendarBlockSchema = (options) => { return schema; }; +export const createGanttBlockSchema = (options) => { + const { collection, resource, fieldNames, ...others } = options; + const schema: ISchema = { + type: 'void', + 'x-acl-action': `${resource || collection}:list`, + 'x-decorator': 'GanttBlockProvider', + 'x-decorator-props': { + collection: collection, + resource: resource || collection, + action: 'list', + fieldNames: { + id: 'id', + ...fieldNames, + }, + params: { + paginate: false, + }, + ...others, + }, + 'x-designer': 'Gantt.Designer', + 'x-component': 'CardItem', + properties: { + [uid()]: { + type: 'array', + 'x-initializer': 'GanttColumnInitializers', + 'x-component': 'Gantt', + 'x-component-props': { + useProps: '{{ useGanttBlockProps }}', + }, + properties: { + toolBar: { + type: 'void', + 'x-component': 'Gantt.ActionBar', + 'x-component-props': { + style: { + marginBottom: 24, + }, + }, + 'x-initializer': 'GanttActionInitializers', + properties: {}, + }, + event: { + type: 'void', + 'x-component': 'Gantt.Event', + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-component-props': { + className: 'nb-action-popup', + }, + title: '{{ t("View record") }}', + properties: { + tabs: { + type: 'void', + 'x-component': 'Tabs', + 'x-component-props': {}, + 'x-initializer': 'TabPaneInitializers', + properties: { + tab1: { + type: 'void', + title: '{{t("Details")}}', + 'x-component': 'Tabs.TabPane', + 'x-designer': 'Tabs.Designer', + 'x-component-props': {}, + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer-props': { + actionInitializers: 'GanttFormActionInitializers', + }, + 'x-initializer': 'RecordBlockInitializers', + properties: {}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + console.log(JSON.stringify(schema, null, 2)); + return schema; +}; export const createKanbanBlockSchema = (options) => { const { collection, resource, groupField, ...others } = options; const schema: ISchema = {