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 = ({ 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 (
{hasChildren && ( )} {!hasChildren &&
}
{color.label}
{name || "Unnamed"}
{assignee && (
{assignee}
)}
); }; interface GanttBarProps { level: number; start?: string | null; end?: string | null; projectStart: Date; totalDays: number; } const GanttBar: React.FC = ({ 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 (
{barStyle && (
{start && end && duration > 3 && `${format(parseISO(start), 'MMM d')} - ${format(parseISO(end), 'MMM d')}`}
)} {/* Background grid lines for each day */}
{Array.from({ length: totalDays }).map((_, index) => (
))}
); }; const GanttChart = () => { const [selectedProjectId, setSelectedProjectId] = useState(""); const [selectedEmployeeId, setSelectedEmployeeId] = useState(""); const [expandedItems, setExpandedItems] = useState>(new Set()); const leftScrollRef = React.useRef(null); const rightScrollRef = React.useRef(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(); 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 (

Gantt Chart

View project timeline with deliverables, features, and tasks

Timeline View Hierarchical view of deliverables, features, and tasks
{isLoadingGantt ? (
Loading Gantt chart...
) : ganttError ? (

Failed to load Gantt chart data

Please try selecting a different project

) : !filteredGanttData?.deliverables || filteredGanttData.deliverables.length === 0 ? (

{selectedEmployeeId ? "No data available for the selected employee" : "No data available for this project"}

{selectedEmployeeId ? "Try selecting a different employee or clear the filter" : "Add deliverables, features, and tasks to see the timeline"}

) : !timelineBounds ? (

No valid dates found for this project

) : (
{/* Single Scroll Container */}
{/* Headers Row */}
Task Name
{timelineBounds.days.map((day, index) => { const isWeekend = day.getDay() === 0 || day.getDay() === 6; return (
{format(day, 'EEE')}
{format(day, 'd')}
{format(day, 'MMM')}
); })}
{/* Content Rows */}
{filteredGanttData.deliverables.map((deliverable) => { const deliverableId = `deliverable-${deliverable.id}`; const isDeliverableExpanded = expandedItems.has(deliverableId); return ( {/* Deliverable Row */}
{deliverable.features.length > 0 && ( )} {deliverable.features.length === 0 &&
}
Deliverable {deliverable.name || "Unnamed"} {(!deliverable.start || !deliverable.end) && ( ⚠ Missing dates )}
{/* Features */} {isDeliverableExpanded && deliverable.features.map((feature) => { const featureId = `feature-${feature.id}`; const isFeatureExpanded = expandedItems.has(featureId); return ( {/* Feature Row */}
{feature.tasks.length > 0 && ( )} {feature.tasks.length === 0 &&
}
Feature {feature.name || "Unnamed"} {feature.assignee && ( {feature.assignee} )} {(!feature.start || !feature.end) && ( ⚠ Missing dates )}
{/* Tasks */} {isFeatureExpanded && feature.tasks.map((task) => (
Task {task.name || "Unnamed"} {task.assignee && ( {task.assignee} )} {(!task.start || !task.end) && ( ⚠ Missing dates )}
))} ); })} ); })}
)}
); }; export default GanttChart;