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";
|
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,60 +48,116 @@ 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="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="space-y-4">
|
||||||
<MetricCard
|
<h2 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h2>
|
||||||
label="Contrattualizzato"
|
|
||||||
value={fmt(data.contracted)}
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"} acquisit${data.clientsAcquired === 1 ? "o" : "i"}`}
|
<MetricCard
|
||||||
accent
|
label="Contrattualizzato"
|
||||||
/>
|
value={fmtEur(data.contracted)}
|
||||||
<MetricCard
|
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"}`}
|
||||||
label="Incassato"
|
accent
|
||||||
value={fmt(data.collected)}
|
/>
|
||||||
sub={`${collectedPct}% del contrattualizzato`}
|
<MetricCard
|
||||||
/>
|
label="Incassato"
|
||||||
<MetricCard
|
value={fmtEur(data.collected)}
|
||||||
label="Da incassare"
|
sub={`${collectedPct}% del contrattualizzato`}
|
||||||
value={fmt(data.pending)}
|
/>
|
||||||
sub="Pagamenti in sospeso (tutti gli anni)"
|
<MetricCard
|
||||||
/>
|
label="Da incassare"
|
||||||
<MetricCard
|
value={fmtEur(data.pending)}
|
||||||
label="Clienti acquisiti"
|
sub="Tutti gli anni"
|
||||||
value={String(data.clientsAcquired)}
|
/>
|
||||||
sub={`Anno ${year}`}
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Monthly chart */}
|
{/* ── SEZIONE TIME TRACKING ── */}
|
||||||
<MonthlyChart data={monthly} year={year} />
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
|
||||||
|
Tempo tracciato
|
||||||
|
</h2>
|
||||||
|
|
||||||
{data.contracted === 0 && (
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<p className="text-sm text-[#71717a] italic text-center py-4">
|
<MetricCard
|
||||||
Nessun cliente registrato nel {year}.
|
label="Ore totali"
|
||||||
</p>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 { 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 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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="phases" className="w-full">
|
<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 { 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">
|
||||||
<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>
|
<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>
|
||||||
<p className="mt-2">
|
{!showArchived && (
|
||||||
<Link
|
<p className="mt-2">
|
||||||
href="/admin/clients/new"
|
<Link href="/admin/clients/new" className="text-[#1A463C] hover:underline font-medium">
|
||||||
className="text-[#1A463C] hover:underline font-medium"
|
Crea il primo cliente
|
||||||
>
|
</Link>
|
||||||
Crea il primo cliente
|
</p>
|
||||||
</Link>
|
)}
|
||||||
</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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
>
|
||||||
|
Sì, elimina
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteStep(0)}
|
||||||
|
className="text-xs text-[#71717a] hover:text-[#1a1a1a] ml-1"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
@@ -13,47 +11,57 @@ const statusConfig: Record<
|
|||||||
|
|
||||||
export function ClientRow({ client }: { client: ClientWithPayments }) {
|
export function ClientRow({ client }: { client: ClientWithPayments }) {
|
||||||
const acconto = client.payments.find((p) => p.label.includes("Acconto"));
|
const acconto = client.payments.find((p) => p.label.includes("Acconto"));
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+72
-48
@@ -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);
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
const [allPayments, activeEntries, totals] = await Promise.all([
|
||||||
brand_name: c.brand_name,
|
db.select().from(payments),
|
||||||
token: c.token,
|
|
||||||
accepted_total: c.accepted_total ?? "0",
|
// Running timer sessions (ended_at IS NULL)
|
||||||
created_at: c.created_at,
|
db
|
||||||
payments: allPayments
|
.select({
|
||||||
.filter((p) => p.client_id === c.id)
|
id: time_entries.id,
|
||||||
.map((p) => ({
|
client_id: time_entries.client_id,
|
||||||
id: p.id,
|
started_at: time_entries.started_at,
|
||||||
label: p.label,
|
})
|
||||||
status: p.status,
|
.from(time_entries)
|
||||||
amount: p.amount,
|
.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) {
|
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user