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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user