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
+88 -22
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"; import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
export const revalidate = 0; export const revalidate = 0;
function fmt(n: number) { function fmtEur(n: number) {
return n.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 }); return n.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 });
} }
interface MetricCardProps { function fmtSeconds(s: number): string {
label: string; const h = Math.floor(s / 3600);
value: string; const m = Math.floor((s % 3600) / 60);
sub?: string; if (h > 0) return `${h}h ${m}m`;
accent?: boolean; 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 ( return (
<div className={`rounded-xl border p-5 ${accent ? "bg-[#1A463C] border-[#1A463C] text-white" : "bg-white border-[#e5e7eb]"}`}> <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]"}`}> <p className={`text-xs font-bold uppercase tracking-wider mb-2 ${accent ? "text-white/60" : "text-[#71717a]"}`}>
@@ -38,44 +48,50 @@ export default async function AnalyticsPage({
const { year: yearParam } = await searchParams; const { year: yearParam } = await searchParams;
const year = parseInt(yearParam ?? "") || new Date().getFullYear(); 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), getAnalyticsByYear(year),
getMonthlyCollected(year), getMonthlyCollected(year),
getAvailableYears(), getAvailableYears(),
getTimeByClient(year),
getTotalTrackedHours(year),
]); ]);
const collectedPct = data.contracted > 0 const collectedPct =
? Math.round((data.collected / data.contracted) * 100) data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
: 0;
const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1;
return ( return (
<div className="space-y-8"> <div className="space-y-10">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-[#1a1a1a]">Statistiche</h1> <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> </div>
<YearSelector currentYear={year} availableYears={availableYears} /> <YearSelector currentYear={year} availableYears={availableYears} />
</div> </div>
{/* Metrics grid */} {/* ── 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"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard <MetricCard
label="Contrattualizzato" label="Contrattualizzato"
value={fmt(data.contracted)} value={fmtEur(data.contracted)}
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"} acquisit${data.clientsAcquired === 1 ? "o" : "i"}`} sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"}`}
accent accent
/> />
<MetricCard <MetricCard
label="Incassato" label="Incassato"
value={fmt(data.collected)} value={fmtEur(data.collected)}
sub={`${collectedPct}% del contrattualizzato`} sub={`${collectedPct}% del contrattualizzato`}
/> />
<MetricCard <MetricCard
label="Da incassare" label="Da incassare"
value={fmt(data.pending)} value={fmtEur(data.pending)}
sub="Pagamenti in sospeso (tutti gli anni)" sub="Tutti gli anni"
/> />
<MetricCard <MetricCard
label="Clienti acquisiti" label="Clienti acquisiti"
@@ -84,14 +100,64 @@ export default async function AnalyticsPage({
/> />
</div> </div>
{/* Monthly chart */}
<MonthlyChart data={monthly} year={year} /> <MonthlyChart data={monthly} year={year} />
{data.contracted === 0 && ( {data.contracted === 0 && (
<p className="text-sm text-[#71717a] italic text-center py-4"> <p className="text-sm text-[#71717a] italic text-center py-2">
Nessun cliente registrato nel {year}. Nessun cliente registrato nel {year}.
</p> </p>
)} )}
</div> </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>
); );
} }
+33
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/db"; import { db } from "@/db";
import { import {
phases, phases,
@@ -14,6 +15,38 @@ import {
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
// ── CLIENT CRUD ───────────────────────────────────────────────────────────────
const clientSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Brand name richiesto"),
brief: z.string(),
});
export async function updateClient(clientId: string, formData: FormData) {
const parsed = clientSchema.safeParse({
name: formData.get("name"),
brand_name: formData.get("brand_name"),
brief: formData.get("brief") ?? "",
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId));
revalidatePath(`/admin/clients/${clientId}`);
revalidatePath("/admin");
}
export async function deleteClient(clientId: string) {
await db.delete(clients).where(eq(clients.id, clientId));
revalidatePath("/admin");
redirect("/admin");
}
export async function setClientArchived(clientId: string, archived: boolean) {
await db.update(clients).set({ archived }).where(eq(clients.id, clientId));
revalidatePath(`/admin/clients/${clientId}`);
revalidatePath("/admin");
}
// ── PHASES ──────────────────────────────────────────────────────────────────── // ── PHASES ────────────────────────────────────────────────────────────────────
export async function addPhase(clientId: string, formData: FormData) { export async function addPhase(clientId: string, formData: FormData) {
+76
View File
@@ -0,0 +1,76 @@
import { notFound } from "next/navigation";
import { db } from "@/db";
import { clients } from "@/db/schema";
import { eq } from "drizzle-orm";
import { updateClient } from "@/app/admin/clients/[id]/actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import Link from "next/link";
export default async function EditClientPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const [client] = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
if (!client) notFound();
return (
<div className="max-w-lg">
<div className="mb-6">
<Link
href={`/admin/clients/${id}`}
className="text-sm text-[#71717a] hover:text-[#1a1a1a]"
>
{client.name}
</Link>
</div>
<h1 className="text-xl font-bold text-[#1a1a1a] mb-6">Modifica cliente</h1>
<form
action={async (fd: FormData) => {
"use server";
await updateClient(id, fd);
}}
className="space-y-5"
>
<div className="space-y-1.5">
<Label htmlFor="name">Nome cliente</Label>
<Input id="name" name="name" defaultValue={client.name} required />
</div>
<div className="space-y-1.5">
<Label htmlFor="brand_name">Brand name</Label>
<Input
id="brand_name"
name="brand_name"
defaultValue={client.brand_name}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="brief">Brief progetto</Label>
<Textarea
id="brief"
name="brief"
defaultValue={client.brief}
rows={4}
placeholder="Descrizione breve del progetto..."
/>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit">Salva modifiche</Button>
<Button asChild variant="ghost">
<Link href={`/admin/clients/${id}`}>Annulla</Link>
</Button>
</div>
</form>
</div>
);
}
+16 -6
View File
@@ -6,6 +6,7 @@ import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab"; import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
import { CommentsTab } from "@/components/admin/tabs/CommentsTab"; import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle"; import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
import { ClientActions } from "@/components/admin/ClientActions";
import Link from "next/link"; import Link from "next/link";
export const revalidate = 0; export const revalidate = 0;
@@ -19,28 +20,37 @@ export default async function ClientDetailPage({
const detail = await getClientFullDetail(id); const detail = await getClientFullDetail(id);
if (!detail) notFound(); if (!detail) notFound();
const { client, phases, payments, documents, notes, comments } = detail; const { client, phases, payments, documents, comments } = detail;
return ( return (
<div> <div>
<div className="mb-4"> <div className="mb-4">
<Link href="/admin" className="text-sm text-gray-500 hover:text-gray-700"> <Link href="/admin" className="text-sm text-[#71717a] hover:text-[#1a1a1a]">
Clienti Clienti
</Link> </Link>
</div> </div>
<div className="mb-6 flex items-start justify-between">
<div className="mb-6 flex items-start justify-between gap-4 flex-wrap">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">{client.name}</h1> <h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
<p className="text-sm text-gray-500">{client.brand_name}</p> <p className="text-sm text-[#71717a]">{client.brand_name}</p>
{client.archived && (
<span className="inline-block mt-1 text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full">
Archiviato
</span>
)}
</div> </div>
<div className="flex flex-col items-end gap-2">
<a <a
href={`/c/${client.token}`} href={`/c/${client.token}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline font-mono bg-blue-50 px-2 py-1 rounded" className="text-xs text-[#1A463C] hover:underline font-mono bg-[#1A463C]/5 px-2 py-1 rounded"
> >
Link cliente Link cliente
</a> </a>
<ClientActions clientId={client.id} archived={client.archived ?? false} />
</div>
</div> </div>
<Tabs defaultValue="phases" className="w-full"> <Tabs defaultValue="phases" className="w-full">
+27 -23
View File
@@ -3,15 +3,29 @@ import { getAllClientsWithPayments } from "@/lib/admin-queries";
import { ClientRow } from "@/components/admin/ClientRow"; import { ClientRow } from "@/components/admin/ClientRow";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
export const revalidate = 0; // always fresh — admin needs real-time data export const revalidate = 0;
export default async function AdminDashboard() { export default async function AdminDashboard({
const clients = await getAllClientsWithPayments(); searchParams,
}: {
searchParams: Promise<{ archived?: string }>;
}) {
const { archived } = await searchParams;
const showArchived = archived === "1";
const clients = await getAllClientsWithPayments(showArchived);
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1> <h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
<a
href={showArchived ? "/admin" : "/admin?archived=1"}
className="text-xs text-[#71717a] hover:text-[#1A463C] underline underline-offset-2"
>
{showArchived ? "Nascondi archiviati" : "Mostra archiviati"}
</a>
</div>
<Button asChild> <Button asChild>
<Link href="/admin/clients/new">+ Nuovo cliente</Link> <Link href="/admin/clients/new">+ Nuovo cliente</Link>
</Button> </Button>
@@ -19,36 +33,26 @@ export default async function AdminDashboard() {
{clients.length === 0 ? ( {clients.length === 0 ? (
<div className="text-center py-20 text-[#71717a]"> <div className="text-center py-20 text-[#71717a]">
<p>Nessun cliente ancora.</p> <p>{showArchived ? "Nessun cliente archiviato." : "Nessun cliente ancora."}</p>
{!showArchived && (
<p className="mt-2"> <p className="mt-2">
<Link <Link href="/admin/clients/new" className="text-[#1A463C] hover:underline font-medium">
href="/admin/clients/new"
className="text-[#1A463C] hover:underline font-medium"
>
Crea il primo cliente Crea il primo cliente
</Link> </Link>
</p> </p>
)}
</div> </div>
) : ( ) : (
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden"> <div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]"> <thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr> <tr>
<th className="text-left py-3 px-4 font-medium text-[#71717a]"> <th className="text-left py-3 px-4 font-medium text-[#71717a]">Cliente</th>
Cliente <th className="text-left py-3 px-4 font-medium text-[#71717a]">Totale</th>
</th> <th className="text-left py-3 px-4 font-medium text-[#71717a]">Acconto</th>
<th className="text-left py-3 px-4 font-medium text-[#71717a]"> <th className="text-left py-3 px-4 font-medium text-[#71717a]">Saldo</th>
Totale <th className="text-left py-3 px-4 font-medium text-[#71717a]">Timer</th>
</th> <th className="text-left py-3 px-4 font-medium text-[#71717a]">Link</th>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">
Acconto
</th>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">
Saldo
</th>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">
Link
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
+55
View File
@@ -0,0 +1,55 @@
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { time_entries } from "@/db/schema";
import { eq, isNull } from "drizzle-orm";
import { nanoid } from "nanoid";
export async function startTimer(clientId: string): Promise<{ entryId: string }> {
// Stop any currently running session (for any client) before starting a new one
const running = await db
.select({ id: time_entries.id })
.from(time_entries)
.where(isNull(time_entries.ended_at));
for (const r of running) {
const now = new Date();
const entry = await db
.select({ started_at: time_entries.started_at })
.from(time_entries)
.where(eq(time_entries.id, r.id))
.limit(1);
if (entry[0]) {
const secs = Math.round((now.getTime() - new Date(entry[0].started_at).getTime()) / 1000);
await db
.update(time_entries)
.set({ ended_at: now, duration_seconds: secs })
.where(eq(time_entries.id, r.id));
}
}
const id = nanoid();
await db.insert(time_entries).values({ id, client_id: clientId });
revalidatePath("/admin");
return { entryId: id };
}
export async function stopTimer(entryId: string): Promise<void> {
const rows = await db
.select({ started_at: time_entries.started_at })
.from(time_entries)
.where(eq(time_entries.id, entryId))
.limit(1);
if (!rows[0]) return;
const now = new Date();
const secs = Math.round((now.getTime() - new Date(rows[0].started_at).getTime()) / 1000);
await db
.update(time_entries)
.set({ ended_at: now, duration_seconds: secs })
.where(eq(time_entries.id, entryId));
revalidatePath("/admin");
}
+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>
);
}
+24 -16
View File
@@ -1,11 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { TimerCell } from "@/components/admin/TimerCell";
import type { ClientWithPayments } from "@/lib/admin-queries"; import type { ClientWithPayments } from "@/lib/admin-queries";
const statusConfig: Record< const statusConfig: Record<string, { label: string; className: string }> = {
string,
{ label: string; className: string }
> = {
da_saldare: { label: "Da saldare", className: "bg-red-100 text-red-700 border-transparent" }, 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" }, inviata: { label: "Inviata", className: "bg-[#DEF168]/30 text-[#1A463C] border-transparent" },
saldato: { label: "Saldato", className: "bg-[#1A463C]/10 text-[#1A463C] border-transparent font-medium" }, saldato: { label: "Saldato", className: "bg-[#1A463C]/10 text-[#1A463C] border-transparent font-medium" },
@@ -16,44 +14,54 @@ export function ClientRow({ client }: { client: ClientWithPayments }) {
const saldo = client.payments.find((p) => p.label.includes("Saldo")); const saldo = client.payments.find((p) => p.label.includes("Saldo"));
return ( 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"> <td className="py-3 px-4">
<Link <Link
href={`/admin/clients/${client.id}`} 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} {client.name}
</Link> </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>
<td className="py-3 px-4 text-sm text-gray-600"> <td className="py-3 px-4 text-sm text-[#1a1a1a]">
{" "} {parseFloat(client.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
{parseFloat(client.accepted_total).toLocaleString("it-IT", {
minimumFractionDigits: 2,
})}
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
{acconto && ( {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} Acconto: {statusConfig[acconto.status]?.label ?? acconto.status}
</Badge> </Badge>
)} )}
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
{saldo && ( {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} Saldo: {statusConfig[saldo.status]?.label ?? saldo.status}
</Badge> </Badge>
)} )}
</td> </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"> <td className="py-3 px-4">
<a <a
href={`/c/${client.token}`} href={`/c/${client.token}`}
target="_blank" target="_blank"
rel="noopener noreferrer" 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> </a>
</td> </td>
</tr> </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>
);
}
+16
View File
@@ -26,6 +26,7 @@ export const clients = pgTable("clients", {
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default( accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default(
"0" "0"
), ),
archived: boolean("archived").notNull().default(false),
created_at: timestamp("created_at", { withTimezone: true }) created_at: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
@@ -130,6 +131,19 @@ export const notes = pgTable("notes", {
.defaultNow(), .defaultNow(),
}); });
// ============ TIME ENTRIES (admin time tracking per client) ============
export const time_entries = pgTable("time_entries", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
client_id: text("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
ended_at: timestamp("ended_at", { withTimezone: true }),
duration_seconds: integer("duration_seconds"), // set on stop
});
// ============ SERVICE CATALOG (admin-only, used for quote generation) ============ // ============ SERVICE CATALOG (admin-only, used for quote generation) ============
export const service_catalog = pgTable("service_catalog", { export const service_catalog = pgTable("service_catalog", {
id: text("id") id: text("id")
@@ -243,3 +257,5 @@ export type ServiceCatalog = typeof service_catalog.$inferSelect;
export type NewServiceCatalog = typeof service_catalog.$inferInsert; export type NewServiceCatalog = typeof service_catalog.$inferInsert;
export type QuoteItem = typeof quote_items.$inferSelect; export type QuoteItem = typeof quote_items.$inferSelect;
export type NewQuoteItem = typeof quote_items.$inferInsert; export type NewQuoteItem = typeof quote_items.$inferInsert;
export type TimeEntry = typeof time_entries.$inferSelect;
export type NewTimeEntry = typeof time_entries.$inferInsert;
+63 -39
View File
@@ -8,8 +8,9 @@ import {
comments, comments,
documents, documents,
notes, notes,
time_entries,
} from "@/db/schema"; } from "@/db/schema";
import { eq, inArray, asc } from "drizzle-orm"; import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
import type { import type {
Client, Client,
Phase, Phase,
@@ -27,51 +28,81 @@ export type ClientWithPayments = {
brand_name: string; brand_name: string;
token: string; token: string;
accepted_total: string; accepted_total: string;
archived: boolean;
created_at: Date; created_at: Date;
payments: Array<{ payments: Array<{ id: string; label: string; status: string; amount: string }>;
id: string; activeTimerEntryId: string | null;
label: string; activeTimerStartedAt: Date | null;
status: string; totalTrackedSeconds: number;
amount: string;
}>;
}; };
export async function getAllClientsWithPayments(): Promise<ClientWithPayments[]> { export async function getAllClientsWithPayments(
includeArchived = false
): Promise<ClientWithPayments[]> {
const allClients = await db const allClients = await db
.select() .select()
.from(clients) .from(clients)
.orderBy(clients.created_at); .orderBy(clients.created_at);
if (allClients.length === 0) return []; const visible = includeArchived
? allClients
: allClients.filter((c) => !c.archived);
const allPayments = await db if (visible.length === 0) return [];
.select()
.from(payments);
return allClients.map((c) => ({ const clientIds = visible.map((c) => c.id);
const [allPayments, activeEntries, totals] = await Promise.all([
db.select().from(payments),
// Running timer sessions (ended_at IS NULL)
db
.select({
id: time_entries.id,
client_id: time_entries.client_id,
started_at: time_entries.started_at,
})
.from(time_entries)
.where(isNull(time_entries.ended_at)),
// Total tracked seconds per client (completed sessions)
db
.select({
client_id: time_entries.client_id,
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
})
.from(time_entries)
.where(inArray(time_entries.client_id, clientIds))
.groupBy(time_entries.client_id),
]);
const totalMap = new Map(totals.map((r) => [r.client_id, parseInt(r.total)]));
const activeMap = new Map(
activeEntries.map((r) => [r.client_id, { id: r.id, started_at: r.started_at }])
);
return visible.map((c) => {
const active = activeMap.get(c.id);
return {
id: c.id, id: c.id,
name: c.name, name: c.name,
brand_name: c.brand_name, brand_name: c.brand_name,
token: c.token, token: c.token,
accepted_total: c.accepted_total ?? "0", accepted_total: c.accepted_total ?? "0",
archived: c.archived ?? false,
created_at: c.created_at, created_at: c.created_at,
payments: allPayments payments: allPayments
.filter((p) => p.client_id === c.id) .filter((p) => p.client_id === c.id)
.map((p) => ({ .map((p) => ({ id: p.id, label: p.label, status: p.status, amount: p.amount })),
id: p.id, activeTimerEntryId: active?.id ?? null,
label: p.label, activeTimerStartedAt: active?.started_at ?? null,
status: p.status, totalTrackedSeconds: totalMap.get(c.id) ?? 0,
amount: p.amount, };
})), });
}));
} }
export async function getClientById(id: string) { export async function getClientById(id: string) {
const rows = await db const rows = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
.select()
.from(clients)
.where(eq(clients.id, id))
.limit(1);
return rows[0] ?? null; return rows[0] ?? null;
} }
@@ -113,15 +144,9 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
const deliverablesRows = const deliverablesRows =
taskIds.length === 0 taskIds.length === 0
? [] ? []
: await db : await db.select().from(deliverables).where(inArray(deliverables.task_id, taskIds));
.select()
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
const paymentsRows = await db const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, id));
.select()
.from(payments)
.where(eq(payments.client_id, id));
const documentsRows = await db const documentsRows = await db
.select() .select()
@@ -135,8 +160,7 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
.where(eq(notes.client_id, id)) .where(eq(notes.client_id, id))
.orderBy(asc(notes.created_at)); .orderBy(asc(notes.created_at));
// Fetch all comments for tasks and deliverables belonging to this client const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
const commentsRows = const commentsRows =
allEntityIds.length === 0 allEntityIds.length === 0
? [] ? []
@@ -146,15 +170,15 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
.where(inArray(comments.entity_id, allEntityIds)) .where(inArray(comments.entity_id, allEntityIds))
.orderBy(asc(comments.created_at)); .orderBy(asc(comments.created_at));
const phasesWithTasks = phasesRows.map((phase) => { const phasesWithTasks = phasesRows.map((phase) => ({
const phaseTasks = tasksRows ...phase,
tasks: tasksRows
.filter((t) => t.phase_id === phase.id) .filter((t) => t.phase_id === phase.id)
.map((task) => ({ .map((task) => ({
...task, ...task,
deliverables: deliverablesRows.filter((d) => d.task_id === task.id), deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
})),
})); }));
return { ...phase, tasks: phaseTasks };
});
return { return {
client, client,
+47 -1
View File
@@ -1,5 +1,5 @@
import { db } from "@/db"; import { db } from "@/db";
import { clients, payments } from "@/db/schema"; import { clients, payments, time_entries } from "@/db/schema";
import { sql, and, eq } from "drizzle-orm"; import { sql, and, eq } from "drizzle-orm";
export async function getAnalyticsByYear(year: number) { export async function getAnalyticsByYear(year: number) {
@@ -69,3 +69,49 @@ export async function getAvailableYears(): Promise<number[]> {
if (!years.includes(currentYear)) years.push(currentYear); if (!years.includes(currentYear)) years.push(currentYear);
return years.sort((a, b) => b - a); return years.sort((a, b) => b - a);
} }
// ── Time tracking ─────────────────────────────────────────────────────────────
export type ClientTimeRow = {
clientId: string;
clientName: string;
totalSeconds: number;
};
export async function getTimeByClient(year: number): Promise<ClientTimeRow[]> {
const rows = await db
.select({
client_id: time_entries.client_id,
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
})
.from(time_entries)
.where(
sql`${time_entries.ended_at} is not null
and extract(year from ${time_entries.started_at}) = ${year}`
)
.groupBy(time_entries.client_id);
if (rows.length === 0) return [];
const allClients = await db.select({ id: clients.id, name: clients.name }).from(clients);
const nameMap = new Map(allClients.map((c) => [c.id, c.name]));
return rows
.map((r) => ({
clientId: r.client_id,
clientName: nameMap.get(r.client_id) ?? r.client_id,
totalSeconds: parseInt(r.total),
}))
.sort((a, b) => b.totalSeconds - a.totalSeconds);
}
export async function getTotalTrackedHours(year: number): Promise<number> {
const [row] = await db
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
.from(time_entries)
.where(
sql`${time_entries.ended_at} is not null
and extract(year from ${time_entries.started_at}) = ${year}`
);
return Math.round(parseInt(row?.total ?? "0") / 3600 * 10) / 10;
}