docs(06): create Phase 6 UX Overhaul plans — sidebar + dashboard (2 plans, 2 waves)
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user