feat(tasks): Kanban board with drag-and-drop (dnd-kit)
Replaces the template's /tasks demo (deleted) with a real multi-tenant
Kanban board.
Schema/validation:
- lib/validation/tasks.ts (taskSchema with status/priority enums + dueDate
optional + assignee/customer optional)
- lib/appwrite/task-actions.ts: createTaskAction, updateTaskAction,
deleteTaskAction, moveTaskAction (used by drag-drop). All audit-logged;
moveTaskAction only audits when status actually changes.
- lib/appwrite/task-queries.ts: listTasks ordered by 'order' asc.
UI:
- /tasks server page assembles { tasks, customers, teamMembers } and
passes to TasksBoard. Removed the template's data-table demo files.
- TasksBoard (client): 4 droppable columns. Columns use @dnd-kit/core
useDroppable; cards inside each column are SortableContext+useSortable
for intra-column ordering. closestCorners collision detection.
- Drag-end computes new 'order' as midpoint between adjacent tasks
(no full reindex), updates UI optimistically, then persists via
moveTaskAction. Rolls back on server error with toast.
- TaskCard: priority badge (color-coded), due-date badge (red if
overdue), assignee badge, customer subtitle, dropdown (Edit/Delete)
on hover.
- TaskFormSheet: title/description/status/priority/dueDate/assignee/
customer. Uses sentinel '__none__' for nullable Selects (Radix Select
forbids empty string values), stripped before submit.
- DragOverlay shows the dragged card rotated 3deg with shadow.
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
PointerSensor,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCorners,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { deleteTaskAction, moveTaskAction } from "@/lib/appwrite/task-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { TaskCard } from "./task-card";
|
||||
import { TaskFormSheet } from "./task-form-sheet";
|
||||
import {
|
||||
COLUMNS,
|
||||
type Customer,
|
||||
type TaskRow,
|
||||
type TaskStatus,
|
||||
type TeamMember,
|
||||
} from "./types";
|
||||
import { useTransition } from "react";
|
||||
|
||||
type Props = {
|
||||
tasks: TaskRow[];
|
||||
customers: Customer[];
|
||||
teamMembers: TeamMember[];
|
||||
};
|
||||
|
||||
function Column({
|
||||
status,
|
||||
title,
|
||||
tasks,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
status: TaskStatus;
|
||||
title: string;
|
||||
tasks: TaskRow[];
|
||||
onAdd: (status: TaskStatus) => void;
|
||||
onEdit: (task: TaskRow) => void;
|
||||
onDelete: (task: TaskRow) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppableColumn(status);
|
||||
|
||||
return (
|
||||
<div className="bg-muted/40 flex flex-col rounded-lg border">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-xs">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => onAdd(status)}
|
||||
aria-label="Yeni görev"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-2 p-2 transition-colors",
|
||||
isOver && "bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<SortableContext
|
||||
items={tasks.map((t) => t.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onEdit={onEdit} onDelete={onDelete} />
|
||||
))}
|
||||
</SortableContext>
|
||||
{tasks.length === 0 && (
|
||||
<p className="text-muted-foreground py-8 text-center text-xs">Boş</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useDroppableColumn(status: TaskStatus) {
|
||||
return useDroppable({
|
||||
id: `col-${status}`,
|
||||
data: { type: "column", status },
|
||||
});
|
||||
}
|
||||
|
||||
export function TasksBoard({ tasks: initialTasks, customers, teamMembers }: Props) {
|
||||
const [tasks, setTasks] = useState<TaskRow[]>(initialTasks);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formStatus, setFormStatus] = useState<TaskStatus>("todo");
|
||||
const [editing, setEditing] = useState<TaskRow | null>(null);
|
||||
const [deleting, setDeleting] = useState<TaskRow | null>(null);
|
||||
const [busy, startTransition] = useTransition();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 6 },
|
||||
}),
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<TaskStatus, TaskRow[]> = {
|
||||
backlog: [],
|
||||
todo: [],
|
||||
in_progress: [],
|
||||
done: [],
|
||||
};
|
||||
for (const t of tasks) {
|
||||
map[t.status].push(t);
|
||||
}
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
const activeTask = useMemo(
|
||||
() => tasks.find((t) => t.id === activeId) ?? null,
|
||||
[tasks, activeId],
|
||||
);
|
||||
|
||||
const onDragStart = (e: DragStartEvent) => {
|
||||
setActiveId(String(e.active.id));
|
||||
};
|
||||
|
||||
const onDragEnd = (e: DragEndEvent) => {
|
||||
const { active, over } = e;
|
||||
setActiveId(null);
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const activeData = active.data.current as { type?: string; status?: TaskStatus } | undefined;
|
||||
const overData = over.data.current as { type?: string; status?: TaskStatus } | undefined;
|
||||
if (activeData?.type !== "task") return;
|
||||
|
||||
let targetStatus: TaskStatus | undefined;
|
||||
if (overData?.type === "column" && overData.status) {
|
||||
targetStatus = overData.status;
|
||||
} else if (overData?.type === "task" && overData.status) {
|
||||
targetStatus = overData.status;
|
||||
}
|
||||
if (!targetStatus) return;
|
||||
|
||||
const sourceTask = tasks.find((t) => t.id === active.id);
|
||||
if (!sourceTask) return;
|
||||
|
||||
// Compute new order: place after the over item, or end of column
|
||||
const targetTasks = tasks.filter(
|
||||
(t) => t.status === targetStatus && t.id !== active.id,
|
||||
);
|
||||
let newOrder: number;
|
||||
if (overData?.type === "task") {
|
||||
const overIndex = targetTasks.findIndex((t) => t.id === over.id);
|
||||
if (overIndex === -1) {
|
||||
newOrder = (targetTasks[targetTasks.length - 1]?.order ?? 0) + 1000;
|
||||
} else {
|
||||
const before = targetTasks[overIndex - 1]?.order ?? 0;
|
||||
const at = targetTasks[overIndex].order;
|
||||
newOrder = (before + at) / 2;
|
||||
}
|
||||
} else {
|
||||
newOrder = (targetTasks[targetTasks.length - 1]?.order ?? 0) + 1000;
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === sourceTask.id ? { ...t, status: targetStatus!, order: newOrder } : t)),
|
||||
);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await moveTaskAction(sourceTask.id, targetStatus!, newOrder);
|
||||
if (!result.ok) {
|
||||
// Rollback
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === sourceTask.id ? { ...t, status: sourceTask.status, order: sourceTask.order } : t,
|
||||
),
|
||||
);
|
||||
toast.error(result.error ?? "Görev taşınamadı.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Keep local state in sync when server data changes (e.g., after revalidate)
|
||||
useMemo(() => setTasks(initialTasks), [initialTasks]);
|
||||
|
||||
const handleAdd = (status: TaskStatus) => {
|
||||
setEditing(null);
|
||||
setFormStatus(status);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleting) return;
|
||||
startTransition(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("id", deleting.id);
|
||||
const result = await deleteTaskAction(fd);
|
||||
if (result.ok) {
|
||||
toast.success("Görev silindi.");
|
||||
setDeleting(null);
|
||||
} else {
|
||||
toast.error(result.error ?? "Silme başarısız.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => handleAdd("todo")}>
|
||||
<Plus className="size-4" />
|
||||
Yeni görev
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{COLUMNS.map((col) => (
|
||||
<Column
|
||||
key={col.key}
|
||||
status={col.key}
|
||||
title={col.title}
|
||||
tasks={grouped[col.key]}
|
||||
onAdd={handleAdd}
|
||||
onEdit={(t) => {
|
||||
setEditing(t);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onDelete={(t) => setDeleting(t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeTask && (
|
||||
<TaskCard
|
||||
task={activeTask}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
isOverlay
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
<TaskFormSheet
|
||||
open={formOpen}
|
||||
onOpenChange={(v) => {
|
||||
setFormOpen(v);
|
||||
if (!v) setEditing(null);
|
||||
}}
|
||||
task={editing}
|
||||
defaultStatus={formStatus}
|
||||
customers={customers}
|
||||
teamMembers={teamMembers}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(deleting)} onOpenChange={(v) => !v && setDeleting(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Görevi sil</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{deleting?.title}</strong> kalıcı olarak silinecek.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleting(null)} disabled={busy}>
|
||||
Vazgeç
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
Sil
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user