theorose49 7f62f41134
All checks were successful
build-and-push / build (push) Successful in 30s
feat(task): JIRA형 작업 카드 — 상세 패널(설명·담당자·우선순위·라벨·진척) + 댓글
- TaskDetailModal: 좌측 제목·설명·댓글 / 우측 속성, 필드 즉시저장(autosave)
- CreateTaskModal: 핵심 필드만 입력 후 상세 카드로 이동
- 칸반 카드에 우선순위 뱃지·라벨 칩·담당자 아바타·진척바
- 간트 막대/캘린더 항목 클릭 → 상세 열기
- 우선순위 메타(PRIORITY_META), 작업 댓글 API, 타입 확장

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:09:20 +09:00

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>
);
}