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:
2026-08-19 22:48:42 +02:00
parent 8f2b3255ab
commit d6d3be00f4
4 changed files with 442 additions and 17 deletions
+13 -8
View File
@@ -15,6 +15,8 @@ import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitab
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
import { getProductAnalytics } from "@/lib/product-analytics";
import { ProductBreakdown } from "@/components/admin/dashboard/ProductBreakdown";
import { getDeliveryBoard } from "@/lib/delivery-queries";
import { DeliveryTimeline } from "@/components/admin/dashboard/DeliveryTimeline";
export const revalidate = 0;
@@ -28,14 +30,16 @@ export default async function AdminDashboard({
const { kpi } = await getDashboardStats();
const [data, totalHours, profitability, forecast, offersSold, products] = await Promise.all([
getAnalyticsByYear(year),
getTotalTrackedHours(year),
getClientProfitability(year),
getRevenueForecast12Months(),
getOffersSoldBreakdown(),
getProductAnalytics(year),
]);
const [data, totalHours, profitability, forecast, offersSold, products, deliveries] =
await Promise.all([
getAnalyticsByYear(year),
getTotalTrackedHours(year),
getClientProfitability(year),
getRevenueForecast12Months(),
getOffersSoldBreakdown(),
getProductAnalytics(year),
getDeliveryBoard(),
]);
const collectedPct =
data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
@@ -81,6 +85,7 @@ export default async function AdminDashboard({
{/* Colonna 1/3 */}
<div className="lg:col-span-1 flex flex-col gap-8">
<DeliveryTimeline board={deliveries} />
<Suspense
fallback={<div className="animate-pulse h-40 bg-muted rounded-xl" />}
>
@@ -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>
);
}
+34 -9
View File
@@ -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>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { db } from "@/db";
import {
projects,
clients,
phases,
tasks,
project_offers,
offer_micros,
offer_macros,
} from "@/db/schema";
import { eq, inArray } from "drizzle-orm";
export type DeliveryStatus = "consegnato" | "in_anticipo" | "in_linea" | "in_ritardo";
export type DeliveryRow = {
projectId: string;
projectName: string;
clientName: string;
/** Categoria dell'offerta — "Entry Offer", "Signature Offer", … */
category: string | null;
startDate: Date;
dueDate: Date;
/** true se la scadenza è stata scritta a mano invece che dedotta. */
dueDateManual: boolean;
progressPct: number;
elapsedPct: number;
daysLeft: number;
doneTasks: number;
totalTasks: number;
status: DeliveryStatus;
};
export type DeliveryBoard = {
rows: DeliveryRow[];
/** Progetti che una consegna ce l'hanno, ma senza data da cui dedurla. */
withoutDueDate: Array<{
projectId: string;
projectName: string;
clientName: string;
progressPct: number;
}>;
};
/**
* Tolleranza in punti percentuali entro cui avanzamento e tempo trascorso si
* considerano allineati. Sotto i 10 punti la differenza è rumore: i task non si
* chiudono a ritmo costante, e un semaforo che vira al rosso ogni volta che una
* settimana va storta smette di essere guardato.
*/
const TOLERANCE_PP = 10;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Lo stato delle consegne: a che punto sono i progetti che si consegnano, e se
* sono in linea con la data attesa.
*
* ## Chi entra
*
* Solo i prodotti che si consegnano, cioè le offerte `una_tantum`. I retainer
* sono continuativi e una consegna non ce l'hanno per costruzione: metterli in
* questa lista significherebbe segnarli in ritardo per sempre. È lo stesso
* discrimine che già regge il forecast (`offer_type` in forecast-queries).
*
* ## La data attesa
*
* `projects.due_date` se c'è, altrimenti `start_date + duration_months`
* dell'offerta. La colonna manuale vince: è nata per i casi in cui la durata a
* catalogo non descrive quel progetto lì.
*
* ## Il semaforo
*
* Confronta due percentuali: quanti task sono chiusi e quanto tempo è passato.
* Oltre la data di consegna senza aver finito è in ritardo, punto — lì il
* confronto tra percentuali non serve più.
*/
export async function getDeliveryBoard(): Promise<DeliveryBoard> {
const projectRows = await db
.select({
id: projects.id,
name: projects.name,
client_id: projects.client_id,
due_date: projects.due_date,
created_at: projects.created_at,
})
.from(projects)
.where(eq(projects.archived, false));
if (projectRows.length === 0) return { rows: [], withoutDueDate: [] };
const projectIds = projectRows.map((p) => p.id);
const [offerRows, phaseRows, clientRows] = await Promise.all([
db
.select({
project_id: project_offers.project_id,
start_date: project_offers.start_date,
duration_months: offer_micros.duration_months,
offer_type: offer_macros.offer_type,
category: offer_macros.category,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(inArray(project_offers.project_id, projectIds)),
db
.select({ id: phases.id, project_id: phases.project_id })
.from(phases)
.where(inArray(phases.project_id, projectIds)),
db
.select({ id: clients.id, name: clients.name })
.from(clients)
.where(inArray(clients.id, [...new Set(projectRows.map((p) => p.client_id))])),
]);
const phaseIds = phaseRows.map((p) => p.id);
const taskRows =
phaseIds.length === 0
? []
: await db
.select({ phase_id: tasks.phase_id, status: tasks.status })
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds));
const phaseToProject = new Map(phaseRows.map((p) => [p.id, p.project_id]));
const clientName = new Map(clientRows.map((c) => [c.id, c.name]));
const taskTally = new Map<string, { done: number; total: number }>();
for (const task of taskRows) {
const projectId = phaseToProject.get(task.phase_id);
if (!projectId) continue;
const tally = taskTally.get(projectId) ?? { done: 0, total: 0 };
tally.total += 1;
if (task.status === "done") tally.done += 1;
taskTally.set(projectId, tally);
}
const now = Date.now();
const rows: DeliveryRow[] = [];
const withoutDueDate: DeliveryBoard["withoutDueDate"] = [];
for (const project of projectRows) {
const offers = offerRows.filter((o) => o.project_id === project.id);
const deliverable = offers.filter((o) => o.offer_type === "una_tantum");
const tally = taskTally.get(project.id) ?? { done: 0, total: 0 };
const progressPct =
tally.total > 0 ? Math.round((tally.done / tally.total) * 100) : 0;
// Un progetto con SOLE offerte a retainer non ha una consegna: esce dalla
// vista invece di finire fra i "senza scadenza", dove sembrerebbe una
// dimenticanza da sistemare.
if (deliverable.length === 0 && offers.length > 0 && !project.due_date) continue;
if (deliverable.length === 0 && offers.length === 0 && !project.due_date) {
withoutDueDate.push({
projectId: project.id,
projectName: project.name,
clientName: clientName.get(project.client_id) ?? "—",
progressPct,
});
continue;
}
// Inizio: la prima offerta consegnabile, o la nascita del progetto quando la
// scadenza è manuale e offerte non ce ne sono.
const startDate = deliverable.length
? new Date(Math.min(...deliverable.map((o) => new Date(o.start_date).getTime())))
: new Date(project.created_at);
let dueDate: Date;
if (project.due_date) {
dueDate = new Date(project.due_date);
} else {
// La più lontana fra le scadenze dedotte: il progetto è consegnato quando
// è finita anche l'ultima offerta che lo compone.
const derived = deliverable.map((o) => {
const d = new Date(o.start_date);
d.setMonth(d.getMonth() + Math.max(1, o.duration_months));
return d.getTime();
});
dueDate = new Date(Math.max(...derived));
}
const span = dueDate.getTime() - startDate.getTime();
const elapsedPct =
span > 0
? Math.max(0, Math.min(100, Math.round(((now - startDate.getTime()) / span) * 100)))
: 100;
let status: DeliveryStatus;
if (tally.total > 0 && progressPct === 100) {
status = "consegnato";
} else if (now > dueDate.getTime()) {
status = "in_ritardo";
} else if (progressPct < elapsedPct - TOLERANCE_PP) {
status = "in_ritardo";
} else if (progressPct > elapsedPct + TOLERANCE_PP) {
status = "in_anticipo";
} else {
status = "in_linea";
}
rows.push({
projectId: project.id,
projectName: project.name,
clientName: clientName.get(project.client_id) ?? "—",
category: deliverable[0]?.category ?? null,
startDate,
dueDate,
dueDateManual: project.due_date !== null,
progressPct,
elapsedPct,
daysLeft: Math.ceil((dueDate.getTime() - now) / DAY_MS),
doneTasks: tally.done,
totalTasks: tally.total,
status,
});
}
// I più urgenti in cima: prima chi ha meno giorni davanti, e i consegnati in fondo.
rows.sort((a, b) => {
if (a.status === "consegnato" && b.status !== "consegnato") return 1;
if (b.status === "consegnato" && a.status !== "consegnato") return -1;
return a.daysLeft - b.daysLeft;
});
return { rows, withoutDueDate };
}