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:
+73
-49
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user