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:
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { eq, asc, and, isNull } from "drizzle-orm";
|
import { eq, asc, and, isNull } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { isTaskStatus } from "@/lib/task-status";
|
||||||
|
|
||||||
// ── ENTITY RESOLUTION ────────────────────────────────────────────────────────
|
// ── ENTITY RESOLUTION ────────────────────────────────────────────────────────
|
||||||
// Both clientId and projectId are passed as "clientId" by tab components.
|
// Both clientId and projectId are passed as "clientId" by tab components.
|
||||||
@@ -180,8 +181,12 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
|
|||||||
// ── PHASE STATUS CASCADE ──────────────────────────────────────────────────────
|
// ── PHASE STATUS CASCADE ──────────────────────────────────────────────────────
|
||||||
// Recomputes phase status from its tasks:
|
// Recomputes phase status from its tasks:
|
||||||
// all done → done
|
// all done → done
|
||||||
// any in_progress or done (but not all done) → active
|
// any task moved off "todo" → active
|
||||||
// all todo (or no tasks) → upcoming
|
// all todo (or no tasks) → upcoming
|
||||||
|
// "any moved off todo" is deliberately a negation, not a list of the statuses
|
||||||
|
// that count as started: as a list it silently forgot new statuses, and a phase
|
||||||
|
// whose tasks were all in review fell back to "upcoming" — reading as
|
||||||
|
// not-started when it was nearly finished.
|
||||||
export async function recomputePhaseStatus(phaseId: string): Promise<void> {
|
export async function recomputePhaseStatus(phaseId: string): Promise<void> {
|
||||||
const phaseTasks = await db
|
const phaseTasks = await db
|
||||||
.select({ status: tasks.status })
|
.select({ status: tasks.status })
|
||||||
@@ -191,9 +196,7 @@ export async function recomputePhaseStatus(phaseId: string): Promise<void> {
|
|||||||
let newStatus: "upcoming" | "active" | "done" = "upcoming";
|
let newStatus: "upcoming" | "active" | "done" = "upcoming";
|
||||||
if (phaseTasks.length > 0) {
|
if (phaseTasks.length > 0) {
|
||||||
const allDone = phaseTasks.every((t) => t.status === "done");
|
const allDone = phaseTasks.every((t) => t.status === "done");
|
||||||
const anyActive = phaseTasks.some(
|
const anyActive = phaseTasks.some((t) => t.status !== "todo");
|
||||||
(t) => t.status === "in_progress" || t.status === "done"
|
|
||||||
);
|
|
||||||
if (allDone) newStatus = "done";
|
if (allDone) newStatus = "done";
|
||||||
else if (anyActive) newStatus = "active";
|
else if (anyActive) newStatus = "active";
|
||||||
}
|
}
|
||||||
@@ -202,8 +205,7 @@ export async function recomputePhaseStatus(phaseId: string): Promise<void> {
|
|||||||
|
|
||||||
export async function updateTaskStatus(taskId: string, id: string, status: string) {
|
export async function updateTaskStatus(taskId: string, id: string, status: string) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const allowed = ["todo", "in_progress", "done"];
|
if (!isTaskStatus(status)) throw new Error("Stato non valido");
|
||||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
|
||||||
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
||||||
|
|
||||||
// Cascade: recompute parent phase status from all its tasks
|
// Cascade: recompute parent phase status from all its tasks
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { OtpGate } from "@/components/client/OtpGate";
|
|||||||
import { PreviewBanner } from "@/components/client/PreviewBanner";
|
import { PreviewBanner } from "@/components/client/PreviewBanner";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type { Comment } from "@/db/schema";
|
import type { Comment } from "@/db/schema";
|
||||||
|
import { normalizeTaskStatus } from "@/lib/task-status";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ function projectViewToClientView(
|
|||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
status: task.status as "todo" | "in_progress" | "done",
|
status: normalizeTaskStatus(task.status),
|
||||||
sort_order: task.sort_order,
|
sort_order: task.sort_order,
|
||||||
deliverables: task.deliverables.map((d) => ({
|
deliverables: task.deliverables.map((d) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
|
|||||||
@@ -15,32 +15,40 @@ import {
|
|||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { updateTaskStatus } from "@/app/admin/clients/[id]/actions";
|
import { updateTaskStatus } from "@/app/admin/clients/[id]/actions";
|
||||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
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] & {
|
type Task = ClientFullDetail["phases"][number]["tasks"][number] & {
|
||||||
phaseTitle: string;
|
phaseTitle: string;
|
||||||
};
|
};
|
||||||
type Status = "todo" | "in_progress" | "done";
|
type Status = TaskStatus;
|
||||||
|
|
||||||
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] = [
|
// Colours are status semantics — the sanctioned exception to the token rule.
|
||||||
{
|
// The hex values are pre-existing design debt (DEBT-01); "in revisione" uses
|
||||||
id: "todo",
|
// violet rather than amber, which PhaseCard already spends on "in corso".
|
||||||
label: "Da fare",
|
const COLUMN_STYLES: Record<Status, { headerClass: string; dotClass: string }> = {
|
||||||
headerClass: "text-[#71717a]",
|
todo: { headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
|
||||||
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",
|
||||||
},
|
},
|
||||||
{
|
done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" },
|
||||||
id: "in_progress",
|
};
|
||||||
label: "In corso",
|
|
||||||
headerClass: "text-[#1A463C]",
|
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] =
|
||||||
dotClass: "bg-[#DEF168]",
|
TASK_STATUSES.map((id) => ({
|
||||||
},
|
id,
|
||||||
{
|
label: TASK_STATUS_LABELS[id],
|
||||||
id: "done",
|
...COLUMN_STYLES[id],
|
||||||
label: "Fatto",
|
}));
|
||||||
headerClass: "text-[#1A463C]",
|
|
||||||
dotClass: "bg-[#1A463C]",
|
// Derived from the columns, never a second hand-written list.
|
||||||
},
|
const VALID_STATUSES: string[] = COLUMNS.map((c) => c.id);
|
||||||
];
|
|
||||||
|
|
||||||
function DroppableColumn({
|
function DroppableColumn({
|
||||||
id,
|
id,
|
||||||
@@ -149,7 +157,7 @@ export function KanbanBoard({
|
|||||||
const map: Record<string, Status> = {};
|
const map: Record<string, Status> = {};
|
||||||
for (const phase of phases) {
|
for (const phase of phases) {
|
||||||
for (const task of phase.tasks) {
|
for (const task of phase.tasks) {
|
||||||
map[task.id] = task.status as Status;
|
map[task.id] = normalizeTaskStatus(task.status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@@ -187,8 +195,7 @@ export function KanbanBoard({
|
|||||||
const currentStatus = taskStatuses[taskId];
|
const currentStatus = taskStatuses[taskId];
|
||||||
|
|
||||||
if (newStatus === currentStatus) return;
|
if (newStatus === currentStatus) return;
|
||||||
if (!(["todo", "in_progress", "done"] as string[]).includes(newStatus))
|
if (!VALID_STATUSES.includes(newStatus)) return;
|
||||||
return;
|
|
||||||
|
|
||||||
setTaskStatuses((prev) => ({ ...prev, [taskId]: newStatus }));
|
setTaskStatuses((prev) => ({ ...prev, [taskId]: newStatus }));
|
||||||
|
|
||||||
@@ -204,7 +211,7 @@ export function KanbanBoard({
|
|||||||
onDragStart={(e) => setActiveId(e.active.id as string)}
|
onDragStart={(e) => setActiveId(e.active.id as string)}
|
||||||
onDragEnd={handleDragEnd}
|
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) => (
|
{COLUMNS.map((col) => (
|
||||||
<DroppableColumn
|
<DroppableColumn
|
||||||
key={col.id}
|
key={col.id}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
|
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";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
phases: ClientFullDetail["phases"];
|
phases: ClientFullDetail["phases"];
|
||||||
@@ -22,11 +23,10 @@ type Props = {
|
|||||||
phaseSeconds?: Record<string, number>;
|
phaseSeconds?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const taskStatusOptions = [
|
const taskStatusOptions = TASK_STATUSES.map((value) => ({
|
||||||
{ value: "todo", label: "Da fare" },
|
value,
|
||||||
{ value: "in_progress", label: "In corso" },
|
label: TASK_STATUS_LABELS[value],
|
||||||
{ value: "done", label: "Fatto" },
|
}));
|
||||||
];
|
|
||||||
|
|
||||||
const phaseStatusOptions = [
|
const phaseStatusOptions = [
|
||||||
{ value: "upcoming", label: "Da iniziare" },
|
{ value: "upcoming", label: "Da iniziare" },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { ApproveButton } from "@/components/client/ApproveButton";
|
import { ApproveButton } from "@/components/client/ApproveButton";
|
||||||
import { useChatContext } from "@/components/client/ChatProvider";
|
import { useChatContext } from "@/components/client/ChatProvider";
|
||||||
import type { ClientView } from "@/lib/client-view";
|
import type { ClientView } from "@/lib/client-view";
|
||||||
|
import { TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
|
||||||
|
|
||||||
type Phase = ClientView["phases"][number];
|
type Phase = ClientView["phases"][number];
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ const phaseBarColor: Record<"upcoming" | "active" | "done", string> = {
|
|||||||
done: "bg-emerald-600",
|
done: "bg-emerald-600",
|
||||||
};
|
};
|
||||||
|
|
||||||
function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) {
|
function TaskStatusIcon({ status }: { status: TaskStatus }) {
|
||||||
if (status === "done") {
|
if (status === "done") {
|
||||||
return (
|
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">
|
<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>
|
</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") {
|
if (status === "in_progress") {
|
||||||
return (
|
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">
|
<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">
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
import type { ClientView } from "@/lib/client-view";
|
import type { ClientView } from "@/lib/client-view";
|
||||||
import { ApproveButton } from "@/components/client/ApproveButton";
|
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] & {
|
type Task = ClientView["phases"][number]["tasks"][number] & {
|
||||||
phaseTitle: string;
|
phaseTitle: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLUMNS: { id: "todo" | "in_progress" | "done"; label: string }[] = [
|
const COLUMNS: { id: TaskStatus; label: string }[] = TASK_STATUSES.map((id) => ({
|
||||||
{ id: "todo", label: "Da fare" },
|
id,
|
||||||
{ id: "in_progress", label: "In corso" },
|
label: TASK_STATUS_LABELS[id],
|
||||||
{ id: "done", label: "Fatto" },
|
}));
|
||||||
];
|
|
||||||
|
|
||||||
function TaskCard({ task, token }: { task: Task; token: string }) {
|
function TaskCard({ task, token }: { task: Task; token: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -46,14 +46,19 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
|
|||||||
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
|
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
|
||||||
);
|
);
|
||||||
|
|
||||||
const tasksByStatus = {
|
// Derived from the statuses, not a hand-written object: as three fixed keys it
|
||||||
todo: allTasks.filter((t) => t.status === "todo"),
|
// dropped any task outside them from every column AND every counter, so the
|
||||||
in_progress: allTasks.filter((t) => t.status === "in_progress"),
|
// client silently saw fewer tasks than the project had.
|
||||||
done: allTasks.filter((t) => t.status === "done"),
|
const tasksByStatus = COLUMNS.reduce(
|
||||||
};
|
(acc, col) => {
|
||||||
|
acc[col.id] = allTasks.filter((t) => t.status === col.id);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<TaskStatus, Task[]>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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) => (
|
{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 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">
|
<div className="flex items-center justify-between border-b border-border-light pb-2">
|
||||||
|
|||||||
+1
-1
@@ -141,7 +141,7 @@ export const tasks = pgTable("tasks", {
|
|||||||
.references(() => phases.id, { onDelete: "cascade" }),
|
.references(() => phases.id, { onDelete: "cascade" }),
|
||||||
title: text("title").notNull(),
|
title: text("title").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
status: text("status").notNull().default("todo"), // todo | in_progress | done
|
status: text("status").notNull().default("todo"), // TASK_STATUSES in src/lib/task-status.ts
|
||||||
sort_order: integer("sort_order").notNull().default(0),
|
sort_order: integer("sort_order").notNull().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
|
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
|
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
|
||||||
|
import type { TaskStatus } from "@/lib/task-status";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ClientView: Legacy shape used by ClientDashboard component.
|
* ClientView: Legacy shape used by ClientDashboard component.
|
||||||
@@ -24,7 +25,7 @@ export interface ClientView {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
status: "todo" | "in_progress" | "done";
|
status: TaskStatus;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
deliverables: Array<{
|
deliverables: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// ── Task statuses, in one place ───────────────────────────────────────────────
|
||||||
|
// `tasks.status` is a plain text column with no CHECK constraint, and until now
|
||||||
|
// the three values were retyped by hand in eight files — as a runtime allow-list,
|
||||||
|
// as a TS union, as kanban columns, as <select> options. Two of those copies
|
||||||
|
// dropped unknown values silently. Everything derives from here instead.
|
||||||
|
//
|
||||||
|
// No db import on purpose: client components import this file.
|
||||||
|
|
||||||
|
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const;
|
||||||
|
|
||||||
|
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
||||||
|
|
||||||
|
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
|
||||||
|
todo: "Da fare",
|
||||||
|
in_progress: "In corso",
|
||||||
|
in_review: "In revisione",
|
||||||
|
done: "Fatto",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isTaskStatus(value: string): value is TaskStatus {
|
||||||
|
return (TASK_STATUSES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guards the boundary where a raw DB string enters a typed union. Previously an
|
||||||
|
// `as` cast, which TypeScript does not check: a row holding an unknown status
|
||||||
|
// slipped through and then vanished from whichever board failed to match it.
|
||||||
|
export function normalizeTaskStatus(value: string): TaskStatus {
|
||||||
|
return isTaskStatus(value) ? value : "todo";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user