feat(portale): gli stati dei task si distinguono, e "in revisione" dice di chi e' la palla
Nella Timeline — la vista di default — lo stato di un task era solo un cerchietto colorato: ambra col puntino da 1.5px per "in corso", violetto col puntino da 2px per "in revisione". A 20px sono lo stesso oggetto. Solo "in revisione" aveva un title; gli altri tre stati non avevano ne' testo, ne' tooltip, ne' aria-label — l'opposto della regola UX 3 del design system, che chiede colore + testo, mai colore da solo. Il punto pero' non era la somiglianza fra i due colori. "In revisione" non significa quello che dice5547e55, che lo descriveva come controllo qualita' interno: nel flusso reale vuol dire "consegnato, aspetta l'OK del cliente". E' uno stato che richiede un'azione, e per questo un cerchietto muto era il problema vero. - Le quattro icone si distinguono per forma, non solo per colore, quindi reggono anche in bianco e nero e con un daltonismo: vuoto -> punto -> spunta vuota -> spunta piena. La spunta compare a lavoro finito e si riempie a lavoro confermato. - Pill di testo solo su "in corso" e "in revisione". "Da fare" e' un cerchio vuoto e "fatto" ha il titolo barrato: etichettarli aggiungeva rumore, non informazione. - Il conteggio dei task in attesa sta nell'header della fase, quindi si legge anche a card chiusa. Serve davvero: l'admin puo' forzare una fase su "completata" dalla select, e in quel caso la card parte collassata con dentro la richiesta. - Legenda + micro-copy sopra entrambe le viste: chi revisiona, e che ogni pagina include un giro di revisione — l'informazione commerciale che il cliente non aveva da nessuna parte. Rimanda alla chat della fase, che esiste gia'. - Colonne del kanban cliente colorate con le stesse tinte dell'icona in Timeline: prima erano quattro colonne grigie identiche. - Icona aria-hidden + sr-only, cosi' lo screen reader annuncia lo stato anche sui due che non portano la pill. La barra di avanzamento non e' stata toccata: un task in revisione conta come uno in corso, cioe' zero. Cambia il tipo di lavoro, non l'avanzamento — gonfiare la percentuale le avrebbe fatto dire una cosa che il contatore "x di y task" smentiva una riga sotto. Visual e copy in un solo file, e la legenda si genera da TASK_STATUSES: un quinto stato non lascera' indietro una lista scritta a mano. Stessa disciplina di5547e55, nata proprio perche' due liste fisse avevano fatto sparire dei task senza un errore. Nessuna migration: e' solo UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
import { useState } from "react";
|
||||
import { ApproveButton } from "@/components/client/ApproveButton";
|
||||
import { useChatContext } from "@/components/client/ChatProvider";
|
||||
import { TaskStatusIcon, TaskStatusPill } from "@/components/client/TaskStatusIndicator";
|
||||
import type { ClientView } from "@/lib/client-view";
|
||||
import { TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
|
||||
|
||||
type Phase = ClientView["phases"][number];
|
||||
|
||||
@@ -29,36 +29,6 @@ const phaseBarColor: Record<"upcoming" | "active" | "done", string> = {
|
||||
done: "bg-emerald-600",
|
||||
};
|
||||
|
||||
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">
|
||||
✓
|
||||
</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">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-border bg-card" />
|
||||
);
|
||||
}
|
||||
|
||||
export function PhaseCard({
|
||||
phase,
|
||||
token,
|
||||
@@ -71,6 +41,10 @@ export function PhaseCard({
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const { openChat } = useChatContext();
|
||||
const doneCount = phase.tasks.filter((t) => t.status === "done").length;
|
||||
// I task in revisione aspettano il cliente, non noi. Il conteggio sta nell'header,
|
||||
// che resta visibile anche a card chiusa: altrimenti la richiesta si nasconde
|
||||
// dentro una fase collassata e il cliente non sa che tocca a lui.
|
||||
const reviewCount = phase.tasks.filter((t) => t.status === "in_review").length;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border-light bg-card p-6 shadow-card">
|
||||
@@ -118,9 +92,17 @@ export function PhaseCard({
|
||||
|
||||
{/* Progress bar — always visible, colore per stato */}
|
||||
<div className="mb-1">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<p className="text-xs text-muted-foreground font-medium">{doneCount} di {phase.tasks.length} task</p>
|
||||
<p className="text-xs font-semibold text-foreground">{phase.progress_pct}%</p>
|
||||
<div className="flex justify-between items-center gap-3 mb-1.5">
|
||||
<p className="text-xs text-muted-foreground font-medium min-w-0">
|
||||
{doneCount} di {phase.tasks.length} task
|
||||
{reviewCount > 0 && (
|
||||
<span className="text-violet-600 dark:text-violet-400">
|
||||
{" · "}
|
||||
{reviewCount} in attesa di riscontro
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-foreground shrink-0">{phase.progress_pct}%</p>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
@@ -142,15 +124,20 @@ export function PhaseCard({
|
||||
<li key={task.id} className="flex items-start gap-3">
|
||||
<TaskStatusIcon status={task.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-xs leading-snug ${
|
||||
task.status === "done"
|
||||
? "line-through text-muted-foreground"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
{/* flex-wrap: su un titolo lungo la pill va a capo invece di
|
||||
schiacciare il testo */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<p
|
||||
className={`text-xs leading-snug ${
|
||||
task.status === "done"
|
||||
? "line-through text-muted-foreground"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
<TaskStatusPill status={task.status} />
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
||||
{task.description}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// ── Come lo stato di un task appare al cliente ───────────────────────────────
|
||||
// Le etichette stanno in src/lib/task-status.ts e sono condivise con l'admin.
|
||||
// Qui c'è solo il livello client-facing: forma, colore e la frase che spiega lo
|
||||
// stato a chi non lavora al progetto.
|
||||
//
|
||||
// Prima di questo file lo stato era un cerchietto senza testo: ambra col puntino
|
||||
// da 1.5px per "in corso", violetto col puntino da 2px per "in revisione". A 20px
|
||||
// sono lo stesso oggetto, e il design system chiede il contrario — colore + testo,
|
||||
// mai colore da solo. Ora le quattro forme si distinguono anche in bianco e nero:
|
||||
// vuoto → punto → spunta vuota → spunta piena. La spunta compare quando il lavoro
|
||||
// è finito e si riempie quando è confermato.
|
||||
|
||||
import { TASK_STATUSES, TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
|
||||
|
||||
type StatusVisual = {
|
||||
/** Classi del cerchio nella lista task. */
|
||||
ring: string;
|
||||
/** Classi della pill di testo. `null` = stato che non ne ha bisogno: "da fare"
|
||||
* è un cerchio vuoto e "fatto" è barrato, si leggono senza etichetta. Tenerlo
|
||||
* come dato della mappa evita che "quali stati hanno la pill" finisca in un if. */
|
||||
pill: string | null;
|
||||
/** Pallino per legenda e intestazioni del kanban. */
|
||||
dot: string;
|
||||
/** Colore dell'etichetta nelle intestazioni del kanban. */
|
||||
label: string;
|
||||
/** Tinta del contatore in cima alla colonna kanban. */
|
||||
count: string;
|
||||
/** Frase client-facing: legge lo screen reader, e sta nella legenda. */
|
||||
hint: string;
|
||||
};
|
||||
|
||||
// Colori di stato: l'eccezione sanzionata alla regola dei soli token semantici
|
||||
// (DESIGN-SYSTEM.md). Soft tint come le altre pill del portale, ogni valore con
|
||||
// la sua variante dark — che l'icona "in corso" non aveva.
|
||||
export const TASK_STATUS_VISUALS: Record<TaskStatus, StatusVisual> = {
|
||||
todo: {
|
||||
ring: "border-2 border-border bg-card",
|
||||
pill: null,
|
||||
dot: "bg-border",
|
||||
label: "text-muted-foreground",
|
||||
count: "bg-muted text-muted-foreground",
|
||||
hint: "Da fare — non ancora iniziato.",
|
||||
},
|
||||
in_progress: {
|
||||
ring: "border-2 border-amber-400 bg-card dark:border-amber-500",
|
||||
pill: "bg-amber-50 text-amber-700 border border-amber-100 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20",
|
||||
dot: "bg-amber-500 dark:bg-amber-400",
|
||||
label: "text-amber-700 dark:text-amber-400",
|
||||
count: "bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400",
|
||||
hint: "In corso — ci stiamo lavorando noi.",
|
||||
},
|
||||
in_review: {
|
||||
ring: "border-2 border-violet-400 bg-card dark:border-violet-500",
|
||||
pill: "bg-violet-50 text-violet-700 border border-violet-100 dark:bg-violet-500/10 dark:text-violet-400 dark:border-violet-500/20",
|
||||
dot: "bg-violet-500 dark:bg-violet-400",
|
||||
label: "text-violet-700 dark:text-violet-400",
|
||||
count: "bg-violet-50 text-violet-700 dark:bg-violet-500/10 dark:text-violet-400",
|
||||
hint: "In revisione — pronto, aspetta il tuo riscontro.",
|
||||
},
|
||||
done: {
|
||||
ring: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400",
|
||||
pill: null,
|
||||
dot: "bg-emerald-500 dark:bg-emerald-400",
|
||||
label: "text-emerald-700 dark:text-emerald-400",
|
||||
count: "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400",
|
||||
hint: "Fatto — completato.",
|
||||
},
|
||||
};
|
||||
|
||||
function CheckGlyph({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Il cerchio di stato accanto al titolo del task.
|
||||
*
|
||||
* L'icona è decorativa (`aria-hidden`) e lo stato viaggia in uno `sr-only`: così
|
||||
* lo screen reader lo annuncia su tutti e quattro gli stati, mentre a schermo solo
|
||||
* i due ambigui portano la pill.
|
||||
*/
|
||||
export function TaskStatusIcon({ status }: { status: TaskStatus }) {
|
||||
const v = TASK_STATUS_VISUALS[status];
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${v.ring}`}
|
||||
>
|
||||
{status === "done" && <CheckGlyph className="h-3 w-3" />}
|
||||
{status === "in_review" && (
|
||||
<CheckGlyph className="h-2.5 w-2.5 text-violet-600 dark:text-violet-400" />
|
||||
)}
|
||||
{status === "in_progress" && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500 dark:bg-amber-400" />
|
||||
)}
|
||||
</span>
|
||||
<span className="sr-only">{v.hint}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Etichetta di testo. Nulla per "da fare" e "fatto", che si leggono da soli. */
|
||||
export function TaskStatusPill({ status }: { status: TaskStatus }) {
|
||||
const v = TASK_STATUS_VISUALS[status];
|
||||
if (!v.pill) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`shrink-0 rounded px-1.5 py-0.5 text-[9px] font-bold uppercase leading-none tracking-wide ${v.pill}`}
|
||||
>
|
||||
{TASK_STATUS_LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legenda degli stati + la riga che spiega "in revisione".
|
||||
*
|
||||
* Le voci si generano da TASK_STATUSES, mai da una lista riscritta a mano: è la
|
||||
* disciplina introdotta insieme allo stato "in revisione", dopo che due liste
|
||||
* fisse avevano già fatto sparire task dal portale senza un errore.
|
||||
*/
|
||||
export function TaskStatusLegend() {
|
||||
return (
|
||||
<div className="mb-6 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<ul className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
{TASK_STATUSES.map((status) => (
|
||||
<li key={status} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${TASK_STATUS_VISUALS[status].dot}`}
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{TASK_STATUS_LABELS[status]}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">In revisione</span> — il lavoro è pronto e
|
||||
aspetta il tuo riscontro: scrivicelo dalla chat della fase. Ogni pagina include un giro di
|
||||
revisione.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ClientView } from "@/lib/client-view";
|
||||
import { ApproveButton } from "@/components/client/ApproveButton";
|
||||
import { TASK_STATUS_VISUALS } from "@/components/client/TaskStatusIndicator";
|
||||
import { TASK_STATUS_LABELS, TASK_STATUSES, type TaskStatus } from "@/lib/task-status";
|
||||
|
||||
type Task = ClientView["phases"][number]["tasks"][number] & {
|
||||
@@ -59,24 +60,34 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">{col.label}</span>
|
||||
<span className="text-[10px] font-bold tabular-nums bg-secondary text-secondary-foreground rounded-full px-2 py-0.5">
|
||||
{tasksByStatus[col.id].length}
|
||||
</span>
|
||||
{COLUMNS.map((col) => {
|
||||
// Stessa tinta dell'icona in Timeline: passando da una vista all'altra
|
||||
// "in corso" e "in revisione" restano riconoscibili senza rileggere.
|
||||
const v = TASK_STATUS_VISUALS[col.id];
|
||||
return (
|
||||
<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 gap-2 border-b border-border-light pb-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span aria-hidden="true" className={`h-1.5 w-1.5 shrink-0 rounded-full ${v.dot}`} />
|
||||
<span className={`truncate text-xs font-bold uppercase tracking-wider ${v.label}`}>
|
||||
{col.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`shrink-0 text-[10px] font-bold tabular-nums rounded-full px-2 py-0.5 ${v.count}`}>
|
||||
{tasksByStatus[col.id].length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-3">
|
||||
{tasksByStatus[col.id].map((task) => (
|
||||
<TaskCard key={task.id} task={task} token={token} />
|
||||
))}
|
||||
{tasksByStatus[col.id].length === 0 && (
|
||||
<p className="text-xs text-muted-foreground italic text-center py-10">Nessun task</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-3">
|
||||
{tasksByStatus[col.id].map((task) => (
|
||||
<TaskCard key={task.id} task={task} token={token} />
|
||||
))}
|
||||
{tasksByStatus[col.id].length === 0 && (
|
||||
<p className="text-xs text-muted-foreground italic text-center py-10">Nessun task</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ClientKanban } from "./ClientKanban";
|
||||
import { TaskStatusLegend } from "@/components/client/TaskStatusIndicator";
|
||||
import type { ClientView } from "@/lib/client-view";
|
||||
|
||||
export function PhaseViewToggle({
|
||||
@@ -39,7 +40,10 @@ export function PhaseViewToggle({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fuori dallo switch: gli stati sono gli stessi in entrambe le viste */}
|
||||
<TaskStatusLegend />
|
||||
|
||||
{view === "timeline" ? timelineView : <ClientKanban phases={phases} token={token} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user