All checks were successful
build-and-push / build (push) Successful in 34s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
80 lines
3.7 KiB
TypeScript
80 lines
3.7 KiB
TypeScript
import {
|
|
DndContext, PointerSensor, useSensor, useSensors, closestCorners,
|
|
type DragEndEvent, useDroppable, useDraggable,
|
|
} from "@dnd-kit/core";
|
|
import type { Lane, ProjectTask } from "@/types";
|
|
import { LANE_LABELS, formatDate, classNames } from "@/lib/format";
|
|
|
|
const LANES: Lane[] = ["todo", "doing", "review", "done"];
|
|
const LANE_DOT: Record<Lane, string> = {
|
|
todo: "#98A2B3", doing: "#2E90FA", review: "#7A5AF8", done: "#12B76A",
|
|
};
|
|
|
|
export function Kanban({
|
|
tasks, onMove, onCardClick, readOnly,
|
|
}: { tasks: ProjectTask[]; onMove: (taskId: string, lane: Lane) => void; onCardClick?: (t: ProjectTask) => void; readOnly?: boolean }) {
|
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
|
|
|
function onDragEnd(e: DragEndEvent) {
|
|
const lane = e.over?.id as Lane | undefined;
|
|
const taskId = e.active.id as string;
|
|
if (lane && LANES.includes(lane)) {
|
|
const t = tasks.find((x) => x.id === taskId);
|
|
if (t && t.lane !== lane) onMove(taskId, lane);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={readOnly ? undefined : onDragEnd}>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
|
|
{LANES.map((lane) => (
|
|
<Column key={lane} lane={lane} tasks={tasks.filter((t) => t.lane === lane)} onCardClick={onCardClick} readOnly={readOnly} />
|
|
))}
|
|
</div>
|
|
</DndContext>
|
|
);
|
|
}
|
|
|
|
function Column({ lane, tasks, onCardClick, readOnly }: { lane: Lane; tasks: ProjectTask[]; onCardClick?: (t: ProjectTask) => void; readOnly?: boolean }) {
|
|
const { setNodeRef, isOver } = useDroppable({ id: lane });
|
|
return (
|
|
<div ref={setNodeRef} className={classNames("rounded-card border bg-canvas/60 min-h-[200px] transition-colors", isOver ? "border-navy bg-navy-subtle/40" : "border-border")}>
|
|
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
|
|
<span className="w-2 h-2 rounded-full" style={{ background: LANE_DOT[lane] }} />
|
|
<span className="text-sm font-semibold text-ink">{LANE_LABELS[lane]}</span>
|
|
<span className="ml-auto text-xs text-ink-muted font-num">{tasks.length}</span>
|
|
</div>
|
|
<div className="p-2 space-y-2">
|
|
{tasks.map((t) => <KanbanCard key={t.id} task={t} onCardClick={onCardClick} readOnly={readOnly} />)}
|
|
{tasks.length === 0 && <div className="text-xs text-ink-muted text-center py-6">비어 있음</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KanbanCard({ task, onCardClick, readOnly }: { task: ProjectTask; onCardClick?: (t: ProjectTask) => void; readOnly?: boolean }) {
|
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, disabled: readOnly });
|
|
const style = transform ? { transform: `translate(${transform.x}px, ${transform.y}px)` } : undefined;
|
|
return (
|
|
<div
|
|
ref={setNodeRef} style={style} {...(readOnly ? {} : listeners)} {...attributes}
|
|
onClick={() => { if (!isDragging) onCardClick?.(task); }}
|
|
className={classNames(
|
|
"bg-surface border border-border rounded-control p-3 shadow-card",
|
|
readOnly ? "" : "cursor-pointer active:cursor-grabbing", isDragging && "opacity-60"
|
|
)}
|
|
>
|
|
<div className="text-sm font-medium text-ink">{task.title}</div>
|
|
<div className="flex items-center justify-between mt-2 text-[11px] text-ink-muted">
|
|
<span className="tabular">{formatDate(task.start)}</span>
|
|
<span>{task.assignee ? task.assignee.split("@")[0] : ""}</span>
|
|
</div>
|
|
{task.progress > 0 && (
|
|
<div className="h-1 rounded-pill bg-divider mt-2 overflow-hidden">
|
|
<div className="h-full bg-navy" style={{ width: `${task.progress}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|