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:
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
€{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{offer.accepted_total && (
|
||||
<span className="text-sm font-mono text-[#1a1a1a]">
|
||||
€{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+32
-103
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user