Files
isletmem-kovakcrm/src/app/(dashboard)/tasks/components/tasks-board.tsx
T
kovakmedya 671195fb7d 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.
2026-04-30 05:57:35 +03:00

311 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
</>
);
}