Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e97a53b73 | |||
| 40162e03ca | |||
| a304328bd6 | |||
| d1d83011aa | |||
| 4d52d87341 | |||
| 191b548e78 | |||
| 29e0e88267 | |||
| 2283740d20 | |||
| 26a688cec8 |
+20
-3
@@ -122,10 +122,26 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
**Status**: ✅ Complete (2026-05-30)
|
**Status**: ✅ Complete (2026-05-30)
|
||||||
|
|
||||||
### Phase 6: Claude AI Onboarding (v2)
|
### Phase 6: UX Overhaul — Sidebar + Dashboard
|
||||||
**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude
|
**Goal**: L'admin apre l'app e vede subito una dashboard con la situazione aziendale; la navigazione è una sidebar persistente a sinistra invece dell'header
|
||||||
**Mode:** mvp
|
**Mode:** mvp
|
||||||
**Depends on**: Phase 5
|
**Depends on**: Phase 5
|
||||||
|
**Requirements**: UX-01, UX-02, UX-03
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. Tutte le pagine admin usano un layout con sidebar sinistra persistente — nessun header nav
|
||||||
|
2. `/admin` mostra una dashboard con: clienti attivi + revenue totale, progetti in corso, pagamenti in sospeso, attività recente
|
||||||
|
3. La lista clienti è accessibile da `/admin/clients`; i link esistenti continuano a funzionare
|
||||||
|
**Plans**: 2 plans
|
||||||
|
**Plan list**:
|
||||||
|
- [ ] 06-01-PLAN.md — Sidebar layout: AdminSidebar component + AppShell + move client list to /admin/clients
|
||||||
|
- [ ] 06-02-PLAN.md — Dashboard page: KPI cards (revenue, clienti, progetti, pagamenti) + activity feed
|
||||||
|
**UI hint**: yes
|
||||||
|
**Status**: Planning complete
|
||||||
|
|
||||||
|
### Phase 7: Claude AI Onboarding (v2)
|
||||||
|
**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude
|
||||||
|
**Mode:** mvp
|
||||||
|
**Depends on**: Phase 6
|
||||||
**Requirements**: CLAUDE-01, CLAUDE-02, CLAUDE-03
|
**Requirements**: CLAUDE-01, CLAUDE-02, CLAUDE-03
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
1. L'admin avvia il flusso Claude inserendo il brief del cliente; Claude guida step-by-step la raccolta delle informazioni necessarie
|
1. L'admin avvia il flusso Claude inserendo il brief del cliente; Claude guida step-by-step la raccolta delle informazioni necessarie
|
||||||
@@ -147,4 +163,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5
|
|||||||
| 3. Service Catalog & Quote Builder | 4/4 | ✅ Done | 2026-05-19 |
|
| 3. Service Catalog & Quote Builder | 4/4 | ✅ Done | 2026-05-19 |
|
||||||
| 4. Progetti — Multi-Project per Cliente | 5/5 | ✅ Done | 2026-05-23 |
|
| 4. Progetti — Multi-Project per Cliente | 5/5 | ✅ Done | 2026-05-23 |
|
||||||
| 5. Offer System | 4/4 | ✅ Done | 2026-05-30 |
|
| 5. Offer System | 4/4 | ✅ Done | 2026-05-30 |
|
||||||
| 6. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
|
| 6. UX Overhaul — Sidebar + Dashboard | 0/2 | Pending | - |
|
||||||
|
| 7. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
---
|
||||||
|
plan_id: 06-01
|
||||||
|
phase: 6
|
||||||
|
wave: 1
|
||||||
|
title: "Sidebar layout — AdminSidebar + AppShell + move client list to /admin/clients"
|
||||||
|
type: execute
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/app/admin/layout.tsx
|
||||||
|
- src/components/admin/NavBar.tsx
|
||||||
|
- src/app/admin/page.tsx
|
||||||
|
- src/app/admin/clients/page.tsx
|
||||||
|
- src/components/admin/AdminSidebar.tsx
|
||||||
|
requirements_addressed: [UX-01, UX-03]
|
||||||
|
autonomous: true
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "AdminLayout uses a flex-row shell: sidebar left (fixed width) + main content right"
|
||||||
|
- "NavBar.tsx is replaced by AdminSidebar.tsx — header nav no longer exists"
|
||||||
|
- "AdminSidebar has links: Dashboard, Clienti, Progetti, Offerte, Forecast, Catalogo, Impostazioni + Logout at bottom"
|
||||||
|
- "Active route is highlighted in the sidebar (usePathname)"
|
||||||
|
- "Client list (previously at /admin) is now at /admin/clients with identical functionality"
|
||||||
|
- "/admin page exists but shows a placeholder or redirects — replaced in 06-02 with real dashboard"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/components/admin/AdminSidebar.tsx"
|
||||||
|
provides: "Sidebar client component with navigation links and logout"
|
||||||
|
contains: "usePathname, signOut, all nav links"
|
||||||
|
- path: "src/app/admin/layout.tsx"
|
||||||
|
provides: "AppShell layout: flex row, sidebar + main"
|
||||||
|
contains: "AdminSidebar, flex, min-h-screen"
|
||||||
|
- path: "src/app/admin/clients/page.tsx"
|
||||||
|
provides: "Client list page moved from /admin"
|
||||||
|
contains: "getAllClientsWithPayments"
|
||||||
|
key_links:
|
||||||
|
- from: "src/components/admin/AdminSidebar.tsx"
|
||||||
|
to: "src/app/admin/layout.tsx"
|
||||||
|
via: "import"
|
||||||
|
pattern: "AdminSidebar"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Replace the top header NavBar with a persistent left sidebar. Move the client list from /admin to /admin/clients. The /admin route will be a blank placeholder for now — the real dashboard comes in 06-02.
|
||||||
|
|
||||||
|
This is a pure layout + routing refactor. No data model changes. No new queries.
|
||||||
|
</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 AdminSidebar component</name>
|
||||||
|
<files>src/components/admin/AdminSidebar.tsx</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/components/admin/NavBar.tsx — copy links and logout logic
|
||||||
|
- src/app/admin/layout.tsx — understand current layout
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
Create `src/components/admin/AdminSidebar.tsx` as a client component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
FolderOpen,
|
||||||
|
Tag,
|
||||||
|
TrendingUp,
|
||||||
|
BookOpen,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||||
|
{ href: "/admin/clients", label: "Clienti", icon: Users },
|
||||||
|
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
|
||||||
|
{ href: "/admin/offers", label: "Offerte", icon: Tag },
|
||||||
|
{ href: "/admin/forecast", label: "Forecast", icon: TrendingUp },
|
||||||
|
{ href: "/admin/catalog", label: "Catalogo", icon: BookOpen },
|
||||||
|
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AdminSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isActive = (href: string, exact?: boolean) =>
|
||||||
|
exact ? pathname === href : pathname === href || pathname.startsWith(href + "/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-56 shrink-0 min-h-screen bg-[#1A463C] flex flex-col">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="px-5 py-5 border-b border-white/10">
|
||||||
|
<span className="font-bold text-white tracking-tight text-sm">iamcavalli</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav links */}
|
||||||
|
<nav className="flex-1 px-3 py-4 flex flex-col gap-0.5">
|
||||||
|
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
|
||||||
|
const active = isActive(href, exact);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-white/15 text-white font-medium"
|
||||||
|
: "text-white/65 hover:text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={16} strokeWidth={1.8} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<div className="px-3 py-4 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 w-full rounded-md text-sm text-white/65 hover:text-white hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={16} strokeWidth={1.8} />
|
||||||
|
Esci
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Check that `lucide-react` is already a dependency (`package.json`). If not, note it as a deviation but do NOT install it — shadcn/ui already bundles it transitively in this project.
|
||||||
|
</action>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Update AdminLayout to AppShell (sidebar + main)</name>
|
||||||
|
<files>src/app/admin/layout.tsx</files>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
Replace the current vertical layout (nav on top, max-w-5xl centered main) with a horizontal AppShell:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { AdminSidebar } from "@/components/admin/AdminSidebar";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-gray-50">
|
||||||
|
{session && <AdminSidebar />}
|
||||||
|
<main className="flex-1 px-8 py-8 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the import of NavBar. The sidebar handles all navigation.
|
||||||
|
</action>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Move client list to /admin/clients/page.tsx</name>
|
||||||
|
<files>src/app/admin/clients/page.tsx, src/app/admin/page.tsx</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/app/admin/page.tsx — full content to copy
|
||||||
|
- src/app/admin/clients/ — check if page.tsx already exists
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
1. Copy the full content of `src/app/admin/page.tsx` to `src/app/admin/clients/page.tsx`.
|
||||||
|
Update the "Nuovo cliente" link href if it points to `/admin/clients/new` — it should already be correct.
|
||||||
|
Update the "Mostra archiviati" toggle link from `?archived=1` on `/admin` to `/admin/clients?archived=1`.
|
||||||
|
|
||||||
|
2. Replace `src/app/admin/page.tsx` with a minimal redirect placeholder for now (dashboard content comes in 06-02):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
// Dashboard page — content added in Phase 6 Plan 02
|
||||||
|
export default function AdminRoot() {
|
||||||
|
redirect("/admin/clients");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the app functional during the transition: clicking Dashboard in the sidebar goes to /admin which redirects to /admin/clients until the real dashboard exists.
|
||||||
|
</action>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 4: Delete NavBar.tsx and verify TypeScript builds</name>
|
||||||
|
<files>src/components/admin/NavBar.tsx</files>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
Delete `src/components/admin/NavBar.tsx` since it is fully replaced by AdminSidebar.
|
||||||
|
|
||||||
|
Run `npx tsc --noEmit` to verify no TypeScript errors. If NavBar is imported anywhere else, remove those imports.
|
||||||
|
|
||||||
|
Run `npm run build` to confirm the Next.js build passes.
|
||||||
|
</action>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- [ ] `npx tsc --noEmit` passes with no errors
|
||||||
|
- [ ] `npm run build` completes successfully
|
||||||
|
- [ ] Sidebar renders with all 7 nav links + logout button
|
||||||
|
- [ ] Active link is visually highlighted
|
||||||
|
- [ ] `/admin/clients` loads the client list correctly
|
||||||
|
- [ ] `/admin` redirects to `/admin/clients`
|
||||||
|
- [ ] No references to NavBar remain in the codebase
|
||||||
|
</verification>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
plan: "06-01"
|
||||||
|
phase: 6
|
||||||
|
subsystem: admin-layout
|
||||||
|
tags: [layout, sidebar, navigation, refactor]
|
||||||
|
dependency_graph:
|
||||||
|
requires: []
|
||||||
|
provides: [AdminSidebar, AppShell, /admin/clients route]
|
||||||
|
affects: [src/app/admin/layout.tsx, src/components/admin/AdminSidebar.tsx, src/app/admin/clients/page.tsx, src/app/admin/page.tsx]
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns: [AppShell flex-row, active route highlighting via usePathname]
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- src/components/admin/AdminSidebar.tsx
|
||||||
|
- src/app/admin/clients/page.tsx
|
||||||
|
modified:
|
||||||
|
- src/app/admin/layout.tsx
|
||||||
|
- src/app/admin/page.tsx
|
||||||
|
deleted:
|
||||||
|
- src/components/admin/NavBar.tsx
|
||||||
|
decisions:
|
||||||
|
- "AdminSidebar uses usePathname with exact-match flag for /admin to avoid it matching all /admin/* routes"
|
||||||
|
- "/admin redirects to /admin/clients as placeholder — real dashboard content in 06-02"
|
||||||
|
- "NavBar deleted entirely — AdminSidebar covers all navigation needs"
|
||||||
|
metrics:
|
||||||
|
duration: "~10 minutes"
|
||||||
|
completed: "2026-05-31"
|
||||||
|
tasks_completed: 4
|
||||||
|
tasks_total: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 6 Plan 01: Sidebar layout — AdminSidebar + AppShell + move client list to /admin/clients Summary
|
||||||
|
|
||||||
|
Replaced the top horizontal NavBar with a persistent left sidebar (AdminSidebar). Admin layout is now a flex-row AppShell. Client list moved from /admin to /admin/clients. /admin redirects to /admin/clients as temporary placeholder until dashboard is built in 06-02.
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| Task | Name | Commit | Files |
|
||||||
|
|------|------|--------|-------|
|
||||||
|
| 1 | Create AdminSidebar component | 2283740 | src/components/admin/AdminSidebar.tsx |
|
||||||
|
| 2 | Update AdminLayout to AppShell | 29e0e88 | src/app/admin/layout.tsx |
|
||||||
|
| 3 | Move client list to /admin/clients | 191b548 | src/app/admin/clients/page.tsx, src/app/admin/page.tsx |
|
||||||
|
| 4 | Delete NavBar.tsx + verify build | 4d52d87 | src/components/admin/NavBar.tsx (deleted) |
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
**AdminSidebar.tsx** — client component with:
|
||||||
|
- 7 nav links: Dashboard (exact match), Clienti, Progetti, Offerte, Forecast, Catalogo, Impostazioni
|
||||||
|
- Active route highlighting via `usePathname` with exact-match support for /admin
|
||||||
|
- Logout button at bottom with `signOut({ callbackUrl: "/admin/login" })`
|
||||||
|
- Brand green sidebar bg `#1A463C`, 224px fixed width
|
||||||
|
|
||||||
|
**AppShell layout** — `layout.tsx` is now `flex min-h-screen`: sidebar (shrink-0, w-56) left + `<main className="flex-1">` right. Removed `max-w-5xl` container constraint.
|
||||||
|
|
||||||
|
**Route migration** — `/admin/clients/page.tsx` is a direct copy of the old `/admin/page.tsx` with the archived toggle URL updated from `/admin?archived=1` to `/admin/clients?archived=1`. `/admin/page.tsx` now simply calls `redirect("/admin/clients")`.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npx tsc --noEmit`: PASSED — no errors
|
||||||
|
- `npm run build`: PASSED — all 22 routes compile, /admin/clients appears as dynamic route
|
||||||
|
- No NavBar references remain in codebase (`grep` found zero matches)
|
||||||
|
- NavBar.tsx deleted from src/components/admin/
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- [x] src/components/admin/AdminSidebar.tsx — created (2283740)
|
||||||
|
- [x] src/app/admin/layout.tsx — updated (29e0e88)
|
||||||
|
- [x] src/app/admin/clients/page.tsx — created (191b548)
|
||||||
|
- [x] src/app/admin/page.tsx — redirect placeholder (191b548)
|
||||||
|
- [x] src/components/admin/NavBar.tsx — deleted (4d52d87)
|
||||||
|
- [x] npm run build passes
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getAllClientsWithPayments } from "@/lib/admin-queries";
|
||||||
|
import { ClientRow } from "@/components/admin/ClientRow";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function AdminClientsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ archived?: string }>;
|
||||||
|
}) {
|
||||||
|
const { archived } = await searchParams;
|
||||||
|
const showArchived = archived === "1";
|
||||||
|
const clients = await getAllClientsWithPayments(showArchived);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
|
||||||
|
<a
|
||||||
|
href={showArchived ? "/admin/clients" : "/admin/clients?archived=1"}
|
||||||
|
className="text-xs text-[#71717a] hover:text-[#1A463C] underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{showArchived ? "Nascondi archiviati" : "Mostra archiviati"}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clients.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-[#71717a]">
|
||||||
|
<p>{showArchived ? "Nessun cliente archiviato." : "Nessun cliente ancora."}</p>
|
||||||
|
{!showArchived && (
|
||||||
|
<p className="mt-2">
|
||||||
|
<Link href="/admin/clients/new" className="text-[#1A463C] hover:underline font-medium">
|
||||||
|
Crea il primo cliente
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Cliente</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">LTV</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Importo incassato</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Importo da saldare</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{clients.map((client) => (
|
||||||
|
<ClientRow key={client.id} client={client} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NavBar } from "@/components/admin/NavBar";
|
import { AdminSidebar } from "@/components/admin/AdminSidebar";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
@@ -9,9 +9,11 @@ export default async function AdminLayout({
|
|||||||
}) {
|
}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="flex min-h-screen bg-gray-50">
|
||||||
{session && <NavBar />}
|
{session && <AdminSidebar />}
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8">{children}</main>
|
<main className="flex-1 px-8 py-8 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-56
@@ -1,67 +1,118 @@
|
|||||||
import Link from "next/link";
|
import { getDashboardStats } from "@/lib/dashboard-queries";
|
||||||
import { getAllClientsWithPayments } from "@/lib/admin-queries";
|
import { Users, FolderOpen, Euro, Clock } from "lucide-react";
|
||||||
import { ClientRow } from "@/components/admin/ClientRow";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
export default async function AdminDashboard({
|
function KpiCard({
|
||||||
searchParams,
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
icon: Icon,
|
||||||
|
color,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ archived?: string }>;
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
sub?: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
color: string;
|
||||||
}) {
|
}) {
|
||||||
const { archived } = await searchParams;
|
|
||||||
const showArchived = archived === "1";
|
|
||||||
const clients = await getAllClientsWithPayments(showArchived);
|
|
||||||
|
|
||||||
return (
|
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>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<p className="text-xs text-gray-500 font-medium uppercase tracking-wide">{label}</p>
|
||||||
<div className="flex items-center gap-4">
|
<p className="text-2xl font-bold text-gray-900 mt-0.5">{value}</p>
|
||||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
|
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
|
||||||
<a
|
</div>
|
||||||
href={showArchived ? "/admin" : "/admin?archived=1"}
|
</div>
|
||||||
className="text-xs text-[#71717a] hover:text-[#1A463C] underline underline-offset-2"
|
);
|
||||||
>
|
}
|
||||||
{showArchived ? "Nascondi archiviati" : "Mostra archiviati"}
|
|
||||||
</a>
|
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>
|
||||||
<Button asChild>
|
|
||||||
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{clients.length === 0 ? (
|
|
||||||
<div className="text-center py-20 text-[#71717a]">
|
|
||||||
<p>{showArchived ? "Nessun cliente archiviato." : "Nessun cliente ancora."}</p>
|
|
||||||
{!showArchived && (
|
|
||||||
<p className="mt-2">
|
|
||||||
<Link href="/admin/clients/new" className="text-[#1A463C] hover:underline font-medium">
|
|
||||||
Crea il primo cliente
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Cliente</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">LTV</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Importo incassato</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Importo da saldare</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Link</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{clients.map((client) => (
|
|
||||||
<ClientRow key={client.id} client={client} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
FolderOpen,
|
||||||
|
Tag,
|
||||||
|
TrendingUp,
|
||||||
|
BookOpen,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||||
|
{ href: "/admin/clients", label: "Clienti", icon: Users },
|
||||||
|
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
|
||||||
|
{ href: "/admin/offers", label: "Offerte", icon: Tag },
|
||||||
|
{ href: "/admin/forecast", label: "Forecast", icon: TrendingUp },
|
||||||
|
{ href: "/admin/catalog", label: "Catalogo", icon: BookOpen },
|
||||||
|
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AdminSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isActive = (href: string, exact?: boolean) =>
|
||||||
|
exact ? pathname === href : pathname === href || pathname.startsWith(href + "/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-56 shrink-0 min-h-screen bg-[#1A463C] flex flex-col">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="px-5 py-5 border-b border-white/10">
|
||||||
|
<span className="font-bold text-white tracking-tight text-sm">iamcavalli</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav links */}
|
||||||
|
<nav className="flex-1 px-3 py-4 flex flex-col gap-0.5">
|
||||||
|
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
|
||||||
|
const active = isActive(href, exact);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-white/15 text-white font-medium"
|
||||||
|
: "text-white/65 hover:text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={16} strokeWidth={1.8} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<div className="px-3 py-4 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 w-full rounded-md text-sm text-white/65 hover:text-white hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={16} strokeWidth={1.8} />
|
||||||
|
Esci
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { signOut } from "next-auth/react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export function NavBar() {
|
|
||||||
return (
|
|
||||||
<nav className="bg-[#1A463C] px-6 py-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<span className="font-bold text-white tracking-tight">iamcavalli</span>
|
|
||||||
<Link href="/admin" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
||||||
Clienti
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/projects" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
||||||
Progetti
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/analytics" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
||||||
Statistiche
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/forecast" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
||||||
Forecast
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
||||||
Catalogo
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/offers" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
||||||
Offerte
|
|
||||||
</Link>
|
|
||||||
<Link href="/admin/impostazioni" className="text-sm text-white/70 hover:text-white transition-colors">
|
|
||||||
Impostazioni
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
|
||||||
className="text-sm text-white/70 hover:text-white hover:bg-white/10"
|
|
||||||
>
|
|
||||||
Esci
|
|
||||||
</Button>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { db } from "@/db";
|
||||||
|
import { clients, projects, payments, deliverables, time_entries } from "@/db/schema";
|
||||||
|
import { eq, and, isNotNull, isNull, desc, sum, count } 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 [clientiAttiviRow] = 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 [pagamentiDaSaldareRow] = await db
|
||||||
|
.select({ total: sum(payments.amount) })
|
||||||
|
.from(payments)
|
||||||
|
.where(eq(payments.status, "da_saldare"));
|
||||||
|
|
||||||
|
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(pagamentiDaSaldareRow?.total ?? "0") || 0) +
|
||||||
|
(parseFloat(pagamentiInviataRow?.total ?? "0") || 0);
|
||||||
|
|
||||||
|
const kpi: KpiStats = {
|
||||||
|
clientiAttivi: clientiAttiviRow?.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
|
||||||
|
const [recentClients, recentProjects, recentApprovals, recentTimers] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ id: clients.id, name: clients.name, created_at: clients.created_at })
|
||||||
|
.from(clients)
|
||||||
|
.orderBy(desc(clients.created_at))
|
||||||
|
.limit(5),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({ id: projects.id, name: projects.name, created_at: projects.created_at })
|
||||||
|
.from(projects)
|
||||||
|
.orderBy(desc(projects.created_at))
|
||||||
|
.limit(5),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({ id: deliverables.id, title: deliverables.title, approved_at: deliverables.approved_at })
|
||||||
|
.from(deliverables)
|
||||||
|
.where(isNotNull(deliverables.approved_at))
|
||||||
|
.orderBy(desc(deliverables.approved_at))
|
||||||
|
.limit(5),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: time_entries.id,
|
||||||
|
ended_at: time_entries.ended_at,
|
||||||
|
duration_seconds: time_entries.duration_seconds,
|
||||||
|
})
|
||||||
|
.from(time_entries)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNotNull(time_entries.ended_at),
|
||||||
|
isNotNull(time_entries.duration_seconds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.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
|
||||||
|
.filter((d): d is typeof d & { approved_at: Date } => d.approved_at !== null)
|
||||||
|
.map((d) => ({
|
||||||
|
type: "deliverable_approvato" as const,
|
||||||
|
label: "Deliverable approvato",
|
||||||
|
detail: d.title,
|
||||||
|
timestamp: d.approved_at,
|
||||||
|
})),
|
||||||
|
...recentTimers
|
||||||
|
.filter(
|
||||||
|
(t): t is typeof t & { ended_at: Date; duration_seconds: number } =>
|
||||||
|
t.ended_at !== null && t.duration_seconds !== null
|
||||||
|
)
|
||||||
|
.map((t) => ({
|
||||||
|
type: "timer_stoppato" as const,
|
||||||
|
label: "Timer stoppato",
|
||||||
|
detail: `${Math.round(t.duration_seconds / 60)} min`,
|
||||||
|
timestamp: t.ended_at,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
return { kpi, activity };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user