feat: offer badges, unified dashboard, income forecast, Pipedrive-style follow-up

- Client detail: category + tier badges on active offers
- Dashboard: remove top KPI cards + recent activity; fold current KPIs into
  bottom MetricCard style; add 12-month income forecast chart
- Forecast query: branch by offer_type (retainer = monthly fee, una_tantum =
  spread over duration), filter archived projects
- Payments: set the month a payment was collected (paid_at) from PaymentsTab
- Redesign FollowUpWidget in clean Pipedrive style (brand green, no orange)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 22:28:12 +02:00
parent fc766ca1ee
commit ae355c33a6
9 changed files with 296 additions and 245 deletions
+19
View File
@@ -289,6 +289,25 @@ export async function updatePaymentStatus(paymentId: string, id: string, status:
revalidatePath(path);
}
// Imposta il mese in cui un pagamento è stato incassato (formato "YYYY-MM").
// Mappa al primo giorno del mese (mezzogiorno UTC per evitare drift di fuso) e
// porta lo stato a "saldato" così l'incasso viene attribuito a quel mese nelle analytics.
export async function setPaymentPaidAt(paymentId: string, id: string, monthStr: string) {
await requireAdmin();
const m = /^(\d{4})-(\d{2})$/.exec(monthStr);
if (!m) throw new Error("Mese non valido");
const year = parseInt(m[1], 10);
const month = parseInt(m[2], 10);
if (month < 1 || month > 12) throw new Error("Mese non valido");
const paid_at = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0));
await db
.update(payments)
.set({ paid_at, status: "saldato" })
.where(eq(payments.id, paymentId));
const { path } = await resolveEntity(id);
revalidatePath(path);
}
// Rescales payment amounts when the total changes.
// If the payment has a `percent` field, use it (new plan rows).
// Legacy rows (percent null) fall back to equal split across all rows.
+33 -12
View File
@@ -130,19 +130,40 @@ export default async function ClientDetailPage({
Offerte Attive ({activeOffers.length})
</p>
<div className="bg-white rounded-xl border border-[#e5e7eb] divide-y divide-[#e5e7eb]">
{activeOffers.map((offer) => (
<div key={offer.offer_id} className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-[#1a1a1a]">{offer.public_name}</p>
<p className="text-xs text-[#71717a]">{offer.project_name}</p>
{activeOffers.map((offer) => {
const categoryLabel =
offer.category ??
(offer.offer_type === "retainer"
? "Retainer"
: offer.offer_type === "una_tantum"
? "Una tantum"
: null);
return (
<div key={offer.offer_id} className="flex items-center justify-between px-4 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-[#1a1a1a]">{offer.public_name}</p>
{categoryLabel && (
<span className="inline-flex items-center rounded-full bg-[#1A463C]/10 px-2 py-0.5 text-[11px] font-semibold text-[#1A463C]">
{categoryLabel}
</span>
)}
{offer.tier_letter && (
<span className="inline-flex items-center rounded-full border border-[#e5e7eb] px-2 py-0.5 text-[11px] font-semibold text-[#71717a]">
Tier {offer.tier_letter}
</span>
)}
</div>
<p className="text-xs text-[#71717a] mt-0.5">{offer.project_name}</p>
</div>
{offer.accepted_total && (
<span className="text-sm font-mono text-[#1a1a1a] shrink-0">
&euro;{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</span>
)}
</div>
{offer.accepted_total && (
<span className="text-sm font-mono text-[#1a1a1a]">
&euro;{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</span>
)}
</div>
))}
);
})}
</div>
</div>
)}
+32 -103
View File
@@ -8,38 +8,12 @@ import {
getTimeByClient,
getTotalTrackedHours,
} from "@/lib/analytics-queries";
import { getRevenueForecast12Months } from "@/lib/forecast-queries";
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
import { Users, FolderOpen, Euro, Clock } from "lucide-react";
import { ForecastChart } from "@/components/admin/ForecastChart";
export const revalidate = 0;
function KpiCard({
label,
value,
sub,
icon: Icon,
color,
}: {
label: string;
value: string | number;
sub?: string;
icon: React.ElementType;
color: string;
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 flex items-start gap-4">
<div className={`p-2 rounded-lg ${color}`}>
<Icon size={20} strokeWidth={1.8} className="text-white" />
</div>
<div>
<p className="text-xs text-gray-500 font-medium uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold text-gray-900 mt-0.5">{value}</p>
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
</div>
</div>
);
}
function MetricCard({
label,
value,
@@ -78,22 +52,6 @@ function MetricCard({
);
}
const ACTIVITY_ICONS: Record<string, string> = {
nuovo_cliente: "👤",
nuovo_progetto: "📁",
deliverable_approvato: "✅",
timer_stoppato: "⏱",
};
function fmt(ts: Date) {
return new Intl.DateTimeFormat("it-IT", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(ts));
}
function fmtEur(n: number) {
return n.toLocaleString("it-IT", {
style: "currency",
@@ -117,14 +75,15 @@ export default async function AdminDashboard({
const { year: yearParam } = await searchParams;
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
const { kpi, activity } = await getDashboardStats();
const { kpi } = await getDashboardStats();
const [data, monthly, availableYears, timeByClient, totalHours] = await Promise.all([
const [data, monthly, availableYears, timeByClient, totalHours, forecast] = await Promise.all([
getAnalyticsByYear(year),
getMonthlyCollected(year),
getAvailableYears(),
getTimeByClient(year),
getTotalTrackedHours(year),
getRevenueForecast12Months(),
]);
const collectedPct =
@@ -143,63 +102,8 @@ export default async function AdminDashboard({
</Suspense>
</div>
{/* KPI cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<KpiCard
label="Clienti attivi"
value={kpi.clientiAttivi}
icon={Users}
color="bg-[#1A463C]"
/>
<KpiCard
label="Revenue totale"
value={fmtEur(parseFloat(kpi.revenueTotale))}
sub="progetti non archiviati"
icon={Euro}
color="bg-emerald-600"
/>
<KpiCard
label="Progetti in corso"
value={kpi.progettiInCorso}
icon={FolderOpen}
color="bg-blue-600"
/>
<KpiCard
label="Pagamenti in sospeso"
value={fmtEur(parseFloat(kpi.pagamentiInSospeso))}
sub="da_saldare + inviata"
icon={Clock}
color="bg-amber-500"
/>
</div>
{/* Activity feed */}
<div className="bg-white rounded-xl border border-gray-200">
<div className="px-5 py-4 border-b border-gray-100">
<h2 className="text-sm font-semibold text-gray-700">Attività recente</h2>
</div>
{activity.length === 0 ? (
<p className="px-5 py-8 text-sm text-gray-400 text-center">Nessuna attività recente</p>
) : (
<ul className="divide-y divide-gray-50">
{activity.map((item, i) => (
<li key={i} className="px-5 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="text-base">{ACTIVITY_ICONS[item.type]}</span>
<div>
<p className="text-sm font-medium text-gray-800">{item.label}</p>
<p className="text-xs text-gray-500">{item.detail}</p>
</div>
</div>
<span className="text-xs text-gray-400 shrink-0">{fmt(item.timestamp)}</span>
</li>
))}
</ul>
)}
</div>
{/* ── SEZIONE STATISTICHE ANNUALI ── */}
<div className="mt-10 space-y-10">
{/* ── SEZIONE STATISTICHE ── */}
<div className="space-y-10">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-gray-900">Statistiche</h2>
@@ -208,6 +112,31 @@ export default async function AdminDashboard({
<YearSelector currentYear={year} availableYears={availableYears} />
</div>
{/* Panoramica corrente (non per-anno) */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Panoramica</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard label="Clienti attivi" value={String(kpi.clientiAttivi)} sub="Totale corrente" />
<MetricCard
label="Progetti in corso"
value={String(kpi.progettiInCorso)}
sub="Non archiviati"
/>
</div>
</div>
{/* Previsione incassi */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
Previsione incassi (12 mesi)
</h3>
<ForecastChart data={forecast} />
<p className="text-xs text-[#a1a1aa] italic">
Stima dalle offerte attive: i retainer contribuiscono il canone mensile, le una-tantum
ripartite sulla durata.
</p>
</div>
{/* Sezione economica */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>