style: rifinitura Quiet Luxury dashboard — bordi soft, badge, colori
Allinea la dashboard alla rifinitura del mock (finezza bordi/colori/badge): - Card su border-border-light (slate-100) per il bordo sottile - KPI: valore verde su Incassato Reale, delta verde "+N questo mese" su Clienti Attivi (nuova metrica getDashboardStats.clientiNuoviMese) - Redditività: badge "Ottimo" con tint emerald soft (non brand primary) - Follow-up: righe come box bordati (no avatar), link "Apri →" primary - Forecast: selettore anno (pill interattiva) spostato nell'header della card, barre non-picco grigie (bg-border), rimosso il numero "mese prossimo" - Offerte Più Richieste: rimossa la pill totale nell'header Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+26
-28
@@ -17,32 +17,34 @@ export const revalidate = 0;
|
|||||||
function MetricCard({
|
function MetricCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
valueAccent,
|
||||||
|
delta,
|
||||||
sub,
|
sub,
|
||||||
positive,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
valueAccent?: boolean;
|
||||||
|
delta?: string;
|
||||||
sub?: string;
|
sub?: string;
|
||||||
positive?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card p-5 rounded-xl border border-border shadow-card">
|
<div className="bg-card p-5 rounded-xl border border-border-light shadow-card">
|
||||||
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
|
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-baseline gap-2 mt-1">
|
<div className="flex items-baseline gap-2 mt-1">
|
||||||
<span className="text-2xl font-bold text-foreground font-mono">{value}</span>
|
<span
|
||||||
{sub && (
|
className={`text-2xl font-bold font-mono ${
|
||||||
<span
|
valueAccent ? "text-emerald-700 dark:text-emerald-400" : "text-foreground"
|
||||||
className={`text-xs ${
|
}`}
|
||||||
positive
|
>
|
||||||
? "font-medium text-emerald-600 dark:text-emerald-400"
|
{value}
|
||||||
: "text-muted-foreground"
|
</span>
|
||||||
}`}
|
{delta ? (
|
||||||
>
|
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{delta}</span>
|
||||||
{sub}
|
) : sub ? (
|
||||||
</span>
|
<span className="text-xs text-muted-foreground">{sub}</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -80,14 +82,14 @@ export default async function AdminDashboard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
{/* Toolbar: selettore anno (governa i KPI year-scoped + redditività) */}
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<YearSelector currentYear={year} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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 label="Clienti Attivi" value={String(kpi.clientiAttivi)} sub="Totale corrente" />
|
<MetricCard
|
||||||
|
label="Clienti Attivi"
|
||||||
|
value={String(kpi.clientiAttivi)}
|
||||||
|
delta={kpi.clientiNuoviMese > 0 ? `+${kpi.clientiNuoviMese} questo mese` : undefined}
|
||||||
|
sub="Totale corrente"
|
||||||
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Contrattualizzato"
|
label="Contrattualizzato"
|
||||||
value={fmtEur0(data.contracted)}
|
value={fmtEur0(data.contracted)}
|
||||||
@@ -96,21 +98,17 @@ export default async function AdminDashboard({
|
|||||||
<MetricCard
|
<MetricCard
|
||||||
label="Incassato Reale"
|
label="Incassato Reale"
|
||||||
value={fmtEur0(data.collected)}
|
value={fmtEur0(data.collected)}
|
||||||
|
valueAccent={data.collected > 0}
|
||||||
sub={`${collectedPct}% del totale`}
|
sub={`${collectedPct}% del totale`}
|
||||||
positive={data.collected > 0}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Ore Tracciate"
|
|
||||||
value={`${totalHours}h`}
|
|
||||||
sub="Su progetti attivi"
|
|
||||||
/>
|
/>
|
||||||
|
<MetricCard label="Ore Tracciate" value={`${totalHours}h`} sub="Su progetti attivi" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Griglia contenuti: 2/3 + 1/3 */}
|
{/* Griglia contenuti: 2/3 + 1/3 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
{/* 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} />
|
<ForecastChart data={forecast} headerAction={<YearSelector currentYear={year} />} />
|
||||||
<ClientProfitability data={profitability} />
|
<ClientProfitability data={profitability} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import type { ForecastMonth } from "@/lib/forecast-queries";
|
import type { ForecastMonth } from "@/lib/forecast-queries";
|
||||||
|
|
||||||
function fmtEur(n: number) {
|
export function ForecastChart({
|
||||||
return n.toLocaleString("it-IT", {
|
data,
|
||||||
style: "currency",
|
headerAction,
|
||||||
currency: "EUR",
|
}: {
|
||||||
minimumFractionDigits: 0,
|
data: ForecastMonth[];
|
||||||
maximumFractionDigits: 0,
|
headerAction?: ReactNode;
|
||||||
});
|
}) {
|
||||||
}
|
|
||||||
|
|
||||||
export function ForecastChart({ data }: { data: ForecastMonth[] }) {
|
|
||||||
const max = Math.max(...data.map((d) => d.total), 1);
|
const max = Math.max(...data.map((d) => d.total), 1);
|
||||||
// index 0 = mese corrente, index 1 = mese prossimo (evidenziato)
|
// index 1 = mese prossimo (evidenziato)
|
||||||
const nextMonth = data[1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-xl border border-border shadow-card p-6 space-y-6">
|
<div className="bg-card rounded-xl border border-border-light shadow-card p-6">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
Previsione Flussi di Cassa (12 Mesi)
|
Previsione Flussi di Cassa (12 Mesi)
|
||||||
@@ -25,28 +22,19 @@ export function ForecastChart({ data }: { data: ForecastMonth[] }) {
|
|||||||
Stima basata su retainer attivi e saldi dilazionati
|
Stima basata su retainer attivi e saldi dilazionati
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{nextMonth && (
|
{headerAction}
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-2xl font-bold tracking-tight text-primary font-mono">
|
|
||||||
{fmtEur(nextMonth.total)}
|
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5 capitalize">
|
|
||||||
Previsto {nextMonth.label}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end gap-2 h-44 border-t border-border pt-4">
|
<div className="flex items-end gap-2 h-48 border-b border-border-light pt-4">
|
||||||
{data.map((m, i) => {
|
{data.map((m, i) => {
|
||||||
const pct = Math.round((m.total / max) * 100);
|
const pct = Math.round((m.total / max) * 100);
|
||||||
const isNext = i === 1;
|
const isNext = i === 1;
|
||||||
return (
|
return (
|
||||||
<div key={`${m.year}-${m.month}`} className="flex-1 flex flex-col items-center gap-1">
|
<div key={`${m.year}-${m.month}`} className="flex-1 flex flex-col items-center gap-2">
|
||||||
<div className="w-full flex flex-col justify-end" style={{ height: "120px" }}>
|
<div className="w-full flex flex-col justify-end" style={{ height: "150px" }}>
|
||||||
<div
|
<div
|
||||||
className={`w-full rounded-t-md transition-all ${
|
className={`w-full rounded-t transition-all ${
|
||||||
isNext ? "bg-primary" : "bg-primary/35"
|
isNext ? "bg-primary" : "bg-border hover:bg-primary/50"
|
||||||
}`}
|
}`}
|
||||||
style={{ height: `${pct}%`, minHeight: m.total > 0 ? "4px" : "0" }}
|
style={{ height: `${pct}%`, minHeight: m.total > 0 ? "4px" : "0" }}
|
||||||
title={`${m.label}: ${m.total.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })}`}
|
title={`${m.label}: ${m.total.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })}`}
|
||||||
@@ -54,7 +42,7 @@ export function ForecastChart({ data }: { data: ForecastMonth[] }) {
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] capitalize ${
|
className={`text-[10px] capitalize ${
|
||||||
isNext ? "font-bold text-primary" : "text-muted-foreground"
|
isNext ? "font-bold text-foreground" : "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{m.label.split(" ")[0]}
|
{m.label.split(" ")[0]}
|
||||||
|
|||||||
@@ -5,22 +5,17 @@ export function OffersSoldChart({ data }: { data: OffersSoldBreakdown }) {
|
|||||||
|
|
||||||
if (data.rows.length === 0) {
|
if (data.rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-xl border border-border shadow-card p-8 text-center">
|
<div className="bg-card rounded-xl border border-border-light shadow-card p-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground italic">Nessuna offerta venduta finora.</p>
|
<p className="text-sm text-muted-foreground italic">Nessuna offerta venduta finora.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-xl border border-border shadow-card p-5 space-y-4">
|
<div className="bg-card rounded-xl border border-border-light shadow-card p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
Offerte Più Richieste
|
||||||
Offerte Più Richieste
|
</h3>
|
||||||
</h3>
|
|
||||||
<span className="inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full bg-primary text-primary-foreground text-xs font-bold font-mono">
|
|
||||||
{data.total}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.rows.map((row, i) => {
|
{data.rows.map((row, i) => {
|
||||||
const pct = Math.round((row.count / max) * 100);
|
const pct = Math.round((row.count / max) * 100);
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export function YearSelector({ currentYear }: { currentYear: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-1 rounded-full border border-border bg-card px-1 py-1 shadow-card">
|
<div className="inline-flex items-center gap-0.5 rounded-lg border border-border-light bg-muted/50 px-1 py-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => go(currentYear - 1)}
|
onClick={() => go(currentYear - 1)}
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-primary transition-colors"
|
className="w-5 h-5 rounded flex items-center justify-center text-muted-foreground hover:text-primary transition-colors"
|
||||||
aria-label="Anno precedente"
|
aria-label="Anno precedente"
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
@@ -25,7 +25,7 @@ export function YearSelector({ currentYear }: { currentYear: number }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => go(currentYear + 1)}
|
onClick={() => go(currentYear + 1)}
|
||||||
disabled={currentYear >= thisYear}
|
disabled={currentYear >= thisYear}
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-primary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
className="w-5 h-5 rounded flex items-center justify-center text-muted-foreground hover:text-primary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
aria-label="Anno successivo"
|
aria-label="Anno successivo"
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const MARGIN_LABEL: Record<ClientProfitabilityRow["margin"], string> = {
|
|||||||
|
|
||||||
export function ClientProfitability({ data }: { data: ClientProfitabilityRow[] }) {
|
export function ClientProfitability({ data }: { data: ClientProfitabilityRow[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-xl border border-border shadow-card p-6">
|
<div className="bg-card rounded-xl border border-border-light shadow-card p-6">
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-4">
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-4">
|
||||||
Analisi Oraria & Redditività Clienti
|
Analisi Oraria & Redditività Clienti
|
||||||
</h3>
|
</h3>
|
||||||
@@ -33,7 +33,7 @@ export function ClientProfitability({ data }: { data: ClientProfitabilityRow[] }
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.clientId}
|
key={row.clientId}
|
||||||
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border bg-muted/30"
|
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border-light bg-muted/30"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h4 className="text-xs font-semibold text-foreground truncate">
|
<h4 className="text-xs font-semibold text-foreground truncate">
|
||||||
@@ -50,7 +50,7 @@ export function ClientProfitability({ data }: { data: ClientProfitabilityRow[] }
|
|||||||
<span
|
<span
|
||||||
className={`text-xs font-bold font-mono px-2 py-0.5 rounded border ${
|
className={`text-xs font-bold font-mono px-2 py-0.5 rounded border ${
|
||||||
isOttimo
|
isOttimo
|
||||||
? "text-primary bg-primary/10 border-primary/20"
|
? "text-emerald-700 bg-emerald-50 border-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20"
|
||||||
: "text-foreground bg-muted border-border"
|
: "text-foreground bg-muted border-border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,13 +3,6 @@ import Link from "next/link";
|
|||||||
|
|
||||||
const MAX_ROWS = 4;
|
const MAX_ROWS = 4;
|
||||||
|
|
||||||
function initials(name: string): string {
|
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length === 0) return "?";
|
|
||||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
||||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function relativeDays(date: Date | string | null | undefined): string | null {
|
function relativeDays(date: Date | string | null | undefined): string | null {
|
||||||
if (!date) return "Mai contattato";
|
if (!date) return "Mai contattato";
|
||||||
const d = date instanceof Date ? date : new Date(date);
|
const d = date instanceof Date ? date : new Date(date);
|
||||||
@@ -25,13 +18,11 @@ export async function FollowUpWidget() {
|
|||||||
|
|
||||||
if (leads.length === 0) {
|
if (leads.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-xl border border-border shadow-card px-5 py-4">
|
<div className="bg-card rounded-xl border border-border-light shadow-card p-5">
|
||||||
<div className="flex items-center gap-2">
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
Follow-up Attivi
|
||||||
Follow-up Attivi
|
</h3>
|
||||||
</h2>
|
<p className="text-sm text-muted-foreground mt-3">Tutti i lead sono aggiornati ✓</p>
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">Tutti i lead sono aggiornati ✓</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,58 +30,43 @@ export async function FollowUpWidget() {
|
|||||||
const visible = leads.slice(0, MAX_ROWS);
|
const visible = leads.slice(0, MAX_ROWS);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-xl border border-border shadow-card overflow-hidden">
|
<div className="bg-card rounded-xl border border-border-light shadow-card p-5">
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border">
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
<div className="flex items-center gap-2.5">
|
Follow-up Attivi
|
||||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
</h3>
|
||||||
Follow-up Attivi
|
<span className="text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 px-1.5 py-0.5 rounded-full dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20">
|
||||||
</h2>
|
{leads.length}
|
||||||
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-amber-50 text-amber-700 border border-amber-100 text-[11px] font-bold dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20">
|
</span>
|
||||||
{leads.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/admin/pipeline"
|
|
||||||
className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
Vedi tutti →
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lead rows */}
|
<div className="space-y-3">
|
||||||
<ul className="divide-y divide-border">
|
{visible.map((lead) => {
|
||||||
{visible.map((lead) => (
|
const subtitle = lead.next_action
|
||||||
<li key={lead.id}>
|
? lead.next_action
|
||||||
|
: [lead.company, relativeDays(lead.last_contact_date)].filter(Boolean).join(" — ");
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
key={lead.id}
|
||||||
href={`/admin/pipeline/${lead.id}`}
|
href={`/admin/pipeline/${lead.id}`}
|
||||||
className="flex items-center gap-3 px-5 py-3 hover:bg-muted/40 transition-colors group"
|
className="flex items-center justify-between gap-3 p-3 border border-border-light rounded-lg hover:border-border transition-colors group"
|
||||||
>
|
>
|
||||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-bold">
|
<div className="min-w-0">
|
||||||
{initials(lead.name)}
|
<h4 className="text-xs font-bold text-foreground truncate">{lead.name}</h4>
|
||||||
</span>
|
<p className="text-[10px] text-muted-foreground truncate">{subtitle}</p>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-semibold text-foreground truncate">{lead.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{lead.next_action
|
|
||||||
? lead.next_action
|
|
||||||
: [lead.company, relativeDays(lead.last_contact_date)]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" · ")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium text-muted-foreground group-hover:text-primary transition-colors shrink-0">
|
<span className="text-[11px] font-bold text-primary group-hover:opacity-70 transition-opacity shrink-0 inline-flex items-center gap-0.5">
|
||||||
Contatta →
|
Apri →
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
);
|
||||||
))}
|
})}
|
||||||
</ul>
|
</div>
|
||||||
|
|
||||||
{leads.length > MAX_ROWS && (
|
{leads.length > MAX_ROWS && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin/pipeline"
|
href="/admin/pipeline"
|
||||||
className="block px-5 py-2.5 text-center text-xs font-medium text-muted-foreground hover:text-primary border-t border-border transition-colors"
|
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 {leads.length} lead
|
Vedi tutti i {leads.length} lead
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, projects, payments } from "@/db/schema";
|
import { clients, projects, payments } from "@/db/schema";
|
||||||
import { eq, sum, count } from "drizzle-orm";
|
import { eq, sum, count, sql } from "drizzle-orm";
|
||||||
|
|
||||||
export type KpiStats = {
|
export type KpiStats = {
|
||||||
clientiAttivi: number;
|
clientiAttivi: number;
|
||||||
|
clientiNuoviMese: number; // clients created in the current calendar month
|
||||||
revenueTotale: string; // sum of projects.accepted_total (non-archived)
|
revenueTotale: string; // sum of projects.accepted_total (non-archived)
|
||||||
pagamentiInSospeso: string; // sum of payments.amount where status = 'da_saldare' or 'inviata'
|
pagamentiInSospeso: string; // sum of payments.amount where status = 'da_saldare' or 'inviata'
|
||||||
progettiInCorso: number; // projects not archived
|
progettiInCorso: number; // projects not archived
|
||||||
@@ -20,6 +21,13 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.archived, false));
|
.where(eq(clients.archived, false));
|
||||||
|
|
||||||
|
const [clientiNuoviMeseRow] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
sql`date_trunc('month', ${clients.created_at}) = date_trunc('month', now())`
|
||||||
|
);
|
||||||
|
|
||||||
const [revenueRow] = await db
|
const [revenueRow] = await db
|
||||||
.select({ total: sum(projects.accepted_total) })
|
.select({ total: sum(projects.accepted_total) })
|
||||||
.from(projects)
|
.from(projects)
|
||||||
@@ -46,6 +54,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|||||||
|
|
||||||
const kpi: KpiStats = {
|
const kpi: KpiStats = {
|
||||||
clientiAttivi: clientiAttiviRow?.count ?? 0,
|
clientiAttivi: clientiAttiviRow?.count ?? 0,
|
||||||
|
clientiNuoviMese: clientiNuoviMeseRow?.count ?? 0,
|
||||||
revenueTotale: revenueRow?.total ?? "0",
|
revenueTotale: revenueRow?.total ?? "0",
|
||||||
pagamentiInSospeso: pagamentiSospeso.toFixed(2),
|
pagamentiInSospeso: pagamentiSospeso.toFixed(2),
|
||||||
progettiInCorso: progettiRow?.count ?? 0,
|
progettiInCorso: progettiRow?.count ?? 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user