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
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteClient, setClientArchived } from "@/app/admin/clients/[id]/actions";
import { Button } from "@/components/ui/button";
export function ClientActions({
clientId,
archived,
}: {
clientId: string;
archived: boolean;
}) {
const [deleteStep, setDeleteStep] = useState(0);
const [, startTransition] = useTransition();
const router = useRouter();
function handleArchive() {
startTransition(async () => {
await setClientArchived(clientId, !archived);
router.refresh();
});
}
function handleDelete() {
if (deleteStep === 0) {
setDeleteStep(1);
return;
}
startTransition(async () => {
await deleteClient(clientId);
});
}
return (
<div className="flex items-center gap-2 flex-wrap">
<Button asChild variant="outline" size="sm">
<a href={`/admin/clients/${clientId}/edit`}>Modifica</a>
</Button>
<Button variant="outline" size="sm" onClick={handleArchive}>
{archived ? "Riattiva" : "Archivia"}
</Button>
{deleteStep === 0 ? (
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
className="text-red-400 hover:text-red-600"
>
Elimina
</Button>
) : (
<div className="flex items-center gap-1.5 bg-red-50 border border-red-200 rounded-lg px-3 py-1.5">
<span className="text-xs text-red-700 font-medium">Eliminare definitivamente?</span>
<button
onClick={handleDelete}
className="text-xs text-red-700 font-bold hover:text-red-900 underline ml-1"
>
, elimina
</button>
<button
onClick={() => setDeleteStep(0)}
className="text-xs text-[#71717a] hover:text-[#1a1a1a] ml-1"
>
Annulla
</button>
</div>
)}
</div>
);
}
+25 -17
View File
@@ -1,11 +1,9 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { TimerCell } from "@/components/admin/TimerCell";
import type { ClientWithPayments } from "@/lib/admin-queries";
const statusConfig: Record<
string,
{ label: string; className: string }
> = {
const statusConfig: Record<string, { label: string; className: string }> = {
da_saldare: { label: "Da saldare", className: "bg-red-100 text-red-700 border-transparent" },
inviata: { label: "Inviata", className: "bg-[#DEF168]/30 text-[#1A463C] border-transparent" },
saldato: { label: "Saldato", className: "bg-[#1A463C]/10 text-[#1A463C] border-transparent font-medium" },
@@ -13,47 +11,57 @@ const statusConfig: Record<
export function ClientRow({ client }: { client: ClientWithPayments }) {
const acconto = client.payments.find((p) => p.label.includes("Acconto"));
const saldo = client.payments.find((p) => p.label.includes("Saldo"));
const saldo = client.payments.find((p) => p.label.includes("Saldo"));
return (
<tr className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
<tr className={`border-b border-[#f4f4f5] hover:bg-[#f9f9f9] transition-colors ${client.archived ? "opacity-60" : ""}`}>
<td className="py-3 px-4">
<Link
href={`/admin/clients/${client.id}`}
className="font-medium text-gray-900 hover:underline"
className="font-medium text-[#1a1a1a] hover:text-[#1A463C] hover:underline"
>
{client.name}
</Link>
<p className="text-xs text-gray-400">{client.brand_name}</p>
<p className="text-xs text-[#71717a]">{client.brand_name}</p>
{client.archived && (
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-1.5 py-0.5 rounded-full">
Archiviato
</span>
)}
</td>
<td className="py-3 px-4 text-sm text-gray-600">
{" "}
{parseFloat(client.accepted_total).toLocaleString("it-IT", {
minimumFractionDigits: 2,
})}
<td className="py-3 px-4 text-sm text-[#1a1a1a]">
{parseFloat(client.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</td>
<td className="py-3 px-4">
{acconto && (
<Badge className={statusConfig[acconto.status]?.className ?? "border-transparent bg-gray-100 text-gray-600"}>
<Badge className={statusConfig[acconto.status]?.className ?? "border-transparent bg-[#f4f4f5] text-[#71717a]"}>
Acconto: {statusConfig[acconto.status]?.label ?? acconto.status}
</Badge>
)}
</td>
<td className="py-3 px-4">
{saldo && (
<Badge className={statusConfig[saldo.status]?.className ?? "border-transparent bg-gray-100 text-gray-600"}>
<Badge className={statusConfig[saldo.status]?.className ?? "border-transparent bg-[#f4f4f5] text-[#71717a]"}>
Saldo: {statusConfig[saldo.status]?.label ?? saldo.status}
</Badge>
)}
</td>
<td className="py-3 px-4">
<TimerCell
clientId={client.id}
activeEntryId={client.activeTimerEntryId}
activeStartedAt={client.activeTimerStartedAt}
totalTrackedSeconds={client.totalTrackedSeconds}
/>
</td>
<td className="py-3 px-4">
<a
href={`/c/${client.token}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline font-mono"
className="text-xs text-[#1A463C] hover:underline font-mono"
>
/c/{client.token.slice(0, 10)}
/c/{client.token.slice(0, 8)}
</a>
</td>
</tr>
+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>
);
}