Skip to content

Commit 0d4e166

Browse files
committed
feat: add GanttView component and integrate color field in GanttConfig
1 parent 1c59f20 commit 0d4e166

File tree

4 files changed

+351
-163
lines changed

4 files changed

+351
-163
lines changed
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
"use client"
10+
11+
import * as React from "react"
12+
import {
13+
ChevronLeft,
14+
ChevronRight,
15+
ZoomIn,
16+
ZoomOut,
17+
Calendar as CalendarIcon,
18+
MoreHorizontal,
19+
Plus
20+
} from "lucide-react"
21+
import {
22+
cn,
23+
Button,
24+
Select,
25+
SelectContent,
26+
SelectItem,
27+
SelectTrigger,
28+
SelectValue,
29+
Separator
30+
} from "@object-ui/components"
31+
32+
const HEADER_HEIGHT = 50;
33+
const ROW_HEIGHT = 40;
34+
const COLUMN_WIDTH = 100; // Time column width
35+
36+
export interface GanttTask {
37+
id: string | number
38+
title: string
39+
start: Date
40+
end: Date
41+
progress: number
42+
color?: string
43+
data?: any
44+
dependencies?: (string | number)[]
45+
}
46+
47+
export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter';
48+
49+
export interface GanttViewProps {
50+
tasks: GanttTask[]
51+
viewMode?: GanttViewMode
52+
startDate?: Date
53+
endDate?: Date
54+
onTaskClick?: (task: GanttTask) => void
55+
onViewChange?: (view: GanttViewMode) => void
56+
onAddClick?: () => void
57+
className?: string
58+
}
59+
60+
export function GanttView({
61+
tasks,
62+
viewMode = 'month',
63+
startDate,
64+
endDate,
65+
onTaskClick,
66+
onViewChange,
67+
onAddClick,
68+
className
69+
}: GanttViewProps) {
70+
const [currentDate, setCurrentDate] = React.useState(new Date());
71+
const [columnWidth, setColumnWidth] = React.useState(60);
72+
73+
// Calculate timeline range
74+
const timelineRange = React.useMemo(() => {
75+
let start = startDate ? new Date(startDate) : new Date();
76+
let end = endDate ? new Date(endDate) : new Date();
77+
78+
if (!startDate && tasks.length > 0) {
79+
// Find min start date
80+
start = new Date(Math.min(...tasks.map(t => t.start.getTime())));
81+
// Add padding
82+
start.setDate(start.getDate() - 7);
83+
}
84+
85+
if (!endDate && tasks.length > 0) {
86+
// Find max end date
87+
end = new Date(Math.max(...tasks.map(t => t.end.getTime())));
88+
// Add padding
89+
end.setDate(end.getDate() + 14);
90+
}
91+
92+
// Normalize to start of day
93+
start.setHours(0,0,0,0);
94+
end.setHours(23,59,59,999);
95+
96+
return { start, end };
97+
}, [startDate, endDate, tasks]);
98+
99+
// Generate timeline columns
100+
const timeColumns = React.useMemo(() => {
101+
const cols: { date: Date; label: string; isWeekend: boolean }[] = [];
102+
const current = new Date(timelineRange.start);
103+
104+
while (current <= timelineRange.end) {
105+
cols.push({
106+
date: new Date(current),
107+
label: current.getDate().toString(),
108+
isWeekend: current.getDay() === 0 || current.getDay() === 6
109+
});
110+
current.setDate(current.getDate() + 1);
111+
}
112+
113+
return cols;
114+
}, [timelineRange]);
115+
116+
const taskListWidth = 300;
117+
118+
const headerRef = React.useRef<HTMLDivElement>(null);
119+
const listRef = React.useRef<HTMLDivElement>(null);
120+
const timelineRef = React.useRef<HTMLDivElement>(null);
121+
122+
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
123+
// Sync horizontal scroll to header
124+
if (headerRef.current) {
125+
headerRef.current.scrollLeft = e.currentTarget.scrollLeft;
126+
}
127+
// Sync vertical scroll to task list
128+
if (listRef.current) {
129+
listRef.current.scrollTop = e.currentTarget.scrollTop;
130+
}
131+
};
132+
133+
const getTaskStyle = (task: GanttTask) => {
134+
const totalDuration = timelineRange.end.getTime() - timelineRange.start.getTime();
135+
const tickWidth = columnWidth; // px per day
136+
const msPerDay = 1000 * 60 * 60 * 24;
137+
138+
const startOffsetMs = task.start.getTime() - timelineRange.start.getTime();
139+
const durationMs = task.end.getTime() - task.start.getTime();
140+
141+
const left = (startOffsetMs / msPerDay) * tickWidth;
142+
const width = Math.max((durationMs / msPerDay) * tickWidth, tickWidth); // Min 1 day width
143+
144+
return { left, width };
145+
};
146+
147+
return (
148+
<div className={cn("flex flex-col h-full bg-background border rounded-lg overflow-hidden", className)}>
149+
{/* Toolbar */}
150+
<div className="flex items-center justify-between p-2 border-b bg-card">
151+
<div className="flex items-center gap-2">
152+
<Button variant="outline" size="sm" onClick={() => onAddClick?.()}>
153+
<Plus className="h-4 w-4 mr-2" />
154+
New Task
155+
</Button>
156+
<div className="h-4 w-px bg-border mx-2" />
157+
<Button variant="ghost" size="icon" className="h-8 w-8">
158+
<ChevronLeft className="h-4 w-4" />
159+
</Button>
160+
<Button variant="ghost" size="icon" className="h-8 w-8">
161+
<ChevronRight className="h-4 w-4" />
162+
</Button>
163+
<span className="font-semibold text-sm">
164+
{timelineRange.start.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })}
165+
</span>
166+
</div>
167+
168+
<div className="flex items-center gap-2">
169+
<Select value={viewMode} onValueChange={(v) => onViewChange?.(v as GanttViewMode)}>
170+
<SelectTrigger className="w-[120px] h-8">
171+
<SelectValue />
172+
</SelectTrigger>
173+
<SelectContent>
174+
<SelectItem value="day">Day View</SelectItem>
175+
<SelectItem value="week">Week View</SelectItem>
176+
<SelectItem value="month">Month View</SelectItem>
177+
<SelectItem value="quarter">Quarter View</SelectItem>
178+
</SelectContent>
179+
</Select>
180+
<div className="flex bg-muted rounded-md p-1">
181+
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setColumnWidth(prev => Math.max(20, prev - 10))}>
182+
<ZoomOut className="h-3 w-3" />
183+
</Button>
184+
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setColumnWidth(prev => Math.min(100, prev + 10))}>
185+
<ZoomIn className="h-3 w-3" />
186+
</Button>
187+
</div>
188+
</div>
189+
</div>
190+
191+
{/* Gantt Body */}
192+
<div className="flex flex-col flex-1 overflow-hidden">
193+
{/* Headers Row */}
194+
<div className="flex border-b bg-muted/30 shrink-0 h-[50px]">
195+
{/* List Header */}
196+
<div
197+
className="flex items-center font-medium text-xs text-muted-foreground px-4 border-r bg-card z-20 shadow-sm"
198+
style={{ width: taskListWidth, minWidth: taskListWidth }}
199+
>
200+
<div className="flex-1">Task Name</div>
201+
<div className="w-20 text-right">Start</div>
202+
<div className="w-20 text-right">End</div>
203+
</div>
204+
205+
{/* Timeline Header */}
206+
<div className="flex-1 overflow-hidden" ref={headerRef}>
207+
<div className="flex h-full" style={{ width: timeColumns.length * columnWidth }}>
208+
{timeColumns.map((col, i) => (
209+
<div
210+
key={i}
211+
className={cn(
212+
"flex flex-col items-center justify-center border-r text-xs text-muted-foreground h-full",
213+
col.isWeekend && "bg-muted/50"
214+
)}
215+
style={{ width: columnWidth, minWidth: columnWidth }}
216+
>
217+
<span className="font-medium text-foreground">{col.label}</span>
218+
<span className="text-[10px] opacity-70">
219+
{col.date.toLocaleDateString(undefined, { weekday: 'narrow' })}
220+
</span>
221+
</div>
222+
))}
223+
</div>
224+
</div>
225+
</div>
226+
227+
{/* Content Row */}
228+
<div className="flex flex-1 overflow-hidden">
229+
{/* Left Side: Task List (Grid) */}
230+
<div
231+
className="overflow-hidden border-r bg-card z-10 shadow-sm"
232+
ref={listRef}
233+
style={{ width: taskListWidth, minWidth: taskListWidth }}
234+
>
235+
{tasks.map((task) => (
236+
<div
237+
key={task.id}
238+
className="flex items-center border-b px-4 hover:bg-accent/50 cursor-pointer transition-colors"
239+
style={{ height: ROW_HEIGHT }}
240+
onClick={() => onTaskClick?.(task)}
241+
>
242+
<div className="flex-1 truncate font-medium text-sm flex items-center gap-2">
243+
<div
244+
className="w-2 h-2 rounded-full"
245+
style={{ backgroundColor: task.color || '#3b82f6' }}
246+
/>
247+
{task.title}
248+
</div>
249+
<div className="w-20 text-right text-xs text-muted-foreground">
250+
{task.start.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' })}
251+
</div>
252+
<div className="w-20 text-right text-xs text-muted-foreground">
253+
{task.end.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' })}
254+
</div>
255+
</div>
256+
))}
257+
</div>
258+
259+
{/* Right Side: Timeline */}
260+
<div
261+
className="flex-1 overflow-auto bg-background/50 relative"
262+
ref={timelineRef}
263+
onScroll={handleScroll}
264+
>
265+
<div style={{ width: timeColumns.length * columnWidth }}>
266+
{/* Timeline Task Rows */}
267+
<div className="relative">
268+
{/* Background Grid */}
269+
<div className="absolute inset-0 flex pointer-events-none z-0">
270+
{timeColumns.map((col, i) => (
271+
<div
272+
key={i}
273+
className={cn(
274+
"border-r h-full",
275+
col.isWeekend && "bg-muted/20"
276+
)}
277+
style={{ width: columnWidth, minWidth: columnWidth }}
278+
/>
279+
))}
280+
</div>
281+
282+
{/* Task Bars */}
283+
{tasks.map((task) => {
284+
const style = getTaskStyle(task);
285+
return (
286+
<div
287+
key={task.id}
288+
className="relative border-b hover:bg-black/5"
289+
style={{ height: ROW_HEIGHT }}
290+
>
291+
<div
292+
className="absolute top-2 h-[calc(100%-16px)] rounded-sm bg-primary border border-primary-foreground/20 shadow-sm cursor-pointer hover:brightness-110 flex items-center px-2 group"
293+
style={{
294+
left: style.left,
295+
width: style.width,
296+
backgroundColor: task.color || '#3b82f6'
297+
}}
298+
onClick={() => onTaskClick?.(task)}
299+
>
300+
{/* Progress Filter */}
301+
{task.progress > 0 && (
302+
<div
303+
className="absolute left-0 top-0 bottom-0 bg-black/20 rounded-l-sm"
304+
style={{ width: `${task.progress}%` }}
305+
/>
306+
)}
307+
308+
{/* Hover Details */}
309+
<span className="text-[10px] text-white font-medium truncate opacity-0 group-hover:opacity-100 transition-opacity">
310+
{Math.round(task.progress)}%
311+
</span>
312+
</div>
313+
</div>
314+
)
315+
})}
316+
317+
{/* Current Time Indicator */}
318+
<div
319+
className="absolute top-0 bottom-0 w-px bg-red-500 z-20 pointer-events-none"
320+
style={{
321+
left: (new Date().getTime() - timelineRange.start.getTime()) / (1000 * 60 * 60 * 24) * columnWidth
322+
}}
323+
>
324+
<div className="w-2 h-2 rounded-full bg-red-500 -ml-[3px]" />
325+
</div>
326+
</div>
327+
</div>
328+
</div>
329+
</div>
330+
</div>
331+
</div>
332+
)
333+
}

0 commit comments

Comments
 (0)