diff --git a/design-reference/DESIGN-SYSTEM.md b/design-reference/DESIGN-SYSTEM.md index 08a59bc..c5a16f4 100644 --- a/design-reference/DESIGN-SYSTEM.md +++ b/design-reference/DESIGN-SYSTEM.md @@ -148,6 +148,8 @@ across all future admin pages. | `LeadsViewToggle` | `src/components/admin/leads/LeadsViewToggle.tsx` | Wires `SearchInput` + `SegmentedToggle` together with the table/kanban views. | | `MilestoneStepper` | `src/components/client/MilestoneStepper.tsx` | Client-portal horizontal milestone stepper: one node per real phase, connecting line filled by `global_progress_pct`. Done = filled `bg-primary` ✓, active = `border-primary` outline, upcoming = `bg-muted`. | | `LeadDetail` | `src/components/admin/leads/LeadDetail.tsx` | Lead detail page (`/admin/pipeline/[id]`): bespoke header (name + inline `StatusBadge` + action bar), asymmetric `lg:grid-cols-3` layout (1/3 profile card w/ icon-chip rows + tags, 2/3 Note/Follow-up + unified timeline). | +| `ClientProfitability` | `src/components/admin/dashboard/ClientProfitability.tsx` | Dashboard widget: valore orario reale per cliente (`getClientProfitability` → contrattualizzato ÷ ore tracciate) con badge margine (Ottimo `bg-primary/10 text-primary` / In Linea `bg-muted`). | +| `YearSelector` | `src/components/admin/YearSelector.tsx` | Selettore anno restilizzato a pill (`rounded-full border-border bg-card shadow-card`), frecce ←/→, valore `font-mono`. Guida i dati year-scoped della dashboard via `?year=`. | **Unified timeline pattern**: activities + transcripts merged into one reverse-chronological "Storico delle Interazioni". Vertical connector = absolute @@ -157,6 +159,24 @@ expandable (`line-clamp-2` → "Mostra tutto/meno"); activity nodes are muted (`bg-muted border-border`). Dates are `font-mono` — activities relative (`formatDistanceToNow`), transcripts absolute (`d MMMM yyyy`). +### Admin Dashboard restyle notes + +The `/admin` dashboard was migrated to "Quiet Luxury" (applied 2026-07-11), +replicating `design-reference/pagina-dashboard`. It is a condensed single-screen +layout: no in-`
` `PageHeader` (sidebar + shell header carry the chrome). + +- **KPI strip**: 4 `bg-card` metric cards (Clienti Attivi · Contrattualizzato · + Incassato Reale · Ore Tracciate). Value `text-2xl font-bold font-mono`, label + `text-[10px] uppercase text-muted-foreground`. Positive delta uses + `text-emerald-600 dark:text-emerald-400`. +- **Griglia `lg:grid-cols-3`**: 2/3 col = `ForecastChart` (istogramma flussi 12 + mesi, mese prossimo evidenziato `bg-primary`) + `ClientProfitability`; 1/3 col = + `FollowUpWidget` (count pill amber) + `OffersSoldChart` ("Offerte Più Richieste"). +- **Year selector** interattivo (`YearSelector`) in una toolbar top-right; governa + i KPI year-scoped + redditività. Il forecast è rolling-12-mesi (non year-scoped). +- Widget rimossi dal legacy: chart incassi mensili, card "Da incassare"/"Clienti + acquisiti", barre "Ore per cliente" (sostituite dalla redditività oraria). + ### Client Portal restyle notes The `/client/[token]` portal was migrated to the same "Quiet Luxury" tokens diff --git a/design-reference/pagina-dashboard b/design-reference/pagina-dashboard new file mode 100644 index 0000000..a06ae0e --- /dev/null +++ b/design-reference/pagina-dashboard @@ -0,0 +1,347 @@ + + + + + + Dashboard — Luxury Admin CRM + + + + + + + + + + + + + + + +
+ + +
+
+ + + Panoramica Aziendale +
+
+ Admin Panel +
+
+ + +
+ + +
+ + +
+ Clienti Attivi +
+ 4 + +1 questo mese +
+
+ + +
+ Contrattualizzato +
+ 19.000€ + Totale anno 2026 +
+
+ + +
+ Incassato Reale +
+ 5.300€ + 28% del totale +
+
+ + +
+ Ore Tracciate +
+ 31.5h + Su progetti attivi +
+
+ +
+ + +
+ + +
+ + +
+
+
+

Previsione Flussi di Cassa (12 Mesi)

+

Stima basata su retainer attivi e saldi dilazionati

+
+ 2026 +
+ + +
+
+
+ Lug +
+
+
+ Ago +
+
+
+ Set +
+
+
+ Ott +
+
+
+ Nov +
+
+
+ Dic +
+
+
+ Gen +
+
+
+ Feb +
+
+
+ Mar +
+
+
+ Apr +
+
+
+ + +
+

Analisi Oraria & Redditività Clienti

+
+ +
+
+

Mario Rossi (Web Domination)

+

Tracciate: 30.0h — Valore Orario Target: €100/h

+
+
+ €233,33 /h Reali +

Margine Ottimo

+
+
+ +
+
+

Gianfranco Barban (Consulenza)

+

Tracciate: 1.5h — Valore Orario Target: €100/h

+
+
+ €133,33 /h Reali +

In Linea

+
+
+
+
+ +
+ + +
+ + +
+
+

Follow-up Attivi

+ 2 +
+ + +
+
+
+

Nome Cognome

+

Azienda Srl — Contattare

+
+ + Apri ➔ + +
+ +
+
+

Mario Rossi

+

Fissare Kickoff

+
+ + Apri ➔ + +
+
+
+ + +
+

Offerte Più Richieste

+
+
+
+ Web Domination (Tier B) + 1 +
+
+
+
+
+ +
+
+ Mantenimento (Tier A) + 1 +
+
+
+
+
+
+
+ +
+ +
+ +
+
+ + + + + \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 7d12564..eb43536 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3,16 +3,14 @@ import { getDashboardStats } from "@/lib/dashboard-queries"; import { FollowUpWidget } from "@/components/admin/dashboard/FollowUpWidget"; import { getAnalyticsByYear, - getMonthlyCollected, - getAvailableYears, - getTimeByClient, getTotalTrackedHours, + getClientProfitability, } from "@/lib/analytics-queries"; import { getRevenueForecast12Months, getOffersSoldBreakdown } from "@/lib/forecast-queries"; -import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector"; +import { YearSelector } from "@/components/admin/YearSelector"; import { ForecastChart } from "@/components/admin/ForecastChart"; import { OffersSoldChart } from "@/components/admin/OffersSoldChart"; -import { PageHeader } from "@/components/admin/PageHeader"; +import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability"; export const revalidate = 0; @@ -20,55 +18,45 @@ function MetricCard({ label, value, sub, - accent, + positive, }: { label: string; value: string; sub?: string; - accent?: boolean; + positive?: boolean; }) { return ( -
-

+

+ {label} -

-

- {value} -

- {sub && ( -

{sub}

- )} +
+
+ {value} + {sub && ( + + {sub} + + )} +
); } -function fmtEur(n: number) { +function fmtEur0(n: number) { return n.toLocaleString("it-IT", { style: "currency", currency: "EUR", - minimumFractionDigits: 2, + minimumFractionDigits: 0, + maximumFractionDigits: 0, }); } -function fmtSeconds(s: number): string { - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} - export default async function AdminDashboard({ searchParams, }: { @@ -79,155 +67,62 @@ export default async function AdminDashboard({ const { kpi } = await getDashboardStats(); - const [data, monthly, availableYears, timeByClient, totalHours, forecast, offersSold] = - await Promise.all([ - getAnalyticsByYear(year), - getMonthlyCollected(year), - getAvailableYears(), - getTimeByClient(year), - getTotalTrackedHours(year), - getRevenueForecast12Months(), - getOffersSoldBreakdown(), - ]); + const [data, totalHours, profitability, forecast, offersSold] = await Promise.all([ + getAnalyticsByYear(year), + getTotalTrackedHours(year), + getClientProfitability(year), + getRevenueForecast12Months(), + getOffersSoldBreakdown(), + ]); const collectedPct = data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0; - const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1; - return ( -
- - - {/* Follow-up Widget */} -
- }> - - +
+ {/* Toolbar: selettore anno (governa i KPI year-scoped + redditività) */} +
+
- {/* ── SEZIONE STATISTICHE ── */} -
-
-
-

Statistiche

-

Panoramica per anno

-
- -
+ {/* KPI strip */} +
+ + + 0} + /> + +
- {/* Panoramica corrente (non per-anno) */} -
-

Panoramica

-
- - -
-
- - {/* Previsione incassi */} -
-

- Previsione incassi (12 mesi) -

+ {/* Griglia contenuti: 2/3 + 1/3 */} +
+ {/* Colonna 2/3 */} +
-

- Stima dalle offerte attive: i retainer contribuiscono il canone mensile, le una-tantum - ripartite sulla durata. -

+
- {/* Offerte vendute */} -
-

- Offerte vendute -

+ {/* Colonna 1/3 */} +
+ } + > + +
- - {/* Sezione economica */} -
-

Fatturato

-
- - - - -
- - {data.contracted === 0 && ( -

- Nessun cliente registrato nel {year}. -

- )} -
- - {/* Sezione time tracking */} -
-

- Tempo tracciato -

-
- -
- - {timeByClient.length === 0 ? ( -
-

- Nessuna sessione registrata nel {year}. Usa il timer ▶ nella lista clienti per - iniziare. -

-
- ) : ( -
-

Ore per cliente — {year}

-
- {timeByClient.map((row) => { - const pct = Math.round((row.totalSeconds / maxClientSeconds) * 100); - return ( -
-
- - {row.clientName} - - - {fmtSeconds(row.totalSeconds)} - -
-
-
-
-
- ); - })} -
-
- )} -
); diff --git a/src/components/admin/ForecastChart.tsx b/src/components/admin/ForecastChart.tsx index 75683ec..6e979a2 100644 --- a/src/components/admin/ForecastChart.tsx +++ b/src/components/admin/ForecastChart.tsx @@ -15,22 +15,29 @@ export function ForecastChart({ data }: { data: ForecastMonth[] }) { const nextMonth = data[1]; return ( -
- {nextMonth && ( -
-
-

- Previsto il mese prossimo -

-

{nextMonth.label}

-
-

- {fmtEur(nextMonth.total)} +

+
+
+

+ Previsione Flussi di Cassa (12 Mesi) +

+

+ Stima basata su retainer attivi e saldi dilazionati

- )} + {nextMonth && ( +
+

+ {fmtEur(nextMonth.total)} +

+

+ Previsto {nextMonth.label} +

+
+ )} +
-
+
{data.map((m, i) => { const pct = Math.round((m.total / max) * 100); const isNext = i === 1; @@ -39,7 +46,7 @@ export function ForecastChart({ data }: { data: ForecastMonth[] }) {
0 ? "4px" : "0" }} title={`${m.label}: ${m.total.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })}`} @@ -47,7 +54,7 @@ export function ForecastChart({ data }: { data: ForecastMonth[] }) {
{m.label.split(" ")[0]} diff --git a/src/components/admin/OffersSoldChart.tsx b/src/components/admin/OffersSoldChart.tsx index 3f49bca..20e1153 100644 --- a/src/components/admin/OffersSoldChart.tsx +++ b/src/components/admin/OffersSoldChart.tsx @@ -5,39 +5,41 @@ export function OffersSoldChart({ data }: { data: OffersSoldBreakdown }) { if (data.rows.length === 0) { return ( -
-

Nessuna offerta venduta finora.

+
+

Nessuna offerta venduta finora.

); } return ( -
+
-

Offerte vendute

- +

+ Offerte Più Richieste +

+ {data.total}
-
+
{data.rows.map((row, i) => { const pct = Math.round((row.count / max) * 100); return (
- + {row.offer_name} {row.tier_letter && ( - + Tier {row.tier_letter} )} - {row.count} + {row.count}
-
+
diff --git a/src/components/admin/YearSelector.tsx b/src/components/admin/YearSelector.tsx index 82bd276..5757ad2 100644 --- a/src/components/admin/YearSelector.tsx +++ b/src/components/admin/YearSelector.tsx @@ -2,15 +2,7 @@ import { useRouter } from "next/navigation"; -const MONTHS = ["Gen", "Feb", "Mar", "Apr", "Mag", "Giu", "Lug", "Ago", "Set", "Ott", "Nov", "Dic"]; - -export function YearSelector({ - currentYear, - availableYears, -}: { - currentYear: number; - availableYears: number[]; -}) { +export function YearSelector({ currentYear }: { currentYear: number }) { const router = useRouter(); const thisYear = new Date().getFullYear(); @@ -19,52 +11,25 @@ export function YearSelector({ } return ( -
+
- + {currentYear}
); -} - -export function MonthlyChart({ data, year }: { data: number[]; year: number }) { - const max = Math.max(...data, 1); - - return ( -
-

Incassato mese per mese — {year}

-
- {data.map((val, i) => { - const pct = Math.round((val / max) * 100); - return ( -
-
-
0 ? "4px" : "0" }} - title={`€ ${val.toLocaleString("it-IT", { minimumFractionDigits: 2 })}`} - /> -
- {MONTHS[i]} -
- ); - })} -
-
- ); } \ No newline at end of file diff --git a/src/components/admin/dashboard/ClientProfitability.tsx b/src/components/admin/dashboard/ClientProfitability.tsx new file mode 100644 index 0000000..cbf5137 --- /dev/null +++ b/src/components/admin/dashboard/ClientProfitability.tsx @@ -0,0 +1,68 @@ +import type { ClientProfitabilityRow } from "@/lib/analytics-queries"; + +function fmtRate(n: number) { + return n.toLocaleString("it-IT", { + style: "currency", + currency: "EUR", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +const MARGIN_LABEL: Record = { + ottimo: "Margine Ottimo", + in_linea: "In Linea", + sotto: "Sotto Target", +}; + +export function ClientProfitability({ data }: { data: ClientProfitabilityRow[] }) { + return ( +
+

+ Analisi Oraria & Redditività Clienti +

+ + {data.length === 0 ? ( +

+ Nessuna sessione tracciata per calcolare la redditività. +

+ ) : ( +
+ {data.map((row) => { + const isOttimo = row.margin === "ottimo"; + return ( +
+
+

+ {row.clientName} +

+

+ Tracciate:{" "} + {row.hours.toLocaleString("it-IT")}h — Valore + Orario Target:{" "} + {row.target}€/h +

+
+
+ + {fmtRate(row.realRate)} /h Reali + +

{MARGIN_LABEL[row.margin]}

+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/admin/dashboard/FollowUpWidget.tsx b/src/components/admin/dashboard/FollowUpWidget.tsx index e8fefd5..dd3e726 100644 --- a/src/components/admin/dashboard/FollowUpWidget.tsx +++ b/src/components/admin/dashboard/FollowUpWidget.tsx @@ -25,11 +25,13 @@ export async function FollowUpWidget() { if (leads.length === 0) { return ( -
+
-

Follow-up

+

+ Follow-up Attivi +

-

Tutti i lead sono aggiornati ✓

+

Tutti i lead sono aggiornati ✓

); } @@ -37,37 +39,39 @@ export async function FollowUpWidget() { const visible = leads.slice(0, MAX_ROWS); return ( -
+
{/* Header */} -
+
-

Follow-up

- +

+ Follow-up Attivi +

+ {leads.length}
Vedi tutti →
{/* Lead rows */} -
    +
      {visible.map((lead) => (
    • - + {initials(lead.name)}
      -

      {lead.name}

      -

      +

      {lead.name}

      +

      {lead.next_action ? lead.next_action : [lead.company, relativeDays(lead.last_contact_date)] @@ -75,7 +79,7 @@ export async function FollowUpWidget() { .join(" · ")}

      - + Contatta → @@ -86,7 +90,7 @@ export async function FollowUpWidget() { {leads.length > MAX_ROWS && ( Vedi tutti i {leads.length} lead diff --git a/src/lib/analytics-queries.ts b/src/lib/analytics-queries.ts index d540141..378a833 100644 --- a/src/lib/analytics-queries.ts +++ b/src/lib/analytics-queries.ts @@ -131,4 +131,64 @@ export async function getTotalTrackedHours(year: number): Promise { and extract(year from ${time_entries.started_at}) = ${year}` ); return Math.round(parseInt(row?.total ?? "0") / 3600 * 10) / 10; +} + +// ── Redditività oraria per cliente ────────────────────────────────────────── + +/** Valore orario "target" di riferimento (€/h) — costante di consulenza. */ +export const TARGET_HOURLY = 100; + +export type ProfitabilityMargin = "ottimo" | "in_linea" | "sotto"; + +export type ClientProfitabilityRow = { + clientId: string; + clientName: string; + hours: number; // ore tracciate (anno), 1 decimale + contracted: number; // totale contrattualizzato del cliente (accepted_total dei progetti) + realRate: number; // contracted / hours, arrotondato a 2 decimali + target: number; // TARGET_HOURLY + margin: ProfitabilityMargin; +}; + +/** + * Redditività oraria reale per cliente: contrattualizzato ÷ ore tracciate (anno). + * Solo clienti con ore tracciate > 0 (serve il denominatore). Ordinati per realRate desc. + */ +export async function getClientProfitability(year: number): Promise { + const timeRows = await getTimeByClient(year); + if (timeRows.length === 0) return []; + + const clientIds = timeRows.map((r) => r.clientId); + + // Contrattualizzato per cliente = somma accepted_total dei suoi progetti. + const contractRows = await db + .select({ + client_id: projects.client_id, + total: sql`coalesce(sum(${projects.accepted_total}::numeric), 0)`, + }) + .from(projects) + .where(inArray(projects.client_id, clientIds)) + .groupBy(projects.client_id); + const contractedMap = new Map( + contractRows.map((r) => [r.client_id, parseFloat(r.total)]) + ); + + return timeRows + .map((row) => { + const hours = Math.round((row.totalSeconds / 3600) * 10) / 10; + const contracted = contractedMap.get(row.clientId) ?? 0; + const realRate = hours > 0 ? Math.round((contracted / hours) * 100) / 100 : 0; + const margin: ProfitabilityMargin = + realRate >= TARGET_HOURLY * 2 ? "ottimo" : realRate >= TARGET_HOURLY ? "in_linea" : "sotto"; + return { + clientId: row.clientId, + clientName: row.clientName, + hours, + contracted, + realRate, + target: TARGET_HOURLY, + margin, + }; + }) + .sort((a, b) => b.realRate - a.realRate); } \ No newline at end of file