diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index 3506fd2..e85ddbb 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -23,6 +23,7 @@ import { } from "@/db/schema"; import { eq, asc, and, isNull } from "drizzle-orm"; import { z } from "zod"; +import { isTaskStatus } from "@/lib/task-status"; // ── ENTITY RESOLUTION ──────────────────────────────────────────────────────── // Both clientId and projectId are passed as "clientId" by tab components. @@ -179,9 +180,13 @@ export async function addTask(phaseId: string, id: string, formData: FormData) { // ── PHASE STATUS CASCADE ────────────────────────────────────────────────────── // Recomputes phase status from its tasks: -// all done → done -// any in_progress or done (but not all done) → active -// all todo (or no tasks) → upcoming +// all done → done +// any task moved off "todo" → active +// 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 { const phaseTasks = await db .select({ status: tasks.status }) @@ -191,9 +196,7 @@ export async function recomputePhaseStatus(phaseId: string): Promise { let newStatus: "upcoming" | "active" | "done" = "upcoming"; if (phaseTasks.length > 0) { const allDone = phaseTasks.every((t) => t.status === "done"); - const anyActive = phaseTasks.some( - (t) => t.status === "in_progress" || t.status === "done" - ); + const anyActive = phaseTasks.some((t) => t.status !== "todo"); if (allDone) newStatus = "done"; else if (anyActive) newStatus = "active"; } @@ -202,8 +205,7 @@ export async function recomputePhaseStatus(phaseId: string): Promise { export async function updateTaskStatus(taskId: string, id: string, status: string) { await requireAdmin(); - const allowed = ["todo", "in_progress", "done"]; - if (!allowed.includes(status)) throw new Error("Stato non valido"); + if (!isTaskStatus(status)) throw new Error("Stato non valido"); await db.update(tasks).set({ status }).where(eq(tasks.id, taskId)); // Cascade: recompute parent phase status from all its tasks diff --git a/src/app/client/[token]/page.tsx b/src/app/client/[token]/page.tsx index 32c33cf..c280ce1 100644 --- a/src/app/client/[token]/page.tsx +++ b/src/app/client/[token]/page.tsx @@ -13,6 +13,7 @@ import { OtpGate } from "@/components/client/OtpGate"; import { PreviewBanner } from "@/components/client/PreviewBanner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { Comment } from "@/db/schema"; +import { normalizeTaskStatus } from "@/lib/task-status"; export const revalidate = 0; @@ -40,7 +41,7 @@ function projectViewToClientView( id: task.id, title: task.title, description: task.description, - status: task.status as "todo" | "in_progress" | "done", + status: normalizeTaskStatus(task.status), sort_order: task.sort_order, deliverables: task.deliverables.map((d) => ({ id: d.id, diff --git a/src/components/admin/kanban/KanbanBoard.tsx b/src/components/admin/kanban/KanbanBoard.tsx index 4a2f039..9e9efcb 100644 --- a/src/components/admin/kanban/KanbanBoard.tsx +++ b/src/components/admin/kanban/KanbanBoard.tsx @@ -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 = { + 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 = {}; 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} > -
+
{COLUMNS.map((col) => ( ; }; -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" }, diff --git a/src/components/client/PhaseCard.tsx b/src/components/client/PhaseCard.tsx index ccac7d1..da7eb9c 100644 --- a/src/components/client/PhaseCard.tsx +++ b/src/components/client/PhaseCard.tsx @@ -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 ( @@ -36,6 +37,16 @@ function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) ); } + if (status === "in_review") { + return ( + + + + ); + } if (status === "in_progress") { return ( diff --git a/src/components/client/kanban/ClientKanban.tsx b/src/components/client/kanban/ClientKanban.tsx index 40ab55c..e28c608 100644 --- a/src/components/client/kanban/ClientKanban.tsx +++ b/src/components/client/kanban/ClientKanban.tsx @@ -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 + ); return ( -
+
{COLUMNS.map((col) => (
diff --git a/src/db/schema.ts b/src/db/schema.ts index b9145eb..fa1bc59 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -141,7 +141,7 @@ export const tasks = pgTable("tasks", { .references(() => phases.id, { onDelete: "cascade" }), title: text("title").notNull(), 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), }); diff --git a/src/lib/client-view.ts b/src/lib/client-view.ts index f3f496a..a509e33 100644 --- a/src/lib/client-view.ts +++ b/src/lib/client-view.ts @@ -1,6 +1,7 @@ import { eq, ne, and, inArray, asc, desc } from "drizzle-orm"; 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 type { TaskStatus } from "@/lib/task-status"; /** * ClientView: Legacy shape used by ClientDashboard component. @@ -24,7 +25,7 @@ export interface ClientView { id: string; title: string; description: string | null; - status: "todo" | "in_progress" | "done"; + status: TaskStatus; sort_order: number; deliverables: Array<{ id: string; diff --git a/src/lib/task-status.ts b/src/lib/task-status.ts new file mode 100644 index 0000000..3db6b65 --- /dev/null +++ b/src/lib/task-status.ts @@ -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