---
plan_id: 06-02
phase: 6
wave: 2
title: "Dashboard page — KPI cards + activity feed at /admin"
type: execute
depends_on: [06-01]
files_modified:
  - src/app/admin/page.tsx
  - src/lib/dashboard-queries.ts
requirements_addressed: [UX-02]
autonomous: true
must_haves:
  truths:
    - "/admin renders a real dashboard (no redirect) with KPI cards and activity feed"
    - "KPI cards show: clienti attivi, revenue totale progetti, pagamenti in sospeso (importo), progetti in corso"
    - "Activity feed shows last 10 events across approvazioni deliverable, nuovi clienti, nuovi progetti, timer stoppati"
    - "getDashboardStats() query is in src/lib/dashboard-queries.ts — not inlined in the page"
    - "Page is an RSC (no 'use client') — data fetched server-side"
  artifacts:
    - path: "src/lib/dashboard-queries.ts"
      provides: "getDashboardStats() returning KPIs and recent activity"
      contains: "clienti attivi, revenue, pagamenti, progetti, activity"
    - path: "src/app/admin/page.tsx"
      provides: "Dashboard RSC page with KPI cards and activity feed"
      contains: "getDashboardStats, KpiCard"
  key_links:
    - from: "src/app/admin/page.tsx"
      to: "src/lib/dashboard-queries.ts"
      via: "import"
      pattern: "getDashboardStats"
---

<objective>
Build the real dashboard page at /admin. Four KPI cards + an activity feed. All data comes from existing tables — no schema changes needed.

Purpose: The admin opens the app and immediately sees the business situation at a glance.
</objective>

<execution_context>
@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md
@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
@/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create dashboard-queries.ts with getDashboardStats()</name>
  <files>src/lib/dashboard-queries.ts</files>

  <read_first>
    - src/lib/admin-queries.ts — understand DB query patterns (Drizzle, db import)
    - src/db/schema.ts — tables: clients, projects, payments, phases, time_entries, deliverables
  </read_first>

  <action>
Create `src/lib/dashboard-queries.ts`:

```typescript
import { db } from "@/db";
import { clients, projects, payments, phases, deliverables, time_entries } from "@/db/schema";
import { eq, and, not, isNull, desc, sum, count, lt } from "drizzle-orm";

export type KpiStats = {
  clientiAttivi: number;
  revenueTotale: string;        // sum of projects.accepted_total (non-archived)
  pagamentiInSospeso: string;   // sum of payments.amount where status = 'da_saldare' or 'inviata'
  progettiInCorso: number;      // projects not archived
};

export type ActivityItem = {
  type: "nuovo_cliente" | "nuovo_progetto" | "deliverable_approvato" | "timer_stoppato";
  label: string;
  detail: string;
  timestamp: Date;
};

export type DashboardStats = {
  kpi: KpiStats;
  activity: ActivityItem[];
};

export async function getDashboardStats(): Promise<DashboardStats> {
  // ── KPIs ────────────────────────────────────────────────────────────────
  const [clientiAttivi] = await db
    .select({ count: count() })
    .from(clients)
    .where(eq(clients.archived, false));

  const [revenueRow] = await db
    .select({ total: sum(projects.accepted_total) })
    .from(projects)
    .where(eq(projects.archived, false));

  const [pagamentiRow] = await db
    .select({ total: sum(payments.amount) })
    .from(payments)
    .where(
      and(
        eq(payments.status, "da_saldare"),
      )
    );

  // Also include "inviata" status in sospeso
  const [pagamentiInviataRow] = await db
    .select({ total: sum(payments.amount) })
    .from(payments)
    .where(eq(payments.status, "inviata"));

  const [progettiRow] = await db
    .select({ count: count() })
    .from(projects)
    .where(eq(projects.archived, false));

  const pagamentiSospeso =
    (parseFloat(pagamentiRow?.total ?? "0") || 0) +
    (parseFloat(pagamentiInviataRow?.total ?? "0") || 0);

  const kpi: KpiStats = {
    clientiAttivi: clientiAttivi?.count ?? 0,
    revenueTotale: revenueRow?.total ?? "0",
    pagamentiInSospeso: pagamentiSospeso.toFixed(2),
    progettiInCorso: progettiRow?.count ?? 0,
  };

  // ── Activity feed ────────────────────────────────────────────────────────
  // Fetch recent events from multiple tables, merge and sort client-side
  const recentClients = await db
    .select({ id: clients.id, name: clients.name, created_at: clients.created_at })
    .from(clients)
    .orderBy(desc(clients.created_at))
    .limit(5);

  const recentProjects = await db
    .select({ id: projects.id, name: projects.name, created_at: projects.created_at })
    .from(projects)
    .orderBy(desc(projects.created_at))
    .limit(5);

  const recentApprovals = await db
    .select({ id: deliverables.id, title: deliverables.title, approved_at: deliverables.approved_at })
    .from(deliverables)
    .where(not(isNull(deliverables.approved_at)))
    .orderBy(desc(deliverables.approved_at))
    .limit(5);

  const recentTimers = await db
    .select({ id: time_entries.id, ended_at: time_entries.ended_at, duration_seconds: time_entries.duration_seconds })
    .from(time_entries)
    .where(not(isNull(time_entries.ended_at)))
    .orderBy(desc(time_entries.ended_at))
    .limit(5);

  const activity: ActivityItem[] = [
    ...recentClients.map((c) => ({
      type: "nuovo_cliente" as const,
      label: "Nuovo cliente",
      detail: c.name,
      timestamp: c.created_at,
    })),
    ...recentProjects.map((p) => ({
      type: "nuovo_progetto" as const,
      label: "Nuovo progetto",
      detail: p.name,
      timestamp: p.created_at,
    })),
    ...recentApprovals.map((d) => ({
      type: "deliverable_approvato" as const,
      label: "Deliverable approvato",
      detail: d.title,
      timestamp: d.approved_at!,
    })),
    ...recentTimers
      .filter((t) => t.ended_at && t.duration_seconds)
      .map((t) => ({
        type: "timer_stoppato" as const,
        label: "Timer stoppato",
        detail: `${Math.round((t.duration_seconds ?? 0) / 60)} min`,
        timestamp: t.ended_at!,
      })),
  ]
    .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
    .slice(0, 10);

  return { kpi, activity };
}
```
  </action>
</task>

<task type="auto">
  <name>Task 2: Build dashboard page at /admin</name>
  <files>src/app/admin/page.tsx</files>

  <action>
Replace the redirect placeholder (from 06-01) with the real dashboard RSC:

```tsx
import { getDashboardStats } from "@/lib/dashboard-queries";
import { Users, FolderOpen, Euro, Clock } from "lucide-react";

export const revalidate = 0;

function KpiCard({
  label,
  value,
  sub,
  icon: Icon,
  color,
}: {
  label: string;
  value: string | number;
  sub?: string;
  icon: React.ElementType;
  color: string;
}) {
  return (
    <div className="bg-white rounded-xl border border-gray-200 p-5 flex items-start gap-4">
      <div className={`p-2 rounded-lg ${color}`}>
        <Icon size={20} strokeWidth={1.8} className="text-white" />
      </div>
      <div>
        <p className="text-xs text-gray-500 font-medium uppercase tracking-wide">{label}</p>
        <p className="text-2xl font-bold text-gray-900 mt-0.5">{value}</p>
        {sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
      </div>
    </div>
  );
}

const ACTIVITY_ICONS: Record<string, string> = {
  nuovo_cliente: "👤",
  nuovo_progetto: "📁",
  deliverable_approvato: "✅",
  timer_stoppato: "⏱",
};

function fmt(ts: Date) {
  return new Intl.DateTimeFormat("it-IT", {
    day: "2-digit",
    month: "short",
    hour: "2-digit",
    minute: "2-digit",
  }).format(new Date(ts));
}

function fmtEur(val: string) {
  return new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR" }).format(
    parseFloat(val) || 0
  );
}

export default async function AdminDashboard() {
  const { kpi, activity } = await getDashboardStats();

  return (
    <div className="max-w-5xl">
      <h1 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>

      {/* KPI cards */}
      <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
        <KpiCard
          label="Clienti attivi"
          value={kpi.clientiAttivi}
          icon={Users}
          color="bg-[#1A463C]"
        />
        <KpiCard
          label="Revenue totale"
          value={fmtEur(kpi.revenueTotale)}
          sub="progetti non archiviati"
          icon={Euro}
          color="bg-emerald-600"
        />
        <KpiCard
          label="Progetti in corso"
          value={kpi.progettiInCorso}
          icon={FolderOpen}
          color="bg-blue-600"
        />
        <KpiCard
          label="Pagamenti in sospeso"
          value={fmtEur(kpi.pagamentiInSospeso)}
          sub="da_saldare + inviata"
          icon={Clock}
          color="bg-amber-500"
        />
      </div>

      {/* Activity feed */}
      <div className="bg-white rounded-xl border border-gray-200">
        <div className="px-5 py-4 border-b border-gray-100">
          <h2 className="text-sm font-semibold text-gray-700">Attività recente</h2>
        </div>
        {activity.length === 0 ? (
          <p className="px-5 py-8 text-sm text-gray-400 text-center">Nessuna attività recente</p>
        ) : (
          <ul className="divide-y divide-gray-50">
            {activity.map((item, i) => (
              <li key={i} className="px-5 py-3 flex items-center justify-between gap-4">
                <div className="flex items-center gap-3">
                  <span className="text-base">{ACTIVITY_ICONS[item.type]}</span>
                  <div>
                    <p className="text-sm font-medium text-gray-800">{item.label}</p>
                    <p className="text-xs text-gray-500">{item.detail}</p>
                  </div>
                </div>
                <span className="text-xs text-gray-400 shrink-0">{fmt(item.timestamp)}</span>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
}
```
  </action>
</task>

<task type="auto">
  <name>Task 3: Verify build and TypeScript</name>
  <files>src/lib/dashboard-queries.ts, src/app/admin/page.tsx</files>

  <action>
Run `npx tsc --noEmit` and fix any type errors (common: Drizzle `count()` return type, nullable fields).

Run `npm run build` to confirm the full Next.js build passes.
  </action>
</task>

</tasks>

<verification>
- [ ] `npx tsc --noEmit` passes
- [ ] `npm run build` passes
- [ ] /admin shows Dashboard heading with 4 KPI cards
- [ ] KPI cards display real data (not all zeros, assuming data in DB)
- [ ] Activity feed shows at least some items if data exists
- [ ] Dashboard is an RSC (no "use client" in page.tsx)
</verification>
