23cb057f0b
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>
11 KiB
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 |
|
|
|
true |
|
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 };
}
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>
);
}
Run npm run build to confirm the full Next.js build passes.