--- 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" --- 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. @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md @/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md @/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md Task 1: Create dashboard-queries.ts with getDashboardStats() src/lib/dashboard-queries.ts - src/lib/admin-queries.ts — understand DB query patterns (Drizzle, db import) - src/db/schema.ts — tables: clients, projects, payments, phases, time_entries, deliverables 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 { // ── 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 }; } ``` Task 2: Build dashboard page at /admin src/app/admin/page.tsx 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 (

{label}

{value}

{sub &&

{sub}

}
); } const ACTIVITY_ICONS: Record = { 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 (

Dashboard

{/* KPI cards */}
{/* Activity feed */}

Attività recente

{activity.length === 0 ? (

Nessuna attività recente

) : (
    {activity.map((item, i) => (
  • {ACTIVITY_ICONS[item.type]}

    {item.label}

    {item.detail}

    {fmt(item.timestamp)}
  • ))}
)}
); } ```
Task 3: Verify build and TypeScript src/lib/dashboard-queries.ts, src/app/admin/page.tsx 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.
- [ ] `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)