feat(portale): task cancellati e le date dei pagamenti

Due cose che il cliente non poteva sapere guardando il portale.

**Task cancellati.** Fino a ieri un'attività tolta dal lavoro poteva solo
sparire (cancellata dal DB) o restare lì a far finta di essere ancora da
fare. Ora `cancelled` è il quinto stato: X dentro il cerchio, titolo barrato,
pill "Cancellata" — l'unico stato chiuso che la porta, perché "fatto" e
"cancellato" sono entrambi barrati e confonderli significa credere consegnato
qualcosa che non esiste.

Esce da tutti i denominatori — fase, progetto, board di consegna, riepilogo
admin — con un unico `countsTowardProgress()` in task-status.ts invece di
cinque `!== "cancelled"` sparsi. Contarlo terrebbe la fase sotto il 100% per
un lavoro che nessuno farà; contarlo come fatto racconterebbe una consegna
mai avvenuta. `recomputePhaseStatus` lo ignora allo stesso modo: senza questo,
cancellare l'ultima voce lasciava la fase "in corso" per sempre.

Nel kanban cliente la colonna compare solo se ha dentro qualcosa — le quattro
che raccontano il lavoro si tengono la larghezza — ma mai se è piena, quindi
nessun task sparisce dalla board. Nell'admin la colonna c'è sempre: è così che
si cancella un task, trascinandocelo.

**Date dei pagamenti** (migration 0023, già applicata in produzione).
`payments` sapeva solo quando una rata era stata incassata, mai quando era
attesa: il portale non poteva rispondere a "quando devo pagare?" e non c'era
niente su cui agganciare il promemoria email. Ora c'è `due_date`, nullable —
una rata senza data concordata è normale, e il portale la mostra solo se c'è.

Il cliente vede in cima al box la prossima scadenza col conto alla rovescia
("tra 12 giorni", "domani", "scaduto da 3 giorni" in rosso), e su ogni riga
la data: "Scade il…" se aperta, "Pagato il…" se saldata. Nessun importo per
riga — LOCKED #2 resta dov'era, le date non sono cifre.

I giorni si contano in `src/lib/payment-dates.ts`, sui giorni civili a Roma e
non sugli istanti: il container gira a UTC e "manca una settimana" non deve
cambiare risposta a seconda del fuso. È lo stesso modulo che userà il
promemoria email, così la mail e il portale non si contraddicono.

Lato admin ogni rata ha il campo Scadenza, e "Incassato nel mese" diventa
"Incassato il" — precisione al giorno, che le analytics (raggruppate per mese)
non notano. Il warning sul cambio schema ora conta anche le scadenze: una rata
"da saldare" con una data è già sotto gli occhi del cliente, e sparirebbe in
silenzio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 14:49:11 +02:00
parent 15b01e3e05
commit fe767899b9
15 changed files with 450 additions and 62 deletions
+58 -14
View File
@@ -23,7 +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"; import { countsTowardProgress, 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.
@@ -187,16 +187,24 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
// that count as started: as a list it silently forgot new statuses, and a phase // 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 // whose tasks were all in review fell back to "upcoming" — reading as
// not-started when it was nearly finished. // not-started when it was nearly finished.
//
// I task cancellati sono invisibili a questo calcolo: non tengono la fase aperta
// (senza questo, cancellare l'ultima voce la lascerebbe "in corso" per sempre) e
// da soli non la fanno partire. Se restano SOLO cancellati la fase torna
// "Da iniziare": è degenere, ma è meglio di "Completata", che al cliente
// racconterebbe una consegna che non c'è stata.
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 })
.from(tasks) .from(tasks)
.where(eq(tasks.phase_id, phaseId)); .where(eq(tasks.phase_id, phaseId));
const countedTasks = phaseTasks.filter((t) => countsTowardProgress(t.status));
let newStatus: "upcoming" | "active" | "done" = "upcoming"; let newStatus: "upcoming" | "active" | "done" = "upcoming";
if (phaseTasks.length > 0) { if (countedTasks.length > 0) {
const allDone = phaseTasks.every((t) => t.status === "done"); const allDone = countedTasks.every((t) => t.status === "done");
const anyActive = phaseTasks.some((t) => t.status !== "todo"); const anyActive = countedTasks.some((t) => t.status !== "todo");
if (allDone) newStatus = "done"; if (allDone) newStatus = "done";
else if (anyActive) newStatus = "active"; else if (anyActive) newStatus = "active";
} }
@@ -326,17 +334,42 @@ export async function updatePaymentStatus(paymentId: string, id: string, status:
revalidatePath(path); revalidatePath(path);
} }
// Imposta il mese in cui un pagamento è stato incassato (formato "YYYY-MM"). // Parsing di una data inserita dall'admin, in due formati:
// Mappa al primo giorno del mese (mezzogiorno UTC per evitare drift di fuso) e // "YYYY-MM-DD" → quel giorno (quello che scrivono i campi di oggi)
// porta lo stato a "saldato" così l'incasso viene attribuito a quel mese nelle analytics. // "YYYY-MM" → primo del mese (formato storico di paid_at, campo <input type="month">)
export async function setPaymentPaidAt(paymentId: string, id: string, monthStr: string) { //
// Mezzogiorno UTC, non mezzanotte: a mezzanotte UTC il giorno civile a Roma è già
// quello dopo per due ore in estate, e la data tornerebbe indietro di un giorno
// una volta riletta. A mezzogiorno nessun fuso reale sposta il giorno.
function parseAdminDate(value: string): Date {
const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (day) {
const [, y, m, d] = day;
const year = parseInt(y, 10);
const month = parseInt(m, 10);
const date = parseInt(d, 10);
if (month < 1 || month > 12 || date < 1 || date > 31) throw new Error("Data non valida");
return new Date(Date.UTC(year, month - 1, date, 12, 0, 0));
}
const month = /^(\d{4})-(\d{2})$/.exec(value);
if (month) {
const [, y, m] = month;
const monthNum = parseInt(m, 10);
if (monthNum < 1 || monthNum > 12) throw new Error("Data non valida");
return new Date(Date.UTC(parseInt(y, 10), monthNum - 1, 1, 12, 0, 0));
}
throw new Error("Data non valida");
}
// Imposta il giorno in cui un pagamento è stato incassato e porta lo stato a
// "saldato", così l'incasso viene attribuito a quel mese nelle analytics (che
// raggruppano con `extract(month from paid_at)`: la precisione al giorno non le
// tocca). Accetta anche il vecchio "YYYY-MM" delle righe storiche.
export async function setPaymentPaidAt(paymentId: string, id: string, dateStr: string) {
await requireAdmin(); await requireAdmin();
const m = /^(\d{4})-(\d{2})$/.exec(monthStr); const paid_at = parseAdminDate(dateStr);
if (!m) throw new Error("Mese non valido");
const year = parseInt(m[1], 10);
const month = parseInt(m[2], 10);
if (month < 1 || month > 12) throw new Error("Mese non valido");
const paid_at = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0));
await db await db
.update(payments) .update(payments)
.set({ paid_at, status: "saldato" }) .set({ paid_at, status: "saldato" })
@@ -345,6 +378,17 @@ export async function setPaymentPaidAt(paymentId: string, id: string, monthStr:
revalidatePath(path); revalidatePath(path);
} }
// Scadenza concordata della rata. Stringa vuota = la scadenza si toglie: era
// stata messa e non vale più, e cancellarla deve essere possibile senza dover
// cancellare la rata.
export async function setPaymentDueDate(paymentId: string, id: string, dateStr: string) {
await requireAdmin();
const due_date = dateStr.trim() === "" ? null : parseAdminDate(dateStr);
await db.update(payments).set({ due_date }).where(eq(payments.id, paymentId));
const { path } = await resolveEntity(id);
revalidatePath(path);
}
// Rescales payment amounts when the total changes. // Rescales payment amounts when the total changes.
// //
// The rule is PER ROW, not per project. The previous version decided with // The rule is PER ROW, not per project. The previous version decided with
+2
View File
@@ -58,6 +58,8 @@ function projectViewToClientView(
id: p.id, id: p.id,
label: p.label, label: p.label,
status: p.status as "da_saldare" | "inviata" | "saldato", status: p.status as "da_saldare" | "inviata" | "saldato",
due_date: p.due_date instanceof Date ? p.due_date.toISOString() : null,
paid_at: p.paid_at instanceof Date ? p.paid_at.toISOString() : null,
})), })),
documents: view.documents.map((d) => ({ documents: view.documents.map((d) => ({
id: d.id, id: d.id,
+4 -1
View File
@@ -1,5 +1,6 @@
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard"; import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
import type { ProjectFullDetail } from "@/lib/admin-queries"; import type { ProjectFullDetail } from "@/lib/admin-queries";
import { countsTowardProgress } from "@/lib/task-status";
type Props = { type Props = {
acceptedTotal: string; acceptedTotal: string;
@@ -38,7 +39,9 @@ export function ProjectSummary({
const collectedPct = contracted > 0 ? Math.round((collected / contracted) * 100) : 0; const collectedPct = contracted > 0 ? Math.round((collected / contracted) * 100) : 0;
const allTasks = phases.flatMap((p) => p.tasks); // Le cancellate escono dal denominatore, come nel portale cliente: le due
// percentuali devono raccontare la stessa cosa.
const allTasks = phases.flatMap((p) => p.tasks).filter((t) => countsTowardProgress(t.status));
const doneTasks = allTasks.filter((t) => t.status === "done").length; const doneTasks = allTasks.filter((t) => t.status === "done").length;
const progressPct = const progressPct =
allTasks.length > 0 ? Math.round((doneTasks / allTasks.length) * 100) : 0; allTasks.length > 0 ? Math.round((doneTasks / allTasks.length) * 100) : 0;
+12 -2
View File
@@ -38,6 +38,7 @@ const COLUMN_STYLES: Record<Status, { headerClass: string; dotClass: string }> =
dotClass: "bg-violet-500", dotClass: "bg-violet-500",
}, },
done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" }, done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" },
cancelled: { headerClass: "text-[#a1a1aa]", dotClass: "bg-[#a1a1aa]" },
}; };
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] = const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] =
@@ -92,6 +93,7 @@ function DroppableColumn({
key={task.id} key={task.id}
task={task} task={task}
isActive={activeId === task.id} isActive={activeId === task.id}
status={id}
/> />
))} ))}
{tasks.length === 0 && ( {tasks.length === 0 && (
@@ -107,9 +109,11 @@ function DroppableColumn({
function DraggableCard({ function DraggableCard({
task, task,
isActive, isActive,
status,
}: { }: {
task: Task; task: Task;
isActive: boolean; isActive: boolean;
status: Status;
}) { }) {
const { attributes, listeners, setNodeRef, transform, isDragging } = const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({ id: task.id }); useDraggable({ id: task.id });
@@ -131,7 +135,13 @@ function DraggableCard({
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate"> <p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
{task.phaseTitle} {task.phaseTitle}
</p> </p>
<p className="text-sm font-medium text-[#1a1a1a] leading-snug">{task.title}</p> <p
className={`text-sm font-medium leading-snug ${
status === "cancelled" ? "text-[#a1a1aa] line-through" : "text-[#1a1a1a]"
}`}
>
{task.title}
</p>
{task.description && ( {task.description && (
<p className="text-xs text-[#71717a] mt-1 leading-snug line-clamp-2"> <p className="text-xs text-[#71717a] mt-1 leading-snug line-clamp-2">
{task.description} {task.description}
@@ -211,7 +221,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-2 gap-4 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-4 xl:grid-cols-5">
{COLUMNS.map((col) => ( {COLUMNS.map((col) => (
<DroppableColumn <DroppableColumn
key={col.id} key={col.id}
+59 -20
View File
@@ -9,6 +9,7 @@ import {
setPaymentPaidAt, setPaymentPaidAt,
updatePaymentField, updatePaymentField,
clearPaymentOverride, clearPaymentOverride,
setPaymentDueDate,
} from "@/app/admin/clients/[id]/actions"; } from "@/app/admin/clients/[id]/actions";
import { setPaymentPlan } from "@/app/admin/projects/project-actions"; import { setPaymentPlan } from "@/app/admin/projects/project-actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -36,14 +37,19 @@ function formatEuro(value: number): string {
return value.toLocaleString("it-IT", { minimumFractionDigits: 2 }); return value.toLocaleString("it-IT", { minimumFractionDigits: 2 });
} }
// paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per <input type="month"> // Date | string | null (serializzato sul confine RSC) → "YYYY-MM-DD" per <input type="date">.
function toMonthValue(paidAt: Date | string | null | undefined): string { // Le date sono salvate a mezzogiorno UTC apposta, quindi i getter locali leggono
if (!paidAt) { // il giorno giusto in qualunque fuso senza scivolare di uno.
const now = new Date(); function toDateValue(value: Date | string | null | undefined): string {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; if (!value) return "";
} const d = value instanceof Date ? value : new Date(value);
const d = paidAt instanceof Date ? paidAt : new Date(paidAt); if (Number.isNaN(d.getTime())) return "";
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function todayValue(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
} }
type PlanMode = "single" | "two" | "three"; type PlanMode = "single" | "two" | "three";
@@ -118,16 +124,19 @@ export function PaymentsTab({
async function handleSetPlan(mode: PlanMode) { async function handleSetPlan(mode: PlanMode) {
if (!projectId) return; if (!projectId) return;
// Picking a plan deletes every existing row — including status and paid_at. // Picking a plan deletes every existing row — including status, paid_at and
// Only worth interrupting when there is actually payment history to lose. // due_date. Only worth interrupting when there is actually something to lose.
// La scadenza conta quanto lo stato: una rata "da saldare" con una data
// concordata è informazione che il cliente sta già leggendo nel portale, e
// sparirebbe senza che nessuno se ne accorga.
const tracked = payments.filter( const tracked = payments.filter(
(p) => p.status === "saldato" || p.status === "inviata" (p) => p.status === "saldato" || p.status === "inviata" || p.due_date !== null
).length; ).length;
if (tracked > 0) { if (tracked > 0) {
const what = const what =
tracked === 1 ? "1 rata già segnata" : `${tracked} rate già segnate`; tracked === 1 ? "1 rata con dati inseriti" : `${tracked} rate con dati inseriti`;
const ok = window.confirm( const ok = window.confirm(
`Cambiare schema cancella tutte le rate di questo progetto.\n\nCi sono ${what} come inviate o saldate: lo stato e il mese di incasso andranno persi.\n\nProcedere?` `Cambiare schema cancella tutte le rate di questo progetto.\n\nCi sono ${what} (stato, scadenza o data di incasso): andranno persi.\n\nProcedere?`
); );
if (!ok) return; if (!ok) return;
} }
@@ -155,11 +164,22 @@ export function PaymentsTab({
} }
} }
async function handlePaidMonthUpdate(paymentId: string, monthStr: string) { async function handlePaidDateUpdate(paymentId: string, dateStr: string) {
if (!monthStr) return; if (!dateStr) return;
setStatusLoading(paymentId); setStatusLoading(paymentId);
try { try {
await setPaymentPaidAt(paymentId, clientId, monthStr); await setPaymentPaidAt(paymentId, clientId, dateStr);
router.refresh();
} finally {
setStatusLoading(null);
}
}
// La scadenza si può anche togliere: la stringa vuota è un valore, non un no-op.
async function handleDueDateUpdate(paymentId: string, dateStr: string) {
setStatusLoading(paymentId);
try {
await setPaymentDueDate(paymentId, clientId, dateStr);
router.refresh(); router.refresh();
} finally { } finally {
setStatusLoading(null); setStatusLoading(null);
@@ -339,17 +359,36 @@ export function PaymentsTab({
</select> </select>
{statusLoading === p.id && <span className="text-xs text-[#71717a]">...</span>} {statusLoading === p.id && <span className="text-xs text-[#71717a]">...</span>}
</div> </div>
{/* Scadenza: è questa che il cliente vede nel portale, col conto alla
rovescia, ed è l'aggancio del futuro promemoria via email.
Sempre modificabile, anche a rata saldata: resta lo storico. */}
<div className="flex items-center gap-2 mt-2">
<Label htmlFor={`due-${p.id}`} className="text-xs text-[#71717a] shrink-0">
Scadenza
</Label>
<input
id={`due-${p.id}`}
type="date"
defaultValue={toDateValue(p.due_date)}
disabled={statusLoading === p.id}
onChange={(e) => handleDueDateUpdate(p.id, e.target.value)}
className="text-sm border border-gray-200 rounded px-2 py-1 bg-white"
/>
{!p.due_date && (
<span className="text-xs text-[#a1a1aa]">non mostrata al cliente</span>
)}
</div>
{p.status === "saldato" && ( {p.status === "saldato" && (
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Label htmlFor={`paid-${p.id}`} className="text-xs text-[#71717a] shrink-0"> <Label htmlFor={`paid-${p.id}`} className="text-xs text-[#71717a] shrink-0">
Incassato nel mese Incassato il
</Label> </Label>
<input <input
id={`paid-${p.id}`} id={`paid-${p.id}`}
type="month" type="date"
defaultValue={toMonthValue(p.paid_at)} defaultValue={toDateValue(p.paid_at) || todayValue()}
disabled={statusLoading === p.id} disabled={statusLoading === p.id}
onChange={(e) => handlePaidMonthUpdate(p.id, e.target.value)} onChange={(e) => handlePaidDateUpdate(p.id, e.target.value)}
className="text-sm border border-gray-200 rounded px-2 py-1 bg-white" className="text-sm border border-gray-200 rounded px-2 py-1 bg-white"
/> />
</div> </div>
+22 -4
View File
@@ -3,7 +3,12 @@
import { useState } from "react"; 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 { TaskStatusIcon, TaskStatusPill } from "@/components/client/TaskStatusIndicator"; import {
isClosedTaskStatus,
TaskStatusIcon,
TaskStatusPill,
} from "@/components/client/TaskStatusIndicator";
import { countsTowardProgress } from "@/lib/task-status";
import type { ClientView } from "@/lib/client-view"; import type { ClientView } from "@/lib/client-view";
type Phase = ClientView["phases"][number]; type Phase = ClientView["phases"][number];
@@ -41,6 +46,10 @@ export function PhaseCard({
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const { openChat } = useChatContext(); const { openChat } = useChatContext();
const doneCount = phase.tasks.filter((t) => t.status === "done").length; const doneCount = phase.tasks.filter((t) => t.status === "done").length;
// Il denominatore esclude le cancellate: "3 di 4" con una quarta voce annullata
// resterebbe fermo lì per sempre, e la barra non toccherebbe mai il 100%.
const countedTotal = phase.tasks.filter((t) => countsTowardProgress(t.status)).length;
const cancelledCount = phase.tasks.length - countedTotal;
// I task in revisione aspettano il cliente, non noi. Il conteggio sta nell'header, // 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 // 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. // dentro una fase collassata e il cliente non sa che tocca a lui.
@@ -94,13 +103,19 @@ export function PhaseCard({
<div className="mb-1"> <div className="mb-1">
<div className="flex justify-between items-center gap-3 mb-1.5"> <div className="flex justify-between items-center gap-3 mb-1.5">
<p className="text-xs text-muted-foreground font-medium min-w-0"> <p className="text-xs text-muted-foreground font-medium min-w-0">
{doneCount} di {phase.tasks.length} task {doneCount} di {countedTotal} task
{reviewCount > 0 && ( {reviewCount > 0 && (
<span className="text-violet-600 dark:text-violet-400"> <span className="text-violet-600 dark:text-violet-400">
{" · "} {" · "}
{reviewCount} in attesa di riscontro {reviewCount} in attesa di riscontro
</span> </span>
)} )}
{cancelledCount > 0 && (
<span>
{" · "}
{cancelledCount} {cancelledCount === 1 ? "cancellata" : "cancellate"}
</span>
)}
</p> </p>
<p className="text-xs font-semibold text-foreground shrink-0">{phase.progress_pct}%</p> <p className="text-xs font-semibold text-foreground shrink-0">{phase.progress_pct}%</p>
</div> </div>
@@ -121,7 +136,10 @@ export function PhaseCard({
) : ( ) : (
<ul className="space-y-3"> <ul className="space-y-3">
{phase.tasks.map((task) => ( {phase.tasks.map((task) => (
<li key={task.id} className="flex items-start gap-3"> <li
key={task.id}
className={`flex items-start gap-3 ${task.status === "cancelled" ? "opacity-70" : ""}`}
>
<TaskStatusIcon status={task.status} /> <TaskStatusIcon status={task.status} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* flex-wrap: su un titolo lungo la pill va a capo invece di {/* flex-wrap: su un titolo lungo la pill va a capo invece di
@@ -129,7 +147,7 @@ export function PhaseCard({
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<p <p
className={`text-xs leading-snug ${ className={`text-xs leading-snug ${
task.status === "done" isClosedTaskStatus(task.status)
? "line-through text-muted-foreground" ? "line-through text-muted-foreground"
: "text-foreground" : "text-foreground"
}`} }`}
+38 -5
View File
@@ -8,7 +8,8 @@
// sono lo stesso oggetto, e il design system chiede il contrario — colore + testo, // 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: // 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 // vuoto → punto → spunta vuota → spunta piena. La spunta compare quando il lavoro
// è finito e si riempie quando è confermato. // è finito e si riempie quando è confermato. La cancellata è l'unica che non è
// un passo avanti: X e testo barrato, tinta spenta, fuori da ogni conteggio.
import { TASK_STATUSES, TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status"; import { TASK_STATUSES, TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
@@ -65,8 +66,24 @@ export const TASK_STATUS_VISUALS: Record<TaskStatus, StatusVisual> = {
count: "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400", count: "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400",
hint: "Fatto — completato.", hint: "Fatto — completato.",
}, },
cancelled: {
ring: "border-2 border-border bg-muted text-muted-foreground",
// Unica pill fra gli stati chiusi: "fatto" e "cancellata" sono entrambi
// barrati, e la differenza fra i due — consegnato o tolto dal lavoro — è
// troppo importante per affidarla al solo disegno dentro il cerchio.
pill: "bg-muted text-muted-foreground border border-border",
dot: "bg-muted-foreground/40",
label: "text-muted-foreground",
count: "bg-muted text-muted-foreground",
hint: "Cancellata — non verrà realizzata.",
},
}; };
/** Stati chiusi: il titolo si legge barrato, il lavoro non è più in corso. */
export function isClosedTaskStatus(status: TaskStatus): boolean {
return status === "done" || status === "cancelled";
}
function CheckGlyph({ className }: { className: string }) { function CheckGlyph({ className }: { className: string }) {
return ( return (
<svg <svg
@@ -82,12 +99,27 @@ function CheckGlyph({ className }: { className: string }) {
); );
} }
function CrossGlyph({ 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="M6 6l12 12M18 6L6 18" />
</svg>
);
}
/** /**
* Il cerchio di stato accanto al titolo del task. * Il cerchio di stato accanto al titolo del task.
* *
* L'icona è decorativa (`aria-hidden`) e lo stato viaggia in uno `sr-only`: così * 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 * lo screen reader lo annuncia su tutti gli stati, mentre a schermo solo quelli
* i due ambigui portano la pill. * ambigui portano la pill.
*/ */
export function TaskStatusIcon({ status }: { status: TaskStatus }) { export function TaskStatusIcon({ status }: { status: TaskStatus }) {
const v = TASK_STATUS_VISUALS[status]; const v = TASK_STATUS_VISUALS[status];
@@ -99,6 +131,7 @@ export function TaskStatusIcon({ status }: { status: TaskStatus }) {
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${v.ring}`} 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 === "done" && <CheckGlyph className="h-3 w-3" />}
{status === "cancelled" && <CrossGlyph className="h-2.5 w-2.5" />}
{status === "in_review" && ( {status === "in_review" && (
<CheckGlyph className="h-2.5 w-2.5 text-violet-600 dark:text-violet-400" /> <CheckGlyph className="h-2.5 w-2.5 text-violet-600 dark:text-violet-400" />
)} )}
@@ -115,8 +148,8 @@ export function TaskStatusIcon({ status }: { status: TaskStatus }) {
* Etichetta di testo. Nulla per "da fare" e "fatto", che si leggono da soli. * Etichetta di testo. Nulla per "da fare" e "fatto", che si leggono da soli.
* *
* `aria-hidden` perché è ridondanza *visiva*: allo screen reader lo stato lo dice già * `aria-hidden` perché è ridondanza *visiva*: allo screen reader lo stato lo dice già
* lo `sr-only` dell'icona, su tutti e quattro gli stati. Senza questo, i due che hanno * lo `sr-only` dell'icona, su tutti gli stati. Senza questo, quelli che hanno la pill
* la pill verrebbero annunciati due volte. * verrebbero annunciati due volte.
*/ */
export function TaskStatusPill({ status }: { status: TaskStatus }) { export function TaskStatusPill({ status }: { status: TaskStatus }) {
const v = TASK_STATUS_VISUALS[status]; const v = TASK_STATUS_VISUALS[status];
+21 -5
View File
@@ -2,7 +2,7 @@
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_VISUALS } from "@/components/client/TaskStatusIndicator"; import { isClosedTaskStatus, TASK_STATUS_VISUALS } from "@/components/client/TaskStatusIndicator";
import { TASK_STATUS_LABELS, TASK_STATUSES, type TaskStatus } from "@/lib/task-status"; 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] & {
@@ -16,11 +16,15 @@ const COLUMNS: { id: TaskStatus; label: string }[] = TASK_STATUSES.map((id) => (
function TaskCard({ task, token }: { task: Task; token: string }) { function TaskCard({ task, token }: { task: Task; token: string }) {
return ( return (
<div className="bg-card rounded-lg border border-border-light px-4 py-4 shadow-sm hover:border-primary/30 hover:shadow-card-hover transition-all"> <div
className={`bg-card rounded-lg border border-border-light px-4 py-4 shadow-sm hover:border-primary/30 hover:shadow-card-hover transition-all ${
task.status === "cancelled" ? "opacity-70" : ""
}`}
>
<p className="text-[9px] font-semibold text-muted-foreground uppercase tracking-wide mb-1 truncate"> <p className="text-[9px] font-semibold text-muted-foreground uppercase tracking-wide mb-1 truncate">
{task.phaseTitle} {task.phaseTitle}
</p> </p>
<p className={`text-xs font-medium leading-snug ${task.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}> <p className={`text-xs font-medium leading-snug ${isClosedTaskStatus(task.status) ? "line-through text-muted-foreground" : "text-foreground"}`}>
{task.title} {task.title}
</p> </p>
{task.description && ( {task.description && (
@@ -58,9 +62,21 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
{} as Record<TaskStatus, Task[]> {} as Record<TaskStatus, Task[]>
); );
// "Cancellate" è l'unica colonna che si nasconde da vuota: nella maggior parte
// dei progetti non ce n'è nessuna, e una quinta colonna vuota ruberebbe spazio
// alle quattro che raccontano il lavoro. Si nasconde solo se è vuota, quindi
// nessun task sparisce mai dalla board — era proprio quello il bug di prima.
const visibleColumns = COLUMNS.filter(
(col) => col.id !== "cancelled" || tasksByStatus.cancelled.length > 0
);
return ( return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4"> <div
{COLUMNS.map((col) => { className={`grid grid-cols-1 gap-6 sm:grid-cols-2 ${
visibleColumns.length > 4 ? "xl:grid-cols-5" : "xl:grid-cols-4"
}`}
>
{visibleColumns.map((col) => {
// Stessa tinta dell'icona in Timeline: passando da una vista all'altra // Stessa tinta dell'icona in Timeline: passando da una vista all'altra
// "in corso" e "in revisione" restano riconoscibili senza rileggere. // "in corso" e "in revisione" restano riconoscibili senza rileggere.
const v = TASK_STATUS_VISUALS[col.id]; const v = TASK_STATUS_VISUALS[col.id];
+77 -5
View File
@@ -1,4 +1,11 @@
import type { ClientView } from '@/lib/client-view'; import type { ClientView } from '@/lib/client-view';
import {
daysUntil,
findNextDue,
formatCountdown,
formatLongDate,
formatShortDate,
} from '@/lib/payment-dates';
interface PaymentStatusProps { interface PaymentStatusProps {
accepted_total: string; accepted_total: string;
@@ -49,6 +56,16 @@ export function PaymentStatus({
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
// Il conto alla rovescia si calcola qui, sul server: la pagina è dinamica
// (`revalidate = 0`), quindi il numero è fresco a ogni apertura del portale.
//
// Il riquadro compare anche quando le righe sono nascoste (retainer): non
// contiene importi, e una scadenza esiste solo se l'admin l'ha scritta — se
// l'ha scritta, è perché il cliente la deve sapere.
const nextDue = findNextDue(payments);
const nextDueDays = nextDue?.due_date ? daysUntil(nextDue.due_date) : null;
const isLate = nextDueDays !== null && nextDueDays < 0;
return ( return (
<div className="rounded-xl border border-border-light bg-card shadow-card overflow-hidden"> <div className="rounded-xl border border-border-light bg-card shadow-card overflow-hidden">
{/* Totale — unico importo visibile al cliente (LOCKED) */} {/* Totale — unico importo visibile al cliente (LOCKED) */}
@@ -61,7 +78,34 @@ export function PaymentStatus({
</p> </p>
</div> </div>
{/* Righe pagamento: solo etichetta + stato, MAI importo singolo — omesse se hideRows */} {/* Prossima scadenza — la domanda più ovvia del cliente, in cima e senza cifre */}
{nextDue && nextDue.due_date && nextDueDays !== null && (
<div
className={`px-5 py-4 border-b border-border-light ${
isLate
? 'bg-red-50 dark:bg-red-500/10'
: 'bg-amber-50/60 dark:bg-amber-500/10'
}`}
>
<p
className={`text-[10px] font-bold uppercase tracking-wider mb-1 ${
isLate
? 'text-red-700 dark:text-red-400'
: 'text-amber-700 dark:text-amber-400'
}`}
>
{isLate ? 'Pagamento scaduto' : 'Prossimo pagamento'}
</p>
<p className="text-sm font-bold text-foreground leading-snug">
{formatLongDate(nextDue.due_date)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{nextDue.label} · {formatCountdown(nextDueDays)}
</p>
</div>
)}
{/* Righe pagamento: solo etichetta, stato e date — MAI importo singolo */}
{!hideRows && ( {!hideRows && (
<div className="px-5 py-4 space-y-2"> <div className="px-5 py-4 space-y-2">
{payments.length === 0 ? ( {payments.length === 0 ? (
@@ -73,14 +117,42 @@ export function PaymentStatus({
const status = payment.status as PaymentStatusValue; const status = payment.status as PaymentStatusValue;
const config = statusConfig[status] ?? statusConfig.da_saldare; const config = statusConfig[status] ?? statusConfig.da_saldare;
// Una riga saldata mostra quando è stata incassata; una ancora
// aperta mostra quando scade. Nessuna delle due inventa la data:
// se in gestionale non c'è, sotto l'etichetta non compare nulla.
const paidOn =
status === 'saldato' && payment.paid_at
? `Pagato il ${formatShortDate(payment.paid_at)}`
: null;
const dueOn =
status !== 'saldato' && payment.due_date
? `Scade il ${formatShortDate(payment.due_date)} · ${formatCountdown(
daysUntil(payment.due_date)
)}`
: null;
const overdue = dueOn !== null && daysUntil(payment.due_date!) < 0;
return ( return (
<div <div
key={payment.id} key={payment.id}
className="flex items-center justify-between gap-4 rounded-lg border border-border-light p-3" className="flex items-start justify-between gap-4 rounded-lg border border-border-light p-3"
> >
<p className="text-xs font-semibold text-foreground"> <div className="min-w-0">
{payment.label} <p className="text-xs font-semibold text-foreground">
</p> {payment.label}
</p>
{(paidOn || dueOn) && (
<p
className={`text-[11px] mt-0.5 leading-snug ${
overdue
? 'text-red-600 dark:text-red-400'
: 'text-muted-foreground'
}`}
>
{paidOn ?? dueOn}
</p>
)}
</div>
{/* Pill stato — nessun importo */} {/* Pill stato — nessun importo */}
<span <span
@@ -0,0 +1,19 @@
-- Additive: la data in cui una rata è attesa.
--
-- Perché: `payments` sapeva solo QUANDO una rata è stata incassata (paid_at) e mai
-- quando ci si aspetta che lo sia. Il portale non poteva quindi rispondere alla
-- domanda più ovvia del cliente — "quando devo pagare?" — e non c'era nessun campo
-- su cui agganciare il promemoria via email prima della scadenza.
--
-- Nullable senza default: una rata senza data concordata è normale, e un default
-- (per esempio "oggi") inventerebbe una scadenza che nessuno ha pattuito. Il
-- portale mostra la scadenza solo se c'è.
--
-- Nessun DROP, nessun TRUNCATE, nessuna DELETE — `payments` è LOCKED in CLAUDE.md.
ALTER TABLE payments ADD COLUMN IF NOT EXISTS due_date timestamptz;
-- Il promemoria email cercherà "le rate non saldate in scadenza entro N giorni":
-- è una scansione su tutta la tabella filtrata per data, non per progetto, quindi
-- l'indice esistente (project_id, sort_order) non la copre.
CREATE INDEX IF NOT EXISTS payments_due_date_idx ON payments(due_date) WHERE due_date IS NOT NULL;
+3
View File
@@ -249,6 +249,9 @@ export const payments = pgTable("payments", {
percent: numeric("percent", { precision: 5, scale: 2 }), // nullable — % of total (for rescaling); null = legacy row percent: numeric("percent", { precision: 5, scale: 2 }), // nullable — % of total (for rescaling); null = legacy row
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
paid_at: timestamp("paid_at", { withTimezone: true }), paid_at: timestamp("paid_at", { withTimezone: true }),
// Scadenza concordata. Nullable: non tutte le rate ne hanno una, e il portale
// la mostra al cliente solo quando c'è. È anche l'aggancio del promemoria email.
due_date: timestamp("due_date", { withTimezone: true }),
sort_order: integer("sort_order").notNull().default(0), sort_order: integer("sort_order").notNull().default(0),
// true = l'importo è stato scritto a mano: rescalePayments non lo tocca più. // true = l'importo è stato scritto a mano: rescalePayments non lo tocca più.
amount_locked: boolean("amount_locked").notNull().default(false), amount_locked: boolean("amount_locked").notNull().default(false),
+19 -5
View File
@@ -1,7 +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, client_channel_reads, project_offers, offer_micros, offer_macros, clientTranscripts } from "@/db/schema"; import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, client_channel_reads, project_offers, offer_micros, offer_macros, clientTranscripts } from "@/db/schema";
import type { TaskStatus } from "@/lib/task-status"; import { countsTowardProgress, type TaskStatus } from "@/lib/task-status";
import { getComputedOfferValues } from "@/lib/offer-value"; import { getComputedOfferValues } from "@/lib/offer-value";
/** /**
@@ -42,6 +42,10 @@ export interface ClientView {
id: string; id: string;
label: string; label: string;
status: "da_saldare" | "inviata" | "saldato"; status: "da_saldare" | "inviata" | "saldato";
// Le date sì, gli importi no: il cliente deve sapere quando paga e quando ha
// pagato — quanto, per singola rata, resta fuori dall'API cliente (LOCKED #2).
due_date: string | null; // ISO
paid_at: string | null; // ISO
}>; }>;
documents: Array<{ documents: Array<{
id: string; id: string;
@@ -108,6 +112,8 @@ export interface ProjectView {
label: string; label: string;
// amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md + DASH-07) // amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md + DASH-07)
status: string; status: string;
due_date: Date | null;
paid_at: Date | null;
}>; }>;
documents: Array<{ documents: Array<{
id: string; id: string;
@@ -326,6 +332,10 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
id: payments.id, id: payments.id,
label: payments.label, label: payments.label,
status: payments.status, status: payments.status,
// Date sì, importo no: la scadenza e l'incasso servono al cliente per
// sapere quando paga; l'importo della singola rata resta escluso.
due_date: payments.due_date,
paid_at: payments.paid_at,
// amount intentionally excluded — client API never exposes payment amounts // amount intentionally excluded — client API never exposes payment amounts
}) })
.from(payments) .from(payments)
@@ -446,16 +456,20 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
deliverables: deliverablesRows.filter((d) => d.task_id === task.id), deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
})); }));
const doneCount = phaseTasks.filter((t) => t.status === "done").length; // I task cancellati escono dal denominatore: restano in lista, barrati, ma
// non tengono la fase sotto il 100% per un lavoro che nessuno farà più.
const countedTasks = phaseTasks.filter((t) => countsTowardProgress(t.status));
const doneCount = countedTasks.filter((t) => t.status === "done").length;
const progress_pct = const progress_pct =
phaseTasks.length > 0 ? Math.round((doneCount / phaseTasks.length) * 100) : 0; countedTasks.length > 0 ? Math.round((doneCount / countedTasks.length) * 100) : 0;
return { ...phase, tasks: phaseTasks, progress_pct }; return { ...phase, tasks: phaseTasks, progress_pct };
}); });
const doneTasks = tasksRows.filter((t) => t.status === "done").length; const countedTasks = tasksRows.filter((t) => countsTowardProgress(t.status));
const doneTasks = countedTasks.filter((t) => t.status === "done").length;
const global_progress_pct = const global_progress_pct =
tasksRows.length > 0 ? Math.round((doneTasks / tasksRows.length) * 100) : 0; countedTasks.length > 0 ? Math.round((doneTasks / countedTasks.length) * 100) : 0;
return { return {
project: { project: {
+4
View File
@@ -9,6 +9,7 @@ import {
offer_macros, offer_macros,
} from "@/db/schema"; } from "@/db/schema";
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { countsTowardProgress } from "@/lib/task-status";
export type DeliveryStatus = "consegnato" | "in_anticipo" | "in_linea" | "in_ritardo"; export type DeliveryStatus = "consegnato" | "in_anticipo" | "in_linea" | "in_ritardo";
@@ -131,6 +132,9 @@ export async function getDeliveryBoard(): Promise<DeliveryBoard> {
for (const task of taskRows) { for (const task of taskRows) {
const projectId = phaseToProject.get(task.phase_id); const projectId = phaseToProject.get(task.phase_id);
if (!projectId) continue; if (!projectId) continue;
// Le cancellate non entrano nel totale: il board di consegna misura quanto
// manca, e quello che è stato tolto dal lavoro non manca più.
if (!countsTowardProgress(task.status)) continue;
const tally = taskTally.get(projectId) ?? { done: 0, total: 0 }; const tally = taskTally.get(projectId) ?? { done: 0, total: 0 };
tally.total += 1; tally.total += 1;
if (task.status === "done") tally.done += 1; if (task.status === "done") tally.done += 1;
+100
View File
@@ -0,0 +1,100 @@
// ── Date dei pagamenti, lette come le legge il cliente ───────────────────────
// Un pagamento ha due date: quella attesa (`due_date`) e quella incassata
// (`paid_at`). Entrambe sono timestamptz, ma al cliente interessa il giorno sul
// calendario, non l'istante: "manca una settimana" non deve cambiare risposta
// perché il container gira a UTC e chi legge sta a Roma.
//
// Da qui passeranno anche i promemoria email ("la scadenza si avvicina"): il
// conteggio dei giorni che decide se mandare la mail deve essere lo stesso
// numero che il cliente vede nel portale, altrimenti la mail arriva a dire
// "mancano 3 giorni" mentre il portale ne mostra 2.
/** Il fuso in cui il cliente guarda il calendario. */
const TZ = "Europe/Rome";
const dayKeyFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const longDateFormatter = new Intl.DateTimeFormat("it-IT", {
timeZone: TZ,
day: "numeric",
month: "long",
year: "numeric",
});
const shortDateFormatter = new Intl.DateTimeFormat("it-IT", {
timeZone: TZ,
day: "numeric",
month: "short",
year: "numeric",
});
function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
/** "2026-03-15" — il giorno civile a Roma, qualunque sia il fuso del server. */
function dayKey(value: Date | string): string {
return dayKeyFormatter.format(toDate(value));
}
/**
* Giorni interi che separano oggi dalla scadenza: 0 = oggi, 3 = fra tre giorni,
* -2 = scaduta da due giorni.
*
* Si confrontano i giorni civili, non gli istanti: una scadenza fissata a
* mezzogiorno non deve leggersi "fra 0 giorni" solo perché mancano 20 ore.
*/
export function daysUntil(due: Date | string, now: Date = new Date()): number {
const a = Date.parse(`${dayKey(now)}T00:00:00Z`);
const b = Date.parse(`${dayKey(due)}T00:00:00Z`);
return Math.round((b - a) / 86_400_000);
}
/** "15 marzo 2026" */
export function formatLongDate(value: Date | string): string {
return longDateFormatter.format(toDate(value));
}
/** "15 mar 2026" — per le righe strette della sidebar. */
export function formatShortDate(value: Date | string): string {
return shortDateFormatter.format(toDate(value));
}
/**
* Il conto alla rovescia in parole. Il ritardo si dice, non si nasconde: una
* rata scaduta che continua a mostrare la sua data senza commento sembra a posto.
*/
export function formatCountdown(days: number): string {
if (days === 0) return "oggi";
if (days === 1) return "domani";
if (days === -1) return "scaduto da 1 giorno";
if (days > 1) return `tra ${days} giorni`;
return `scaduto da ${Math.abs(days)} giorni`;
}
/** Una rata è ancora aperta finché non è saldata — "inviata" non è incassata. */
export function isOutstanding(status: string): boolean {
return status !== "saldato";
}
type Schedulable = { status: string; due_date: Date | string | null };
/**
* La prossima rata da pagare: la più vicina fra quelle aperte che hanno una data.
*
* Le scadute restano candidate e vincono sulle future — se c'è un arretrato è
* quello "il prossimo pagamento", non la rata del mese prossimo.
*/
export function findNextDue<T extends Schedulable>(payments: T[]): T | null {
const withDate = payments.filter((p) => isOutstanding(p.status) && p.due_date !== null);
if (withDate.length === 0) return null;
return withDate.reduce((earliest, p) =>
toDate(p.due_date!).getTime() < toDate(earliest.due_date!).getTime() ? p : earliest
);
}
+12 -1
View File
@@ -6,7 +6,7 @@
// //
// No db import on purpose: client components import this file. // No db import on purpose: client components import this file.
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const; export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done", "cancelled"] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number]; export type TaskStatus = (typeof TASK_STATUSES)[number];
@@ -15,8 +15,19 @@ export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
in_progress: "In corso", in_progress: "In corso",
in_review: "In revisione", in_review: "In revisione",
done: "Fatto", done: "Fatto",
cancelled: "Cancellata",
}; };
// Un task cancellato è chiuso, non fatto: resta visibile (barrato) perché il
// cliente ha il diritto di sapere che quella voce esisteva ed è stata tolta, ma
// esce dai conteggi. Contarlo nel denominatore bloccherebbe la fase sotto il
// 100% per sempre; contarlo come fatto racconterebbe una consegna mai avvenuta.
// Vive qui e non come `!== "cancelled"` sparso nei file: è la stessa disciplina
// che ha fatto nascere questo modulo.
export function countsTowardProgress(status: string): boolean {
return status !== "cancelled";
}
export function isTaskStatus(value: string): value is TaskStatus { export function isTaskStatus(value: string): value is TaskStatus {
return (TASK_STATUSES as readonly string[]).includes(value); return (TASK_STATUSES as readonly string[]).includes(value);
} }