@startsimpli/ui 0.2.0 → 0.4.0
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/package.json +4 -1
- package/src/components/gantt/GanttChart.tsx +1026 -0
- package/src/components/gantt/gantt.css +877 -0
- package/src/components/gantt/index.ts +19 -0
- package/src/components/gantt/lib/dates.ts +61 -0
- package/src/components/gantt/lib/progress.ts +39 -0
- package/src/components/gantt/types.ts +93 -0
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState, useRef, useEffect, useCallback } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
addDays,
|
|
6
|
+
differenceInDays,
|
|
7
|
+
format,
|
|
8
|
+
isWeekend,
|
|
9
|
+
isToday,
|
|
10
|
+
startOfDay,
|
|
11
|
+
endOfDay,
|
|
12
|
+
startOfMonth,
|
|
13
|
+
getMonth,
|
|
14
|
+
getWeek,
|
|
15
|
+
isAfter,
|
|
16
|
+
isBefore,
|
|
17
|
+
} from 'date-fns'
|
|
18
|
+
import type {
|
|
19
|
+
TimelineItem,
|
|
20
|
+
GanttTask,
|
|
21
|
+
GanttChartProps,
|
|
22
|
+
GanttFilterState,
|
|
23
|
+
GanttViewMode,
|
|
24
|
+
} from './types'
|
|
25
|
+
import { parseDateRangeFromTitle, getHierarchyLevel } from './lib/dates'
|
|
26
|
+
import { calculateExpectedProgress, calculateHealthStatus, getHealthColor } from './lib/progress'
|
|
27
|
+
|
|
28
|
+
const ROW_HEIGHT = 36
|
|
29
|
+
const ZOOM_LEVELS = [8, 12, 16, 24, 32, 48]
|
|
30
|
+
const DEFAULT_ZOOM_INDEX = 3
|
|
31
|
+
const INDENT_WIDTH = 20
|
|
32
|
+
|
|
33
|
+
const DEFAULT_CATEGORY_COLORS: Record<string, string> = {
|
|
34
|
+
financial: '#22c55e',
|
|
35
|
+
product: '#3b82f6',
|
|
36
|
+
team: '#a855f7',
|
|
37
|
+
market: '#f97316',
|
|
38
|
+
other: '#6b7280',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function defaultStatusToProgress(status: string): number {
|
|
42
|
+
if (status === 'completed') return 100
|
|
43
|
+
if (status === 'in_progress' || status === 'active' || status === 'on_track') return 50
|
|
44
|
+
return 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_FILTER_STATE: GanttFilterState = {
|
|
48
|
+
search: '',
|
|
49
|
+
statuses: [],
|
|
50
|
+
categories: [],
|
|
51
|
+
dateRange: { start: null, end: null },
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface DragState {
|
|
55
|
+
taskId: string
|
|
56
|
+
type: 'move' | 'resize-start' | 'resize-end'
|
|
57
|
+
startX: number
|
|
58
|
+
originalStart: Date
|
|
59
|
+
originalEnd: Date
|
|
60
|
+
currentStart: Date
|
|
61
|
+
currentEnd: Date
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function GanttChart({
|
|
65
|
+
items,
|
|
66
|
+
dependencies = [],
|
|
67
|
+
onItemClick,
|
|
68
|
+
onDateChange,
|
|
69
|
+
statusToProgress = defaultStatusToProgress,
|
|
70
|
+
categoryColors = DEFAULT_CATEGORY_COLORS,
|
|
71
|
+
hierarchical = true,
|
|
72
|
+
initialZoom = DEFAULT_ZOOM_INDEX,
|
|
73
|
+
className,
|
|
74
|
+
infoColumnLabel = 'Item',
|
|
75
|
+
infoColumnWidth = 320,
|
|
76
|
+
showCategory = true,
|
|
77
|
+
showStatus = true,
|
|
78
|
+
showFilterBar = false,
|
|
79
|
+
showFullscreen = false,
|
|
80
|
+
showViewSwitcher = false,
|
|
81
|
+
initialViewMode = 'timeline',
|
|
82
|
+
boardStatuses,
|
|
83
|
+
onItemEdit,
|
|
84
|
+
onStatusChange,
|
|
85
|
+
persistCollapseKey,
|
|
86
|
+
}: GanttChartProps) {
|
|
87
|
+
const [zoomIndex, setZoomIndex] = useState(initialZoom)
|
|
88
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
|
89
|
+
if (persistCollapseKey && typeof window !== 'undefined') {
|
|
90
|
+
try {
|
|
91
|
+
const saved = localStorage.getItem(`gantt-collapse-${persistCollapseKey}`)
|
|
92
|
+
if (saved) return new Set(JSON.parse(saved))
|
|
93
|
+
} catch { /* ignore */ }
|
|
94
|
+
}
|
|
95
|
+
return new Set()
|
|
96
|
+
})
|
|
97
|
+
const [mounted, setMounted] = useState(false)
|
|
98
|
+
const [dragState, setDragState] = useState<DragState | null>(null)
|
|
99
|
+
const [filters, setFilters] = useState<GanttFilterState>(DEFAULT_FILTER_STATE)
|
|
100
|
+
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
101
|
+
const [viewMode, setViewMode] = useState<GanttViewMode>(initialViewMode)
|
|
102
|
+
const [detailItem, setDetailItem] = useState<TimelineItem | null>(null)
|
|
103
|
+
const [focusedRowIndex, setFocusedRowIndex] = useState(-1)
|
|
104
|
+
const [boardDragItem, setBoardDragItem] = useState<string | null>(null)
|
|
105
|
+
|
|
106
|
+
const dayWidth = ZOOM_LEVELS[zoomIndex]
|
|
107
|
+
const bodyScrollRef = useRef<HTMLDivElement>(null)
|
|
108
|
+
const headerTimelineRef = useRef<HTMLDivElement>(null)
|
|
109
|
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
110
|
+
const infoColumnRef = useRef<HTMLDivElement>(null)
|
|
111
|
+
|
|
112
|
+
useEffect(() => { setMounted(true) }, [])
|
|
113
|
+
|
|
114
|
+
// Persist collapse state to localStorage
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (persistCollapseKey && typeof window !== 'undefined') {
|
|
117
|
+
localStorage.setItem(`gantt-collapse-${persistCollapseKey}`, JSON.stringify([...collapsed]))
|
|
118
|
+
}
|
|
119
|
+
}, [collapsed, persistCollapseKey])
|
|
120
|
+
|
|
121
|
+
// Fullscreen change listener
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const handleFullscreenChange = () => {
|
|
124
|
+
setIsFullscreen(!!document.fullscreenElement)
|
|
125
|
+
}
|
|
126
|
+
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
|
127
|
+
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
128
|
+
}, [])
|
|
129
|
+
|
|
130
|
+
const toggleFullscreen = useCallback(() => {
|
|
131
|
+
if (!wrapperRef.current) return
|
|
132
|
+
if (document.fullscreenElement) {
|
|
133
|
+
document.exitFullscreen()
|
|
134
|
+
} else {
|
|
135
|
+
wrapperRef.current.requestFullscreen()
|
|
136
|
+
}
|
|
137
|
+
}, [])
|
|
138
|
+
|
|
139
|
+
// Handle item click — open detail modal if onItemEdit is provided, otherwise delegate to onItemClick
|
|
140
|
+
const handleItemClick = useCallback((item: TimelineItem) => {
|
|
141
|
+
if (onItemEdit) {
|
|
142
|
+
setDetailItem(item)
|
|
143
|
+
}
|
|
144
|
+
onItemClick?.(item)
|
|
145
|
+
}, [onItemEdit, onItemClick])
|
|
146
|
+
|
|
147
|
+
// Extract unique statuses and categories from items for filter dropdowns
|
|
148
|
+
const { uniqueStatuses, uniqueCategories } = useMemo(() => {
|
|
149
|
+
const statuses = new Set<string>()
|
|
150
|
+
const categories = new Set<string>()
|
|
151
|
+
const collect = (itemList: TimelineItem[]) => {
|
|
152
|
+
for (const item of itemList) {
|
|
153
|
+
statuses.add(item.status)
|
|
154
|
+
if (item.category) categories.add(item.category)
|
|
155
|
+
if (item.children) collect(item.children)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
collect(items)
|
|
159
|
+
return { uniqueStatuses: [...statuses].sort(), uniqueCategories: [...categories].sort() }
|
|
160
|
+
}, [items])
|
|
161
|
+
|
|
162
|
+
// Filter items
|
|
163
|
+
const filteredItems = useMemo(() => {
|
|
164
|
+
if (!showFilterBar) return items
|
|
165
|
+
const { search, statuses, categories, dateRange } = filters
|
|
166
|
+
const hasFilters = search || statuses.length > 0 || categories.length > 0 || dateRange.start || dateRange.end
|
|
167
|
+
if (!hasFilters) return items
|
|
168
|
+
|
|
169
|
+
const searchLower = search.toLowerCase()
|
|
170
|
+
|
|
171
|
+
const matchesItem = (item: TimelineItem): boolean => {
|
|
172
|
+
if (search && !item.title.toLowerCase().includes(searchLower) && !item.description?.toLowerCase().includes(searchLower)) return false
|
|
173
|
+
if (statuses.length > 0 && !statuses.includes(item.status)) return false
|
|
174
|
+
if (categories.length > 0 && (!item.category || !categories.includes(item.category))) return false
|
|
175
|
+
if (dateRange.start || dateRange.end) {
|
|
176
|
+
const itemEnd = item.end_date ? new Date(item.end_date) : null
|
|
177
|
+
const itemStart = item.start_date ? new Date(item.start_date) : null
|
|
178
|
+
if (dateRange.start && itemEnd && isBefore(itemEnd, dateRange.start)) return false
|
|
179
|
+
if (dateRange.end && itemStart && isAfter(itemStart, dateRange.end)) return false
|
|
180
|
+
}
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const filterTree = (itemList: TimelineItem[]): TimelineItem[] => {
|
|
185
|
+
const result: TimelineItem[] = []
|
|
186
|
+
for (const item of itemList) {
|
|
187
|
+
const filteredChildren = item.children ? filterTree(item.children) : undefined
|
|
188
|
+
if (matchesItem(item) || (filteredChildren && filteredChildren.length > 0)) {
|
|
189
|
+
result.push({ ...item, children: filteredChildren })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return result
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return filterTree(items)
|
|
196
|
+
}, [items, filters, showFilterBar])
|
|
197
|
+
|
|
198
|
+
const hasActiveFilters = filters.search || filters.statuses.length > 0 || filters.categories.length > 0 || filters.dateRange.start || filters.dateRange.end
|
|
199
|
+
|
|
200
|
+
const toggleCollapse = (id: string) => {
|
|
201
|
+
setCollapsed((prev) => {
|
|
202
|
+
const next = new Set(prev)
|
|
203
|
+
if (next.has(id)) next.delete(id)
|
|
204
|
+
else next.add(id)
|
|
205
|
+
return next
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Build hierarchy from children[] arrays or dependencies
|
|
210
|
+
const { childrenMap, parentMap, itemMap } = useMemo(() => {
|
|
211
|
+
const childrenMap = new Map<string, string[]>()
|
|
212
|
+
const parentMap = new Map<string, string>()
|
|
213
|
+
const itemMap = new Map(filteredItems.map((item) => [item.id, item]))
|
|
214
|
+
|
|
215
|
+
if (hierarchical) {
|
|
216
|
+
for (const item of filteredItems) {
|
|
217
|
+
if (item.children && item.children.length > 0) {
|
|
218
|
+
const childIds = item.children
|
|
219
|
+
.filter((child) => itemMap.has(child.id))
|
|
220
|
+
.map((child) => child.id)
|
|
221
|
+
if (childIds.length > 0) {
|
|
222
|
+
childrenMap.set(item.id, childIds)
|
|
223
|
+
for (const childId of childIds) {
|
|
224
|
+
parentMap.set(childId, item.id)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const dep of dependencies) {
|
|
231
|
+
if (dep.type === 'blocks') {
|
|
232
|
+
const parentItem = itemMap.get(dep.from_id)
|
|
233
|
+
const childItem = itemMap.get(dep.to_id)
|
|
234
|
+
if (!parentItem || !childItem) continue
|
|
235
|
+
const parentLevel = getHierarchyLevel(parentItem.title)
|
|
236
|
+
const childLevel = getHierarchyLevel(childItem.title)
|
|
237
|
+
if (parentLevel > childLevel && !parentMap.has(dep.to_id)) {
|
|
238
|
+
const children = childrenMap.get(dep.from_id) || []
|
|
239
|
+
children.push(dep.to_id)
|
|
240
|
+
childrenMap.set(dep.from_id, children)
|
|
241
|
+
parentMap.set(dep.to_id, dep.from_id)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { childrenMap, parentMap, itemMap }
|
|
248
|
+
}, [filteredItems, dependencies, hierarchical])
|
|
249
|
+
|
|
250
|
+
// Parse dates for all items
|
|
251
|
+
const itemDates = useMemo(() => {
|
|
252
|
+
const now = new Date()
|
|
253
|
+
const currentYear = now.getFullYear()
|
|
254
|
+
const dates = new Map<string, { start: Date; end: Date }>()
|
|
255
|
+
|
|
256
|
+
for (const item of filteredItems) {
|
|
257
|
+
const parsedRange = parseDateRangeFromTitle(item.title, currentYear)
|
|
258
|
+
if (parsedRange) { dates.set(item.id, parsedRange); continue }
|
|
259
|
+
|
|
260
|
+
const startStr = item.start_date
|
|
261
|
+
const endStr = item.end_date
|
|
262
|
+
|
|
263
|
+
if (startStr && endStr) {
|
|
264
|
+
const s = new Date(startStr)
|
|
265
|
+
const e = new Date(endStr)
|
|
266
|
+
if (!isNaN(s.getTime()) && !isNaN(e.getTime())) {
|
|
267
|
+
dates.set(item.id, { start: startOfDay(s), end: endOfDay(e) }); continue
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (endStr) {
|
|
272
|
+
const e = new Date(endStr)
|
|
273
|
+
if (!isNaN(e.getTime())) {
|
|
274
|
+
const s = item.start_date ? new Date(item.start_date) :
|
|
275
|
+
item.created_at ? new Date(item.created_at) : addDays(e, -14)
|
|
276
|
+
dates.set(item.id, { start: startOfDay(s), end: endOfDay(e) }); continue
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (startStr) {
|
|
281
|
+
const s = new Date(startStr)
|
|
282
|
+
if (!isNaN(s.getTime())) {
|
|
283
|
+
dates.set(item.id, { start: startOfDay(s), end: endOfDay(addDays(s, 14)) }); continue
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const fallbackStart = item.created_at ? new Date(item.created_at) : now
|
|
288
|
+
dates.set(item.id, { start: startOfDay(fallbackStart), end: endOfDay(addDays(fallbackStart, 14)) })
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return dates
|
|
292
|
+
}, [filteredItems])
|
|
293
|
+
|
|
294
|
+
// Build flattened task tree
|
|
295
|
+
const tasks = useMemo(() => {
|
|
296
|
+
const roots: string[] = []
|
|
297
|
+
for (const item of filteredItems) {
|
|
298
|
+
const parentId = parentMap.get(item.id)
|
|
299
|
+
if (!parentId || !itemMap.has(parentId)) roots.push(item.id)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const sortByDate = (ids: string[]) => [...ids].sort((a, b) => {
|
|
303
|
+
const dateA = itemDates.get(a)?.start || new Date()
|
|
304
|
+
const dateB = itemDates.get(b)?.start || new Date()
|
|
305
|
+
return dateA.getTime() - dateB.getTime()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
const taskList: GanttTask[] = []
|
|
309
|
+
const visited = new Set<string>()
|
|
310
|
+
|
|
311
|
+
const traverse = (id: string, depth: number) => {
|
|
312
|
+
if (visited.has(id)) return
|
|
313
|
+
visited.add(id)
|
|
314
|
+
const item = itemMap.get(id)
|
|
315
|
+
if (!item) return
|
|
316
|
+
const dates = itemDates.get(id)
|
|
317
|
+
if (!dates) return
|
|
318
|
+
|
|
319
|
+
const children = childrenMap.get(id) || []
|
|
320
|
+
const hasChildren = children.length > 0
|
|
321
|
+
const progress = item.progress ?? statusToProgress(item.status)
|
|
322
|
+
|
|
323
|
+
const now = new Date()
|
|
324
|
+
const taskStart = startOfDay(dates.start)
|
|
325
|
+
const taskEnd = endOfDay(dates.end)
|
|
326
|
+
const totalDuration = taskEnd.getTime() - taskStart.getTime()
|
|
327
|
+
const elapsed = now.getTime() - taskStart.getTime()
|
|
328
|
+
const timeProgress = totalDuration > 0
|
|
329
|
+
? Math.max(0, Math.min(100, (elapsed / totalDuration) * 100))
|
|
330
|
+
: 0
|
|
331
|
+
|
|
332
|
+
const expectedProgress = calculateExpectedProgress(taskStart, taskEnd, now)
|
|
333
|
+
const healthResult = calculateHealthStatus(progress, expectedProgress, item.status === 'blocked')
|
|
334
|
+
|
|
335
|
+
taskList.push({
|
|
336
|
+
item, start: taskStart, end: taskEnd, progress, timeProgress,
|
|
337
|
+
depth, hasChildren, parentId: parentMap.get(id) || null,
|
|
338
|
+
healthStatus: healthResult.status,
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
if (hasChildren && !collapsed.has(id)) {
|
|
342
|
+
for (const childId of sortByDate(children)) traverse(childId, depth + 1)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (const rootId of sortByDate(roots)) traverse(rootId, 0)
|
|
347
|
+
return taskList
|
|
348
|
+
}, [filteredItems, itemDates, itemMap, childrenMap, parentMap, collapsed, statusToProgress])
|
|
349
|
+
|
|
350
|
+
// Keyboard navigation
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
const wrapper = wrapperRef.current
|
|
353
|
+
if (!wrapper) return
|
|
354
|
+
|
|
355
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
356
|
+
if (!wrapper.contains(document.activeElement) && document.activeElement !== wrapper) return
|
|
357
|
+
|
|
358
|
+
if (e.key === 'ArrowDown') {
|
|
359
|
+
e.preventDefault()
|
|
360
|
+
setFocusedRowIndex((prev) => Math.min(prev + 1, tasks.length - 1))
|
|
361
|
+
} else if (e.key === 'ArrowUp') {
|
|
362
|
+
e.preventDefault()
|
|
363
|
+
setFocusedRowIndex((prev) => Math.max(prev - 1, 0))
|
|
364
|
+
} else if ((e.key === 'Enter' || e.key === ' ') && focusedRowIndex >= 0 && focusedRowIndex < tasks.length) {
|
|
365
|
+
e.preventDefault()
|
|
366
|
+
handleItemClick(tasks[focusedRowIndex].item)
|
|
367
|
+
} else if (e.key === 'Escape') {
|
|
368
|
+
if (detailItem) {
|
|
369
|
+
setDetailItem(null)
|
|
370
|
+
} else {
|
|
371
|
+
setFocusedRowIndex(-1)
|
|
372
|
+
wrapper.blur()
|
|
373
|
+
}
|
|
374
|
+
} else if (e.key === 'ArrowRight' && focusedRowIndex >= 0) {
|
|
375
|
+
const task = tasks[focusedRowIndex]
|
|
376
|
+
if (task?.hasChildren && collapsed.has(task.item.id)) {
|
|
377
|
+
toggleCollapse(task.item.id)
|
|
378
|
+
}
|
|
379
|
+
} else if (e.key === 'ArrowLeft' && focusedRowIndex >= 0) {
|
|
380
|
+
const task = tasks[focusedRowIndex]
|
|
381
|
+
if (task?.hasChildren && !collapsed.has(task.item.id)) {
|
|
382
|
+
toggleCollapse(task.item.id)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
wrapper.addEventListener('keydown', handleKeyDown)
|
|
388
|
+
return () => wrapper.removeEventListener('keydown', handleKeyDown)
|
|
389
|
+
}, [tasks, focusedRowIndex, collapsed, detailItem, handleItemClick, toggleCollapse])
|
|
390
|
+
|
|
391
|
+
// Scroll focused row into view
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
if (focusedRowIndex >= 0 && infoColumnRef.current) {
|
|
394
|
+
const row = infoColumnRef.current.children[focusedRowIndex] as HTMLElement
|
|
395
|
+
row?.scrollIntoView({ block: 'nearest' })
|
|
396
|
+
}
|
|
397
|
+
}, [focusedRowIndex])
|
|
398
|
+
|
|
399
|
+
// Calculate date range
|
|
400
|
+
const { startDate, days } = useMemo(() => {
|
|
401
|
+
const now = new Date()
|
|
402
|
+
const currentYear = now.getFullYear()
|
|
403
|
+
let minDate = new Date(currentYear, 0, 1)
|
|
404
|
+
let maxDate = new Date(currentYear, 11, 31)
|
|
405
|
+
|
|
406
|
+
for (const task of tasks) {
|
|
407
|
+
if (task.start < minDate) minDate = startOfDay(task.start)
|
|
408
|
+
if (task.end > maxDate) maxDate = endOfDay(task.end)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
minDate = startOfMonth(minDate)
|
|
412
|
+
maxDate = endOfDay(addDays(maxDate, 30))
|
|
413
|
+
const dayCount = differenceInDays(maxDate, minDate) + 1
|
|
414
|
+
const daysArr: Date[] = []
|
|
415
|
+
for (let i = 0; i < dayCount; i++) daysArr.push(addDays(minDate, i))
|
|
416
|
+
return { startDate: minDate, days: daysArr }
|
|
417
|
+
}, [tasks])
|
|
418
|
+
|
|
419
|
+
// Group days by month
|
|
420
|
+
const months = useMemo(() => {
|
|
421
|
+
const monthsArr: { month: Date; days: number; label: string }[] = []
|
|
422
|
+
let currentMonth = -1
|
|
423
|
+
let monthDays = 0
|
|
424
|
+
|
|
425
|
+
for (const day of days) {
|
|
426
|
+
const m = getMonth(day)
|
|
427
|
+
if (m !== currentMonth) {
|
|
428
|
+
if (currentMonth !== -1) {
|
|
429
|
+
monthsArr.push({ month: addDays(day, -1), days: monthDays, label: format(addDays(day, -1), 'MMM yyyy') })
|
|
430
|
+
}
|
|
431
|
+
currentMonth = m
|
|
432
|
+
monthDays = 1
|
|
433
|
+
} else { monthDays++ }
|
|
434
|
+
}
|
|
435
|
+
if (monthDays > 0 && days.length > 0) {
|
|
436
|
+
monthsArr.push({ month: days[days.length - 1], days: monthDays, label: format(days[days.length - 1], 'MMM yyyy') })
|
|
437
|
+
}
|
|
438
|
+
return monthsArr
|
|
439
|
+
}, [days])
|
|
440
|
+
|
|
441
|
+
// Time header units
|
|
442
|
+
const timeHeaderUnits = useMemo(() => {
|
|
443
|
+
if (dayWidth >= 16) {
|
|
444
|
+
return {
|
|
445
|
+
mode: 'days' as const,
|
|
446
|
+
units: days.map((day, i) => ({
|
|
447
|
+
date: day, width: dayWidth,
|
|
448
|
+
label: day.getDate() === 1 || i === 0 ? format(day, 'd') : String(day.getDate()),
|
|
449
|
+
isWeekend: isWeekend(day), isToday: mounted && isToday(day),
|
|
450
|
+
})),
|
|
451
|
+
}
|
|
452
|
+
} else if (dayWidth >= 12) {
|
|
453
|
+
return {
|
|
454
|
+
mode: 'sparse-days' as const,
|
|
455
|
+
units: days.map((day, i) => ({
|
|
456
|
+
date: day, width: dayWidth,
|
|
457
|
+
label: (day.getDay() === 1 || day.getDate() === 1 || i === 0) ? String(day.getDate()) : '',
|
|
458
|
+
isWeekend: isWeekend(day), isToday: mounted && isToday(day),
|
|
459
|
+
})),
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
const weeks: { startDate: Date; endDate: Date; days: number; label: string; hasToday: boolean }[] = []
|
|
463
|
+
let currentWeek = -1, weekDays = 0, weekStart: Date | null = null, weekHasToday = false
|
|
464
|
+
|
|
465
|
+
for (const day of days) {
|
|
466
|
+
const week = getWeek(day)
|
|
467
|
+
if (week !== currentWeek) {
|
|
468
|
+
if (currentWeek !== -1 && weekStart) {
|
|
469
|
+
weeks.push({ startDate: weekStart, endDate: addDays(day, -1), days: weekDays, label: format(weekStart, 'MMM d'), hasToday: mounted && weekHasToday })
|
|
470
|
+
}
|
|
471
|
+
currentWeek = week; weekDays = 1; weekStart = day; weekHasToday = mounted && isToday(day)
|
|
472
|
+
} else {
|
|
473
|
+
weekDays++
|
|
474
|
+
if (mounted && isToday(day)) weekHasToday = true
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (weekDays > 0 && weekStart) {
|
|
478
|
+
weeks.push({ startDate: weekStart, endDate: days[days.length - 1], days: weekDays, label: format(weekStart, 'MMM d'), hasToday: mounted && weekHasToday })
|
|
479
|
+
}
|
|
480
|
+
return { mode: 'weeks' as const, units: weeks.map((w) => ({ ...w, width: w.days * dayWidth })) }
|
|
481
|
+
}
|
|
482
|
+
}, [days, dayWidth, mounted])
|
|
483
|
+
|
|
484
|
+
// Scroll to today on mount
|
|
485
|
+
useEffect(() => {
|
|
486
|
+
if (bodyScrollRef.current) {
|
|
487
|
+
const today = new Date()
|
|
488
|
+
const daysFromStart = differenceInDays(today, startDate)
|
|
489
|
+
bodyScrollRef.current.scrollLeft = Math.max(0, daysFromStart * dayWidth - 200)
|
|
490
|
+
}
|
|
491
|
+
}, [startDate, dayWidth])
|
|
492
|
+
|
|
493
|
+
const handleBodyScroll = () => {
|
|
494
|
+
if (bodyScrollRef.current && headerTimelineRef.current) {
|
|
495
|
+
headerTimelineRef.current.scrollLeft = bodyScrollRef.current.scrollLeft
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const getBarPosition = (task: GanttTask, rowIndex: number, useDragDates = false) => {
|
|
500
|
+
const dates = useDragDates && dragState?.taskId === task.item.id
|
|
501
|
+
? { start: dragState.currentStart, end: dragState.currentEnd }
|
|
502
|
+
: { start: task.start, end: task.end }
|
|
503
|
+
const startOffset = differenceInDays(dates.start, startDate)
|
|
504
|
+
const duration = differenceInDays(dates.end, dates.start) + 1
|
|
505
|
+
const heights = [22, 20, 18, 16]
|
|
506
|
+
const barHeight = heights[Math.min(task.depth, 3)]
|
|
507
|
+
const topOffset = (ROW_HEIGHT - barHeight) / 2
|
|
508
|
+
return { left: startOffset * dayWidth, width: Math.max(duration * dayWidth - 2, 16), top: rowIndex * ROW_HEIGHT + topOffset, height: barHeight }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Drag handlers
|
|
512
|
+
const handleDragStart = (e: React.MouseEvent, task: GanttTask, type: 'move' | 'resize-start' | 'resize-end') => {
|
|
513
|
+
if (!onDateChange) return
|
|
514
|
+
e.preventDefault(); e.stopPropagation()
|
|
515
|
+
setDragState({ taskId: task.item.id, type, startX: e.clientX, originalStart: task.start, originalEnd: task.end, currentStart: task.start, currentEnd: task.end })
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
useEffect(() => {
|
|
519
|
+
if (!dragState) return
|
|
520
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
521
|
+
const deltaDays = Math.round((e.clientX - dragState.startX) / dayWidth)
|
|
522
|
+
if (deltaDays === 0 && dragState.currentStart.getTime() === dragState.originalStart.getTime()) return
|
|
523
|
+
let newStart = dragState.originalStart, newEnd = dragState.originalEnd
|
|
524
|
+
if (dragState.type === 'move') { newStart = addDays(dragState.originalStart, deltaDays); newEnd = addDays(dragState.originalEnd, deltaDays) }
|
|
525
|
+
else if (dragState.type === 'resize-start') { newStart = addDays(dragState.originalStart, deltaDays); if (newStart >= newEnd) newStart = addDays(newEnd, -1) }
|
|
526
|
+
else if (dragState.type === 'resize-end') { newEnd = addDays(dragState.originalEnd, deltaDays); if (newEnd <= newStart) newEnd = addDays(newStart, 1) }
|
|
527
|
+
setDragState((prev) => prev ? { ...prev, currentStart: newStart, currentEnd: newEnd } : null)
|
|
528
|
+
}
|
|
529
|
+
const handleMouseUp = () => {
|
|
530
|
+
if (dragState && onDateChange) {
|
|
531
|
+
const startChanged = dragState.currentStart.getTime() !== dragState.originalStart.getTime()
|
|
532
|
+
const endChanged = dragState.currentEnd.getTime() !== dragState.originalEnd.getTime()
|
|
533
|
+
if (startChanged || endChanged) onDateChange(dragState.taskId, dragState.currentStart, dragState.currentEnd)
|
|
534
|
+
}
|
|
535
|
+
setDragState(null)
|
|
536
|
+
}
|
|
537
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
538
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
539
|
+
return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp) }
|
|
540
|
+
}, [dragState, dayWidth, onDateChange])
|
|
541
|
+
|
|
542
|
+
// Dependency arrow paths
|
|
543
|
+
const dependencyPaths = useMemo(() => {
|
|
544
|
+
const paths: Array<{ path: string; fromId: string; toId: string }> = []
|
|
545
|
+
const taskIndexMap = new Map<string, number>()
|
|
546
|
+
tasks.forEach((task, index) => taskIndexMap.set(task.item.id, index))
|
|
547
|
+
|
|
548
|
+
for (const dep of dependencies) {
|
|
549
|
+
const fromIdx = taskIndexMap.get(dep.from_id)
|
|
550
|
+
const toIdx = taskIndexMap.get(dep.to_id)
|
|
551
|
+
if (fromIdx !== undefined && toIdx !== undefined) {
|
|
552
|
+
const fromPos = getBarPosition(tasks[fromIdx], fromIdx)
|
|
553
|
+
const toPos = getBarPosition(tasks[toIdx], toIdx)
|
|
554
|
+
const x1 = fromPos.left + fromPos.width, y1 = fromPos.top + fromPos.height / 2
|
|
555
|
+
const x2 = toPos.left, y2 = toPos.top + toPos.height / 2
|
|
556
|
+
const midX = (x1 + x2) / 2
|
|
557
|
+
paths.push({ path: `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`, fromId: dep.from_id, toId: dep.to_id })
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return paths
|
|
561
|
+
}, [tasks, dependencies, dayWidth, startDate])
|
|
562
|
+
|
|
563
|
+
const timelineWidth = days.length * dayWidth
|
|
564
|
+
const bodyHeight = tasks.length * ROW_HEIGHT
|
|
565
|
+
|
|
566
|
+
// Board view: group items by status
|
|
567
|
+
const boardColumns = useMemo(() => {
|
|
568
|
+
if (viewMode !== 'board') return []
|
|
569
|
+
const statuses = boardStatuses || uniqueStatuses
|
|
570
|
+
const columns: Array<{ status: string; items: TimelineItem[] }> = statuses.map((s) => ({ status: s, items: [] }))
|
|
571
|
+
const statusSet = new Set(statuses)
|
|
572
|
+
|
|
573
|
+
const flatItems = (itemList: TimelineItem[]): TimelineItem[] => {
|
|
574
|
+
const result: TimelineItem[] = []
|
|
575
|
+
for (const item of itemList) {
|
|
576
|
+
result.push(item)
|
|
577
|
+
if (item.children) result.push(...flatItems(item.children))
|
|
578
|
+
}
|
|
579
|
+
return result
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
for (const item of flatItems(filteredItems)) {
|
|
583
|
+
if (statusSet.has(item.status)) {
|
|
584
|
+
columns.find((c) => c.status === item.status)?.items.push(item)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return columns
|
|
588
|
+
}, [viewMode, filteredItems, boardStatuses, uniqueStatuses])
|
|
589
|
+
|
|
590
|
+
// List view: flatten all items
|
|
591
|
+
const listItems = useMemo(() => {
|
|
592
|
+
if (viewMode !== 'list') return []
|
|
593
|
+
return tasks.map((t) => t)
|
|
594
|
+
}, [viewMode, tasks])
|
|
595
|
+
|
|
596
|
+
if (tasks.length === 0 && filteredItems.length === 0) {
|
|
597
|
+
return (
|
|
598
|
+
<div className={`gantt-empty ${className || ''}`}>
|
|
599
|
+
<p>No items with dates to display</p>
|
|
600
|
+
</div>
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return (
|
|
605
|
+
<div ref={wrapperRef} className={`gantt-wrapper ${isFullscreen ? 'gantt-fullscreen' : ''} ${className || ''}`} tabIndex={0}>
|
|
606
|
+
{/* Filter bar */}
|
|
607
|
+
{showFilterBar && (
|
|
608
|
+
<div className="gantt-filter-bar">
|
|
609
|
+
<input
|
|
610
|
+
type="text"
|
|
611
|
+
className="gantt-filter-search"
|
|
612
|
+
placeholder="Search items..."
|
|
613
|
+
value={filters.search}
|
|
614
|
+
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
|
|
615
|
+
/>
|
|
616
|
+
{uniqueStatuses.length > 0 && (
|
|
617
|
+
<select
|
|
618
|
+
className="gantt-filter-select"
|
|
619
|
+
value=""
|
|
620
|
+
onChange={(e) => {
|
|
621
|
+
const val = e.target.value
|
|
622
|
+
if (!val) return
|
|
623
|
+
setFilters((f) => ({
|
|
624
|
+
...f,
|
|
625
|
+
statuses: f.statuses.includes(val) ? f.statuses.filter((s) => s !== val) : [...f.statuses, val],
|
|
626
|
+
}))
|
|
627
|
+
}}
|
|
628
|
+
>
|
|
629
|
+
<option value="">Status{filters.statuses.length > 0 ? ` (${filters.statuses.length})` : ''}</option>
|
|
630
|
+
{uniqueStatuses.map((s) => (
|
|
631
|
+
<option key={s} value={s}>{filters.statuses.includes(s) ? '✓ ' : ''}{s.replace(/_/g, ' ')}</option>
|
|
632
|
+
))}
|
|
633
|
+
</select>
|
|
634
|
+
)}
|
|
635
|
+
{uniqueCategories.length > 0 && (
|
|
636
|
+
<select
|
|
637
|
+
className="gantt-filter-select"
|
|
638
|
+
value=""
|
|
639
|
+
onChange={(e) => {
|
|
640
|
+
const val = e.target.value
|
|
641
|
+
if (!val) return
|
|
642
|
+
setFilters((f) => ({
|
|
643
|
+
...f,
|
|
644
|
+
categories: f.categories.includes(val) ? f.categories.filter((c) => c !== val) : [...f.categories, val],
|
|
645
|
+
}))
|
|
646
|
+
}}
|
|
647
|
+
>
|
|
648
|
+
<option value="">Category{filters.categories.length > 0 ? ` (${filters.categories.length})` : ''}</option>
|
|
649
|
+
{uniqueCategories.map((c) => (
|
|
650
|
+
<option key={c} value={c}>{filters.categories.includes(c) ? '✓ ' : ''}{c}</option>
|
|
651
|
+
))}
|
|
652
|
+
</select>
|
|
653
|
+
)}
|
|
654
|
+
<input
|
|
655
|
+
type="date"
|
|
656
|
+
className="gantt-filter-date"
|
|
657
|
+
value={filters.dateRange.start ? format(filters.dateRange.start, 'yyyy-MM-dd') : ''}
|
|
658
|
+
onChange={(e) => setFilters((f) => ({ ...f, dateRange: { ...f.dateRange, start: e.target.value ? new Date(e.target.value) : null } }))}
|
|
659
|
+
title="Filter from date"
|
|
660
|
+
/>
|
|
661
|
+
<input
|
|
662
|
+
type="date"
|
|
663
|
+
className="gantt-filter-date"
|
|
664
|
+
value={filters.dateRange.end ? format(filters.dateRange.end, 'yyyy-MM-dd') : ''}
|
|
665
|
+
onChange={(e) => setFilters((f) => ({ ...f, dateRange: { ...f.dateRange, end: e.target.value ? new Date(e.target.value) : null } }))}
|
|
666
|
+
title="Filter to date"
|
|
667
|
+
/>
|
|
668
|
+
{hasActiveFilters && (
|
|
669
|
+
<button className="gantt-filter-clear" onClick={() => setFilters(DEFAULT_FILTER_STATE)}>Clear</button>
|
|
670
|
+
)}
|
|
671
|
+
{/* Active filter pills */}
|
|
672
|
+
{filters.statuses.map((s) => (
|
|
673
|
+
<span key={`s-${s}`} className="gantt-filter-pill">
|
|
674
|
+
{s.replace(/_/g, ' ')}
|
|
675
|
+
<button onClick={() => setFilters((f) => ({ ...f, statuses: f.statuses.filter((x) => x !== s) }))}>×</button>
|
|
676
|
+
</span>
|
|
677
|
+
))}
|
|
678
|
+
{filters.categories.map((c) => (
|
|
679
|
+
<span key={`c-${c}`} className="gantt-filter-pill">
|
|
680
|
+
{c}
|
|
681
|
+
<button onClick={() => setFilters((f) => ({ ...f, categories: f.categories.filter((x) => x !== c) }))}>×</button>
|
|
682
|
+
</span>
|
|
683
|
+
))}
|
|
684
|
+
</div>
|
|
685
|
+
)}
|
|
686
|
+
|
|
687
|
+
{/* View switcher */}
|
|
688
|
+
{showViewSwitcher && (
|
|
689
|
+
<div className="gantt-view-switcher">
|
|
690
|
+
{(['timeline', 'board', 'list'] as const).map((mode) => (
|
|
691
|
+
<button
|
|
692
|
+
key={mode}
|
|
693
|
+
className={`gantt-view-tab ${viewMode === mode ? 'active' : ''}`}
|
|
694
|
+
onClick={() => setViewMode(mode)}
|
|
695
|
+
>
|
|
696
|
+
{mode === 'timeline' ? 'Timeline' : mode === 'board' ? 'Board' : 'List'}
|
|
697
|
+
</button>
|
|
698
|
+
))}
|
|
699
|
+
</div>
|
|
700
|
+
)}
|
|
701
|
+
|
|
702
|
+
{/* Header row */}
|
|
703
|
+
<div className="gantt-header-row" style={{ display: viewMode === 'timeline' ? undefined : 'none' }}>
|
|
704
|
+
<div className="gantt-info-header" style={{ width: infoColumnWidth }}>
|
|
705
|
+
<span style={{ flex: 1 }}>{infoColumnLabel}</span>
|
|
706
|
+
<div className="gantt-zoom-controls">
|
|
707
|
+
<button onClick={() => setZoomIndex((i) => Math.max(i - 1, 0))} disabled={zoomIndex === 0} aria-label="Zoom out">−</button>
|
|
708
|
+
<input type="range" min="0" max={ZOOM_LEVELS.length - 1} value={zoomIndex} onChange={(e) => setZoomIndex(parseInt(e.target.value))} className="gantt-zoom-slider" aria-label={`Zoom level: ${dayWidth}px per day`} />
|
|
709
|
+
<button onClick={() => setZoomIndex((i) => Math.min(i + 1, ZOOM_LEVELS.length - 1))} disabled={zoomIndex === ZOOM_LEVELS.length - 1} aria-label="Zoom in">+</button>
|
|
710
|
+
{showFullscreen && (
|
|
711
|
+
<button onClick={toggleFullscreen} aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'} title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
|
|
712
|
+
{isFullscreen ? '⊠' : '⊞'}
|
|
713
|
+
</button>
|
|
714
|
+
)}
|
|
715
|
+
</div>
|
|
716
|
+
</div>
|
|
717
|
+
<div className="gantt-timeline-header" ref={headerTimelineRef}>
|
|
718
|
+
<div style={{ width: timelineWidth }}>
|
|
719
|
+
<div className="gantt-month-header" style={{ width: timelineWidth }}>
|
|
720
|
+
{months.map((m, i) => (<div key={i} className="gantt-month" style={{ width: m.days * dayWidth }}>{m.label}</div>))}
|
|
721
|
+
</div>
|
|
722
|
+
<div className="gantt-day-header" style={{ width: timelineWidth }}>
|
|
723
|
+
{timeHeaderUnits.mode === 'weeks' ? (
|
|
724
|
+
timeHeaderUnits.units.map((unit, i) => (
|
|
725
|
+
<div key={i} className={`gantt-week ${unit.hasToday ? 'has-today' : ''}`} style={{ width: unit.width }}>{unit.width > 40 ? unit.label : ''}</div>
|
|
726
|
+
))
|
|
727
|
+
) : (
|
|
728
|
+
timeHeaderUnits.units.map((unit, i) => (
|
|
729
|
+
<div key={i} className={`gantt-day ${unit.isWeekend ? 'weekend' : ''} ${unit.isToday ? 'today' : ''}`} style={{ width: unit.width }}>{unit.label}</div>
|
|
730
|
+
))
|
|
731
|
+
)}
|
|
732
|
+
</div>
|
|
733
|
+
</div>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
737
|
+
{/* Scrollable body (timeline view) */}
|
|
738
|
+
<div className="gantt-body-scroll" ref={bodyScrollRef} onScroll={handleBodyScroll} style={{ display: viewMode === 'timeline' ? undefined : 'none' }}>
|
|
739
|
+
<div className="gantt-info-column" ref={infoColumnRef} style={{ width: infoColumnWidth }}>
|
|
740
|
+
{tasks.map((task, rowIdx) => (
|
|
741
|
+
<div key={task.item.id} className={`gantt-info-row ${focusedRowIndex === rowIdx ? 'gantt-row-focused' : ''}`}>
|
|
742
|
+
<div className="gantt-row-indent" style={{ width: task.depth * INDENT_WIDTH }} />
|
|
743
|
+
{task.hasChildren ? (
|
|
744
|
+
<button className="gantt-collapse-btn" onClick={() => toggleCollapse(task.item.id)} aria-label={collapsed.has(task.item.id) ? 'Expand' : 'Collapse'}>
|
|
745
|
+
{collapsed.has(task.item.id) ? '▶' : '▼'}
|
|
746
|
+
</button>
|
|
747
|
+
) : (<span className="gantt-collapse-spacer" />)}
|
|
748
|
+
<span className="gantt-row-title" onClick={() => handleItemClick(task.item)} title={task.item.title} style={{ cursor: onItemClick || onItemEdit ? 'pointer' : 'default' }}>
|
|
749
|
+
{task.item.title}
|
|
750
|
+
</span>
|
|
751
|
+
{showCategory && task.item.category && (
|
|
752
|
+
<span className="gantt-category-badge" style={{ backgroundColor: categoryColors[task.item.category] || categoryColors.other || '#6b7280' }}>
|
|
753
|
+
{task.item.category}
|
|
754
|
+
</span>
|
|
755
|
+
)}
|
|
756
|
+
{showStatus && (
|
|
757
|
+
<span className={`gantt-status-badge gantt-status-${task.item.status}`}>{task.item.status.replace(/_/g, ' ')}</span>
|
|
758
|
+
)}
|
|
759
|
+
</div>
|
|
760
|
+
))}
|
|
761
|
+
</div>
|
|
762
|
+
|
|
763
|
+
<div className="gantt-timeline-body" style={{ width: timelineWidth, height: bodyHeight }}>
|
|
764
|
+
{days.map((day, i) => (
|
|
765
|
+
<div key={i} className={`gantt-grid-line ${isWeekend(day) ? 'weekend' : ''} ${mounted && isToday(day) ? 'today' : ''}`} style={{ left: i * dayWidth, width: dayWidth, height: bodyHeight }} />
|
|
766
|
+
))}
|
|
767
|
+
{tasks.map((_, i) => (<div key={i} className="gantt-row-line" style={{ top: (i + 1) * ROW_HEIGHT - 1 }} />))}
|
|
768
|
+
|
|
769
|
+
{dependencyPaths.length > 0 && (
|
|
770
|
+
<svg className="gantt-deps-svg" style={{ width: timelineWidth, height: bodyHeight }} aria-hidden="true">
|
|
771
|
+
{dependencyPaths.map((dep, i) => (<path key={`${dep.fromId}-${dep.toId}-${i}`} d={dep.path} className="gantt-dep-line" />))}
|
|
772
|
+
</svg>
|
|
773
|
+
)}
|
|
774
|
+
|
|
775
|
+
{tasks.map((task, rowIndex) => {
|
|
776
|
+
const isDragging = dragState?.taskId === task.item.id
|
|
777
|
+
const pos = getBarPosition(task, rowIndex, true)
|
|
778
|
+
const barColor = getHealthColor(task.healthStatus)
|
|
779
|
+
const dates = isDragging ? { start: dragState!.currentStart, end: dragState!.currentEnd } : { start: task.start, end: task.end }
|
|
780
|
+
|
|
781
|
+
return (
|
|
782
|
+
<div
|
|
783
|
+
key={task.item.id}
|
|
784
|
+
className={`gantt-bar depth-${Math.min(task.depth, 3)} ${isDragging ? 'dragging' : ''}`}
|
|
785
|
+
style={{
|
|
786
|
+
left: pos.left, width: pos.width, top: pos.top, height: pos.height,
|
|
787
|
+
backgroundColor: barColor,
|
|
788
|
+
cursor: onDateChange ? (dragState ? (dragState.type === 'move' ? 'grabbing' : 'ew-resize') : 'grab') : 'pointer',
|
|
789
|
+
opacity: task.item.status === 'completed' || task.item.status === 'closed' ? 0.7 : 0.85,
|
|
790
|
+
}}
|
|
791
|
+
onClick={() => !dragState && handleItemClick(task.item)}
|
|
792
|
+
onMouseDown={(e) => handleDragStart(e, task, 'move')}
|
|
793
|
+
title={`${task.item.title}\n${format(dates.start, 'MMM d, yyyy')} – ${format(dates.end, 'MMM d, yyyy')}\nProgress: ${task.progress}%`}
|
|
794
|
+
>
|
|
795
|
+
{onDateChange && (
|
|
796
|
+
<>
|
|
797
|
+
<div className="gantt-bar-handle gantt-bar-handle-left" onMouseDown={(e) => { e.stopPropagation(); handleDragStart(e, task, 'resize-start') }} />
|
|
798
|
+
<div className="gantt-bar-handle gantt-bar-handle-right" onMouseDown={(e) => { e.stopPropagation(); handleDragStart(e, task, 'resize-end') }} />
|
|
799
|
+
</>
|
|
800
|
+
)}
|
|
801
|
+
{task.timeProgress > 0 && task.timeProgress < 100 && (
|
|
802
|
+
<div className="gantt-bar-time-marker" style={{ left: `${task.timeProgress}%` }} />
|
|
803
|
+
)}
|
|
804
|
+
{task.progress > 0 && (
|
|
805
|
+
<div className={`gantt-bar-progress ${task.progress < task.timeProgress - 10 ? 'behind' : task.progress > task.timeProgress + 10 ? 'ahead' : ''}`} style={{ width: `${task.progress}%` }} />
|
|
806
|
+
)}
|
|
807
|
+
<span className="gantt-bar-label">{pos.width > 80 ? task.item.title.replace(/^\[[^\]]+\]\s*/, '') : ''}</span>
|
|
808
|
+
{isDragging && (<div className="gantt-drag-tooltip">{format(dates.start, 'MMM d')} – {format(dates.end, 'MMM d')}</div>)}
|
|
809
|
+
</div>
|
|
810
|
+
)
|
|
811
|
+
})}
|
|
812
|
+
|
|
813
|
+
{mounted && (() => {
|
|
814
|
+
const todayOffset = differenceInDays(new Date(), startDate)
|
|
815
|
+
if (todayOffset >= 0 && todayOffset < days.length) return <div className="gantt-today-line" style={{ left: todayOffset * dayWidth + dayWidth / 2 }} />
|
|
816
|
+
return null
|
|
817
|
+
})()}
|
|
818
|
+
</div>
|
|
819
|
+
</div>
|
|
820
|
+
|
|
821
|
+
{/* Board view */}
|
|
822
|
+
{viewMode === 'board' && (
|
|
823
|
+
<div className="gantt-board">
|
|
824
|
+
{boardColumns.map((col) => (
|
|
825
|
+
<div
|
|
826
|
+
key={col.status}
|
|
827
|
+
className={`gantt-board-column ${boardDragItem ? 'gantt-board-drop-target' : ''}`}
|
|
828
|
+
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('gantt-board-drag-over') }}
|
|
829
|
+
onDragLeave={(e) => { e.currentTarget.classList.remove('gantt-board-drag-over') }}
|
|
830
|
+
onDrop={(e) => {
|
|
831
|
+
e.preventDefault()
|
|
832
|
+
e.currentTarget.classList.remove('gantt-board-drag-over')
|
|
833
|
+
if (boardDragItem && onStatusChange) {
|
|
834
|
+
onStatusChange(boardDragItem, col.status)
|
|
835
|
+
}
|
|
836
|
+
setBoardDragItem(null)
|
|
837
|
+
}}
|
|
838
|
+
>
|
|
839
|
+
<div className="gantt-board-column-header">
|
|
840
|
+
<span className={`gantt-status-badge gantt-status-${col.status}`}>{col.status.replace(/_/g, ' ')}</span>
|
|
841
|
+
<span className="gantt-board-count">{col.items.length}</span>
|
|
842
|
+
</div>
|
|
843
|
+
<div className="gantt-board-cards">
|
|
844
|
+
{col.items.map((item) => (
|
|
845
|
+
<div
|
|
846
|
+
key={item.id}
|
|
847
|
+
className="gantt-board-card"
|
|
848
|
+
draggable={!!onStatusChange}
|
|
849
|
+
onDragStart={() => setBoardDragItem(item.id)}
|
|
850
|
+
onDragEnd={() => setBoardDragItem(null)}
|
|
851
|
+
onClick={() => handleItemClick(item)}
|
|
852
|
+
>
|
|
853
|
+
<div className="gantt-board-card-title">{item.title}</div>
|
|
854
|
+
{item.category && (
|
|
855
|
+
<span className="gantt-category-badge" style={{ backgroundColor: categoryColors[item.category] || categoryColors.other || '#6b7280' }}>
|
|
856
|
+
{item.category}
|
|
857
|
+
</span>
|
|
858
|
+
)}
|
|
859
|
+
{item.end_date && (
|
|
860
|
+
<span className="gantt-board-card-date">{format(new Date(item.end_date), 'MMM d')}</span>
|
|
861
|
+
)}
|
|
862
|
+
</div>
|
|
863
|
+
))}
|
|
864
|
+
</div>
|
|
865
|
+
</div>
|
|
866
|
+
))}
|
|
867
|
+
</div>
|
|
868
|
+
)}
|
|
869
|
+
|
|
870
|
+
{/* List view */}
|
|
871
|
+
{viewMode === 'list' && (
|
|
872
|
+
<div className="gantt-list">
|
|
873
|
+
<div className="gantt-list-header">
|
|
874
|
+
<span className="gantt-list-col-title">Title</span>
|
|
875
|
+
<span className="gantt-list-col-status">Status</span>
|
|
876
|
+
<span className="gantt-list-col-category">Category</span>
|
|
877
|
+
<span className="gantt-list-col-dates">Dates</span>
|
|
878
|
+
<span className="gantt-list-col-progress">Progress</span>
|
|
879
|
+
</div>
|
|
880
|
+
{listItems.map((task, idx) => (
|
|
881
|
+
<div
|
|
882
|
+
key={task.item.id}
|
|
883
|
+
className={`gantt-list-row ${focusedRowIndex === idx ? 'gantt-row-focused' : ''}`}
|
|
884
|
+
onClick={() => handleItemClick(task.item)}
|
|
885
|
+
>
|
|
886
|
+
<span className="gantt-list-col-title">
|
|
887
|
+
{task.depth > 0 && <span style={{ width: task.depth * INDENT_WIDTH, display: 'inline-block' }} />}
|
|
888
|
+
{task.item.title}
|
|
889
|
+
</span>
|
|
890
|
+
<span className="gantt-list-col-status">
|
|
891
|
+
<span className={`gantt-status-badge gantt-status-${task.item.status}`}>{task.item.status.replace(/_/g, ' ')}</span>
|
|
892
|
+
</span>
|
|
893
|
+
<span className="gantt-list-col-category">
|
|
894
|
+
{task.item.category && (
|
|
895
|
+
<span className="gantt-category-badge" style={{ backgroundColor: categoryColors[task.item.category] || categoryColors.other || '#6b7280' }}>
|
|
896
|
+
{task.item.category}
|
|
897
|
+
</span>
|
|
898
|
+
)}
|
|
899
|
+
</span>
|
|
900
|
+
<span className="gantt-list-col-dates">
|
|
901
|
+
{format(task.start, 'MMM d')} – {format(task.end, 'MMM d')}
|
|
902
|
+
</span>
|
|
903
|
+
<span className="gantt-list-col-progress">
|
|
904
|
+
<div className="gantt-list-progress-bar">
|
|
905
|
+
<div className="gantt-list-progress-fill" style={{ width: `${task.progress}%`, backgroundColor: getHealthColor(task.healthStatus) }} />
|
|
906
|
+
</div>
|
|
907
|
+
<span>{task.progress}%</span>
|
|
908
|
+
</span>
|
|
909
|
+
</div>
|
|
910
|
+
))}
|
|
911
|
+
</div>
|
|
912
|
+
)}
|
|
913
|
+
|
|
914
|
+
{/* Detail modal */}
|
|
915
|
+
{detailItem && onItemEdit && (
|
|
916
|
+
<div className="gantt-detail-overlay" onClick={() => setDetailItem(null)}>
|
|
917
|
+
<div className="gantt-detail-modal" onClick={(e) => e.stopPropagation()}>
|
|
918
|
+
<div className="gantt-detail-header">
|
|
919
|
+
<h3>{detailItem.title}</h3>
|
|
920
|
+
<button className="gantt-detail-close" onClick={() => setDetailItem(null)} aria-label="Close">×</button>
|
|
921
|
+
</div>
|
|
922
|
+
<div className="gantt-detail-body">
|
|
923
|
+
{detailItem.description && (
|
|
924
|
+
<div className="gantt-detail-field">
|
|
925
|
+
<label>Description</label>
|
|
926
|
+
<p>{detailItem.description}</p>
|
|
927
|
+
</div>
|
|
928
|
+
)}
|
|
929
|
+
<div className="gantt-detail-row">
|
|
930
|
+
<div className="gantt-detail-field">
|
|
931
|
+
<label>Status</label>
|
|
932
|
+
<select
|
|
933
|
+
value={detailItem.status}
|
|
934
|
+
onChange={(e) => {
|
|
935
|
+
const updated = { ...detailItem, status: e.target.value }
|
|
936
|
+
setDetailItem(updated)
|
|
937
|
+
onItemEdit(detailItem.id, { status: e.target.value })
|
|
938
|
+
}}
|
|
939
|
+
className="gantt-detail-select"
|
|
940
|
+
>
|
|
941
|
+
{uniqueStatuses.map((s) => (
|
|
942
|
+
<option key={s} value={s}>{s.replace(/_/g, ' ')}</option>
|
|
943
|
+
))}
|
|
944
|
+
</select>
|
|
945
|
+
</div>
|
|
946
|
+
{detailItem.category !== undefined && (
|
|
947
|
+
<div className="gantt-detail-field">
|
|
948
|
+
<label>Category</label>
|
|
949
|
+
<select
|
|
950
|
+
value={detailItem.category || ''}
|
|
951
|
+
onChange={(e) => {
|
|
952
|
+
const updated = { ...detailItem, category: e.target.value || undefined }
|
|
953
|
+
setDetailItem(updated)
|
|
954
|
+
onItemEdit(detailItem.id, { category: e.target.value || undefined })
|
|
955
|
+
}}
|
|
956
|
+
className="gantt-detail-select"
|
|
957
|
+
>
|
|
958
|
+
<option value="">None</option>
|
|
959
|
+
{uniqueCategories.map((c) => (
|
|
960
|
+
<option key={c} value={c}>{c}</option>
|
|
961
|
+
))}
|
|
962
|
+
</select>
|
|
963
|
+
</div>
|
|
964
|
+
)}
|
|
965
|
+
</div>
|
|
966
|
+
<div className="gantt-detail-row">
|
|
967
|
+
<div className="gantt-detail-field">
|
|
968
|
+
<label>Start Date</label>
|
|
969
|
+
<input
|
|
970
|
+
type="date"
|
|
971
|
+
className="gantt-detail-input"
|
|
972
|
+
value={detailItem.start_date ? format(new Date(detailItem.start_date), 'yyyy-MM-dd') : ''}
|
|
973
|
+
onChange={(e) => {
|
|
974
|
+
const updated = { ...detailItem, start_date: e.target.value || null }
|
|
975
|
+
setDetailItem(updated)
|
|
976
|
+
onItemEdit(detailItem.id, { start_date: e.target.value || null })
|
|
977
|
+
}}
|
|
978
|
+
/>
|
|
979
|
+
</div>
|
|
980
|
+
<div className="gantt-detail-field">
|
|
981
|
+
<label>End Date</label>
|
|
982
|
+
<input
|
|
983
|
+
type="date"
|
|
984
|
+
className="gantt-detail-input"
|
|
985
|
+
value={detailItem.end_date ? format(new Date(detailItem.end_date), 'yyyy-MM-dd') : ''}
|
|
986
|
+
onChange={(e) => {
|
|
987
|
+
const updated = { ...detailItem, end_date: e.target.value || null }
|
|
988
|
+
setDetailItem(updated)
|
|
989
|
+
onItemEdit(detailItem.id, { end_date: e.target.value || null })
|
|
990
|
+
}}
|
|
991
|
+
/>
|
|
992
|
+
</div>
|
|
993
|
+
</div>
|
|
994
|
+
{detailItem.progress !== undefined && (
|
|
995
|
+
<div className="gantt-detail-field">
|
|
996
|
+
<label>Progress: {detailItem.progress}%</label>
|
|
997
|
+
<input
|
|
998
|
+
type="range"
|
|
999
|
+
min="0"
|
|
1000
|
+
max="100"
|
|
1001
|
+
value={detailItem.progress}
|
|
1002
|
+
onChange={(e) => {
|
|
1003
|
+
const val = parseInt(e.target.value)
|
|
1004
|
+
const updated = { ...detailItem, progress: val }
|
|
1005
|
+
setDetailItem(updated)
|
|
1006
|
+
onItemEdit(detailItem.id, { progress: val })
|
|
1007
|
+
}}
|
|
1008
|
+
className="gantt-detail-slider"
|
|
1009
|
+
/>
|
|
1010
|
+
</div>
|
|
1011
|
+
)}
|
|
1012
|
+
</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
</div>
|
|
1015
|
+
)}
|
|
1016
|
+
|
|
1017
|
+
<div className="gantt-legend" style={{ display: viewMode === 'timeline' ? undefined : 'none' }}>
|
|
1018
|
+
<div className="gantt-legend-item"><div className="gantt-legend-color" style={{ background: '#22c55e' }} /><span>On Track</span></div>
|
|
1019
|
+
<div className="gantt-legend-item"><div className="gantt-legend-color" style={{ background: '#eab308' }} /><span>At Risk</span></div>
|
|
1020
|
+
<div className="gantt-legend-item"><div className="gantt-legend-color" style={{ background: '#ef4444' }} /><span>Blocked</span></div>
|
|
1021
|
+
<div className="gantt-legend-item"><div className="gantt-legend-color" style={{ background: '#9ca3af' }} /><span>Not Started</span></div>
|
|
1022
|
+
<div className="gantt-legend-item"><div className="gantt-legend-color" style={{ background: '#3b82f6', width: 2, height: 12 }} /><span>Today</span></div>
|
|
1023
|
+
</div>
|
|
1024
|
+
</div>
|
|
1025
|
+
)
|
|
1026
|
+
}
|