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
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useState, useEffect, useTransition } from "react";
import { useRouter } from "next/navigation";
import { startTimer, stopTimer } from "@/app/admin/timer-actions";
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function TimerCell({
clientId,
activeEntryId,
activeStartedAt,
totalTrackedSeconds,
}: {
clientId: string;
activeEntryId: string | null;
activeStartedAt: Date | null;
totalTrackedSeconds: number;
}) {
const router = useRouter();
const [, startTransition] = useTransition();
const isRunning = activeEntryId !== null;
// Live elapsed counter
const [elapsed, setElapsed] = useState(() => {
if (!activeStartedAt) return 0;
return Math.round((Date.now() - new Date(activeStartedAt).getTime()) / 1000);
});
useEffect(() => {
if (!isRunning) { setElapsed(0); return; }
const start = activeStartedAt ? new Date(activeStartedAt).getTime() : Date.now();
const tick = () => setElapsed(Math.round((Date.now() - start) / 1000));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [isRunning, activeStartedAt]);
function handleToggle() {
startTransition(async () => {
if (isRunning && activeEntryId) {
await stopTimer(activeEntryId);
} else {
await startTimer(clientId);
}
router.refresh();
});
}
const displayTotal = formatDuration(totalTrackedSeconds + (isRunning ? elapsed : 0));
return (
<div className="flex items-center gap-2">
<button
onClick={handleToggle}
title={isRunning ? "Ferma timer" : "Avvia timer"}
className={`w-7 h-7 rounded-full flex items-center justify-center transition-colors shrink-0 ${
isRunning
? "bg-red-100 text-red-600 hover:bg-red-200"
: "bg-[#1A463C]/10 text-[#1A463C] hover:bg-[#1A463C]/20"
}`}
>
{isRunning ? (
// Stop square
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 16 16">
<rect x="3" y="3" width="10" height="10" rx="1.5" />
</svg>
) : (
// Play triangle
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 16 16">
<path d="M4 3.5v9l9-4.5-9-4.5z" />
</svg>
)}
</button>
<span
className={`text-xs tabular-nums font-mono ${
isRunning ? "text-red-600 font-semibold" : "text-[#71717a]"
}`}
>
{isRunning ? formatDuration(elapsed) : displayTotal}
</span>
</div>
);
}