pmtool / src /pages /gantt-chart.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
27.6 kB
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;