@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.
@@ -1,4 +1,11 @@
1
- import React, { useCallback, useMemo, useRef, useState, useEffect } from "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
- // -------------------- Types --------------------
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; // ISO
19
- end: string; // ISO
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
- // -------------------- Component --------------------
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 = 1;
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); // default aligns with CSS min-width
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 timeUnits = useMemo(() => {
111
- return Array.from({ length: 24 }).map((_, h) => addMinutes(startDate, h * 60));
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<HTMLDivElement>(".hour");
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(".gantt-row") as HTMLElement | null;
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
- const label = row.querySelector(".task-column-label")?.textContent?.trim();
148
- const user = label && label !== "Unassigned" ? label : ""; // empty string is our Unassigned key
149
- const daysEl = row.querySelector(".days") as HTMLElement | null;
150
- return { user, daysEl };
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
- setTasks((prev) =>
196
- prev.map((t) => {
197
- if (t.id !== ds.taskId) return t;
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
- if (ds.mode === "resize-start") {
212
- const newLeft = clamp(ds.originLeftMin + deltaMinutes, 0, ds.originRightMin - 1);
213
- const newStart = addMinutes(startDate, newLeft);
214
- return { ...t, start: formatISO(newStart, { representation: "complete" }) };
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
- const newRight = clamp(ds.originRightMin + deltaMinutes, ds.originLeftMin + 1, totalDays * 24 * 60);
218
- const newEnd = addMinutes(startDate, newRight);
219
- return { ...t, end: formatISO(newEnd, { representation: "complete" }) };
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
- setTasks((prev) => {
240
- return prev.map((t) => {
241
- if (t.id !== ds.taskId) return t;
242
- const duration = minutesBetween(new Date(t.start), new Date(t.end));
243
- const newStart = addMinutes(startDate, clamp(dropMinutes, 0, 24 * 60 - duration));
244
- const newEnd = addMinutes(newStart, duration);
245
- return {
246
- ...t,
247
- start: formatISO(newStart, { representation: "complete" }),
248
- end: formatISO(newEnd, { representation: "complete" }),
249
- user: dropUser ?? t.user,
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 dayStart = startOfDay(currentDay);
270
- const dayEnd = addMinutes(dayStart, 24 * 60);
446
+ const rangeStart = startDate;
447
+ const rangeEnd = addDays(startDate, totalDays);
271
448
 
272
- const displayStart = taskStart < dayStart ? dayStart : taskStart;
273
- const displayEnd = taskEnd > dayEnd ? dayEnd : 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(dayStart, displayStart);
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
- className="task-bar"
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
- {/* <div className="resize-handle left" onPointerDown={(e) => onPointerDownBar(e, task.id, "resize-start")} /> */}
294
- <div className="bar">
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="month-controls">
308
- <button onClick={() => setCurrentDay((d) => addDays(d, -1))}>Prev</button>
309
- <span>{format(currentDay, "MMMM dd, yyyy")}</span>
310
- <button onClick={() => setCurrentDay((d) => addDays(d, 1))}>Next</button>
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
- <div className="gantt-body">
331
- {[...tasks]
332
- .sort((a, b) => {
333
- if (!a.user) return -1;
334
- if (!b.user) return 1;
335
- return 0;
336
- })
337
- .map((task) => (
338
- <div key={task.id} className="gantt-row">
339
- <div className="task-column-label">{task.user?.trim() || "Unassigned"}</div>
340
- <div className="days">
341
- {timeUnits.map((d) => (
342
- <div date-cell={d.toISOString()} key={d.toISOString()} />
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.8,
551
+ opacity: 0.9,
360
552
  background: tasks.find((t) => t.id === dragGhost.taskId)?.color ?? "gray",
361
- padding: "0px 6px",
553
+ padding: "4px 8px",
362
554
  height: "24px",
363
- width: "100px",
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;