+ }
>
diff --git a/src/components/admin/dashboard/DeliveryTimeline.tsx b/src/components/admin/dashboard/DeliveryTimeline.tsx
new file mode 100644
index 0000000..dbd286a
--- /dev/null
+++ b/src/components/admin/dashboard/DeliveryTimeline.tsx
@@ -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 = {
+ consegnato: "Consegnato",
+ in_anticipo: "In anticipo",
+ in_linea: "In linea",
+ in_ritardo: "In ritardo",
+};
+
+const STATUS_TONE: Record = {
+ 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 (
+
+
+ Consegne
+
+
+ Nessun progetto con una consegna in corso.
+
+
+ );
+ }
+
+ return (
+
+
+
+ Consegne
+
+
+ Solo prodotti a consegna — i retainer non scadono
+
+
+ );
+}
diff --git a/src/components/ui/StatusBadge.tsx b/src/components/ui/StatusBadge.tsx
index c8466e7..224a813 100644
--- a/src/components/ui/StatusBadge.tsx
+++ b/src/components/ui/StatusBadge.tsx
@@ -25,29 +25,54 @@ const STAGE_STYLES: Record = {
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 = {
+ 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 (
-
- {status.replace(/_/g, " ")}
+
+ {label ?? status.replace(/_/g, " ")}
);
}
diff --git a/src/lib/delivery-queries.ts b/src/lib/delivery-queries.ts
new file mode 100644
index 0000000..70004bc
--- /dev/null
+++ b/src/lib/delivery-queries.ts
@@ -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 {
+ 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();
+ 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 };
+}