diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 7c7b91c..6327eb8 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 */}
+ } > 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 + +
+ +
+ {rows.map((row) => ( + +
+
+
+

+ {row.projectName} +

+ {row.category && ( + + {row.category} + + )} +
+

{row.clientName}

+
+ +
+ + {fmtDate(row.dueDate)} + {row.dueDateManual && ( + + )} + + +
+
+ + {/* Barra: pieno = fatto, tacca = tempo trascorso */} +
+
+ {row.status !== "consegnato" && ( + + )} +
+ +
+ + + {row.progressPct}% + {" "} + {row.totalTasks > 0 + ? `(${row.doneTasks}/${row.totalTasks} task)` + : "— nessun task creato"} + + + {row.status === "consegnato" ? "completato" : daysLabel(row.daysLeft)} + +
+ + ))} +
+ + {withoutDueDate.length > 0 && ( +
+

+ Senza scadenza calcolabile +

+

+ Nessuna offerta assegnata da cui dedurre la consegna. Assegnala dal + progetto, oppure imposta una data a mano. +

+
+ {withoutDueDate.map((p) => ( + + {p.projectName} + + {p.progressPct}% + + + ))} +
+
+ )} +
+ ); +} 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 }; +}