@startsimpli/ui 0.4.7 → 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.
- package/package.json +1 -1
- package/src/__mocks__/next/link.js +11 -0
- package/src/components/account/__tests__/account.test.tsx +315 -0
- package/src/components/command-palette/CommandGroup.tsx +23 -0
- package/src/components/command-palette/CommandPalette.tsx +183 -200
- package/src/components/command-palette/CommandResultItem.tsx +59 -0
- package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
- package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
- package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
- package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
- package/src/components/command-palette/index.ts +6 -0
- package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
- package/src/components/compose/__tests__/compose.test.tsx +656 -0
- package/src/components/dashboard/PipelineFunnel.tsx +126 -0
- package/src/components/dashboard/TopCampaigns.tsx +132 -0
- package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
- package/src/components/dashboard/index.ts +6 -0
- package/src/components/dialog/ConfirmDialog.tsx +72 -0
- package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
- package/src/components/dialog/index.ts +3 -0
- package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
- package/src/components/email-editor/BlockRenderer.tsx +120 -0
- package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
- package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
- package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
- package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
- package/src/components/email-editor/editor-sidebar.tsx +6 -731
- package/src/components/email-editor/email-editor.tsx +78 -467
- package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
- package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
- package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
- package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
- package/src/components/email-editor/index.ts +1 -0
- package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
- package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
- package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
- package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
- package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
- package/src/components/email-editor/panels/index.ts +3 -0
- package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
- package/src/components/gantt/GanttBoardView.tsx +71 -0
- package/src/components/gantt/GanttChart.tsx +134 -881
- package/src/components/gantt/GanttFilterBar.tsx +100 -0
- package/src/components/gantt/GanttListView.tsx +63 -0
- package/src/components/gantt/GanttTimelineView.tsx +215 -0
- package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
- package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
- package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
- package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
- package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
- package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
- package/src/components/gantt/hooks/useGanttState.ts +644 -0
- package/src/components/gantt/index.ts +10 -0
- package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
- package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
- package/src/components/lists/__tests__/lists.test.tsx +263 -0
- package/src/components/loading/__tests__/loading.test.tsx +114 -0
- package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
- package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
- package/src/components/settings/__tests__/settings.test.tsx +181 -0
- package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { useMemo, useState, useRef, useEffect, useCallback } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
addDays,
|
|
4
|
+
differenceInDays,
|
|
5
|
+
format,
|
|
6
|
+
isWeekend,
|
|
7
|
+
isToday,
|
|
8
|
+
startOfDay,
|
|
9
|
+
endOfDay,
|
|
10
|
+
startOfMonth,
|
|
11
|
+
getMonth,
|
|
12
|
+
getWeek,
|
|
13
|
+
isAfter,
|
|
14
|
+
isBefore,
|
|
15
|
+
} from 'date-fns'
|
|
16
|
+
import type {
|
|
17
|
+
TimelineItem,
|
|
18
|
+
GanttTask,
|
|
19
|
+
GanttChartProps,
|
|
20
|
+
GanttFilterState,
|
|
21
|
+
GanttViewMode,
|
|
22
|
+
} from '../types'
|
|
23
|
+
import { parseDateRangeFromTitle, getHierarchyLevel } from '../lib/dates'
|
|
24
|
+
import { calculateExpectedProgress, calculateHealthStatus, getHealthColor } from '../lib/progress'
|
|
25
|
+
|
|
26
|
+
export const ROW_HEIGHT = 36
|
|
27
|
+
export const ZOOM_LEVELS = [8, 12, 16, 24, 32, 48]
|
|
28
|
+
const DEFAULT_ZOOM_INDEX = 3
|
|
29
|
+
export const INDENT_WIDTH = 20
|
|
30
|
+
|
|
31
|
+
const DEFAULT_CATEGORY_COLORS: Record<string, string> = {
|
|
32
|
+
financial: '#22c55e',
|
|
33
|
+
product: '#3b82f6',
|
|
34
|
+
team: '#a855f7',
|
|
35
|
+
market: '#f97316',
|
|
36
|
+
other: '#6b7280',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function defaultStatusToProgress(status: string): number {
|
|
40
|
+
if (status === 'completed') return 100
|
|
41
|
+
if (status === 'in_progress' || status === 'active' || status === 'on_track') return 50
|
|
42
|
+
return 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_FILTER_STATE: GanttFilterState = {
|
|
46
|
+
search: '',
|
|
47
|
+
statuses: [],
|
|
48
|
+
categories: [],
|
|
49
|
+
dateRange: { start: null, end: null },
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface DragState {
|
|
53
|
+
taskId: string
|
|
54
|
+
type: 'move' | 'resize-start' | 'resize-end'
|
|
55
|
+
startX: number
|
|
56
|
+
originalStart: Date
|
|
57
|
+
originalEnd: Date
|
|
58
|
+
currentStart: Date
|
|
59
|
+
currentEnd: Date
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useGanttState({
|
|
63
|
+
items,
|
|
64
|
+
dependencies = [],
|
|
65
|
+
onItemClick,
|
|
66
|
+
onDateChange,
|
|
67
|
+
statusToProgress = defaultStatusToProgress,
|
|
68
|
+
categoryColors = DEFAULT_CATEGORY_COLORS,
|
|
69
|
+
hierarchical = true,
|
|
70
|
+
initialZoom = DEFAULT_ZOOM_INDEX,
|
|
71
|
+
showFilterBar = false,
|
|
72
|
+
initialViewMode = 'timeline',
|
|
73
|
+
boardStatuses,
|
|
74
|
+
onItemEdit,
|
|
75
|
+
onStatusChange,
|
|
76
|
+
persistCollapseKey,
|
|
77
|
+
}: GanttChartProps) {
|
|
78
|
+
const [zoomIndex, setZoomIndex] = useState(initialZoom)
|
|
79
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
|
80
|
+
if (persistCollapseKey && typeof window !== 'undefined') {
|
|
81
|
+
try {
|
|
82
|
+
const saved = localStorage.getItem(`gantt-collapse-${persistCollapseKey}`)
|
|
83
|
+
if (saved) return new Set(JSON.parse(saved))
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
}
|
|
86
|
+
return new Set()
|
|
87
|
+
})
|
|
88
|
+
const [mounted, setMounted] = useState(false)
|
|
89
|
+
const [dragState, setDragState] = useState<DragState | null>(null)
|
|
90
|
+
const [filters, setFilters] = useState<GanttFilterState>(DEFAULT_FILTER_STATE)
|
|
91
|
+
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
92
|
+
const [viewMode, setViewMode] = useState<GanttViewMode>(initialViewMode)
|
|
93
|
+
const [detailItem, setDetailItem] = useState<TimelineItem | null>(null)
|
|
94
|
+
const [focusedRowIndex, setFocusedRowIndex] = useState(-1)
|
|
95
|
+
const [boardDragItem, setBoardDragItem] = useState<string | null>(null)
|
|
96
|
+
|
|
97
|
+
const dayWidth = ZOOM_LEVELS[zoomIndex]
|
|
98
|
+
const bodyScrollRef = useRef<HTMLDivElement>(null)
|
|
99
|
+
const headerTimelineRef = useRef<HTMLDivElement>(null)
|
|
100
|
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
101
|
+
const infoColumnRef = useRef<HTMLDivElement>(null)
|
|
102
|
+
|
|
103
|
+
useEffect(() => { setMounted(true) }, [])
|
|
104
|
+
|
|
105
|
+
// Persist collapse state to localStorage
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (persistCollapseKey && typeof window !== 'undefined') {
|
|
108
|
+
localStorage.setItem(`gantt-collapse-${persistCollapseKey}`, JSON.stringify([...collapsed]))
|
|
109
|
+
}
|
|
110
|
+
}, [collapsed, persistCollapseKey])
|
|
111
|
+
|
|
112
|
+
// Fullscreen change listener
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const handleFullscreenChange = () => {
|
|
115
|
+
setIsFullscreen(!!document.fullscreenElement)
|
|
116
|
+
}
|
|
117
|
+
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
|
118
|
+
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
119
|
+
}, [])
|
|
120
|
+
|
|
121
|
+
const toggleFullscreen = useCallback(() => {
|
|
122
|
+
if (!wrapperRef.current) return
|
|
123
|
+
if (document.fullscreenElement) {
|
|
124
|
+
document.exitFullscreen()
|
|
125
|
+
} else {
|
|
126
|
+
wrapperRef.current.requestFullscreen()
|
|
127
|
+
}
|
|
128
|
+
}, [])
|
|
129
|
+
|
|
130
|
+
// Handle item click — open detail modal if onItemEdit is provided, otherwise delegate to onItemClick
|
|
131
|
+
const handleItemClick = useCallback((item: TimelineItem) => {
|
|
132
|
+
if (onItemEdit) {
|
|
133
|
+
setDetailItem(item)
|
|
134
|
+
}
|
|
135
|
+
onItemClick?.(item)
|
|
136
|
+
}, [onItemEdit, onItemClick])
|
|
137
|
+
|
|
138
|
+
// Extract unique statuses and categories from items for filter dropdowns
|
|
139
|
+
const { uniqueStatuses, uniqueCategories } = useMemo(() => {
|
|
140
|
+
const statuses = new Set<string>()
|
|
141
|
+
const categories = new Set<string>()
|
|
142
|
+
const collect = (itemList: TimelineItem[]) => {
|
|
143
|
+
for (const item of itemList) {
|
|
144
|
+
statuses.add(item.status)
|
|
145
|
+
if (item.category) categories.add(item.category)
|
|
146
|
+
if (item.children) collect(item.children)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
collect(items)
|
|
150
|
+
return { uniqueStatuses: [...statuses].sort(), uniqueCategories: [...categories].sort() }
|
|
151
|
+
}, [items])
|
|
152
|
+
|
|
153
|
+
// Filter items
|
|
154
|
+
const filteredItems = useMemo(() => {
|
|
155
|
+
if (!showFilterBar) return items
|
|
156
|
+
const { search, statuses, categories, dateRange } = filters
|
|
157
|
+
const hasFilters = search || statuses.length > 0 || categories.length > 0 || dateRange.start || dateRange.end
|
|
158
|
+
if (!hasFilters) return items
|
|
159
|
+
|
|
160
|
+
const searchLower = search.toLowerCase()
|
|
161
|
+
|
|
162
|
+
const matchesItem = (item: TimelineItem): boolean => {
|
|
163
|
+
if (search && !item.title.toLowerCase().includes(searchLower) && !item.description?.toLowerCase().includes(searchLower)) return false
|
|
164
|
+
if (statuses.length > 0 && !statuses.includes(item.status)) return false
|
|
165
|
+
if (categories.length > 0 && (!item.category || !categories.includes(item.category))) return false
|
|
166
|
+
if (dateRange.start || dateRange.end) {
|
|
167
|
+
const itemEnd = item.endDate ? new Date(item.endDate) : null
|
|
168
|
+
const itemStart = item.startDate ? new Date(item.startDate) : null
|
|
169
|
+
if (dateRange.start && itemEnd && isBefore(itemEnd, dateRange.start)) return false
|
|
170
|
+
if (dateRange.end && itemStart && isAfter(itemStart, dateRange.end)) return false
|
|
171
|
+
}
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const filterTree = (itemList: TimelineItem[]): TimelineItem[] => {
|
|
176
|
+
const result: TimelineItem[] = []
|
|
177
|
+
for (const item of itemList) {
|
|
178
|
+
const filteredChildren = item.children ? filterTree(item.children) : undefined
|
|
179
|
+
if (matchesItem(item) || (filteredChildren && filteredChildren.length > 0)) {
|
|
180
|
+
result.push({ ...item, children: filteredChildren })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return result
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return filterTree(items)
|
|
187
|
+
}, [items, filters, showFilterBar])
|
|
188
|
+
|
|
189
|
+
const hasActiveFilters = filters.search || filters.statuses.length > 0 || filters.categories.length > 0 || filters.dateRange.start || filters.dateRange.end
|
|
190
|
+
|
|
191
|
+
const toggleCollapse = useCallback((id: string) => {
|
|
192
|
+
setCollapsed((prev) => {
|
|
193
|
+
const next = new Set(prev)
|
|
194
|
+
if (next.has(id)) next.delete(id)
|
|
195
|
+
else next.add(id)
|
|
196
|
+
return next
|
|
197
|
+
})
|
|
198
|
+
}, [])
|
|
199
|
+
|
|
200
|
+
// Build hierarchy from children[] arrays or dependencies
|
|
201
|
+
const { childrenMap, parentMap, itemMap } = useMemo(() => {
|
|
202
|
+
const childrenMap = new Map<string, string[]>()
|
|
203
|
+
const parentMap = new Map<string, string>()
|
|
204
|
+
const itemMap = new Map(filteredItems.map((item) => [item.id, item]))
|
|
205
|
+
|
|
206
|
+
if (hierarchical) {
|
|
207
|
+
for (const item of filteredItems) {
|
|
208
|
+
if (item.children && item.children.length > 0) {
|
|
209
|
+
const childIds = item.children
|
|
210
|
+
.filter((child) => itemMap.has(child.id))
|
|
211
|
+
.map((child) => child.id)
|
|
212
|
+
if (childIds.length > 0) {
|
|
213
|
+
childrenMap.set(item.id, childIds)
|
|
214
|
+
for (const childId of childIds) {
|
|
215
|
+
parentMap.set(childId, item.id)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const dep of dependencies) {
|
|
222
|
+
if (dep.type === 'blocks') {
|
|
223
|
+
const parentItem = itemMap.get(dep.fromId)
|
|
224
|
+
const childItem = itemMap.get(dep.toId)
|
|
225
|
+
if (!parentItem || !childItem) continue
|
|
226
|
+
const parentLevel = getHierarchyLevel(parentItem.title)
|
|
227
|
+
const childLevel = getHierarchyLevel(childItem.title)
|
|
228
|
+
if (parentLevel > childLevel && !parentMap.has(dep.toId)) {
|
|
229
|
+
const children = childrenMap.get(dep.fromId) || []
|
|
230
|
+
children.push(dep.toId)
|
|
231
|
+
childrenMap.set(dep.fromId, children)
|
|
232
|
+
parentMap.set(dep.toId, dep.fromId)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { childrenMap, parentMap, itemMap }
|
|
239
|
+
}, [filteredItems, dependencies, hierarchical])
|
|
240
|
+
|
|
241
|
+
// Parse dates for all items
|
|
242
|
+
const itemDates = useMemo(() => {
|
|
243
|
+
const now = new Date()
|
|
244
|
+
const currentYear = now.getFullYear()
|
|
245
|
+
const dates = new Map<string, { start: Date; end: Date }>()
|
|
246
|
+
|
|
247
|
+
for (const item of filteredItems) {
|
|
248
|
+
const parsedRange = parseDateRangeFromTitle(item.title, currentYear)
|
|
249
|
+
if (parsedRange) { dates.set(item.id, parsedRange); continue }
|
|
250
|
+
|
|
251
|
+
const startStr = item.startDate
|
|
252
|
+
const endStr = item.endDate
|
|
253
|
+
|
|
254
|
+
if (startStr && endStr) {
|
|
255
|
+
const s = new Date(startStr)
|
|
256
|
+
const e = new Date(endStr)
|
|
257
|
+
if (!isNaN(s.getTime()) && !isNaN(e.getTime())) {
|
|
258
|
+
dates.set(item.id, { start: startOfDay(s), end: endOfDay(e) }); continue
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (endStr) {
|
|
263
|
+
const e = new Date(endStr)
|
|
264
|
+
if (!isNaN(e.getTime())) {
|
|
265
|
+
const s = item.startDate ? new Date(item.startDate) :
|
|
266
|
+
item.createdAt ? new Date(item.createdAt) : addDays(e, -14)
|
|
267
|
+
dates.set(item.id, { start: startOfDay(s), end: endOfDay(e) }); continue
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (startStr) {
|
|
272
|
+
const s = new Date(startStr)
|
|
273
|
+
if (!isNaN(s.getTime())) {
|
|
274
|
+
dates.set(item.id, { start: startOfDay(s), end: endOfDay(addDays(s, 14)) }); continue
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const fallbackStart = item.createdAt ? new Date(item.createdAt) : now
|
|
279
|
+
dates.set(item.id, { start: startOfDay(fallbackStart), end: endOfDay(addDays(fallbackStart, 14)) })
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return dates
|
|
283
|
+
}, [filteredItems])
|
|
284
|
+
|
|
285
|
+
// Build flattened task tree
|
|
286
|
+
const tasks = useMemo(() => {
|
|
287
|
+
const roots: string[] = []
|
|
288
|
+
for (const item of filteredItems) {
|
|
289
|
+
const parentId = parentMap.get(item.id)
|
|
290
|
+
if (!parentId || !itemMap.has(parentId)) roots.push(item.id)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const sortByDate = (ids: string[]) => [...ids].sort((a, b) => {
|
|
294
|
+
const dateA = itemDates.get(a)?.start || new Date()
|
|
295
|
+
const dateB = itemDates.get(b)?.start || new Date()
|
|
296
|
+
return dateA.getTime() - dateB.getTime()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const taskList: GanttTask[] = []
|
|
300
|
+
const visited = new Set<string>()
|
|
301
|
+
|
|
302
|
+
const traverse = (id: string, depth: number) => {
|
|
303
|
+
if (visited.has(id)) return
|
|
304
|
+
visited.add(id)
|
|
305
|
+
const item = itemMap.get(id)
|
|
306
|
+
if (!item) return
|
|
307
|
+
const dates = itemDates.get(id)
|
|
308
|
+
if (!dates) return
|
|
309
|
+
|
|
310
|
+
const children = childrenMap.get(id) || []
|
|
311
|
+
const hasChildren = children.length > 0
|
|
312
|
+
const progress = item.progress ?? statusToProgress(item.status)
|
|
313
|
+
|
|
314
|
+
const now = new Date()
|
|
315
|
+
const taskStart = startOfDay(dates.start)
|
|
316
|
+
const taskEnd = endOfDay(dates.end)
|
|
317
|
+
const totalDuration = taskEnd.getTime() - taskStart.getTime()
|
|
318
|
+
const elapsed = now.getTime() - taskStart.getTime()
|
|
319
|
+
const timeProgress = totalDuration > 0
|
|
320
|
+
? Math.max(0, Math.min(100, (elapsed / totalDuration) * 100))
|
|
321
|
+
: 0
|
|
322
|
+
|
|
323
|
+
const expectedProgress = calculateExpectedProgress(taskStart, taskEnd, now)
|
|
324
|
+
const healthResult = calculateHealthStatus(progress, expectedProgress, item.status === 'blocked')
|
|
325
|
+
|
|
326
|
+
taskList.push({
|
|
327
|
+
item, start: taskStart, end: taskEnd, progress, timeProgress,
|
|
328
|
+
depth, hasChildren, parentId: parentMap.get(id) || null,
|
|
329
|
+
healthStatus: healthResult.status,
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
if (hasChildren && !collapsed.has(id)) {
|
|
333
|
+
for (const childId of sortByDate(children)) traverse(childId, depth + 1)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
for (const rootId of sortByDate(roots)) traverse(rootId, 0)
|
|
338
|
+
return taskList
|
|
339
|
+
}, [filteredItems, itemDates, itemMap, childrenMap, parentMap, collapsed, statusToProgress])
|
|
340
|
+
|
|
341
|
+
// Keyboard navigation
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
const wrapper = wrapperRef.current
|
|
344
|
+
if (!wrapper) return
|
|
345
|
+
|
|
346
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
347
|
+
if (!wrapper.contains(document.activeElement) && document.activeElement !== wrapper) return
|
|
348
|
+
|
|
349
|
+
if (e.key === 'ArrowDown') {
|
|
350
|
+
e.preventDefault()
|
|
351
|
+
setFocusedRowIndex((prev) => Math.min(prev + 1, tasks.length - 1))
|
|
352
|
+
} else if (e.key === 'ArrowUp') {
|
|
353
|
+
e.preventDefault()
|
|
354
|
+
setFocusedRowIndex((prev) => Math.max(prev - 1, 0))
|
|
355
|
+
} else if ((e.key === 'Enter' || e.key === ' ') && focusedRowIndex >= 0 && focusedRowIndex < tasks.length) {
|
|
356
|
+
e.preventDefault()
|
|
357
|
+
handleItemClick(tasks[focusedRowIndex].item)
|
|
358
|
+
} else if (e.key === 'Escape') {
|
|
359
|
+
if (detailItem) {
|
|
360
|
+
setDetailItem(null)
|
|
361
|
+
} else {
|
|
362
|
+
setFocusedRowIndex(-1)
|
|
363
|
+
wrapper.blur()
|
|
364
|
+
}
|
|
365
|
+
} else if (e.key === 'ArrowRight' && focusedRowIndex >= 0) {
|
|
366
|
+
const task = tasks[focusedRowIndex]
|
|
367
|
+
if (task?.hasChildren && collapsed.has(task.item.id)) {
|
|
368
|
+
toggleCollapse(task.item.id)
|
|
369
|
+
}
|
|
370
|
+
} else if (e.key === 'ArrowLeft' && focusedRowIndex >= 0) {
|
|
371
|
+
const task = tasks[focusedRowIndex]
|
|
372
|
+
if (task?.hasChildren && !collapsed.has(task.item.id)) {
|
|
373
|
+
toggleCollapse(task.item.id)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
wrapper.addEventListener('keydown', handleKeyDown)
|
|
379
|
+
return () => wrapper.removeEventListener('keydown', handleKeyDown)
|
|
380
|
+
}, [tasks, focusedRowIndex, collapsed, detailItem, handleItemClick, toggleCollapse])
|
|
381
|
+
|
|
382
|
+
// Scroll focused row into view
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (focusedRowIndex >= 0 && infoColumnRef.current) {
|
|
385
|
+
const row = infoColumnRef.current.children[focusedRowIndex] as HTMLElement
|
|
386
|
+
row?.scrollIntoView({ block: 'nearest' })
|
|
387
|
+
}
|
|
388
|
+
}, [focusedRowIndex])
|
|
389
|
+
|
|
390
|
+
// Calculate date range
|
|
391
|
+
const { startDate, days } = useMemo(() => {
|
|
392
|
+
const now = new Date()
|
|
393
|
+
const currentYear = now.getFullYear()
|
|
394
|
+
let minDate = new Date(currentYear, 0, 1)
|
|
395
|
+
let maxDate = new Date(currentYear, 11, 31)
|
|
396
|
+
|
|
397
|
+
for (const task of tasks) {
|
|
398
|
+
if (task.start < minDate) minDate = startOfDay(task.start)
|
|
399
|
+
if (task.end > maxDate) maxDate = endOfDay(task.end)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
minDate = startOfMonth(minDate)
|
|
403
|
+
maxDate = endOfDay(addDays(maxDate, 30))
|
|
404
|
+
const dayCount = differenceInDays(maxDate, minDate) + 1
|
|
405
|
+
const daysArr: Date[] = []
|
|
406
|
+
for (let i = 0; i < dayCount; i++) daysArr.push(addDays(minDate, i))
|
|
407
|
+
return { startDate: minDate, days: daysArr }
|
|
408
|
+
}, [tasks])
|
|
409
|
+
|
|
410
|
+
// Group days by month
|
|
411
|
+
const months = useMemo(() => {
|
|
412
|
+
const monthsArr: { month: Date; days: number; label: string }[] = []
|
|
413
|
+
let currentMonth = -1
|
|
414
|
+
let monthDays = 0
|
|
415
|
+
|
|
416
|
+
for (const day of days) {
|
|
417
|
+
const m = getMonth(day)
|
|
418
|
+
if (m !== currentMonth) {
|
|
419
|
+
if (currentMonth !== -1) {
|
|
420
|
+
monthsArr.push({ month: addDays(day, -1), days: monthDays, label: format(addDays(day, -1), 'MMM yyyy') })
|
|
421
|
+
}
|
|
422
|
+
currentMonth = m
|
|
423
|
+
monthDays = 1
|
|
424
|
+
} else { monthDays++ }
|
|
425
|
+
}
|
|
426
|
+
if (monthDays > 0 && days.length > 0) {
|
|
427
|
+
monthsArr.push({ month: days[days.length - 1], days: monthDays, label: format(days[days.length - 1], 'MMM yyyy') })
|
|
428
|
+
}
|
|
429
|
+
return monthsArr
|
|
430
|
+
}, [days])
|
|
431
|
+
|
|
432
|
+
// Time header units
|
|
433
|
+
const timeHeaderUnits = useMemo(() => {
|
|
434
|
+
if (dayWidth >= 16) {
|
|
435
|
+
return {
|
|
436
|
+
mode: 'days' as const,
|
|
437
|
+
units: days.map((day, i) => ({
|
|
438
|
+
date: day, width: dayWidth,
|
|
439
|
+
label: day.getDate() === 1 || i === 0 ? format(day, 'd') : String(day.getDate()),
|
|
440
|
+
isWeekend: isWeekend(day), isToday: mounted && isToday(day),
|
|
441
|
+
})),
|
|
442
|
+
}
|
|
443
|
+
} else if (dayWidth >= 12) {
|
|
444
|
+
return {
|
|
445
|
+
mode: 'sparse-days' as const,
|
|
446
|
+
units: days.map((day, i) => ({
|
|
447
|
+
date: day, width: dayWidth,
|
|
448
|
+
label: (day.getDay() === 1 || day.getDate() === 1 || i === 0) ? String(day.getDate()) : '',
|
|
449
|
+
isWeekend: isWeekend(day), isToday: mounted && isToday(day),
|
|
450
|
+
})),
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
const weeks: { startDate: Date; endDate: Date; days: number; label: string; hasToday: boolean }[] = []
|
|
454
|
+
let currentWeek = -1, weekDays = 0, weekStart: Date | null = null, weekHasToday = false
|
|
455
|
+
|
|
456
|
+
for (const day of days) {
|
|
457
|
+
const week = getWeek(day)
|
|
458
|
+
if (week !== currentWeek) {
|
|
459
|
+
if (currentWeek !== -1 && weekStart) {
|
|
460
|
+
weeks.push({ startDate: weekStart, endDate: addDays(day, -1), days: weekDays, label: format(weekStart, 'MMM d'), hasToday: mounted && weekHasToday })
|
|
461
|
+
}
|
|
462
|
+
currentWeek = week; weekDays = 1; weekStart = day; weekHasToday = mounted && isToday(day)
|
|
463
|
+
} else {
|
|
464
|
+
weekDays++
|
|
465
|
+
if (mounted && isToday(day)) weekHasToday = true
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (weekDays > 0 && weekStart) {
|
|
469
|
+
weeks.push({ startDate: weekStart, endDate: days[days.length - 1], days: weekDays, label: format(weekStart, 'MMM d'), hasToday: mounted && weekHasToday })
|
|
470
|
+
}
|
|
471
|
+
return { mode: 'weeks' as const, units: weeks.map((w) => ({ ...w, width: w.days * dayWidth })) }
|
|
472
|
+
}
|
|
473
|
+
}, [days, dayWidth, mounted])
|
|
474
|
+
|
|
475
|
+
// Scroll to today on mount
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
if (bodyScrollRef.current) {
|
|
478
|
+
const today = new Date()
|
|
479
|
+
const daysFromStart = differenceInDays(today, startDate)
|
|
480
|
+
bodyScrollRef.current.scrollLeft = Math.max(0, daysFromStart * dayWidth - 200)
|
|
481
|
+
}
|
|
482
|
+
}, [startDate, dayWidth])
|
|
483
|
+
|
|
484
|
+
const handleBodyScroll = useCallback(() => {
|
|
485
|
+
if (bodyScrollRef.current && headerTimelineRef.current) {
|
|
486
|
+
headerTimelineRef.current.scrollLeft = bodyScrollRef.current.scrollLeft
|
|
487
|
+
}
|
|
488
|
+
}, [])
|
|
489
|
+
|
|
490
|
+
const getBarPosition = useCallback((task: GanttTask, rowIndex: number, useDragDates = false) => {
|
|
491
|
+
const dates = useDragDates && dragState?.taskId === task.item.id
|
|
492
|
+
? { start: dragState.currentStart, end: dragState.currentEnd }
|
|
493
|
+
: { start: task.start, end: task.end }
|
|
494
|
+
const startOffset = differenceInDays(dates.start, startDate)
|
|
495
|
+
const duration = differenceInDays(dates.end, dates.start) + 1
|
|
496
|
+
const heights = [22, 20, 18, 16]
|
|
497
|
+
const barHeight = heights[Math.min(task.depth, 3)]
|
|
498
|
+
const topOffset = (ROW_HEIGHT - barHeight) / 2
|
|
499
|
+
return { left: startOffset * dayWidth, width: Math.max(duration * dayWidth - 2, 16), top: rowIndex * ROW_HEIGHT + topOffset, height: barHeight }
|
|
500
|
+
}, [dragState, startDate, dayWidth])
|
|
501
|
+
|
|
502
|
+
// Drag handlers
|
|
503
|
+
const handleDragStart = useCallback((e: React.MouseEvent, task: GanttTask, type: 'move' | 'resize-start' | 'resize-end') => {
|
|
504
|
+
if (!onDateChange) return
|
|
505
|
+
e.preventDefault(); e.stopPropagation()
|
|
506
|
+
setDragState({ taskId: task.item.id, type, startX: e.clientX, originalStart: task.start, originalEnd: task.end, currentStart: task.start, currentEnd: task.end })
|
|
507
|
+
}, [onDateChange])
|
|
508
|
+
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
if (!dragState) return
|
|
511
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
512
|
+
const deltaDays = Math.round((e.clientX - dragState.startX) / dayWidth)
|
|
513
|
+
if (deltaDays === 0 && dragState.currentStart.getTime() === dragState.originalStart.getTime()) return
|
|
514
|
+
let newStart = dragState.originalStart, newEnd = dragState.originalEnd
|
|
515
|
+
if (dragState.type === 'move') { newStart = addDays(dragState.originalStart, deltaDays); newEnd = addDays(dragState.originalEnd, deltaDays) }
|
|
516
|
+
else if (dragState.type === 'resize-start') { newStart = addDays(dragState.originalStart, deltaDays); if (newStart >= newEnd) newStart = addDays(newEnd, -1) }
|
|
517
|
+
else if (dragState.type === 'resize-end') { newEnd = addDays(dragState.originalEnd, deltaDays); if (newEnd <= newStart) newEnd = addDays(newStart, 1) }
|
|
518
|
+
setDragState((prev) => prev ? { ...prev, currentStart: newStart, currentEnd: newEnd } : null)
|
|
519
|
+
}
|
|
520
|
+
const handleMouseUp = () => {
|
|
521
|
+
if (dragState && onDateChange) {
|
|
522
|
+
const startChanged = dragState.currentStart.getTime() !== dragState.originalStart.getTime()
|
|
523
|
+
const endChanged = dragState.currentEnd.getTime() !== dragState.originalEnd.getTime()
|
|
524
|
+
if (startChanged || endChanged) onDateChange(dragState.taskId, dragState.currentStart, dragState.currentEnd)
|
|
525
|
+
}
|
|
526
|
+
setDragState(null)
|
|
527
|
+
}
|
|
528
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
529
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
530
|
+
return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp) }
|
|
531
|
+
}, [dragState, dayWidth, onDateChange])
|
|
532
|
+
|
|
533
|
+
// Dependency arrow paths
|
|
534
|
+
const dependencyPaths = useMemo(() => {
|
|
535
|
+
const paths: Array<{ path: string; fromId: string; toId: string }> = []
|
|
536
|
+
const taskIndexMap = new Map<string, number>()
|
|
537
|
+
tasks.forEach((task, index) => taskIndexMap.set(task.item.id, index))
|
|
538
|
+
|
|
539
|
+
for (const dep of dependencies) {
|
|
540
|
+
const fromIdx = taskIndexMap.get(dep.fromId)
|
|
541
|
+
const toIdx = taskIndexMap.get(dep.toId)
|
|
542
|
+
if (fromIdx !== undefined && toIdx !== undefined) {
|
|
543
|
+
const fromPos = getBarPosition(tasks[fromIdx], fromIdx)
|
|
544
|
+
const toPos = getBarPosition(tasks[toIdx], toIdx)
|
|
545
|
+
const x1 = fromPos.left + fromPos.width, y1 = fromPos.top + fromPos.height / 2
|
|
546
|
+
const x2 = toPos.left, y2 = toPos.top + toPos.height / 2
|
|
547
|
+
const midX = (x1 + x2) / 2
|
|
548
|
+
paths.push({ path: `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`, fromId: dep.fromId, toId: dep.toId })
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return paths
|
|
552
|
+
}, [tasks, dependencies, getBarPosition])
|
|
553
|
+
|
|
554
|
+
const timelineWidth = days.length * dayWidth
|
|
555
|
+
const bodyHeight = tasks.length * ROW_HEIGHT
|
|
556
|
+
|
|
557
|
+
// Board view: group items by status
|
|
558
|
+
const boardColumns = useMemo(() => {
|
|
559
|
+
if (viewMode !== 'board') return []
|
|
560
|
+
const statuses = boardStatuses || uniqueStatuses
|
|
561
|
+
const columns: Array<{ status: string; items: TimelineItem[] }> = statuses.map((s) => ({ status: s, items: [] }))
|
|
562
|
+
const statusSet = new Set(statuses)
|
|
563
|
+
|
|
564
|
+
const flatItems = (itemList: TimelineItem[]): TimelineItem[] => {
|
|
565
|
+
const result: TimelineItem[] = []
|
|
566
|
+
for (const item of itemList) {
|
|
567
|
+
result.push(item)
|
|
568
|
+
if (item.children) result.push(...flatItems(item.children))
|
|
569
|
+
}
|
|
570
|
+
return result
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
for (const item of flatItems(filteredItems)) {
|
|
574
|
+
if (statusSet.has(item.status)) {
|
|
575
|
+
columns.find((c) => c.status === item.status)?.items.push(item)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return columns
|
|
579
|
+
}, [viewMode, filteredItems, boardStatuses, uniqueStatuses])
|
|
580
|
+
|
|
581
|
+
// List view: flatten all items
|
|
582
|
+
const listItems = useMemo(() => {
|
|
583
|
+
if (viewMode !== 'list') return []
|
|
584
|
+
return tasks.map((t) => t)
|
|
585
|
+
}, [viewMode, tasks])
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
// State
|
|
589
|
+
zoomIndex,
|
|
590
|
+
setZoomIndex,
|
|
591
|
+
collapsed,
|
|
592
|
+
mounted,
|
|
593
|
+
dragState,
|
|
594
|
+
filters,
|
|
595
|
+
setFilters,
|
|
596
|
+
isFullscreen,
|
|
597
|
+
viewMode,
|
|
598
|
+
setViewMode,
|
|
599
|
+
detailItem,
|
|
600
|
+
setDetailItem,
|
|
601
|
+
focusedRowIndex,
|
|
602
|
+
boardDragItem,
|
|
603
|
+
setBoardDragItem,
|
|
604
|
+
|
|
605
|
+
// Derived values
|
|
606
|
+
dayWidth,
|
|
607
|
+
hasActiveFilters,
|
|
608
|
+
uniqueStatuses,
|
|
609
|
+
uniqueCategories,
|
|
610
|
+
filteredItems,
|
|
611
|
+
tasks,
|
|
612
|
+
startDate,
|
|
613
|
+
days,
|
|
614
|
+
months,
|
|
615
|
+
timeHeaderUnits,
|
|
616
|
+
dependencyPaths,
|
|
617
|
+
timelineWidth,
|
|
618
|
+
bodyHeight,
|
|
619
|
+
boardColumns,
|
|
620
|
+
listItems,
|
|
621
|
+
|
|
622
|
+
// Refs
|
|
623
|
+
bodyScrollRef,
|
|
624
|
+
headerTimelineRef,
|
|
625
|
+
wrapperRef,
|
|
626
|
+
infoColumnRef,
|
|
627
|
+
|
|
628
|
+
// Handlers
|
|
629
|
+
toggleFullscreen,
|
|
630
|
+
handleItemClick,
|
|
631
|
+
toggleCollapse,
|
|
632
|
+
handleBodyScroll,
|
|
633
|
+
getBarPosition,
|
|
634
|
+
handleDragStart,
|
|
635
|
+
|
|
636
|
+
// Props pass-through (needed by the render shell)
|
|
637
|
+
categoryColors,
|
|
638
|
+
onDateChange,
|
|
639
|
+
onItemEdit,
|
|
640
|
+
onStatusChange,
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export type UseGanttStateReturn = ReturnType<typeof useGanttState>
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
export { GanttChart } from './GanttChart'
|
|
2
|
+
export { GanttTimelineView } from './GanttTimelineView'
|
|
3
|
+
export type { GanttTimelineViewProps } from './GanttTimelineView'
|
|
4
|
+
export { GanttBoardView } from './GanttBoardView'
|
|
5
|
+
export type { GanttBoardViewProps } from './GanttBoardView'
|
|
6
|
+
export { GanttListView } from './GanttListView'
|
|
7
|
+
export type { GanttListViewProps } from './GanttListView'
|
|
8
|
+
export { GanttFilterBar } from './GanttFilterBar'
|
|
9
|
+
export type { GanttFilterBarProps } from './GanttFilterBar'
|
|
10
|
+
export { useGanttState } from './hooks/useGanttState'
|
|
11
|
+
export type { UseGanttStateReturn } from './hooks/useGanttState'
|
|
2
12
|
export type {
|
|
3
13
|
TimelineItem,
|
|
4
14
|
TimelineDependency,
|