feat: client edit/delete/archive + time tracker + analytics time section

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>
This commit is contained in:
Simone Cavalli
2026-05-16 21:28:01 +02:00
parent d322162c0a
commit 0f48570cd7
12 changed files with 656 additions and 153 deletions
+110 -44
View File
@@ -1,20 +1,30 @@
import { getAnalyticsByYear, getMonthlyCollected, getAvailableYears } from "@/lib/analytics-queries";
import {
getAnalyticsByYear,
getMonthlyCollected,
getAvailableYears,
getTimeByClient,
getTotalTrackedHours,
} from "@/lib/analytics-queries";
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
export const revalidate = 0;
function fmt(n: number) {
function fmtEur(n: number) {
return n.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 });
}
interface MetricCardProps {
label: string;
value: string;
sub?: string;
accent?: boolean;
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 }: MetricCardProps) {
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]"}`}>
@@ -38,60 +48,116 @@ export default async function AnalyticsPage({
const { year: yearParam } = await searchParams;
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
const [data, monthly, availableYears] = await Promise.all([
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 collectedPct =
data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1;
return (
<div className="space-y-8">
<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 finanziaria per anno</p>
<p className="text-sm text-[#71717a] mt-0.5">Panoramica per anno</p>
</div>
<YearSelector currentYear={year} availableYears={availableYears} />
</div>
{/* Metrics grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Contrattualizzato"
value={fmt(data.contracted)}
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"} acquisit${data.clientsAcquired === 1 ? "o" : "i"}`}
accent
/>
<MetricCard
label="Incassato"
value={fmt(data.collected)}
sub={`${collectedPct}% del contrattualizzato`}
/>
<MetricCard
label="Da incassare"
value={fmt(data.pending)}
sub="Pagamenti in sospeso (tutti gli anni)"
/>
<MetricCard
label="Clienti acquisiti"
value={String(data.clientsAcquired)}
sub={`Anno ${year}`}
/>
{/* ── 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>
{/* Monthly chart */}
<MonthlyChart data={monthly} year={year} />
{/* ── SEZIONE TIME TRACKING ── */}
<div className="space-y-4">
<h2 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
Tempo tracciato
</h2>
{data.contracted === 0 && (
<p className="text-sm text-[#71717a] italic text-center py-4">
Nessun cliente registrato nel {year}.
</p>
)}
<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>
);
}