Compare commits

..

3 Commits

Author SHA1 Message Date
simone d6d3be00f4 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>
2026-08-19 22:48:42 +02:00
simone 8f2b3255ab feat(dashboard): analytics per linea di prodotto, con il residuo in chiaro
Entry (l'Audit), Signature e Retainer a confronto: quante offerte sono partite
questo mese, quante nell'anno, quanto valgono e quanto e' stato incassato. Le
categorie si leggono da offer_macros.category, cioe' dalla stessa tassonomia
che si edita da /admin/impostazioni: aggiungerne una la fa comparire, senza
toccare il codice. Le categorie configurate compaiono sempre, anche a zero —
"questo mese non e' partito nessun audit" e' una risposta, una riga mancante no.

Il punto delicato e' l'attribuzione dell'incassato. I pagamenti stanno sul
PROGETTO, non sull'offerta: per dire quanto ha incassato una linea di prodotto
bisogna ridiscendere dal progetto alle sue offerte. Un'offerta sola prende
tutto; piu' offerte si spartiscono in proporzione all'accepted_total; nessuna
offerta finisce in una riga "Senza offerta", separata e visibile.

Quella riga separata non e' prudenza teorica. Sui dati di oggi vale il 100%
dell'incassato: 5.300 EUR su 5.300. I due progetti che hanno incassato (Caruso
Speaker, Protocollo Estetico) non hanno nessuna offerta assegnata; i due che
l'hanno (Rossi Inc, Teckell) non hanno ancora incassato. Spalmando quei soldi
sulle tre categorie la dashboard avrebbe mostrato numeri inventati con un
totale che quadra. Cosi' invece si vede che c'e' da assegnare le offerte.

Lo stato dell'offerta non filtra niente: un retainer disdetto oggi ha comunque
incassato quello che ha incassato. Stessa ragione gia' scritta in
getOffersSoldBreakdown — il ciclo di vita riguarda il forecast, non lo storico.

Attesi per il 2026, verificati a mano sul DB: Entry 0, Retainer 1 offerta /
200 EUR, Signature 1 / 7.000 EUR, Senza offerta 5.300 EUR incassati. Nessuna
partenza ad agosto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:45:23 +02:00
simone 1115bb2265 feat(dashboard): l'inbox sale in cima e dice da quanto aspettano
Il widget dei messaggi esisteva gia', ma era il terzo riquadro della colonna
stretta: sotto la piega, cioe' invisibile. Un messaggio non letto e' la cosa
piu' urgente della giornata e ora sta in testa alla dashboard, a piena
larghezza, due colonne.

Due dati erano gia' in ConversationSummary e non venivano mostrati: da quanto
aspetta il messaggio e su cosa e' stato scritto (fase, task, deliverable o
generale). Sono esattamente i due che dicono con che fretta rispondere. Il
tempo relativo si ferma alla settimana e poi passa alla data: oltre, "23 giorni
fa" non aiuta piu' nessuno.

La fascia sparisce del tutto quando non c'e' niente da leggere, invece di
lasciare a video un riquadro vuoto che si impara a saltare — e con lui si
imparerebbe a saltare anche quello pieno.

relativeTime va in src/lib/dates.ts perche' serve anche altrove. FollowUpWidget
ha ancora la sua copia locale con il prefisso "Contattato": la si unifica
quando la si tocca, non oggi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:42:52 +02:00
9 changed files with 860 additions and 87 deletions
+17 -7
View File
@@ -1,7 +1,7 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { getDashboardStats } from "@/lib/dashboard-queries"; import { getDashboardStats } from "@/lib/dashboard-queries";
import { FollowUpWidget } from "@/components/admin/dashboard/FollowUpWidget"; import { FollowUpWidget } from "@/components/admin/dashboard/FollowUpWidget";
import { MessagesWidget } from "@/components/admin/dashboard/MessagesWidget"; import { InboxBand } from "@/components/admin/dashboard/InboxBand";
import { import {
getAnalyticsByYear, getAnalyticsByYear,
getTotalTrackedHours, getTotalTrackedHours,
@@ -13,6 +13,10 @@ import { ForecastChart } from "@/components/admin/ForecastChart";
import { OffersSoldChart } from "@/components/admin/OffersSoldChart"; import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability"; import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability";
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard"; 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; export const revalidate = 0;
@@ -26,12 +30,15 @@ export default async function AdminDashboard({
const { kpi } = await getDashboardStats(); const { kpi } = await getDashboardStats();
const [data, totalHours, profitability, forecast, offersSold] = await Promise.all([ const [data, totalHours, profitability, forecast, offersSold, products, deliveries] =
await Promise.all([
getAnalyticsByYear(year), getAnalyticsByYear(year),
getTotalTrackedHours(year), getTotalTrackedHours(year),
getClientProfitability(year), getClientProfitability(year),
getRevenueForecast12Months(), getRevenueForecast12Months(),
getOffersSoldBreakdown(), getOffersSoldBreakdown(),
getProductAnalytics(year),
getDeliveryBoard(),
]); ]);
const collectedPct = const collectedPct =
@@ -39,6 +46,12 @@ export default async function AdminDashboard({
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
{/* Messaggi in attesa — in cima perché è la cosa più urgente della
giornata. Non renderizza nulla quando non c'è niente da leggere. */}
<Suspense fallback={null}>
<InboxBand />
</Suspense>
{/* KPI strip */} {/* KPI strip */}
<section className="grid grid-cols-2 lg:grid-cols-4 gap-6"> <section className="grid grid-cols-2 lg:grid-cols-4 gap-6">
<MetricCard <MetricCard
@@ -66,21 +79,18 @@ export default async function AdminDashboard({
{/* Colonna 2/3 */} {/* Colonna 2/3 */}
<div className="lg:col-span-2 flex flex-col gap-8"> <div className="lg:col-span-2 flex flex-col gap-8">
<ForecastChart data={forecast} headerAction={<YearSelector currentYear={year} />} /> <ForecastChart data={forecast} headerAction={<YearSelector currentYear={year} />} />
<ProductBreakdown rows={products} year={year} />
<ClientProfitability data={profitability} /> <ClientProfitability data={profitability} />
</div> </div>
{/* Colonna 1/3 */} {/* Colonna 1/3 */}
<div className="lg:col-span-1 flex flex-col gap-8"> <div className="lg:col-span-1 flex flex-col gap-8">
<DeliveryTimeline board={deliveries} />
<Suspense <Suspense
fallback={<div className="animate-pulse h-40 bg-muted rounded-xl" />} fallback={<div className="animate-pulse h-40 bg-muted rounded-xl" />}
> >
<FollowUpWidget /> <FollowUpWidget />
</Suspense> </Suspense>
<Suspense
fallback={<div className="animate-pulse h-40 bg-muted rounded-xl" />}
>
<MessagesWidget />
</Suspense>
<OffersSoldChart data={offersSold} /> <OffersSoldChart data={offersSold} />
</div> </div>
</div> </div>
@@ -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>
);
}
@@ -0,0 +1,93 @@
import { getConversations } from "@/lib/conversations-queries";
import { relativeTime } from "@/lib/dates";
import Link from "next/link";
const MAX_ROWS = 6;
/**
* I messaggi che aspettano una risposta, in cima alla dashboard.
*
* Prima era un riquadro stretto in fondo alla terza colonna: c'era, ma sotto la
* piega. Un messaggio non letto è la cosa più urgente della giornata e va vista
* per prima — per questo occupa tutta la larghezza, e per questo sparisce del
* tutto quando non c'è niente da leggere invece di lasciare a video una fascia
* vuota che si impara a saltare.
*
* Si legge qui, si risponde in /admin/conversazioni.
*/
export async function InboxBand() {
const conversations = await getConversations();
const unread = conversations.filter((c) => c.unread);
if (unread.length === 0) return null;
const visible = unread.slice(0, MAX_ROWS);
const totalMessages = unread.reduce((sum, c) => sum + c.unreadCount, 0);
return (
<section className="bg-card rounded-xl border border-border-light shadow-card p-5">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Da rispondere
</h2>
<span className="text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 px-2 py-0.5 rounded-full dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20">
{totalMessages} {totalMessages === 1 ? "messaggio" : "messaggi"} ·{" "}
{unread.length} {unread.length === 1 ? "cliente" : "clienti"}
</span>
</div>
<Link
href="/admin/conversazioni"
className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
Apri conversazioni
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{visible.map((conv) => (
<Link
key={conv.clientId}
href={`/admin/conversazioni?c=${conv.clientId}`}
className="flex items-start justify-between gap-3 p-3 border border-border-light rounded-lg hover:border-border transition-colors group"
>
<div className="min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<h3 className="text-xs font-bold text-foreground truncate">
{conv.brand_name}
</h3>
<span className="text-[10px] text-muted-foreground font-mono tabular-nums shrink-0">
{relativeTime(conv.lastMessageAt)}
</span>
{conv.unreadCount > 1 && (
<span className="text-[10px] font-bold text-emerald-700 dark:text-emerald-400 shrink-0">
{conv.unreadCount} nuovi
</span>
)}
</div>
<p className="text-[11px] text-muted-foreground mt-1 line-clamp-2">
{conv.lastMessage}
</p>
<p className="text-[10px] text-muted-foreground/70 mt-1 truncate">
{conv.lastEntityLabel}
</p>
</div>
<span className="text-[11px] font-bold text-primary group-hover:opacity-70 transition-opacity shrink-0 mt-0.5">
Rispondi
</span>
</Link>
))}
</div>
{unread.length > MAX_ROWS && (
<Link
href="/admin/conversazioni"
className="block mt-3 pt-3 text-center text-xs font-medium text-muted-foreground hover:text-primary border-t border-border-light transition-colors"
>
Altri {unread.length - MAX_ROWS} clienti in attesa
</Link>
)}
</section>
);
}
@@ -1,65 +0,0 @@
import { getConversations } from "@/lib/conversations-queries";
import Link from "next/link";
const MAX_ROWS = 4;
export async function MessagesWidget() {
const conversations = await getConversations();
const unread = conversations.filter((c) => c.unread);
if (unread.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">
Messaggi Clienti
</h3>
<p className="text-sm text-muted-foreground mt-3">Nessun messaggio in attesa </p>
</div>
);
}
const visible = unread.slice(0, MAX_ROWS);
return (
<div className="bg-card rounded-xl border border-border-light shadow-card p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Messaggi Clienti
</h3>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
</div>
<span className="text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 px-2 py-0.5 rounded-full dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20">
{unread.length} {unread.length === 1 ? "Nuovo" : "Nuovi"}
</span>
</div>
<div className="space-y-3">
{visible.map((conv) => (
<Link
key={conv.clientId}
href={`/admin/conversazioni?c=${conv.clientId}`}
className="flex items-center justify-between gap-3 p-3 border border-border-light rounded-lg hover:border-border transition-colors group"
>
<div className="min-w-0">
<h4 className="text-xs font-bold text-foreground truncate">{conv.brand_name}</h4>
<p className="text-[10px] text-muted-foreground truncate">{conv.lastMessage}</p>
</div>
<span className="text-[11px] font-bold text-primary group-hover:opacity-70 transition-opacity shrink-0 inline-flex items-center gap-0.5">
Rispondi
</span>
</Link>
))}
</div>
{unread.length > MAX_ROWS && (
<Link
href="/admin/conversazioni"
className="block mt-3 pt-3 text-center text-xs font-medium text-muted-foreground hover:text-primary border-t border-border-light transition-colors"
>
Vedi tutti i messaggi
</Link>
)}
</div>
);
}
@@ -0,0 +1,115 @@
import type { ProductRow } from "@/lib/product-analytics";
import { fmtEur0 } from "@/components/admin/MetricCard";
/**
* Le linee di prodotto a confronto: quante ne sono partite e quanto hanno
* incassato. Una tabella e non un grafico — sono tre o quattro righe con cinque
* numeri ciascuna, e un grafico le renderebbe più difficili da leggere, non meno.
*/
export function ProductBreakdown({ rows, year }: { rows: ProductRow[]; year: number }) {
const totals = rows.reduce(
(acc, r) => ({
startedThisMonth: acc.startedThisMonth + r.startedThisMonth,
startedThisYear: acc.startedThisYear + r.startedThisYear,
contractedThisYear: acc.contractedThisYear + r.contractedThisYear,
collectedThisYear: acc.collectedThisYear + r.collectedThisYear,
}),
{ startedThisMonth: 0, startedThisYear: 0, contractedThisYear: 0, collectedThisYear: 0 }
);
return (
<div className="bg-card rounded-xl border border-border-light shadow-card overflow-hidden">
<div className="p-5 pb-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Linee di prodotto
</h3>
<p className="text-xs text-muted-foreground mt-1">
Offerte partite e denaro incassato nel {year}
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-y border-border bg-muted/50 text-muted-foreground text-[10px] font-semibold uppercase tracking-wider">
<th className="py-3 px-5 text-left font-semibold">Categoria</th>
<th className="py-3 px-5 text-right font-semibold">Questo mese</th>
<th className="py-3 px-5 text-right font-semibold">Anno</th>
<th className="py-3 px-5 text-right font-semibold">Contrattualizzato</th>
<th className="py-3 px-5 text-right font-semibold">Incassato</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((row) => (
<tr key={row.category} className="hover:bg-muted/40 transition-colors">
<td className="py-3 px-5">
<span
className={
row.unattributed
? "text-muted-foreground italic"
: "font-semibold text-foreground"
}
>
{row.category}
</span>
{row.unattributed && (
<p className="text-[10px] text-muted-foreground mt-0.5">
Progetti a cui non è assegnata nessuna offerta
</p>
)}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums text-foreground">
{row.startedThisMonth > 0 ? (
row.startedThisMonth
) : (
<span className="text-muted-foreground/40"></span>
)}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums text-foreground">
{row.startedThisYear > 0 ? (
row.startedThisYear
) : (
<span className="text-muted-foreground/40"></span>
)}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums text-foreground">
{fmtEur0(row.contractedThisYear)}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums font-semibold">
<span
className={
row.collectedThisYear > 0
? "text-emerald-700 dark:text-emerald-400"
: "text-muted-foreground/40"
}
>
{fmtEur0(row.collectedThisYear)}
</span>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-border bg-muted/30 text-foreground">
<td className="py-3 px-5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
Totale
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums font-bold">
{totals.startedThisMonth}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums font-bold">
{totals.startedThisYear}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums font-bold">
{fmtEur0(totals.contractedThisYear)}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums font-bold">
{fmtEur0(totals.collectedThisYear)}
</td>
</tr>
</tfoot>
</table>
</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", 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 * Rounded-full status pill for lead stages. Replaces the old scattered
* STAGE_COLOR maps in LeadTable/LeadsKanbanBoard — one source of truth for * STAGE_COLOR maps in LeadTable/LeadsKanbanBoard — one source of truth for
* stage → color, with explicit dark: variants for legibility. * 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({ export function StatusBadge({
status, status,
tone,
label,
className, className,
}: { }: {
status: string; status: string;
tone?: BadgeTone;
/** Testo mostrato al posto di `status`, per le etichette già in italiano. */
label?: string;
className?: 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 ( return (
<span <span className={cn(BADGE_BASE, styles, className)}>
className={cn( {label ?? status.replace(/_/g, " ")}
"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> </span>
); );
} }
+29
View File
@@ -0,0 +1,29 @@
/**
* Distanza da adesso, in italiano e senza prefissi: "adesso", "12 min fa",
* "3 ore fa", "ieri", "5 giorni fa", "12 mar".
*
* Sui messaggi la scala che conta è quella delle ore: sapere che un cliente
* scrive da due ore o da due giorni cambia con che fretta gli si risponde, e
* una data assoluta lo direbbe soltanto dopo un calcolo mentale.
* Oltre la settimana la distanza smette di essere utile e si passa alla data.
*/
export function relativeTime(date: Date | string | null | undefined): string {
if (!date) return "—";
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return "—";
const seconds = Math.floor((Date.now() - d.getTime()) / 1000);
if (seconds < 60) return "adesso";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min fa`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} ${hours === 1 ? "ora" : "ore"} fa`;
const days = Math.floor(hours / 24);
if (days === 1) return "ieri";
if (days < 7) return `${days} giorni fa`;
return d.toLocaleDateString("it-IT", { day: "numeric", month: "short" });
}
+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 };
}
+171
View File
@@ -0,0 +1,171 @@
import { db } from "@/db";
import { project_offers, offer_micros, offer_macros, projects, payments } from "@/db/schema";
import { eq, and, sql } from "drizzle-orm";
import { getPool } from "@/lib/taxonomy";
/** Riga sintetica per una linea di prodotto (Entry / Signature / Retainer). */
export type ProductRow = {
category: string;
/** Offerte partite nel mese corrente. */
startedThisMonth: number;
/** Offerte partite nell'anno richiesto. */
startedThisYear: number;
/** Somma degli accepted_total delle offerte partite nell'anno. */
contractedThisYear: number;
/** Pagamenti saldati nell'anno, attribuiti a questa categoria. */
collectedThisYear: number;
/** true per la riga "Senza offerta": non è una categoria, è un residuo. */
unattributed?: boolean;
};
export const UNATTRIBUTED_LABEL = "Senza offerta";
/**
* Analytics per linea di prodotto, lette da `offer_macros.category` — la stessa
* tassonomia che si edita da /admin/impostazioni. L'Audit è l'Entry Offer: non
* ha una sorgente separata, e non deve averla, altrimenti i tre numeri
* smetterebbero di essere confrontabili tra loro.
*
* ## Il problema dell'attribuzione, e come viene risolto
*
* I pagamenti stanno sul PROGETTO, non sull'offerta: `payments.project_id` è
* l'unico legame. Per dire quanto ha incassato una linea di prodotto bisogna
* quindi ridiscendere dal progetto alle sue offerte, e la regola è:
*
* 1. progetto con UNA offerta → tutto l'incasso va a quella categoria;
* 2. progetto con PIÙ offerte → ripartito in proporzione ai rispettivi
* `accepted_total` (se sono tutti a zero, in
* parti uguali: meglio equamente sbagliato che
* arbitrariamente attribuito a una sola);
* 3. progetto SENZA offerte → riga "Senza offerta", mostrata a video.
*
* Il terzo caso non va nascosto. Sommarlo di soppiatto a una categoria darebbe
* un totale che quadra e tre righe che mentono; tenerlo separato fa vedere
* quanti soldi non sono ancora collegati a un'offerta, che è una cosa da
* sistemare, non da mimetizzare.
*
* Lo stato dell'offerta NON filtra nulla qui: un retainer disdetto oggi ha
* comunque incassato quello che ha incassato. Vale la stessa ragione già
* scritta in `getOffersSoldBreakdown` — il ciclo di vita riguarda il forecast,
* non lo storico.
*/
export async function getProductAnalytics(year: number): Promise<ProductRow[]> {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth();
const [offerRows, collectedRows, pool] = await Promise.all([
// Tutte le offerte assegnate, con la categoria della loro macro.
db
.select({
project_id: project_offers.project_id,
category: offer_macros.category,
accepted_total: project_offers.accepted_total,
start_date: project_offers.start_date,
})
.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)),
// Incassato per progetto nell'anno.
db
.select({
project_id: payments.project_id,
total: sql<string>`coalesce(sum(${payments.amount}::numeric), 0)`,
})
.from(payments)
.innerJoin(projects, eq(payments.project_id, projects.id))
.where(
and(
eq(payments.status, "saldato"),
sql`${payments.paid_at} is not null and extract(year from ${payments.paid_at}) = ${year}`
)
)
.groupBy(payments.project_id),
getPool("offer_categoria"),
]);
// Le categorie configurate compaiono SEMPRE, anche a zero: "questo mese non
// è partito nessun audit" è una risposta, una riga mancante no.
const rows = new Map<string, ProductRow>();
const ensure = (category: string, unattributed = false): ProductRow => {
let row = rows.get(category);
if (!row) {
row = {
category,
startedThisMonth: 0,
startedThisYear: 0,
contractedThisYear: 0,
collectedThisYear: 0,
...(unattributed ? { unattributed: true } : {}),
};
rows.set(category, row);
}
return row;
};
for (const category of pool) ensure(category);
// ── Partenze e contrattualizzato ───────────────────────────────────────────
for (const offer of offerRows) {
const category = offer.category ?? UNATTRIBUTED_LABEL;
const row = ensure(category, category === UNATTRIBUTED_LABEL);
const start = new Date(offer.start_date);
if (start.getFullYear() !== year) continue;
row.startedThisYear += 1;
row.contractedThisYear += parseFloat(String(offer.accepted_total ?? "0")) || 0;
if (year === currentYear && start.getMonth() === currentMonth) {
row.startedThisMonth += 1;
}
}
// ── Incassato, ripartito sulle offerte del progetto ────────────────────────
const offersByProject = new Map<string, typeof offerRows>();
for (const offer of offerRows) {
const list = offersByProject.get(offer.project_id) ?? [];
list.push(offer);
offersByProject.set(offer.project_id, list);
}
for (const { project_id, total } of collectedRows) {
const collected = parseFloat(total) || 0;
if (collected === 0) continue;
const offers = offersByProject.get(project_id) ?? [];
if (offers.length === 0) {
ensure(UNATTRIBUTED_LABEL, true).collectedThisYear += collected;
continue;
}
const weights = offers.map((o) => parseFloat(String(o.accepted_total ?? "0")) || 0);
const weightSum = weights.reduce((a, b) => a + b, 0);
offers.forEach((offer, i) => {
const share = weightSum > 0 ? weights[i] / weightSum : 1 / offers.length;
const category = offer.category ?? UNATTRIBUTED_LABEL;
ensure(category, category === UNATTRIBUTED_LABEL).collectedThisYear += collected * share;
});
}
// Ordine: le categorie come le ha configurate l'utente, il residuo in fondo.
const ordered = [...rows.values()].sort((a, b) => {
if (a.unattributed) return 1;
if (b.unattributed) return -1;
const ai = pool.indexOf(a.category);
const bi = pool.indexOf(b.category);
if (ai === -1 && bi === -1) return a.category.localeCompare(b.category, "it");
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
// Il residuo si mostra solo se c'è davvero qualcosa dentro.
return ordered.filter(
(r) =>
!r.unattributed ||
r.collectedThisYear > 0 ||
r.startedThisYear > 0 ||
r.contractedThisYear > 0
);
}