Files
vfxreview/components/tasks/KanbanBoard.tsx
T
twotalesanimation 0fbe856dce Initial commit
2026-05-19 22:20:29 +02:00

223 lines
6.2 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import { useDroppable } from "@dnd-kit/core";
import { useRouter } from "next/navigation";
import { useToast } from "@/components/ui/use-toast";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { KanbanCard } from "./KanbanCard";
import { TASK_STATUS_CONFIG } from "./TaskCard";
import { updateTaskStatus } from "@/actions/tasks";
import { TaskStatus, TaskType } from "@prisma/client";
import { cn } from "@/lib/utils";
const COLUMN_ORDER: TaskStatus[] = [
"TODO",
"IN_PROGRESS",
"INTERNAL_REVIEW",
"CLIENT_REVIEW",
"CHANGES",
"DONE",
];
interface KanbanTask {
id: string;
title: string;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: Date | null;
shot?: { shotCode: string } | null;
asset?: { assetCode: string; name: string } | null;
assignedArtist?: {
id: string;
name: string | null;
email: string;
image: string | null;
} | null;
_count?: { versions: number };
versions?: {
id: string;
versionNumber: number;
approvalStatus: string;
createdAt: Date;
}[];
}
function KanbanColumn({
status,
tasks,
}: {
status: TaskStatus;
tasks: KanbanTask[];
}) {
const { setNodeRef, isOver } = useDroppable({ id: status });
const cfg = TASK_STATUS_CONFIG[status];
const Icon = cfg.icon;
return (
<div className="flex flex-col min-w-[240px] w-[240px] shrink-0">
{/* Column header */}
<div className="flex items-center gap-2 mb-3 px-1">
<Icon className="h-3.5 w-3.5 text-zinc-500" />
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
{cfg.label}
</span>
<span className="ml-auto text-xs text-zinc-600 bg-zinc-800 rounded-full px-1.5 py-0.5 font-mono">
{tasks.length}
</span>
</div>
{/* Drop zone */}
<div
ref={setNodeRef}
className={cn(
"flex-1 flex flex-col gap-2 rounded-xl p-2 min-h-[200px] transition-colors",
isOver ? "bg-zinc-800/60 ring-1 ring-amber-500/30" : "bg-zinc-900/40"
)}
>
{tasks.map((task) => (
<KanbanCard key={task.id} task={task} />
))}
{tasks.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<p className="text-xs text-zinc-700">Drop here</p>
</div>
)}
</div>
</div>
);
}
interface KanbanBoardProps {
tasks: KanbanTask[];
projectId: string;
artists?: { id: string; name: string | null; email: string }[];
}
export function KanbanBoard({ tasks: initialTasks, projectId, artists = [] }: KanbanBoardProps) {
const [tasks, setTasks] = useState<KanbanTask[]>(initialTasks);
const [draggingTask, setDraggingTask] = useState<KanbanTask | null>(null);
const [filterArtist, setFilterArtist] = useState<string>("__all__");
const { toast } = useToast();
const router = useRouter();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
);
const filteredTasks =
filterArtist === "__all__"
? tasks
: tasks.filter((t) => t.assignedArtist?.id === filterArtist);
const columns = COLUMN_ORDER.map((status) => ({
status,
tasks: filteredTasks.filter((t) => t.status === status),
}));
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const task = tasks.find((t) => t.id === event.active.id);
if (task) setDraggingTask(task);
},
[tasks]
);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
setDraggingTask(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const taskId = active.id as string;
const newStatus = over.id as TaskStatus;
// Validate target is a column
if (!COLUMN_ORDER.includes(newStatus)) return;
const task = tasks.find((t) => t.id === taskId);
if (!task || task.status === newStatus) return;
// Optimistic update
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t))
);
try {
await updateTaskStatus(taskId, newStatus);
router.refresh();
} catch {
// Revert on failure
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, status: task.status } : t))
);
toast({ title: "Failed to update task status", variant: "destructive" });
}
},
[tasks, router, toast]
);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* Artist filter */}
{artists.length > 0 && (
<div className="flex items-center gap-3 mb-4">
<span className="text-xs text-zinc-500 font-medium">Filter by artist:</span>
<Select value={filterArtist} onValueChange={setFilterArtist}>
<SelectTrigger className="w-44 h-8 text-sm">
<SelectValue placeholder="All artists" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All artists</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name ?? a.email}
</SelectItem>
))}
</SelectContent>
</Select>
{filterArtist !== "__all__" && (
<button
onClick={() => setFilterArtist("__all__")}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Clear
</button>
)}
</div>
)}
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[400px]">
{columns.map(({ status, tasks: colTasks }) => (
<KanbanColumn key={status} status={status} tasks={colTasks} />
))}
</div>
<DragOverlay dropAnimation={null}>
{draggingTask && <KanbanCard task={draggingTask} isDragOverlay />}
</DragOverlay>
</DndContext>
);
}