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
+4 -1
View File
@@ -1,5 +1,6 @@
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
import type { ProjectFullDetail } from "@/lib/admin-queries";
import { countsTowardProgress } from "@/lib/task-status";
type Props = {
acceptedTotal: string;
@@ -38,7 +39,9 @@ export function ProjectSummary({
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 progressPct =
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",
},
done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" },
cancelled: { headerClass: "text-[#a1a1aa]", dotClass: "bg-[#a1a1aa]" },
};
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] =
@@ -92,6 +93,7 @@ function DroppableColumn({
key={task.id}
task={task}
isActive={activeId === task.id}
status={id}
/>
))}
{tasks.length === 0 && (
@@ -107,9 +109,11 @@ function DroppableColumn({
function DraggableCard({
task,
isActive,
status,
}: {
task: Task;
isActive: boolean;
status: Status;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({ id: task.id });
@@ -131,7 +135,13 @@ function DraggableCard({
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
{task.phaseTitle}
</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 && (
<p className="text-xs text-[#71717a] mt-1 leading-snug line-clamp-2">
{task.description}
@@ -211,7 +221,7 @@ export function KanbanBoard({
onDragStart={(e) => setActiveId(e.active.id as string)}
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) => (
<DroppableColumn
key={col.id}
+59 -20
View File
@@ -9,6 +9,7 @@ import {
setPaymentPaidAt,
updatePaymentField,
clearPaymentOverride,
setPaymentDueDate,
} from "@/app/admin/clients/[id]/actions";
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
import { Button } from "@/components/ui/button";
@@ -36,14 +37,19 @@ function formatEuro(value: number): string {
return value.toLocaleString("it-IT", { minimumFractionDigits: 2 });
}
// paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per <input type="month">
function toMonthValue(paidAt: Date | string | null | undefined): string {
if (!paidAt) {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
const d = paidAt instanceof Date ? paidAt : new Date(paidAt);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
// Date | string | null (serializzato sul confine RSC) → "YYYY-MM-DD" per <input type="date">.
// Le date sono salvate a mezzogiorno UTC apposta, quindi i getter locali leggono
// il giorno giusto in qualunque fuso senza scivolare di uno.
function toDateValue(value: Date | string | null | undefined): string {
if (!value) return "";
const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return "";
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";
@@ -118,16 +124,19 @@ export function PaymentsTab({
async function handleSetPlan(mode: PlanMode) {
if (!projectId) return;
// Picking a plan deletes every existing row — including status and paid_at.
// Only worth interrupting when there is actually payment history to lose.
// Picking a plan deletes every existing row — including status, paid_at and
// 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(
(p) => p.status === "saldato" || p.status === "inviata"
(p) => p.status === "saldato" || p.status === "inviata" || p.due_date !== null
).length;
if (tracked > 0) {
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(
`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;
}
@@ -155,11 +164,22 @@ export function PaymentsTab({
}
}
async function handlePaidMonthUpdate(paymentId: string, monthStr: string) {
if (!monthStr) return;
async function handlePaidDateUpdate(paymentId: string, dateStr: string) {
if (!dateStr) return;
setStatusLoading(paymentId);
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();
} finally {
setStatusLoading(null);
@@ -339,17 +359,36 @@ export function PaymentsTab({
</select>
{statusLoading === p.id && <span className="text-xs text-[#71717a]">...</span>}
</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" && (
<div className="flex items-center gap-2 mt-2">
<Label htmlFor={`paid-${p.id}`} className="text-xs text-[#71717a] shrink-0">
Incassato nel mese
Incassato il
</Label>
<input
id={`paid-${p.id}`}
type="month"
defaultValue={toMonthValue(p.paid_at)}
type="date"
defaultValue={toDateValue(p.paid_at) || todayValue()}
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"
/>
</div>
+22 -4
View File
@@ -3,7 +3,12 @@
import { useState } from "react";
import { ApproveButton } from "@/components/client/ApproveButton";
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";
type Phase = ClientView["phases"][number];
@@ -41,6 +46,10 @@ export function PhaseCard({
const [open, setOpen] = useState(defaultOpen);
const { openChat } = useChatContext();
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,
// 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.
@@ -94,13 +103,19 @@ export function PhaseCard({
<div className="mb-1">
<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
{doneCount} di {countedTotal} task
{reviewCount > 0 && (
<span className="text-violet-600 dark:text-violet-400">
{" · "}
{reviewCount} in attesa di riscontro
</span>
)}
{cancelledCount > 0 && (
<span>
{" · "}
{cancelledCount} {cancelledCount === 1 ? "cancellata" : "cancellate"}
</span>
)}
</p>
<p className="text-xs font-semibold text-foreground shrink-0">{phase.progress_pct}%</p>
</div>
@@ -121,7 +136,10 @@ export function PhaseCard({
) : (
<ul className="space-y-3">
{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} />
<div className="flex-1 min-w-0">
{/* 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">
<p
className={`text-xs leading-snug ${
task.status === "done"
isClosedTaskStatus(task.status)
? "line-through text-muted-foreground"
: "text-foreground"
}`}
+38 -5
View File
@@ -8,7 +8,8 @@
// 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.
// è 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";
@@ -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",
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 }) {
return (
<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.
*
* 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.
* lo screen reader lo annuncia su tutti gli stati, mentre a schermo solo quelli
* ambigui portano la pill.
*/
export function TaskStatusIcon({ status }: { status: TaskStatus }) {
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}`}
>
{status === "done" && <CheckGlyph className="h-3 w-3" />}
{status === "cancelled" && <CrossGlyph className="h-2.5 w-2.5" />}
{status === "in_review" && (
<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.
*
* `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
* la pill verrebbero annunciati due volte.
* lo `sr-only` dell'icona, su tutti gli stati. Senza questo, quelli che hanno la pill
* verrebbero annunciati due volte.
*/
export function TaskStatusPill({ status }: { status: TaskStatus }) {
const v = TASK_STATUS_VISUALS[status];
+21 -5
View File
@@ -2,7 +2,7 @@
import type { ClientView } from "@/lib/client-view";
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";
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 }) {
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">
{task.phaseTitle}
</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}
</p>
{task.description && (
@@ -58,9 +62,21 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
{} 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 (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
{COLUMNS.map((col) => {
<div
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
// "in corso" e "in revisione" restano riconoscibili senza rileggere.
const v = TASK_STATUS_VISUALS[col.id];
+77 -5
View File
@@ -1,4 +1,11 @@
import type { ClientView } from '@/lib/client-view';
import {
daysUntil,
findNextDue,
formatCountdown,
formatLongDate,
formatShortDate,
} from '@/lib/payment-dates';
interface PaymentStatusProps {
accepted_total: string;
@@ -49,6 +56,16 @@ export function PaymentStatus({
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 (
<div className="rounded-xl border border-border-light bg-card shadow-card overflow-hidden">
{/* Totale — unico importo visibile al cliente (LOCKED) */}
@@ -61,7 +78,34 @@ export function PaymentStatus({
</p>
</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 && (
<div className="px-5 py-4 space-y-2">
{payments.length === 0 ? (
@@ -73,14 +117,42 @@ export function PaymentStatus({
const status = payment.status as PaymentStatusValue;
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 (
<div
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">
{payment.label}
</p>
<div className="min-w-0">
<p className="text-xs font-semibold text-foreground">
{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 */}
<span