feat(dashboard): timeline delle consegne con semaforo ritardo/anticipo
Quali progetti vanno consegnati, a che punto sono e se il ritmo regge. La data attesa non esisteva nello schema. Si deduce da start_date + duration_months dell'offerta, e projects.due_date (migration 0018) la sovrascrive quando la durata a catalogo non descrive quel progetto li'. Entrano solo le offerte una_tantum. Un retainer e' continuativo e una consegna non ce l'ha per costruzione: in questa lista risulterebbe in ritardo per sempre. E' lo stesso discrimine che gia' regge il forecast. Teckell, che ha solo un "Mantenimento", sparisce dalla vista — e sparisce del tutto, non finisce fra i "senza scadenza" dove sembrerebbe una dimenticanza. Il semaforo confronta due percentuali, task chiusi e tempo trascorso, con dieci punti di tolleranza: sotto, la differenza e' rumore, e un semaforo che vira al rosso ogni settimana storta smette di essere guardato. Oltre la data di consegna e' rosso e basta. La barra le mostra entrambe: pieno = fatto, tacca = tempo passato. La distanza fra le due E' il ritardo, e si legge senza doversi fidare del semaforo. I progetti senza scadenza calcolabile restano elencati sotto invece di sparire: sono quelli a cui non e' assegnata un'offerta, cioe' esattamente il problema che la vista dovrebbe far notare. StatusBadge guadagna i toni. Serviva perche' i quattro stati di consegna non sono stadi di lead e cadevano tutti nel grigio di fallback: quattro pillole grigie non sono un semaforo. I lead continuano a usarlo come prima. Atteso e verificato sul DB: una sola riga, Rossi Inc in ritardo (22 giu + 1 mese = 22 lug, oggi 19 ago, 3 task su 28), piu' tre progetti senza scadenza. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import Link from "next/link";
|
||||
import { StatusBadge, type BadgeTone } from "@/components/ui/StatusBadge";
|
||||
import type { DeliveryBoard, DeliveryStatus } from "@/lib/delivery-queries";
|
||||
|
||||
const STATUS_LABEL: Record<DeliveryStatus, string> = {
|
||||
consegnato: "Consegnato",
|
||||
in_anticipo: "In anticipo",
|
||||
in_linea: "In linea",
|
||||
in_ritardo: "In ritardo",
|
||||
};
|
||||
|
||||
const STATUS_TONE: Record<DeliveryStatus, BadgeTone> = {
|
||||
consegnato: "neutral",
|
||||
in_anticipo: "positive",
|
||||
in_linea: "positive",
|
||||
in_ritardo: "danger",
|
||||
};
|
||||
|
||||
function fmtDate(d: Date): string {
|
||||
return new Date(d).toLocaleDateString("it-IT", { day: "numeric", month: "short", year: "2-digit" });
|
||||
}
|
||||
|
||||
/** "fra 12 giorni" · "oggi" · "12 giorni fa" — la scadenza detta come la si pensa. */
|
||||
function daysLabel(days: number): string {
|
||||
if (days === 0) return "oggi";
|
||||
if (days === 1) return "domani";
|
||||
if (days === -1) return "ieri";
|
||||
if (days > 0) return `fra ${days} giorni`;
|
||||
return `${Math.abs(days)} giorni fa`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le consegne in arrivo, con avanzamento e semaforo.
|
||||
*
|
||||
* La barra mostra due cose sovrapposte: quanto è stato fatto (pieno) e quanto
|
||||
* tempo è passato (tacca). La distanza fra le due È il ritardo — leggerla non
|
||||
* richiede di fidarsi del semaforo, che la riassume soltanto.
|
||||
*/
|
||||
export function DeliveryTimeline({ board }: { board: DeliveryBoard }) {
|
||||
const { rows, withoutDueDate } = board;
|
||||
|
||||
if (rows.length === 0 && withoutDueDate.length === 0) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card p-5">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Consegne
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
Nessun progetto con una consegna in corso.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card p-5">
|
||||
<div className="flex items-baseline justify-between gap-3 mb-4 flex-wrap">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Consegne
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Solo prodotti a consegna — i retainer non scadono
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{rows.map((row) => (
|
||||
<Link
|
||||
key={row.projectId}
|
||||
href={`/admin/projects/${row.projectId}`}
|
||||
className="block p-3 -mx-1 rounded-lg border border-transparent hover:border-border-light hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-semibold text-foreground truncate">
|
||||
{row.projectName}
|
||||
</h4>
|
||||
{row.category && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{row.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">{row.clientName}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[11px] font-mono tabular-nums text-muted-foreground">
|
||||
{fmtDate(row.dueDate)}
|
||||
{row.dueDateManual && (
|
||||
<span title="Scadenza impostata a mano"> ✎</span>
|
||||
)}
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={row.status}
|
||||
tone={STATUS_TONE[row.status]}
|
||||
label={STATUS_LABEL[row.status]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barra: pieno = fatto, tacca = tempo trascorso */}
|
||||
<div className="relative h-2 mt-3 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
row.status === "in_ritardo" ? "bg-red-400 dark:bg-red-500" : "bg-emerald-500"
|
||||
}`}
|
||||
style={{ width: `${row.progressPct}%` }}
|
||||
/>
|
||||
{row.status !== "consegnato" && (
|
||||
<span
|
||||
className="absolute top-0 bottom-0 w-px bg-foreground/50"
|
||||
style={{ left: `${row.elapsedPct}%` }}
|
||||
title={`Tempo trascorso: ${row.elapsedPct}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 mt-1.5 text-[11px] text-muted-foreground flex-wrap">
|
||||
<span>
|
||||
<span className="font-mono tabular-nums text-foreground font-semibold">
|
||||
{row.progressPct}%
|
||||
</span>{" "}
|
||||
{row.totalTasks > 0
|
||||
? `(${row.doneTasks}/${row.totalTasks} task)`
|
||||
: "— nessun task creato"}
|
||||
</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{row.status === "consegnato" ? "completato" : daysLabel(row.daysLeft)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{withoutDueDate.length > 0 && (
|
||||
<div className="mt-5 pt-4 border-t border-border-light">
|
||||
<p className="text-[10px] uppercase font-bold tracking-wider text-muted-foreground">
|
||||
Senza scadenza calcolabile
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 mb-2">
|
||||
Nessuna offerta assegnata da cui dedurre la consegna. Assegnala dal
|
||||
progetto, oppure imposta una data a mano.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{withoutDueDate.map((p) => (
|
||||
<Link
|
||||
key={p.projectId}
|
||||
href={`/admin/projects/${p.projectId}`}
|
||||
className="inline-flex items-center gap-2 text-[11px] px-2.5 py-1 rounded-full border border-border-light hover:border-border transition-colors"
|
||||
>
|
||||
<span className="text-foreground font-medium">{p.projectName}</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground">
|
||||
{p.progressPct}%
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,29 +25,54 @@ const STAGE_STYLES: Record<LeadStage, string> = {
|
||||
lost: "bg-red-50 text-red-600 border-red-100 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900",
|
||||
};
|
||||
|
||||
/**
|
||||
* Toni semantici per gli usi che NON sono stadi di lead (per esempio lo stato
|
||||
* di una consegna). Servono perché `status` fuori dal dominio dei lead cadrebbe
|
||||
* sempre nel grigio di fallback, e quattro stati tutti grigi non sono un
|
||||
* semaforo.
|
||||
*/
|
||||
export type BadgeTone = "neutral" | "positive" | "warning" | "danger";
|
||||
|
||||
const TONE_STYLES: Record<BadgeTone, string> = {
|
||||
neutral: "bg-muted text-muted-foreground border-border",
|
||||
positive:
|
||||
"bg-emerald-50 text-emerald-700 border-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900",
|
||||
warning:
|
||||
"bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900",
|
||||
danger:
|
||||
"bg-red-50 text-red-600 border-red-100 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900",
|
||||
};
|
||||
|
||||
export const BADGE_BASE =
|
||||
"inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide uppercase border whitespace-nowrap";
|
||||
|
||||
/**
|
||||
* Rounded-full status pill for lead stages. Replaces the old scattered
|
||||
* STAGE_COLOR maps in LeadTable/LeadsKanbanBoard — one source of truth for
|
||||
* stage → color, with explicit dark: variants for legibility.
|
||||
*
|
||||
* Con `tone` (e, se serve, `label`) la stessa pillola serve anche fuori dai
|
||||
* lead senza doverne disegnare una seconda.
|
||||
*/
|
||||
export function StatusBadge({
|
||||
status,
|
||||
tone,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
status: string;
|
||||
tone?: BadgeTone;
|
||||
/** Testo mostrato al posto di `status`, per le etichette già in italiano. */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const styles = STAGE_STYLES[status as LeadStage] ?? "bg-muted text-muted-foreground border-border";
|
||||
const styles = tone
|
||||
? TONE_STYLES[tone]
|
||||
: STAGE_STYLES[status as LeadStage] ?? TONE_STYLES.neutral;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide uppercase border whitespace-nowrap",
|
||||
styles,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{status.replace(/_/g, " ")}
|
||||
<span className={cn(BADGE_BASE, styles, className)}>
|
||||
{label ?? status.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user