diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index a9c26bb..4e989cf 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -227,6 +227,40 @@ export async function deleteTask(taskId: string, id: string) { revalidatePath(path); } +// Persists a new task order inside one phase. +// +// Rewrites sort_order for EVERY task of the phase as 0..n-1 rather than swapping +// two rows. That is deliberate: sort_order is not a reliable index today — +// deleteTask leaves gaps (0,1,3), rows created outside addTask/importOfferIntoProject +// sit at the 0 default, and there is no unique index on (phase_id, sort_order) so +// duplicates are legal. A full rewrite normalizes all of that on every drop. +export async function reorderTasks( + phaseId: string, + id: string, + orderedTaskIds: string[] +): Promise { + await requireAdmin(); + + // The array comes from the client: only trust ids that really belong to this phase. + const phaseTasks = await db + .select({ id: tasks.id }) + .from(tasks) + .where(eq(tasks.phase_id, phaseId)); + const belongs = new Set(phaseTasks.map((t) => t.id)); + + const ordered = orderedTaskIds.filter((taskId) => belongs.has(taskId)); + if (ordered.length !== phaseTasks.length) { + throw new Error("Ordine non valido: elenco task incompleto"); + } + + for (let i = 0; i < ordered.length; i++) { + await db.update(tasks).set({ sort_order: i }).where(eq(tasks.id, ordered[i])); + } + + const { path } = await resolveEntity(id); + revalidatePath(path); +} + // ── DELIVERABLES ────────────────────────────────────────────────────────────── export async function addDeliverable(taskId: string, id: string, formData: FormData) { diff --git a/src/components/admin/SortableTaskList.tsx b/src/components/admin/SortableTaskList.tsx new file mode 100644 index 0000000..5f34008 --- /dev/null +++ b/src/components/admin/SortableTaskList.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { type ReactNode, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical } from "lucide-react"; +import { reorderTasks } from "@/app/admin/clients/[id]/actions"; + +export type SortableTaskItem = { id: string; node: ReactNode }; + +// PhasesTab is a Server Component holding inline "use server" form closures, which +// cannot live in a client module. So the task rows stay server-rendered and arrive +// here as opaque ReactNodes; this component only adds the handle and the ordering. +// Same shape as PhasesViewToggle, which already passes a server-rendered tab as a prop. +function SortableRow({ id, children }: { id: string; children: ReactNode }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id }); + + return ( +
+ +
{children}
+
+ ); +} + +export function SortableTaskList({ + phaseId, + entityId, + items, +}: { + phaseId: string; + entityId: string; + items: SortableTaskItem[]; +}) { + const router = useRouter(); + const [, startTransition] = useTransition(); + + const incomingIds = items.map((i) => i.id); + const [order, setOrder] = useState(incomingIds); + + // Re-sync when the server sends a different set or order. The two existing + // kanban boards seed state once and never resync; for a status that barely + // shows, for an ORDER it means a stale local array fights router.refresh(). + // Adjusted during render (React's documented pattern), not in an effect. + const [prevIds, setPrevIds] = useState(incomingIds.join(",")); + if (prevIds !== incomingIds.join(",")) { + setPrevIds(incomingIds.join(",")); + setOrder(incomingIds); + } + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor) + ); + + const byId = new Map(items.map((i) => [i.id, i.node])); + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const from = order.indexOf(active.id as string); + const to = order.indexOf(over.id as string); + if (from === -1 || to === -1) return; + + const next = arrayMove(order, from, to); + setOrder(next); + + startTransition(async () => { + await reorderTasks(phaseId, entityId, next); + router.refresh(); + }); + } + + // One task can't be reordered — skip the DnD machinery entirely. + if (items.length < 2) { + return
{items.map((i) => i.node)}
; + } + + return ( + + +
+ {order.map((id) => ( + + {byId.get(id)} + + ))} +
+
+
+ ); +} diff --git a/src/components/admin/tabs/PhasesTab.tsx b/src/components/admin/tabs/PhasesTab.tsx index b9d5be8..ea921ec 100644 --- a/src/components/admin/tabs/PhasesTab.tsx +++ b/src/components/admin/tabs/PhasesTab.tsx @@ -10,6 +10,7 @@ import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton" import { TimerCell } from "@/components/admin/TimerCell"; import type { ClientFullDetail } from "@/lib/admin-queries"; import { TASK_STATUS_LABELS, TASK_STATUSES } from "@/lib/task-status"; +import { SortableTaskList } from "@/components/admin/SortableTaskList"; type Props = { phases: ClientFullDetail["phases"]; @@ -102,7 +103,7 @@ export async function PhasesTab({ await updatePhaseStatus( phase.id, clientId, - fd.get("status") as string + fd.get("status") as string, ); }} className="flex items-center gap-2" @@ -118,73 +119,98 @@ export async function PhasesTab({ ))} - - + - {/* Tasks */} -
- {phase.tasks.map((task) => ( -
- {task.title} -
- {projectId && ( - - )} -
{ - "use server"; - await updateTaskStatus( - task.id, - clientId, - fd.get("status") as string - ); - }} - className="flex items-center gap-1" - > - - -
- -
-
- ))} + {/* Tasks — server-rendered rows handed to a client wrapper that adds the + drag handle. The rows keep their inline "use server" forms, which is + only legal inside a Server Component. */} +
+ ({ + id: task.id, + node: ( +
+ + {task.title} + +
+ {projectId && ( + + )} +
{ + "use server"; + await updateTaskStatus( + task.id, + clientId, + fd.get("status") as string, + ); + }} + className="flex items-center gap-1" + > + + +
+ +
+
+ ), + }))} + />
{/* Add task form */}