@startsimpli/ui 0.4.6 → 0.4.8

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.
Files changed (122) hide show
  1. package/package.json +2 -1
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/ActivityTimeline.tsx +173 -0
  4. package/src/components/LogActivityDialog.tsx +303 -0
  5. package/src/components/QuickLogButtons.tsx +32 -0
  6. package/src/components/account/__tests__/account.test.tsx +315 -0
  7. package/src/components/badge/StageBadge.tsx +31 -0
  8. package/src/components/badge/index.ts +3 -0
  9. package/src/components/command-palette/CommandGroup.tsx +23 -0
  10. package/src/components/command-palette/CommandPalette.tsx +327 -0
  11. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  12. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  13. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  14. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  15. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  16. package/src/components/command-palette/command-palette-context.tsx +51 -0
  17. package/src/components/command-palette/index.ts +9 -0
  18. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  19. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  20. package/src/components/compose/compose-header.tsx +72 -0
  21. package/src/components/compose/compose-loading.tsx +13 -0
  22. package/src/components/compose/index.ts +6 -0
  23. package/src/components/compose/save-status-indicator.tsx +57 -0
  24. package/src/components/compose/send-confirmation-dialog.tsx +87 -0
  25. package/src/components/compose/subject-input.tsx +25 -0
  26. package/src/components/compose/useAutoSave.ts +93 -0
  27. package/src/components/dashboard/DashboardGrid.tsx +32 -0
  28. package/src/components/dashboard/DashboardSection.tsx +32 -0
  29. package/src/components/dashboard/MetricCard.tsx +129 -0
  30. package/src/components/dashboard/PeriodSelector.tsx +55 -0
  31. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  32. package/src/components/dashboard/SparklineTrend.tsx +102 -0
  33. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  34. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  35. package/src/components/dashboard/index.ts +20 -0
  36. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  37. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  38. package/src/components/dialog/index.ts +3 -0
  39. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  40. package/src/components/email-dialogs/index.ts +14 -0
  41. package/src/components/email-dialogs/merge-fields.tsx +196 -0
  42. package/src/components/email-dialogs/preview-dialog.tsx +194 -0
  43. package/src/components/email-dialogs/schedule-dialog.tsx +297 -0
  44. package/src/components/email-dialogs/template-picker.tsx +225 -0
  45. package/src/components/email-dialogs/test-send-dialog.tsx +188 -0
  46. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  47. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  48. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  49. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  50. package/src/components/email-editor/add-block-menu.tsx +151 -0
  51. package/src/components/email-editor/block-toolbar.tsx +73 -0
  52. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  53. package/src/components/email-editor/blocks/button-block.tsx +44 -0
  54. package/src/components/email-editor/blocks/divider-block.tsx +43 -0
  55. package/src/components/email-editor/blocks/footer-block.tsx +39 -0
  56. package/src/components/email-editor/blocks/header-block.tsx +39 -0
  57. package/src/components/email-editor/blocks/image-block.tsx +61 -0
  58. package/src/components/email-editor/blocks/index.ts +9 -0
  59. package/src/components/email-editor/blocks/metrics-block.tsx +198 -0
  60. package/src/components/email-editor/blocks/social-block.tsx +75 -0
  61. package/src/components/email-editor/blocks/spacer-block.tsx +26 -0
  62. package/src/components/email-editor/blocks/text-block.tsx +75 -0
  63. package/src/components/email-editor/editor-sidebar.tsx +66 -0
  64. package/src/components/email-editor/email-editor.tsx +497 -0
  65. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  66. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  67. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  68. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  69. package/src/components/email-editor/index.ts +51 -0
  70. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  71. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  72. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  73. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  74. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  75. package/src/components/email-editor/panels/index.ts +3 -0
  76. package/src/components/email-editor/renderer/block-renderers.ts +209 -0
  77. package/src/components/email-editor/renderer/email-html-renderer.ts +128 -0
  78. package/src/components/email-editor/types.ts +413 -0
  79. package/src/components/email-editor/utils/defaults.ts +116 -0
  80. package/src/components/email-editor/utils/undo-redo.ts +59 -0
  81. package/src/components/enrichment/EnrichButton.tsx +33 -0
  82. package/src/components/enrichment/EnrichmentProgress.tsx +66 -0
  83. package/src/components/enrichment/QualityBadge.tsx +43 -0
  84. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  85. package/src/components/enrichment/index.ts +8 -0
  86. package/src/components/gantt/GanttBoardView.tsx +71 -0
  87. package/src/components/gantt/GanttChart.tsx +140 -887
  88. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  89. package/src/components/gantt/GanttListView.tsx +63 -0
  90. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  91. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  92. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  93. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  94. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  95. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  96. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  97. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  98. package/src/components/gantt/index.ts +10 -0
  99. package/src/components/gantt/types.ts +5 -5
  100. package/src/components/index.ts +46 -0
  101. package/src/components/integrations/ConnectionStatus.tsx +77 -0
  102. package/src/components/integrations/IntegrationCard.tsx +92 -0
  103. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  104. package/src/components/integrations/index.ts +5 -0
  105. package/src/components/kanban/KanbanBoard.tsx +103 -0
  106. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  107. package/src/components/kanban/index.ts +2 -0
  108. package/src/components/lists/CreateListDialog.tsx +158 -0
  109. package/src/components/lists/ListCard.tsx +77 -0
  110. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  111. package/src/components/lists/index.ts +5 -0
  112. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  113. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  114. package/src/components/pipeline/StageTransitionModal.tsx +146 -0
  115. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  116. package/src/components/pipeline/index.ts +2 -0
  117. package/src/components/settings/SettingsCard.tsx +33 -0
  118. package/src/components/settings/SettingsLayout.tsx +28 -0
  119. package/src/components/settings/SettingsNav.tsx +42 -0
  120. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  121. package/src/components/settings/index.ts +6 -0
  122. package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
@@ -1,597 +1,81 @@
1
1
  'use client'
2
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])
3
+ import { format } from 'date-fns'
4
+ import type { GanttChartProps } from './types'
5
+ import { useGanttState } from './hooks/useGanttState'
6
+ import { GanttFilterBar } from './GanttFilterBar'
7
+ import { GanttTimelineView } from './GanttTimelineView'
8
+ import { GanttBoardView } from './GanttBoardView'
9
+ import { GanttListView } from './GanttListView'
10
+
11
+ export function GanttChart(props: GanttChartProps) {
12
+ const {
13
+ className,
14
+ infoColumnLabel = 'Item',
15
+ infoColumnWidth = 320,
16
+ showCategory = true,
17
+ showStatus = true,
18
+ showFilterBar = false,
19
+ showFullscreen = false,
20
+ showViewSwitcher = false,
21
+ onItemEdit,
22
+ } = props
23
+
24
+ const {
25
+ isFullscreen,
26
+ viewMode,
27
+ setViewMode,
28
+ detailItem,
29
+ setDetailItem,
30
+
31
+ uniqueStatuses,
32
+ uniqueCategories,
33
+ filteredItems,
34
+ tasks,
35
+
36
+ filters,
37
+ setFilters,
38
+
39
+ wrapperRef,
40
+
41
+ handleItemClick,
42
+
43
+ categoryColors,
44
+ onStatusChange,
45
+
46
+ // Timeline view props
47
+ zoomIndex,
48
+ setZoomIndex,
49
+ collapsed,
50
+ mounted,
51
+ dragState,
52
+ focusedRowIndex,
53
+ dayWidth,
54
+ startDate,
55
+ days,
56
+ months,
57
+ timeHeaderUnits,
58
+ dependencyPaths,
59
+ timelineWidth,
60
+ bodyHeight,
61
+ bodyScrollRef,
62
+ headerTimelineRef,
63
+ infoColumnRef,
64
+ toggleFullscreen,
65
+ toggleCollapse,
66
+ handleBodyScroll,
67
+ getBarPosition,
68
+ handleDragStart,
69
+ onDateChange,
70
+
71
+ // Board view props
72
+ boardColumns,
73
+ boardDragItem,
74
+ setBoardDragItem,
75
+
76
+ // List view props
77
+ listItems,
78
+ } = useGanttState(props)
595
79
 
596
80
  if (tasks.length === 0 && filteredItems.length === 0) {
597
81
  return (
@@ -605,83 +89,12 @@ export function GanttChart({
605
89
  <div ref={wrapperRef} className={`gantt-wrapper ${isFullscreen ? 'gantt-fullscreen' : ''} ${className || ''}`} tabIndex={0}>
606
90
  {/* Filter bar */}
607
91
  {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>
92
+ <GanttFilterBar
93
+ filters={filters}
94
+ onFilterChange={(f) => setFilters(f)}
95
+ uniqueStatuses={uniqueStatuses}
96
+ uniqueCategories={uniqueCategories}
97
+ />
685
98
  )}
686
99
 
687
100
  {/* View switcher */}
@@ -699,216 +112,64 @@ export function GanttChart({
699
112
  </div>
700
113
  )}
701
114
 
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>
115
+ {/* Timeline view */}
116
+ {viewMode === 'timeline' && (
117
+ <GanttTimelineView
118
+ infoColumnWidth={infoColumnWidth}
119
+ infoColumnLabel={infoColumnLabel}
120
+ showCategory={showCategory}
121
+ showStatus={showStatus}
122
+ showFullscreen={showFullscreen}
123
+ zoomIndex={zoomIndex}
124
+ setZoomIndex={setZoomIndex}
125
+ collapsed={collapsed}
126
+ mounted={mounted}
127
+ dragState={dragState}
128
+ focusedRowIndex={focusedRowIndex}
129
+ dayWidth={dayWidth}
130
+ tasks={tasks}
131
+ startDate={startDate}
132
+ days={days}
133
+ months={months}
134
+ timeHeaderUnits={timeHeaderUnits}
135
+ dependencyPaths={dependencyPaths}
136
+ timelineWidth={timelineWidth}
137
+ bodyHeight={bodyHeight}
138
+ isFullscreen={isFullscreen}
139
+ categoryColors={categoryColors}
140
+ onDateChange={onDateChange}
141
+ bodyScrollRef={bodyScrollRef}
142
+ headerTimelineRef={headerTimelineRef}
143
+ infoColumnRef={infoColumnRef}
144
+ toggleFullscreen={toggleFullscreen}
145
+ handleItemClick={handleItemClick}
146
+ toggleCollapse={toggleCollapse}
147
+ handleBodyScroll={handleBodyScroll}
148
+ getBarPosition={getBarPosition}
149
+ handleDragStart={handleDragStart}
150
+ />
151
+ )}
820
152
 
821
153
  {/* Board view */}
822
154
  {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>
155
+ <GanttBoardView
156
+ boardColumns={boardColumns}
157
+ boardDragItem={boardDragItem}
158
+ setBoardDragItem={setBoardDragItem}
159
+ onStatusChange={onStatusChange}
160
+ categoryColors={categoryColors}
161
+ handleItemClick={handleItemClick}
162
+ />
868
163
  )}
869
164
 
870
165
  {/* List view */}
871
166
  {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>
167
+ <GanttListView
168
+ listItems={listItems}
169
+ focusedRowIndex={focusedRowIndex}
170
+ categoryColors={categoryColors}
171
+ handleItemClick={handleItemClick}
172
+ />
912
173
  )}
913
174
 
914
175
  {/* Detail modal */}
@@ -917,7 +178,7 @@ export function GanttChart({
917
178
  <div className="gantt-detail-modal" onClick={(e) => e.stopPropagation()}>
918
179
  <div className="gantt-detail-header">
919
180
  <h3>{detailItem.title}</h3>
920
- <button className="gantt-detail-close" onClick={() => setDetailItem(null)} aria-label="Close">×</button>
181
+ <button className="gantt-detail-close" onClick={() => setDetailItem(null)} aria-label="Close">x</button>
921
182
  </div>
922
183
  <div className="gantt-detail-body">
923
184
  {detailItem.description && (
@@ -969,11 +230,11 @@ export function GanttChart({
969
230
  <input
970
231
  type="date"
971
232
  className="gantt-detail-input"
972
- value={detailItem.start_date ? format(new Date(detailItem.start_date), 'yyyy-MM-dd') : ''}
233
+ value={detailItem.startDate ? format(new Date(detailItem.startDate), 'yyyy-MM-dd') : ''}
973
234
  onChange={(e) => {
974
- const updated = { ...detailItem, start_date: e.target.value || null }
235
+ const updated = { ...detailItem, startDate: e.target.value || null }
975
236
  setDetailItem(updated)
976
- onItemEdit(detailItem.id, { start_date: e.target.value || null })
237
+ onItemEdit(detailItem.id, { startDate: e.target.value || null })
977
238
  }}
978
239
  />
979
240
  </div>
@@ -982,11 +243,11 @@ export function GanttChart({
982
243
  <input
983
244
  type="date"
984
245
  className="gantt-detail-input"
985
- value={detailItem.end_date ? format(new Date(detailItem.end_date), 'yyyy-MM-dd') : ''}
246
+ value={detailItem.endDate ? format(new Date(detailItem.endDate), 'yyyy-MM-dd') : ''}
986
247
  onChange={(e) => {
987
- const updated = { ...detailItem, end_date: e.target.value || null }
248
+ const updated = { ...detailItem, endDate: e.target.value || null }
988
249
  setDetailItem(updated)
989
- onItemEdit(detailItem.id, { end_date: e.target.value || null })
250
+ onItemEdit(detailItem.id, { endDate: e.target.value || null })
990
251
  }}
991
252
  />
992
253
  </div>
@@ -1013,14 +274,6 @@ export function GanttChart({
1013
274
  </div>
1014
275
  </div>
1015
276
  )}
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
277
  </div>
1025
278
  )
1026
279
  }