import { useMemo } from "react"; import type { ProjectTask } from "@/types"; import { LANE_LABELS, classNames } from "@/lib/format"; const LANE_COLOR: Record = { todo: "#98A2B3", doing: "#2E90FA", review: "#7A5AF8", done: "#12B76A", }; const DAY = 86400000; function parse(d: string): number { const t = new Date(d).getTime(); return Number.isNaN(t) ? 0 : t; } // Lightweight SVG-free Gantt: a day-scaled track with one bar per task. Matches // the real calendar by positioning bars on an absolute date axis. export function Gantt({ tasks, onTaskClick }: { tasks: ProjectTask[]; onTaskClick?: (t: ProjectTask) => void }) { const { min, max, months } = useMemo(() => { const starts = tasks.map((t) => parse(t.start)).filter(Boolean); const ends = tasks.map((t) => parse(t.end)).filter(Boolean); if (!starts.length) return { min: 0, max: 0, months: [] as { label: string; left: number; width: number }[] }; let lo = Math.min(...starts), hi = Math.max(...ends, ...starts); lo = lo - DAY * 3; hi = hi + DAY * 3; const span = hi - lo; // month ticks const months: { label: string; left: number; width: number }[] = []; const d = new Date(lo); d.setDate(1); while (d.getTime() < hi) { const start = Math.max(d.getTime(), lo); const next = new Date(d); next.setMonth(d.getMonth() + 1); const end = Math.min(next.getTime(), hi); months.push({ label: `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}`, left: ((start - lo) / span) * 100, width: ((end - start) / span) * 100, }); d.setMonth(d.getMonth() + 1); } return { min: lo, max: hi, months }; }, [tasks]); if (!tasks.length || max === min) { return
일정이 있는 작업이 없습니다.
; } const span = max - min; return (
{/* month axis */}
{months.map((m, i) => (
{m.label}
))}
{tasks.map((t) => { const s = parse(t.start), e = parse(t.end) || s + DAY; const left = ((s - min) / span) * 100; const width = Math.max(1.5, ((e - s) / span) * 100); const color = LANE_COLOR[t.lane] ?? "#03143F"; return (
onTaskClick?.(t)}>
{t.title}
{t.progress > 0 ? `${t.progress}%` : ""}
); })}
); }