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
+34
View File
@@ -227,6 +227,40 @@ export async function deleteTask(taskId: string, id: string) {
revalidatePath(path); 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<void> {
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 ────────────────────────────────────────────────────────────── // ── DELIVERABLES ──────────────────────────────────────────────────────────────
export async function addDeliverable(taskId: string, id: string, formData: FormData) { export async function addDeliverable(taskId: string, id: string, formData: FormData) {
+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 { TimerCell } from "@/components/admin/TimerCell";
import type { ClientFullDetail } from "@/lib/admin-queries"; import type { ClientFullDetail } from "@/lib/admin-queries";
import { TASK_STATUS_LABELS, TASK_STATUSES } from "@/lib/task-status"; import { TASK_STATUS_LABELS, TASK_STATUSES } from "@/lib/task-status";
import { SortableTaskList } from "@/components/admin/SortableTaskList";
type Props = { type Props = {
phases: ClientFullDetail["phases"]; phases: ClientFullDetail["phases"];
@@ -102,7 +103,7 @@ export async function PhasesTab({
await updatePhaseStatus( await updatePhaseStatus(
phase.id, phase.id,
clientId, clientId,
fd.get("status") as string fd.get("status") as string,
); );
}} }}
className="flex items-center gap-2" className="flex items-center gap-2"
@@ -118,73 +119,98 @@ export async function PhasesTab({
</option> </option>
))} ))}
</select> </select>
<Button type="submit" variant="ghost" size="sm" className="text-xs"> <Button
type="submit"
variant="ghost"
size="sm"
className="text-xs"
>
Salva Salva
</Button> </Button>
</form> </form>
<DeletePhaseTaskButton type="phase" phaseId={phase.id} clientId={clientId} /> <DeletePhaseTaskButton
type="phase"
phaseId={phase.id}
clientId={clientId}
/>
</div> </div>
</div> </div>
{/* Tasks */} {/* Tasks — server-rendered rows handed to a client wrapper that adds the
<div className="space-y-2 mb-3"> drag handle. The rows keep their inline "use server" forms, which is
{phase.tasks.map((task) => ( only legal inside a Server Component. */}
<div <div className="mb-3">
key={task.id} <SortableTaskList
className="flex items-center justify-between gap-3 pl-3 border-l-2 border-border" phaseId={phase.id}
> entityId={clientId}
<span className="text-sm text-foreground">{task.title}</span> items={phase.tasks.map((task) => ({
<div className="flex items-center gap-1"> id: task.id,
{projectId && ( node: (
<TimerCell <div className="flex items-center justify-between gap-3 pl-3 border-l-2 border-border">
projectId={projectId} <span className="text-sm text-foreground">
phaseId={phase.id} {task.title}
taskId={task.id} </span>
activeEntryId={ <div className="flex items-center gap-1">
activeTimerTaskId === task.id ? activeTimerEntryId : null {projectId && (
} <TimerCell
activeStartedAt={ projectId={projectId}
activeTimerTaskId === task.id ? activeTimerStartedAt : null phaseId={phase.id}
} taskId={task.id}
totalTrackedSeconds={taskSeconds[task.id] ?? 0} activeEntryId={
compact activeTimerTaskId === task.id
/> ? activeTimerEntryId
)} : null
<form }
action={async (fd: FormData) => { activeStartedAt={
"use server"; activeTimerTaskId === task.id
await updateTaskStatus( ? activeTimerStartedAt
task.id, : null
clientId, }
fd.get("status") as string totalTrackedSeconds={taskSeconds[task.id] ?? 0}
); compact
}} />
className="flex items-center gap-1" )}
> <form
<select action={async (fd: FormData) => {
name="status" "use server";
defaultValue={task.status} await updateTaskStatus(
className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground" task.id,
> clientId,
{taskStatusOptions.map((o) => ( fd.get("status") as string,
<option key={o.value} value={o.value}> );
{o.label} }}
</option> className="flex items-center gap-1"
))} >
</select> <select
<Button name="status"
type="submit" defaultValue={task.status}
variant="ghost" className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground"
size="sm" >
className="text-xs px-1" {taskStatusOptions.map((o) => (
> <option key={o.value} value={o.value}>
{o.label}
</Button> </option>
</form> ))}
<DeletePhaseTaskButton type="task" taskId={task.id} clientId={clientId} /> </select>
</div> <Button
</div> type="submit"
))} variant="ghost"
size="sm"
className="text-xs px-1"
>
</Button>
</form>
<DeletePhaseTaskButton
type="task"
taskId={task.id}
clientId={clientId}
/>
</div>
</div>
),
}))}
/>
</div> </div>
{/* Add task form */} {/* Add task form */}