Files
clienthub/src/lib/analytics-queries.ts
T
simone 08aadc1d97 feat: Dashboard admin redesign to Quiet Luxury + client profitability widget
Replica il mock design-reference/pagina-dashboard: layout condensato a
schermata singola (4 KPI + griglia 2/3-1/3), token semantici per dual-theme.

- Nuova query getClientProfitability(year): valore orario reale per cliente
  (contrattualizzato / ore tracciate) con badge margine, target 100 €/h
- Nuovo widget ClientProfitability "Analisi Oraria & Redditività Clienti"
- Rewrite /admin: KPI strip + Cashflow + Redditività | Follow-up + Offerte
  Più Richieste. Rimossi chart mensile, card extra, barre ore/cliente
- Tokenizzati ForecastChart, OffersSoldChart, FollowUpWidget, YearSelector
  (ora pill interattiva); rimosso MonthlyChart e prop availableYears inutile
- DESIGN-SYSTEM.md: inventario + note dashboard (applied 2026-07-11)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:57:10 +02:00

194 lines
6.9 KiB
TypeScript

import { db } from "@/db";
import { clients, payments, time_entries, projects } from "@/db/schema";
import { sql, and, eq, inArray } from "drizzle-orm";
export async function getAnalyticsByYear(year: number) {
const [contracted] = await db
.select({ total: sql<string>`coalesce(sum(${projects.accepted_total}::numeric), 0)` })
.from(projects)
.innerJoin(clients, eq(projects.client_id, clients.id))
.where(sql`extract(year from ${clients.created_at}) = ${year}`);
const [clientsRow] = await db
.select({ count: sql<string>`count(*)` })
.from(clients)
.where(sql`extract(year from ${clients.created_at}) = ${year}`);
const [collected] = await db
.select({ total: sql<string>`coalesce(sum(${payments.amount}::numeric), 0)` })
.from(payments)
.where(
and(
eq(payments.status, "saldato"),
sql`${payments.paid_at} is not null and extract(year from ${payments.paid_at}) = ${year}`
)
);
const [pending] = await db
.select({ total: sql<string>`coalesce(sum(${payments.amount}::numeric), 0)` })
.from(payments)
.where(sql`${payments.status} in ('da_saldare', 'inviata')`);
return {
contracted: parseFloat(contracted?.total ?? "0"),
collected: parseFloat(collected?.total ?? "0"),
clientsAcquired: parseInt(clientsRow?.count ?? "0"),
pending: parseFloat(pending?.total ?? "0"),
};
}
export async function getMonthlyCollected(year: number): Promise<number[]> {
const rows = await db
.select({
month: sql<number>`extract(month from ${payments.paid_at})::int`,
total: sql<string>`coalesce(sum(${payments.amount}::numeric), 0)`,
})
.from(payments)
.where(
and(
eq(payments.status, "saldato"),
sql`${payments.paid_at} is not null and extract(year from ${payments.paid_at}) = ${year}`
)
)
.groupBy(sql`extract(month from ${payments.paid_at})`);
const byMonth: number[] = Array(12).fill(0);
for (const row of rows) {
byMonth[(row.month as number) - 1] = parseFloat(row.total);
}
return byMonth;
}
export async function getAvailableYears(): Promise<number[]> {
const rows = await db
.select({ year: sql<number>`extract(year from ${clients.created_at})::int` })
.from(clients)
.groupBy(sql`extract(year from ${clients.created_at})`);
const years = rows.map((r) => r.year as number);
const currentYear = new Date().getFullYear();
if (!years.includes(currentYear)) years.push(currentYear);
return years.sort((a, b) => b - a);
}
// ── Time tracking ─────────────────────────────────────────────────────────────
export type ClientTimeRow = {
clientId: string;
clientName: string;
totalSeconds: number;
};
export async function getTimeByClient(year: number): Promise<ClientTimeRow[]> {
const rows = await db
.select({
project_id: time_entries.project_id,
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
})
.from(time_entries)
.where(
sql`${time_entries.ended_at} is not null
and extract(year from ${time_entries.started_at}) = ${year}`
)
.groupBy(time_entries.project_id);
if (rows.length === 0) return [];
// Map project_id → client_id
const projectIds = rows.map((r) => r.project_id);
const projectRows = await db
.select({ id: projects.id, client_id: projects.client_id })
.from(projects)
.where(inArray(projects.id, projectIds));
const projectToClient = new Map(projectRows.map((p) => [p.id, p.client_id]));
const allClients = await db.select({ id: clients.id, name: clients.name }).from(clients);
const nameMap = new Map(allClients.map((c) => [c.id, c.name]));
// Aggregate by client_id (a client may have multiple projects)
const clientTotals = new Map<string, number>();
for (const row of rows) {
const clientId = projectToClient.get(row.project_id);
if (!clientId) continue;
clientTotals.set(clientId, (clientTotals.get(clientId) ?? 0) + parseInt(row.total));
}
return [...clientTotals.entries()]
.map(([clientId, totalSeconds]) => ({
clientId,
clientName: nameMap.get(clientId) ?? clientId,
totalSeconds,
}))
.sort((a, b) => b.totalSeconds - a.totalSeconds);
}
export async function getTotalTrackedHours(year: number): Promise<number> {
const [row] = await db
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
.from(time_entries)
.where(
sql`${time_entries.ended_at} is not null
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<ClientProfitabilityRow[]> {
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<string>`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);
}