feat(task): riordino dei task dentro la fase, trascinando

tasks.sort_order esisteva e veniva letto in ORDER BY, ma non era mai scritto se
non come max+1 all'inserimento: nel repo non c'era alcun riordino, per nessuna
entita'. @dnd-kit/sortable era gia' installato e mai importato.

Il nodo era PhasesTab: e' un Server Component con quattro closure "use server"
inline, che in un modulo client non compilano. Quindi niente conversione: le
righe restano renderizzate dal server e arrivano a SortableTaskList come
ReactNode opachi, che ci monta attorno solo la maniglia. E' la stessa forma di
PhasesViewToggle, che gia' passa un tab server-renderizzato come prop.

reorderTasks riscrive sort_order come 0..n-1 per tutta la fase invece di
scambiare due righe. Non e' pigrizia: in produzione una fase ha 14 task con
sort_order sparsi su 0..23 (buchi lasciati dai delete), le righe legacy stanno
sullo 0 di default e non esiste unique index su (phase_id, sort_order), quindi i
duplicati sono ammessi. La riscrittura completa normalizza tutto a ogni drop. Gli
id arrivano dal client, quindi vengono filtrati su quelli che appartengono
davvero alla fase.

Niente pacchetti nuovi: @dnd-kit/modifiers non c'e', e il vincolo verticale si
ottiene azzerando la X della transform.

Verificato a runtime contro il DB di produzione (build di produzione + tunnel
SSH, in sola lettura): la pagina progetto risponde 200 e rende 27 maniglie, che
sono esattamente i task delle fasi con piu' di un task. Il passaggio di nodi
server con "use server" inline attraverso il confine client regge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 22:41:46 +02:00
parent b49d4bfaaa
commit 1fa8e1ab5e
3 changed files with 250 additions and 62 deletions
+128
View File
@@ -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 (
<div
ref={setNodeRef}
// Vertical-only: zero the X component instead of pulling in
// @dnd-kit/modifiers just for restrictToVerticalAxis.
style={{
transform: CSS.Transform.toString(transform ? { ...transform, x: 0 } : null),
transition,
}}
className={`flex items-center gap-1 ${isDragging ? "relative z-10 opacity-80" : ""}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label="Trascina per riordinare"
className="shrink-0 cursor-grab touch-none rounded p-1 text-tertiary opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 active:cursor-grabbing group-hover/tasks:opacity-100"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
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<string[]>(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 <div className="space-y-2">{items.map((i) => i.node)}</div>;
}
return (
<DndContext
sensors={sensors}
onDragEnd={handleDragEnd}
>
<SortableContext items={order} strategy={verticalListSortingStrategy}>
<div className="group/tasks space-y-2">
{order.map((id) => (
<SortableRow key={id} id={id}>
{byId.get(id)}
</SortableRow>
))}
</div>
</SortableContext>
</DndContext>
);
}
+88 -62
View File
@@ -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({
</option>
))}
</select>
<Button type="submit" variant="ghost" size="sm" className="text-xs">
<Button
type="submit"
variant="ghost"
size="sm"
className="text-xs"
>
Salva
</Button>
</form>
<DeletePhaseTaskButton type="phase" phaseId={phase.id} clientId={clientId} />
<DeletePhaseTaskButton
type="phase"
phaseId={phase.id}
clientId={clientId}
/>
</div>
</div>
{/* Tasks */}
<div className="space-y-2 mb-3">
{phase.tasks.map((task) => (
<div
key={task.id}
className="flex items-center justify-between gap-3 pl-3 border-l-2 border-border"
>
<span className="text-sm text-foreground">{task.title}</span>
<div className="flex items-center gap-1">
{projectId && (
<TimerCell
projectId={projectId}
phaseId={phase.id}
taskId={task.id}
activeEntryId={
activeTimerTaskId === task.id ? activeTimerEntryId : null
}
activeStartedAt={
activeTimerTaskId === task.id ? activeTimerStartedAt : null
}
totalTrackedSeconds={taskSeconds[task.id] ?? 0}
compact
/>
)}
<form
action={async (fd: FormData) => {
"use server";
await updateTaskStatus(
task.id,
clientId,
fd.get("status") as string
);
}}
className="flex items-center gap-1"
>
<select
name="status"
defaultValue={task.status}
className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground"
>
{taskStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<Button
type="submit"
variant="ghost"
size="sm"
className="text-xs px-1"
>
</Button>
</form>
<DeletePhaseTaskButton type="task" taskId={task.id} clientId={clientId} />
</div>
</div>
))}
{/* 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. */}
<div className="mb-3">
<SortableTaskList
phaseId={phase.id}
entityId={clientId}
items={phase.tasks.map((task) => ({
id: task.id,
node: (
<div className="flex items-center justify-between gap-3 pl-3 border-l-2 border-border">
<span className="text-sm text-foreground">
{task.title}
</span>
<div className="flex items-center gap-1">
{projectId && (
<TimerCell
projectId={projectId}
phaseId={phase.id}
taskId={task.id}
activeEntryId={
activeTimerTaskId === task.id
? activeTimerEntryId
: null
}
activeStartedAt={
activeTimerTaskId === task.id
? activeTimerStartedAt
: null
}
totalTrackedSeconds={taskSeconds[task.id] ?? 0}
compact
/>
)}
<form
action={async (fd: FormData) => {
"use server";
await updateTaskStatus(
task.id,
clientId,
fd.get("status") as string,
);
}}
className="flex items-center gap-1"
>
<select
name="status"
defaultValue={task.status}
className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground"
>
{taskStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<Button
type="submit"
variant="ghost"
size="sm"
className="text-xs px-1"
>
</Button>
</form>
<DeletePhaseTaskButton
type="task"
taskId={task.id}
clientId={clientId}
/>
</div>
</div>
),
}))}
/>
</div>
{/* Add task form */}