Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ npm start
| preStepsCount | number | 最初のタスクの前の空白を指定します。 |
| locale | string | 月名の言語を指定します。利用可能な形式: ISO 639-2, Java Locale。 |
| rtl | boolean | rtl モードを設定します。 |
| workHoursPerDay | number | 実績正規化で使用する 1 日あたりの稼働時間(時間単位)。未指定時は業務時間帯から算出されます。 |
| workdayStartTime | string | 実績正規化で使用する業務開始時刻("HH:mm")。未指定・不正時は "09:00" を使用します。 |
| workdayEndTime | string | 実績正規化で使用する業務終了時刻("HH:mm")。未指定・不正時は "18:00" を使用します。 |
| calendar | [CalendarConfig](#calendarconfig) | 稼働日計算と日付表示のカレンダー設定を指定します。未指定の場合は従来の動作を維持します(オプトイン式)。 |

### CalendarConfig
Expand Down
45 changes: 37 additions & 8 deletions src/components/gantt/gantt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { HorizontalScroll } from "../other/horizontal-scroll";
import { removeHiddenTasks, sortTasks } from "../../helpers/other-helper";
import { DEFAULT_VISIBLE_FIELDS } from "../../helpers/task-helper";
import { normalizeCalendarConfig } from "../../helpers/calendar-helper";
import { normalizeActuals } from "../../helpers/actuals-helper";
import styles from "./gantt.module.css";

const DEFAULT_TASK_LIST_WIDTH = 450;
Expand All @@ -49,6 +50,9 @@ export const Gantt: React.FunctionComponent<GanttProps> = ({
preStepsCount = 1,
locale = "en-GB",
calendar,
workHoursPerDay,
workdayStartTime,
workdayEndTime,
barFill = 60,
barCornerRadius = 3,
barProgressColor = "#a3a3ff",
Expand Down Expand Up @@ -91,6 +95,19 @@ export const Gantt: React.FunctionComponent<GanttProps> = ({
() => (calendar ? normalizeCalendarConfig(calendar) : undefined),
[calendar]
);
const actualsOptions = useMemo(
() => ({
calendarConfig,
workHoursPerDay,
workdayStartTime,
workdayEndTime,
}),
[calendarConfig, workHoursPerDay, workdayStartTime, workdayEndTime]
);
const normalizedTasks = useMemo(
() => tasks.map(task => normalizeActuals(task, actualsOptions)),
[tasks, actualsOptions]
);

const wrapperRef = useRef<HTMLDivElement>(null);
const taskListRef = useRef<HTMLDivElement>(null);
Expand All @@ -104,7 +121,11 @@ export const Gantt: React.FunctionComponent<GanttProps> = ({
const supportsPointerEvents =
typeof window !== "undefined" && "PointerEvent" in window;
const [dateSetup, setDateSetup] = useState<DateSetup>(() => {
const [startDate, endDate] = ganttDateRange(tasks, viewMode, preStepsCount);
const [startDate, endDate] = ganttDateRange(
normalizedTasks,
viewMode,
preStepsCount
);
return { viewMode, dates: seedDates(startDate, endDate, viewMode) };
});
const [currentViewDate, setCurrentViewDate] = useState<Date | undefined>(
Expand Down Expand Up @@ -142,9 +163,9 @@ export const Gantt: React.FunctionComponent<GanttProps> = ({
useEffect(() => {
let filteredTasks: Task[];
if (onExpanderClick) {
filteredTasks = removeHiddenTasks(tasks);
filteredTasks = removeHiddenTasks(normalizedTasks);
} else {
filteredTasks = tasks;
filteredTasks = normalizedTasks;
}
filteredTasks = filteredTasks.sort(sortTasks);
const [startDate, endDate] = ganttDateRange(
Expand Down Expand Up @@ -183,7 +204,7 @@ export const Gantt: React.FunctionComponent<GanttProps> = ({
)
);
}, [
tasks,
normalizedTasks,
viewMode,
preStepsCount,
rowHeight,
Expand Down Expand Up @@ -306,9 +327,9 @@ export const Gantt: React.FunctionComponent<GanttProps> = ({
if (ganttHeight) {
setSvgContainerHeight(ganttHeight + headerHeight);
} else {
setSvgContainerHeight(tasks.length * rowHeight + headerHeight);
setSvgContainerHeight(normalizedTasks.length * rowHeight + headerHeight);
}
}, [ganttHeight, tasks, headerHeight, rowHeight]);
}, [ganttHeight, normalizedTasks, headerHeight, rowHeight]);

useEffect(() => {
return () => {
Expand Down Expand Up @@ -389,7 +410,14 @@ export const Gantt: React.FunctionComponent<GanttProps> = ({
}
window.removeEventListener("resize", updateLeftScroller);
};
}, [tasks, fontFamily, fontSize, listCellWidth, taskListBodyRef, visibleFields]);
}, [
normalizedTasks,
fontFamily,
fontSize,
listCellWidth,
taskListBodyRef,
visibleFields,
]);

const handleScrollY = (event: SyntheticEvent<HTMLDivElement>) => {
if (scrollY !== event.currentTarget.scrollTop && !ignoreScrollLeftRef.current) {
Expand Down Expand Up @@ -583,7 +611,7 @@ export const Gantt: React.FunctionComponent<GanttProps> = ({
const gridProps: GridProps = {
columnWidth,
svgWidth,
tasks: tasks,
tasks: normalizedTasks,
rowHeight,
dates: dateSetup.dates,
todayColor,
Expand Down Expand Up @@ -651,6 +679,7 @@ export const Gantt: React.FunctionComponent<GanttProps> = ({
onCellCommit,
effortDisplayUnit,
enableColumnDrag,
actualsOptions,
};
return (
<div>
Expand Down
108 changes: 106 additions & 2 deletions src/components/task-list/task-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ import {
Task,
VisibleField,
} from "../../types/public-types";
import {
ActualsNormalizeOptions,
normalizeActuals,
} from "../../helpers/actuals-helper";
import {
formatDate,
parseDateFromInput,
sanitizeEffortInput,
} from "../../helpers/task-helper";
import { ParsedTime, parseTimeString } from "../../helpers/time-helper";
import { OverlayEditor } from "./overlay-editor";

export type EditingTrigger = "dblclick" | "enter" | "key";
Expand Down Expand Up @@ -50,6 +60,7 @@ export type TaskListProps = {
visibleFields: VisibleField[];
effortDisplayUnit: EffortUnit;
tasks: Task[];
actualsOptions?: ActualsNormalizeOptions;
taskListRef: React.RefObject<HTMLDivElement>;
headerContainerRef?: React.RefObject<HTMLDivElement>;
bodyContainerRef?: React.RefObject<HTMLDivElement>;
Expand Down Expand Up @@ -93,6 +104,34 @@ export const DEFAULT_MIN_WIDTH = 32;
export const getDefaultWidth = (field: VisibleField, rowWidth: string): number =>
field === "name" ? 140 : Number.parseInt(rowWidth, 10) || 155;

const isValidDate = (value?: Date) =>
value instanceof Date && !Number.isNaN(value.getTime());

// invalid dates are treated as different to ensure normalization updates propagate
const isSameDate = (a?: Date, b?: Date): boolean =>
!!a && !!b && isValidDate(a) && isValidDate(b) && a.getTime() === b.getTime();

const applyTimeToDate = (
date: Date,
sourceDate: Date | undefined,
fallbackTime?: ParsedTime | null
) => {
const next = new Date(date);
if (sourceDate && isValidDate(sourceDate)) {
next.setHours(
sourceDate.getHours(),
sourceDate.getMinutes(),
sourceDate.getSeconds(),
sourceDate.getMilliseconds()
);
return next;
}
if (fallbackTime) {
next.setHours(fallbackTime.hours, fallbackTime.minutes, 0, 0);
}
return next;
};

export const TaskList: React.FC<TaskListProps> = ({
headerHeight,
fontFamily,
Expand All @@ -116,6 +155,7 @@ export const TaskList: React.FC<TaskListProps> = ({
onUpdateTask,
onCellCommit,
effortDisplayUnit,
actualsOptions,
enableColumnDrag = true,
onHorizontalScroll,
}) => {
Expand Down Expand Up @@ -265,6 +305,66 @@ export const TaskList: React.FC<TaskListProps> = ({
}
const rowId = editingState.rowId;
const columnId = editingState.columnId;
const task = tasks.find(row => row.id === rowId);
const resolveActualsCommit = () => {
if (!task) {
return null;
}
if (columnId !== "start" && columnId !== "end" && columnId !== "actualEffort") {
return null;
}
let parsedValue: number | Date | undefined;
if (columnId === "actualEffort") {
parsedValue = sanitizeEffortInput(value);
} else {
const parsedDate = parseDateFromInput(value);
if (!parsedDate) {
return null;
}
const sourceDate = columnId === "start" ? task.start : task.end;
const fallbackTime = parseTimeString(
columnId === "start"
? actualsOptions?.workdayStartTime
: actualsOptions?.workdayEndTime
);
parsedValue = applyTimeToDate(parsedDate, sourceDate, fallbackTime);
}
if (parsedValue === undefined) {
return null;
}
const invalidEndForRecalc = new Date("invalid");
const draftTask = {
...task,
[columnId]: parsedValue,
...(columnId === "actualEffort"
? { end: invalidEndForRecalc }
: {}),
} as Task;
const normalized = normalizeActuals(draftTask, actualsOptions ?? {});
const updatedFields: Partial<Task> = {};
if (!isSameDate(normalized.start, task.start)) {
updatedFields.start = normalized.start;
}
if (!isSameDate(normalized.end, task.end)) {
updatedFields.end = normalized.end;
}
if (normalized.actualEffort !== task.actualEffort) {
updatedFields.actualEffort = normalized.actualEffort;
}
const normalizedValue =
columnId === "actualEffort"
? normalized.actualEffort !== undefined
? `${normalized.actualEffort}`
: value
: columnId === "start"
? formatDate(normalized.start)
: formatDate(normalized.end);
return {
normalizedValue,
updatedFields: Object.keys(updatedFields).length > 0 ? updatedFields : null,
};
};
const actualsCommit = resolveActualsCommit();
setEditingState(prev => {
if (
prev.mode !== "editing" ||
Expand All @@ -277,7 +377,11 @@ export const TaskList: React.FC<TaskListProps> = ({
return { ...prev, pending: true, errorMessage: null };
});
try {
await onCellCommit({ rowId, columnId, value, trigger });
const commitValue = actualsCommit?.normalizedValue ?? value;
await onCellCommit({ rowId, columnId, value: commitValue, trigger });
if (actualsCommit?.updatedFields && onUpdateTask) {
onUpdateTask(rowId, actualsCommit.updatedFields);
}
if (!mountedRef.current) {
return;
}
Expand Down Expand Up @@ -324,7 +428,7 @@ export const TaskList: React.FC<TaskListProps> = ({
});
}
},
[editingState, onCellCommit]
[actualsOptions, editingState, onCellCommit, onUpdateTask, tasks]
);

const selectCell = useCallback((rowId: string, columnId: VisibleField) => {
Expand Down
Loading