fe767899b9
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>
110 lines
4.6 KiB
TypeScript
110 lines
4.6 KiB
TypeScript
"use client";
|
|
|
|
import type { ClientView } from "@/lib/client-view";
|
|
import { ApproveButton } from "@/components/client/ApproveButton";
|
|
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] & {
|
|
phaseTitle: string;
|
|
};
|
|
|
|
const COLUMNS: { id: TaskStatus; label: string }[] = TASK_STATUSES.map((id) => ({
|
|
id,
|
|
label: TASK_STATUS_LABELS[id],
|
|
}));
|
|
|
|
function TaskCard({ task, token }: { task: Task; token: string }) {
|
|
return (
|
|
<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 ${isClosedTaskStatus(task.status) ? "line-through text-muted-foreground" : "text-foreground"}`}>
|
|
{task.title}
|
|
</p>
|
|
{task.description && (
|
|
<p className="text-[11px] text-muted-foreground mt-2 leading-snug">{task.description}</p>
|
|
)}
|
|
{task.deliverables.length > 0 && (
|
|
<ul className="space-y-1.5 mt-2 pt-2 border-t border-border-light">
|
|
{task.deliverables.map((d) => (
|
|
<li key={d.id} className="flex items-center justify-between gap-2">
|
|
<span className="text-xs text-muted-foreground truncate">{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>
|
|
);
|
|
}
|
|
|
|
export function ClientKanban({ phases, token }: { phases: ClientView["phases"]; token: string }) {
|
|
const allTasks: Task[] = phases.flatMap((phase) =>
|
|
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
|
|
);
|
|
|
|
// Derived from the statuses, not a hand-written object: as three fixed keys it
|
|
// dropped any task outside them from every column AND every counter, so the
|
|
// client silently saw fewer tasks than the project had.
|
|
const tasksByStatus = COLUMNS.reduce(
|
|
(acc, col) => {
|
|
acc[col.id] = allTasks.filter((t) => t.status === col.id);
|
|
return acc;
|
|
},
|
|
{} as Record<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 ${
|
|
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];
|
|
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>
|
|
);
|
|
}
|