Files
adiuva/src/renderer/components/timeline/GanttChart.tsx
Roberto Musso 7860ca6ad1 feat: add inline project timeline and notes section to Project Detail
- Implemented a Gantt timeline using GanttChart component scoped to project checkpoints.
- Added functionality to create and manage checkpoints with AddCheckpointDialog.
- Introduced EditCheckpointDialog for editing existing checkpoints.
- Created a notes section displaying a list of notes with the ability to add new notes.
- Updated routing to include notes detail page.
- Enhanced GanttChart with context menu for editing and deleting checkpoints.
- Improved UI components for better user experience.
2026-02-22 15:15:30 +01:00

303 lines
9.1 KiB
TypeScript

import { useRef, useEffect, useState, useCallback } from 'react';
import { format } from 'date-fns';
import { Pencil, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
export interface GanttCheckpoint {
id: string;
title: string;
date: number; // unix timestamp ms
projectId: string;
projectName?: string;
isAiSuggested: number;
isApproved: number;
}
interface GanttChartProps {
checkpoints: GanttCheckpoint[];
startDate: Date;
endDate: Date;
onDelete?: (id: string) => void;
onEdit?: (checkpoint: GanttCheckpoint) => void;
onToggleApproval?: (id: string, currentApproved: number) => void;
}
const HEADER_HEIGHT = 30;
const BASELINE_Y = 70;
const BASELINE_HEIGHT = 8;
const SVG_HEIGHT = 110;
const DOT_RADIUS = 10;
const PADDING_X = 40;
function getMonthsBetween(start: Date, end: Date): Date[] {
const months: Date[] = [];
const current = new Date(start.getFullYear(), start.getMonth(), 1);
while (current <= end) {
months.push(new Date(current));
current.setMonth(current.getMonth() + 1);
}
return months;
}
function dateToX(date: Date, start: Date, end: Date, width: number): number {
const totalMs = end.getTime() - start.getTime();
if (totalMs <= 0) return PADDING_X;
const ratio = (date.getTime() - start.getTime()) / totalMs;
return PADDING_X + ratio * (width - PADDING_X * 2);
}
export function GanttChart({
checkpoints,
startDate,
endDate,
onDelete,
onEdit,
onToggleApproval,
}: GanttChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(600);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setWidth(entry.contentRect.width);
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const months = getMonthsBetween(startDate, endDate);
const todayX = dateToX(new Date(), startDate, endDate, width);
const todayVisible = todayX >= PADDING_X && todayX <= width - PADDING_X;
return (
<div ref={containerRef} className="w-full overflow-hidden">
<svg width={width} height={SVG_HEIGHT} className="select-none overflow-visible">
{/* Month labels */}
{months.map((month) => {
const x = dateToX(month, startDate, endDate, width);
return (
<g key={month.toISOString()}>
<text
x={x}
y={HEADER_HEIGHT - 8}
textAnchor="middle"
className="fill-muted-foreground"
fontSize={12}
fontFamily="Geist, sans-serif"
>
{format(month, 'MMM yyyy')}
</text>
<line
x1={x}
y1={HEADER_HEIGHT}
x2={x}
y2={BASELINE_Y + 14}
stroke="var(--border)"
strokeWidth={1}
strokeDasharray="4 4"
/>
</g>
);
})}
{/* Baseline — thick rounded bar */}
<rect
x={PADDING_X}
y={BASELINE_Y - BASELINE_HEIGHT / 2}
width={Math.max(0, width - PADDING_X * 2)}
height={BASELINE_HEIGHT}
rx={BASELINE_HEIGHT / 2}
fill="var(--border)"
/>
{/* Today marker — dashed line */}
{todayVisible && (
<line
x1={todayX}
y1={HEADER_HEIGHT}
x2={todayX}
y2={BASELINE_Y + 14}
stroke="var(--destructive)"
strokeWidth={1.5}
strokeDasharray="4 2"
/>
)}
{/* Today marker — Badge label via foreignObject */}
{todayVisible && (
<foreignObject
x={todayX - 30}
y={BASELINE_Y + 12}
width={60}
height={28}
className="overflow-visible"
>
<div className="flex justify-center">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-destructive text-destructive">
Today
</Badge>
</div>
</foreignObject>
)}
{/* Checkpoint dots */}
{checkpoints.map((cp) => {
const cx = dateToX(new Date(cp.date), startDate, endDate, width);
return (
<CheckpointDot
key={cp.id}
checkpoint={cp}
cx={cx}
onDelete={onDelete}
onEdit={onEdit}
onToggleApproval={onToggleApproval}
/>
);
})}
</svg>
</div>
);
}
function getStatusLabel(checkpoint: GanttCheckpoint): { text: string; className: string } {
if (checkpoint.isApproved === 0) {
return { text: 'Pending', className: 'bg-muted text-muted-foreground' };
}
if (checkpoint.date < Date.now()) {
return { text: 'Completed', className: 'bg-chart-2/15 text-chart-2' };
}
return { text: 'To Do', className: 'bg-primary/10 text-primary' };
}
function CheckpointDot({
checkpoint,
cx,
onDelete,
onEdit,
onToggleApproval,
}: {
checkpoint: GanttCheckpoint;
cx: number;
onDelete?: (id: string) => void;
onEdit?: (checkpoint: GanttCheckpoint) => void;
onToggleApproval?: (id: string, currentApproved: number) => void;
}) {
const [hovered, setHovered] = useState(false);
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = useCallback(() => {
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(() => setHovered(true), 200);
}, []);
const handleMouseLeave = useCallback(() => {
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(() => setHovered(false), 150);
}, []);
const isPending = checkpoint.isApproved === 0;
const isPast = checkpoint.date < Date.now();
const fill = isPending ? 'none' : (isPast ? 'var(--chart-2)' : 'var(--primary)');
const stroke = isPending ? 'var(--muted-foreground)' : 'none';
const strokeDasharray = isPending ? '3 2' : undefined;
const status = getStatusLabel(checkpoint);
const dotSize = DOT_RADIUS * 2 + 2;
const hitArea = dotSize + 8;
const dotSvg = (
<svg width={dotSize} height={dotSize} className="shrink-0">
<circle
cx={DOT_RADIUS + 1}
cy={DOT_RADIUS + 1}
r={DOT_RADIUS}
fill={fill}
stroke={stroke || 'var(--primary)'}
strokeWidth={isPending ? 1.5 : 0}
strokeDasharray={strokeDasharray}
/>
</svg>
);
return (
<foreignObject
x={cx - hitArea / 2}
y={BASELINE_Y - hitArea / 2}
width={hitArea}
height={hitArea}
className="overflow-visible"
>
<ContextMenu>
<Popover open={hovered}>
<ContextMenuTrigger asChild>
<PopoverTrigger asChild>
<button
className="flex items-center justify-center w-full h-full focus:outline-none cursor-pointer"
type="button"
onClick={() => onToggleApproval?.(checkpoint.id, checkpoint.isApproved)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{dotSvg}
</button>
</PopoverTrigger>
</ContextMenuTrigger>
<PopoverContent
className="w-52 p-3 pointer-events-none"
side="top"
sideOffset={8}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-sm leading-snug truncate">
{checkpoint.title}
</span>
<Badge variant="secondary" className={`text-[10px] px-1.5 py-0 shrink-0 ${status.className}`}>
{status.text}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
{format(new Date(checkpoint.date), 'PPP')}
</span>
{checkpoint.projectName && (
<span className="text-xs text-muted-foreground">
{checkpoint.projectName}
</span>
)}
</div>
</PopoverContent>
</Popover>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onEdit?.(checkpoint)}>
<Pencil className="h-4 w-4 mr-2" />
Edit Checkpoint
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onDelete?.(checkpoint.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Checkpoint
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</foreignObject>
);
}