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.
This commit is contained in:
Roberto Musso
2026-02-22 15:15:30 +01:00
parent 40ac075633
commit 7860ca6ad1
11 changed files with 595 additions and 129 deletions

View File

@@ -1,7 +1,14 @@
import { useRef, useState, useEffect, useCallback } from 'react';
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 { Button } from '@/components/ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
export interface GanttCheckpoint {
id: string;
@@ -18,12 +25,15 @@ interface GanttChartProps {
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 SVG_HEIGHT = 100;
const DOT_RADIUS = 7;
const BASELINE_HEIGHT = 8;
const SVG_HEIGHT = 110;
const DOT_RADIUS = 10;
const PADDING_X = 40;
function getMonthsBetween(start: Date, end: Date): Date[] {
@@ -43,7 +53,14 @@ function dateToX(date: Date, start: Date, end: Date, width: number): number {
return PADDING_X + ratio * (width - PADDING_X * 2);
}
export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttChartProps) {
export function GanttChart({
checkpoints,
startDate,
endDate,
onDelete,
onEdit,
onToggleApproval,
}: GanttChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(600);
@@ -63,10 +80,11 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
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">
<svg width={width} height={SVG_HEIGHT} className="select-none overflow-visible">
{/* Month labels */}
{months.map((month) => {
const x = dateToX(month, startDate, endDate, width);
@@ -86,8 +104,8 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
x1={x}
y1={HEADER_HEIGHT}
x2={x}
y2={BASELINE_Y + 10}
stroke="#e5e5e5"
y2={BASELINE_Y + 14}
stroke="var(--border)"
strokeWidth={1}
strokeDasharray="4 4"
/>
@@ -95,41 +113,47 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
);
})}
{/* Baseline */}
<line
x1={PADDING_X}
y1={BASELINE_Y}
x2={width - PADDING_X}
y2={BASELINE_Y}
stroke="#d4d4d4"
strokeWidth={2}
{/* 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 */}
{todayX >= PADDING_X && todayX <= width - PADDING_X && (
<g>
<line
x1={todayX}
y1={HEADER_HEIGHT}
x2={todayX}
y2={BASELINE_Y + 10}
stroke="#ef4444"
strokeWidth={1.5}
/>
<text
x={todayX}
y={BASELINE_Y + 22}
textAnchor="middle"
fill="#ef4444"
fontSize={10}
fontFamily="Geist, sans-serif"
>
Today
</text>
</g>
{/* 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"
/>
)}
{/* Checkpoint dots rendered as foreignObject for Popover */}
{/* 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 (
@@ -138,6 +162,8 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
checkpoint={cp}
cx={cx}
onDelete={onDelete}
onEdit={onEdit}
onToggleApproval={onToggleApproval}
/>
);
})}
@@ -146,83 +172,131 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
);
}
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 [open, setOpen] = useState(false);
const [hovered, setHovered] = useState(false);
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleDelete = useCallback(() => {
onDelete?.(checkpoint.id);
setOpen(false);
}, [onDelete, checkpoint.id]);
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);
}, []);
// Determine dot style
// isApproved=0 (pending AI suggestion) → dashed outline
// isApproved=1 + date in past → green (#16a34a) = completed
// isApproved=1 + date in future → dark (#171717) = todo
const isPending = checkpoint.isApproved === 0;
const isPast = checkpoint.date < Date.now();
const fill = isPending ? 'none' : (isPast ? '#16a34a' : '#171717');
const stroke = isPending ? '#737373' : 'none';
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 - 16}
y={BASELINE_Y - 16}
width={32}
height={32}
x={cx - hitArea / 2}
y={BASELINE_Y - hitArea / 2}
width={hitArea}
height={hitArea}
className="overflow-visible"
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="flex items-center justify-center w-full h-full focus:outline-none cursor-pointer"
type="button"
>
<svg width={DOT_RADIUS * 2 + 2} height={DOT_RADIUS * 2 + 2}>
<circle
cx={DOT_RADIUS + 1}
cy={DOT_RADIUS + 1}
r={DOT_RADIUS}
fill={fill}
stroke={stroke || '#171717'}
strokeWidth={isPending ? 1.5 : 0}
strokeDasharray={strokeDasharray}
/>
</svg>
</button>
</PopoverTrigger>
<PopoverContent className="w-60 p-3" side="top">
<div className="flex flex-col gap-2">
<div className="font-semibold text-sm">{checkpoint.title}</div>
<div className="text-xs text-muted-foreground">
{format(new Date(checkpoint.date), 'PPP')}
</div>
{checkpoint.projectName && (
<div className="text-xs text-muted-foreground">
Project: {checkpoint.projectName}
</div>
)}
{onDelete && (
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
className="mt-1"
<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}
>
Delete
</Button>
)}
</div>
</PopoverContent>
</Popover>
{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>
);
}