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
+73 -49
View File
@@ -8,8 +8,9 @@ import {
comments,
documents,
notes,
time_entries,
} from "@/db/schema";
import { eq, inArray, asc } from "drizzle-orm";
import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
import type {
Client,
Phase,
@@ -27,51 +28,81 @@ export type ClientWithPayments = {
brand_name: string;
token: string;
accepted_total: string;
archived: boolean;
created_at: Date;
payments: Array<{
id: string;
label: string;
status: string;
amount: string;
}>;
payments: Array<{ id: string; label: string; status: string; amount: string }>;
activeTimerEntryId: string | null;
activeTimerStartedAt: Date | null;
totalTrackedSeconds: number;
};
export async function getAllClientsWithPayments(): Promise<ClientWithPayments[]> {
export async function getAllClientsWithPayments(
includeArchived = false
): Promise<ClientWithPayments[]> {
const allClients = await db
.select()
.from(clients)
.orderBy(clients.created_at);
if (allClients.length === 0) return [];
const visible = includeArchived
? allClients
: allClients.filter((c) => !c.archived);
const allPayments = await db
.select()
.from(payments);
if (visible.length === 0) return [];
return allClients.map((c) => ({
id: c.id,
name: c.name,
brand_name: c.brand_name,
token: c.token,
accepted_total: c.accepted_total ?? "0",
created_at: c.created_at,
payments: allPayments
.filter((p) => p.client_id === c.id)
.map((p) => ({
id: p.id,
label: p.label,
status: p.status,
amount: p.amount,
})),
}));
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,
name: c.name,
brand_name: c.brand_name,
token: c.token,
accepted_total: c.accepted_total ?? "0",
archived: c.archived ?? false,
created_at: c.created_at,
payments: allPayments
.filter((p) => p.client_id === c.id)
.map((p) => ({ id: p.id, label: p.label, status: p.status, amount: p.amount })),
activeTimerEntryId: active?.id ?? null,
activeTimerStartedAt: active?.started_at ?? null,
totalTrackedSeconds: totalMap.get(c.id) ?? 0,
};
});
}
export async function getClientById(id: string) {
const rows = await db
.select()
.from(clients)
.where(eq(clients.id, id))
.limit(1);
const rows = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
return rows[0] ?? null;
}
@@ -113,15 +144,9 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
const deliverablesRows =
taskIds.length === 0
? []
: await db
.select()
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
: await db.select().from(deliverables).where(inArray(deliverables.task_id, taskIds));
const paymentsRows = await db
.select()
.from(payments)
.where(eq(payments.client_id, id));
const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, id));
const documentsRows = await db
.select()
@@ -135,8 +160,7 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
.where(eq(notes.client_id, id))
.orderBy(asc(notes.created_at));
// Fetch all comments for tasks and deliverables belonging to this client
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
const commentsRows =
allEntityIds.length === 0
? []
@@ -146,15 +170,15 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
.where(inArray(comments.entity_id, allEntityIds))
.orderBy(asc(comments.created_at));
const phasesWithTasks = phasesRows.map((phase) => {
const phaseTasks = tasksRows
const phasesWithTasks = phasesRows.map((phase) => ({
...phase,
tasks: tasksRows
.filter((t) => t.phase_id === phase.id)
.map((task) => ({
...task,
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
}));
return { ...phase, tasks: phaseTasks };
});
})),
}));
return {
client,
@@ -164,4 +188,4 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
notes: notesRows,
comments: commentsRows,
};
}
}