From 0f48570cd7eb72746309bafeb2255edbc6fc2702 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sat, 16 May 2026 21:28:01 +0200 Subject: [PATCH] feat: client edit/delete/archive + time tracker + analytics time section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/admin/analytics/page.tsx | 154 ++++++++++++++++------- src/app/admin/clients/[id]/actions.ts | 33 +++++ src/app/admin/clients/[id]/edit/page.tsx | 76 +++++++++++ src/app/admin/clients/[id]/page.tsx | 36 ++++-- src/app/admin/page.tsx | 60 ++++----- src/app/admin/timer-actions.ts | 55 ++++++++ src/components/admin/ClientActions.tsx | 74 +++++++++++ src/components/admin/ClientRow.tsx | 42 ++++--- src/components/admin/TimerCell.tsx | 91 ++++++++++++++ src/db/schema.ts | 18 ++- src/lib/admin-queries.ts | 122 ++++++++++-------- src/lib/analytics-queries.ts | 48 ++++++- 12 files changed, 656 insertions(+), 153 deletions(-) create mode 100644 src/app/admin/clients/[id]/edit/page.tsx create mode 100644 src/app/admin/timer-actions.ts create mode 100644 src/components/admin/ClientActions.tsx create mode 100644 src/components/admin/TimerCell.tsx diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index 5d894f9..28ae351 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -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 (

@@ -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 ( -

+
{/* Header */}

Statistiche

-

Panoramica finanziaria per anno

+

Panoramica per anno

- {/* Metrics grid */} -
- - - - + {/* ── SEZIONE ECONOMICA ── */} +
+

Fatturato

+ +
+ + + + +
+ + + + {data.contracted === 0 && ( +

+ Nessun cliente registrato nel {year}. +

+ )}
- {/* Monthly chart */} - + {/* ── SEZIONE TIME TRACKING ── */} +
+

+ Tempo tracciato +

- {data.contracted === 0 && ( -

- Nessun cliente registrato nel {year}. -

- )} +
+ +
+ + {timeByClient.length === 0 ? ( +
+

+ Nessuna sessione registrata nel {year}. + Usa il timer ▶ nella lista clienti per iniziare. +

+
+ ) : ( +
+

Ore per cliente — {year}

+
+ {timeByClient.map((row) => { + const pct = Math.round((row.totalSeconds / maxClientSeconds) * 100); + return ( +
+
+ {row.clientName} + + {fmtSeconds(row.totalSeconds)} + +
+
+
+
+
+ ); + })} +
+
+ )} +
); } \ No newline at end of file diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index 15ad379..e665c36 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -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) { diff --git a/src/app/admin/clients/[id]/edit/page.tsx b/src/app/admin/clients/[id]/edit/page.tsx new file mode 100644 index 0000000..2618b7c --- /dev/null +++ b/src/app/admin/clients/[id]/edit/page.tsx @@ -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 ( +
+
+ + ← {client.name} + +
+ +

Modifica cliente

+ +
{ + "use server"; + await updateClient(id, fd); + }} + className="space-y-5" + > +
+ + +
+ +
+ + +
+ +
+ +