Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo } from "react"; | |
| import { useQuery } from "@tanstack/react-query"; | |
| import Layout from "@/components/layout/layout"; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { CustomSelect } from "@/components/ui/custom-select"; | |
| import { Label } from "@/components/ui/label"; | |
| import { projectService, ganttChartService, employeeService, GanttChartResponseDto, GanttDeliverableDto, GanttFeatureDto, GanttTaskDto } from "@/lib/api"; | |
| import { ChevronRight, ChevronDown, User, Loader2 } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| import { toast } from "@/lib/custom-toast"; | |
| import { format, parseISO, differenceInDays, addDays, eachDayOfInterval } from "date-fns"; | |
| interface GanttTaskNameProps { | |
| level: number; | |
| name: string; | |
| assignee?: string | null; | |
| hasChildren: boolean; | |
| isExpanded?: boolean; | |
| onToggle?: () => void; | |
| } | |
| const GanttTaskName: React.FC<GanttTaskNameProps> = ({ level, name, assignee, hasChildren, isExpanded, onToggle }) => { | |
| const levelColors = [ | |
| { text: "text-blue-700", bg: "bg-blue-50", label: "Deliverable" }, | |
| { text: "text-green-700", bg: "bg-green-50", label: "Feature" }, | |
| { text: "text-purple-700", bg: "bg-purple-50", label: "Task" }, | |
| ]; | |
| const color = levelColors[level] || levelColors[2]; | |
| return ( | |
| <div className="border-b border-gray-200 p-3 hover:bg-gray-50 min-h-[60px] flex items-center" style={{ paddingLeft: `${level * 20 + 12}px` }}> | |
| {hasChildren && ( | |
| <button | |
| onClick={onToggle} | |
| className="flex-shrink-0 hover:bg-gray-200 rounded p-1 mr-2 transition-colors" | |
| > | |
| {isExpanded ? ( | |
| <ChevronDown className="h-4 w-4" /> | |
| ) : ( | |
| <ChevronRight className="h-4 w-4" /> | |
| )} | |
| </button> | |
| )} | |
| {!hasChildren && <div className="w-6 mr-2" />} | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2 mb-1"> | |
| <span className={cn("text-xs font-medium px-1.5 py-0.5 rounded", color.text, color.bg)}> | |
| {color.label} | |
| </span> | |
| </div> | |
| <div className="font-medium text-sm truncate">{name || "Unnamed"}</div> | |
| {assignee && ( | |
| <div className="flex items-center gap-1 text-xs text-gray-600 mt-1"> | |
| <User className="h-3 w-3" /> | |
| <span>{assignee}</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| interface GanttBarProps { | |
| level: number; | |
| start?: string | null; | |
| end?: string | null; | |
| projectStart: Date; | |
| totalDays: number; | |
| } | |
| const GanttBar: React.FC<GanttBarProps> = ({ level, start, end, projectStart, totalDays }) => { | |
| const { barStyle, duration } = useMemo(() => { | |
| if (!start || !end) return { barStyle: null, duration: 0 }; | |
| try { | |
| const startDate = parseISO(start); | |
| const endDate = parseISO(end); | |
| const daysFromStart = differenceInDays(startDate, projectStart); | |
| const taskDuration = differenceInDays(endDate, startDate) + 1; | |
| const leftPixels = daysFromStart * 40; // 40px per day | |
| const widthPixels = taskDuration * 40; | |
| return { | |
| barStyle: { | |
| left: `${leftPixels}px`, | |
| width: `${widthPixels}px` | |
| }, | |
| duration: taskDuration | |
| }; | |
| } catch (error) { | |
| return { barStyle: null, duration: 0 }; | |
| } | |
| }, [start, end, projectStart, totalDays]); | |
| const levelColors = [ | |
| { bg: "bg-blue-500", hover: "hover:bg-blue-600" }, | |
| { bg: "bg-green-500", hover: "hover:bg-green-600" }, | |
| { bg: "bg-purple-500", hover: "hover:bg-purple-600" }, | |
| ]; | |
| const color = levelColors[level] || levelColors[2]; | |
| return ( | |
| <div className="relative border-b border-gray-200 min-h-[60px] flex items-center"> | |
| {barStyle && ( | |
| <div | |
| className={cn( | |
| "absolute h-8 rounded shadow-sm flex items-center justify-center text-white text-xs font-medium transition-colors", | |
| color.bg, | |
| color.hover | |
| )} | |
| style={barStyle} | |
| title={start && end ? `${format(parseISO(start), 'MMM d, yyyy')} - ${format(parseISO(end), 'MMM d, yyyy')}` : ''} | |
| > | |
| <span className="truncate px-2"> | |
| {start && end && duration > 3 && `${format(parseISO(start), 'MMM d')} - ${format(parseISO(end), 'MMM d')}`} | |
| </span> | |
| </div> | |
| )} | |
| {/* Background grid lines for each day */} | |
| <div className="absolute inset-0 flex pointer-events-none"> | |
| {Array.from({ length: totalDays }).map((_, index) => ( | |
| <div | |
| key={index} | |
| className="border-r border-gray-100 flex-shrink-0" | |
| style={{ width: "40px" }} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const GanttChart = () => { | |
| const [selectedProjectId, setSelectedProjectId] = useState<string>(""); | |
| const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>(""); | |
| const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set()); | |
| const leftScrollRef = React.useRef<HTMLDivElement>(null); | |
| const rightScrollRef = React.useRef<HTMLDivElement>(null); | |
| const toggleExpand = (id: string) => { | |
| setExpandedItems(prev => { | |
| const newSet = new Set(prev); | |
| if (newSet.has(id)) { | |
| newSet.delete(id); | |
| } else { | |
| newSet.add(id); | |
| } | |
| return newSet; | |
| }); | |
| }; | |
| const { data: projects = [], isLoading: isLoadingProjects } = useQuery({ | |
| queryKey: ["projects"], | |
| queryFn: async () => { | |
| try { | |
| return await projectService.getAll(); | |
| } catch (error) { | |
| console.error("Failed to fetch projects:", error); | |
| toast.error("Failed to load projects"); | |
| return []; | |
| } | |
| }, | |
| }); | |
| const { data: employees = [], isLoading: isLoadingEmployees } = useQuery({ | |
| queryKey: ["employees"], | |
| queryFn: async () => { | |
| try { | |
| return await employeeService.getAll(); | |
| } catch (error) { | |
| console.error("Failed to fetch employees:", error); | |
| throw error; | |
| } | |
| }, | |
| }); | |
| // Convert projects to CustomSelect options | |
| const projectOptions = projects.map((project) => ({ | |
| value: String(project.id), | |
| label: project.projectName || `Project ${project.id}`, | |
| })); | |
| // Synchronize vertical scroll - only right panel scrolls, left follows | |
| const handleRightScroll = () => { | |
| if (leftScrollRef.current && rightScrollRef.current) { | |
| // Apply negative transform to left panel to simulate scroll | |
| const scrollTop = rightScrollRef.current.scrollTop; | |
| leftScrollRef.current.style.transform = `translateY(-${scrollTop}px)`; | |
| } | |
| }; | |
| const { data: ganttData, isLoading: isLoadingGantt, error: ganttError } = useQuery({ | |
| queryKey: ["ganttChart", selectedProjectId], | |
| queryFn: async () => { | |
| if (!selectedProjectId) return null; | |
| try { | |
| return await ganttChartService.getByProjectId(Number(selectedProjectId)); | |
| } catch (error) { | |
| console.error("Failed to fetch Gantt chart data:", error); | |
| toast.error("Failed to load Gantt chart data"); | |
| throw error; | |
| } | |
| }, | |
| enabled: !!selectedProjectId, | |
| }); | |
| // Get unique employees from the current project's Gantt data | |
| const projectEmployees = useMemo(() => { | |
| if (!ganttData?.deliverables) return []; | |
| const employeeNames = new Set<string>(); | |
| ganttData.deliverables.forEach((deliverable) => { | |
| deliverable.features.forEach((feature) => { | |
| if (feature.assignee) employeeNames.add(feature.assignee); | |
| feature.tasks.forEach((task) => { | |
| if (task.assignee) employeeNames.add(task.assignee); | |
| }); | |
| }); | |
| }); | |
| // Match employee names to employee IDs | |
| const projectEmployeeList = employees.filter((employee: any) => { | |
| const fullName = `${employee.firstName} ${employee.lastName}`; | |
| return employeeNames.has(fullName); | |
| }); | |
| return projectEmployeeList; | |
| }, [ganttData, employees]); | |
| // Convert project employees to CustomSelect options | |
| const employeeOptions = useMemo(() => [ | |
| { value: "", label: "All Employees" }, | |
| ...projectEmployees.map((employee: any) => ({ | |
| value: String(employee.id), | |
| label: `${employee.firstName} ${employee.lastName}`, | |
| })) | |
| ], [projectEmployees]); | |
| // Filter Gantt data based on selected employee | |
| const filteredGanttData = useMemo(() => { | |
| if (!ganttData || !selectedEmployeeId) return ganttData; | |
| const selectedEmployee = employees.find((emp: any) => String(emp.id) === selectedEmployeeId); | |
| if (!selectedEmployee) return ganttData; | |
| const employeeName = `${selectedEmployee.firstName} ${selectedEmployee.lastName}`; | |
| // Filter deliverables and their nested features/tasks | |
| const filteredDeliverables = ganttData.deliverables.map((deliverable) => { | |
| const filteredFeatures = deliverable.features | |
| .filter((feature) => feature.assignee === employeeName) | |
| .map((feature) => { | |
| const filteredTasks = feature.tasks.filter( | |
| (task) => task.assignee === employeeName | |
| ); | |
| return { ...feature, tasks: filteredTasks }; | |
| }); | |
| // Also include features where tasks match the employee | |
| const featuresWithMatchingTasks = deliverable.features | |
| .filter((feature) => feature.assignee !== employeeName) | |
| .map((feature) => { | |
| const filteredTasks = feature.tasks.filter( | |
| (task) => task.assignee === employeeName | |
| ); | |
| return filteredTasks.length > 0 ? { ...feature, tasks: filteredTasks } : null; | |
| }) | |
| .filter((f) => f !== null); | |
| const allFilteredFeatures = [...filteredFeatures, ...featuresWithMatchingTasks]; | |
| return allFilteredFeatures.length > 0 | |
| ? { ...deliverable, features: allFilteredFeatures } | |
| : null; | |
| }).filter((d) => d !== null) as GanttDeliverableDto[]; | |
| return { ...ganttData, deliverables: filteredDeliverables }; | |
| }, [ganttData, selectedEmployeeId, employees]); | |
| // Calculate project timeline bounds | |
| const timelineBounds = useMemo(() => { | |
| if (!filteredGanttData?.deliverables || filteredGanttData.deliverables.length === 0) { | |
| return null; | |
| } | |
| const allDates: Date[] = []; | |
| filteredGanttData.deliverables.forEach((deliverable) => { | |
| if (deliverable.start) allDates.push(parseISO(deliverable.start)); | |
| if (deliverable.end) allDates.push(parseISO(deliverable.end)); | |
| deliverable.features.forEach((feature) => { | |
| if (feature.start) allDates.push(parseISO(feature.start)); | |
| if (feature.end) allDates.push(parseISO(feature.end)); | |
| feature.tasks.forEach((task) => { | |
| if (task.start) allDates.push(parseISO(task.start)); | |
| if (task.end) allDates.push(parseISO(task.end)); | |
| }); | |
| }); | |
| }); | |
| if (allDates.length === 0) return null; | |
| const minDate = new Date(Math.min(...allDates.map(d => d.getTime()))); | |
| const maxDate = new Date(Math.max(...allDates.map(d => d.getTime()))); | |
| const projectStart = minDate; | |
| const projectEnd = maxDate; | |
| const totalDays = differenceInDays(projectEnd, projectStart) + 1; | |
| const days = eachDayOfInterval({ start: projectStart, end: projectEnd }); | |
| return { | |
| projectStart, | |
| projectEnd, | |
| totalDays, | |
| days, | |
| }; | |
| }, [filteredGanttData]); | |
| return ( | |
| <Layout> | |
| <div className="container mx-auto py-6 space-y-6"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h1 className="text-3xl font-bold tracking-tight">Gantt Chart</h1> | |
| <p className="text-muted-foreground mt-1"> | |
| View project timeline with deliverables, features, and tasks | |
| </p> | |
| </div> | |
| </div> | |
| <Card> | |
| <CardHeader> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <CardTitle>Timeline View</CardTitle> | |
| <CardDescription> | |
| Hierarchical view of deliverables, features, and tasks | |
| </CardDescription> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-64"> | |
| <CustomSelect | |
| options={projectOptions} | |
| value={selectedProjectId} | |
| onChange={setSelectedProjectId} | |
| placeholder="Select project..." | |
| disabled={isLoadingProjects} | |
| className="w-full" | |
| /> | |
| </div> | |
| <div className="w-64"> | |
| <CustomSelect | |
| options={employeeOptions} | |
| value={selectedEmployeeId} | |
| onChange={setSelectedEmployeeId} | |
| placeholder="Filter by employee..." | |
| disabled={isLoadingEmployees} | |
| className="w-full" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </CardHeader> | |
| <CardContent> | |
| {isLoadingGantt ? ( | |
| <div className="flex items-center justify-center py-12"> | |
| <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> | |
| <span className="ml-2 text-muted-foreground">Loading Gantt chart...</span> | |
| </div> | |
| ) : ganttError ? ( | |
| <div className="text-center py-12"> | |
| <p className="text-red-500">Failed to load Gantt chart data</p> | |
| <p className="text-sm text-muted-foreground mt-2"> | |
| Please try selecting a different project | |
| </p> | |
| </div> | |
| ) : !filteredGanttData?.deliverables || filteredGanttData.deliverables.length === 0 ? ( | |
| <div className="text-center py-12"> | |
| <p className="text-muted-foreground"> | |
| {selectedEmployeeId ? "No data available for the selected employee" : "No data available for this project"} | |
| </p> | |
| <p className="text-sm text-muted-foreground mt-2"> | |
| {selectedEmployeeId ? "Try selecting a different employee or clear the filter" : "Add deliverables, features, and tasks to see the timeline"} | |
| </p> | |
| </div> | |
| ) : !timelineBounds ? ( | |
| <div className="text-center py-12"> | |
| <p className="text-muted-foreground">No valid dates found for this project</p> | |
| </div> | |
| ) : ( | |
| <div className="border rounded-lg overflow-hidden bg-white"> | |
| {/* Single Scroll Container */} | |
| <div className="max-h-[600px] overflow-auto" ref={rightScrollRef}> | |
| <div style={{ minWidth: `${80 * 16 + timelineBounds.days.length * 40}px` }}> | |
| {/* Headers Row */} | |
| <div className="flex border-b-2 border-gray-300 bg-gray-50 sticky top-0 z-20"> | |
| <div className="w-80 flex-shrink-0 border-r-2 border-gray-300 p-3 font-semibold"> | |
| Task Name | |
| </div> | |
| <div className="flex"> | |
| {timelineBounds.days.map((day, index) => { | |
| const isWeekend = day.getDay() === 0 || day.getDay() === 6; | |
| return ( | |
| <div | |
| key={index} | |
| className={cn( | |
| "border-r border-gray-300 p-2 text-center font-semibold text-xs flex-shrink-0", | |
| isWeekend && "bg-gray-100" | |
| )} | |
| style={{ width: "40px" }} | |
| > | |
| <div className="text-[10px] text-gray-600">{format(day, 'EEE')}</div> | |
| <div>{format(day, 'd')}</div> | |
| <div className="text-[9px] text-gray-500">{format(day, 'MMM')}</div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| {/* Content Rows */} | |
| <div> | |
| {filteredGanttData.deliverables.map((deliverable) => { | |
| const deliverableId = `deliverable-${deliverable.id}`; | |
| const isDeliverableExpanded = expandedItems.has(deliverableId); | |
| return ( | |
| <React.Fragment key={deliverableId}> | |
| {/* Deliverable Row */} | |
| <div className={cn( | |
| "flex border-b border-gray-200 hover:bg-gray-50", | |
| (!deliverable.start || !deliverable.end) && "bg-red-50 border-l-4 border-l-red-500" | |
| )}> | |
| <div className="w-80 flex-shrink-0 border-r border-gray-200 px-2 py-1.5 flex items-center" style={{ paddingLeft: "8px" }}> | |
| {deliverable.features.length > 0 && ( | |
| <button | |
| onClick={() => toggleExpand(deliverableId)} | |
| className="flex-shrink-0 hover:bg-gray-200 rounded p-0.5 mr-1.5 transition-colors" | |
| > | |
| {isDeliverableExpanded ? ( | |
| <ChevronDown className="h-3.5 w-3.5" /> | |
| ) : ( | |
| <ChevronRight className="h-3.5 w-3.5" /> | |
| )} | |
| </button> | |
| )} | |
| {deliverable.features.length === 0 && <div className="w-5 mr-1.5" />} | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-1.5"> | |
| <span className="text-[10px] font-medium px-1 py-0.5 rounded text-blue-700 bg-blue-50"> | |
| Deliverable | |
| </span> | |
| <span className={cn( | |
| "font-medium text-xs truncate", | |
| (!deliverable.start || !deliverable.end) && "text-red-700" | |
| )}>{deliverable.name || "Unnamed"}</span> | |
| {(!deliverable.start || !deliverable.end) && ( | |
| <span className="text-[9px] text-red-600 font-medium">⚠ Missing dates</span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex-1 relative min-h-[36px] flex items-center"> | |
| <GanttBar | |
| level={0} | |
| start={deliverable.start} | |
| end={deliverable.end} | |
| projectStart={timelineBounds.projectStart} | |
| totalDays={timelineBounds.totalDays} | |
| /> | |
| </div> | |
| </div> | |
| {/* Features */} | |
| {isDeliverableExpanded && deliverable.features.map((feature) => { | |
| const featureId = `feature-${feature.id}`; | |
| const isFeatureExpanded = expandedItems.has(featureId); | |
| return ( | |
| <React.Fragment key={featureId}> | |
| {/* Feature Row */} | |
| <div className={cn( | |
| "flex border-b border-gray-200 hover:bg-gray-50", | |
| (!feature.start || !feature.end) && "bg-red-50 border-l-4 border-l-red-500" | |
| )}> | |
| <div className="w-80 flex-shrink-0 border-r border-gray-200 px-2 py-1.5 flex items-center" style={{ paddingLeft: "24px" }}> | |
| {feature.tasks.length > 0 && ( | |
| <button | |
| onClick={() => toggleExpand(featureId)} | |
| className="flex-shrink-0 hover:bg-gray-200 rounded p-0.5 mr-1.5 transition-colors" | |
| > | |
| {isFeatureExpanded ? ( | |
| <ChevronDown className="h-3.5 w-3.5" /> | |
| ) : ( | |
| <ChevronRight className="h-3.5 w-3.5" /> | |
| )} | |
| </button> | |
| )} | |
| {feature.tasks.length === 0 && <div className="w-5 mr-1.5" />} | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-1.5 flex-wrap"> | |
| <span className="text-[10px] font-medium px-1 py-0.5 rounded text-green-700 bg-green-50"> | |
| Feature | |
| </span> | |
| <span className={cn( | |
| "font-medium text-xs truncate", | |
| (!feature.start || !feature.end) && "text-red-700" | |
| )}>{feature.name || "Unnamed"}</span> | |
| {feature.assignee && ( | |
| <span className="flex items-center gap-0.5 text-[10px] text-gray-600"> | |
| <User className="h-2.5 w-2.5" /> | |
| {feature.assignee} | |
| </span> | |
| )} | |
| {(!feature.start || !feature.end) && ( | |
| <span className="text-[9px] text-red-600 font-medium">⚠ Missing dates</span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex-1 relative min-h-[36px] flex items-center"> | |
| <GanttBar | |
| level={1} | |
| start={feature.start} | |
| end={feature.end} | |
| projectStart={timelineBounds.projectStart} | |
| totalDays={timelineBounds.totalDays} | |
| /> | |
| </div> | |
| </div> | |
| {/* Tasks */} | |
| {isFeatureExpanded && feature.tasks.map((task) => ( | |
| <div key={`task-${task.id}`} className={cn( | |
| "flex border-b border-gray-200 hover:bg-gray-50", | |
| (!task.start || !task.end) && "bg-red-50 border-l-4 border-l-red-500" | |
| )}> | |
| <div className="w-80 flex-shrink-0 border-r border-gray-200 px-2 py-1.5 flex items-center" style={{ paddingLeft: "40px" }}> | |
| <div className="w-5 mr-1.5" /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-1.5 flex-wrap"> | |
| <span className="text-[10px] font-medium px-1 py-0.5 rounded text-purple-700 bg-purple-50"> | |
| Task | |
| </span> | |
| <span className={cn( | |
| "font-medium text-xs truncate", | |
| (!task.start || !task.end) && "text-red-700" | |
| )}>{task.name || "Unnamed"}</span> | |
| {task.assignee && ( | |
| <span className="flex items-center gap-0.5 text-[10px] text-gray-600"> | |
| <User className="h-2.5 w-2.5" /> | |
| {task.assignee} | |
| </span> | |
| )} | |
| {(!task.start || !task.end) && ( | |
| <span className="text-[9px] text-red-600 font-medium">⚠ Missing dates</span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex-1 relative min-h-[36px] flex items-center"> | |
| <GanttBar | |
| level={2} | |
| start={task.start} | |
| end={task.end} | |
| projectStart={timelineBounds.projectStart} | |
| totalDays={timelineBounds.totalDays} | |
| /> | |
| </div> | |
| </div> | |
| ))} | |
| </React.Fragment> | |
| ); | |
| })} | |
| </React.Fragment> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </Layout> | |
| ); | |
| }; | |
| export default GanttChart; | |