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:
@@ -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"
|
||||
>
|
||||
Sì, elimina
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteStep(0)}
|
||||
className="text-xs text-[#71717a] hover:text-[#1a1a1a] ml-1"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user