theorose49 c29e3af9c2
All checks were successful
build-and-push / build (push) Successful in 34s
feat: 2차 수정/삭제 일괄 — 작업자 portion·담당자·분할입금·작업(칸반 카드)·거래·세금 수정/삭제
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:57:13 +09:00

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