Files
simone 23cb057f0b docs(07): phase 7 planning complete — 2 plans (schema migration + admin crud)
Wave 1: 07-01-PLAN.md — Unified services table (audit trail, backfill, validation)
Wave 2: 07-02-PLAN.md — Admin catalog CRUD rewire (preserve historical references)

Ready for execution: /gsd-execute-phase 7

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-11 06:17:29 +02:00

11 KiB

plan_id, phase, wave, title, type, depends_on, files_modified, requirements_addressed, autonomous, must_haves
plan_id phase wave title type depends_on files_modified requirements_addressed autonomous must_haves
06-02 6 2 Dashboard page — KPI cards + activity feed at /admin execute
06-01
src/app/admin/page.tsx
src/lib/dashboard-queries.ts
UX-02
true
truths artifacts key_links
/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
path provides contains
src/lib/dashboard-queries.ts getDashboardStats() returning KPIs and recent activity clienti attivi, revenue, pagamenti, progetti, activity
path provides contains
src/app/admin/page.tsx Dashboard RSC page with KPI cards and activity feed getDashboardStats, KpiCard
from to via pattern
src/app/admin/page.tsx src/lib/dashboard-queries.ts import 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.

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

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

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

Create `src/lib/dashboard-queries.ts`:
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 };
}
Task 2: Build dashboard page at /admin src/app/admin/page.tsx Replace the redirect placeholder (from 06-01) with the real dashboard RSC:
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>
  );
}
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)