671195fb7d
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.
311 lines
8.6 KiB
TypeScript
311 lines
8.6 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|