0f48570cd7
Schema: - clients.archived boolean (default false) - time_entries table (client_id, started_at, ended_at, duration_seconds) Client management: - /admin/clients/[id]/edit — form pre-compilato con nome, brand, brief - ClientActions: Modifica / Archivia / Elimina con doppia conferma - setClientArchived: toggle archiviazione senza perdere dati - deleteClient: elimina con cascade, redirect a /admin - Admin list: toggle "Mostra archiviati" via ?archived=1, righe archiviate opache Time tracker: - startTimer: crea sessione, ferma automaticamente quella precedente - stopTimer: chiude sessione, calcola duration_seconds - TimerCell: ▶/⏹ per ogni cliente, contatore live in secondi, totale cumulativo - Una sola sessione attiva alla volta su tutta la lista Analytics: - Sezione "Fatturato" (invariata) + sezione "Tempo tracciato" separata - Ore totali per anno + barre orizzontali per cliente - getTotalTrackedHours, getTimeByClient queries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
163 lines
5.4 KiB
TypeScript
163 lines
5.4 KiB
TypeScript
import {
|
|
getAnalyticsByYear,
|
|
getMonthlyCollected,
|
|
getAvailableYears,
|
|
getTimeByClient,
|
|
getTotalTrackedHours,
|
|
} from "@/lib/analytics-queries";
|
|
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
|
|
|
|
export const revalidate = 0;
|
|
|
|
function fmtEur(n: number) {
|
|
return n.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 });
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
function MetricCard({
|
|
label, value, sub, accent,
|
|
}: {
|
|
label: string; value: string; sub?: string; accent?: boolean;
|
|
}) {
|
|
return (
|
|
<div className={`rounded-xl border p-5 ${accent ? "bg-[#1A463C] border-[#1A463C] text-white" : "bg-white border-[#e5e7eb]"}`}>
|
|
<p className={`text-xs font-bold uppercase tracking-wider mb-2 ${accent ? "text-white/60" : "text-[#71717a]"}`}>
|
|
{label}
|
|
</p>
|
|
<p className={`text-2xl font-bold tracking-tight ${accent ? "text-white" : "text-[#1a1a1a]"}`}>
|
|
{value}
|
|
</p>
|
|
{sub && (
|
|
<p className={`text-xs mt-1 ${accent ? "text-white/60" : "text-[#71717a]"}`}>{sub}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default async function AnalyticsPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ year?: string }>;
|
|
}) {
|
|
const { year: yearParam } = await searchParams;
|
|
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
|
|
|
|
const [data, monthly, availableYears, timeByClient, totalHours] = await Promise.all([
|
|
getAnalyticsByYear(year),
|
|
getMonthlyCollected(year),
|
|
getAvailableYears(),
|
|
getTimeByClient(year),
|
|
getTotalTrackedHours(year),
|
|
]);
|
|
|
|
const collectedPct =
|
|
data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
|
|
|
|
const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1;
|
|
|
|
return (
|
|
<div className="space-y-10">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Statistiche</h1>
|
|
<p className="text-sm text-[#71717a] mt-0.5">Panoramica per anno</p>
|
|
</div>
|
|
<YearSelector currentYear={year} availableYears={availableYears} />
|
|
</div>
|
|
|
|
{/* ── SEZIONE ECONOMICA ── */}
|
|
<div className="space-y-4">
|
|
<h2 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h2>
|
|
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<MetricCard
|
|
label="Contrattualizzato"
|
|
value={fmtEur(data.contracted)}
|
|
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"}`}
|
|
accent
|
|
/>
|
|
<MetricCard
|
|
label="Incassato"
|
|
value={fmtEur(data.collected)}
|
|
sub={`${collectedPct}% del contrattualizzato`}
|
|
/>
|
|
<MetricCard
|
|
label="Da incassare"
|
|
value={fmtEur(data.pending)}
|
|
sub="Tutti gli anni"
|
|
/>
|
|
<MetricCard
|
|
label="Clienti acquisiti"
|
|
value={String(data.clientsAcquired)}
|
|
sub={`Anno ${year}`}
|
|
/>
|
|
</div>
|
|
|
|
<MonthlyChart data={monthly} year={year} />
|
|
|
|
{data.contracted === 0 && (
|
|
<p className="text-sm text-[#71717a] italic text-center py-2">
|
|
Nessun cliente registrato nel {year}.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── SEZIONE TIME TRACKING ── */}
|
|
<div className="space-y-4">
|
|
<h2 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
|
|
Tempo tracciato
|
|
</h2>
|
|
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<MetricCard
|
|
label="Ore totali"
|
|
value={`${totalHours}h`}
|
|
sub={`Anno ${year}`}
|
|
accent
|
|
/>
|
|
</div>
|
|
|
|
{timeByClient.length === 0 ? (
|
|
<div className="bg-white rounded-xl border border-[#e5e7eb] p-8 text-center">
|
|
<p className="text-sm text-[#71717a] italic">
|
|
Nessuna sessione registrata nel {year}.
|
|
Usa il timer ▶ nella lista clienti per iniziare.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-4">
|
|
<h3 className="text-sm font-bold text-[#1a1a1a]">Ore per cliente — {year}</h3>
|
|
<div className="space-y-3">
|
|
{timeByClient.map((row) => {
|
|
const pct = Math.round((row.totalSeconds / maxClientSeconds) * 100);
|
|
return (
|
|
<div key={row.clientId}>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm font-medium text-[#1a1a1a]">{row.clientName}</span>
|
|
<span className="text-sm tabular-nums text-[#71717a]">
|
|
{fmtSeconds(row.totalSeconds)}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-[#f4f4f5] overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full bg-[#1A463C] transition-all"
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |