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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
phases,
|
||||
@@ -14,6 +15,38 @@ import {
|
||||
import { eq } from "drizzle-orm";
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addPhase(clientId: string, formData: FormData) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||
import { ClientActions } from "@/components/admin/ClientActions";
|
||||
import Link from "next/link";
|
||||
|
||||
export const revalidate = 0;
|
||||
@@ -19,28 +20,37 @@ export default async function ClientDetailPage({
|
||||
const detail = await getClientFullDetail(id);
|
||||
if (!detail) notFound();
|
||||
|
||||
const { client, phases, payments, documents, notes, comments } = detail;
|
||||
const { client, phases, payments, documents, comments } = detail;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
|
||||
<div className="mb-6 flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{client.name}</h1>
|
||||
<p className="text-sm text-gray-500">{client.brand_name}</p>
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
|
||||
<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 className="flex flex-col items-end gap-2">
|
||||
<a
|
||||
href={`/c/${client.token}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-[#1A463C] hover:underline font-mono bg-[#1A463C]/5 px-2 py-1 rounded"
|
||||
>
|
||||
Link cliente →
|
||||
</a>
|
||||
<ClientActions clientId={client.id} archived={client.archived ?? false} />
|
||||
</div>
|
||||
<a
|
||||
href={`/c/${client.token}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline font-mono bg-blue-50 px-2 py-1 rounded"
|
||||
>
|
||||
Link cliente →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="phases" className="w-full">
|
||||
|
||||
+32
-28
@@ -3,15 +3,29 @@ import { getAllClientsWithPayments } from "@/lib/admin-queries";
|
||||
import { ClientRow } from "@/components/admin/ClientRow";
|
||||
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() {
|
||||
const clients = await getAllClientsWithPayments();
|
||||
export default async function AdminDashboard({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ archived?: string }>;
|
||||
}) {
|
||||
const { archived } = await searchParams;
|
||||
const showArchived = archived === "1";
|
||||
const clients = await getAllClientsWithPayments(showArchived);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<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>
|
||||
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
|
||||
</Button>
|
||||
@@ -19,36 +33,26 @@ export default async function AdminDashboard() {
|
||||
|
||||
{clients.length === 0 ? (
|
||||
<div className="text-center py-20 text-[#71717a]">
|
||||
<p>Nessun cliente ancora.</p>
|
||||
<p className="mt-2">
|
||||
<Link
|
||||
href="/admin/clients/new"
|
||||
className="text-[#1A463C] hover:underline font-medium"
|
||||
>
|
||||
Crea il primo cliente
|
||||
</Link>
|
||||
</p>
|
||||
<p>{showArchived ? "Nessun cliente archiviato." : "Nessun cliente ancora."}</p>
|
||||
{!showArchived && (
|
||||
<p className="mt-2">
|
||||
<Link href="/admin/clients/new" className="text-[#1A463C] hover:underline font-medium">
|
||||
Crea il primo cliente
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">
|
||||
Cliente
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">
|
||||
Totale
|
||||
</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>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Cliente</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Totale</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]">Timer</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user