All checks were successful
build-and-push / build (push) Successful in 30s
- TaskDetailModal: 좌측 제목·설명·댓글 / 우측 속성, 필드 즉시저장(autosave) - CreateTaskModal: 핵심 필드만 입력 후 상세 카드로 이동 - 칸반 카드에 우선순위 뱃지·라벨 칩·담당자 아바타·진척바 - 간트 막대/캘린더 항목 클릭 → 상세 열기 - 우선순위 메타(PRIORITY_META), 작업 댓글 API, 타입 확장 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
87 lines
3.6 KiB
TypeScript
87 lines
3.6 KiB
TypeScript
import { useMemo } from "react";
|
|
import type { ProjectTask } from "@/types";
|
|
import { LANE_LABELS, classNames } from "@/lib/format";
|
|
|
|
const LANE_COLOR: Record<string, string> = {
|
|
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 <div className="text-sm text-ink-muted py-10 text-center">일정이 있는 작업이 없습니다.</div>;
|
|
}
|
|
const span = max - min;
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<div className="min-w-[720px]">
|
|
{/* month axis */}
|
|
<div className="flex h-7 border-b border-border mb-2 ml-[200px] relative text-[11px] text-ink-muted">
|
|
{months.map((m, i) => (
|
|
<div key={i} className="absolute top-0 h-full border-l border-divider pl-1" style={{ left: `${m.left}%`, width: `${m.width}%` }}>
|
|
{m.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{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 (
|
|
<div key={t.id}
|
|
className={classNames("flex items-center h-9 group", onTaskClick && "cursor-pointer hover:bg-canvas rounded-md")}
|
|
onClick={() => onTaskClick?.(t)}>
|
|
<div className="w-[200px] shrink-0 pr-3 text-sm text-ink truncate">{t.title}</div>
|
|
<div className="relative flex-1 h-full">
|
|
<div
|
|
className="absolute top-1.5 h-6 rounded-md flex items-center px-2 text-[11px] text-white font-medium overflow-hidden"
|
|
style={{ left: `${left}%`, width: `${width}%`, background: color }}
|
|
title={`${LANE_LABELS[t.lane]} · ${t.progress}%`}
|
|
>
|
|
<span className="truncate">{t.progress > 0 ? `${t.progress}%` : ""}</span>
|
|
<div className="absolute left-0 bottom-0 h-1 bg-white/40" style={{ width: `${t.progress}%` }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|