feat(tasks): stato "In revisione", con gli stati finalmente in un posto solo

Mancava il modo di dire "finito, ma da controllare prima di consegnarlo".
Il nuovo stato sta fra "In corso" e "Fatto" ed e' visibile anche al cliente:
il lavoro c'e' ed e' in controllo qualita', non e' fermo.

Il costo non era la logica ma la dispersione: tre letterali ricopiati a mano in
otto file, in tre forme diverse (allow-list a runtime, union TS, colonne kanban,
opzioni della select) e nessun CHECK in DB a tenerli insieme. Invece di
modificarne quattordici occorrenze, tutto deriva da TASK_STATUSES in
src/lib/task-status.ts: la prossima aggiunta costa una riga.

Due punti perdevano dati in silenzio, ed erano il vero motivo per centralizzare:

- recomputePhaseStatus considerava "iniziato" solo in_progress|done, come lista.
  Una fase con tutti i task in revisione non rientrava ne' in allDone ne' in
  anyActive e retrocedeva a "upcoming": si leggeva "non iniziata" quando era
  quasi finita. Ora e' la negazione di "todo", e regge anche il prossimo stato.
- ClientKanban ripartiva i task con un oggetto a tre chiavi fisse, non derivato
  dalle colonne: un task fuori da quelle spariva da ogni colonna e da ogni
  contatore, e il cliente ne vedeva meno di quanti ce n'erano, senza errore.

Chiuso anche il cast non verificato al confine del portale (page.tsx), che era
la causa a monte di entrambi: ora ci passa normalizeTaskStatus.

Nessuna migration: tasks.status e' text senza CHECK, le righe esistenti valgono.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 16:10:52 +02:00
parent 3fcb10dac6
commit 5547e555bd
9 changed files with 108 additions and 52 deletions
+31 -24
View File
@@ -15,32 +15,40 @@ import {
} from "@dnd-kit/core";
import { updateTaskStatus } from "@/app/admin/clients/[id]/actions";
import type { ClientFullDetail } from "@/lib/admin-queries";
import {
normalizeTaskStatus,
TASK_STATUS_LABELS,
TASK_STATUSES,
type TaskStatus,
} from "@/lib/task-status";
type Task = ClientFullDetail["phases"][number]["tasks"][number] & {
phaseTitle: string;
};
type Status = "todo" | "in_progress" | "done";
type Status = TaskStatus;
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] = [
{
id: "todo",
label: "Da fare",
headerClass: "text-[#71717a]",
dotClass: "bg-[#d4d4d8]",
// Colours are status semantics — the sanctioned exception to the token rule.
// The hex values are pre-existing design debt (DEBT-01); "in revisione" uses
// violet rather than amber, which PhaseCard already spends on "in corso".
const COLUMN_STYLES: Record<Status, { headerClass: string; dotClass: string }> = {
todo: { headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
in_progress: { headerClass: "text-[#1A463C]", dotClass: "bg-[#DEF168]" },
in_review: {
headerClass: "text-violet-600 dark:text-violet-400",
dotClass: "bg-violet-500",
},
{
id: "in_progress",
label: "In corso",
headerClass: "text-[#1A463C]",
dotClass: "bg-[#DEF168]",
},
{
id: "done",
label: "Fatto",
headerClass: "text-[#1A463C]",
dotClass: "bg-[#1A463C]",
},
];
done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" },
};
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] =
TASK_STATUSES.map((id) => ({
id,
label: TASK_STATUS_LABELS[id],
...COLUMN_STYLES[id],
}));
// Derived from the columns, never a second hand-written list.
const VALID_STATUSES: string[] = COLUMNS.map((c) => c.id);
function DroppableColumn({
id,
@@ -149,7 +157,7 @@ export function KanbanBoard({
const map: Record<string, Status> = {};
for (const phase of phases) {
for (const task of phase.tasks) {
map[task.id] = task.status as Status;
map[task.id] = normalizeTaskStatus(task.status);
}
}
return map;
@@ -187,8 +195,7 @@ export function KanbanBoard({
const currentStatus = taskStatuses[taskId];
if (newStatus === currentStatus) return;
if (!(["todo", "in_progress", "done"] as string[]).includes(newStatus))
return;
if (!VALID_STATUSES.includes(newStatus)) return;
setTaskStatuses((prev) => ({ ...prev, [taskId]: newStatus }));
@@ -204,7 +211,7 @@ export function KanbanBoard({
onDragStart={(e) => setActiveId(e.active.id as string)}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
{COLUMNS.map((col) => (
<DroppableColumn
key={col.id}
+5 -5
View File
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
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";
type Props = {
phases: ClientFullDetail["phases"];
@@ -22,11 +23,10 @@ type Props = {
phaseSeconds?: Record<string, number>;
};
const taskStatusOptions = [
{ value: "todo", label: "Da fare" },
{ value: "in_progress", label: "In corso" },
{ value: "done", label: "Fatto" },
];
const taskStatusOptions = TASK_STATUSES.map((value) => ({
value,
label: TASK_STATUS_LABELS[value],
}));
const phaseStatusOptions = [
{ value: "upcoming", label: "Da iniziare" },
+12 -1
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { ApproveButton } from "@/components/client/ApproveButton";
import { useChatContext } from "@/components/client/ChatProvider";
import type { ClientView } from "@/lib/client-view";
import { TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
type Phase = ClientView["phases"][number];
@@ -28,7 +29,7 @@ const phaseBarColor: Record<"upcoming" | "active" | "done", string> = {
done: "bg-emerald-600",
};
function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) {
function TaskStatusIcon({ status }: { status: TaskStatus }) {
if (status === "done") {
return (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-50 text-[10px] font-bold text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">
@@ -36,6 +37,16 @@ function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" })
</span>
);
}
if (status === "in_review") {
return (
<span
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-violet-400 bg-card dark:border-violet-500"
title={TASK_STATUS_LABELS.in_review}
>
<span className="h-2 w-2 rounded-full bg-violet-500 dark:bg-violet-400" />
</span>
);
}
if (status === "in_progress") {
return (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-amber-400 bg-card">
+16 -11
View File
@@ -2,16 +2,16 @@
import type { ClientView } from "@/lib/client-view";
import { ApproveButton } from "@/components/client/ApproveButton";
import { TASK_STATUS_LABELS, TASK_STATUSES, type TaskStatus } from "@/lib/task-status";
type Task = ClientView["phases"][number]["tasks"][number] & {
phaseTitle: string;
};
const COLUMNS: { id: "todo" | "in_progress" | "done"; label: string }[] = [
{ id: "todo", label: "Da fare" },
{ id: "in_progress", label: "In corso" },
{ id: "done", label: "Fatto" },
];
const COLUMNS: { id: TaskStatus; label: string }[] = TASK_STATUSES.map((id) => ({
id,
label: TASK_STATUS_LABELS[id],
}));
function TaskCard({ task, token }: { task: Task; token: string }) {
return (
@@ -46,14 +46,19 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
);
const tasksByStatus = {
todo: allTasks.filter((t) => t.status === "todo"),
in_progress: allTasks.filter((t) => t.status === "in_progress"),
done: allTasks.filter((t) => t.status === "done"),
};
// Derived from the statuses, not a hand-written object: as three fixed keys it
// dropped any task outside them from every column AND every counter, so the
// client silently saw fewer tasks than the project had.
const tasksByStatus = COLUMNS.reduce(
(acc, col) => {
acc[col.id] = allTasks.filter((t) => t.status === col.id);
return acc;
},
{} as Record<TaskStatus, Task[]>
);
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
{COLUMNS.map((col) => (
<div key={col.id} className="flex flex-col gap-4 rounded-xl border border-border-light bg-muted/60 p-4 min-h-[300px]">
<div className="flex items-center justify-between border-b border-border-light pb-2">