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:
@@ -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<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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addDeliverable(taskId: string, id: string, formData: FormData) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user