528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
memo,
|
|
useCallback,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
import { TaskCard } from "@/components/molecules/TaskCard";
|
|
import { parseApiDatetime } from "@/lib/datetime";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type TaskStatus = "inbox" | "in_progress" | "review" | "done";
|
|
|
|
type Task = {
|
|
id: string;
|
|
title: string;
|
|
status: TaskStatus;
|
|
priority: string;
|
|
description?: string | null;
|
|
due_at?: string | null;
|
|
assigned_agent_id?: string | null;
|
|
assignee?: string | null;
|
|
approvals_pending_count?: number;
|
|
tags?: Array<{ id: string; name: string; slug: string; color: string }>;
|
|
depends_on_task_ids?: string[];
|
|
blocked_by_task_ids?: string[];
|
|
is_blocked?: boolean;
|
|
};
|
|
|
|
type TaskBoardProps = {
|
|
tasks: Task[];
|
|
onTaskSelect?: (task: Task) => void;
|
|
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
|
|
readOnly?: boolean;
|
|
};
|
|
|
|
type ReviewBucket = "all" | "approval_needed" | "waiting_lead" | "blocked";
|
|
|
|
const columns: Array<{
|
|
title: string;
|
|
status: TaskStatus;
|
|
dot: string;
|
|
accent: string;
|
|
text: string;
|
|
badge: string;
|
|
}> = [
|
|
{
|
|
title: "Inbox",
|
|
status: "inbox",
|
|
dot: "bg-slate-400",
|
|
accent: "hover:border-slate-400 hover:bg-slate-50",
|
|
text: "group-hover:text-slate-700 text-slate-500",
|
|
badge: "bg-slate-100 text-slate-600",
|
|
},
|
|
{
|
|
title: "In Progress",
|
|
status: "in_progress",
|
|
dot: "bg-purple-500",
|
|
accent: "hover:border-purple-400 hover:bg-purple-50",
|
|
text: "group-hover:text-purple-600 text-slate-500",
|
|
badge: "bg-purple-100 text-purple-700",
|
|
},
|
|
{
|
|
title: "Review",
|
|
status: "review",
|
|
dot: "bg-indigo-500",
|
|
accent: "hover:border-indigo-400 hover:bg-indigo-50",
|
|
text: "group-hover:text-indigo-600 text-slate-500",
|
|
badge: "bg-indigo-100 text-indigo-700",
|
|
},
|
|
{
|
|
title: "Done",
|
|
status: "done",
|
|
dot: "bg-green-500",
|
|
accent: "hover:border-green-400 hover:bg-green-50",
|
|
text: "group-hover:text-green-600 text-slate-500",
|
|
badge: "bg-emerald-100 text-emerald-700",
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Build compact due-date UI state for a task card.
|
|
*
|
|
* - Returns `due: undefined` when the task has no due date (or it's invalid), so
|
|
* callers can omit the due-date UI entirely.
|
|
* - Treats a task as overdue only if it is not `done` (so "Done" tasks don't
|
|
* keep showing as overdue forever).
|
|
*/
|
|
const resolveDueState = (
|
|
task: Task,
|
|
): { due: string | undefined; isOverdue: boolean } => {
|
|
const date = parseApiDatetime(task.due_at);
|
|
if (!date) return { due: undefined, isOverdue: false };
|
|
|
|
const dueLabel = date.toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
|
|
const isOverdue = task.status !== "done" && date.getTime() < Date.now();
|
|
return {
|
|
due: isOverdue ? `Overdue · ${dueLabel}` : dueLabel,
|
|
isOverdue,
|
|
};
|
|
};
|
|
|
|
type CardPosition = { left: number; top: number };
|
|
|
|
const KANBAN_MOVE_ANIMATION_MS = 240;
|
|
const KANBAN_MOVE_EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)";
|
|
|
|
/**
|
|
* Kanban-style task board with 4 columns.
|
|
*
|
|
* Notes:
|
|
* - Uses a lightweight FLIP animation (via `useLayoutEffect`) to animate cards
|
|
* to their new positions when tasks move between columns.
|
|
* - Drag interactions can temporarily fight browser-managed drag images; the
|
|
* animation is disabled while a card is being dragged.
|
|
* - Respects `prefers-reduced-motion`.
|
|
*/
|
|
export const TaskBoard = memo(function TaskBoard({
|
|
tasks,
|
|
onTaskSelect,
|
|
onTaskMove,
|
|
readOnly = false,
|
|
}: TaskBoardProps) {
|
|
const boardRef = useRef<HTMLDivElement | null>(null);
|
|
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
const prevPositionsRef = useRef<Map<string, CardPosition>>(new Map());
|
|
const animationRafRef = useRef<number | null>(null);
|
|
const cleanupTimeoutRef = useRef<number | null>(null);
|
|
const animatedTaskIdsRef = useRef<Set<string>>(new Set());
|
|
|
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
const [activeColumn, setActiveColumn] = useState<TaskStatus | null>(null);
|
|
const [reviewBucket, setReviewBucket] = useState<ReviewBucket>("all");
|
|
|
|
const setCardRef = useCallback(
|
|
(taskId: string) => (node: HTMLDivElement | null) => {
|
|
if (node) {
|
|
cardRefs.current.set(taskId, node);
|
|
return;
|
|
}
|
|
cardRefs.current.delete(taskId);
|
|
},
|
|
[],
|
|
);
|
|
|
|
/**
|
|
* Snapshot each card's position relative to the scroll container.
|
|
*
|
|
* We store these measurements so we can compute deltas (prev - next) and
|
|
* apply the FLIP technique on the next render.
|
|
*/
|
|
const measurePositions = useCallback((): Map<string, CardPosition> => {
|
|
const positions = new Map<string, CardPosition>();
|
|
const container = boardRef.current;
|
|
const containerRect = container?.getBoundingClientRect();
|
|
const scrollLeft = container?.scrollLeft ?? 0;
|
|
const scrollTop = container?.scrollTop ?? 0;
|
|
|
|
for (const [taskId, element] of cardRefs.current.entries()) {
|
|
const rect = element.getBoundingClientRect();
|
|
positions.set(taskId, {
|
|
left:
|
|
containerRect && container
|
|
? rect.left - containerRect.left + scrollLeft
|
|
: rect.left,
|
|
top:
|
|
containerRect && container
|
|
? rect.top - containerRect.top + scrollTop
|
|
: rect.top,
|
|
});
|
|
}
|
|
|
|
return positions;
|
|
}, []);
|
|
|
|
// Animate card reordering smoothly by applying FLIP whenever layout positions change.
|
|
useLayoutEffect(() => {
|
|
const cardRefsSnapshot = cardRefs.current;
|
|
if (animationRafRef.current !== null) {
|
|
window.cancelAnimationFrame(animationRafRef.current);
|
|
animationRafRef.current = null;
|
|
}
|
|
if (cleanupTimeoutRef.current !== null) {
|
|
window.clearTimeout(cleanupTimeoutRef.current);
|
|
cleanupTimeoutRef.current = null;
|
|
}
|
|
for (const taskId of animatedTaskIdsRef.current) {
|
|
const element = cardRefsSnapshot.get(taskId);
|
|
if (!element) continue;
|
|
element.style.transform = "";
|
|
element.style.transition = "";
|
|
element.style.willChange = "";
|
|
element.style.position = "";
|
|
element.style.zIndex = "";
|
|
}
|
|
animatedTaskIdsRef.current.clear();
|
|
|
|
const prevPositions = prevPositionsRef.current;
|
|
const nextPositions = measurePositions();
|
|
prevPositionsRef.current = nextPositions;
|
|
|
|
// Avoid fighting the browser while it manages the drag image.
|
|
if (draggingId) return;
|
|
|
|
const prefersReducedMotion =
|
|
window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
|
|
if (prefersReducedMotion) return;
|
|
|
|
const moved: Array<{
|
|
taskId: string;
|
|
element: HTMLDivElement;
|
|
dx: number;
|
|
dy: number;
|
|
}> = [];
|
|
for (const [taskId, next] of nextPositions.entries()) {
|
|
const prev = prevPositions.get(taskId);
|
|
if (!prev) continue;
|
|
const dx = prev.left - next.left;
|
|
const dy = prev.top - next.top;
|
|
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
|
|
const element = cardRefsSnapshot.get(taskId);
|
|
if (!element) continue;
|
|
moved.push({ taskId, element, dx, dy });
|
|
}
|
|
|
|
if (!moved.length) return;
|
|
animatedTaskIdsRef.current = new Set(moved.map(({ taskId }) => taskId));
|
|
|
|
// FLIP: invert to the previous position before paint, then animate back to 0.
|
|
for (const { element, dx, dy } of moved) {
|
|
element.style.transform = `translate(${dx}px, ${dy}px)`;
|
|
element.style.transition = "transform 0s";
|
|
element.style.willChange = "transform";
|
|
element.style.position = "relative";
|
|
element.style.zIndex = "1";
|
|
}
|
|
|
|
animationRafRef.current = window.requestAnimationFrame(() => {
|
|
for (const { element } of moved) {
|
|
element.style.transition = `transform ${KANBAN_MOVE_ANIMATION_MS}ms ${KANBAN_MOVE_EASING}`;
|
|
element.style.transform = "";
|
|
}
|
|
|
|
cleanupTimeoutRef.current = window.setTimeout(() => {
|
|
for (const { element } of moved) {
|
|
element.style.transition = "";
|
|
element.style.willChange = "";
|
|
element.style.position = "";
|
|
element.style.zIndex = "";
|
|
}
|
|
animatedTaskIdsRef.current.clear();
|
|
cleanupTimeoutRef.current = null;
|
|
}, KANBAN_MOVE_ANIMATION_MS + 60);
|
|
|
|
animationRafRef.current = null;
|
|
});
|
|
|
|
return () => {
|
|
if (animationRafRef.current !== null) {
|
|
window.cancelAnimationFrame(animationRafRef.current);
|
|
animationRafRef.current = null;
|
|
}
|
|
if (cleanupTimeoutRef.current !== null) {
|
|
window.clearTimeout(cleanupTimeoutRef.current);
|
|
cleanupTimeoutRef.current = null;
|
|
}
|
|
for (const taskId of animatedTaskIdsRef.current) {
|
|
const element = cardRefsSnapshot.get(taskId);
|
|
if (!element) continue;
|
|
element.style.transform = "";
|
|
element.style.transition = "";
|
|
element.style.willChange = "";
|
|
element.style.position = "";
|
|
element.style.zIndex = "";
|
|
}
|
|
animatedTaskIdsRef.current.clear();
|
|
};
|
|
}, [draggingId, measurePositions, tasks]);
|
|
|
|
const grouped = useMemo(() => {
|
|
const buckets: Record<TaskStatus, Task[]> = {
|
|
inbox: [],
|
|
in_progress: [],
|
|
review: [],
|
|
done: [],
|
|
};
|
|
for (const column of columns) {
|
|
buckets[column.status] = [];
|
|
}
|
|
tasks.forEach((task) => {
|
|
const bucket = buckets[task.status] ?? buckets.inbox;
|
|
bucket.push(task);
|
|
});
|
|
return buckets;
|
|
}, [tasks]);
|
|
|
|
// Keep drag/drop state and payload handling centralized for column move interactions.
|
|
const handleDragStart =
|
|
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
|
|
if (readOnly) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (task.is_blocked) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
setDraggingId(task.id);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData(
|
|
"text/plain",
|
|
JSON.stringify({ taskId: task.id, status: task.status }),
|
|
);
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
setDraggingId(null);
|
|
setActiveColumn(null);
|
|
};
|
|
|
|
const handleDrop =
|
|
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
|
|
if (readOnly) return;
|
|
event.preventDefault();
|
|
setActiveColumn(null);
|
|
const raw = event.dataTransfer.getData("text/plain");
|
|
if (!raw) return;
|
|
try {
|
|
const payload = JSON.parse(raw) as { taskId?: string; status?: string };
|
|
if (!payload.taskId || !payload.status) return;
|
|
if (payload.status === status) return;
|
|
onTaskMove?.(payload.taskId, status);
|
|
} catch {
|
|
// Ignore malformed payloads.
|
|
}
|
|
};
|
|
|
|
const handleDragOver =
|
|
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
|
|
if (readOnly) return;
|
|
event.preventDefault();
|
|
if (activeColumn !== status) {
|
|
setActiveColumn(status);
|
|
}
|
|
};
|
|
|
|
const handleDragLeave = (status: TaskStatus) => () => {
|
|
if (readOnly) return;
|
|
if (activeColumn === status) {
|
|
setActiveColumn(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={boardRef}
|
|
data-testid="task-board"
|
|
className={cn(
|
|
// Mobile-first: stack columns vertically to avoid horizontal scrolling.
|
|
"grid grid-cols-1 gap-4 overflow-x-hidden pb-6",
|
|
// Desktop/tablet: switch back to horizontally scrollable kanban columns.
|
|
"sm:grid-flow-col sm:auto-cols-[minmax(260px,320px)] sm:grid-cols-none sm:overflow-x-auto",
|
|
)}
|
|
>
|
|
{columns.map((column) => {
|
|
const columnTasks = grouped[column.status] ?? [];
|
|
// Derive review tab counts and the active subset from one canonical task list.
|
|
const reviewCounts =
|
|
column.status === "review"
|
|
? columnTasks.reduce(
|
|
(acc, task) => {
|
|
if (task.is_blocked) {
|
|
acc.blocked += 1;
|
|
return acc;
|
|
}
|
|
if ((task.approvals_pending_count ?? 0) > 0) {
|
|
acc.approval_needed += 1;
|
|
return acc;
|
|
}
|
|
acc.waiting_lead += 1;
|
|
return acc;
|
|
},
|
|
{
|
|
all: columnTasks.length,
|
|
approval_needed: 0,
|
|
waiting_lead: 0,
|
|
blocked: 0,
|
|
},
|
|
)
|
|
: null;
|
|
|
|
const filteredTasks =
|
|
column.status === "review" && reviewBucket !== "all"
|
|
? columnTasks.filter((task) => {
|
|
if (reviewBucket === "blocked") return Boolean(task.is_blocked);
|
|
if (reviewBucket === "approval_needed")
|
|
return (
|
|
(task.approvals_pending_count ?? 0) > 0 && !task.is_blocked
|
|
);
|
|
if (reviewBucket === "waiting_lead")
|
|
return (
|
|
!task.is_blocked &&
|
|
(task.approvals_pending_count ?? 0) === 0
|
|
);
|
|
return true;
|
|
})
|
|
: columnTasks;
|
|
|
|
return (
|
|
<div
|
|
key={column.title}
|
|
className={cn(
|
|
// On mobile, columns are stacked, so avoid forcing tall fixed heights.
|
|
"kanban-column min-h-0",
|
|
// On larger screens, keep columns tall to reduce empty space during drag.
|
|
"sm:min-h-[calc(100vh-260px)]",
|
|
activeColumn === column.status &&
|
|
!readOnly &&
|
|
"ring-2 ring-slate-200",
|
|
)}
|
|
onDrop={readOnly ? undefined : handleDrop(column.status)}
|
|
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
|
|
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
|
|
>
|
|
<div className="column-header z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white px-4 py-3 sm:sticky sm:top-0 sm:bg-white/80 sm:backdrop-blur">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
|
|
<h3 className="text-sm font-semibold text-slate-900">
|
|
{column.title}
|
|
</h3>
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
|
|
column.badge,
|
|
)}
|
|
>
|
|
{filteredTasks.length}
|
|
</span>
|
|
</div>
|
|
{column.status === "review" && reviewCounts ? (
|
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
|
{(
|
|
[
|
|
{ key: "all", label: "All", count: reviewCounts.all },
|
|
{
|
|
key: "approval_needed",
|
|
label: "Approval needed",
|
|
count: reviewCounts.approval_needed,
|
|
},
|
|
{
|
|
key: "waiting_lead",
|
|
label: "Lead review",
|
|
count: reviewCounts.waiting_lead,
|
|
},
|
|
{
|
|
key: "blocked",
|
|
label: "Blocked",
|
|
count: reviewCounts.blocked,
|
|
},
|
|
] as const
|
|
).map((option) => (
|
|
<button
|
|
key={option.key}
|
|
type="button"
|
|
onClick={() => setReviewBucket(option.key)}
|
|
className={cn(
|
|
"rounded-full border px-2.5 py-1 transition",
|
|
reviewBucket === option.key
|
|
? "border-slate-900 bg-slate-900 text-white"
|
|
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50",
|
|
)}
|
|
aria-pressed={reviewBucket === option.key}
|
|
>
|
|
{option.label} · {option.count}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
|
<div className="space-y-3">
|
|
{filteredTasks.map((task) => {
|
|
const dueState = resolveDueState(task);
|
|
return (
|
|
<div key={task.id} ref={setCardRef(task.id)}>
|
|
<TaskCard
|
|
title={task.title}
|
|
status={task.status}
|
|
priority={task.priority}
|
|
assignee={task.assignee ?? undefined}
|
|
due={dueState.due}
|
|
isOverdue={dueState.isOverdue}
|
|
approvalsPendingCount={task.approvals_pending_count}
|
|
tags={task.tags}
|
|
isBlocked={task.is_blocked}
|
|
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
|
|
onClick={() => onTaskSelect?.(task)}
|
|
draggable={!readOnly && !task.is_blocked}
|
|
isDragging={draggingId === task.id}
|
|
onDragStart={
|
|
readOnly ? undefined : handleDragStart(task)
|
|
}
|
|
onDragEnd={readOnly ? undefined : handleDragEnd}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
TaskBoard.displayName = "TaskBoard";
|