---
plan: "06-02"
phase: 6
subsystem: admin-dashboard
tags: [dashboard, kpi, activity-feed, rsc, drizzle]
dependency_graph:
  requires: [06-01]
  provides: [getDashboardStats, /admin dashboard page]
  affects: [src/app/admin/page.tsx, src/lib/dashboard-queries.ts]
tech_stack:
  added: []
  patterns: [RSC async server component, Promise.all parallel DB fetch, Intl.NumberFormat/DateTimeFormat]
key_files:
  created:
    - src/lib/dashboard-queries.ts
  modified:
    - src/app/admin/page.tsx
decisions:
  - "isNotNull() used instead of not(isNull()) — isNotNull is the correct Drizzle-orm helper for nullable timestamp WHERE clauses"
  - "Promise.all for the 4 activity feed queries — parallel fetch reduces latency"
  - "Type guard filters (.filter with type predicate) used for nullable approved_at and ended_at before mapping — avoids non-null assertion operator"
  - "revalidate=0 on dashboard page — always fresh data, no stale cache"
metrics:
  duration: "~10 minutes"
  completed: "2026-05-31"
  tasks_completed: 3
  tasks_total: 3
---

# Phase 6 Plan 02: Dashboard page — KPI cards + activity feed at /admin Summary

Real admin dashboard at /admin with 4 KPI cards (clienti attivi, revenue totale, progetti in corso, pagamenti in sospeso) and an activity feed showing the last 10 events across clients, projects, deliverables, and time entries. Page is a pure RSC — no client component directive. Data layer extracted into `src/lib/dashboard-queries.ts`.

## Tasks Completed

| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Create dashboard-queries.ts with getDashboardStats() | a304328 | src/lib/dashboard-queries.ts |
| 2 | Build dashboard page at /admin | 40162e0 | src/app/admin/page.tsx |
| 3 | Verify build and TypeScript | 40162e0 | — |

## What Was Built

**dashboard-queries.ts** — server-only query module with:
- 4 KPI queries: `count()` of non-archived clients, `sum()` of non-archived projects.accepted_total, `sum()` of payments with status `da_saldare` + `inviata`, `count()` of non-archived projects
- Activity feed: 4 parallel queries via `Promise.all` fetching the 5 most recent events from clients, projects, deliverables (approved), time_entries (stopped). Merged and sorted by timestamp descending, sliced to 10.
- `isNotNull()` used for nullable timestamp columns (approved_at, ended_at) — correct Drizzle-orm helper

**admin/page.tsx** — RSC async server component with:
- `KpiCard` sub-component (inline RSC, no "use client")
- 4-column grid KPI cards with Lucide icons (Users, Euro, FolderOpen, Clock)
- Activity feed list with emoji icons per event type
- `Intl.DateTimeFormat` for Italian locale timestamps, `Intl.NumberFormat` for EUR currency formatting
- `revalidate = 0` for always-fresh data

## Deviations from Plan

**1. [Rule 1 - Bug] Replaced `not(isNull())` with `isNotNull()`**
- **Found during:** Task 1 implementation
- **Issue:** Plan code used `not(isNull(deliverables.approved_at))` but `not` is not an ergonomic Drizzle-orm filter helper for this pattern — `isNotNull()` is the correct dedicated helper
- **Fix:** Used `isNotNull()` from drizzle-orm directly, matching the pattern used in `admin-queries.ts`
- **Files modified:** src/lib/dashboard-queries.ts

**2. [Rule 2 - Correctness] Added type guard filters for nullable fields**
- **Found during:** Task 1 implementation  
- **Issue:** Mapping `approved_at` and `ended_at` after the WHERE clause still required TypeScript type narrowing (Drizzle returns nullable types regardless of WHERE)
- **Fix:** Added `.filter((d): d is ... & { approved_at: Date }` type predicates before mapping — avoids non-null assertions and is type-safe
- **Files modified:** src/lib/dashboard-queries.ts

**3. [Rule 2 - Performance] Parallel fetch for activity queries**
- **Found during:** Task 1 implementation
- **Issue:** Plan code had 4 sequential activity queries; `Promise.all` reduces latency
- **Fix:** Wrapped all 4 activity queries in `Promise.all([...])`
- **Files modified:** src/lib/dashboard-queries.ts

## Verification

- `npx tsc --noEmit`: PASSED — no errors
- `npm run build`: PASSED — 22 routes compiled, /admin shows as dynamic (ƒ) server-rendered route
- /admin no longer redirects — renders real Dashboard page
- Page is RSC — no "use client" directive in page.tsx
- KpiCard and fmt/fmtEur helpers are plain functions (not client components)

## Known Stubs

None — all KPI values and activity items come from live DB queries. If the database is empty, cards show 0/€0,00 and activity feed shows "Nessuna attività recente" — intentional, not a stub.

## Self-Check: PASSED

- [x] src/lib/dashboard-queries.ts — created (a304328)
- [x] src/app/admin/page.tsx — updated, no redirect, full dashboard RSC (40162e0)
- [x] npx tsc --noEmit passes
- [x] npm run build passes — /admin is ƒ (dynamic)
- [x] No "use client" in page.tsx
