--- 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