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:
kovakmedya
2026-04-30 05:57:35 +03:00
parent add2317717
commit 671195fb7d
22 changed files with 1047 additions and 1793 deletions
@@ -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>
</>
);
}