Initial commit
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user