Compare commits

...

8 Commits

Author SHA1 Message Date
simone 044a056480 docs(phase-18): plan 03 — mark CLEAN-01..04 complete, build verified
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 16:20:50 +02:00
simone afe4274de9 docs(18-02): complete plan — analytics consolidated into admin dashboard 2026-06-19 12:09:54 +02:00
simone c00ec25e48 docs(18-01): complete plan — remove forecast and manual quote builder routes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:09:17 +02:00
simone 268f56ccd2 chore(18-01): remove quote builder route, exclusive components, and SendQuoteModal entry point (CLEAN-02)
- Delete src/app/admin/quotes/new/page.tsx and actions.ts re-export
- Delete QuoteBuilderForm, OfferSelector, PriceOverrideInput, QuotePreview components
- Rewrite SendQuoteModal: remove "Crea Nuovo" tab and window.location navigation to quotes/new
- Remove stale JSDoc comment referencing /admin/quotes/new from admin-queries.ts
2026-06-19 12:07:48 +02:00
simone 14bdbab880 feat(phase-18): plan 02 — consolidate analytics into admin dashboard
- Merge /admin/analytics content (MetricCard, MonthlyChart, time tracking) into /admin
- AdminDashboard now accepts searchParams and year query param
- Add getAnalyticsByYear, getMonthlyCollected, getAvailableYears, getTimeByClient, getTotalTrackedHours queries
- Unify fmtEur to accept number (more robust); update KPI card callers with parseFloat()
- Add MetricCard and fmtSeconds helpers to admin/page.tsx
- YearSelector navigates to /admin?year=Y (was /admin/analytics?year=Y)
- Delete src/app/admin/analytics/page.tsx — /admin/analytics now returns 404
2026-06-19 12:07:31 +02:00
simone 7d884099aa chore(18-01): remove forecast route, sidebar entry, and revalidatePath calls (CLEAN-01)
- Remove Forecast NAV_ITEMS entry and TrendingUp import from AdminSidebar
- Delete src/app/admin/forecast/page.tsx and empty directory
- Remove revalidatePath("/admin/forecast") from assignOfferToProject, removeProjectOffer, updateProjectOfferTotal
2026-06-19 12:05:05 +02:00
simone b3670d164b docs(phase-18): create phase plan — cleanup & consolidamento
3 piani in 2 wave: 18-01 (rimozioni Forecast+QuoteBuilder),
18-02 (fusione analytics→dashboard), 18-03 (CLEAN-04 verify + build check).
ROADMAP.md aggiornato con plan list Phase 18.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:51:03 +02:00
simone 10a390239a docs: reset milestone v2.2 "Sales Loop"
Strategic reset after articulating the real sales flow (piano in
.claude/plans/glittery-sprouting-pudding.md). v2.1 delivered Offer Studio
(Phase 11+12) + CRM Attio (Phase 14); residual phases re-scoped:

- Phase 13 (Servizi Attivi) → frozen (touches post-sale portal, kept as-is)
- Phase 15 (Dashboard Revenue) → dropped (analytics merges into dashboard)
- Phase 16/17 (Proposal AI) → re-scoped into v2.2 with transcripts added

New milestone v2.2 = sales loop, Phase 18→22 (R1→R5):
- R1 Cleanup: remove forecast + manual quote builder, merge analytics into dashboard
- R2 Pipeline CRM Kanban (Pipedrive-style)
- R3 Knowledge Base (dated call transcripts per lead/client)
- R4 AI agent (admin picks offer, Claude personalizes proposal from transcripts)
- R5 Public /preventivo/[slug] page + email

New requirements: CLEAN-01..04, PIPE-01/02, KB-01/02, AI-01/02, PUB-01/02/03.
Updated ROADMAP, REQUIREMENTS, STATE, PROJECT. No code changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:37:08 +02:00
23 changed files with 1496 additions and 801 deletions
+37 -24
View File
@@ -8,15 +8,20 @@ Suite operativa per un consulente di personal branding, live su hub.iamcavalli.n
Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
## Current Milestone: v2.1 Offer Studio + Proposal AI
## Current Milestone: v2.2 Sales Loop *(reset 2026-06-19)*
**Goal:** Trasformare catalogo/offerte in una UX rapida stile Notion/Airtable (inline edit, tag, drag-to-compose), ristrutturare i tab del workspace progetto, ricostruire il CRM in stile Attio, poi chiudere con l'AI proposal builder → pagina pubblica → link pagamento.
**Contesto reset:** v2.1 ha consegnato Offer Studio (catalogo + offer editor) e il CRM Attio. Dopo aver articolato il flusso commerciale reale, le fasi residue v2.1 (13/15/16/17) sono congelate/abbandonate/ri-scopate. Piano completo: `.claude/plans/glittery-sprouting-pudding.md`.
**Target features:**
- **Catalogo & Offerte (Offer Studio)** — vista database stile Notion/Airtable per servizi e offerte: inline editing, tag multi-select con creazione al volo, quick-add, filtri/ricerca istantanei, composizione offerta via drag&drop (`@dnd-kit`) con totale live, import CSV one-shot per seed
- **Workspace progetto** — rimuovere tab "Preventivo" (UI only, `quote_items` intatto — `accepted_total` resta editabile via Payments, zero perdita funzionale verificata); nuova tab "Servizi attivi" che assorbe "Offerte" (one-shot + ricorrenti mensili, schema per tracking incassi mese-per-mese); rimuovere pagina "Forecast", statistiche revenue mensile nella Dashboard
- **CRM custom Attio-style** — tabelle/viste con inline editing, tag, filtri; assorbe i fix minori noti (FollowUpWidget IT, LeadForm types, SendQuoteModal flow)
- **Proposal AI** — preventivo builder AI (pesca cliente + offerta + info extra) → pagina pubblica HTML/CSS redesign → link pagamento (Stripe Payment Link su offerta); assorbe ex-Phase 11 (auto-provisioning al "Vinto": crea cliente+progetto+fasi+pagamenti) ed ex-Phase 12 (notifiche email Resend per invio link)
**Goal:** Chiudere il **loop di vendita** end-to-end: lead in pipeline Kanban (Pipedrive-style) → transcript datati delle call per cliente → agente AI (io scelgo l'offerta, l'AI personalizza) che genera il preventivo → pagina pubblica `/preventivo/[slug]` + invio email → vinto/perso manuale nel CRM. Il portale cliente post-vendita resta invariato.
**Target features (Phase 18→22 / R1→R5):**
- **R1 Cleanup** — rimuovere Forecast e quote builder manuale; fondere `/admin/analytics` nella Dashboard (no doppioni)
- **R2 Pipeline Kanban** — board lead stile Pipedrive con drag-drop tra stage; vinto/perso = cambio-colonna manuale
- **R3 Knowledge Base** — schema + UI per transcript datati multipli per lead/cliente
- **R4 Agente AI** — builder: admin sceglie cliente+offerta, l'AI (Claude) legge transcript + offerta → bozza preventivo; admin revisiona
- **R5 Pagina pubblica** — `/preventivo/[slug]` HTML/CSS, accept/reject → CRM, invio link via email (Resend)
**Backlog post-R5** (ex Proposal AI v2.1): Stripe Payment Link su offerta (PROP-03) + auto-provisioning cliente/progetto/fasi/pagamenti al "Vinto" (PROP-04). Phase 13 (Servizi attivi/ricorrenti post-vendita) congelata, ripescabile.
## Requirements
@@ -43,29 +48,37 @@ Shipped in v2.0 (Phases 710, in produzione su hub.iamcavalli.net):
- ✓ Preventivo pubblico `/quote/[token]`: stati draft→sent→viewed→accepted/rejected, `accepted_at` immutabile, raccolta email/note cliente — Phase 9
- ✓ CRM pipeline lead: stati, activity log, reminder follow-up in dashboard — Phase 10
Validated in v2.1 (in corso):
Validated in v2.1 (consegnato, in prod):
- ✓ Catalogo `services` come vista database (inline edit, tag multi-select, quick-add, ricerca istantanea) + consolidamento legacy — Phase 11 (OFFER-07..10, OFFER-13)
- ✓ Offer Editor: lista filtrabile/archiviabile + 3 tier A/B/C via matrice checkbox, totale live, prezzo pubblico manuale, tag 4-dimensioni, promessa di trasformazione — Phase 12 (OFFER-11, OFFER-15..18). 55 servizi reali + tag offerta caricati.
- ✓ CRM custom Attio-style: `/admin/leads` ridisegnata come tabella inline-edit (status, next_action, ecc.) + tag multi-select con creazione al volo; FollowUpWidget interamente in italiano; `LeadForm` tipizzato senza `useForm<any>`; `SendQuoteModal`/`assignQuoteToLead` senza rami di codice irraggiungibili — Phase 14 (CRM-08, CRM-09, CRM-10, CRM-11, CRM-12)
### Active
### Active — v2.2 Sales Loop (reset 2026-06-19)
**Catalogo & Offerte — Offer Studio (priorità 1):**
- [ ] Vista database stile Notion/Airtable per servizi e offerte: inline editing, tag multi-select con creazione al volo, quick-add, filtri/ricerca istantanei
- [ ] Composizione offerta via drag&drop servizi dal catalogo (`@dnd-kit`) con totale live
- [ ] Import CSV one-shot per seed pacchetti esistenti (porta d'ingresso, non fonte di verità)
- [ ] Consolidamento finale `service_catalog` + `offer_services` legacy → `services` (carry-over da v2.0)
**R1 Cleanup & Consolidamento (Phase 18):**
- [ ] Rimuovere pagina Forecast (`/admin/forecast`) + voce sidebar (CLEAN-01)
- [ ] Rimuovere quote builder manuale (`/admin/quotes/new`) (CLEAN-02)
- [ ] Fondere `/admin/analytics` nella Dashboard `/admin`, eliminare la rotta duplicata (CLEAN-03)
**Workspace progetto (priorità 2):**
- [ ] Rimuovere tab "Preventivo" (solo UI — `quote_items` resta intatto, `accepted_total` già editabile via Payments)
- [ ] Nuova tab "Servizi attivi" che assorbe "Offerte": offerte one-shot + ricorrenti mensili
- [ ] Schema per tracking incassi mese-per-mese sulle offerte ricorrenti (UI può arrivare dopo)
- [ ] Rimuovere pagina "Forecast"; statistiche revenue mensile nella Dashboard (UI dopo mockup utente)
**R2 Pipeline CRM Kanban (Phase 19):**
- [ ] Board lead stile Pipedrive con colonne per stage + drag-drop (PIPE-01)
- [ ] Vinto/perso come cambio-colonna manuale (PIPE-02)
**Proposal AI (priorità 3, ultimo miglio):**
- [ ] Preventivo Builder AI: genera proposta da cliente + offerta + info extra fornite dall'utente
- [ ] Redesign pagina pubblica preventivo (HTML/CSS) con link pagamento (Stripe Payment Link su offerta)
- [ ] Auto-provisioning al "Vinto"/accettazione: crea cliente + progetto + fasi copiate + 1-4 pagamenti (ex-Phase 11)
- [ ] Notifiche email per invio link preventivo/proposta (ex-Phase 12, Resend)
**R3 Knowledge Base Cliente (Phase 20):**
- [ ] Schema additivo transcript datati multipli per lead/cliente (KB-01)
- [ ] UI per incollare/elencare transcript nel profilo (KB-02)
**R4 Agente AI — generazione preventivo (Phase 21):**
- [ ] Builder: admin sceglie cliente+offerta, l'AI (Claude) legge transcript + offerta → bozza preventivo (AI-01)
- [ ] Admin rivede/edita la bozza prima di pubblicare (AI-02)
**R5 Pagina pubblica `/preventivo/[slug]` + email (Phase 22):**
- [ ] Pagina pubblica HTML/CSS della proposta (PUB-01)
- [ ] Accept/reject → riflesso nel CRM (PUB-02)
- [ ] Invio link proposta via email Resend (PUB-03)
**Backlog (post-R5):** Stripe Payment Link su offerta (PROP-03), auto-provisioning al "Vinto" (PROP-04), Servizi attivi/ricorrenti post-vendita (ex Phase 13).
### Out of Scope
+91 -28
View File
@@ -1,10 +1,12 @@
# Requirements — ClientHub v2.1 Offer Studio + Proposal AI
# Requirements — ClientHub (v2.1 chiuso → v2.2 Sales Loop)
**Defined:** 2026-06-13
**Defined:** 2026-06-13 · **Reset:** 2026-06-19
**Core Value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
v2.0 (Phases 710) ha consegnato catalogo unificato, offerte con fasi, preventivo pubblico e CRM pipeline (vedi MILESTONES.md, REQUIREMENTS.md archiviato in `.planning/milestones/v2.0-REQUIREMENTS.md`). v2.1 rifà la UX di catalogo/offerte in stile database-view, ristruttura il workspace progetto, ricostruisce il CRM in stile Attio e chiude il loop commerciale con un builder preventivi AI.
> **RESET 2026-06-19 — milestone v2.2 "Sales Loop".** v2.1 ha consegnato Offer Studio (Phase 11+12) e CRM Attio (Phase 14). Le fasi residue v2.1 — **13 (Servizi Attivi), 15 (Dashboard Revenue), 16/17 (Proposal AI originali)** — sono state **congelate/abbandonate/ri-scopate** dopo che l'utente ha articolato il flusso commerciale reale (vedi piano `.claude/plans/glittery-sprouting-pudding.md`). Il nuovo north-star è il **loop di vendita**: lead in pipeline Kanban → transcript call → agente AI genera preventivo → pagina pubblica `/preventivo/[slug]` → vinto/perso. Requirements attivi: sezione **v2.2** più sotto.
## Offer Studio — Catalogo & Offerte (v1)
| ID | Requirement | Status |
@@ -20,15 +22,17 @@ v2.0 (Phases 710) ha consegnato catalogo unificato, offerte con fasi, prevent
| OFFER-18 | La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate) | Complete |
| OFFER-13 | I dati legacy di `service_catalog` e `offer_services` sono consolidati in `services` senza perdita (migration additiva) | Complete |
## Workspace Progetto — Servizi Attivi (v1)
## Workspace Progetto — Servizi Attivi (ex Phase 13 — CONGELATO v2.2)
> Congelato dal reset 2026-06-19: tocca il portale post-vendita che l'utente vuole lasciare invariato. Ripescabile in futuro se serve il tracking ricavi ricorrenti. (PROJ-10 "rimuovi Forecast" è confluito in CLEAN-01 v2.2.)
| ID | Requirement | Status |
|----|-------------|--------|
| PROJ-06 | La tab "Preventivo" è rimossa dal workspace progetto (solo UI — `quote_items` resta intatta, `accepted_total` resta editabile via tab Pagamenti) | Pending |
| PROJ-07 | La tab "Offerte" diventa "Servizi attivi": mostra tutte le offerte assegnate al progetto (`project_offers`) | Pending |
| PROJ-08 | L'utente marca ogni "servizio attivo" come one-shot o ricorrente mensile | Pending |
| PROJ-09 | Per i servizi ricorrenti, l'utente registra in quali mesi è stata incassata la fattura (schema nuovo, additivo — UI può essere minimale) | Pending |
| PROJ-10 | La pagina "Forecast" e la relativa voce di sidebar sono rimosse | Pending |
| PROJ-06 | La tab "Preventivo" è rimossa dal workspace progetto (solo UI — `quote_items` resta intatta, `accepted_total` resta editabile via tab Pagamenti) | Congelato (v2.2) |
| PROJ-07 | La tab "Offerte" diventa "Servizi attivi": mostra tutte le offerte assegnate al progetto (`project_offers`) | Congelato (v2.2) |
| PROJ-08 | L'utente marca ogni "servizio attivo" come one-shot o ricorrente mensile | Congelato (v2.2) |
| PROJ-09 | Per i servizi ricorrenti, l'utente registra in quali mesi è stata incassata la fattura (schema nuovo, additivo — UI può essere minimale) | Congelato (v2.2) |
| PROJ-10 | La pagina "Forecast" e la relativa voce di sidebar sono rimosse | → CLEAN-01 (v2.2) |
## CRM Custom Attio-style (v1)
@@ -40,21 +44,67 @@ v2.0 (Phases 710) ha consegnato catalogo unificato, offerte con fasi, prevent
| CRM-11 | Il form lead (`LeadForm`) valida i campi senza workaround di type-relaxation su react-hook-form | Complete |
| CRM-12 | Il flusso `SendQuoteModal` (preventivo esistente vs nuovo) non contiene rami di codice irraggiungibili | Complete |
## Dashboard — Revenue Stats (v1, bloccato su mockup utente)
## Dashboard — Revenue Stats (ex Phase 15 — ABBANDONATO v2.2)
> Abbandonato dal reset 2026-06-19: mockup mai arrivato, valore dubbio nel nuovo flusso. L'esigenza analytics è coperta da CLEAN-03 (fusione `/admin/analytics` nella dashboard, no fase dedicata).
| ID | Requirement | Status |
|----|-------------|--------|
| DASH-11 | La Dashboard admin mostra le statistiche di revenue mensile (ex `/admin/forecast`, calcolate da `project_offers`) | Blocked — attesa mockup dashboard utente |
| DASH-11 | La Dashboard admin mostra le statistiche di revenue mensile (ex `/admin/forecast`, calcolate da `project_offers`) | Dropped (v2.2 → CLEAN-03) |
## Proposal AI (v1)
## Proposal AI — versione v2.1 originale (SUPERSEDED da v2.2)
> Ri-scopata dal reset 2026-06-19 nelle fasi v2.2 (PIPE/KB/AI/PUB) con il flusso chiaro + l'aggiunta dei transcript (che mancava). PROP-03 (Stripe Payment Link) e PROP-04 (auto-provisioning al "Vinto") restano backlog: non nel loop minimo, da rivalutare dopo R5.
| ID | Requirement | Status |
|----|-------------|--------|
| PROP-01 | L'admin avvia un builder AI che genera una proposta a partire da cliente + offerta(e) selezionata(e) + note extra fornite manualmente | Pending |
| PROP-02 | La pagina pubblica `/quote/[token]` è ridisegnata (HTML/CSS) per presentare la proposta generata | Pending |
| PROP-03 | Ogni offerta/preventivo ha un campo "link di pagamento" (Stripe Payment Link) mostrato come CTA nella pagina pubblica | Pending |
| PROP-04 | All'accettazione del preventivo, il sistema crea automaticamente cliente + progetto, copia le fasi dall'offerta e configura 1-4 pagamenti (ex-Phase 11 WIN-01..06) | Pending |
| PROP-05 | Il sistema invia via email (Resend) il link della proposta pubblica al lead (ex-Phase 12) | Pending |
| PROP-01 | L'admin avvia un builder AI che genera una proposta a partire da cliente + offerta(e) selezionata(e) + note extra fornite manualmente | Superseded → AI-01/AI-02 (v2.2) |
| PROP-02 | La pagina pubblica `/quote/[token]` è ridisegnata (HTML/CSS) per presentare la proposta generata | Superseded → PUB-01 (v2.2) |
| PROP-03 | Ogni offerta/preventivo ha un campo "link di pagamento" (Stripe Payment Link) mostrato come CTA nella pagina pubblica | Backlog (post-R5) |
| PROP-04 | All'accettazione del preventivo, il sistema crea automaticamente cliente + progetto, copia le fasi dall'offerta e configura 1-4 pagamenti (ex-Phase 11 WIN-01..06) | Backlog (post-R5) |
| PROP-05 | Il sistema invia via email (Resend) il link della proposta pubblica al lead (ex-Phase 12) | Superseded → PUB-03 (v2.2) |
## v2.2 — Sales Loop: Lead → AI → Preventivo (ATTIVO)
> Reset 2026-06-19. Il flusso commerciale articolato dall'utente sostituisce le fasi residue v2.1. Fasi eseguite in ordine R1→R5 (numerazione GSD continua: Phase 18→22). Le fasi 11/12/14 restano completate.
### Cleanup & Consolidamento (Phase 18 / R1)
| ID | Requirement | Status |
|----|-------------|--------|
| CLEAN-01 | La pagina Forecast (`/admin/forecast`) e la sua voce di sidebar sono rimosse | Complete |
| CLEAN-02 | Il quote builder manuale (`/admin/quotes/new`) e il suo entry point sono rimossi (superato dall'agente AI) | Complete |
| CLEAN-03 | Le statistiche di `/admin/analytics` sono fuse nella Dashboard `/admin` (vista unica); la rotta duplicata è eliminata | Complete |
| CLEAN-04 | Le fasi v2.1 residue (13/15/16/17) sono archiviate/ri-scopate nel planning | Complete |
### Pipeline CRM Kanban (Phase 19 / R2)
| ID | Requirement | Status |
|----|-------------|--------|
| PIPE-01 | I lead sono visualizzabili in una board Kanban stile Pipedrive con colonne per stage e drag-drop per cambiare stage | Pending |
| PIPE-02 | Spostare un lead nelle colonne "Vinto"/"Perso" è il cambio-stato manuale dell'esito | Pending |
### Knowledge Base Cliente — Transcript (Phase 20 / R3)
| ID | Requirement | Status |
|----|-------------|--------|
| KB-01 | Schema additivo per memorizzare transcript datati e multipli per lead/cliente | Pending |
| KB-02 | L'admin incolla ed elenca i transcript (storico ordinato per data) nel dettaglio lead/cliente | Pending |
### Agente AI — Generazione preventivo (Phase 21 / R4)
| ID | Requirement | Status |
|----|-------------|--------|
| AI-01 | L'admin seleziona cliente + offerta; l'agente AI (Claude) legge i transcript + i dati dell'offerta e genera una bozza di preventivo personalizzata | Pending |
| AI-02 | L'admin rivede ed edita la bozza generata prima di pubblicarla | Pending |
### Pagina pubblica preventivo + email (Phase 22 / R5)
| ID | Requirement | Status |
|----|-------------|--------|
| PUB-01 | La proposta generata è pubblicata come pagina pubblica HTML/CSS a `/preventivo/[slug]` (evolve la meccanica di `/quote/[token]`) | Pending |
| PUB-02 | Il cliente accetta/rifiuta dalla pagina; l'esito si riflette nel CRM (conferma vinto/perso manuale lato admin) | Pending |
| PUB-03 | Il link della proposta viene inviato al lead via email (Resend) | Pending |
## v2 Requirements (Deferred)
@@ -101,23 +151,36 @@ Popolata dal roadmapper durante la creazione della roadmap.
| OFFER-17 | Phase 12 | Complete |
| OFFER-18 | Phase 12 | Complete |
| OFFER-12 | Deferred (v2) | Rimandato 2026-06-14 |
| PROJ-06 | Phase 13 | Pending |
| PROJ-07 | Phase 13 | Pending |
| PROJ-08 | Phase 13 | Pending |
| PROJ-09 | Phase 13 | Pending |
| PROJ-10 | Phase 13 | Pending |
| PROJ-06 | Phase 13 | Congelato (v2.2) |
| PROJ-07 | Phase 13 | Congelato (v2.2) |
| PROJ-08 | Phase 13 | Congelato (v2.2) |
| PROJ-09 | Phase 13 | Congelato (v2.2) |
| PROJ-10 | Phase 13 | → CLEAN-01 |
| CRM-08 | Phase 14 | Complete |
| CRM-09 | Phase 14 | Complete |
| CRM-10 | Phase 14 | Complete |
| CRM-11 | Phase 14 | Complete |
| CRM-12 | Phase 14 | Complete |
| DASH-11 | Phase 15 | Blocked |
| PROP-03 | Phase 16 | Pending |
| PROP-04 | Phase 16 | Pending |
| PROP-01 | Phase 17 | Pending |
| PROP-02 | Phase 17 | Pending |
| PROP-05 | Phase 17 | Pending |
| DASH-11 | Phase 15 | Dropped (→ CLEAN-03) |
| PROP-03 | Phase 16 | Backlog (post-R5) |
| PROP-04 | Phase 16 | Backlog (post-R5) |
| PROP-01 | Phase 17 | Superseded → AI-01/02 |
| PROP-02 | Phase 17 | Superseded → PUB-01 |
| PROP-05 | Phase 17 | Superseded → PUB-03 |
| CLEAN-01 | Phase 18 (R1) | Complete |
| CLEAN-02 | Phase 18 (R1) | Complete |
| CLEAN-03 | Phase 18 (R1) | Complete |
| CLEAN-04 | Phase 18 (R1) | Complete |
| PIPE-01 | Phase 19 (R2) | Pending |
| PIPE-02 | Phase 19 (R2) | Pending |
| KB-01 | Phase 20 (R3) | Pending |
| KB-02 | Phase 20 (R3) | Pending |
| AI-01 | Phase 21 (R4) | Pending |
| AI-02 | Phase 21 (R4) | Pending |
| PUB-01 | Phase 22 (R5) | Pending |
| PUB-02 | Phase 22 (R5) | Pending |
| PUB-03 | Phase 22 (R5) | Pending |
---
*Requirements defined: 2026-06-13*
*Last updated: 2026-06-18 — Phase 12 complete: OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18 → Complete*
*Last updated: 2026-06-19 RESET v2.2 "Sales Loop": Phase 13 congelata, 15 abbandonata, 16/17 ri-scopate; nuovi requirements CLEAN/PIPE/KB/AI/PUB (Phase 18→22)*
+93 -13
View File
@@ -1,23 +1,32 @@
# Roadmap: ClientHub v2.1 Offer Studio + Proposal AI
# Roadmap: ClientHub v2.1 (chiuso) → v2.2 Sales Loop
## Overview
**v2.0 (Phases 110, Complete)**: ClientHub è una suite operativa completa — catalogo servizi unificato, offerte con fasi, preventivo pubblico (`/quote/[token]`), CRM pipeline lead con activity log. Archiviato in `.planning/milestones/v2.0-ROADMAP.md`.
**v2.1 (Phases 1117, Planning)**: Rifare la UX di catalogo/offerte in stile database-view (Notion-like), ristrutturare il workspace progetto (Preventivo → Servizi Attivi, rimozione Forecast), ricostruire il CRM in stile Attio/Pipedrive (inline edit, tag, fix bug residui), e chiudere il loop commerciale con un builder preventivi AI + invio email + auto-provisioning. Numerazione fasi: v2.0 ha chiuso a Phase 10; v2.1 parte da Phase 11.
**v2.1 (Phases 1117) — PARZIALE / CHIUSO dal reset 2026-06-19**: ha consegnato Offer Studio (Phase 11 catalogo database-view + Phase 12 offer editor tier A/B/C) e CRM Attio-style (Phase 14). Le fasi residue **13/15/16/17 sono congelate/abbandonate/ri-scopate** (vedi sotto).
**v2.2 (Phases 1822, Sales Loop) — ATTIVO**: dopo che l'utente ha articolato il flusso commerciale reale (piano `.claude/plans/glittery-sprouting-pudding.md`), il focus diventa il **loop di vendita**: cleanup dei doppioni → pipeline CRM Kanban (Pipedrive-style) → knowledge base transcript per cliente → agente AI che genera il preventivo → pagina pubblica `/preventivo/[slug]` + email. Numerazione GSD continua: v2.2 parte da Phase 18. Le fasi R1R5 mappano su Phase 1822.
## Phases
- [x] **Phase 11: Catalog Database-View UX & Legacy Consolidation** - Catalogo `services` come tabella con inline edit, tag multi-select, quick-add, ricerca istantanea; consolidamento `service_catalog`/`offer_services``services`
- [x] **Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico** - Editor offerte completo: lista filtrabile + 3 tier (A/B/C) via matrice checkbox sui servizi `services`, totale live per tier, prezzo pubblico manuale, tag multi-dimensione, promessa di trasformazione (import CSV/Notion rimandato) (completed 2026-06-15)
- [ ] **Phase 13: Workspace — Servizi Attivi** - Tab "Preventivo" rimossa (UI-only), "Offerte" → "Servizi attivi" (one-shot/ricorrenti + tracking incassi mensili), rimozione pagina Forecast
- [x] **Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico** - Editor offerte completo: lista filtrabile + 3 tier (A/B/C) via matrice checkbox sui servizi `services`, totale live per tier, prezzo pubblico manuale, tag multi-dimensione, promessa di trasformazione (completed 2026-06-18)
- [~] **Phase 13: Workspace — Servizi Attivi** - ❌ CONGELATA (reset 2026-06-19): tocca il post-vendita lasciato invariato; ripescabile in futuro
- [x] **Phase 14: CRM Attio-style & Fix** - Redesign `/admin/leads` stile Attio/Pipedrive (inline edit + tag), fix FollowUpWidget IT / LeadForm types / SendQuoteModal rami irraggiungibili (completed 2026-06-14)
- [ ] **Phase 15: Dashboard Revenue Stats** - Dashboard admin mostra statistiche revenue mensile (ex `/admin/forecast`) da `project_offers`**BLOCCATA**, attesa mockup utente
- [ ] **Phase 16: Proposal AI — Data Foundations & Auto-Provisioning** - Campo Stripe Payment Link su offerte/preventivi + auto-provisioning (client+progetto+fasi+pagamenti) all'accettazione
- [ ] **Phase 17: Proposal AI — Builder, Pagina Pubblica & Email** - Builder AI proposta da cliente+offerta+note, redesign `/quote/[token]`, invio email Resend con link proposta
- [~] **Phase 15: Dashboard Revenue Stats** - ❌ ABBANDONATA (reset 2026-06-19): mockup mai arrivato; analytics confluisce nella dashboard via CLEAN-03
- [~] **Phase 16: Proposal AI — Data Foundations & Auto-Provisioning** - ❌ RI-SCOPATA in v2.2; Stripe Payment Link + auto-provisioning → backlog post-R5
- [~] **Phase 17: Proposal AI — Builder, Pagina Pubblica & Email** - ❌ RI-SCOPATA in v2.2 (Phase 21/22 con transcript + flusso chiaro)
- [ ] **Phase 18: Cleanup & Consolidamento** - Rimuovi Forecast + quote builder manuale; fondi `/admin/analytics` nella dashboard; archivia fasi residue v2.1
- [ ] **Phase 19: Pipeline CRM Kanban** - Board lead stile Pipedrive con colonne per stage + drag-drop; vinto/perso come cambio-colonna manuale
- [ ] **Phase 20: Knowledge Base Cliente** - Schema additivo + UI per incollare transcript datati multipli per lead/cliente
- [ ] **Phase 21: Agente AI — generazione preventivo** - Builder: admin sceglie cliente+offerta, l'AI (Claude) legge transcript + offerta e genera bozza preventivo; admin revisiona
- [ ] **Phase 22: Pagina pubblica preventivo + email** - Pagina pubblica HTML/CSS, accept/reject → CRM, invio link via email (Resend)
## Phase Details
> **Reset 2026-06-19**: i dettagli delle fasi 13/15/16/17 sotto sono conservati per storico ma quelle fasi sono **cancellate/congelate** (vedi checklist Phases sopra e Progress sotto). Le fasi attive sono **1822 (R1R5)**, in fondo a questa sezione.
### Phase 11: Catalog Database-View UX & Legacy Consolidation
**Goal**: L'utente vede ed edita il catalogo servizi come una tabella stile database (Notion-like) — inline editing, tag multi-select, quick-add, ricerca istantanea — su un'unica tabella `services` consolidata senza perdita di dati legacy
**Mode:** migration + ui
@@ -137,14 +146,85 @@ Plans:
**UI hint**: yes
**Status**: Pending planning
---
## v2.2 — Sales Loop (fasi attive)
### Phase 18: Cleanup & Consolidamento
**Goal**: Sgomberare il campo dai doppioni e dal peso morto prima di costruire il loop di vendita, senza perdere dati.
**Mode:** ui (solo rimozioni/rotte, nessun drop di tabelle)
**Depends on**: nessuna (può partire subito)
**Requirements**: CLEAN-01, CLEAN-02, CLEAN-03, CLEAN-04
**Success Criteria** (what must be TRUE):
1. La pagina Forecast (`/admin/forecast`) e la sua voce di sidebar non esistono più
2. Il quote builder manuale (`/admin/quotes/new`) e il suo entry point non esistono più
3. Le statistiche annuali di `/admin/analytics` sono visibili nella Dashboard `/admin` come vista unica; la rotta `/admin/analytics` è eliminata (nessun doppione)
4. Le fasi v2.1 residue (13/15/16/17) sono marcate cancellate/congelate nel planning
**Plans**: 3 plans
**Plan list**:
- [ ] 18-01-PLAN.md — Rimozione Forecast + Quote Builder manuale (CLEAN-01, CLEAN-02)
- [ ] 18-02-PLAN.md — Fusione analytics nella Dashboard, eliminazione rotta /admin/analytics (CLEAN-03)
- [ ] 18-03-PLAN.md — Verifica CLEAN-04 planning docs + build check + checkpoint visuale
**Status**: Planned — ready for `/gsd-execute-phase 18`
### Phase 19: Pipeline CRM Kanban
**Goal**: I lead si gestiscono in una board Kanban stile Pipedrive con drag-drop tra gli stage; vinto/perso è un cambio-colonna manuale.
**Mode:** ui
**Depends on**: Phase 14 (CRM lead esistente). Riusa `@dnd-kit` (pattern KanbanBoard progetti).
**Requirements**: PIPE-01, PIPE-02
**Success Criteria** (what must be TRUE):
1. I lead sono mostrati in colonne per stage (contacted → qualified → proposal_sent → negotiating → won/lost) con drag-drop che aggiorna `leads.status`
2. La tabella inline-edit esistente resta disponibile come vista alternativa
3. Spostare un lead su "Vinto"/"Perso" è l'azione manuale che ne registra l'esito
**Status**: Pending planning — `/gsd-plan-phase 19`
### Phase 20: Knowledge Base Cliente
**Goal**: Memorizzare i transcript datati delle call (multipli per lead/cliente) e mostrarli nel profilo, pronti per essere dati in pasto all'AI.
**Mode:** schema + ui
**Depends on**: Phase 14 (lead) / clienti. Migration a prod via SSH PRIMA del codice dipendente.
**Requirements**: KB-01, KB-02
**Success Criteria** (what must be TRUE):
1. Esiste uno schema additivo `client_transcripts` (lead_id/client_id, testo, data, titolo/tipo, created_at)
2. L'admin incolla un transcript con data dal dettaglio lead/cliente
3. I transcript sono elencati in ordine cronologico nel profilo
**Status**: Pending planning — `/gsd-plan-phase 20`
### Phase 21: Agente AI — generazione preventivo
**Goal**: L'admin seleziona cliente + offerta; l'agente AI (Claude) legge i transcript + i dati dell'offerta e genera una bozza di preventivo personalizzata, che l'admin può rivedere ed editare.
**Mode:** ai + ui
**Depends on**: Phase 20 (transcript) + DB Offerte (Phase 11/12, fatto)
**Requirements**: AI-01, AI-02
**Success Criteria** (what must be TRUE):
1. Dal builder l'admin sceglie cliente + offerta e lancia la generazione
2. L'AI produce una bozza di preventivo che riflette i transcript del cliente e i contenuti dell'offerta
3. L'admin rivede/edita la bozza prima di pubblicarla
**Note**: decisioni di design AI (modello Claude, prompt, storage output) in fase di planning dedicata
**Status**: Pending planning — `/gsd-plan-phase 21`
### Phase 22: Pagina pubblica preventivo + email
**Goal**: Pubblicare la proposta come pagina pubblica a `/preventivo/[slug]`, con accept/reject che si riflette nel CRM, e inviare il link via email.
**Mode:** ui
**Depends on**: Phase 21 (modello dati proposta). Evolve la meccanica di `/quote/[token]`.
**Requirements**: PUB-01, PUB-02, PUB-03
**Success Criteria** (what must be TRUE):
1. La proposta generata è visibile come pagina pubblica HTML/CSS a `/preventivo/[slug]`
2. Il cliente accetta/rifiuta dalla pagina; l'esito è disponibile lato admin per la conferma vinto/perso nel CRM
3. Il link della proposta viene inviato al lead via email (Resend)
**Status**: Pending planning — `/gsd-plan-phase 22`
## Progress
| Phase | Plans | Status | Completed |
|-------|-------|--------|-----------|
| 11. Catalog Database-View UX & Legacy Consolidation | 4/4 | ✅ Done | 2026-06-13 |
| 12. Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico | 5/5 | Complete | 2026-06-18 |
| 13. Workspace — Servizi Attivi | — | Pending planning | — |
| 14. CRM Attio-style & Fix | 3/3 | Complete | 2026-06-14 |
| 15. Dashboard Revenue Stats | — | Blocked (mockup utente) | — |
| 16. Proposal AI — Data Foundations & Auto-Provisioning | — | Pending planning | — |
| 17. Proposal AI — Builder, Pagina Pubblica & Email | — | Pending planning | — |
| 12. Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico | 5/5 | ✅ Done | 2026-06-18 |
| 13. Workspace — Servizi Attivi | — | ❌ Congelata (v2.2) | — |
| 14. CRM Attio-style & Fix | 3/3 | ✅ Done | 2026-06-14 |
| 15. Dashboard Revenue Stats | — | ❌ Abbandonata (v2.2) | — |
| 16. Proposal AI — Data Foundations & Auto-Provisioning | — | ❌ Ri-scopata (v2.2) | — |
| 17. Proposal AI — Builder, Pagina Pubblica & Email | — | ❌ Ri-scopata (v2.2) | — |
| 18 (R1). Cleanup & Consolidamento | — | Pending planning | — |
| 19 (R2). Pipeline CRM Kanban | — | Pending planning | — |
| 20 (R3). Knowledge Base Cliente (transcript) | — | Pending planning | — |
| 21 (R4). Agente AI — generazione preventivo | — | Pending planning | — |
| 22 (R5). Pagina pubblica preventivo + email | — | Pending planning | — |
+30 -22
View File
@@ -1,17 +1,17 @@
---
gsd_state_version: 1.0
milestone: v2.1
milestone_name: milestone
status: completed
stopped_at: "Phase 12 complete — Offer Editor (Tier A/B/C, Tag & Prezzo Pubblico) eseguita e verificata. Prossima: Phase 13 (Workspace Servizi Attivi) o Phase 14 (CRM)."
last_updated: "2026-06-18T15:23:00.000Z"
last_activity: 2026-06-18 -- Phase 12 execution complete (5/5 piani, build gate passed, OFFER-11/15/16/17/18 verified)
milestone: v2.2
milestone_name: — Sales Loop
status: executing
stopped_at: "Phase 12 re-scoped (mockup utente → editor offerte) e pianificata: 5 piani / 4 wave, planner + plan-checker pass. Pronta per /gsd-execute-phase 12"
last_updated: "2026-06-19T10:25:00.000Z"
last_activity: 2026-06-19 -- Phase 18 Plan 02 complete (analytics merged into /admin, /admin/analytics deleted)
progress:
total_phases: 7
total_phases: 12
completed_phases: 3
total_plans: 12
total_plans: 15
completed_plans: 12
percent: 100
percent: 80
---
# Project State
@@ -22,16 +22,20 @@ See: .planning/PROJECT.md (updated 2026-06-13)
**Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
**Current focus:** Phase 13 (Workspace Servizi Attivi) o Phase 14 (CRM Attio-style) — entrambe eseguibili. Phase 15 resta bloccata su mockup utente. Phase 12 (Offer Editor) completata e verificata.
**Current focus:** Milestone **v2.2 "Sales Loop"** (reset 2026-06-19). North-star: lead in pipeline Kanban → transcript call → agente AI genera preventivo → pagina pubblica `/preventivo/[slug]` → vinto/perso. Piano: `.claude/plans/glittery-sprouting-pudding.md`. Prossimo passo: pianificare Phase 18 (R1) Cleanup.
## Current Position
Phase: 13 (next) — ready to plan/execute
Plan:
Status: Phase 12 complete. Pronto per /gsd-execute-phase 13 o /gsd-execute-phase 14.
Last activity: 2026-06-18 -- Phase 12 execution complete (OFFER-11/15/16/17/18 all verified PASS)
Phase: 18 (R1) Cleanup & Consolidamento
Plan: 02 complete (03 next)
Status: Executing
Last activity: 2026-06-19 -- Phase 18 Plan 02 complete (analytics merged into /admin, /admin/analytics deleted)
Progress: [█████████] 100%
Progress (v2.2): [██░░░░░░░░] 20% — Phase 18 in progress (01, 02 done)
### Fasi completate (v2.1, storico)
Phase 11 (catalogo), Phase 12 (offer editor), Phase 14 (CRM Attio) — consegnate e in prod. 55 servizi reali + tag offerta caricati.
## Performance Metrics
@@ -66,7 +70,8 @@ Progress: [██████████] 100%
Decisions are logged in PROJECT.md Key Decisions table.
Recent decisions affecting current work:
- v2.1 roadmap: Offer Studio (Phases 11-15) sequenced before Proposal AI (Phases 16-17) — clean/fast data UX before the AI builder
- **[RESET 2026-06-19] Milestone v2.2 "Sales Loop"** sostituisce le fasi residue v2.1. Decisioni bloccate: (1) URL preventivo = `/preventivo/[slug]` pubblico; (2) tagliare Forecast + quote builder manuale + Phase 15, fondere `/admin/analytics` nella dashboard; (3) portale post-vendita resta core, non si tocca (Phase 13 congelata); (4) agente AI = "io scelgo l'offerta, l'AI personalizza" leggendo i transcript, provider Claude. Piano: `.claude/plans/glittery-sprouting-pudding.md`
- [SUPERSEDED dal reset] v2.1 roadmap: Offer Studio (Phases 11-15) sequenced before Proposal AI (Phases 16-17) — clean/fast data UX before the AI builder
- Phase 11 bundles catalog database-view UX (OFFER-07..10) with legacy consolidation (OFFER-13) since the new UX should be built on a single unified `services` table, not on top of legacy `service_catalog`/`offer_services`
- Phase 13 (Workspace — Servizi Attivi) is independent of Phases 11/12 — can execute in parallel order if useful, but numbered after for narrative flow
- Phase 15 (Dashboard Revenue Stats / DASH-11) is isolated and BLOCKED on user-provided mockup; no other phase depends on it — can be deferred/skipped without blocking Phase 16/17
@@ -75,6 +80,8 @@ Recent decisions affecting current work:
- [Phase 11]: Phase 11 Plan 02: onConflictDoNothing() without explicit target compiles cleanly for tags table (single unique index tags_entity_name_unique) — used as written in plan, no fallback needed — Avoids unnecessary deviation; Drizzle's no-target ON CONFLICT DO NOTHING is correct given the single unique constraint from Plan 01
- [Phase 11]: Phase 11 Plan 03: removed the plan's prescribed value-sync useEffect (and a follow-up render-time ref-read attempt) from EditableCell — both violate this project's react-hooks lint rules (set-state-in-effect, refs-during-render / React Compiler). tempValue is now only (re)initialized in startEdit()/cancel(), and the toggle display branch reads `value` directly instead of `tempValue` — Rule 1 lint fix, no behavioral change to the 8 spec'd test behaviors
- [Phase 11]: Phase 11 Plan 04: left `createService`/`serviceSchema` in `src/app/admin/catalog/actions.ts` as unused dead code after deleting `ServiceForm.tsx` (its only consumer) — `actions.ts` was outside this plan's `files_modified` scope and `updateService` still depends on `serviceSchema`; logged to deferred-items.md for future cleanup
- [Phase 18-02]: fmtEur unified to number version (analytics/page.tsx variant); KPI card callers using DB string values wrapped with parseFloat() — cleaner than maintaining two named variants
- [Phase 18-02]: /admin/analytics route deleted; YearSelector now routes to /admin?year=Y — single admin entry point for statistics (CLEAN-03)
### Pending Todos
@@ -84,9 +91,10 @@ None yet.
### Blockers/Concerns
- **Phase 15 (DASH-11)**: Blocked pending dashboard mockup from user. Do not start until mockup provided; safe to skip in execution order without impacting Phase 16/17.
- **Migrations**: Per CLAUDE.md, every phase touching schema (11 legacy consolidation, 13 recurring-revenue tracking, 16 payment link + provisioning) MUST have its migration applied to prod via SSH+docker exec BEFORE pushing dependent code.
- Phase 11 Plan 1 (11-01): DB-execution steps blocked — DATABASE_URL (178.104.27.55:54321) firewalled, reachable only via SSH+docker exec on the production host (out of scope for this agent's authorization). Required before OFFER-13/D-02 are satisfied: run scripts/push-11-tags-migration.ts, scripts/migrate-services.ts, scripts/validate-services-migration.ts, scripts/migrate-tags.ts, scripts/validate-tags-migration.ts via an SSH-accessible session (see 11-01-SUMMARY.md 'Issues Encountered' for exact commands). OFFER-13 requirement NOT yet marked complete.
- **Migrations (sempre valido)**: ogni fase con schema (es. Phase 20 transcript) DEVE avere la migration applicata a prod via tunnel SSH (`ssh -L 54321:localhost:54321 root@178.104.27.55`, `DATABASE_URL` riscritto a `127.0.0.1:54321`) PRIMA di pushare il codice dipendente. `drizzle-kit generate` rotto → SQL a mano.
- **Phase 21 (AI)**: nessuna integrazione AI ancora nel codice. Servirà chiave Anthropic API in env (Coolify) + decisioni modello/prompt in fase di planning.
- **Debito tecnico (non bloccante)**: tabelle legacy `service_catalog`/`offer_services`/`offer_micro_services` restano come deadweight; `createService`/`serviceSchema` dead code in `catalog/actions.ts`. Script di validazione consolidamento Phase 11 (migrate/validate) mai eseguiti ma OFFER-13 è di fatto soddisfatto (catalogo `services` in uso, 55 servizi reali caricati).
- **[RISOLTO]** ~~Phase 15 bloccata su mockup~~ → fase abbandonata dal reset 2026-06-19.
## Deferred Items
@@ -100,6 +108,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-06-14T19:04:45.211Z
Stopped at: Phase 12 re-scoped (mockup utente → editor offerte) e pianificata: 5 piani / 4 wave, planner + plan-checker pass. Pronta per /gsd-execute-phase 12
Resume file: .planning/phases/12-offer-composition-drag-drop-csv-import/12-01-PLAN.md
Last session: 2026-06-19T10:25:00.000Z
Stopped at: Phase 18 Plan 02 complete — CLEAN-03 done. Next: 18-03-PLAN.md
Resume file: .planning/phases/18-cleanup-consolidamento/18-03-PLAN.md
@@ -0,0 +1,233 @@
---
phase: 18-cleanup-consolidamento
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/components/admin/AdminSidebar.tsx
- src/app/admin/forecast/page.tsx
- src/app/admin/quotes/new/page.tsx
- src/components/admin/leads/SendQuoteModal.tsx
- src/app/admin/projects/project-actions.ts
autonomous: true
requirements:
- CLEAN-01
- CLEAN-02
must_haves:
truths:
- "La rotta /admin/forecast non è più raggiungibile (404 o redirect)"
- "La voce 'Forecast' non appare più nella sidebar admin"
- "La rotta /admin/quotes/new non è più raggiungibile"
- "SendQuoteModal non ha più il bottone che naviga a /admin/quotes/new"
- "project-actions.ts non chiama più revalidatePath('/admin/forecast')"
artifacts:
- path: "src/components/admin/AdminSidebar.tsx"
provides: "Sidebar senza voce Forecast"
contains: "NON deve contenere href: \"/admin/forecast\""
- path: "src/app/admin/forecast/page.tsx"
provides: "File eliminato (rotta rimossa)"
- path: "src/app/admin/quotes/new/page.tsx"
provides: "File eliminato (rotta rimossa)"
- path: "src/components/admin/leads/SendQuoteModal.tsx"
provides: "Modal senza bottone navigazione a quotes/new"
- path: "src/app/admin/projects/project-actions.ts"
provides: "Server actions senza revalidatePath forecast"
key_links:
- from: "AdminSidebar.tsx NAV_ITEMS"
to: "/admin/forecast"
via: "href nel NAV_ITEMS array"
pattern: "forecast"
- from: "SendQuoteModal.tsx"
to: "/admin/quotes/new"
via: "window.location.href"
pattern: "quotes/new"
---
<objective>
Rimuovere le rotte /admin/forecast e /admin/quotes/new e tutti i loro entry point.
Purpose: CLEAN-01 e CLEAN-02 — eliminare feature obsolete prima del Sales Loop. Il forecast è sostituito dal loop AI; il quote builder manuale è sostituito dall'agente AI in Phase 21.
Output: Due rotte rimosse, sidebar pulita, SendQuoteModal senza entry point obsoleto, project-actions.ts senza revalidatePath forecast.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Rimuovi rotta Forecast — file, sidebar, revalidatePath (CLEAN-01)</name>
<read_first>
- src/components/admin/AdminSidebar.tsx — array NAV_ITEMS, riga con href: "/admin/forecast"
- src/app/admin/forecast/page.tsx — conferma contenuto prima di eliminare
- src/app/admin/projects/project-actions.ts — righe 138, 145, 161 con revalidatePath("/admin/forecast")
</read_first>
<files>
src/components/admin/AdminSidebar.tsx,
src/app/admin/projects/project-actions.ts
</files>
<action>
1. In AdminSidebar.tsx: rimuovi la riga dell'oggetto Forecast dall'array NAV_ITEMS:
`{ href: "/admin/forecast", label: "Forecast", icon: TrendingUp },`
Rimuovi anche l'import `TrendingUp` da lucide-react se non è usato altrove nel file.
2. In project-actions.ts: rimuovi le tre chiamate `revalidatePath("/admin/forecast")` alle righe ~138, ~145, ~161.
Ogni funzione che la contiene (addProjectOffer, removeProjectOffer, updateProjectOfferTotal)
mantiene le proprie altre revalidatePath (es. `/admin/projects/${projectId}`) — togli SOLO le righe forecast.
3. Elimina il file della rotta forecast:
- src/app/admin/forecast/page.tsx
Elimina anche la directory se è rimasta vuota:
- src/app/admin/forecast/ (directory)
NON toccare: tabelle DB, lib/forecast-queries.ts (deadweight tollerabile, non blocca nulla),
nessun'altra pagina o componente.
</action>
<verify>
<automated>
grep -r "admin/forecast" /Users/simonecavalli/Vault/IAMCAVALLI/src --include="*.tsx" --include="*.ts" | grep -v "node_modules" | grep -v ".next" | wc -l
</automated>
Risultato atteso: 0 righe (nessun riferimento a /admin/forecast rimasto nel codice sorgente).
Verifica aggiuntiva sidebar:
grep "TrendingUp\|forecast" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/AdminSidebar.tsx
Risultato atteso: nessun output (zero match).
</verify>
<acceptance_criteria>
- grep -r "admin/forecast" src/ produce 0 risultati (esclusi node_modules e .next)
- Il file src/app/admin/forecast/page.tsx non esiste più
- AdminSidebar.tsx non contiene la stringa "forecast" né "TrendingUp" (se rimosso)
- project-actions.ts non contiene revalidatePath("/admin/forecast")
</acceptance_criteria>
<done>
La voce Forecast è assente dalla sidebar, la rotta /admin/forecast è 404, nessun server action
chiama revalidatePath su quella rotta.
</done>
</task>
<task type="auto">
<name>Task 2: Rimuovi rotta Quote Builder manuale e il suo entry point (CLEAN-02)</name>
<read_first>
- src/app/admin/quotes/new/page.tsx — conferma contenuto prima di eliminare
- src/components/admin/leads/SendQuoteModal.tsx — riga ~120 con window.location.href = /admin/quotes/new
- src/components/admin/quotes/QuoteBuilderForm.tsx — identifica che è usato solo da quotes/new
- src/components/admin/quotes/OfferSelector.tsx — identifica che è usato solo da QuoteBuilderForm
- src/components/admin/quotes/PriceOverrideInput.tsx — stesso
- src/components/admin/quotes/QuotePreview.tsx — stesso
</read_first>
<files>
src/app/admin/quotes/new/page.tsx,
src/components/admin/leads/SendQuoteModal.tsx,
src/components/admin/quotes/QuoteBuilderForm.tsx,
src/components/admin/quotes/OfferSelector.tsx,
src/components/admin/quotes/PriceOverrideInput.tsx,
src/components/admin/quotes/QuotePreview.tsx
</files>
<action>
1. In SendQuoteModal.tsx: trova il blocco che naviga a /admin/quotes/new:
`window.location.href = /admin/quotes/new?lead_id=${leadId};`
Rimuovi il pulsante / branch che lo contiene. Se il modal ha una logica "crea nuovo preventivo"
che porta a quella rotta, rimuovi quella branch. Mantieni tutto il resto del modal intatto
(il flusso "preventivo esistente" non va toccato).
Prima di modificare, leggi il file per capire la struttura esatta del branch da eliminare.
2. Elimina i file della rotta e i componenti esclusivi:
- src/app/admin/quotes/new/page.tsx
- src/components/admin/quotes/QuoteBuilderForm.tsx
- src/components/admin/quotes/OfferSelector.tsx
- src/components/admin/quotes/PriceOverrideInput.tsx
- src/components/admin/quotes/QuotePreview.tsx
Elimina le directory rimaste vuote:
- src/app/admin/quotes/new/ (directory)
- src/components/admin/quotes/ (directory, solo se completamente vuota dopo la rimozione)
NON toccare: src/app/admin/quotes/ se esistono altre sotto-rotte (verifica prima),
lib/admin-queries.ts (getAllOfferMacrosWithMicros — usata altrove), DB.
ATTENZIONE — verifica preventiva: prima di eliminare src/components/admin/quotes/,
esegui: grep -r "quotes/" src/components --include="*.tsx" --include="*.ts"
per confermare che i quattro componenti sopra non sono importati da altri file al di fuori di quotes/new.
Se ci sono altri consumer, NON eliminare il componente e segnala in SUMMARY.
</action>
<verify>
<automated>
grep -r "quotes/new" /Users/simonecavalli/Vault/IAMCAVALLI/src --include="*.tsx" --include="*.ts" | grep -v "node_modules" | grep -v ".next" | wc -l
</automated>
Risultato atteso: 0 righe.
Verifica esistenza file:
test -f /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/quotes/new/page.tsx && echo "ESISTE_ANCORA" || echo "OK_RIMOSSO"
Risultato atteso: OK_RIMOSSO
Verifica SendQuoteModal:
grep "quotes/new" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/SendQuoteModal.tsx
Risultato atteso: nessun output.
</verify>
<acceptance_criteria>
- grep -r "quotes/new" src/ produce 0 risultati
- src/app/admin/quotes/new/page.tsx non esiste più
- src/components/admin/quotes/QuoteBuilderForm.tsx non esiste più
- SendQuoteModal.tsx non contiene la stringa "quotes/new"
</acceptance_criteria>
<done>
La rotta /admin/quotes/new è 404, il pulsante di navigazione nel SendQuoteModal è rimosso,
i componenti esclusivi del quote builder sono eliminati.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin UI → file system | Operazioni di eliminazione file irreversibili |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-01 | Tampering | project-actions.ts | accept | Rimozione di sole righe revalidatePath inerte — nessun path di dati toccato; le funzioni restano funzionali |
| T-18-02 | Denial of Service | SendQuoteModal.tsx | mitigate | Leggere il file prima di modificare per evitare di rompere il flusso "preventivo esistente" che resta necessario |
</threat_model>
<verification>
Al termine del piano:
1. `npm run build` deve completare senza errori TypeScript (nessun import rotto)
2. `grep -r "admin/forecast" src/` → 0 risultati
3. `grep -r "quotes/new" src/` → 0 risultati
4. La sidebar admin non mostra la voce "Forecast"
</verification>
<success_criteria>
- CLEAN-01: /admin/forecast → 404, sidebar senza voce Forecast, project-actions.ts senza revalidatePath forecast
- CLEAN-02: /admin/quotes/new → 404, SendQuoteModal senza navigazione a quotes/new, componenti quotes/ rimossi
- Build TypeScript pulita (nessun import broken)
</success_criteria>
<output>
After completion, create `.planning/phases/18-cleanup-consolidamento/18-01-SUMMARY.md`
</output>
@@ -0,0 +1,156 @@
---
phase: 18-cleanup-consolidamento
plan: "01"
subsystem: ui
tags: [next.js, admin, cleanup, sidebar, server-actions]
requires: []
provides:
- Forecast route removed (/admin/forecast → 404)
- Admin sidebar without Forecast entry
- project-actions.ts without revalidatePath forecast calls
- Quote builder route removed (/admin/quotes/new → 404)
- SendQuoteModal without navigation to quotes/new
- QuoteBuilderForm, OfferSelector, PriceOverrideInput, QuotePreview components deleted
affects: [phase-21-ai-quote-agent, any future admin nav changes]
tech-stack:
added: []
patterns:
- "Dead-code deletion: remove page files + directories + all entry points atomically before Sales Loop phase"
key-files:
created: []
modified:
- src/components/admin/AdminSidebar.tsx
- src/app/admin/projects/project-actions.ts
- src/components/admin/leads/SendQuoteModal.tsx
- src/lib/admin-queries.ts
deleted:
- src/app/admin/forecast/page.tsx
- src/app/admin/quotes/new/page.tsx
- src/app/admin/quotes/new/actions.ts
- src/components/admin/quotes/QuoteBuilderForm.tsx
- src/components/admin/quotes/OfferSelector.tsx
- src/components/admin/quotes/PriceOverrideInput.tsx
- src/components/admin/quotes/QuotePreview.tsx
key-decisions:
- "Kept lib/forecast-queries.ts and lib/quote-actions.ts as tolerable deadweight — no broken imports, no consumer"
- "SendQuoteModal Tabs UI replaced with flat single-form layout (only 'existing' flow remains)"
- "quotes/new/actions.ts (re-export shim) deleted along with page since its only consumer was QuoteBuilderForm"
- "Stale JSDoc comment in admin-queries.ts referencing /admin/quotes/new cleaned up (Rule 1 auto-fix)"
patterns-established:
- "Cleanup tasks: always grep for all entry points (revalidatePath, navigation, JSDoc) before marking done"
requirements-completed: [CLEAN-01, CLEAN-02]
duration: 12min
completed: 2026-06-19
---
# Phase 18 Plan 01: Cleanup — Remove Forecast and Manual Quote Builder Routes Summary
**Deleted /admin/forecast and /admin/quotes/new routes with all entry points (sidebar, revalidatePath, navigation button, exclusive components) to clear the codebase for the Sales Loop AI phase.**
## Performance
- **Duration:** ~12 min
- **Started:** 2026-06-19T10:04:00Z
- **Completed:** 2026-06-19T10:16:00Z
- **Tasks:** 2
- **Files modified/deleted:** 11
## Accomplishments
- Removed Forecast route: deleted `src/app/admin/forecast/page.tsx`, removed sidebar nav entry and `TrendingUp` import, stripped 3 `revalidatePath("/admin/forecast")` calls from project-actions.ts
- Removed Quote Builder route: deleted `quotes/new/page.tsx`, `quotes/new/actions.ts`, and all 4 exclusive components (QuoteBuilderForm, OfferSelector, PriceOverrideInput, QuotePreview)
- Simplified SendQuoteModal from two-tab layout to single flat form — "Crea Nuovo" tab and `window.location` navigation removed entirely; "existing quote" flow unchanged
- Build passes clean: 0 TypeScript errors, 0 broken imports
## Task Commits
1. **Task 1: Rimuovi rotta Forecast** - `7d88409` (chore)
2. **Task 2: Rimuovi rotta Quote Builder** - `268f56c` (chore)
**Plan metadata:** (see final commit below)
## Files Created/Modified
- `src/components/admin/AdminSidebar.tsx` - Removed Forecast NAV_ITEMS entry and TrendingUp import
- `src/app/admin/projects/project-actions.ts` - Removed 3 revalidatePath("/admin/forecast") calls
- `src/components/admin/leads/SendQuoteModal.tsx` - Removed "Crea Nuovo" tab and quotes/new navigation
- `src/lib/admin-queries.ts` - Removed stale JSDoc comment referencing /admin/quotes/new
**Deleted:**
- `src/app/admin/forecast/page.tsx`
- `src/app/admin/quotes/new/page.tsx`
- `src/app/admin/quotes/new/actions.ts`
- `src/components/admin/quotes/QuoteBuilderForm.tsx`
- `src/components/admin/quotes/OfferSelector.tsx`
- `src/components/admin/quotes/PriceOverrideInput.tsx`
- `src/components/admin/quotes/QuotePreview.tsx`
## Decisions Made
- Kept `lib/forecast-queries.ts` and `lib/quote-actions.ts` intact — they have no broken imports and are tolerable deadweight; removing them was explicitly out of scope per the plan
- Deleted `quotes/new/actions.ts` (not listed in plan) because it was a server re-export shim with `QuoteBuilderForm` as its only consumer — Rule 3 auto-fix (would have blocked clean directory removal)
- Stale JSDoc comment in `admin-queries.ts` updated — Rule 1 auto-fix (stale reference counts as dead code)
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Removed stale JSDoc comment referencing deleted route**
- **Found during:** Task 2 (final grep verification)
- **Issue:** `admin-queries.ts` line 862 had a `* Used by: /admin/quotes/new form (OfferSelector dropdown)` comment — the route no longer exists
- **Fix:** Removed the stale line from the JSDoc block
- **Files modified:** src/lib/admin-queries.ts
- **Verification:** `grep -r "quotes/new" src/` → 0 results
- **Committed in:** 268f56c (Task 2 commit)
**2. [Rule 3 - Blocking] Deleted quotes/new/actions.ts (unlisted file)**
- **Found during:** Task 2 — directory was non-empty after deleting page.tsx
- **Issue:** `src/app/admin/quotes/new/actions.ts` was a re-export shim (`export { createQuote } from "@/lib/quote-actions"`) with QuoteBuilderForm as its only consumer. It would leave a broken directory if not removed.
- **Fix:** Confirmed no other consumers via grep, then deleted the file
- **Files modified:** src/app/admin/quotes/new/actions.ts (deleted)
- **Verification:** `grep -r "createQuote" src/` shows only lib/quote-actions.ts (the source) — no broken references
- **Committed in:** 268f56c (Task 2 commit)
---
**Total deviations:** 2 auto-fixed (1 Rule 1 stale comment, 1 Rule 3 blocking unlisted file)
**Impact on plan:** Both auto-fixes necessary for completeness and clean directory state. No scope creep.
## Issues Encountered
- A linter hook reverted `SendQuoteModal.tsx` after the first Write. On re-read, the linter had actually applied the intended changes (removed Tabs imports, removed tab state, flattened to single form). Confirmed the file was correct before staging.
## User Setup Required
None — no external service configuration required.
## Next Phase Readiness
- CLEAN-01 and CLEAN-02 complete: codebase is clear of forecast and manual quote builder dead routes
- Ready for Phase 21 (AI Quote Agent) which will provide the replacement quote creation flow
- `lib/forecast-queries.ts` and `lib/quote-actions.ts` remain as inert deadweight — can be cleaned in a future pass if desired
---
*Phase: 18-cleanup-consolidamento*
*Completed: 2026-06-19*
## Self-Check: PASSED
- `src/components/admin/AdminSidebar.tsx` — FOUND (modified)
- `src/app/admin/projects/project-actions.ts` — FOUND (modified)
- `src/components/admin/leads/SendQuoteModal.tsx` — FOUND (modified)
- `src/lib/admin-queries.ts` — FOUND (modified)
- `src/app/admin/forecast/page.tsx` — MISSING (deleted as intended)
- `src/app/admin/quotes/new/page.tsx` — MISSING (deleted as intended)
- Commit 7d88409 — FOUND
- Commit 268f56c — FOUND
- `grep -r "admin/forecast" src/` → 0 results
- `grep -r "quotes/new" src/` → 0 results
- Build: 0 errors
@@ -0,0 +1,345 @@
---
phase: 18-cleanup-consolidamento
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- src/app/admin/page.tsx
- src/app/admin/analytics/page.tsx
- src/components/admin/YearSelector.tsx
autonomous: true
requirements:
- CLEAN-03
must_haves:
truths:
- "La Dashboard /admin mostra le statistiche annuali (MetricCard fatturato, grafico mensile, time tracking)"
- "La rotta /admin/analytics non esiste più (404)"
- "Il selettore anno naviga a /admin?year=X, non a /admin/analytics?year=X"
- "Non esiste più il file src/app/admin/analytics/page.tsx"
artifacts:
- path: "src/app/admin/page.tsx"
provides: "Dashboard con sezione Statistiche integrata"
contains: "getAnalyticsByYear"
- path: "src/app/admin/analytics/page.tsx"
provides: "File eliminato"
- path: "src/components/admin/YearSelector.tsx"
provides: "YearSelector che naviga a /admin?year=Y"
contains: "router.push(`/admin?year=${y}`)"
key_links:
- from: "src/app/admin/page.tsx"
to: "lib/analytics-queries"
via: "import getAnalyticsByYear, getMonthlyCollected, getAvailableYears, getTimeByClient, getTotalTrackedHours"
pattern: "analytics-queries"
- from: "YearSelector"
to: "/admin"
via: "router.push"
pattern: "router.push.*admin"
---
<objective>
Fondere le statistiche di /admin/analytics nella Dashboard /admin ed eliminare la rotta duplicata.
Purpose: CLEAN-03 — unica vista admin per statistiche. Elimina il doppione /admin/analytics e porta
tutto il suo contenuto (MetricCard economiche + MonthlyChart + time tracking per cliente) nella dashboard
esistente, sotto le KPI card e il feed attività già presenti.
Output: /admin mostra statistiche annuali, /admin/analytics → 404, YearSelector aggiornato.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
</context>
<interfaces>
<!-- Estratto da src/app/admin/analytics/page.tsx — contratti da portare nella dashboard -->
Imports da copiare in admin/page.tsx:
```typescript
import {
getAnalyticsByYear,
getMonthlyCollected,
getAvailableYears,
getTimeByClient,
getTotalTrackedHours,
} from "@/lib/analytics-queries";
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
```
Funzioni helper da copiare in admin/page.tsx:
```typescript
function fmtEur(n: number) { /* formatta in EUR it-IT */ }
function fmtSeconds(s: number): string { /* h m format */ }
```
ATTENZIONE: admin/page.tsx ha già una funzione `fmtEur` (ma accetta `string`, non `number`).
Rinominare una delle due o unificarle — preferire la versione che accetta `number` (più robusta)
e aggiornare i consumer nel file.
Componente MetricCard (da analytics/page.tsx) — da spostare in admin/page.tsx:
```typescript
function MetricCard({ label, value, sub, accent }: {
label: string; value: string; sub?: string; accent?: boolean;
}) { ... }
```
Questo componente è diverso da KpiCard già presente in admin/page.tsx:
KpiCard ha icona + colore; MetricCard ha stile accent verde o bianco con bordo.
Mantieni ENTRAMBI (scopi diversi): KpiCard per le 4 metriche operative in cima,
MetricCard per le statistiche economiche annuali nella nuova sezione.
Firma del componente analytics/page.tsx (async, searchParams):
```typescript
export default async function AnalyticsPage({
searchParams,
}: {
searchParams: Promise<{ year?: string }>;
}) {
const { year: yearParam } = await searchParams;
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
...
}
```
La stessa logica `year` va aggiunta al componente AdminDashboard in admin/page.tsx.
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Integra statistiche annuali nella Dashboard (CLEAN-03)</name>
<read_first>
- src/app/admin/page.tsx — struttura attuale (KpiCard, FollowUpWidget, activity feed, fmtEur esistente)
- src/app/admin/analytics/page.tsx — tutto il contenuto da portare (già in contesto da discovery)
- src/components/admin/YearSelector.tsx — YearSelector e MonthlyChart (già in contesto da discovery)
</read_first>
<files>
src/app/admin/page.tsx,
src/components/admin/YearSelector.tsx
</files>
<action>
STEP 1 — Aggiorna AdminDashboard in src/app/admin/page.tsx:
a) Aggiungi alla firma del componente il parametro searchParams (come in analytics/page.tsx):
```typescript
export default async function AdminDashboard({
searchParams,
}: {
searchParams: Promise<{ year?: string }>;
}) {
const { year: yearParam } = await searchParams;
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
```
b) Aggiungi gli import mancanti in cima al file:
```typescript
import {
getAnalyticsByYear,
getMonthlyCollected,
getAvailableYears,
getTimeByClient,
getTotalTrackedHours,
} from "@/lib/analytics-queries";
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
```
c) Aggiungi le query analytics al Promise.all esistente (o crea un secondo await per separare
le query di diversa frequenza — è accettabile avere due await distinti per chiarezza):
```typescript
const [data, monthly, availableYears, timeByClient, totalHours] = await Promise.all([
getAnalyticsByYear(year),
getMonthlyCollected(year),
getAvailableYears(),
getTimeByClient(year),
getTotalTrackedHours(year),
]);
```
d) Copia la funzione MetricCard da analytics/page.tsx nel file (accanto a KpiCard).
e) Risolvi il conflitto fmtEur:
- La versione attuale in admin/page.tsx accetta `string` e fa `parseFloat(val)`.
- Quella in analytics/page.tsx accetta `number`.
- Unifica in una sola funzione che accetta `number`, aggiorna i consumer esistenti
nel file che passavano una stringa (es. `fmtEur(kpi.revenueTotale)` e
`fmtEur(kpi.pagamentiInSospeso)`) convertendo a number dove necessario,
oppure mantieni entrambe con nomi distinti (`fmtEurStr` / `fmtEurNum`).
Scegli l'approccio più pulito senza rompere i KPI card esistenti.
f) Aggiungi la sezione Statistiche DOPO il feed attività esistente (mantieni tutto quello
che c'era prima intatto — FollowUpWidget, KPI card, activity feed):
```tsx
{/* ── SEZIONE STATISTICHE ANNUALI ── */}
<div className="mt-10 space-y-10">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-gray-900">Statistiche</h2>
<p className="text-sm text-gray-400 mt-0.5">Panoramica per anno</p>
</div>
<YearSelector currentYear={year} availableYears={availableYears} />
</div>
{/* Sezione economica */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard label="Contrattualizzato" value={fmtEur(data.contracted)}
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"}`} accent />
<MetricCard label="Incassato" value={fmtEur(data.collected)}
sub={`${collectedPct}% del contrattualizzato`} />
<MetricCard label="Da incassare" value={fmtEur(data.pending)} sub="Tutti gli anni" />
<MetricCard label="Clienti acquisiti" value={String(data.clientsAcquired)} sub={`Anno ${year}`} />
</div>
<MonthlyChart data={monthly} year={year} />
</div>
{/* Sezione time tracking */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Tempo tracciato</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard label="Ore totali" value={`${totalHours}h`} sub={`Anno ${year}`} accent />
</div>
{/* ... tabella ore per cliente (copia da analytics/page.tsx) ... */}
</div>
</div>
```
Ricopia fedelmente il blocco "ore per cliente" da analytics/page.tsx (righe 127-160)
nella sezione time tracking della dashboard.
Calcola collectedPct prima del return:
```typescript
const collectedPct = data.contracted > 0
? Math.round((data.collected / data.contracted) * 100) : 0;
const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1;
```
STEP 2 — Aggiorna YearSelector per navigare verso /admin:
In src/components/admin/YearSelector.tsx, riga ~18:
```typescript
// PRIMA:
router.push(`/admin/analytics?year=${y}`);
// DOPO:
router.push(`/admin?year=${y}`);
```
NON modificare: MonthlyChart (rimane nello stesso file), struttura esistente della dashboard
(FollowUpWidget, KPI card, activity feed — tutto invariato sopra la nuova sezione).
</action>
<verify>
<automated>
grep "getAnalyticsByYear\|getMonthlyCollected\|getAvailableYears" /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/page.tsx | wc -l
</automated>
Risultato atteso: 3 (tutte e tre le query importate e usate).
Verifica YearSelector:
grep "router.push" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/YearSelector.tsx
Risultato atteso: contiene "/admin?year=" e NON "/admin/analytics".
</verify>
<acceptance_criteria>
- src/app/admin/page.tsx importa da @/lib/analytics-queries
- src/app/admin/page.tsx importa YearSelector e MonthlyChart
- YearSelector.tsx naviga a /admin?year=Y (non a /admin/analytics)
- Il file compila senza errori TypeScript
</acceptance_criteria>
<done>
La Dashboard mostra le statistiche annuali con selettore anno; il selettore naviga
correttamente a /admin?year=X.
</done>
</task>
<task type="auto">
<name>Task 2: Elimina rotta /admin/analytics (CLEAN-03)</name>
<read_first>
- src/app/admin/analytics/page.tsx — conferma che non è più necessario (Task 1 ha già portato
tutto il contenuto nella dashboard)
</read_first>
<files>
src/app/admin/analytics/page.tsx
</files>
<action>
1. Verifica preventiva — assicurati che nessun altro file nel progetto importi da
src/app/admin/analytics/page.tsx direttamente (è una page, non un modulo, quindi
non dovrebbe avere consumer diretti — ma controlla):
grep -r "admin/analytics" src/ --include="*.tsx" --include="*.ts"
Se trovi riferimenti diversi da YearSelector (già aggiornato in Task 1), risolvili prima.
2. Elimina:
- src/app/admin/analytics/page.tsx
- Directory src/app/admin/analytics/ (se vuota dopo la rimozione del file)
NON eliminare: lib/analytics-queries.ts (ancora importata dalla dashboard),
src/components/admin/YearSelector.tsx (ancora usata dalla dashboard).
</action>
<verify>
<automated>
test -f /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/analytics/page.tsx && echo "ESISTE_ANCORA" || echo "OK_RIMOSSO"
</automated>
Risultato atteso: OK_RIMOSSO
Verifica nessun riferimento rimasto:
grep -r "admin/analytics" /Users/simonecavalli/Vault/IAMCAVALLI/src --include="*.tsx" --include="*.ts" | grep -v "node_modules" | grep -v ".next"
Risultato atteso: 0 righe.
</verify>
<acceptance_criteria>
- src/app/admin/analytics/page.tsx non esiste più
- grep -r "admin/analytics" src/ produce 0 risultati
- npm run build completa senza errori
</acceptance_criteria>
<done>
La rotta /admin/analytics è eliminata. Navigarci restituisce 404.
Tutte le statistiche sono accessibili alla Dashboard /admin.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin/page.tsx → analytics-queries | Aggiunta di query server-side già esistenti — nessuna nuova superficie |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-03 | Information Disclosure | admin/page.tsx | accept | Le statistiche erano già accessibili a /admin/analytics — stessa autenticazione Auth.js, nessuna nuova esposizione |
| T-18-04 | Tampering | YearSelector.tsx | accept | Il cambio da /admin/analytics a /admin nella navigazione è una semplice redirect di path — nessun dato mutato |
</threat_model>
<verification>
Al termine del piano:
1. `npm run build` completa senza errori TypeScript
2. `grep -r "admin/analytics" src/` → 0 risultati
3. La Dashboard /admin mostra le sezioni Statistiche con selettore anno
4. Navigare a /admin/analytics restituisce 404
5. YearSelector porta a /admin?year=X quando si cambia anno
</verification>
<success_criteria>
- CLEAN-03: /admin mostra le statistiche annuali (MetricCard + MonthlyChart + time tracking)
- /admin/analytics → 404 (rotta eliminata)
- YearSelector naviga a /admin?year=X
- Build TypeScript pulita
</success_criteria>
<output>
After completion, create `.planning/phases/18-cleanup-consolidamento/18-02-SUMMARY.md`
</output>
@@ -0,0 +1,113 @@
---
phase: 18-cleanup-consolidamento
plan: "02"
subsystem: ui
tags: [nextjs, analytics, dashboard, react, tailwind]
# Dependency graph
requires:
- phase: analytics-queries
provides: getAnalyticsByYear, getMonthlyCollected, getAvailableYears, getTimeByClient, getTotalTrackedHours
provides:
- Admin dashboard at /admin integrates annual statistics (MetricCard, MonthlyChart, time tracking per client)
- /admin/analytics route deleted — returns 404
- YearSelector navigates to /admin?year=Y
affects: [18-cleanup-consolidamento, admin-ui, analytics]
# Tech tracking
tech-stack:
added: []
patterns:
- Server page accepts searchParams for year filtering
- Analytics queries co-located with dashboard queries in same async page
key-files:
created: []
modified:
- src/app/admin/page.tsx
- src/components/admin/YearSelector.tsx
deleted:
- src/app/admin/analytics/page.tsx
key-decisions:
- "Unified fmtEur to accept number (analytics version); KPI card callers wrapped with parseFloat() for string DB values"
- "Two separate awaits in AdminDashboard: getDashboardStats() first, then analytics Promise.all — acceptable for clarity"
- "Stale .next cache (OfferSelector.tsx reference) cleared before final build — pre-existing issue, not introduced by this plan"
patterns-established:
- "Statistics section appended after activity feed — existing dashboard structure preserved above new section"
requirements-completed:
- CLEAN-03
# Metrics
duration: 15min
completed: 2026-06-19
---
# Phase 18 Plan 02: Consolidate Analytics Dashboard Summary
**Merged /admin/analytics statistics (MetricCard, MonthlyChart, per-client time tracking) into /admin dashboard and deleted the duplicate route**
## Performance
- **Duration:** ~15 min
- **Started:** 2026-06-19T10:04:00Z
- **Completed:** 2026-06-19T10:19:00Z
- **Tasks:** 2
- **Files modified:** 3 (2 modified, 1 deleted)
## Accomplishments
- AdminDashboard now shows annual statistics section below activity feed: MetricCard economic metrics, MonthlyChart, and per-client time tracking bars
- /admin/analytics route deleted — navigating there returns 404
- YearSelector updated to navigate to /admin?year=Y instead of /admin/analytics?year=Y
- fmtEur unified to number-accepting version (more robust); existing KPI card string values wrapped with parseFloat()
## Task Commits
1. **Task 1: Integra statistiche annuali nella Dashboard** — included in `14bdbab` (feat)
2. **Task 2: Elimina rotta /admin/analytics** — included in `14bdbab` (feat)
**Plan metadata:** (docs commit below)
## Files Created/Modified
- `src/app/admin/page.tsx` — Added analytics imports, searchParams, analytics queries, MetricCard, fmtSeconds, fmtEur(number), statistics section
- `src/components/admin/YearSelector.tsx` — Updated router.push from /admin/analytics to /admin
- `src/app/admin/analytics/page.tsx` — Deleted
## Decisions Made
- Unified `fmtEur` to the `number` version from analytics (cleaner, no `parseFloat` internally). Updated the two KPI card callers that received string values from DB to pass `parseFloat(kpi.revenueTotale)` and `parseFloat(kpi.pagamentiInSospeso)`.
- Used two sequential awaits (getDashboardStats first, then analytics Promise.all) rather than one large combined Promise.all — avoids mixing query concerns and follows plan guidance for acceptable two-await pattern.
- Preserved all existing dashboard content (FollowUpWidget, KPI cards, activity feed) exactly; statistics section appended at the bottom with `mt-10`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Cleared stale .next cache referencing deleted OfferSelector.tsx**
- **Found during:** Build verification after Task 2
- **Issue:** Stale `.next/tsconfig.tsbuildinfo` incremental cache listed `src/components/admin/quotes/OfferSelector.tsx` as a root file. That file had been deleted in a prior session. TypeScript errored with "File not found — root file specified for compilation." The pre-existing cache was masking this because prior builds hit cached output.
- **Fix:** Ran `rm -rf .next` to clear the stale incremental cache, then rebuilt clean.
- **Files modified:** .next/ (cache only — not tracked in git)
- **Verification:** Clean build passes with TypeScript check and all routes compiled correctly.
- **Committed in:** n/a (cache not committed)
---
**Total deviations:** 1 auto-fixed (Rule 3 - blocking)
**Impact on plan:** Required to get a clean build. Pre-existing issue unrelated to this plan's scope. .next/ is gitignored so no source changes.
## Issues Encountered
- Stale `.next` incremental TypeScript cache referenced `OfferSelector.tsx` (deleted in a prior session). Cleared cache, rebuilt clean. Build passes correctly.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- /admin is the single analytics entry point — ready for 18-03 and any future admin UI work
- No stale routes remain in the analytics path
- Build is clean
---
*Phase: 18-cleanup-consolidamento*
*Completed: 2026-06-19*
@@ -0,0 +1,197 @@
---
phase: 18-cleanup-consolidamento
plan: 03
type: execute
wave: 2
depends_on:
- "18-01"
- "18-02"
autonomous: false
files_modified:
- .planning/ROADMAP.md
- .planning/REQUIREMENTS.md
requirements:
- CLEAN-04
must_haves:
truths:
- "Le fasi 13/15/16/17 sono marcate esplicitamente come cancellate/congelate/ri-scopate nel ROADMAP.md"
- "REQUIREMENTS.md riflette lo stato aggiornato dei requirement corrispondenti"
- "CLEAN-04 è marcato Done in REQUIREMENTS.md"
- "La build del progetto passa dopo le rimozioni dei piani precedenti"
artifacts:
- path: ".planning/ROADMAP.md"
provides: "Fasi 13/15/16/17 con status esplicito cancellate/congelate"
contains: "CONGELATA\|ABBANDONATA\|RI-SCOPATA"
- path: ".planning/REQUIREMENTS.md"
provides: "CLEAN-04 marcato Complete"
key_links:
- from: "ROADMAP.md fasi 13/15/16/17"
to: "STATUS field"
via: "checklist e label espliciti"
pattern: "Congelata|Abbandonata|Ri-scopata"
---
<objective>
Verificare formalmente il completamento di CLEAN-04 e validare la build post-cleanup.
Purpose: CLEAN-04 — le fasi v2.1 residue sono già state archiviate/marcate nel reset 2026-06-19.
Questo piano le verifica, le sigla formalmente nel planning e valida che i piani 01/02
non abbiano introdotto errori di build.
Output: CLEAN-04 marcato Done, build TypeScript verificata, check umano che la dashboard funziona.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/STATE.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Verifica CLEAN-04 e aggiorna planning docs</name>
<read_first>
- .planning/ROADMAP.md — sezione fasi 13/15/16/17, verifica che abbiano label/status esplicito
- .planning/REQUIREMENTS.md — sezione CLEAN-04, verifica stato attuale
</read_first>
<files>
.planning/ROADMAP.md,
.planning/REQUIREMENTS.md
</files>
<action>
STEP 1 — Verifica ROADMAP.md:
Leggi le voci delle fasi 13, 15, 16, 17 nel ROADMAP.md.
Devono avere tutte uno status esplicito. Controlla che la checklist mostri:
- Phase 13: [~] con label "❌ CONGELATA (reset 2026-06-19)"
- Phase 15: [~] con label "❌ ABBANDONATA (reset 2026-06-19)"
- Phase 16: [~] con label "❌ RI-SCOPATA in v2.2"
- Phase 17: [~] con label "❌ RI-SCOPATA in v2.2"
Se uno di questi status manca o è incompleto, aggiungilo/completalo.
Aggiungi anche una nota nella tabella Progress in fondo con la data di chiusura 2026-06-19
dove mancante.
STEP 2 — Aggiorna REQUIREMENTS.md:
Nella tabella "Cleanup & Consolidamento (Phase 18 / R1)":
- CLEAN-01: cambia Status da "Pending" a "Complete"
- CLEAN-02: cambia Status da "Pending" a "Complete"
- CLEAN-03: cambia Status da "Pending" a "Complete"
- CLEAN-04: cambia Status da "In corso (questo reset)" a "Complete"
Nella tabella Traceability in fondo:
- CLEAN-01 | Phase 18 (R1) | Complete
- CLEAN-02 | Phase 18 (R1) | Complete
- CLEAN-03 | Phase 18 (R1) | Complete
- CLEAN-04 | Phase 18 (R1) | Complete
STEP 3 — Build verification:
Esegui: cd /Users/simonecavalli/Vault/IAMCAVALLI && npm run build
Se la build fallisce:
- Leggi l'output dell'errore
- Identifica il file con import rotto
- Risolvi (probabilmente un import di componente eliminato o una funzione non trovata)
- Ri-esegui npm run build fino a successo
Registra nel SUMMARY se ci sono stati fix necessari.
</action>
<verify>
<automated>
grep "CLEAN-04" /Users/simonecavalli/Vault/IAMCAVALLI/.planning/REQUIREMENTS.md
</automated>
Risultato atteso: riga contenente "CLEAN-04" e "Complete".
Verifica fasi archiviate:
grep -E "Phase 13|Phase 15|Phase 16|Phase 17" /Users/simonecavalli/Vault/IAMCAVALLI/.planning/ROADMAP.md | grep -E "CONGELATA|ABBANDONATA|RI-SCOPATA" | wc -l
Risultato atteso: 4 (tutte e quattro le fasi con label esplicito).
</verify>
<acceptance_criteria>
- REQUIREMENTS.md: CLEAN-04 è "Complete"
- REQUIREMENTS.md: CLEAN-01, CLEAN-02, CLEAN-03 sono "Complete"
- ROADMAP.md: fasi 13/15/16/17 hanno label di stato esplicito (CONGELATA/ABBANDONATA/RI-SCOPATA)
- npm run build completa senza errori
</acceptance_criteria>
<done>
Tutti i requisiti CLEAN-01..04 sono marcati Complete nei planning docs.
La build TypeScript è pulita. Phase 18 è formalmente chiusa.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Checkpoint: Verifica visuale Dashboard e assenza rotte rimosse</name>
<what-built>
Piano 01 ha rimosso /admin/forecast e /admin/quotes/new.
Piano 02 ha fuso le statistiche di /admin/analytics nella Dashboard /admin
e rimosso la rotta /admin/analytics.
Il selettore anno nella dashboard naviga a /admin?year=X.
</what-built>
<how-to-verify>
1. Avvia il dev server: `npm run dev` (o `npx next dev`)
2. Vai su http://localhost:3000/admin — verifica:
- Sidebar: NON compare la voce "Forecast"
- Dashboard: in fondo alla pagina compare la sezione "Statistiche" con selettore anno,
MetricCard economiche (Contrattualizzato / Incassato / Da incassare / Clienti acquisiti),
grafico mensile a barre, sezione ore per cliente
- KPI card esistenti (Clienti attivi, Revenue totale, Progetti in corso, Pagamenti in sospeso)
sono ancora presenti in cima
- FollowUpWidget e feed attività sono ancora presenti
3. Vai su http://localhost:3000/admin/forecast → deve restituire 404
4. Vai su http://localhost:3000/admin/analytics → deve restituire 404
5. Vai su http://localhost:3000/admin/quotes/new → deve restituire 404
6. Clicca sul selettore anno nella sezione Statistiche → cambia anno e verifica che
l'URL diventa /admin?year=XXXX (non /admin/analytics)
7. Nel dettaglio di un lead, apri il modal "Invia preventivo" → verifica che NON
compare più il bottone per creare un nuovo preventivo manuale
</how-to-verify>
<resume-signal>
Scrivi "ok" se tutto funziona correttamente.
Se trovi problemi, descrivi cosa non funziona e l'esecuzione correggerà prima di chiudere la fase.
</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| planning docs → stato del progetto | Aggiornamento documenti di tracking — nessuna superficie di attacco |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-05 | Repudiation | ROADMAP.md / REQUIREMENTS.md | accept | I planning docs sono in git — ogni modifica è tracciata e reversibile |
</threat_model>
<verification>
Al termine del piano:
1. REQUIREMENTS.md: CLEAN-01, CLEAN-02, CLEAN-03, CLEAN-04 → tutti "Complete"
2. ROADMAP.md: fasi 13/15/16/17 con status esplicito
3. npm run build → 0 errori
4. Checkpoint umano superato (Dashboard mostra statistiche, rotte rimosse → 404)
</verification>
<success_criteria>
- CLEAN-04: fasi v2.1 residue formalmente chiuse nei planning docs
- Planning docs aggiornati con stati finali Phase 18
- Build pulita confermata
- Verifica visuale umana completata
</success_criteria>
<output>
After completion, create `.planning/phases/18-cleanup-consolidamento/18-03-SUMMARY.md`
</output>
-163
View File
@@ -1,163 +0,0 @@
import {
getAnalyticsByYear,
getMonthlyCollected,
getAvailableYears,
getTimeByClient,
getTotalTrackedHours,
} from "@/lib/analytics-queries";
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
export const revalidate = 0;
function fmtEur(n: number) {
return n.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 });
}
function fmtSeconds(s: number): string {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function MetricCard({
label, value, sub, accent,
}: {
label: string; value: string; sub?: string; accent?: boolean;
}) {
return (
<div className={`rounded-xl border p-5 ${accent ? "bg-[#1A463C] border-[#1A463C] text-white" : "bg-white border-[#e5e7eb]"}`}>
<p className={`text-xs font-bold uppercase tracking-wider mb-2 ${accent ? "text-white/60" : "text-[#71717a]"}`}>
{label}
</p>
<p className={`text-2xl font-bold tracking-tight ${accent ? "text-white" : "text-[#1a1a1a]"}`}>
{value}
</p>
{sub && (
<p className={`text-xs mt-1 ${accent ? "text-white/60" : "text-[#71717a]"}`}>{sub}</p>
)}
</div>
);
}
export default async function AnalyticsPage({
searchParams,
}: {
searchParams: Promise<{ year?: string }>;
}) {
const { year: yearParam } = await searchParams;
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
const [data, monthly, availableYears, timeByClient, totalHours] = await Promise.all([
getAnalyticsByYear(year),
getMonthlyCollected(year),
getAvailableYears(),
getTimeByClient(year),
getTotalTrackedHours(year),
]);
const collectedPct =
data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1;
return (
<div className="space-y-10">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[#1a1a1a]">Statistiche</h1>
<p className="text-sm text-[#71717a] mt-0.5">Panoramica per anno</p>
</div>
<YearSelector currentYear={year} availableYears={availableYears} />
</div>
{/* ── SEZIONE ECONOMICA ── */}
<div className="space-y-4">
<h2 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Contrattualizzato"
value={fmtEur(data.contracted)}
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"}`}
accent
/>
<MetricCard
label="Incassato"
value={fmtEur(data.collected)}
sub={`${collectedPct}% del contrattualizzato`}
/>
<MetricCard
label="Da incassare"
value={fmtEur(data.pending)}
sub="Tutti gli anni"
/>
<MetricCard
label="Clienti acquisiti"
value={String(data.clientsAcquired)}
sub={`Anno ${year}`}
/>
</div>
<MonthlyChart data={monthly} year={year} />
{data.contracted === 0 && (
<p className="text-sm text-[#71717a] italic text-center py-2">
Nessun cliente registrato nel {year}.
</p>
)}
</div>
{/* ── SEZIONE TIME TRACKING ── */}
<div className="space-y-4">
<h2 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
Tempo tracciato
</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Ore totali"
value={`${totalHours}h`}
sub={`Anno ${year}`}
accent
/>
</div>
{timeByClient.length === 0 ? (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-8 text-center">
<p className="text-sm text-[#71717a] italic">
Nessuna sessione registrata nel {year}.
Usa il timer nella lista clienti per iniziare.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-4">
<h3 className="text-sm font-bold text-[#1a1a1a]">Ore per cliente {year}</h3>
<div className="space-y-3">
{timeByClient.map((row) => {
const pct = Math.round((row.totalSeconds / maxClientSeconds) * 100);
return (
<div key={row.clientId}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-[#1a1a1a]">{row.clientName}</span>
<span className="text-sm tabular-nums text-[#71717a]">
{fmtSeconds(row.totalSeconds)}
</span>
</div>
<div className="h-2 rounded-full bg-[#f4f4f5] overflow-hidden">
<div
className="h-full rounded-full bg-[#1A463C] transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
}
-75
View File
@@ -1,75 +0,0 @@
import { getRevenueForecast12Months } from "@/lib/forecast-queries";
export const revalidate = 0;
export default async function ForecastPage() {
const forecast = await getRevenueForecast12Months();
const totalForecast = forecast.reduce((sum, m) => sum + m.total, 0);
return (
<div className="max-w-3xl mx-auto py-8 px-4">
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-2">Revenue Forecast</h1>
<p className="text-sm text-[#71717a] mb-6">
Proiezione 12 mesi basata sulle offerte attive, i loro accepted_total e le durate in mesi.
Ogni offerta distribuisce il suo totale equamente per i mesi di durata a partire dalla data di inizio.
</p>
<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="px-4 py-3 text-left text-xs font-semibold text-[#71717a] uppercase tracking-wider">
Mese
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-[#71717a] uppercase tracking-wider">
Fatturato previsto
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-[#71717a] uppercase tracking-wider">
Barra
</th>
</tr>
</thead>
<tbody className="divide-y divide-[#e5e7eb]">
{forecast.map((month) => {
const maxTotal = Math.max(...forecast.map((m) => m.total), 1);
const pct = Math.round((month.total / maxTotal) * 100);
return (
<tr key={`${month.year}-${month.month}`} className="hover:bg-[#f9f9f9]">
<td className="px-4 py-3 text-[#1a1a1a] capitalize">{month.label}</td>
<td className="px-4 py-3 text-right font-mono text-[#1a1a1a]">
{month.total > 0 ? `${month.total.toFixed(2)}` : <span className="text-[#71717a]"></span>}
</td>
<td className="px-4 py-3">
{month.total > 0 && (
<div className="flex justify-end items-center">
<div
className="bg-[#1A463C] h-2 rounded"
style={{ width: `${pct}%`, minWidth: "4px", maxWidth: "120px" }}
/>
</div>
)}
</td>
</tr>
);
})}
</tbody>
<tfoot className="border-t-2 border-[#e5e7eb] bg-[#f9f9f9]">
<tr>
<td className="px-4 py-3 text-sm font-semibold text-[#1a1a1a]">Totale 12 mesi</td>
<td className="px-4 py-3 text-right font-mono font-bold text-[#1a1a1a]">
{totalForecast.toFixed(2)}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
{forecast.every((m) => m.total === 0) && (
<p className="text-sm text-[#71717a] mt-4">
Nessuna offerta con accepted_total trovata. Assegna micro-offerte ai progetti e imposta il totale accettato.
</p>
)}
</div>
);
}
+173 -7
View File
@@ -1,6 +1,14 @@
import { Suspense } from "react";
import { getDashboardStats } from "@/lib/dashboard-queries";
import { FollowUpWidget } from "@/components/admin/dashboard/FollowUpWidget";
import {
getAnalyticsByYear,
getMonthlyCollected,
getAvailableYears,
getTimeByClient,
getTotalTrackedHours,
} from "@/lib/analytics-queries";
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
import { Users, FolderOpen, Euro, Clock } from "lucide-react";
export const revalidate = 0;
@@ -32,6 +40,44 @@ function KpiCard({
);
}
function MetricCard({
label,
value,
sub,
accent,
}: {
label: string;
value: string;
sub?: string;
accent?: boolean;
}) {
return (
<div
className={`rounded-xl border p-5 ${
accent ? "bg-[#1A463C] border-[#1A463C] text-white" : "bg-white border-[#e5e7eb]"
}`}
>
<p
className={`text-xs font-bold uppercase tracking-wider mb-2 ${
accent ? "text-white/60" : "text-[#71717a]"
}`}
>
{label}
</p>
<p
className={`text-2xl font-bold tracking-tight ${
accent ? "text-white" : "text-[#1a1a1a]"
}`}
>
{value}
</p>
{sub && (
<p className={`text-xs mt-1 ${accent ? "text-white/60" : "text-[#71717a]"}`}>{sub}</p>
)}
</div>
);
}
const ACTIVITY_ICONS: Record<string, string> = {
nuovo_cliente: "👤",
nuovo_progetto: "📁",
@@ -48,15 +94,44 @@ function fmt(ts: Date) {
}).format(new Date(ts));
}
function fmtEur(val: string) {
return new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR" }).format(
parseFloat(val) || 0
);
function fmtEur(n: number) {
return n.toLocaleString("it-IT", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
});
}
export default async function AdminDashboard() {
function fmtSeconds(s: number): string {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export default async function AdminDashboard({
searchParams,
}: {
searchParams: Promise<{ year?: string }>;
}) {
const { year: yearParam } = await searchParams;
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
const { kpi, activity } = await getDashboardStats();
const [data, monthly, availableYears, timeByClient, totalHours] = await Promise.all([
getAnalyticsByYear(year),
getMonthlyCollected(year),
getAvailableYears(),
getTimeByClient(year),
getTotalTrackedHours(year),
]);
const collectedPct =
data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1;
return (
<div className="max-w-5xl">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
@@ -78,7 +153,7 @@ export default async function AdminDashboard() {
/>
<KpiCard
label="Revenue totale"
value={fmtEur(kpi.revenueTotale)}
value={fmtEur(parseFloat(kpi.revenueTotale))}
sub="progetti non archiviati"
icon={Euro}
color="bg-emerald-600"
@@ -91,7 +166,7 @@ export default async function AdminDashboard() {
/>
<KpiCard
label="Pagamenti in sospeso"
value={fmtEur(kpi.pagamentiInSospeso)}
value={fmtEur(parseFloat(kpi.pagamentiInSospeso))}
sub="da_saldare + inviata"
icon={Clock}
color="bg-amber-500"
@@ -122,6 +197,97 @@ export default async function AdminDashboard() {
</ul>
)}
</div>
{/* ── SEZIONE STATISTICHE ANNUALI ── */}
<div className="mt-10 space-y-10">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-gray-900">Statistiche</h2>
<p className="text-sm text-gray-400 mt-0.5">Panoramica per anno</p>
</div>
<YearSelector currentYear={year} availableYears={availableYears} />
</div>
{/* Sezione economica */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Contrattualizzato"
value={fmtEur(data.contracted)}
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"}`}
accent
/>
<MetricCard
label="Incassato"
value={fmtEur(data.collected)}
sub={`${collectedPct}% del contrattualizzato`}
/>
<MetricCard
label="Da incassare"
value={fmtEur(data.pending)}
sub="Tutti gli anni"
/>
<MetricCard
label="Clienti acquisiti"
value={String(data.clientsAcquired)}
sub={`Anno ${year}`}
/>
</div>
<MonthlyChart data={monthly} year={year} />
{data.contracted === 0 && (
<p className="text-sm text-[#71717a] italic text-center py-2">
Nessun cliente registrato nel {year}.
</p>
)}
</div>
{/* Sezione time tracking */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
Tempo tracciato
</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard label="Ore totali" value={`${totalHours}h`} sub={`Anno ${year}`} accent />
</div>
{timeByClient.length === 0 ? (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-8 text-center">
<p className="text-sm text-[#71717a] italic">
Nessuna sessione registrata nel {year}. Usa il timer &#9654; nella lista clienti per
iniziare.
</p>
</div>
) : (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-4">
<h3 className="text-sm font-bold text-[#1a1a1a]">Ore per cliente {year}</h3>
<div className="space-y-3">
{timeByClient.map((row) => {
const pct = Math.round((row.totalSeconds / maxClientSeconds) * 100);
return (
<div key={row.clientId}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-[#1a1a1a]">
{row.clientName}
</span>
<span className="text-sm tabular-nums text-[#71717a]">
{fmtSeconds(row.totalSeconds)}
</span>
</div>
<div className="h-2 rounded-full bg-[#f4f4f5] overflow-hidden">
<div
className="h-full rounded-full bg-[#1A463C] transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -135,14 +135,12 @@ export async function assignOfferToProject(formData: FormData) {
: null,
});
revalidatePath(`/admin/projects/${parsed.data.project_id}`);
revalidatePath("/admin/forecast");
}
export async function removeProjectOffer(projectOfferId: string, projectId: string) {
await requireAdmin();
await db.delete(project_offers).where(eq(project_offers.id, projectOfferId));
revalidatePath(`/admin/projects/${projectId}`);
revalidatePath("/admin/forecast");
}
export async function updateProjectOfferTotal(
@@ -158,5 +156,4 @@ export async function updateProjectOfferTotal(
.set({ accepted_total: amount.toFixed(2) })
.where(eq(project_offers.id, projectOfferId));
revalidatePath(`/admin/projects/${projectId}`);
revalidatePath("/admin/forecast");
}
-3
View File
@@ -1,3 +0,0 @@
"use server";
export { createQuote } from "@/lib/quote-actions";
-28
View File
@@ -1,28 +0,0 @@
import { Card } from "@/components/ui/card";
import { QuoteBuilderForm } from "@/components/admin/quotes/QuoteBuilderForm";
import { getAllClientsWithPayments } from "@/lib/admin-queries";
import { getAllOfferMacrosWithMicros } from "@/lib/admin-queries";
export const revalidate = 0;
export default async function QuoteBuilderPage() {
const [clientsData, offersData] = await Promise.all([
getAllClientsWithPayments(),
getAllOfferMacrosWithMicros(),
]);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Crea Preventivo</h1>
<p className="text-gray-600 mt-1">
Componi un preventivo per il cliente selezionando l'offerta e il prezzo
</p>
</div>
<Card className="p-6">
<QuoteBuilderForm clients={clientsData} offerMacros={offersData} />
</Card>
</div>
);
}
-2
View File
@@ -8,7 +8,6 @@ import {
Users,
FolderOpen,
Tag,
TrendingUp,
BookOpen,
Settings,
LogOut,
@@ -21,7 +20,6 @@ const NAV_ITEMS = [
{ href: "/admin/leads", label: "Lead", icon: Zap },
{ 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 },
];
+1 -1
View File
@@ -15,7 +15,7 @@ export function YearSelector({
const thisYear = new Date().getFullYear();
function go(y: number) {
router.push(`/admin/analytics?year=${y}`);
router.push(`/admin?year=${y}`);
}
return (
@@ -22,7 +22,6 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Loader2 } from "lucide-react";
const assignQuoteSchema = z.object({
@@ -33,7 +32,6 @@ const assignQuoteSchema = z.object({
export function SendQuoteModal({ leadId }: { leadId: string }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState<"existing" | "new">("existing");
const form = useForm<any>({
resolver: zodResolver(assignQuoteSchema),
@@ -72,13 +70,7 @@ export function SendQuoteModal({ leadId }: { leadId: string }) {
<DialogHeader>
<DialogTitle>Invia Preventivo al Lead</DialogTitle>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => setTab(v as "existing" | "new")}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="existing">Preventivo Esistente</TabsTrigger>
<TabsTrigger value="new">Crea Nuovo</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="mt-4">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
@@ -108,22 +100,6 @@ export function SendQuoteModal({ leadId }: { leadId: string }) {
</Button>
</form>
</Form>
</TabsContent>
<TabsContent value="new" className="mt-4">
<p className="text-sm text-gray-600 mb-4">
Crea un nuovo preventivo per questo lead. Ti reindirizzeremo al builder.
</p>
<Button
className="w-full"
onClick={() => {
window.location.href = `/admin/quotes/new?lead_id=${leadId}`;
}}
>
Apri Quote Builder
</Button>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
@@ -1,46 +0,0 @@
"use client";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OfferMacro, OfferMicro } from "@/db/schema";
interface OfferSelectorProps {
macros: (OfferMacro & { micros: OfferMicro[] })[];
value: string;
onValueChange: (value: string) => void;
}
export function OfferSelector({ macros, value, onValueChange }: OfferSelectorProps) {
return (
<div className="space-y-2">
<Label htmlFor="offer">Offerta*</Label>
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger id="offer">
<SelectValue placeholder="Seleziona un'offerta" />
</SelectTrigger>
<SelectContent>
{macros.map((macro) => (
<SelectGroup key={macro.id}>
<SelectLabel className="text-xs font-semibold text-gray-600">
{macro.public_name}
</SelectLabel>
{macro.micros.map((micro) => (
<SelectItem key={micro.id} value={micro.id}>
{micro.public_name}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -1,63 +0,0 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Check, X } from "lucide-react";
import { useState } from "react";
interface PriceOverrideInputProps {
value: string;
onChange: (value: string) => void;
calculatedTotal?: number;
}
export function PriceOverrideInput({ value, onChange, calculatedTotal }: PriceOverrideInputProps) {
const [isTouched, setIsTouched] = useState(false);
const numValue = parseFloat(value) || 0;
const matches = calculatedTotal !== undefined && Math.abs(numValue - calculatedTotal) < 0.01;
return (
<div className="space-y-2">
<Label htmlFor="total">Totale preventivo*</Label>
<div className="relative">
<Input
id="total"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => setIsTouched(true)}
className="pr-10"
required
/>
{isTouched && value && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{matches ? (
<Check size={18} className="text-green-500" />
) : (
<X size={18} className="text-red-500" />
)}
</div>
)}
</div>
{calculatedTotal !== undefined && (
<p className="text-xs text-gray-500">
Prezzo calcolato: {calculatedTotal.toLocaleString("it-IT", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
)}
{isTouched && value && !matches && (
<p className="text-xs text-yellow-600">
Attenzione: il valore inserito non corrisponde al totale calcolato (server verificherà)
</p>
)}
</div>
);
}
@@ -1,210 +0,0 @@
"use client";
import { useState, useCallback, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card } from "@/components/ui/card";
import { QuotePreview } from "./QuotePreview";
import { OfferSelector } from "./OfferSelector";
import { PriceOverrideInput } from "./PriceOverrideInput";
import { createQuote, getOfferWithPhases } from "@/lib/quote-actions";
import { OfferMacro, OfferMicro } from "@/db/schema";
import { Check, Copy } from "lucide-react";
interface ClientOption {
id: string;
name: string;
}
interface QuoteBuilderFormProps {
clients: ClientOption[];
offerMacros: (OfferMacro & { micros: OfferMicro[] })[];
}
export function QuoteBuilderForm({ clients, offerMacros }: QuoteBuilderFormProps) {
const [isPending, startTransition] = useTransition();
const [selectedClient, setSelectedClient] = useState<string>("");
const [selectedOffer, setSelectedOffer] = useState<string>("");
const [selectedTotal, setSelectedTotal] = useState<string>("");
const [currentOffer, setCurrentOffer] = useState<any>(null);
const [successLink, setSuccessLink] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// Fetch offer details when selection changes
const handleOfferChange = useCallback((offerId: string) => {
setSelectedOffer(offerId);
setError(null);
startTransition(async () => {
const offer = await getOfferWithPhases(offerId);
setCurrentOffer(offer);
});
}, []);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
setSuccessLink(null);
startTransition(async () => {
const result = await createQuote({
client_id: selectedClient,
offer_micro_id: selectedOffer,
accepted_total: selectedTotal,
});
if (result.success) {
setSuccessLink(result.publicLink || "");
// Reset form
setSelectedClient("");
setSelectedOffer("");
setSelectedTotal("");
setCurrentOffer(null);
} else {
setError(result.error || "Errore sconosciuto");
}
});
};
const handleCopyLink = async () => {
if (successLink) {
const fullUrl = `${window.location.origin}${successLink}`;
await navigator.clipboard.writeText(fullUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (successLink) {
return (
<Card className="p-6 space-y-4">
<div className="text-center space-y-3">
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-100 rounded-full">
<Check size={24} className="text-green-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900">Preventivo creato</h3>
<p className="text-sm text-gray-600">
Il preventivo è stato salvato e il link è pronto per essere condiviso
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-500 mb-2">Link pubblico</p>
<div className="flex gap-2">
<code className="flex-1 bg-white border border-gray-200 rounded px-3 py-2 text-xs text-gray-900 font-mono">
{window.location.origin}
{successLink}
</code>
<Button
type="button"
size="sm"
variant={copied ? "default" : "outline"}
onClick={handleCopyLink}
className="flex items-center gap-2"
>
{copied ? (
<>
<Check size={16} /> Copiato
</>
) : (
<>
<Copy size={16} /> Copia
</>
)}
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
className="w-full"
onClick={() => {
setSuccessLink(null);
setSelectedClient("");
setSelectedOffer("");
setSelectedTotal("");
setCurrentOffer(null);
}}
>
Crea un altro preventivo
</Button>
</Card>
);
}
return (
<div className="grid grid-cols-2 gap-6">
{/* LEFT COLUMN: FORM */}
<div>
<form onSubmit={handleSubmit} className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Crea Preventivo</h2>
{/* Client Select */}
<div className="space-y-2">
<Label htmlFor="client">Cliente*</Label>
<Select value={selectedClient} onValueChange={setSelectedClient}>
<SelectTrigger id="client">
<SelectValue placeholder="Seleziona un cliente" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Offer Select */}
{selectedClient && (
<OfferSelector
macros={offerMacros}
value={selectedOffer}
onValueChange={handleOfferChange}
/>
)}
{/* Price Input */}
{selectedOffer && (
<PriceOverrideInput
value={selectedTotal}
onChange={setSelectedTotal}
calculatedTotal={currentOffer?.duration_months ? currentOffer.duration_months * 1000 : undefined}
/>
)}
{/* Error Message */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{/* Submit Button */}
<Button
type="submit"
disabled={!selectedClient || !selectedOffer || !selectedTotal || isPending}
className="w-full bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
>
{isPending ? "Salvataggio…" : "Salva Preventivo"}
</Button>
</form>
</div>
{/* RIGHT COLUMN: PREVIEW */}
<div>
<QuotePreview offer={currentOffer} selectedTotal={parseFloat(selectedTotal) || 0} />
</div>
</div>
);
}
@@ -1,61 +0,0 @@
"use client";
import { Card } from "@/components/ui/card";
import { OfferMicro, OfferPhase } from "@/db/schema";
interface QuotePreviewProps {
offer: (OfferMicro & { phases: OfferPhase[] }) | null;
selectedTotal: number;
}
export function QuotePreview({ offer, selectedTotal }: QuotePreviewProps) {
if (!offer) {
return (
<div className="h-full flex items-center justify-center text-gray-400">
<p className="text-sm">Seleziona un'offerta per visualizzare l'anteprima</p>
</div>
);
}
return (
<Card className="p-6 space-y-6">
<div className="border-b pb-6">
<h3 className="text-lg font-semibold text-gray-900">{offer.public_name}</h3>
{offer.transformation_promise && (
<p className="text-sm text-gray-600 mt-2">{offer.transformation_promise}</p>
)}
<p className="text-xs text-gray-500 mt-3">
Durata: {offer.duration_months} mese{offer.duration_months !== 1 ? "i" : ""}
</p>
</div>
<div className="space-y-4">
<h4 className="font-medium text-gray-900">Fasi incluse</h4>
{offer.phases && offer.phases.length > 0 ? (
offer.phases.map((phase) => (
<div key={phase.id} className="space-y-2">
<p className="text-sm font-medium text-gray-800">{phase.title}</p>
{phase.description && (
<p className="text-xs text-gray-600 ml-2">{phase.description}</p>
)}
</div>
))
) : (
<p className="text-xs text-gray-400">Nessuna fase configurata</p>
)}
</div>
<div className="border-t pt-6 space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">Totale preventivo</span>
<span className="text-2xl font-bold text-gray-900">
{selectedTotal.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
<p className="text-xs text-gray-500">
Questo preventivo sarà visibile al cliente tramite link riservato
</p>
</div>
</Card>
);
}
-1
View File
@@ -859,7 +859,6 @@ export async function getQuotesByClient(clientId: string): Promise<Quote[]> {
/**
* Get all offer macros with their micros (ordered by sort_order)
* Used by: /admin/quotes/new form (OfferSelector dropdown)
*/
export async function getAllOfferMacrosWithMicros(): Promise<
(OfferMacro & { micros: OfferMicro[] })[]