- 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.
303 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|