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);
|
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) {
|
||||||
|
|||||||
@@ -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 { 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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user