5547e555bd
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>
192 lines
7.5 KiB
TypeScript
192 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
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];
|
|
|
|
const phaseStatusLabel: Record<"upcoming" | "active" | "done", string> = {
|
|
upcoming: "Da iniziare",
|
|
active: "In corso",
|
|
done: "Completata",
|
|
};
|
|
|
|
// Soft-tint status pill — replica del mock ("Quiet Luxury")
|
|
const phaseStatusStyle: Record<"upcoming" | "active" | "done", string> = {
|
|
upcoming: "bg-muted text-muted-foreground border border-border",
|
|
active:
|
|
"bg-amber-50 text-amber-700 border border-amber-100 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20",
|
|
done: "bg-emerald-50 text-emerald-700 border border-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20",
|
|
};
|
|
|
|
// Colore della barra di avanzamento per stato fase
|
|
const phaseBarColor: Record<"upcoming" | "active" | "done", string> = {
|
|
upcoming: "bg-border",
|
|
active: "bg-amber-500",
|
|
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,
|
|
defaultOpen,
|
|
}: {
|
|
phase: Phase;
|
|
token: string;
|
|
defaultOpen: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
const { openChat } = useChatContext();
|
|
const doneCount = phase.tasks.filter((t) => t.status === "done").length;
|
|
|
|
return (
|
|
<div className="rounded-xl border border-border-light bg-card p-6 shadow-card">
|
|
{/* Header — always visible, click to toggle */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="w-full text-left"
|
|
aria-expanded={open}
|
|
>
|
|
<div className="flex items-start justify-between gap-3 mb-3">
|
|
<h3 className="text-sm font-bold text-foreground leading-snug min-w-0">{phase.title}</h3>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span
|
|
className={`rounded px-2.5 py-0.5 text-[10px] font-bold uppercase ${phaseStatusStyle[phase.status]}`}
|
|
>
|
|
{phaseStatusLabel[phase.status]}
|
|
</span>
|
|
{/* Chat bubble — opens panel pre-tagged with this phase */}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation(); // don't toggle accordion
|
|
openChat(phase.id);
|
|
}}
|
|
aria-label="Apri chat per questa fase"
|
|
className="p-1 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/5 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={1.8} viewBox="0 0 24 24" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.83L3 20l1.09-3.27C3.39 15.56 3 13.83 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
</svg>
|
|
</button>
|
|
<svg
|
|
className={`w-4 h-4 text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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>
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${phaseBarColor[phase.status]}`}
|
|
style={{ width: `${phase.progress_pct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Collapsible task list */}
|
|
{open && (
|
|
<div className="mt-4 border-t border-border-light pt-4">
|
|
{phase.tasks.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground italic">Nessun task ancora configurato.</p>
|
|
) : (
|
|
<ul className="space-y-3">
|
|
{phase.tasks.map((task) => (
|
|
<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>
|
|
{task.description && (
|
|
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
|
{task.description}
|
|
</p>
|
|
)}
|
|
{task.deliverables.length > 0 && (
|
|
<ul className="mt-1.5 space-y-2">
|
|
{task.deliverables.map((d) => (
|
|
<li
|
|
key={d.id}
|
|
className="bg-muted rounded-lg px-3 py-2 flex items-center justify-between gap-2"
|
|
>
|
|
<span className="text-xs text-muted-foreground truncate font-medium">
|
|
{d.title}
|
|
</span>
|
|
{(d.status === "pending" ||
|
|
d.status === "submitted" ||
|
|
d.approved_at !== null) && (
|
|
<ApproveButton
|
|
deliverableId={d.id}
|
|
token={token}
|
|
approvedAt={d.approved_at}
|
|
/>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|