Compare commits
3 Commits
d6e95ef66a
...
d6d3be00f4
| Author | SHA1 | Date | |
|---|---|---|---|
| d6d3be00f4 | |||
| 8f2b3255ab | |||
| 1115bb2265 |
+23
-13
@@ -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,19 +30,28 @@ 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] =
|
||||||
getAnalyticsByYear(year),
|
await Promise.all([
|
||||||
getTotalTrackedHours(year),
|
getAnalyticsByYear(year),
|
||||||
getClientProfitability(year),
|
getTotalTrackedHours(year),
|
||||||
getRevenueForecast12Months(),
|
getClientProfitability(year),
|
||||||
getOffersSoldBreakdown(),
|
getRevenueForecast12Months(),
|
||||||
]);
|
getOffersSoldBreakdown(),
|
||||||
|
getProductAnalytics(year),
|
||||||
|
getDeliveryBoard(),
|
||||||
|
]);
|
||||||
|
|
||||||
const collectedPct =
|
const collectedPct =
|
||||||
data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
|
data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user