@hellobetterdigitalnz/betterui 0.0.3-310 → 0.0.3-313
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Components/DataDisplay/GanttChart/GanttChart.d.ts +20 -3
- package/dist/Components/DataDisplay/index.d.ts +1 -0
- package/dist/betterui.css +1 -1
- package/dist/index.cjs.js +10 -10
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +3264 -2952
- package/dist/index.es.js.map +1 -1
- package/package.json +1 -1
- package/src/Components/DataDisplay/GanttChart/{GanttChart.scss → GanttChart.module.scss} +74 -55
- package/src/Components/DataDisplay/GanttChart/GanttChart.tsx +379 -179
- package/src/Components/DataDisplay/index.ts +2 -0
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useMemo,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useEffect,
|
|
7
|
+
ReactNode,
|
|
8
|
+
} from "react";
|
|
2
9
|
import {
|
|
3
10
|
addDays,
|
|
4
11
|
addMinutes,
|
|
@@ -7,27 +14,43 @@ import {
|
|
|
7
14
|
formatISO,
|
|
8
15
|
startOfDay,
|
|
9
16
|
} from "date-fns";
|
|
10
|
-
import "./GanttChart.scss";
|
|
17
|
+
import styles from "./GanttChart.module.scss";
|
|
11
18
|
import { DotsSixVertical } from "../../Icons";
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
import CaretLeft from "../../Icons/Arrows/CaretLeft/CaretLeft.tsx";
|
|
20
|
+
import CaretRight from "../../Icons/Arrows/CaretRight/CaretRight.tsx";
|
|
14
21
|
|
|
15
22
|
type GanttTask = {
|
|
16
23
|
id: string;
|
|
17
24
|
name: string;
|
|
18
|
-
start: string;
|
|
19
|
-
end: string;
|
|
25
|
+
start: string;
|
|
26
|
+
end: string;
|
|
20
27
|
color?: string;
|
|
21
28
|
user?: string;
|
|
29
|
+
userId?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type GanttTaskChange = {
|
|
33
|
+
task: GanttTask;
|
|
34
|
+
previousTask: GanttTask;
|
|
35
|
+
changeType: "move" | "resize-start" | "resize-end";
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type WorkingHours = {
|
|
39
|
+
userId: string;
|
|
40
|
+
date: string; // ISO date string (YYYY-MM-DD)
|
|
41
|
+
startTime: string; // HH:mm format (e.g., "09:00")
|
|
42
|
+
endTime: string; // HH:mm format (e.g., "17:00")
|
|
22
43
|
};
|
|
23
44
|
|
|
24
45
|
type GanttChartProps = {
|
|
25
46
|
tasks?: GanttTask[];
|
|
26
|
-
onChange?: (tasks: GanttTask[]) => void;
|
|
47
|
+
onChange?: (tasks: GanttTask[], change?: GanttTaskChange) => void;
|
|
48
|
+
dropdown?: ReactNode;
|
|
49
|
+
lockTime?: boolean; // When true, dragging only changes user, time stays fixed
|
|
50
|
+
preventOverlap?: boolean; // When true, tasks cannot overlap
|
|
51
|
+
workingHours?: WorkingHours[]; // Optional working hours configuration
|
|
27
52
|
};
|
|
28
53
|
|
|
29
|
-
// -------------------- Utils --------------------
|
|
30
|
-
|
|
31
54
|
function dateToMinuteIndex(base: Date, d: Date) {
|
|
32
55
|
return differenceInMinutes(d, base);
|
|
33
56
|
}
|
|
@@ -40,82 +63,140 @@ function clamp(n: number, min: number, max: number) {
|
|
|
40
63
|
return Math.min(max, Math.max(min, n));
|
|
41
64
|
}
|
|
42
65
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
export default function GanttChart({ tasks: initialTasks, onChange }: GanttChartProps) {
|
|
66
|
+
const GanttChart = ({ tasks: initialTasks, onChange, dropdown, lockTime, preventOverlap, workingHours }: GanttChartProps) => {
|
|
46
67
|
const [currentDay, setCurrentDay] = useState<Date>(startOfDay(new Date()));
|
|
47
68
|
const startDate = startOfDay(currentDay);
|
|
48
|
-
const totalDays =
|
|
69
|
+
const totalDays = 7;
|
|
49
70
|
|
|
50
|
-
// Use header hours for sizing (stable across rows). Avoid a shared ref across many rows.
|
|
51
71
|
const headerHoursRef = useRef<HTMLDivElement>(null);
|
|
52
|
-
const [cellSize, setCellSize] = useState<number>(60);
|
|
53
|
-
|
|
54
|
-
const [tasks, setTasks] = useState<GanttTask[]>(
|
|
55
|
-
initialTasks ?? [
|
|
56
|
-
{
|
|
57
|
-
id: "1",
|
|
58
|
-
name: "Design",
|
|
59
|
-
start: "2025-08-27T05:30:00",
|
|
60
|
-
end: "2025-08-27T10:30:00",
|
|
61
|
-
color: "blue",
|
|
62
|
-
user: "Alice",
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
id: "2",
|
|
66
|
-
name: "Development",
|
|
67
|
-
start: "2025-08-27T09:45:00",
|
|
68
|
-
end: "2025-08-27T17:30:00", // FIX: valid 24h time and correct date
|
|
69
|
-
color: "green",
|
|
70
|
-
user: "Bob",
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
id: "3",
|
|
74
|
-
name: "QA",
|
|
75
|
-
start: "2025-08-27T08:10:00",
|
|
76
|
-
end: "2025-08-27T09:40:00",
|
|
77
|
-
color: "orange",
|
|
78
|
-
user: "Charlie",
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
id: "4",
|
|
82
|
-
name: "QA",
|
|
83
|
-
start: "2025-08-27T08:10:00",
|
|
84
|
-
end: "2025-08-27T09:40:00",
|
|
85
|
-
color: "red",
|
|
86
|
-
user: "Mamudu",
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
id: "5",
|
|
90
|
-
name: "QA",
|
|
91
|
-
start: "2025-08-27T08:10:00",
|
|
92
|
-
end: "2025-08-27T09:40:00",
|
|
93
|
-
color: "black",
|
|
94
|
-
user: "",
|
|
95
|
-
},
|
|
96
|
-
]
|
|
97
|
-
);
|
|
72
|
+
const [cellSize, setCellSize] = useState<number>(60);
|
|
73
|
+
|
|
74
|
+
const [tasks, setTasks] = useState<GanttTask[]>(initialTasks ?? []);
|
|
98
75
|
|
|
99
76
|
const [dragGhost, setDragGhost] = useState<null | {
|
|
100
77
|
taskId: string;
|
|
101
78
|
x: number;
|
|
102
79
|
y: number;
|
|
103
80
|
rowUser: string;
|
|
81
|
+
rowUserId: string;
|
|
104
82
|
}>(null);
|
|
105
83
|
|
|
106
84
|
const minuteWidth = useMemo(() => (cellSize > 0 ? cellSize / 60 : 1), [cellSize]);
|
|
107
85
|
const pxToMinutes = useCallback((px: number) => px / minuteWidth, [minuteWidth]);
|
|
108
86
|
const minutesToPx = useCallback((minutes: number) => minutes * minuteWidth, [minuteWidth]);
|
|
109
87
|
|
|
110
|
-
const
|
|
111
|
-
return Array.from({ length:
|
|
112
|
-
}, [startDate]);
|
|
88
|
+
const daysArray = useMemo(() => {
|
|
89
|
+
return Array.from({ length: totalDays }).map((_, i) => addDays(startDate, i));
|
|
90
|
+
}, [startDate, totalDays]);
|
|
91
|
+
|
|
92
|
+
// Keep track of all users that have ever been seen
|
|
93
|
+
const [allKnownUsers, setAllKnownUsers] = useState<Map<string, string>>(new Map());
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
// Update known users whenever tasks change
|
|
97
|
+
setAllKnownUsers((prevUsers) => {
|
|
98
|
+
const newUsers = new Map(prevUsers);
|
|
99
|
+
tasks.forEach((task) => {
|
|
100
|
+
const userName = task.user?.trim() || "";
|
|
101
|
+
const userId = task.userId || "";
|
|
102
|
+
if (!newUsers.has(userName)) {
|
|
103
|
+
newUsers.set(userName, userId);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return newUsers;
|
|
107
|
+
});
|
|
108
|
+
}, [tasks]);
|
|
109
|
+
|
|
110
|
+
// Group tasks by user and maintain all unique users
|
|
111
|
+
const groupedTasks = useMemo(() => {
|
|
112
|
+
const groups = new Map<string, GanttTask[]>();
|
|
113
|
+
|
|
114
|
+
// Initialize groups for all known users (including those without tasks)
|
|
115
|
+
allKnownUsers.forEach((_, userName) => {
|
|
116
|
+
groups.set(userName, []);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Populate groups with tasks
|
|
120
|
+
tasks.forEach((task) => {
|
|
121
|
+
const key = task.user?.trim() || "";
|
|
122
|
+
if (groups.has(key)) {
|
|
123
|
+
groups.get(key)!.push(task);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Sort so unassigned ("") comes first, and store userId alongside
|
|
128
|
+
const sorted = Array.from(groups.entries())
|
|
129
|
+
.map(([userName, tasks]) => {
|
|
130
|
+
const userId = allKnownUsers.get(userName) || "";
|
|
131
|
+
return { userName, userId, tasks };
|
|
132
|
+
})
|
|
133
|
+
.sort((a, b) => {
|
|
134
|
+
if (!a.userName) return -1;
|
|
135
|
+
if (!b.userName) return 1;
|
|
136
|
+
return a.userName.localeCompare(b.userName);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return sorted;
|
|
140
|
+
}, [tasks, allKnownUsers]);
|
|
141
|
+
|
|
142
|
+
// Helper to check if a specific time slot is within working hours
|
|
143
|
+
const isWithinWorkingHours = useCallback(
|
|
144
|
+
(userId: string, date: Date, hour: number): boolean => {
|
|
145
|
+
if (!workingHours || workingHours.length === 0) return true;
|
|
146
|
+
|
|
147
|
+
const dateStr = format(date, "yyyy-MM-dd");
|
|
148
|
+
const userWorkingHours = workingHours.filter(
|
|
149
|
+
(wh) => wh.userId === userId && wh.date === dateStr
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (userWorkingHours.length === 0) return true; // No restrictions for this user/date
|
|
153
|
+
|
|
154
|
+
return userWorkingHours.some((wh) => {
|
|
155
|
+
const [startHour, startMin] = wh.startTime.split(":").map(Number);
|
|
156
|
+
const [endHour, endMin] = wh.endTime.split(":").map(Number);
|
|
157
|
+
const startMinutes = startHour * 60 + startMin;
|
|
158
|
+
const endMinutes = endHour * 60 + endMin;
|
|
159
|
+
const currentMinutes = hour * 60;
|
|
160
|
+
|
|
161
|
+
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
[workingHours]
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Check if a time range is valid for a user (all minutes within working hours)
|
|
168
|
+
const isTimeRangeValid = useCallback(
|
|
169
|
+
(userId: string, startTime: Date, endTime: Date): boolean => {
|
|
170
|
+
if (!workingHours || workingHours.length === 0) return true;
|
|
171
|
+
|
|
172
|
+
const startMinutes = dateToMinuteIndex(startOfDay(startTime), startTime);
|
|
173
|
+
const endMinutes = dateToMinuteIndex(startOfDay(endTime), endTime);
|
|
174
|
+
const dateStr = format(startTime, "yyyy-MM-dd");
|
|
175
|
+
|
|
176
|
+
const userWorkingHours = workingHours.filter(
|
|
177
|
+
(wh) => wh.userId === userId && wh.date === dateStr
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (userWorkingHours.length === 0) return true;
|
|
181
|
+
|
|
182
|
+
// Check if entire range falls within any working hours period
|
|
183
|
+
return userWorkingHours.some((wh) => {
|
|
184
|
+
const [startHour, startMin] = wh.startTime.split(":").map(Number);
|
|
185
|
+
const [endHour, endMin] = wh.endTime.split(":").map(Number);
|
|
186
|
+
const whStartMinutes = startHour * 60 + startMin;
|
|
187
|
+
const whEndMinutes = endHour * 60 + endMin;
|
|
188
|
+
|
|
189
|
+
return startMinutes >= whStartMinutes && endMinutes <= whEndMinutes;
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
[workingHours]
|
|
193
|
+
);
|
|
113
194
|
|
|
114
195
|
useEffect(() => {
|
|
115
196
|
const updateSize = () => {
|
|
116
|
-
const firstHour = headerHoursRef.current?.querySelector
|
|
197
|
+
const firstHour = headerHoursRef.current?.querySelector(`.${styles.hour}`);
|
|
117
198
|
if (firstHour) {
|
|
118
|
-
setCellSize(firstHour.offsetWidth);
|
|
199
|
+
setCellSize((firstHour as HTMLElement).offsetWidth);
|
|
119
200
|
}
|
|
120
201
|
};
|
|
121
202
|
|
|
@@ -124,12 +205,6 @@ export default function GanttChart({ tasks: initialTasks, onChange }: GanttChart
|
|
|
124
205
|
return () => window.removeEventListener("resize", updateSize);
|
|
125
206
|
}, []);
|
|
126
207
|
|
|
127
|
-
// Keep consumer in sync without stale closures
|
|
128
|
-
useEffect(() => {
|
|
129
|
-
if (onChange) onChange(tasks);
|
|
130
|
-
}, [tasks, onChange]);
|
|
131
|
-
|
|
132
|
-
// Drag state
|
|
133
208
|
const dragState = useRef<null | {
|
|
134
209
|
mode: "move" | "resize-start" | "resize-end";
|
|
135
210
|
taskId: string;
|
|
@@ -137,17 +212,35 @@ export default function GanttChart({ tasks: initialTasks, onChange }: GanttChart
|
|
|
137
212
|
originLeftMin: number;
|
|
138
213
|
originRightMin: number;
|
|
139
214
|
originUser?: string;
|
|
215
|
+
originalTask?: GanttTask;
|
|
140
216
|
}>(null);
|
|
141
217
|
|
|
142
|
-
function getRowInfoFromPoint(x: number, y: number): { user: string; daysEl: HTMLElement | null } {
|
|
218
|
+
function getRowInfoFromPoint(x: number, y: number): { user: string; userId: string; daysEl: HTMLElement | null } {
|
|
143
219
|
const el = document.elementFromPoint(x, y) as HTMLElement | null;
|
|
144
|
-
const row = el?.closest(
|
|
145
|
-
if (!row) return { user: "", daysEl: null };
|
|
220
|
+
const row = el?.closest(`.${styles.ganttRow}`) as HTMLElement | null;
|
|
221
|
+
if (!row) return { user: "", userId: "", daysEl: null };
|
|
222
|
+
|
|
223
|
+
const label = row.querySelector(`.${styles.taskColLabel}`)?.textContent?.trim();
|
|
224
|
+
const user = label && label !== "Unassigned" ? label : "";
|
|
225
|
+
const userId = row.getAttribute("data-user-id") || "";
|
|
226
|
+
const daysEl = row.querySelector(`.${styles.days}`) as HTMLElement | null;
|
|
227
|
+
return { user, userId, daysEl };
|
|
228
|
+
}
|
|
146
229
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
230
|
+
// Check if a task overlaps with any other tasks for the same user
|
|
231
|
+
function checkOverlap(taskId: string, user: string, newStart: Date, newEnd: Date): boolean {
|
|
232
|
+
if (!preventOverlap) return false;
|
|
233
|
+
|
|
234
|
+
return tasks.some((t) => {
|
|
235
|
+
if (t.id === taskId) return false; // Don't check against itself
|
|
236
|
+
if ((t.user?.trim() || "") !== user) return false; // Only check same user
|
|
237
|
+
|
|
238
|
+
const tStart = new Date(t.start);
|
|
239
|
+
const tEnd = new Date(t.end);
|
|
240
|
+
|
|
241
|
+
// Check if there's any overlap
|
|
242
|
+
return newStart < tEnd && newEnd > tStart;
|
|
243
|
+
});
|
|
151
244
|
}
|
|
152
245
|
|
|
153
246
|
const onPointerDownBar = (
|
|
@@ -156,6 +249,7 @@ export default function GanttChart({ tasks: initialTasks, onChange }: GanttChart
|
|
|
156
249
|
mode: "move" | "resize-start" | "resize-end"
|
|
157
250
|
) => {
|
|
158
251
|
e.preventDefault();
|
|
252
|
+
e.stopPropagation();
|
|
159
253
|
const task = tasks.find((t) => t.id === taskId);
|
|
160
254
|
if (!task) return;
|
|
161
255
|
|
|
@@ -172,53 +266,68 @@ export default function GanttChart({ tasks: initialTasks, onChange }: GanttChart
|
|
|
172
266
|
originLeftMin: dateToMinuteIndex(startDate, taskStart),
|
|
173
267
|
originRightMin: dateToMinuteIndex(startDate, taskEnd),
|
|
174
268
|
originUser: task.user ?? "",
|
|
269
|
+
originalTask: { ...task },
|
|
175
270
|
};
|
|
176
271
|
|
|
177
272
|
if (mode === "move") {
|
|
178
|
-
setDragGhost({ taskId, x: e.clientX, y: e.clientY, rowUser: task.user ?? "" });
|
|
273
|
+
setDragGhost({ taskId, x: e.clientX, y: e.clientY, rowUser: task.user ?? "", rowUserId: task.userId ?? "" });
|
|
179
274
|
}
|
|
180
275
|
|
|
181
276
|
const onPointerMove = (ev: PointerEvent) => {
|
|
182
277
|
if (!dragState.current) return;
|
|
183
278
|
if (minuteWidth <= 0) return;
|
|
184
279
|
const ds = dragState.current;
|
|
185
|
-
const dx = ev.clientX - ds.originX;
|
|
186
|
-
const deltaMinutes = Math.round(pxToMinutes(dx));
|
|
187
280
|
|
|
188
281
|
if (ds.mode === "move") {
|
|
189
|
-
const { user: hoverUser } = getRowInfoFromPoint(ev.clientX, ev.clientY);
|
|
282
|
+
const { user: hoverUser, userId: hoverUserId } = getRowInfoFromPoint(ev.clientX, ev.clientY);
|
|
190
283
|
setDragGhost((ghost) =>
|
|
191
|
-
ghost ? { ...ghost, x: ev.clientX, y: ev.clientY, rowUser: hoverUser } : null
|
|
284
|
+
ghost ? { ...ghost, x: ev.clientX, y: ev.clientY, rowUser: hoverUser, rowUserId: hoverUserId } : null
|
|
192
285
|
);
|
|
193
|
-
}
|
|
286
|
+
} else {
|
|
287
|
+
// Only update task position for resize operations
|
|
288
|
+
const dx = ev.clientX - ds.originX;
|
|
289
|
+
const deltaMinutes = Math.round(pxToMinutes(dx));
|
|
194
290
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (ds.mode === "move") {
|
|
200
|
-
const widthMinutes = minutesBetween(new Date(t.start), new Date(t.end));
|
|
201
|
-
const newLeft = clamp(ds.originLeftMin + deltaMinutes, 0, totalDays * 24 * 60 - widthMinutes);
|
|
202
|
-
const newStart = addMinutes(startDate, newLeft);
|
|
203
|
-
const newEnd = addMinutes(startDate, newLeft + widthMinutes);
|
|
204
|
-
return {
|
|
205
|
-
...t,
|
|
206
|
-
start: formatISO(newStart, { representation: "complete" }),
|
|
207
|
-
end: formatISO(newEnd, { representation: "complete" }),
|
|
208
|
-
};
|
|
209
|
-
}
|
|
291
|
+
setTasks((prev) =>
|
|
292
|
+
prev.map((t) => {
|
|
293
|
+
if (t.id !== ds.taskId) return t;
|
|
210
294
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
295
|
+
if (ds.mode === "resize-start") {
|
|
296
|
+
const newLeft = clamp(ds.originLeftMin + deltaMinutes, 0, ds.originRightMin - 1);
|
|
297
|
+
const newStart = addMinutes(startDate, newLeft);
|
|
298
|
+
const currentEnd = new Date(t.end);
|
|
216
299
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
300
|
+
// Check working hours
|
|
301
|
+
if (!isTimeRangeValid(t.userId || "", newStart, currentEnd)) {
|
|
302
|
+
return t;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Check for overlap
|
|
306
|
+
if (checkOverlap(t.id, t.user?.trim() || "", newStart, currentEnd)) {
|
|
307
|
+
return t; // Don't update if it would cause overlap
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { ...t, start: formatISO(newStart, { representation: "complete" }) };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const newRight = clamp(ds.originRightMin + deltaMinutes, ds.originLeftMin + 1, totalDays * 24 * 60);
|
|
314
|
+
const newEnd = addMinutes(startDate, newRight);
|
|
315
|
+
const currentStart = new Date(t.start);
|
|
316
|
+
|
|
317
|
+
// Check working hours
|
|
318
|
+
if (!isTimeRangeValid(t.userId || "", currentStart, newEnd)) {
|
|
319
|
+
return t;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Check for overlap
|
|
323
|
+
if (checkOverlap(t.id, t.user?.trim() || "", currentStart, newEnd)) {
|
|
324
|
+
return t; // Don't update if it would cause overlap
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return { ...t, end: formatISO(newEnd, { representation: "complete" }) };
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
}
|
|
222
331
|
};
|
|
223
332
|
|
|
224
333
|
const onPointerUp = (ev: PointerEvent) => {
|
|
@@ -226,30 +335,98 @@ export default function GanttChart({ tasks: initialTasks, onChange }: GanttChart
|
|
|
226
335
|
target.releasePointerCapture(e.pointerId);
|
|
227
336
|
} catch {}
|
|
228
337
|
|
|
229
|
-
const { user: dropUser, daysEl } = dragGhost
|
|
230
|
-
? getRowInfoFromPoint(dragGhost.x, dragGhost.y)
|
|
231
|
-
: getRowInfoFromPoint(ev.clientX, ev.clientY);
|
|
232
|
-
|
|
233
338
|
const ds = dragState.current;
|
|
234
|
-
if (ds && daysEl) {
|
|
235
|
-
const rect = daysEl.getBoundingClientRect();
|
|
236
|
-
const dropX = (dragGhost?.x ?? ev.clientX) - rect.left;
|
|
237
|
-
const dropMinutes = Math.round(pxToMinutes(dropX));
|
|
238
339
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
340
|
+
if (ds && ds.mode === "move") {
|
|
341
|
+
const { user: dropUser, userId: dropUserId, daysEl } = dragGhost
|
|
342
|
+
? getRowInfoFromPoint(dragGhost.x, dragGhost.y)
|
|
343
|
+
: getRowInfoFromPoint(ev.clientX, ev.clientY);
|
|
344
|
+
|
|
345
|
+
if (daysEl) {
|
|
346
|
+
setTasks((prev) => {
|
|
347
|
+
const updatedTasks = prev.map((t) => {
|
|
348
|
+
if (t.id !== ds.taskId) return t;
|
|
349
|
+
|
|
350
|
+
if (lockTime) {
|
|
351
|
+
// Only update user assignment, keep original start/end times
|
|
352
|
+
const newUser = dropUser !== undefined ? dropUser : t.user;
|
|
353
|
+
const newUserId = dropUserId !== undefined ? dropUserId : t.userId;
|
|
354
|
+
|
|
355
|
+
// Check working hours for new user
|
|
356
|
+
if (!isTimeRangeValid(newUserId || "", new Date(t.start), new Date(t.end))) {
|
|
357
|
+
return t;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Check for overlap when moving to new user
|
|
361
|
+
if (checkOverlap(t.id, newUser?.trim() || "", new Date(t.start), new Date(t.end))) {
|
|
362
|
+
return t; // Don't update if it would cause overlap
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
...t,
|
|
367
|
+
user: newUser,
|
|
368
|
+
userId: newUserId,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Original behavior: update both time and user based on drop position
|
|
373
|
+
const rect = daysEl.getBoundingClientRect();
|
|
374
|
+
const dropX = (dragGhost?.x ?? ev.clientX) - rect.left;
|
|
375
|
+
const dropMinutes = Math.round(pxToMinutes(dropX));
|
|
376
|
+
const duration = minutesBetween(new Date(t.start), new Date(t.end));
|
|
377
|
+
const maxMinutes = totalDays * 24 * 60 - duration;
|
|
378
|
+
const newLeft = clamp(dropMinutes, 0, maxMinutes);
|
|
379
|
+
const newStart = addMinutes(startDate, newLeft);
|
|
380
|
+
const newEnd = addMinutes(newStart, duration);
|
|
381
|
+
const newUser = dropUser !== undefined ? dropUser : t.user;
|
|
382
|
+
const newUserId = dropUserId !== undefined ? dropUserId : t.userId;
|
|
383
|
+
|
|
384
|
+
// Check working hours
|
|
385
|
+
if (!isTimeRangeValid(newUserId || "", newStart, newEnd)) {
|
|
386
|
+
return t;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Check for overlap
|
|
390
|
+
if (checkOverlap(t.id, newUser?.trim() || "", newStart, newEnd)) {
|
|
391
|
+
return t; // Don't update if it would cause overlap
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
...t,
|
|
396
|
+
start: formatISO(newStart, { representation: "complete" }),
|
|
397
|
+
end: formatISO(newEnd, { representation: "complete" }),
|
|
398
|
+
user: newUser,
|
|
399
|
+
userId: newUserId,
|
|
400
|
+
};
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Call onChange with change details
|
|
404
|
+
if (onChange && ds.originalTask) {
|
|
405
|
+
const updatedTask = updatedTasks.find((t) => t.id === ds.taskId);
|
|
406
|
+
if (updatedTask) {
|
|
407
|
+
onChange(updatedTasks, {
|
|
408
|
+
task: updatedTask,
|
|
409
|
+
previousTask: ds.originalTask,
|
|
410
|
+
changeType: ds.mode,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return updatedTasks;
|
|
251
416
|
});
|
|
252
|
-
}
|
|
417
|
+
}
|
|
418
|
+
} else if (ds && (ds.mode === "resize-start" || ds.mode === "resize-end")) {
|
|
419
|
+
// For resize operations, call onChange
|
|
420
|
+
if (onChange && ds.originalTask) {
|
|
421
|
+
const currentTask = tasks.find((t) => t.id === ds.taskId);
|
|
422
|
+
if (currentTask) {
|
|
423
|
+
onChange(tasks, {
|
|
424
|
+
task: currentTask,
|
|
425
|
+
previousTask: ds.originalTask,
|
|
426
|
+
changeType: ds.mode,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
253
430
|
}
|
|
254
431
|
|
|
255
432
|
setDragGhost(null);
|
|
@@ -266,15 +443,15 @@ export default function GanttChart({ tasks: initialTasks, onChange }: GanttChart
|
|
|
266
443
|
const taskStart = new Date(task.start);
|
|
267
444
|
const taskEnd = new Date(task.end);
|
|
268
445
|
|
|
269
|
-
const
|
|
270
|
-
const
|
|
446
|
+
const rangeStart = startDate;
|
|
447
|
+
const rangeEnd = addDays(startDate, totalDays);
|
|
271
448
|
|
|
272
|
-
const displayStart = taskStart <
|
|
273
|
-
const displayEnd = taskEnd >
|
|
449
|
+
const displayStart = taskStart < rangeStart ? rangeStart : taskStart;
|
|
450
|
+
const displayEnd = taskEnd > rangeEnd ? rangeEnd : taskEnd;
|
|
274
451
|
|
|
275
452
|
if (displayStart >= displayEnd) return null;
|
|
276
453
|
|
|
277
|
-
const minutesFromStart = dateToMinuteIndex(
|
|
454
|
+
const minutesFromStart = dateToMinuteIndex(rangeStart, displayStart);
|
|
278
455
|
const minutesWidth = minutesBetween(displayStart, displayEnd);
|
|
279
456
|
|
|
280
457
|
const leftPx = minutesToPx(minutesFromStart);
|
|
@@ -282,7 +459,8 @@ export default function GanttChart({ tasks: initialTasks, onChange }: GanttChart
|
|
|
282
459
|
|
|
283
460
|
return (
|
|
284
461
|
<div
|
|
285
|
-
|
|
462
|
+
key={task.id}
|
|
463
|
+
className={styles.taskBar}
|
|
286
464
|
style={{
|
|
287
465
|
left: leftPx,
|
|
288
466
|
width: widthPx,
|
|
@@ -290,61 +468,75 @@ export default function GanttChart({ tasks: initialTasks, onChange }: GanttChart
|
|
|
290
468
|
opacity: dragGhost?.taskId === task.id ? 0.4 : 1,
|
|
291
469
|
}}
|
|
292
470
|
>
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
<div onPointerDown={(e) => onPointerDownBar(e, task.id, "move")} className={"bar-drag"}>
|
|
471
|
+
<div className={styles.bar}>
|
|
472
|
+
<div onPointerDown={(e) => onPointerDownBar(e, task.id, "move")} className={styles.barDrag}>
|
|
296
473
|
<DotsSixVertical />
|
|
297
474
|
</div>
|
|
298
475
|
<span>{task.name}</span>
|
|
299
476
|
</div>
|
|
300
|
-
{/* <div className="resize-handle right" onPointerDown={(e) => onPointerDownBar(e, task.id, "resize-end")} /> */}
|
|
301
477
|
</div>
|
|
302
478
|
);
|
|
303
479
|
};
|
|
304
480
|
|
|
305
481
|
return (
|
|
306
482
|
<>
|
|
307
|
-
<div className=
|
|
308
|
-
<
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
</div>
|
|
312
|
-
|
|
313
|
-
<div className="gantt-chart">
|
|
314
|
-
<div className="gantt-header">
|
|
315
|
-
<div className="task-column"></div>
|
|
316
|
-
<div className="days">
|
|
317
|
-
<div className="date-day-view current-day">
|
|
318
|
-
<div className="single-date">{format(currentDay, "MM/dd")}</div>
|
|
319
|
-
<div className="hours" ref={headerHoursRef}>
|
|
320
|
-
{Array.from({ length: 24 }).map((_, h) => (
|
|
321
|
-
<div className="hour" key={h}>
|
|
322
|
-
{`${String(h).padStart(2, "0")}:00`}
|
|
323
|
-
</div>
|
|
324
|
-
))}
|
|
325
|
-
</div>
|
|
326
|
-
</div>
|
|
483
|
+
<div className={styles.monthControls}>
|
|
484
|
+
<div className={styles.monthControlLeft}>
|
|
485
|
+
<div className={styles.prevBtn} onClick={() => setCurrentDay((d) => addDays(d, -1))}>
|
|
486
|
+
<CaretLeft />
|
|
327
487
|
</div>
|
|
488
|
+
<div className={styles.nxtBtn} onClick={() => setCurrentDay((d) => addDays(d, 1))}>
|
|
489
|
+
<CaretRight />
|
|
490
|
+
</div>
|
|
491
|
+
<span className={styles.period}>{format(currentDay, "MMMM dd, yyyy")}</span>
|
|
328
492
|
</div>
|
|
493
|
+
<div className={styles.monthControlRight}>{dropdown}</div>
|
|
494
|
+
</div>
|
|
329
495
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
<div className=
|
|
340
|
-
<div className=
|
|
341
|
-
{
|
|
342
|
-
<div
|
|
496
|
+
<div className={styles.ganttChart}>
|
|
497
|
+
<div className={styles.ganttHeader}>
|
|
498
|
+
<div className={styles.taskCol}></div>
|
|
499
|
+
<div className={styles.days}>
|
|
500
|
+
{daysArray.map((day, idx) => (
|
|
501
|
+
<div
|
|
502
|
+
key={day.toISOString()}
|
|
503
|
+
className={`${styles.dateDayView} ${idx === 0 ? styles.currentDay : ""}`}
|
|
504
|
+
>
|
|
505
|
+
<div className={styles.singleDate}>{format(day, "MM/dd")}</div>
|
|
506
|
+
<div className={styles.hours} ref={idx === 0 ? headerHoursRef : null}>
|
|
507
|
+
{Array.from({ length: 24 }).map((_, h) => (
|
|
508
|
+
<div className={styles.hour} key={h}>
|
|
509
|
+
{`${String(h).padStart(2, "0")}:00`}
|
|
510
|
+
</div>
|
|
343
511
|
))}
|
|
344
|
-
{renderBar(task)}
|
|
345
512
|
</div>
|
|
346
513
|
</div>
|
|
347
514
|
))}
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
<div className={styles.ganttBody}>
|
|
519
|
+
{groupedTasks.map(({ userName, userId, tasks: userTasks }) => (
|
|
520
|
+
<div key={userName || "unassigned"} className={styles.ganttRow} data-user-id={userId}>
|
|
521
|
+
<div className={styles.taskColLabel}>{userName || "Unassigned"}</div>
|
|
522
|
+
<div className={styles.days}>
|
|
523
|
+
{daysArray.map((day) => (
|
|
524
|
+
<div key={day.toISOString()} className={styles.dayColumn}>
|
|
525
|
+
{Array.from({ length: 24 }).map((_, h) => {
|
|
526
|
+
const isWorking = isWithinWorkingHours(userId, day, h);
|
|
527
|
+
return (
|
|
528
|
+
<div
|
|
529
|
+
className={`${styles.dayHrs} ${!isWorking && workingHours ? styles.disabled : ""}`}
|
|
530
|
+
key={h}
|
|
531
|
+
/>
|
|
532
|
+
);
|
|
533
|
+
})}
|
|
534
|
+
</div>
|
|
535
|
+
))}
|
|
536
|
+
{userTasks.map((task) => renderBar(task))}
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
))}
|
|
348
540
|
</div>
|
|
349
541
|
</div>
|
|
350
542
|
|
|
@@ -354,15 +546,21 @@ export default function GanttChart({ tasks: initialTasks, onChange }: GanttChart
|
|
|
354
546
|
style={{
|
|
355
547
|
position: "fixed",
|
|
356
548
|
left: dragGhost.x,
|
|
357
|
-
top: dragGhost.y,
|
|
549
|
+
top: dragGhost.y - 12,
|
|
358
550
|
pointerEvents: "none",
|
|
359
|
-
opacity: 0.
|
|
551
|
+
opacity: 0.9,
|
|
360
552
|
background: tasks.find((t) => t.id === dragGhost.taskId)?.color ?? "gray",
|
|
361
|
-
padding: "
|
|
553
|
+
padding: "4px 8px",
|
|
362
554
|
height: "24px",
|
|
363
|
-
|
|
555
|
+
minWidth: "100px",
|
|
364
556
|
borderRadius: 4,
|
|
365
557
|
zIndex: 9999,
|
|
558
|
+
display: "flex",
|
|
559
|
+
alignItems: "center",
|
|
560
|
+
fontSize: "12px",
|
|
561
|
+
color: "white",
|
|
562
|
+
fontWeight: 500,
|
|
563
|
+
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
|
|
366
564
|
}}
|
|
367
565
|
>
|
|
368
566
|
<span>{tasks.find((t) => t.id === dragGhost.taskId)?.name}</span>
|
|
@@ -370,4 +568,6 @@ export default function GanttChart({ tasks: initialTasks, onChange }: GanttChart
|
|
|
370
568
|
)}
|
|
371
569
|
</>
|
|
372
570
|
);
|
|
373
|
-
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
export default GanttChart;
|