Compare commits
13 Commits
b7ff3fe430
..
v2.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 55a2da5faf | |||
| fb1ef95c96 | |||
| cbfd959c36 | |||
| c803efe059 | |||
| fdcc938252 | |||
| 86c86cd420 | |||
| 009466ac18 | |||
| 9f5a6dcbe5 | |||
| b96b6a2083 | |||
| a46e8b0bc8 | |||
| 70a101ca98 | |||
| dc934fb04a | |||
| 3e10f97215 |
@@ -1,5 +1,35 @@
|
||||
# Milestones
|
||||
|
||||
## v2.2 Sales Loop (Phases 18–22, shipped 2026-06-20)
|
||||
|
||||
**Phases completed:** 5 phases (18–22) · 9 plans · 27 commits · 87 files · +7.349/-842 righe
|
||||
|
||||
**Key accomplishments:**
|
||||
|
||||
- Cleanup & Consolidamento (Phase 18): rimossi Forecast, quote builder manuale e rotta `/admin/analytics` duplicata; tutte le statistiche admin in un'unica Dashboard (CLEAN-01..04)
|
||||
- Pipeline CRM Kanban (Phase 19): board lead 6 colonne stile Pipedrive con drag-drop persistente via `@dnd-kit`; toggle Lista/Kanban con ricerca integrata; vinto/perso come cambio-colonna manuale (PIPE-01, PIPE-02)
|
||||
- Knowledge Base Transcript (Phase 20): tabella `client_transcripts` applicata a prod via SSH; data layer Drizzle + server actions; UI incolla/elenca/cancella nel dettaglio lead (KB-01, KB-02)
|
||||
- Agente AI Preventivo (Phase 21): `generateProposalContent()` con Claude Opus 4.8 (~$0.44/preventivo); Zod schema 20+ sezioni; `AssembledProposal` snapshot JSONB; form admin con pre-fill da LeadDetail (AI-01, AI-02)
|
||||
- Deck Pubblico (Phase 22): `/preventivo/[slug]` — deck 20+ slide `h-screen` con keyboard nav + dots; accept/reject con `accepted_at` immutabile; PUB-03 email Resend deferred a backlog (PUB-01, PUB-02)
|
||||
|
||||
**Known deferred items at close:** 1 — PUB-03 email Resend (vedi backlog v2.3)
|
||||
|
||||
Archive: `.planning/milestones/v2.2-ROADMAP.md` · `.planning/milestones/v2.2-REQUIREMENTS.md`
|
||||
|
||||
---
|
||||
|
||||
## v2.1 Offer Studio + CRM (Phases 11–14, parziale — reset 2026-06-19)
|
||||
|
||||
**Phases completed:** 3 phases (11, 12, 14) · fasi 13/15/16/17 congelate/abbandonate/ri-scopate in v2.2
|
||||
|
||||
**Key accomplishments:**
|
||||
|
||||
- Catalog Database-View UX (Phase 11): catalogo `services` come tabella inline-edit con tag multi-select, quick-add e ricerca istantanea; consolidamento legacy (OFFER-07..10, OFFER-13)
|
||||
- Offer Editor Tier A/B/C (Phase 12): editor offerte con matrice checkbox servizi×tier, totale live, prezzo pubblico manuale, tag 4-dimensioni, promessa di trasformazione; 55 servizi reali caricati (OFFER-11, OFFER-15..18)
|
||||
- CRM Attio-style (Phase 14): `/admin/leads` ridisegnata con inline edit + tag multi-select; FollowUpWidget in italiano; LeadForm tipizzato; SendQuoteModal senza rami irraggiungibili (CRM-08..12)
|
||||
|
||||
---
|
||||
|
||||
## v2.0 Business Operations Suite (Phases 7–10, completato 2026-06-13)
|
||||
|
||||
**Phases completed:** 4 phases (7–10)
|
||||
|
||||
+35
-47
@@ -2,26 +2,21 @@
|
||||
|
||||
## What This Is
|
||||
|
||||
Suite operativa per un consulente di personal branding, live su hub.iamcavalli.net (Coolify/Hetzner): un'area admin (`/admin/*`) per gestire clienti, progetti, offerte, preventivi e CRM, e una dashboard cliente via link segreto (`/client/[token]`) dove ogni cliente vede lo stato del suo progetto. Con la v2.0 (Phase 7-10) è diventata suite completa: catalogo servizi unificato, builder offerte con fasi, preventivi pubblici e CRM con pipeline lead. La v2.1 ("Offer Studio + Proposal AI") rifà la UX di catalogo/offerte in stile database-view veloce, ristruttura il workspace progetto, ricostruisce il CRM in stile Attio e chiude il loop con un builder preventivi AI → pagina pubblica → link di pagamento.
|
||||
Suite operativa per un consulente di personal branding, live su hub.iamcavalli.net (Coolify/Hetzner): un'area admin (`/admin/*`) per gestire clienti, progetti, offerte, preventivi e CRM, e una dashboard cliente via link segreto (`/client/[token]`) dove ogni cliente vede lo stato del suo progetto. Con la v2.2 ("Sales Loop") il focus è diventato il loop commerciale completo: lead in pipeline Kanban → transcript datati delle call → agente AI (Claude Opus 4.8) genera preventivo personalizzato leggendo transcript + offerta → deck pubblico 20+ slide navigabile a `/preventivo/[slug]` → cliente accetta tier A/B/C → vinto/perso nel CRM.
|
||||
|
||||
## 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 Milestone: v2.2 Sales Loop *(reset 2026-06-19)*
|
||||
## Current State: v2.2 Sales Loop ✅ SHIPPED 2026-06-20
|
||||
|
||||
**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`.
|
||||
Il loop di vendita end-to-end è live in produzione: lead in pipeline Kanban → transcript datati → agente AI (Claude Opus 4.8) genera preventivo personalizzato → deck pubblico 20+ slide a `/preventivo/[slug]` → cliente sceglie tier A/B/C e accetta → vinto/perso nel CRM.
|
||||
|
||||
**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.
|
||||
**Prossima milestone:** `/gsd-new-milestone` — candidati backlog:
|
||||
- **PUB-03** — Invio link preventivo via email Resend (primo candidato, piccola effort)
|
||||
- **PROP-03/04** — Stripe Payment Link + auto-provisioning al "Vinto"
|
||||
- **AUTH-OTP-01** — Accesso cliente via OTP email (design già pronto)
|
||||
- **Phase 13** — Servizi attivi/ricorrenti post-vendita (congelata, ripescabile)
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -54,31 +49,20 @@ Validated in v2.1 (consegnato, in prod):
|
||||
- ✓ 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 — v2.2 Sales Loop (reset 2026-06-19)
|
||||
Validated in v2.2 Sales Loop (shipped 2026-06-20):
|
||||
|
||||
**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)
|
||||
- ✓ Cleanup: Forecast + quote builder manuale rimossi; analytics fuse in Dashboard — Phase 18 (CLEAN-01..04)
|
||||
- ✓ Pipeline CRM Kanban 6 colonne con drag-drop; toggle Lista/Kanban; vinto/perso come cambio-colonna — Phase 19 (PIPE-01, PIPE-02)
|
||||
- ✓ Knowledge Base transcript: `client_transcripts` in prod; UI incolla/elenca nel dettaglio lead — Phase 20 (KB-01, KB-02)
|
||||
- ✓ Agente AI: Claude Opus 4.8, Zod schema 20+ sezioni, snapshot JSONB proposals, form admin — Phase 21 (AI-01, AI-02)
|
||||
- ✓ Deck pubblico `/preventivo/[slug]`: 20+ slide 100vh, keyboard nav; accept/reject `accepted_at` immutabile — Phase 22 (PUB-01, PUB-02)
|
||||
|
||||
**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)
|
||||
### Active — v2.3 (prossima milestone)
|
||||
|
||||
**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).
|
||||
- [ ] PUB-03 — Invio link preventivo via email Resend (primo candidato)
|
||||
- [ ] PROP-03 — Stripe Payment Link su offerta pubblica
|
||||
- [ ] PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto"
|
||||
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email (design pronto)
|
||||
|
||||
### Out of Scope
|
||||
|
||||
@@ -95,12 +79,13 @@ Validated in v2.1 (consegnato, in prod):
|
||||
|
||||
- Produzione: hub.iamcavalli.net su Coolify (Hetzner), Postgres self-hosted, deploy via webhook Gitea (NON Vercel)
|
||||
- Stack: Next.js 16 App Router, Drizzle ORM, Auth.js v4 (admin), token middleware (clienti), Tailwind v4, shadcn/ui, `@dnd-kit` già in deps
|
||||
- Tutto sotto la stessa app: `/admin/*` (sessione Auth.js) + `/client/[token]/*` (token) + pagine pubbliche preventivo `/quote/[token]`
|
||||
- La sidebar admin (Phase 6) è l'hub di navigazione per le nuove sezioni (CRM, Preventivi, Offerte)
|
||||
- `project_offers` (Phase 5) già copre l'attribuzione di offerte a progetti con `start_date`+`accepted_total`+`duration_months` — base per "Servizi attivi", da rifinire non ricostruire
|
||||
- `PaymentsTab` ha già un editor `accepted_total` (stessa server action di QuoteTab) — rimozione tab "Preventivo" è zero-perdita, verificato 2026-06-13
|
||||
- Migrations sono manuali: applicare a prod via SSH+docker exec PRIMA di pushare codice che usa il nuovo schema (vedi REGOLA in HANDOFF.md storico)
|
||||
- Il flusso commerciale reale: call con lead → preventivo 3 tier (es. Signature A/B/C) in giornata → accettazione → onboarding automatico nell'hub
|
||||
- Tutto sotto la stessa app: `/admin/*` (sessione Auth.js) + `/client/[token]/*` (token) + `/preventivo/[slug]` (pubblico)
|
||||
- La sidebar admin include: Dashboard, Leads (con toggle Lista/Kanban), Offerte, Catalogo, Preventivi (con CTA globale "Genera preventivo")
|
||||
- Stack v2.2: `@anthropic-ai/sdk@0.105.0` (Claude Opus 4.8), `@dnd-kit` (Kanban), `nanoid` (slug proposals)
|
||||
- DB live: 10 migrazioni applicate a prod (0000–0010); `proposals` table con `content jsonb` snapshot; `client_transcripts` per lead
|
||||
- Migrations sono manuali: SSH tunnel → `node` script PRIMA di pushare codice schema-dipendente; `drizzle-kit generate` rotto da Phase 8
|
||||
- `ANTHROPIC_API_KEY` in Coolify — aggiunta 2026-06-20 via PHP artisan; costo ~$0.44/preventivo (Opus 4.8)
|
||||
- Il flusso commerciale reale: call con lead → transcript incollato → genera preventivo AI → deck pubblica → cliente sceglie tier → vinto/perso
|
||||
|
||||
## Constraints
|
||||
|
||||
@@ -119,13 +104,16 @@ Validated in v2.1 (consegnato, in prod):
|
||||
| Preventivo: cliente vede solo il totale | Il dettaglio dei prezzi è informazione commerciale riservata | ✓ Good |
|
||||
| Suite unica sotto /admin/* invece di 3 app separate | Stesso DB, stesso deploy, auth unica — overhead di 3 app ingiustificato per singolo admin | ✓ Confermato — formalizzato come "compartimenti stagni" (2026-06-12) |
|
||||
| Catalogo servizi unificato (una tabella `services`) | Due cataloghi paralleli (service_catalog + offer_services) duplicano manutenzione prezzi | ✓ Good — tabella `services` live da Phase 7, consolidamento legacy in v2.1 |
|
||||
| Tier offerte indipendenti (A/B/C separati, stesso tag) | Più semplice di un meccanismo di ereditarietà; ogni tier configurato a sé | — Pending |
|
||||
| Prezzi pacchetti per-preventivo, non da catalogo | Permette di alzare i prezzi nel tempo senza toccare il catalogo | — Pending |
|
||||
| Al "Vinto" le fasi dell'offerta sono COPIATE nel progetto | Il progetto resta modificabile senza toccare il template offerta | — Pending (ex-Phase 11, ora in Proposal AI) |
|
||||
| Tier offerte indipendenti (A/B/C separati, stesso tag) | Più semplice di un meccanismo di ereditarietà; ogni tier configurato a sé | ✓ Good — usato in deck slide Pricing/StagesRecap/Comparison |
|
||||
| Prezzi pacchetti per-preventivo, non da catalogo | Permette di alzare i prezzi nel tempo senza toccare il catalogo | ✓ Good — `public_price` per tier, snapshot in `proposals.content` |
|
||||
| Al "Vinto" le fasi dell'offerta sono COPIATE nel progetto | Il progetto resta modificabile senza toccare il template offerta | — Pending (PROP-04, backlog v2.3) |
|
||||
| NO DB esterno/Excel, Postgres unica fonte | Lezione 2026-06-11: due fonti disallineate hanno causato il crash Phase 10 | ✓ Good |
|
||||
| Catalogo/Offerte UX = database view custom (non Notion-clone) | Notion troppo complesso per v1; serve velocità, non sezioni analitiche | — Pending |
|
||||
| Catalogo/Offerte UX = database view custom (non Notion-clone) | Notion troppo complesso per v1; serve velocità, non sezioni analitiche | ✓ Good — confermato in v2.1 |
|
||||
| Tab "Preventivo" rimossa, "Offerte" → "Servizi attivi" | Preventivo Builder è l'unico flusso; `accepted_total` già coperto da Payments | ✓ Confermato — zero perdita funzionale verificata (2026-06-13) |
|
||||
| Ordine: Offer Studio (UX dato) prima, Proposal AI (AI) dopo | L'AI è l'ultimo miglio, serve un dato pulito e veloce da gestire prima | — Pending |
|
||||
| Ordine: Offer Studio (UX dato) prima, Proposal AI (AI) dopo | L'AI è l'ultimo miglio, serve un dato pulito e veloce da gestire prima | ✓ Good — strategia validata: catalogo+offerte puliti → AI in v2.2 |
|
||||
| Output AI = JSON strutturato Zod → template fisso | Coerenza visiva garantita; zero rischio HTML rotto dall'AI | ✓ Good — 20+ sezioni Zod validate, deck sempre coerente |
|
||||
| `proposals.content` = JSONB snapshot immutabile | Prezzi e profilo consulente "bloccati" al momento della generazione | ✓ Good — invariante di audit, coerente con `accepted_at` |
|
||||
| Email Resend (PUB-03) deferred | Scope minimo funziona; link condiviso manualmente per ora | — Pending (v2.3 candidato #1) |
|
||||
|
||||
## Evolution
|
||||
|
||||
@@ -145,4 +133,4 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
4. Update Context with current state
|
||||
|
||||
---
|
||||
*Last updated: 2026-06-14 — Phase 14 (CRM Attio-style & Fix) complete: CRM-08..12 validated*
|
||||
*Last updated: 2026-06-20 — v2.2 milestone complete: Sales Loop end-to-end shipped (Phases 18–22)*
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
# Requirements — ClientHub (v2.1 chiuso → v2.2 Sales Loop)
|
||||
|
||||
**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 7–10) 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 |
|
||||
|----|-------------|--------|
|
||||
| OFFER-07 | L'utente vede ed edita il catalogo `services` come tabella con inline editing (click su cella → edit → invio salva) | Complete |
|
||||
| OFFER-08 | L'utente assegna tag multi-select a servizi/offerte, creando nuovi tag al volo senza uscire dalla tabella | Complete |
|
||||
| OFFER-09 | L'utente aggiunge un nuovo servizio/offerta tramite riga vuota in fondo alla tabella (nome + invio) | Complete |
|
||||
| OFFER-10 | L'utente filtra/cerca servizi e offerte istantaneamente (ricerca client-side, nessun reload) | Complete |
|
||||
| OFFER-11 | L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo unificato `services` tramite matrice di checkbox (servizio × tier); il totale servizi di ogni tier si aggiorna live | Complete |
|
||||
| OFFER-15 | Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag polimorfico di Phase 11 | Complete |
|
||||
| OFFER-16 | Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi | Complete |
|
||||
| OFFER-17 | Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo) | Complete |
|
||||
| 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 (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) | 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)
|
||||
|
||||
| ID | Requirement | Status |
|
||||
|----|-------------|--------|
|
||||
| CRM-08 | La tabella lead (`/admin/leads`) supporta inline editing dei campi principali (status, next_action, ecc.) senza apertura modale | Complete |
|
||||
| CRM-09 | L'utente assegna tag multi-select ai lead, creando nuovi tag al volo | Complete |
|
||||
| CRM-10 | Il `FollowUpWidget` è in italiano, coerente col resto dell'app | Complete |
|
||||
| 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 (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`) | Dropped (v2.2 → CLEAN-03) |
|
||||
|
||||
## 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 | 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)
|
||||
|
||||
### Catalogo Avanzato
|
||||
|
||||
- **OFFER-12**: Import CSV/Notion one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo) — *rimandato il 2026-06-14 su decisione utente ("per ora saltiamo"); rientra nel filone "import dati da Notion" da valutare in una fase futura*
|
||||
- **OFFER-14**: Sezioni analitiche stile Notion per le offerte (psicologia, rating, performance)
|
||||
|
||||
### Accesso Cliente
|
||||
|
||||
- **AUTH-OTP-01**: Accesso dashboard cliente via OTP email (design pronto, vedi memoria `project_clienthub_otp_access`)
|
||||
|
||||
### Architettura
|
||||
|
||||
- **ARCH-01**: Split di un modulo "compartimento stagno" in deploy separato (solo se il modulo cresce abbastanza da giustificarlo)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Fatturazione e invio fatture | Solo stato pagamenti, gestione contabile fuori |
|
||||
| App mobile nativa | Solo web responsive |
|
||||
| Multi-utente con team | Solo admin singolo per ora |
|
||||
| Prezzi singoli visibili al cliente | Vede solo il totale accettato (vincolo LOCKED) |
|
||||
| File hosting | Documenti solo come URL esterni (vincolo v1, ancora valido) |
|
||||
| Database esterno o Excel come fonte dati | Postgres unica fonte di verità — il problema è la UX, non il dato |
|
||||
| Notion-clone completo (sezioni psicologia/rating/performance) | Troppo complesso per v1, vedi v2 Requirements |
|
||||
| Deploy separati per modulo (architettura OMC multi-app) | Non finché un modulo non cresce abbastanza da giustificarlo |
|
||||
|
||||
## Traceability
|
||||
|
||||
Popolata dal roadmapper durante la creazione della roadmap.
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| OFFER-07 | Phase 11 | Complete |
|
||||
| OFFER-08 | Phase 11 | Complete |
|
||||
| OFFER-09 | Phase 11 | Complete |
|
||||
| OFFER-10 | Phase 11 | Complete |
|
||||
| OFFER-13 | Phase 11 | Complete |
|
||||
| OFFER-11 | Phase 12 | Complete |
|
||||
| OFFER-15 | Phase 12 | Complete |
|
||||
| OFFER-16 | Phase 12 | Complete |
|
||||
| OFFER-17 | Phase 12 | Complete |
|
||||
| OFFER-18 | Phase 12 | Complete |
|
||||
| OFFER-12 | Deferred (v2) | Rimandato 2026-06-14 |
|
||||
| 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 | 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-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)*
|
||||
+47
-216
@@ -1,233 +1,64 @@
|
||||
# Roadmap: ClientHub — v2.1 (chiuso) → v2.2 Sales Loop
|
||||
# Roadmap: ClientHub
|
||||
|
||||
## Overview
|
||||
## Milestones
|
||||
|
||||
**v2.0 (Phases 1–10, 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 11–17) — 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 18–22, 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 R1–R5 mappano su Phase 18–22.
|
||||
- ✅ **v1.0 Client Portal & Offer System** — Phases 1–6 (shipped 2026-06-10) — [archive](milestones/v1.0-ROADMAP.md)
|
||||
- ✅ **v2.0 Business Operations Suite** — Phases 7–10 (shipped 2026-06-13) — [archive](milestones/v2.0-ROADMAP.md)
|
||||
- ✅ **v2.1 Offer Studio + CRM** — Phases 11–14 parziale (chiuso 2026-06-19, reset → v2.2)
|
||||
- ✅ **v2.2 Sales Loop** — Phases 18–22 (shipped 2026-06-20) — [archive](milestones/v2.2-ROADMAP.md)
|
||||
- 📋 **v2.3** — prossima milestone (in definizione)
|
||||
|
||||
## 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 (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** - ❌ 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)
|
||||
- [x] **Phase 18: Cleanup & Consolidamento** - Rimuovi Forecast + quote builder manuale; fondi `/admin/analytics` nella dashboard; archivia fasi residue v2.1
|
||||
- [x] **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)
|
||||
<details>
|
||||
<summary>✅ v1.0 + v2.0 + v2.1 (Phases 1–17) — SHIPPED / CHIUSE</summary>
|
||||
|
||||
## Phase Details
|
||||
Vedi archivi:
|
||||
- `milestones/v1.0-ROADMAP.md` — Phases 1–6
|
||||
- `milestones/v2.0-ROADMAP.md` — Phases 7–10
|
||||
- Phases 11, 12, 14 — Offer Studio + CRM Attio (shipped in prod)
|
||||
- Phases 13, 15, 16, 17 — congelate/abbandonate/ri-scopate in v2.2
|
||||
|
||||
> **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 **18–22 (R1–R5)**, in fondo a questa sezione.
|
||||
</details>
|
||||
|
||||
### 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
|
||||
**Depends on**: Phase 10 (CRM Pipeline, ultima fase v2.0)
|
||||
**Requirements**: OFFER-07, OFFER-08, OFFER-09, OFFER-10, OFFER-13
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. L'utente edita inline ogni cella del catalogo `services` (click sulla cella → edit → invio salva) senza apertura di modali
|
||||
2. L'utente assegna tag multi-select a servizi/offerte, creando nuovi tag al volo senza uscire dalla tabella
|
||||
3. L'utente aggiunge un nuovo servizio tramite riga vuota in fondo alla tabella (nome + invio)
|
||||
4. L'utente filtra/cerca servizi e offerte istantaneamente (ricerca client-side, nessun reload)
|
||||
5. I dati legacy di `service_catalog` e `offer_services` sono consolidati in `services` senza perdita (migration additiva, audit trail `migrated_from`/`migrated_id`)
|
||||
**Plans**: 4 plans
|
||||
**Plan list**:
|
||||
- [x] 11-01-PLAN.md
|
||||
- [x] 11-02-PLAN.md
|
||||
- [x] 11-03-PLAN.md
|
||||
- [x] 11-04-PLAN.md
|
||||
**UI hint**: yes
|
||||
**Status**: ✅ Complete (2026-06-13) — migration 0007 (`services.fase`) applicata in prod, commit pushato su `gitea/main`
|
||||
<details>
|
||||
<summary>✅ v2.2 Sales Loop (Phases 18–22) — SHIPPED 2026-06-20</summary>
|
||||
|
||||
### Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico
|
||||
**Goal**: L'admin crea e modifica offerte complete da una lista filtrabile: editor con 3 tier (A/B/C) composti via matrice di checkbox dai servizi del catalogo unificato `services`, totale servizi live per tier, prezzo pubblico manuale per tier, tag multi-dimensione (categoria/ticket/tipo/obiettivo) e promessa di trasformazione strutturata. I servizi disponibili nella matrice sono filtrati per categoria offerta (entry/signature/retainer), designazione già impostata sul servizio a livello di catalogo.
|
||||
**Mode:** ui + schema
|
||||
**Depends on**: Phase 11 (tabella `services` unificata + sistema tag polimorfico)
|
||||
**Requirements**: OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo `services` tramite matrice di checkbox; il totale servizi di ogni tier si aggiorna live
|
||||
2. Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag di Phase 11
|
||||
3. Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi
|
||||
4. Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo)
|
||||
5. La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate)
|
||||
**Note**: Import CSV/Notion (ex OFFER-12) spostato a fase futura su decisione utente (2026-06-14, "per ora saltiamo") — vedi REQUIREMENTS.md
|
||||
**Plans**: 5 plans
|
||||
**Plan list**:
|
||||
**Wave 1**
|
||||
- [x] 12-01-PLAN.md — Schema: offer_macros/offer_micros additive columns (category, ticket, is_archived, transformation promise, tier_letter, public_price) + offer_tier_services junction table, migration 0008 + idempotent push script
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 12-02-PLAN.md — BLOCKING checkpoint: apply migration 0008 to production database (SSH+docker exec, manual)
|
||||
**Wave 3** *(blocked on Wave 2 — prod migration applied before schema-dependent code)*
|
||||
- [x] 12-03-PLAN.md — Query layer (getOfferEditorData/getOfferListCards/getOfferFieldOptions) + server actions (saveOfferEditor/toggleOfferArchived/tag CRUD/createOfferMacro)
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 12-04-PLAN.md — Offer list page redesign: category filter chips, archive toggle, card grid, "+ Nuova Offerta" CTA
|
||||
- [x] 12-05-PLAN.md — Offer editor detail page: tags (4 dimensions), services matrix (tier A/B/C checkboxes + live totals), prezzo pubblico, promessa di trasformazione, Salva/Annulla/Archivia
|
||||
**UI hint**: yes — riferimento mockup utente 2026-06-14 (lista card per categoria + editor con matrice servizi×tier)
|
||||
**Status**: Planned (2026-06-14) — ready for `/gsd-execute-phase 12`
|
||||
- [x] Phase 18: Cleanup & Consolidamento (3/3 plans) — completed 2026-06-19
|
||||
- [x] Phase 19: Pipeline CRM Kanban (1/1 plan) — completed 2026-06-19
|
||||
- [x] Phase 20: Knowledge Base Cliente (3/3 plans) — completed 2026-06-20
|
||||
- [x] Phase 21: Agente AI — generazione preventivo (1/1 plan) — completed 2026-06-20
|
||||
- [x] Phase 22: Pagina pubblica preventivo (1/1 plan, PUB-03 deferred) — completed 2026-06-20
|
||||
|
||||
### Phase 13: Workspace — Servizi Attivi
|
||||
**Goal**: Il workspace progetto sostituisce le tab "Preventivo"/"Offerte"/"Forecast" con un'unica tab "Servizi attivi" che traccia servizi one-shot e ricorrenti con incassi mensili, senza perdere `accepted_total`/`quote_items`
|
||||
**Mode:** ui + schema
|
||||
**Depends on**: Phase 5 (`project_offers`), Phase 9 (flusso `accepted_total` via quote)
|
||||
**Requirements**: PROJ-06, PROJ-07, PROJ-08, PROJ-09, PROJ-10
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. La tab "Preventivo" è rimossa dal workspace progetto (solo UI — `quote_items` resta intatta, `accepted_total` resta editabile via tab Pagamenti)
|
||||
2. La tab "Offerte" diventa "Servizi attivi" e mostra tutte le offerte assegnate al progetto (`project_offers`)
|
||||
3. L'utente marca ogni "servizio attivo" come one-shot o ricorrente mensile
|
||||
4. Per i servizi ricorrenti, l'utente registra in quali mesi è stata incassata la fattura (schema nuovo, additivo)
|
||||
5. La pagina "Forecast" e la relativa voce di sidebar sono rimosse
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
**Status**: Pending planning
|
||||
Archivio completo: [milestones/v2.2-ROADMAP.md](milestones/v2.2-ROADMAP.md)
|
||||
|
||||
### Phase 14: CRM Attio-style & Fix
|
||||
**Goal**: La tabella lead (`/admin/leads`) è ridisegnata in stile Attio/Pipedrive — inline editing dei campi principali e tag multi-select — e i bug residui del modulo CRM (FollowUpWidget non in italiano, LeadForm con type-relaxation, SendQuoteModal con rami irraggiungibili) sono risolti
|
||||
**Mode:** ui
|
||||
**Depends on**: Phase 10 (CRM Pipeline base)
|
||||
**Requirements**: CRM-08, CRM-09, CRM-10, CRM-11, CRM-12
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. La tabella lead supporta inline editing dei campi principali (status, next_action, ecc.) senza apertura di modale
|
||||
2. L'utente assegna tag multi-select ai lead, creando nuovi tag al volo
|
||||
3. Il `FollowUpWidget` è interamente in italiano, coerente col resto dell'app
|
||||
4. Il form lead (`LeadForm`) valida i campi senza workaround di type-relaxation su react-hook-form
|
||||
5. Il flusso `SendQuoteModal` (preventivo esistente vs nuovo) non contiene rami di codice irraggiungibili
|
||||
**Plans:** 5/5 plans complete
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 14-01-PLAN.md — Data-layer foundation: getLeadsWithTags/getLeadFieldOptions query helpers + updateLeadField/addLeadTag/removeLeadTag/renameLeadTag server actions (requireAdmin-guarded)
|
||||
- [x] 14-03-PLAN.md — Bug fixes: FollowUpWidget Italian translation (CRM-10), LeadForm typed useForm (CRM-11), SendQuoteModal dead branch removal (CRM-12)
|
||||
</details>
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 14-02-PLAN.md — LeadTable raw-table rewrite (inline edit + status dropdown + tag multi-select), LeadsSearch instant filter, page wiring, LeadDetail tags
|
||||
**UI hint**: yes — riferimento design Pipedrive/Attio (confermato dall'utente 2026-06-13)
|
||||
**Status**: ✅ Complete (2026-06-14) — 5/5 success criteria verified, code review 0 critical/6 warnings/3 info
|
||||
### 📋 v2.3 — Prossima Milestone (in definizione)
|
||||
|
||||
### Phase 15: Dashboard Revenue Stats
|
||||
**Goal**: La Dashboard admin mostra le statistiche di revenue mensile (ex `/admin/forecast`), calcolate da `project_offers`
|
||||
**Mode:** ui
|
||||
**Depends on**: Phase 13 (modello dati Servizi attivi)
|
||||
**Requirements**: DASH-11
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. La Dashboard admin mostra un breakdown di revenue mensile calcolato da `project_offers`
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
**Status**: Blocked — attesa mockup dashboard dall'utente (isolata/skippabile, non blocca Phase 16/17)
|
||||
Candidati backlog (da formalizzare con `/gsd-new-milestone`):
|
||||
|
||||
### Phase 16: Proposal AI — Data Foundations & Auto-Provisioning
|
||||
**Goal**: Ogni offerta/preventivo ha un campo link di pagamento Stripe mostrato come CTA pubblica, e all'accettazione del preventivo il sistema crea automaticamente cliente + progetto, copia le fasi dall'offerta e configura 1-4 pagamenti
|
||||
**Mode:** schema + automation
|
||||
**Depends on**: Phase 9 (flusso accettazione preventivo), Phase 13 (Servizi attivi come target del provisioning)
|
||||
**Requirements**: PROP-03, PROP-04
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Ogni offerta/preventivo ha un campo "link di pagamento" (Stripe Payment Link) mostrato come CTA nella pagina pubblica
|
||||
2. All'accettazione del preventivo, il sistema crea automaticamente cliente + progetto, copia le fasi dall'offerta e configura 1-4 pagamenti
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
**Status**: Pending planning
|
||||
|
||||
### Phase 17: Proposal AI — Builder, Pagina Pubblica & Email
|
||||
**Goal**: Un builder AI genera una proposta a partire da cliente + offerta(e) selezionata(e) + note extra; la pagina pubblica `/quote/[token]` è ridisegnata; il link della proposta viene inviato via email (Resend) al lead
|
||||
**Mode:** ai + ui
|
||||
**Depends on**: Phase 16
|
||||
**Requirements**: PROP-01, PROP-02, PROP-05
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. L'admin avvia un builder AI che genera una proposta a partire da cliente + offerta(e) selezionata(e) + note extra fornite manualmente
|
||||
2. La pagina pubblica `/quote/[token]` è ridisegnata (HTML/CSS) per presentare la proposta generata
|
||||
3. Il sistema invia via email (Resend) il link della proposta pubblica al lead
|
||||
**Plans**: TBD
|
||||
**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**:
|
||||
- [x] 18-01-PLAN.md — Rimozione Forecast + Quote Builder manuale (CLEAN-01, CLEAN-02)
|
||||
- [x] 18-02-PLAN.md — Fusione analytics nella Dashboard, eliminazione rotta /admin/analytics (CLEAN-03)
|
||||
- [x] 18-03-PLAN.md — Verifica CLEAN-04 planning docs + build check + checkpoint visuale
|
||||
**Status**: ✅ Complete (2026-06-19) — CLEAN-01..04 verificati, build verde
|
||||
|
||||
### 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
|
||||
**Plans**: 1 plan
|
||||
**Plan list**:
|
||||
- [x] 19-01-PLAN.md — LeadsKanbanBoard + LeadsViewToggle + page wiring (PIPE-01, PIPE-02)
|
||||
**Status**: ✅ Complete (2026-06-19) — board 6 colonne, drag-drop persistente, toggle Lista/Kanban, search integrata
|
||||
|
||||
### 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`
|
||||
- [ ] PUB-03 — Invio link preventivo via email Resend
|
||||
- [ ] PROP-03 — Stripe Payment Link su offerta pubblica
|
||||
- [ ] PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto"
|
||||
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email (design pronto)
|
||||
- [ ] Phase 13 — Servizi attivi/ricorrenti post-vendita (congelata, ripescabile)
|
||||
|
||||
## 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 | ✅ 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 | 3/3 | ✅ Done | 2026-06-19 |
|
||||
| 19 (R2). Pipeline CRM Kanban | 1/1 | ✅ Done | 2026-06-19 |
|
||||
| 20 (R3). Knowledge Base Cliente (transcript) | — | Pending planning | — |
|
||||
| 21 (R4). Agente AI — generazione preventivo | — | Pending planning | — |
|
||||
| 22 (R5). Pagina pubblica preventivo + email | — | Pending planning | — |
|
||||
| Phase | Milestone | Plans | Status | Completed |
|
||||
|-------|-----------|-------|--------|-----------|
|
||||
| 1–6. Foundation → UX Overhaul | v1.0 | 24/24 | ✅ Done | 2026-06-10 |
|
||||
| 7–10. Unified Catalog → CRM Pipeline | v2.0 | 12/12 | ✅ Done | 2026-06-13 |
|
||||
| 11. Catalog Database-View UX | v2.1 | 4/4 | ✅ Done | 2026-06-13 |
|
||||
| 12. Offer Editor Tier A/B/C | v2.1 | 5/5 | ✅ Done | 2026-06-18 |
|
||||
| 13. Workspace Servizi Attivi | v2.1 | — | ❌ Congelata | — |
|
||||
| 14. CRM Attio-style & Fix | v2.1 | 3/3 | ✅ Done | 2026-06-14 |
|
||||
| 15. Dashboard Revenue Stats | v2.1 | — | ❌ Abbandonata | — |
|
||||
| 16–17. Proposal AI originale | v2.1 | — | ❌ Ri-scopata in v2.2 | — |
|
||||
| 18. Cleanup & Consolidamento | v2.2 | 3/3 | ✅ Done | 2026-06-19 |
|
||||
| 19. Pipeline CRM Kanban | v2.2 | 1/1 | ✅ Done | 2026-06-19 |
|
||||
| 20. Knowledge Base Cliente | v2.2 | 3/3 | ✅ Done | 2026-06-20 |
|
||||
| 21. Agente AI Preventivo | v2.2 | 1/1 | ✅ Done | 2026-06-20 |
|
||||
| 22. Pagina Pubblica + Deck | v2.2 | 1/1 | ✅ Done | 2026-06-20 |
|
||||
| 23+. v2.3 TBD | v2.3 | TBD | 📋 Planned | — |
|
||||
|
||||
+12
-12
@@ -2,16 +2,16 @@
|
||||
gsd_state_version: 1.0
|
||||
milestone: v2.2
|
||||
milestone_name: — Sales Loop
|
||||
status: executing
|
||||
stopped_at: "Phase 19 completa (19-01 done). Pronta per /gsd-plan-phase 20"
|
||||
last_updated: "2026-06-19T18:05:00.000Z"
|
||||
last_activity: 2026-06-19 -- Phase 19 complete — LeadsKanbanBoard + LeadsViewToggle (PIPE-01/PIPE-02)
|
||||
status: complete
|
||||
stopped_at: "v2.2 completa — tutto in prod. Backlog: PUB-03 email Resend, PROP-03 Stripe, PROP-04 auto-provisioning"
|
||||
last_updated: "2026-06-20T16:30:00.000Z"
|
||||
last_activity: 2026-06-20 -- v2.2 chiusa — 5/5 fasi complete, REQUIREMENTS aggiornati, SUMMARY.md scritti per 21+22
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 2
|
||||
total_plans: 4
|
||||
completed_plans: 4
|
||||
percent: 40
|
||||
completed_phases: 5
|
||||
total_plans: 7
|
||||
completed_plans: 7
|
||||
percent: 100
|
||||
---
|
||||
|
||||
# Project State
|
||||
@@ -26,10 +26,10 @@ See: .planning/PROJECT.md (updated 2026-06-13)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 20 (R3) Knowledge Base Cliente — pending planning
|
||||
Plan: —
|
||||
Status: Ready to plan
|
||||
Last activity: 2026-06-19 -- Phase 19 complete (19-01 done — PIPE-01/PIPE-02 verified)
|
||||
Phase: 20 (R3) Knowledge Base Cliente — ready to execute
|
||||
Plan: 3 piani (20-01 migration, 20-02 data layer, 20-03 UI)
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-06-19 -- Phase 20 planned (3 piani, verification passed — KB-01/KB-02)
|
||||
|
||||
Progress (v2.2): [████░░░░░░] 40% — 2/5 fasi complete
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Requirements Archive — v2.2 Sales Loop
|
||||
|
||||
**Archived:** 2026-06-20
|
||||
**Milestone:** v2.2 Sales Loop (Phases 18–22)
|
||||
**Outcome:** 10/11 requirements Complete · 1 deferred (PUB-03 email Resend)
|
||||
|
||||
---
|
||||
|
||||
## v2.2 — Sales Loop Requirements
|
||||
|
||||
### Cleanup & Consolidamento (Phase 18 / R1)
|
||||
|
||||
| ID | Requirement | Outcome |
|
||||
|----|-------------|---------|
|
||||
| CLEAN-01 | Pagina Forecast + voce sidebar rimossa | ✅ Complete — Phase 18-01 |
|
||||
| CLEAN-02 | Quote builder manuale rimosso | ✅ Complete — Phase 18-01 |
|
||||
| CLEAN-03 | Analytics fusa nella Dashboard, rotta duplicata eliminata | ✅ Complete — Phase 18-02 |
|
||||
| CLEAN-04 | Fasi v2.1 residue archiviate nel planning | ✅ Complete — Phase 18-03 |
|
||||
|
||||
### Pipeline CRM Kanban (Phase 19 / R2)
|
||||
|
||||
| ID | Requirement | Outcome |
|
||||
|----|-------------|---------|
|
||||
| PIPE-01 | Board Kanban Pipedrive-style con drag-drop tra stage | ✅ Complete — Phase 19-01 |
|
||||
| PIPE-02 | Vinto/Perso come cambio-colonna manuale | ✅ Complete — Phase 19-01 |
|
||||
|
||||
### Knowledge Base Cliente (Phase 20 / R3)
|
||||
|
||||
| ID | Requirement | Outcome |
|
||||
|----|-------------|---------|
|
||||
| KB-01 | Schema additivo `client_transcripts` con migration a prod | ✅ Complete — Phase 20-01/02 |
|
||||
| KB-02 | UI incolla/elenca transcript nel dettaglio lead | ✅ Complete — Phase 20-03 |
|
||||
|
||||
### Agente AI — Generazione Preventivo (Phase 21 / R4)
|
||||
|
||||
| ID | Requirement | Outcome |
|
||||
|----|-------------|---------|
|
||||
| AI-01 | Admin seleziona cliente+offerta; AI legge transcript+offerta → bozza | ✅ Complete — Phase 21-01 |
|
||||
| AI-02 | Admin rivede ed edita la bozza prima di pubblicare | ✅ Complete — Phase 21-01 |
|
||||
|
||||
### Pagina Pubblica Preventivo (Phase 22 / R5)
|
||||
|
||||
| ID | Requirement | Outcome |
|
||||
|----|-------------|---------|
|
||||
| PUB-01 | Pagina pubblica `/preventivo/[slug]` — deck 20+ slide | ✅ Complete — Phase 22-01 |
|
||||
| PUB-02 | Cliente accetta/rifiuta; esito riflesso nel DB | ✅ Complete — Phase 22-01 |
|
||||
| PUB-03 | Invio link via email Resend | ❌ Not implemented — deferred to v2.3 backlog |
|
||||
|
||||
---
|
||||
|
||||
## Backlog Portato Avanti
|
||||
|
||||
| ID | Requirement | Stato |
|
||||
|----|-------------|-------|
|
||||
| PUB-03 | Email Resend invio link preventivo | Deferred → v2.3 |
|
||||
| PROP-03 | Stripe Payment Link su offerta | Backlog (post-R5 originale) |
|
||||
| PROP-04 | Auto-provisioning al "Vinto" | Backlog (post-R5 originale) |
|
||||
| AUTH-OTP-01 | Accesso cliente via OTP email | Design pronto, deferred |
|
||||
| OFFER-12 | Import CSV/Notion one-shot | Rimandato 2026-06-14 |
|
||||
|
||||
---
|
||||
|
||||
## Requirements Non-v2.2 (Storico)
|
||||
|
||||
*I requirements di v1.0 e v2.0/v2.1 sono archiviati in:*
|
||||
- `.planning/milestones/v1.0-REQUIREMENTS.md`
|
||||
- `.planning/milestones/v2.0-REQUIREMENTS.md`
|
||||
|
||||
*I requirements congelati/abbandonati di v2.1 (PROJ-06..10, DASH-11, PROP-01..05) sono documentati in `.planning/milestones/v2.2-ROADMAP.md` sezione "Milestone Summary".*
|
||||
|
||||
---
|
||||
|
||||
*Archived: 2026-06-20 at v2.2 milestone close*
|
||||
@@ -0,0 +1,144 @@
|
||||
# Milestone v2.2: Sales Loop
|
||||
|
||||
**Status:** ✅ SHIPPED 2026-06-20
|
||||
**Phases:** 18–22 (R1–R5)
|
||||
**Total Plans:** 9 plans · 27 commits · 87 files · +7.349/-842 righe
|
||||
**Timeline:** 2026-06-19 → 2026-06-20
|
||||
|
||||
## Overview
|
||||
|
||||
v2.2 chiude il **loop di vendita end-to-end**: dopo che l'utente ha articolato il flusso commerciale reale (piano `.claude/plans/glittery-sprouting-pudding.md`), le fasi residue v2.1 (13/15/16/17) sono state congelate/abbandonate e sostituite da 5 fasi nuove. Il flusso: lead in pipeline Kanban → transcript datati delle call → agente AI genera preventivo → deck pubblico 20+ slide → accept/reject manuale nel CRM.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 18: Cleanup & Consolidamento (R1)
|
||||
|
||||
**Goal**: Sgomberare il campo dai doppioni e dal peso morto prima di costruire il loop di vendita.
|
||||
**Mode:** ui (solo rimozioni/rotte, nessun drop di tabelle)
|
||||
**Requirements**: CLEAN-01, CLEAN-02, CLEAN-03, CLEAN-04
|
||||
**Plans**: 3 plans
|
||||
|
||||
Plans:
|
||||
- [x] 18-01-PLAN.md — Rimozione Forecast + Quote Builder manuale (CLEAN-01, CLEAN-02)
|
||||
- [x] 18-02-PLAN.md — Fusione analytics nella Dashboard, eliminazione rotta /admin/analytics (CLEAN-03)
|
||||
- [x] 18-03-PLAN.md — Verifica CLEAN-04 planning docs + build check + checkpoint visuale
|
||||
|
||||
**Status**: ✅ Complete (2026-06-19)
|
||||
|
||||
**What was built:**
|
||||
- Rimossa pagina `/admin/forecast` + voce sidebar
|
||||
- Rimosso quote builder manuale `/admin/quotes/new` + SendQuoteModal rami irraggiungibili
|
||||
- Statistiche annuali `/admin/analytics` fuse nella Dashboard `/admin` (CLEAN-03); rotta duplicata eliminata
|
||||
- Fasi v2.1 residue (13/15/16/17) marcate cancellate/congelate nel planning
|
||||
|
||||
---
|
||||
|
||||
### Phase 19: Pipeline CRM Kanban (R2)
|
||||
|
||||
**Goal**: I lead si gestiscono in una board Kanban stile Pipedrive con drag-drop tra gli stage.
|
||||
**Mode:** ui
|
||||
**Depends on**: Phase 14 (CRM lead esistente). Riusa `@dnd-kit`.
|
||||
**Requirements**: PIPE-01, PIPE-02
|
||||
**Plans**: 1 plan
|
||||
|
||||
Plans:
|
||||
- [x] 19-01-PLAN.md — LeadsKanbanBoard + LeadsViewToggle + page wiring (PIPE-01, PIPE-02)
|
||||
|
||||
**Status**: ✅ Complete (2026-06-19)
|
||||
|
||||
**What was built:**
|
||||
- Board Kanban 6 colonne (contacted → qualified → proposal_sent → negotiating → won/lost) con drag-drop persistente
|
||||
- Toggle Lista/Kanban con ricerca integrata
|
||||
- Spostare un lead su "Vinto"/"Perso" aggiorna `leads.status` → registra l'esito manuale
|
||||
|
||||
---
|
||||
|
||||
### Phase 20: Knowledge Base Cliente (R3)
|
||||
|
||||
**Goal**: Memorizzare i transcript datati delle call e mostrarli nel profilo lead.
|
||||
**Mode:** schema + ui
|
||||
**Depends on**: Phase 14 (lead). Migration a prod via SSH.
|
||||
**Requirements**: KB-01, KB-02
|
||||
**Plans**: 3 plans
|
||||
|
||||
Plans:
|
||||
- [x] 20-01-PLAN.md — Migration SQL `0009_client_transcripts` (BLOCKING prod checkpoint)
|
||||
- [x] 20-02-PLAN.md — Schema Drizzle `clientTranscripts` + `getTranscripts`/`addTranscript`/`deleteTranscript`
|
||||
- [x] 20-03-PLAN.md — `TranscriptModal` + sezione "Transcript Call" in `LeadDetail` + wiring `page.tsx`
|
||||
|
||||
**Status**: ✅ Complete (2026-06-20)
|
||||
|
||||
**What was built:**
|
||||
- Tabella `client_transcripts` (lead_id, client_id, content, call_date, title, created_at) applicata a prod via SSH tunnel
|
||||
- Data layer completo con Drizzle ORM + server actions
|
||||
- UI: sezione "Transcript Call" nel dettaglio lead con modal per incollare, lista espandibile/collassabile, elimina
|
||||
|
||||
---
|
||||
|
||||
### Phase 21: Agente AI — Generazione Preventivo (R4)
|
||||
|
||||
**Goal**: Admin seleziona cliente+offerta; AI legge transcript+offerta → genera bozza preventivo.
|
||||
**Mode:** ai + ui
|
||||
**Depends on**: Phase 20 (transcript) + Phase 11/12 (offerte)
|
||||
**Requirements**: AI-01, AI-02
|
||||
**Plans**: 1 plan (21+22 eseguiti insieme per coerenza architetturale)
|
||||
|
||||
Plans:
|
||||
- [x] 21-01-PLAN.md — Agente AI + form builder admin + migration 0010 proposals
|
||||
|
||||
**Status**: ✅ Complete (2026-06-20)
|
||||
|
||||
**What was built:**
|
||||
- `@anthropic-ai/sdk@0.105.0` + `ANTHROPIC_API_KEY` in Coolify
|
||||
- `src/lib/proposal/schema.ts` — Zod schema `ProposalContent` (20+ sezioni strutturate)
|
||||
- `src/lib/proposal/agent.ts` — `generateProposalContent()` → Claude Opus 4.8, JSON validato Zod
|
||||
- `src/lib/proposal/assemble.ts` — `assembleProposal()`: fonde AI + offerta DB + profilo consulente
|
||||
- `src/lib/proposal/profile.ts` — `CONSULTANT_PROFILE` (bio, fatti, testimonianze, legal, nextSteps)
|
||||
- `proposals` table in DB: id, slug, lead_id, client_id, offer_macro_id, content jsonb, state, accepted_at
|
||||
- Admin pages: lista preventivi, form genera (con pre-fill `?lead_id=X`), review bozza, pubblica/elimina
|
||||
|
||||
---
|
||||
|
||||
### Phase 22: Pagina Pubblica Preventivo + Email (R5)
|
||||
|
||||
**Goal**: Pubblicare la proposta come deck pubblico `/preventivo/[slug]` con accept/reject.
|
||||
**Mode:** ui
|
||||
**Depends on**: Phase 21 (proposals table + content)
|
||||
**Requirements**: PUB-01, PUB-02 ✅ · PUB-03 ❌ deferred
|
||||
**Plans**: 1 plan
|
||||
|
||||
Plans:
|
||||
- [x] 22-01-PLAN.md — ProposalDeck + public page + accept/reject actions
|
||||
|
||||
**Status**: ✅ Complete (2026-06-20) · PUB-03 deferred
|
||||
|
||||
**What was built:**
|
||||
- `src/app/preventivo/[slug]/page.tsx` — server component pubblico con gestione stati
|
||||
- `src/app/preventivo/[slug]/actions.ts` — `acceptProposal` + `rejectProposal` con `accepted_at` immutabile
|
||||
- `ProposalDeck.tsx` — deck client component: `h-screen overflow-hidden`, keyboard nav, dots
|
||||
- 20 componenti slide: Cover, Vision, Index, ChapterDivider ×5, Strategist, Facts, Testimonials, ProblemNode ×N, SynthesisDiagram, SolutionNode ×N, SolutionSynthesis, Scope, Deliverables, Timeline, Pricing, StagesRecap, ComparisonMatrix, NextSteps, Accept, Closing
|
||||
- **PUB-03 (email Resend) NOT implemented** — link va condiviso manualmente; deferred a v2.3 backlog
|
||||
|
||||
---
|
||||
|
||||
## Milestone Summary
|
||||
|
||||
**Key Decisions:**
|
||||
- Sales Loop come north-star v2.2: abbandonato workspace post-vendita (Phase 13), revenue stats (Phase 15), Proposal AI v2.1 (16/17) — riscopate come 21+22 con transcript
|
||||
- AI model: `claude-opus-4-8` per qualità copywriting strategico (~$0.44/preventivo)
|
||||
- Output AI: JSON strutturato validato Zod → template fisso → coerenza visiva garantita
|
||||
- `proposals.content` come JSONB snapshot (non riferimento a entità live) → immutabile
|
||||
- Email Resend (PUB-03) deferred: scope minimo funziona, priorità al loop core
|
||||
|
||||
**Known Gaps at Close:**
|
||||
- PUB-03: invio email Resend non implementato (1 item, vedi backlog v2.3)
|
||||
- `profile.ts`: testimonianze placeholder, da aggiornare con dati reali
|
||||
|
||||
**Technical Debt:**
|
||||
- Tabelle legacy `service_catalog`/`offer_services`/`offer_micro_services` ancora presenti (deadweight, cleanup futuro)
|
||||
- `createService`/`serviceSchema` dead code in `catalog/actions.ts`
|
||||
- `drizzle-kit generate` rotto da Phase 8 (meta snapshots out of sync) — SQL scritto a mano
|
||||
|
||||
---
|
||||
|
||||
*Per lo stato corrente del progetto, vedi `.planning/ROADMAP.md`*
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
phase: 20-knowledge-base-cliente
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/db/migrations/0009_client_transcripts.sql
|
||||
autonomous: false
|
||||
requirements:
|
||||
- KB-01
|
||||
must_haves:
|
||||
truths:
|
||||
- "Il file 0009_client_transcripts.sql esiste con DDL completo e idempotente"
|
||||
- "La tabella client_transcripts è presente nel database di produzione"
|
||||
- "Il codice schema-dipendente dei piani successivi non viene pushato prima che la migration sia applicata"
|
||||
artifacts:
|
||||
- path: "src/db/migrations/0009_client_transcripts.sql"
|
||||
provides: "DDL CREATE TABLE IF NOT EXISTS client_transcripts con tutti i campi D-01/D-02"
|
||||
contains: "CREATE TABLE IF NOT EXISTS client_transcripts"
|
||||
key_links:
|
||||
- from: "src/db/migrations/0009_client_transcripts.sql"
|
||||
to: "database produzione"
|
||||
via: "SSH tunnel + psql/docker exec"
|
||||
pattern: "CREATE TABLE IF NOT EXISTS client_transcripts"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Scrivere il file SQL della migration manuale per la tabella `client_transcripts` e applicarlo al database di produzione prima di pushare qualsiasi codice schema-dipendente.
|
||||
|
||||
Purpose: Il progetto ha `drizzle-kit generate` rotto (meta-snapshot fuori sync). Ogni schema change richiede SQL a mano applicato a prod via SSH PRIMA del codice dipendente — invariante bloccante LOCKED in CLAUDE.md.
|
||||
|
||||
Output: `src/db/migrations/0009_client_transcripts.sql` applicato a prod. Il checkpoint umano sblocca i piani 20-02 e 20-03.
|
||||
</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/phases/20-knowledge-base-cliente/20-CONTEXT.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Pattern migration a mano — da src/db/migrations/0005 e 0008 -->
|
||||
<!-- Convenzioni: CREATE TABLE IF NOT EXISTS, tipi Postgres espliciti, FK con ON DELETE CASCADE -->
|
||||
<!-- nanoid PK: text NOT NULL — il valore viene inserito dall'applicazione, non da DEFAULT -->
|
||||
<!-- Struttura 0008: header commento, istruzioni additive, CREATE INDEX IF NOT EXISTS -->
|
||||
|
||||
Da 0005_phase_10_crm_leads_activities_reminders.sql:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS "activities" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"lead_id" text NOT NULL,
|
||||
...
|
||||
FOREIGN KEY ("lead_id") REFERENCES "leads"("id") ON DELETE cascade
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "activities_lead_id" ON "activities"("lead_id");
|
||||
```
|
||||
|
||||
Da 0008_offer_tier_schema.sql (header):
|
||||
```sql
|
||||
-- Phase 12: Offer Editor — additive schema ...
|
||||
-- All statements additive/idempotent. No drops/truncates (Data Safety LOCKED).
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Scrivere src/db/migrations/0009_client_transcripts.sql</name>
|
||||
<files>src/db/migrations/0009_client_transcripts.sql</files>
|
||||
<read_first>
|
||||
- src/db/migrations/0008_offer_tier_schema.sql (pattern header + CREATE TABLE IF NOT EXISTS)
|
||||
- src/db/migrations/0005_phase_10_crm_leads_activities_reminders.sql (pattern FK + index per tabelle CRM)
|
||||
- .planning/phases/20-knowledge-base-cliente/20-CONTEXT.md (D-01 e D-02 — campi esatti)
|
||||
</read_first>
|
||||
<action>
|
||||
Creare il file `src/db/migrations/0009_client_transcripts.sql` con il seguente contenuto esatto:
|
||||
|
||||
```sql
|
||||
-- Phase 20: Knowledge Base Cliente — tabella transcript datati per lead/cliente
|
||||
-- Additive only. No drops/truncates (Data Safety LOCKED).
|
||||
-- Applicare a prod via SSH tunnel PRIMA di pushare il codice dipendente (D-03).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_transcripts (
|
||||
id text PRIMARY KEY NOT NULL,
|
||||
lead_id text REFERENCES leads(id) ON DELETE CASCADE,
|
||||
client_id text REFERENCES clients(id) ON DELETE CASCADE,
|
||||
title text,
|
||||
content text NOT NULL,
|
||||
call_date date NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS client_transcripts_lead_id_idx
|
||||
ON client_transcripts (lead_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS client_transcripts_client_id_idx
|
||||
ON client_transcripts (client_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS client_transcripts_call_date_idx
|
||||
ON client_transcripts (call_date DESC);
|
||||
```
|
||||
|
||||
Note sui campi (da D-01/D-02):
|
||||
- `id`: text PK NOT NULL — nanoid inserito dall'app, nessun DEFAULT SQL (pattern progetto)
|
||||
- `lead_id`: nullable FK → leads(id) ON DELETE CASCADE (D-01)
|
||||
- `client_id`: nullable FK → clients(id) ON DELETE CASCADE (D-01)
|
||||
- `title`: text nullable (D-02) — titolo libero opzionale
|
||||
- `content`: text NOT NULL (D-02) — testo grezzo illimitato
|
||||
- `call_date`: date NOT NULL (D-02) — giorno della call, non timestamp
|
||||
- `created_at`: timestamp with time zone NOT NULL DEFAULT now() (D-02)
|
||||
|
||||
Gli indici su lead_id, client_id e call_date ottimizzano le query per lead (Phase 20) e future query per client_id (Phase 21+).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c "CREATE TABLE IF NOT EXISTS client_transcripts" /Users/simonecavalli/Vault/IAMCAVALLI/src/db/migrations/0009_client_transcripts.sql</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `src/db/migrations/0009_client_transcripts.sql` esiste
|
||||
- Contiene `CREATE TABLE IF NOT EXISTS client_transcripts`
|
||||
- Contiene `lead_id text REFERENCES leads(id) ON DELETE CASCADE`
|
||||
- Contiene `client_id text REFERENCES clients(id) ON DELETE CASCADE`
|
||||
- Contiene `content text NOT NULL`
|
||||
- Contiene `call_date date NOT NULL`
|
||||
- Contiene `created_at timestamp with time zone NOT NULL DEFAULT now()`
|
||||
- Contiene 3 `CREATE INDEX IF NOT EXISTS` (lead_id_idx, client_id_idx, call_date_idx)
|
||||
- Nessun DROP o TRUNCATE nel file
|
||||
</acceptance_criteria>
|
||||
<done>File SQL completo e idempotente scritto, pronto per applicazione a prod.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking">
|
||||
<name>Task 2: [BLOCKING] Applicare migration 0009 al database di produzione via SSH</name>
|
||||
<read_first>
|
||||
- src/db/migrations/0009_client_transcripts.sql (verificare il contenuto prima di applicare)
|
||||
</read_first>
|
||||
<action>
|
||||
La migration va applicata a prod via SSH tunnel PRIMA di eseguire i piani 20-02 e 20-03. Il database di produzione è accessibile solo tramite tunnel SSH.
|
||||
|
||||
**Passi:**
|
||||
|
||||
1. Aprire un terminale locale e avviare il tunnel SSH:
|
||||
```bash
|
||||
ssh -L 54321:localhost:54321 root@178.104.27.55
|
||||
```
|
||||
Lasciare questo terminale aperto per tutta la durata.
|
||||
|
||||
2. In un altro terminale, applicare la migration con psql (il DATABASE_URL del progetto punta a 127.0.0.1:54321):
|
||||
```bash
|
||||
cd /Users/simonecavalli/Vault/IAMCAVALLI
|
||||
psql "$(grep DATABASE_URL .env.local | cut -d= -f2-)" -f src/db/migrations/0009_client_transcripts.sql
|
||||
```
|
||||
|
||||
Oppure, se il DATABASE_URL non funziona direttamente, applicare via docker exec sul server:
|
||||
```bash
|
||||
# Sul server (nella sessione SSH aperta al punto 1):
|
||||
docker exec -i <nome_container_postgres> psql -U <db_user> -d <db_name> < /path/to/0009_client_transcripts.sql
|
||||
```
|
||||
(Il nome del container e le credenziali sono in `.env.local` o nei secret Coolify.)
|
||||
|
||||
3. Verificare che la tabella esista:
|
||||
```bash
|
||||
psql "$(grep DATABASE_URL .env.local | cut -d= -f2-)" -c "\d client_transcripts"
|
||||
```
|
||||
Deve mostrare le colonne: id, lead_id, client_id, title, content, call_date, created_at.
|
||||
|
||||
4. Chiudere il tunnel SSH solo dopo aver verificato il punto 3.
|
||||
</action>
|
||||
<what-built>Il file SQL 0009_client_transcripts.sql è stato scritto da Claude nel Task 1. Questo task richiede solo che tu applichi la migration a prod — nessun codice da scrivere.</what-built>
|
||||
<how-to-verify>
|
||||
Eseguire in locale (con tunnel attivo):
|
||||
```bash
|
||||
psql "$(grep DATABASE_URL .env.local | cut -d= -f2-)" -c "SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = 'client_transcripts' ORDER BY ordinal_position;"
|
||||
```
|
||||
Risultato atteso: 7 righe con le colonne id, lead_id, client_id, title, content, call_date, created_at.
|
||||
</how-to-verify>
|
||||
<resume-signal>Digita "migration applicata" dopo aver verificato che \d client_transcripts mostra le 7 colonne attese.</resume-signal>
|
||||
<acceptance_criteria>
|
||||
- La tabella `client_transcripts` esiste nel database di produzione
|
||||
- `\d client_transcripts` mostra 7 colonne: id (text), lead_id (text, nullable), client_id (text, nullable), title (text, nullable), content (text, NOT NULL), call_date (date, NOT NULL), created_at (timestamptz, NOT NULL)
|
||||
- 3 indici presenti: client_transcripts_lead_id_idx, client_transcripts_client_id_idx, client_transcripts_call_date_idx
|
||||
</acceptance_criteria>
|
||||
<done>Database di produzione aggiornato con la tabella client_transcripts. I piani 20-02 e 20-03 sono sbloccati.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Descrizione |
|
||||
|----------|-------------|
|
||||
| developer → database prod | SQL applicato manualmente via SSH tunnel autenticato |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-20-01 | Tampering | 0009_client_transcripts.sql | mitigate | Revisione manuale del contenuto prima dell'applicazione (Task 2 step 1); file in version control |
|
||||
| T-20-02 | Repudiation | Applicazione migration prod | accept | L'operazione è tracciata nel git commit di questo piano; nessun audit log applicativo necessario per DDL |
|
||||
| T-20-03 | Denial of Service | DROP/TRUNCATE accidentale | mitigate | Il file usa solo CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS; nessuna istruzione distruttiva presente |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# Verificare che il file SQL esista e contenga i campi obbligatori
|
||||
grep -E "CREATE TABLE IF NOT EXISTS client_transcripts|content.*text NOT NULL|call_date.*date NOT NULL|lead_id.*REFERENCES leads|client_id.*REFERENCES clients" \
|
||||
/Users/simonecavalli/Vault/IAMCAVALLI/src/db/migrations/0009_client_transcripts.sql
|
||||
|
||||
# Verificare assenza di istruzioni distruttive
|
||||
grep -i "drop\|truncate" /Users/simonecavalli/Vault/IAMCAVALLI/src/db/migrations/0009_client_transcripts.sql | wc -l
|
||||
# Atteso: 0
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `src/db/migrations/0009_client_transcripts.sql` esiste con DDL completo e idempotente (KB-01)
|
||||
- La tabella `client_transcripts` è presente nel database di produzione con le 7 colonne di D-02
|
||||
- Nessuna istruzione distruttiva nel file SQL
|
||||
- Il checkpoint umano è stato completato e segnalato con "migration applicata"
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Dopo il completamento del Task 2, creare `.planning/phases/20-knowledge-base-cliente/20-01-SUMMARY.md` con:
|
||||
- Migration file creato: `src/db/migrations/0009_client_transcripts.sql`
|
||||
- Migration applicata a prod: sì/no
|
||||
- Colonne verificate: lista delle 7 colonne confermate
|
||||
</output>
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
phase: 20-knowledge-base-cliente
|
||||
plan: "01"
|
||||
status: complete
|
||||
completed_at: "2026-06-20"
|
||||
---
|
||||
|
||||
# Plan 20-01 Summary: Migration client_transcripts
|
||||
|
||||
## What Was Built
|
||||
|
||||
Migration file `src/db/migrations/0009_client_transcripts.sql` scritto e applicato a produzione.
|
||||
|
||||
## Key Files
|
||||
|
||||
### Created
|
||||
- `src/db/migrations/0009_client_transcripts.sql` — DDL completo e idempotente
|
||||
|
||||
## Migration Applied to Production
|
||||
|
||||
- **Metodo:** SSH tunnel (127.0.0.1:54321) + Node.js postgres client
|
||||
- **Risultato:** ✓ applicata senza errori
|
||||
|
||||
## Colonne Verificate (7/7)
|
||||
|
||||
| column_name | data_type | nullable |
|
||||
|-------------|--------------------------|----------|
|
||||
| id | text | NO |
|
||||
| lead_id | text | YES |
|
||||
| client_id | text | YES |
|
||||
| title | text | YES |
|
||||
| content | text | NO |
|
||||
| call_date | date | NO |
|
||||
| created_at | timestamp with time zone | NO |
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- ✓ File SQL esiste con DDL completo e idempotente
|
||||
- ✓ Tabella `client_transcripts` presente nel database di produzione
|
||||
- ✓ 7 colonne verificate (D-01/D-02)
|
||||
- ✓ Nessun DROP/TRUNCATE nel file
|
||||
- ✓ 3 indici creati (lead_id_idx, client_id_idx, call_date_idx)
|
||||
@@ -0,0 +1,401 @@
|
||||
---
|
||||
phase: 20-knowledge-base-cliente
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 20-01
|
||||
files_modified:
|
||||
- src/db/schema.ts
|
||||
- src/lib/lead-service.ts
|
||||
- src/app/admin/leads/actions.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- KB-01
|
||||
- KB-02
|
||||
must_haves:
|
||||
truths:
|
||||
- "La tabella clientTranscripts è definita in schema.ts con le relazioni Drizzle corrette"
|
||||
- "getTranscripts(leadId) restituisce i transcript in ordine call_date DESC"
|
||||
- "addTranscript e deleteTranscript sono server actions con requireAdmin guard"
|
||||
artifacts:
|
||||
- path: "src/db/schema.ts"
|
||||
provides: "Definizione tabella clientTranscripts + relazioni Drizzle per leads e clients"
|
||||
contains: "export const clientTranscripts = pgTable"
|
||||
- path: "src/lib/lead-service.ts"
|
||||
provides: "Query getTranscripts(leadId) — tutti i campi incluso content completo"
|
||||
exports: ["getTranscripts"]
|
||||
- path: "src/app/admin/leads/actions.ts"
|
||||
provides: "Server actions addTranscript e deleteTranscript con requireAdmin"
|
||||
exports: ["addTranscript", "deleteTranscript"]
|
||||
key_links:
|
||||
- from: "src/app/admin/leads/actions.ts"
|
||||
to: "src/db/schema.ts"
|
||||
via: "import clientTranscripts"
|
||||
pattern: "clientTranscripts"
|
||||
- from: "src/lib/lead-service.ts"
|
||||
to: "src/db/schema.ts"
|
||||
via: "import clientTranscripts"
|
||||
pattern: "getTranscripts"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Aggiungere la definizione Drizzle della tabella `clientTranscripts` in schema.ts, la query `getTranscripts` in lead-service.ts e le server actions `addTranscript` / `deleteTranscript` in actions.ts.
|
||||
|
||||
Purpose: Fornisce il layer dati completo per la UI del piano 20-03. Phase 21 (AI) leggerà i transcript via `getTranscripts(leadId)` — la firma deve restituire tutti i campi incluso `content` completo.
|
||||
|
||||
Output: Schema Drizzle + query + actions pronte, build TypeScript senza errori.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/20-knowledge-base-cliente/20-CONTEXT.md
|
||||
@.planning/phases/20-knowledge-base-cliente/20-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Pattern esistenti estratti da src/db/schema.ts (linee 464-643) -->
|
||||
|
||||
Pattern tabella CRM con FK lead_id (activities, linee 464-481):
|
||||
```typescript
|
||||
export const activities = pgTable("activities", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
lead_id: text("lead_id").notNull().references(() => leads.id, { onDelete: "cascade" }),
|
||||
type: text("type").notNull(),
|
||||
notes: text("notes").notNull(),
|
||||
activity_date: timestamp("activity_date", { withTimezone: true }).notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
```
|
||||
|
||||
Pattern relazioni (leadsRelations, linea 631-635):
|
||||
```typescript
|
||||
export const leadsRelations = relations(leads, ({ many }) => ({
|
||||
quotes: many(quotes),
|
||||
activities: many(activities),
|
||||
reminders: many(reminders),
|
||||
}));
|
||||
|
||||
export const activitiesRelations = relations(activities, ({ one }) => ({
|
||||
lead: one(leads, { fields: [activities.lead_id], references: [leads.id] }),
|
||||
}));
|
||||
```
|
||||
|
||||
Pattern TypeScript types (fine file):
|
||||
```typescript
|
||||
export type Activity = typeof activities.$inferSelect;
|
||||
export type NewActivity = typeof activities.$inferInsert;
|
||||
```
|
||||
|
||||
Pattern query (lead-service.ts linee 51-57):
|
||||
```typescript
|
||||
export async function getActivityLog(leadId: string) {
|
||||
return await db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.lead_id, leadId))
|
||||
.orderBy(desc(activities.activity_date));
|
||||
}
|
||||
```
|
||||
|
||||
Pattern requireAdmin + server action (actions.ts linee 164-205):
|
||||
```typescript
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
export async function updateLeadField(leadId: string, ...) {
|
||||
await requireAdmin();
|
||||
// ... validazione ...
|
||||
await db.update(leads).set({...}).where(eq(leads.id, leadId));
|
||||
revalidatePath("/admin/leads");
|
||||
revalidatePath(`/admin/leads/${leadId}`);
|
||||
}
|
||||
```
|
||||
|
||||
Import correnti in actions.ts (linee 1-16):
|
||||
```typescript
|
||||
"use server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { leads, quotes, tags } from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
```
|
||||
|
||||
Import correnti in lead-service.ts (linee 1-4):
|
||||
```typescript
|
||||
import { eq, and, isNull, desc, gte, lte, ilike, or } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { leads, activities, reminders, quotes } from "@/db/schema";
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Aggiungere clientTranscripts a src/db/schema.ts</name>
|
||||
<files>src/db/schema.ts</files>
|
||||
<read_first>
|
||||
- src/db/schema.ts (leggere tutto il file per capire dove inserire la nuova tabella e come aggiornare le relations esistenti)
|
||||
- .planning/phases/20-knowledge-base-cliente/20-CONTEXT.md (D-01, D-02 — campi esatti)
|
||||
</read_first>
|
||||
<action>
|
||||
Aggiungere in `src/db/schema.ts` tre blocchi, rispettando la struttura esistente del file:
|
||||
|
||||
**1. Definizione tabella** — aggiungere dopo `// ============ REMINDERS TABLE` (dopo la chiusura della definizione reminders a linea ~499) e prima di `// ============ RELATIONS`:
|
||||
|
||||
```typescript
|
||||
// ============ CLIENT TRANSCRIPTS TABLE (Knowledge Base — Phase 20) ============
|
||||
// Transcript datati delle call, multipli per lead o cliente.
|
||||
// lead_id e client_id sono entrambi nullable (D-01): un transcript può appartenere
|
||||
// a un lead pre-conversione (Phase 20 UI) o a un cliente post-conversione (futuro).
|
||||
// onDelete cascade su entrambe le FK — se il lead/cliente viene eliminato, i transcript vengono eliminati.
|
||||
export const clientTranscripts = pgTable("client_transcripts", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
lead_id: text("lead_id")
|
||||
.references(() => leads.id, { onDelete: "cascade" }),
|
||||
client_id: text("client_id")
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
title: text("title"), // opzionale — es. "Discovery call 12 giugno"
|
||||
content: text("content").notNull(), // testo grezzo illimitato (PostgreSQL text)
|
||||
call_date: text("call_date").notNull(), // DATE come text "YYYY-MM-DD" — coerente col pattern date nel progetto
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
```
|
||||
|
||||
Nota su `call_date`: usare `text` invece di un tipo `date` Drizzle — il progetto gestisce le date come string ISO nei form e nel DB (vedi `activity_date` che è timestamp, ma `call_date` è date pura). Se Drizzle pg-core espone `date` nativo, usarlo; altrimenti usare `text("call_date").notNull()` per coerenza con i pattern di input da `<input type="date">` che restituisce stringhe "YYYY-MM-DD".
|
||||
|
||||
**2. Relazioni** — aggiungere nei blocchi relations esistenti:
|
||||
|
||||
Aggiornare `leadsRelations` (attuale linea ~631):
|
||||
```typescript
|
||||
export const leadsRelations = relations(leads, ({ many }) => ({
|
||||
quotes: many(quotes),
|
||||
activities: many(activities),
|
||||
reminders: many(reminders),
|
||||
transcripts: many(clientTranscripts), // ← aggiungere questa riga
|
||||
}));
|
||||
```
|
||||
|
||||
Aggiornare `clientsRelations` (attuale linea ~503):
|
||||
```typescript
|
||||
export const clientsRelations = relations(clients, ({ many }) => ({
|
||||
projects: many(projects),
|
||||
transcripts: many(clientTranscripts), // ← aggiungere questa riga
|
||||
}));
|
||||
```
|
||||
|
||||
Aggiungere le nuove relations dopo `remindersRelations`:
|
||||
```typescript
|
||||
export const clientTranscriptsRelations = relations(clientTranscripts, ({ one }) => ({
|
||||
lead: one(leads, {
|
||||
fields: [clientTranscripts.lead_id],
|
||||
references: [leads.id],
|
||||
}),
|
||||
client: one(clients, {
|
||||
fields: [clientTranscripts.client_id],
|
||||
references: [clients.id],
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
**3. TypeScript types** — aggiungere in fondo al file dopo `export type Reminder`:
|
||||
```typescript
|
||||
export type ClientTranscript = typeof clientTranscripts.$inferSelect;
|
||||
export type NewClientTranscript = typeof clientTranscripts.$inferInsert;
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "export const clientTranscripts = pgTable" src/db/schema.ts` restituisce 1
|
||||
- `grep -c "transcripts: many(clientTranscripts)" src/db/schema.ts` restituisce 2 (leadsRelations + clientsRelations)
|
||||
- `grep -c "export const clientTranscriptsRelations" src/db/schema.ts` restituisce 1
|
||||
- `grep -c "export type ClientTranscript" src/db/schema.ts` restituisce 1
|
||||
- `npx tsc --noEmit` passa senza errori TypeScript relativi a schema.ts
|
||||
</acceptance_criteria>
|
||||
<done>clientTranscripts definita in schema.ts con relazioni Drizzle bidirezionali e TypeScript types esportati.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Aggiungere getTranscripts a lead-service.ts e addTranscript/deleteTranscript ad actions.ts</name>
|
||||
<files>src/lib/lead-service.ts, src/app/admin/leads/actions.ts</files>
|
||||
<read_first>
|
||||
- src/lib/lead-service.ts (leggere per vedere gli import correnti e dove appendere getTranscripts)
|
||||
- src/app/admin/leads/actions.ts (leggere per vedere gli import correnti e la posizione di requireAdmin)
|
||||
- src/db/schema.ts (dopo il Task 1 — per verificare il nome esatto dell'export clientTranscripts)
|
||||
</read_first>
|
||||
<action>
|
||||
**In src/lib/lead-service.ts:**
|
||||
|
||||
Aggiungere `clientTranscripts` agli import esistenti:
|
||||
```typescript
|
||||
import { leads, activities, reminders, quotes, clientTranscripts } from "@/db/schema";
|
||||
```
|
||||
|
||||
Appendere in fondo al file (dopo `getQuotesByLeadId`):
|
||||
```typescript
|
||||
// Get transcript log for a lead — call_date DESC (D-05)
|
||||
// Restituisce tutti i campi incluso content completo (Phase 21 li legge in blocco).
|
||||
export async function getTranscripts(leadId: string) {
|
||||
return await db
|
||||
.select()
|
||||
.from(clientTranscripts)
|
||||
.where(eq(clientTranscripts.lead_id, leadId))
|
||||
.orderBy(desc(clientTranscripts.call_date));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**In src/app/admin/leads/actions.ts:**
|
||||
|
||||
Aggiungere `clientTranscripts` agli import dal DB schema:
|
||||
```typescript
|
||||
import { leads, quotes, tags, clientTranscripts } from "@/db/schema";
|
||||
```
|
||||
|
||||
Aggiungere `nanoid` agli import (serve per generare l'id del transcript):
|
||||
```typescript
|
||||
import { nanoid } from "nanoid";
|
||||
```
|
||||
|
||||
Definire lo schema Zod per il transcript e appendere le due actions dopo le tag actions esistenti (dopo `renameLeadTag`):
|
||||
|
||||
```typescript
|
||||
// ── Transcript actions (Phase 20 — KB-02) ────────────────────────────────────
|
||||
|
||||
const addTranscriptSchema = z.object({
|
||||
lead_id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
content: z.string().min(1, "Il testo del transcript è obbligatorio"),
|
||||
call_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data non valida (YYYY-MM-DD)"),
|
||||
});
|
||||
|
||||
export async function addTranscript(data: z.infer<typeof addTranscriptSchema>) {
|
||||
await requireAdmin();
|
||||
|
||||
const parsed = addTranscriptSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
const [transcript] = await db
|
||||
.insert(clientTranscripts)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
lead_id: parsed.data.lead_id,
|
||||
title: parsed.data.title || null,
|
||||
content: parsed.data.content,
|
||||
call_date: parsed.data.call_date,
|
||||
})
|
||||
.returning();
|
||||
|
||||
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
|
||||
return { success: true, transcript };
|
||||
} catch (error) {
|
||||
console.error("addTranscript error:", error);
|
||||
return { success: false, error: "Errore nel salvataggio del transcript" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTranscript(transcriptId: string, leadId: string) {
|
||||
await requireAdmin();
|
||||
|
||||
if (!transcriptId) throw new Error("ID transcript richiesto");
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(clientTranscripts)
|
||||
.where(eq(clientTranscripts.id, transcriptId));
|
||||
|
||||
revalidatePath(`/admin/leads/${leadId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("deleteTranscript error:", error);
|
||||
return { success: false, error: "Errore nell'eliminazione del transcript" };
|
||||
}
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | grep -E "lead-service|actions" | head -10</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "export async function getTranscripts" src/lib/lead-service.ts` restituisce 1
|
||||
- `grep -c "orderBy(desc(clientTranscripts.call_date))" src/lib/lead-service.ts` restituisce 1
|
||||
- `grep -c "export async function addTranscript" src/app/admin/leads/actions.ts` restituisce 1
|
||||
- `grep -c "export async function deleteTranscript" src/app/admin/leads/actions.ts` restituisce 1
|
||||
- `grep -c "await requireAdmin()" src/app/admin/leads/actions.ts` è >= 3 (addTranscript e deleteTranscript devono averlo, più le actions esistenti)
|
||||
- `grep -c "revalidatePath" src/app/admin/leads/actions.ts` aumenta di 2 rispetto al file originale (una per addTranscript, una per deleteTranscript)
|
||||
- `npx tsc --noEmit` passa senza errori su lead-service.ts e actions.ts
|
||||
</acceptance_criteria>
|
||||
<done>getTranscripts esportata da lead-service.ts, addTranscript e deleteTranscript aggiunte ad actions.ts con requireAdmin guard e revalidatePath.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Descrizione |
|
||||
|----------|-------------|
|
||||
| browser → server action | addTranscript e deleteTranscript sono "use server" — input non trusted |
|
||||
| server action → database | Query Drizzle con parametri typed |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-20-04 | Elevation of Privilege | addTranscript / deleteTranscript | mitigate | `await requireAdmin()` come prima istruzione in entrambe le actions — lancia Error se sessione assente, blocca esecuzione |
|
||||
| T-20-05 | Tampering | addTranscript — content | accept | Nessuna sanitization HTML richiesta (testo grezzo, non renderizzato come HTML — CONTEXT.md security note); Zod valida presenza e tipo |
|
||||
| T-20-06 | Tampering | deleteTranscript — transcriptId | mitigate | L'ID viene passato dalla UI admin-only (protetta da requireAdmin); Drizzle usa query parametrizzata — no SQL injection |
|
||||
| T-20-07 | Information Disclosure | getTranscripts | mitigate | Funzione usata solo in server components admin (`/admin/leads/[id]/page.tsx`); non esposta su API client (D-04 — client_id FK presente ma non UI client in Phase 20) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
cd /Users/simonecavalli/Vault/IAMCAVALLI
|
||||
|
||||
# Schema: tabella e relazioni presenti
|
||||
grep -E "clientTranscripts|ClientTranscript" src/db/schema.ts | grep -v "^//"
|
||||
|
||||
# lead-service: query con ordinamento corretto
|
||||
grep -A 6 "getTranscripts" src/lib/lead-service.ts
|
||||
|
||||
# actions: requireAdmin su entrambe le nuove actions
|
||||
grep -B 1 "requireAdmin" src/app/admin/leads/actions.ts
|
||||
|
||||
# Build TypeScript
|
||||
npx tsc --noEmit 2>&1 | tail -5
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- schema.ts contiene `clientTranscripts` table, relazioni bidirezionali (leads + clients), e TypeScript types (KB-01)
|
||||
- lead-service.ts esporta `getTranscripts(leadId)` che ordina per `call_date DESC` (KB-02, D-05)
|
||||
- actions.ts ha `addTranscript` e `deleteTranscript` entrambe con `requireAdmin()` guard (KB-02)
|
||||
- `npx tsc --noEmit` passa senza errori sui file modificati
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Dopo il completamento, creare `.planning/phases/20-knowledge-base-cliente/20-02-SUMMARY.md` con:
|
||||
- Tabella Drizzle aggiunta: clientTranscripts con 7 campi
|
||||
- Query aggiunta: getTranscripts(leadId) in lead-service.ts
|
||||
- Actions aggiunte: addTranscript, deleteTranscript in actions.ts
|
||||
- TypeScript check: passed/failed
|
||||
</output>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
phase: 20-knowledge-base-cliente
|
||||
plan: "02"
|
||||
status: complete
|
||||
completed_at: "2026-06-20"
|
||||
---
|
||||
|
||||
# Plan 20-02 Summary: Data Layer client_transcripts
|
||||
|
||||
## What Was Built
|
||||
|
||||
Drizzle schema, query layer e server actions per i transcript delle call.
|
||||
|
||||
## Key Files Modified
|
||||
|
||||
- `src/db/schema.ts` — tabella `clientTranscripts` + relazioni bidirezionali + TypeScript types
|
||||
- `src/lib/lead-service.ts` — `getTranscripts(leadId)` con orderBy call_date DESC
|
||||
- `src/app/admin/leads/actions.ts` — `addTranscript` + `deleteTranscript` con requireAdmin
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- ✓ `export const clientTranscripts = pgTable("client_transcripts", ...)` in schema.ts
|
||||
- ✓ `leadsRelations` aggiornato con `transcripts: many(clientTranscripts)`
|
||||
- ✓ `clientsRelations` aggiornato con `transcripts: many(clientTranscripts)`
|
||||
- ✓ `clientTranscriptsRelations` con one(leads) + one(clients)
|
||||
- ✓ `export type ClientTranscript` e `NewClientTranscript` esportati
|
||||
- ✓ `getTranscripts(leadId)` ordinato per `call_date DESC`
|
||||
- ✓ `addTranscript` con requireAdmin + Zod + revalidatePath
|
||||
- ✓ `deleteTranscript` con requireAdmin + revalidatePath
|
||||
- ✓ `npx tsc --noEmit` → no errors
|
||||
@@ -0,0 +1,626 @@
|
||||
---
|
||||
phase: 20-knowledge-base-cliente
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- 20-01
|
||||
- 20-02
|
||||
files_modified:
|
||||
- src/components/admin/leads/TranscriptModal.tsx
|
||||
- src/components/admin/leads/LeadDetail.tsx
|
||||
- src/app/admin/leads/[id]/page.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- KB-02
|
||||
must_haves:
|
||||
truths:
|
||||
- "L'admin può aggiungere un transcript dalla pagina dettaglio lead via modal"
|
||||
- "I transcript sono elencati in call_date DESC con titolo, data formattata e anteprima del testo"
|
||||
- "Il testo completo di ogni transcript è espandibile/collassabile"
|
||||
- "L'admin può eliminare un transcript con un bottone 'Elimina'"
|
||||
- "La sezione Transcript appare dopo lo Storico Attività nel LeadDetail"
|
||||
artifacts:
|
||||
- path: "src/components/admin/leads/TranscriptModal.tsx"
|
||||
provides: "Modal form per aggiungere transcript (call_date, title opzionale, content textarea)"
|
||||
exports: ["TranscriptModal"]
|
||||
- path: "src/components/admin/leads/LeadDetail.tsx"
|
||||
provides: "Sezione Transcript con lista collassabile e azione Elimina"
|
||||
contains: "Transcript"
|
||||
- path: "src/app/admin/leads/[id]/page.tsx"
|
||||
provides: "getTranscripts(id) nel Promise.all + prop transcripts passato a LeadDetail"
|
||||
contains: "getTranscripts"
|
||||
key_links:
|
||||
- from: "src/components/admin/leads/TranscriptModal.tsx"
|
||||
to: "src/app/admin/leads/actions.ts"
|
||||
via: "import addTranscript"
|
||||
pattern: "addTranscript"
|
||||
- from: "src/components/admin/leads/LeadDetail.tsx"
|
||||
to: "src/app/admin/leads/actions.ts"
|
||||
via: "import deleteTranscript"
|
||||
pattern: "deleteTranscript"
|
||||
- from: "src/app/admin/leads/[id]/page.tsx"
|
||||
to: "src/lib/lead-service.ts"
|
||||
via: "import getTranscripts"
|
||||
pattern: "getTranscripts"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Creare il componente `TranscriptModal` (form per aggiungere transcript) e integrare la sezione Transcript in `LeadDetail`, con wiring nella page server.
|
||||
|
||||
Purpose: Completa il loop KB-02 — l'admin può incollare transcript datati e vederli elencati in ordine cronologico nel profilo lead. I dati sono pronti per essere letti dall'agente AI in Phase 21.
|
||||
|
||||
Output: UI completa funzionante — modal di aggiunta + lista con expand/collapse + elimina.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/20-knowledge-base-cliente/20-CONTEXT.md
|
||||
@.planning/phases/20-knowledge-base-cliente/20-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Pattern estratti da file esistenti — l'executor NON deve rileggere questi file -->
|
||||
|
||||
Da LogActivityModal.tsx — pattern modal con react-hook-form + zod + shadcn Dialog:
|
||||
```typescript
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
// ... action import ...
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function LogActivityModal({ leadId }: { leadId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<any>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { lead_id: leadId, activity_date: new Date().toISOString().split("T")[0], ... },
|
||||
});
|
||||
async function onSubmit(data) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await logActivity(data);
|
||||
if (result.success) { setOpen(false); form.reset({...}); }
|
||||
} finally { setLoading(false); }
|
||||
}
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild><Button variant="outline" size="sm">...</Button></DialogTrigger>
|
||||
<DialogContent className="max-w-sm">
|
||||
...
|
||||
<Input type="date" {...field} value={field.value || ""} />
|
||||
<Textarea className="resize-none h-24" {...field} value={field.value || ""} />
|
||||
<Button type="submit" className="w-full" disabled={loading}>...</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Da LeadDetail.tsx — struttura esistente con Card shadcn + sezione attività (pattern da replicare per Transcript):
|
||||
```typescript
|
||||
"use client";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Lead, Activity, Reminder } from "@/db/schema"; // ← aggiungere ClientTranscript
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { format } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
|
||||
// Signature attuale LeadDetail:
|
||||
export function LeadDetail({
|
||||
lead, activities, reminders, tags, tagOptions
|
||||
}: {
|
||||
lead: Lead; activities: Activity[]; reminders: Reminder[];
|
||||
tags: string[]; tagOptions: string[];
|
||||
})
|
||||
|
||||
// Sezione Attività (pattern da replicare per Transcript):
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Storico Attività</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{activities.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="border-l-4 border-blue-300 pl-4 pb-4">
|
||||
...testo e date formattate con date-fns...
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Nessuna attività registrata</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
Da page.tsx — pattern Promise.all e passaggio props:
|
||||
```typescript
|
||||
import { getActivityLog, getUpcomingReminders } from "@/lib/lead-service";
|
||||
// ← aggiungere: import { getTranscripts } from "@/lib/lead-service";
|
||||
|
||||
const activities = await getActivityLog(id);
|
||||
const reminders = await getUpcomingReminders(id);
|
||||
// ← aggiungere nel Promise.all oppure sequenzialmente dopo
|
||||
|
||||
return (
|
||||
<LeadDetail
|
||||
lead={lead}
|
||||
activities={activities}
|
||||
reminders={reminders}
|
||||
tags={lead.tags}
|
||||
tagOptions={options.tags}
|
||||
// ← aggiungere: transcripts={transcripts}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
Tipo ClientTranscript (da schema.ts dopo Plan 02):
|
||||
```typescript
|
||||
export type ClientTranscript = typeof clientTranscripts.$inferSelect;
|
||||
// Campi: id, lead_id, client_id, title, content, call_date, created_at
|
||||
```
|
||||
|
||||
Formattazione date italiana con date-fns (già usato nel progetto):
|
||||
```typescript
|
||||
import { format } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
// call_date è string "YYYY-MM-DD" — usare new Date(t.call_date + "T00:00:00") per evitare timezone shift
|
||||
format(new Date(t.call_date + "T00:00:00"), "d MMMM yyyy", { locale: it })
|
||||
// → "12 giugno 2026"
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Creare TranscriptModal.tsx e aggiornare page.tsx</name>
|
||||
<files>src/components/admin/leads/TranscriptModal.tsx, src/app/admin/leads/[id]/page.tsx</files>
|
||||
<read_first>
|
||||
- src/components/admin/leads/LogActivityModal.tsx (pattern esatto del modal da replicare)
|
||||
- src/app/admin/leads/[id]/page.tsx (vedere la struttura attuale per capire dove aggiungere getTranscripts)
|
||||
</read_first>
|
||||
<action>
|
||||
**Creare src/components/admin/leads/TranscriptModal.tsx:**
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { addTranscript } from "@/app/admin/leads/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const transcriptSchema = z.object({
|
||||
lead_id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
content: z.string().min(1, "Il testo del transcript è obbligatorio"),
|
||||
call_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data non valida"),
|
||||
});
|
||||
|
||||
type TranscriptInput = z.infer<typeof transcriptSchema>;
|
||||
|
||||
export function TranscriptModal({ leadId }: { leadId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<TranscriptInput>({
|
||||
resolver: zodResolver(transcriptSchema),
|
||||
defaultValues: {
|
||||
lead_id: leadId,
|
||||
title: "",
|
||||
content: "",
|
||||
call_date: new Date().toISOString().split("T")[0],
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: TranscriptInput) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await addTranscript(data);
|
||||
if (result.success) {
|
||||
setOpen(false);
|
||||
form.reset({
|
||||
lead_id: leadId,
|
||||
title: "",
|
||||
content: "",
|
||||
call_date: new Date().toISOString().split("T")[0],
|
||||
});
|
||||
} else {
|
||||
setError(result.error ?? "Errore nel salvataggio");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Aggiungi Transcript
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Aggiungi Transcript Call</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="call_date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Data call</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Titolo (opzionale)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="es. Discovery call"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Transcript</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Incolla qui il testo del transcript..."
|
||||
className="min-h-48 resize-y"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Salvataggio..." : "Salva Transcript"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Aggiornare src/app/admin/leads/[id]/page.tsx:**
|
||||
|
||||
Aggiungere `getTranscripts` all'import da lead-service:
|
||||
```typescript
|
||||
import { getActivityLog, getUpcomingReminders, getTranscripts } from "@/lib/lead-service";
|
||||
```
|
||||
|
||||
Aggiungere la fetch nel corpo del componente. La page attualmente fa due call sequenziali dopo il Promise.all. Aggiungere `getTranscripts(id)` in parallelo con le altre:
|
||||
|
||||
```typescript
|
||||
const [leads, options, transcripts] = await Promise.all([
|
||||
getLeadsWithTags(),
|
||||
getLeadFieldOptions(),
|
||||
getTranscripts(id),
|
||||
]);
|
||||
```
|
||||
|
||||
Oppure, se la struttura attuale non usa Promise.all per tutte le chiamate, aggiungere sequenzialmente dopo `reminders`:
|
||||
```typescript
|
||||
const transcripts = await getTranscripts(id);
|
||||
```
|
||||
|
||||
Passare la prop al componente:
|
||||
```typescript
|
||||
return (
|
||||
<LeadDetail
|
||||
lead={lead}
|
||||
activities={activities}
|
||||
reminders={reminders}
|
||||
tags={lead.tags}
|
||||
tagOptions={options.tags}
|
||||
transcripts={transcripts}
|
||||
/>
|
||||
);
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | grep -E "TranscriptModal|page" | head -10</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `src/components/admin/leads/TranscriptModal.tsx` esiste
|
||||
- `grep -c "export function TranscriptModal" src/components/admin/leads/TranscriptModal.tsx` restituisce 1
|
||||
- `grep -c "min-h-48" src/components/admin/leads/TranscriptModal.tsx` restituisce 1 (textarea generosa)
|
||||
- `grep -c "Aggiungi Transcript" src/components/admin/leads/TranscriptModal.tsx` restituisce almeno 1
|
||||
- `grep -c "getTranscripts" src/app/admin/leads/[id]/page.tsx` restituisce almeno 2 (import + chiamata)
|
||||
- `grep -c "transcripts={transcripts}" src/app/admin/leads/[id]/page.tsx` restituisce 1
|
||||
- `npx tsc --noEmit` passa senza errori su questi file
|
||||
</acceptance_criteria>
|
||||
<done>TranscriptModal creato, page.tsx aggiornata con getTranscripts e prop transcripts passata a LeadDetail.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Aggiornare LeadDetail.tsx — aggiungere prop transcripts e sezione Transcript</name>
|
||||
<files>src/components/admin/leads/LeadDetail.tsx</files>
|
||||
<read_first>
|
||||
- src/components/admin/leads/LeadDetail.tsx (leggere per vedere la struttura attuale — dove finisce la sezione Attività e dove inserire Transcript)
|
||||
</read_first>
|
||||
<action>
|
||||
Aggiornare `src/components/admin/leads/LeadDetail.tsx` con le seguenti modifiche:
|
||||
|
||||
**1. Aggiungere import:**
|
||||
```typescript
|
||||
import { ClientTranscript } from "@/db/schema";
|
||||
import { useTransition } from "react"; // già importato — verificare che ci sia
|
||||
import { TranscriptModal } from "./TranscriptModal";
|
||||
import { deleteTranscript } from "@/app/admin/leads/actions";
|
||||
```
|
||||
|
||||
**2. Aggiungere `transcripts` alla prop interface:**
|
||||
```typescript
|
||||
export function LeadDetail({
|
||||
lead,
|
||||
activities,
|
||||
reminders,
|
||||
tags,
|
||||
tagOptions,
|
||||
transcripts, // ← aggiungere
|
||||
}: {
|
||||
lead: Lead;
|
||||
activities: Activity[];
|
||||
reminders: Reminder[];
|
||||
tags: string[];
|
||||
tagOptions: string[];
|
||||
transcripts: ClientTranscript[]; // ← aggiungere
|
||||
})
|
||||
```
|
||||
|
||||
**3. Aggiungere state per expand/collapse e delete nel corpo del componente:**
|
||||
|
||||
Aggiungere dopo gli useState esistenti:
|
||||
```typescript
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [, startDeleteTransition] = useTransition();
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(transcriptId: string) {
|
||||
setDeletingId(transcriptId);
|
||||
startDeleteTransition(async () => {
|
||||
try {
|
||||
await deleteTranscript(transcriptId, lead.id);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.error("deleteTranscript error:", e);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**4. Aggiungere il pulsante TranscriptModal nell'header (accanto a LogActivityModal):**
|
||||
```typescript
|
||||
<div className="flex gap-2">
|
||||
<LogActivityModal leadId={lead.id} />
|
||||
<TranscriptModal leadId={lead.id} /> {/* ← aggiungere */}
|
||||
<SendQuoteModal leadId={lead.id} />
|
||||
<EditLeadModal lead={lead} />
|
||||
</div>
|
||||
```
|
||||
|
||||
**5. Aggiungere la sezione Transcript dopo la sezione Storico Attività (dopo la chiusura del `</Card>` dell'attività):**
|
||||
|
||||
```typescript
|
||||
{/* Transcript */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Transcript Call</CardTitle>
|
||||
<span className="text-sm text-gray-500">
|
||||
{transcripts.length} {transcripts.length === 1 ? "transcript" : "transcript"}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transcripts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{transcripts.map((t) => {
|
||||
const isExpanded = expandedIds.has(t.id);
|
||||
// Formattare call_date come "12 giugno 2026"
|
||||
// call_date è string "YYYY-MM-DD" — aggiungere T00:00:00 per evitare timezone shift
|
||||
const formattedDate = format(
|
||||
new Date(t.call_date + "T00:00:00"),
|
||||
"d MMMM yyyy",
|
||||
{ locale: it }
|
||||
);
|
||||
// Anteprima: prime 3 righe o 200 caratteri (il primo valore raggiunto)
|
||||
const preview = t.content
|
||||
.split("\n")
|
||||
.slice(0, 3)
|
||||
.join("\n")
|
||||
.slice(0, 200);
|
||||
const hasMore = t.content.length > preview.length || t.content.split("\n").length > 3;
|
||||
|
||||
return (
|
||||
<div key={t.id} className="border-l-4 border-purple-300 pl-4 pb-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">{formattedDate}</span>
|
||||
{t.title && (
|
||||
<span className="text-gray-600 text-sm">— {t.title}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 whitespace-pre-wrap">
|
||||
{isExpanded ? t.content : preview}
|
||||
{!isExpanded && hasMore && (
|
||||
<span className="text-gray-400">...</span>
|
||||
)}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => toggleExpand(t.id)}
|
||||
className="text-xs text-blue-600 hover:underline mt-1"
|
||||
>
|
||||
{isExpanded ? "Mostra meno" : "Mostra tutto"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 shrink-0"
|
||||
disabled={deletingId === t.id}
|
||||
onClick={() => handleDelete(t.id)}
|
||||
>
|
||||
{deletingId === t.id ? "..." : "Elimina"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Nessun transcript registrato</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | grep "LeadDetail" | head -10</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "transcripts: ClientTranscript\[\]" src/components/admin/leads/LeadDetail.tsx` restituisce 1
|
||||
- `grep -c "TranscriptModal" src/components/admin/leads/LeadDetail.tsx` restituisce almeno 2 (import + uso nel JSX)
|
||||
- `grep -c "deleteTranscript" src/components/admin/leads/LeadDetail.tsx` restituisce almeno 2 (import + uso in handleDelete)
|
||||
- `grep -c "Transcript Call" src/components/admin/leads/LeadDetail.tsx` restituisce 1 (titolo Card sezione)
|
||||
- `grep -c "Mostra tutto" src/components/admin/leads/LeadDetail.tsx` restituisce 1 (expand/collapse)
|
||||
- `grep -c "T00:00:00" src/components/admin/leads/LeadDetail.tsx` restituisce 1 (fix timezone per call_date)
|
||||
- `grep -c "border-l-4 border-purple-300" src/components/admin/leads/LeadDetail.tsx` restituisce 1 (stile sezione transcript, distinto dal blu delle attività)
|
||||
- `npx tsc --noEmit` passa senza errori su LeadDetail.tsx
|
||||
- `npm run build` completa senza errori (verificare alla fine)
|
||||
</acceptance_criteria>
|
||||
<done>LeadDetail aggiornato con prop transcripts, sezione Transcript con lista expand/collapse e azione Elimina posizionata dopo Storico Attività.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Descrizione |
|
||||
|----------|-------------|
|
||||
| browser → TranscriptModal form | Input utente admin non sanitizzato prima del submit |
|
||||
| TranscriptModal → addTranscript server action | "use server" boundary — Zod valida il payload |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-20-08 | Tampering | TranscriptModal — content field | accept | Il content è testo grezzo (textarea), non renderizzato come HTML nella UI — nessun XSS possibile con `whitespace-pre-wrap` senza `dangerouslySetInnerHTML`; Zod valida min length 1 |
|
||||
| T-20-09 | Elevation of Privilege | TranscriptModal — addTranscript call | mitigate | La route `/admin/leads/[id]` è protetta dal middleware Auth.js (sessione admin richiesta); `addTranscript` server action chiama `requireAdmin()` come prima istruzione |
|
||||
| T-20-10 | Elevation of Privilege | deleteTranscript client call | mitigate | `deleteTranscript` server action chiama `requireAdmin()` come prima istruzione; il transcriptId viene dalla prop SSR, non da input utente libero |
|
||||
| T-20-11 | Information Disclosure | Transcript content nel DOM | accept | La pagina è `/admin/*` — solo admin autenticato la vede; nessuna esposizione lato client pubblico |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
cd /Users/simonecavalli/Vault/IAMCAVALLI
|
||||
|
||||
# Verificare che tutti i file esistano
|
||||
ls -la src/components/admin/leads/TranscriptModal.tsx
|
||||
grep -c "getTranscripts" src/app/admin/leads/\[id\]/page.tsx
|
||||
grep -c "transcripts: ClientTranscript" src/components/admin/leads/LeadDetail.tsx
|
||||
|
||||
# Verificare struttura sezione Transcript
|
||||
grep -n "Transcript\|deleteTranscript\|TranscriptModal" src/components/admin/leads/LeadDetail.tsx
|
||||
|
||||
# Build completo
|
||||
npm run build 2>&1 | tail -15
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `TranscriptModal.tsx` esiste con form: call_date (date input), title (optional text), content (textarea min-h-48) (KB-02)
|
||||
- `LeadDetail.tsx` ha prop `transcripts: ClientTranscript[]` e sezione "Transcript Call" dopo "Storico Attività" (KB-02, D-05)
|
||||
- Lista transcript mostra: data formattata in italiano, titolo se presente, anteprima testo con expand/collapse, bottone Elimina (KB-02)
|
||||
- `page.tsx` chiama `getTranscripts(id)` e passa `transcripts` a `LeadDetail` (KB-02)
|
||||
- `npm run build` completa senza errori TypeScript o di compilazione
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Dopo il completamento, creare `.planning/phases/20-knowledge-base-cliente/20-03-SUMMARY.md` con:
|
||||
- Componenti creati: TranscriptModal.tsx
|
||||
- Componenti modificati: LeadDetail.tsx, page.tsx
|
||||
- Features: modal aggiunta, lista con expand/collapse, eliminazione, data in italiano
|
||||
- Build status: passed/failed
|
||||
- Note su eventuali adattamenti ai pattern esistenti
|
||||
</output>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
phase: 20-knowledge-base-cliente
|
||||
plan: "03"
|
||||
status: complete
|
||||
completed_at: "2026-06-20"
|
||||
---
|
||||
|
||||
# Plan 20-03 Summary: UI Transcript Call
|
||||
|
||||
## What Was Built
|
||||
|
||||
UI completa per aggiungere, visualizzare ed eliminare i transcript delle call nel profilo lead.
|
||||
|
||||
## Key Files
|
||||
|
||||
### Created
|
||||
- `src/components/admin/leads/TranscriptModal.tsx` — modal form (call_date + title + content textarea min-h-48)
|
||||
|
||||
### Modified
|
||||
- `src/components/admin/leads/LeadDetail.tsx` — prop `transcripts: ClientTranscript[]`, sezione "Transcript Call" con expand/collapse e Elimina, TranscriptModal nel header
|
||||
- `src/app/admin/leads/[id]/page.tsx` — `getTranscripts(id)` nel Promise.all, prop `transcripts` passato a LeadDetail
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- ✓ `TranscriptModal.tsx` esiste con form call_date/title/content (min-h-48)
|
||||
- ✓ `LeadDetail.tsx` ha prop `transcripts: ClientTranscript[]`
|
||||
- ✓ Sezione "Transcript Call" appare dopo "Storico Attività"
|
||||
- ✓ Data formattata in italiano con `new Date(t.call_date + "T00:00:00")` (fix TZ shift)
|
||||
- ✓ Preview 3 righe/200 caratteri con toggle "Mostra tutto / Mostra meno"
|
||||
- ✓ Bottone "Elimina" via `deleteTranscript` server action
|
||||
- ✓ `border-l-4 border-purple-300` — stile distinto dal blu delle attività
|
||||
- ✓ `getTranscripts(id)` nel Promise.all di page.tsx
|
||||
- ✓ `npx tsc --noEmit` → no errors
|
||||
- ✓ `npm run build` → clean
|
||||
@@ -0,0 +1,111 @@
|
||||
# Phase 20: Knowledge Base Cliente — Context
|
||||
|
||||
**Gathered:** 2026-06-19
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Aggiungere uno store di transcript datati per lead: l'admin incolla il testo grezzo di ogni call con data e titolo libero, la lista appare in ordine cronologico nel dettaglio lead, e i dati sono pronti per essere letti dall'agente AI in Phase 21.
|
||||
|
||||
**In scope:** schema `client_transcripts` + server actions + UI nel LeadDetail
|
||||
**Out of scope:** UI per clienti (client_id FK presente ma non esposta), ricerca full-text sui transcript, trascrizione automatica da audio
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Schema — client_transcripts
|
||||
|
||||
- **D-01:** La tabella `client_transcripts` ha **entrambe** le FK nullable: `lead_id` (references leads, onDelete cascade) e `client_id` (references clients, onDelete cascade). Ragionamento: prepara la struttura per transcript post-conversione senza richiedere una migration futura.
|
||||
- **D-02:** Campi: `id` (nanoid PK), `lead_id` (nullable FK), `client_id` (nullable FK), `title` (text, optional — titolo libero es. "Discovery call 12 giugno"), `content` (text NOT NULL — testo incollato, lunghezza illimitata), `call_date` (date NOT NULL — giorno della call, non timestamp), `created_at` (timestamp with timezone, defaultNow).
|
||||
- **D-03:** Migration mano-scritta come 0009 — drizzle-kit generate è rotto. Applicata a prod via SSH **prima** di pushare il codice dipendente (invariante bloccante del progetto).
|
||||
|
||||
### UI — Solo lato lead in Phase 20
|
||||
|
||||
- **D-04:** In Phase 20 la UI espone solo il lato lead. La FK `client_id` è nello schema ma **non ha form o lista** in questa fase — si aggiunge in futuro se serve la pagina `/admin/clients/[id]`.
|
||||
- **D-05:** I transcript sono listati in ordine `call_date DESC` nel dettaglio lead (chiamata più recente in cima).
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
Le seguenti aree non sono state discusse — Claude ha flessibilità:
|
||||
|
||||
- **Metadati:** No tipo enum — `title` libero (optional) è sufficiente. Il testo del transcript parla da solo.
|
||||
- **Placement UI:** Nuova sezione "Transcript" collassabile in `LeadDetail`, dopo la sezione Attività esistente. Stessa convenzione visiva (Card + lista). Form/modal per aggiungere seguendo il pattern `LogActivityModal`.
|
||||
- **Nessun limite di lunghezza:** `content` è `text` PostgreSQL (illimitato). Textarea nel form senza troncatura.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Requirements & Roadmap
|
||||
- `.planning/ROADMAP.md` — Phase 20 goal, success criteria, schema spec (`client_transcripts` con `lead_id/client_id, testo, data, titolo/tipo, created_at`)
|
||||
- `.planning/REQUIREMENTS.md` — KB-01 (schema additivo transcript), KB-02 (UI incolla/elenca)
|
||||
|
||||
### Schema & Migrations
|
||||
- `src/db/schema.ts` — pattern tabelle append-only CRM: `activities` (lead_id, type, notes, activity_date), `reminders` (lead_id, due_date). La nuova `client_transcripts` segue questo pattern.
|
||||
- `src/db/migrations/0008_offer_tier_schema.sql` — ultimo esempio di SQL migration a mano (struttura e convenzioni da seguire)
|
||||
- `src/db/migrations/0005_phase_10_crm_leads_activities_reminders.sql` — migration originale di `activities` e `reminders` (pattern più vicino alla nuova tabella)
|
||||
|
||||
### UI & Components
|
||||
- `src/components/admin/leads/LeadDetail.tsx` — struttura UI esistente del dettaglio lead (dove va inserita la sezione Transcript)
|
||||
- `src/components/admin/leads/LogActivityModal.tsx` — pattern modal per aggiungere dati CRM (da seguire per il form transcript)
|
||||
- `src/app/admin/leads/[id]/page.tsx` — pattern page con `Promise.all` per fetch parallele (aggiungere `getTranscripts(id)`)
|
||||
|
||||
### Query & Actions Layer
|
||||
- `src/lib/lead-service.ts` — pattern query layer CRM (`getActivityLog`, `getUpcomingReminders` — aggiungere `getTranscripts`)
|
||||
- `src/app/admin/leads/actions.ts` — pattern server actions con `requireAdmin` guard (da seguire per `addTranscript`, `deleteTranscript`)
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `LogActivityModal.tsx` — modal con form controllato (date picker + textarea + submit), pronto da clonare per il form transcript
|
||||
- `activities` / `reminders` pattern in `schema.ts` — FK `lead_id` con `onDelete: cascade`, `nanoid()` PK, `created_at` defaultNow — copia esatta per `client_transcripts`
|
||||
- `getActivityLog(leadId)` in `lead-service.ts` — query Drizzle con `where(eq(activities.lead_id, leadId))` e `orderBy(desc(activities.activity_date))` — pattern identico per `getTranscripts`
|
||||
- `Card`, `CardContent`, `CardHeader`, `CardTitle` da shadcn/ui — già usati nel LeadDetail per ogni sezione
|
||||
|
||||
### Established Patterns
|
||||
- **Migration a mano**: ogni schema change è SQL scritto a mano (`CREATE TABLE IF NOT EXISTS`, tipi Postgres espliciti, FK con `ON DELETE CASCADE`). NON usare `drizzle-kit generate`.
|
||||
- **requireAdmin guard**: tutte le server actions in `actions.ts` iniziano con `await requireAdmin()` — obbligatorio anche per le nuove actions transcript.
|
||||
- **`revalidatePath`** dopo ogni mutation: `revalidatePath(\`/admin/leads/${leadId}\`)`.
|
||||
- **Promise.all fetch**: `page.tsx` del dettaglio lead usa `await Promise.all([...])` per fetch parallele — aggiungere `getTranscripts(id)` nello stesso array.
|
||||
|
||||
### Integration Points
|
||||
- `src/app/admin/leads/[id]/page.tsx` — aggiungere `getTranscripts(id)` nel `Promise.all`, passare `transcripts` a `<LeadDetail />`
|
||||
- `src/components/admin/leads/LeadDetail.tsx` — aggiungere prop `transcripts` e sezione "Transcript" dopo `<ActivitySection>`
|
||||
- `src/db/schema.ts` — aggiungere definizione `client_transcripts` + relazioni Drizzle
|
||||
- `src/lib/lead-service.ts` — aggiungere `getTranscripts(leadId: string)`
|
||||
- `src/app/admin/leads/actions.ts` — aggiungere `addTranscript(leadId, data)` e `deleteTranscript(transcriptId)`
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Il testo del transcript può essere molto lungo (trascrizioni complete di call). Il form deve avere una `<textarea>` con altezza generosa (es. min-h-48 o più) senza limite di caratteri.
|
||||
- Il `call_date` è una `date` (non timestamp) — l'admin sceglie il giorno della call, non l'ora. Nel DB: `DATE` type PostgreSQL.
|
||||
- La lista transcript deve mostrare: `call_date` formattata (es. "12 giugno 2026"), `title` se presente, anteprima delle prime righe del `content`, e un'azione "Elimina". Espansione/collapse del testo completo.
|
||||
- Phase 21 leggerà i transcript via `getTranscripts(leadId)` — la query deve restituire tutti i campi incluso `content` completo.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **UI transcript per clienti** (`/admin/clients/[id]`): la FK `client_id` è nella tabella, ma la UI lato cliente è deferred a una fase futura (post v2.2 o su richiesta).
|
||||
- **Ricerca full-text sui transcript**: fuori scope v2.2 — i transcript vengono letti dall'AI in blocco, non cercati dall'admin.
|
||||
- **Trascrizione automatica da audio**: fuori scope — l'admin incolla manualmente il testo.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 20-Knowledge Base Cliente*
|
||||
*Context gathered: 2026-06-19*
|
||||
@@ -0,0 +1,50 @@
|
||||
# Phase 20: Knowledge Base Cliente — Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-06-19
|
||||
**Phase:** 20-knowledge-base-cliente
|
||||
**Areas discussed:** Lead-scoped o anche client?
|
||||
|
||||
---
|
||||
|
||||
## Lead-scoped o anche client?
|
||||
|
||||
### Q1 — FK scope della tabella
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Solo lead per ora | `client_transcripts` ha solo `lead_id`. Semplice, sufficiente per Phase 21. La FK `client_id` si aggiunge in futuro se serve. | |
|
||||
| Prepara entrambe le FK ora | `lead_id` e `client_id` entrambi nullable nella tabella. Più lavoro ora, ma non serve una migration futura se vuoi trascrizioni anche per clienti esistenti. | ✓ |
|
||||
|
||||
**User's choice:** Prepara entrambe le FK ora
|
||||
**Notes:** L'utente preferisce prepararsi alla flessibilità futura con una sola migration ora.
|
||||
|
||||
---
|
||||
|
||||
### Q2 — UI Phase 20 per client_id
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Solo dal dettaglio lead per ora | La FK `client_id` c'è nello schema ma non è esposta in UI in Phase 20. Si usa quando un lead è convertito — per dopo. | ✓ |
|
||||
| Anche dalla pagina admin cliente | Phase 20 aggiunge il blocco transcript sia nel LeadDetail che in `/admin/clients/[id]`. Più lavoro, ma copre subito clienti esistenti senza lead. | |
|
||||
|
||||
**User's choice:** Solo dal dettaglio lead per ora
|
||||
**Notes:** L'UI Phase 20 è solo lato lead. `client_id` è nello schema ma non esposta.
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
Le seguenti aree non sono state discusse e sono state lasciate al giudizio di Claude:
|
||||
|
||||
- **Metadati del transcript** — Scelto: `title` (text, optional) + `content` (text, illimitato) + `call_date` (date). No enum tipo — il titolo libero è sufficiente.
|
||||
- **Collocazione UI nel LeadDetail** — Scelto: nuova sezione "Transcript" collassabile dopo le Attività, seguendo il pattern visivo esistente (Card + lista). Modal per aggiungere (come `LogActivityModal`).
|
||||
- **Ordinamento** — `call_date DESC` (chiamata più recente in cima).
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- UI transcript nella pagina admin cliente (`/admin/clients/[id]`) — la FK è pronta, la UI è deferred post-v2.2.
|
||||
- Ricerca full-text sui transcript — fuori scope, i transcript vengono letti dall'AI in blocco.
|
||||
- Trascrizione automatica da audio — fuori scope v2.2.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
phase: 21-agente-ai-generazione-preventivo
|
||||
plan: "01"
|
||||
status: complete
|
||||
completed_at: "2026-06-20"
|
||||
requirements_satisfied:
|
||||
- AI-01
|
||||
- AI-02
|
||||
---
|
||||
|
||||
# Plan 21-01 Summary: Agente AI — Generazione Preventivo
|
||||
|
||||
## What Was Built
|
||||
|
||||
Sistema completo di generazione preventivo AI: l'admin seleziona un lead/cliente e un'offerta, il sistema legge i transcript della call e chiama Claude Opus 4.8 per generare una bozza di preventivo strutturata in JSON. La bozza viene salvata nel DB come `draft`, l'admin la rivede e la pubblica.
|
||||
|
||||
## Key Files
|
||||
|
||||
### Created
|
||||
- `src/lib/proposal/schema.ts` — Zod schema `ProposalContent` (vision, problems 3-5, solutions, scope, deliverables, timeline, stagesRecap, comparisonMatrix)
|
||||
- `src/lib/proposal/agent.ts` — `generateProposalContent()`: chiama Claude Opus 4.8, valida output Zod, gestisce JSON wrapping in backtick
|
||||
- `src/lib/proposal/assemble.ts` — `assembleProposal()`: fonde content AI + offerta DB (snapshot prezzi) + profilo consulente in `AssembledProposal`
|
||||
- `src/lib/proposal/profile.ts` — `CONSULTANT_PROFILE`: fonte di verità statica (bio, facts, testimonianze, legal, nextSteps) — editare direttamente per aggiornare
|
||||
- `src/lib/proposal/queries.ts` — `listProposals`, `getProposalById`, `getProposalBySlug` con join leads/clients/offer_macros
|
||||
- `src/app/admin/preventivi/actions.ts` — `generateProposalDraft`, `publishProposal`, `updateProposalTitle`, `deleteProposal` (server actions)
|
||||
- `src/app/admin/preventivi/page.tsx` — lista preventivi admin con stato (bozza/pubblicato/accettato/rifiutato)
|
||||
- `src/app/admin/preventivi/genera/page.tsx` + `GeneraProposalForm.tsx` — form builder con pre-fill `?lead_id=X` da LeadDetail
|
||||
- `src/app/admin/preventivi/[id]/page.tsx` — review bozza: titolo editabile, pubblica, elimina
|
||||
- `src/db/migrations/0010_proposals.sql` — `CREATE TABLE IF NOT EXISTS proposals` (id, slug, lead_id, client_id, offer_macro_id, title, content jsonb, model, state, selected_tier, accepted_at immutabile, client_email, client_notes)
|
||||
|
||||
### Modified
|
||||
- `src/db/schema.ts` — tabella `proposals` + `proposalsRelations` + tipi `Proposal`/`NewProposal`
|
||||
- `src/components/admin/AdminSidebar.tsx` — voce "Preventivi" + CTA globale lime "Genera preventivo"
|
||||
- `src/components/admin/leads/LeadDetail.tsx` — pulsante "Genera preventivo" → `/admin/preventivi/genera?lead_id=X`
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
| Decisione | Scelta | Motivo |
|
||||
|-----------|--------|--------|
|
||||
| Output AI | JSON strutturato → Zod validate | Coerenza visiva garantita, zero rischio HTML rotto |
|
||||
| Storage | `content jsonb` snapshot completo | Parser automatico postgres-js, type-safe, immutabile |
|
||||
| Profile | File `profile.ts` hardcoded | No UI necessaria v1, editing diretto semplice |
|
||||
| Modello | `claude-opus-4-8` | Qualità copywriting strategico superiore |
|
||||
|
||||
## Requirements Verified
|
||||
|
||||
- AI-01: ✅ Admin seleziona cliente+offerta → AI legge transcript + offerta → genera bozza personalizzata
|
||||
- AI-02: ✅ Admin rivede ed edita il titolo della bozza, la pubblica o elimina prima dell'invio
|
||||
|
||||
## Post-Deploy Steps Completed
|
||||
|
||||
- Migration 0010 applicata a prod via SSH tunnel (2026-06-20 14:29)
|
||||
- `ANTHROPIC_API_KEY` configurata in Coolify (2026-06-20 — fix chiave invalida)
|
||||
@@ -0,0 +1,122 @@
|
||||
# Phase 21: Agente AI — generazione preventivo - Context
|
||||
|
||||
**Gathered:** 2026-06-20
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
L'admin apre il dettaglio di un lead, seleziona un'offerta e lancia la generazione: Claude legge i transcript del lead e i dati dell'offerta (tutti e 3 i tier) e produce una bozza di preventivo strutturata in sezioni fisse. L'admin rivede il testo in una textarea e salva la bozza prima che diventi pubblicabile in Phase 22.
|
||||
|
||||
**In scope:** prompt engineering + chiamata API Claude + bozza strutturata in sezioni + form di revisione/editing + tabella `proposals` nel DB
|
||||
**Out of scope:** pagina pubblica `/preventivo/[slug]` (Phase 22), invio email (Phase 22), accettazione/rifiuto cliente (Phase 22)
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Struttura output AI
|
||||
|
||||
- **D-01:** Claude produce sezioni strutturate fisse, non testo libero. Il prompt specifica esattamente i blocchi da riempire.
|
||||
- **D-02:** Le sezioni del preventivo generato sono, in ordine:
|
||||
1. **Situazione Attuale** — Claude descrive la situazione del cliente basandosi sul contenuto dei transcript (cosa sta vivendo, i pain emersi nelle call)
|
||||
2. **La Proposta** — Presentazione personalizzata dell'offerta: perché questa offerta è giusta per questo cliente specifico
|
||||
3. **Opzione A / Opzione B / Opzione C** — Una sotto-sezione per ogni tier, con lista dei servizi inclusi e prezzo pubblico del tier
|
||||
4. **Prossimi Passi** — Call to action finale (Claude scrive testo standard, admin può editare)
|
||||
- **D-03:** L'admin **non sceglie un tier** prima della generazione. Claude genera tutti e 3 i tier in un unico documento. Il cliente leggerà le 3 opzioni e sceglierà.
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
Le seguenti aree non sono state discusse — Claude ha flessibilità:
|
||||
|
||||
- **Builder location:** Entry point nel LeadDetail esistente (`src/components/admin/leads/LeadDetail.tsx`) — bottone "Genera Preventivo" che apre un modal o sezione inline con selezione offerta + trigger generazione. È il posto più naturale perché i transcript del lead sono già in contesto.
|
||||
- **UX generazione:** Fire-and-wait con spinner. Nessun streaming. L'admin clicca "Genera", vede uno stato di caricamento, poi il testo appare. Più semplice e affidabile per il caso d'uso (generazione ~5-15 sec).
|
||||
- **Editor bozza:** Textarea non-formattata. Il preventivo generato appare in una `<textarea>` grande che l'admin può editare liberamente prima di salvare. Nessun rich text editor in questa fase.
|
||||
- **SDK:** `@anthropic-ai/sdk` (pacchetto ufficiale Anthropic). Nessun Vercel AI SDK o LangChain.
|
||||
- **Modello:** `claude-sonnet-4-6` (modello attivo del progetto).
|
||||
- **Storage:** Nuova tabella `proposals` nel DB seguendo i pattern esistenti: `id` (nanoid PK), `lead_id` (FK → leads, nullable cascade), `offer_id` (FK → offer_macros), `content` (text NOT NULL — il testo completo della bozza), `slug` (text unique — nanoid, per il link pubblico Phase 22), `status` (text: `draft` | `published`), `created_at` (timestamp with timezone).
|
||||
- **Migration:** SQL a mano come `0010_proposals.sql` (drizzle-kit generate è rotto). Applicare a prod via SSH prima di pushare il codice.
|
||||
- **Server action / API route:** Server Action Next.js per il trigger di generazione (pattern coerente col resto del progetto). La chiamata Anthropic avviene lato server.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Requirements & Roadmap
|
||||
- `.planning/ROADMAP.md` — Phase 21 goal, success criteria (AI-01/AI-02), dipendenze (Phase 20 + DB offerte Phase 11/12)
|
||||
- `.planning/REQUIREMENTS.md` — AI-01 (generazione), AI-02 (revisione bozza)
|
||||
|
||||
### Contesto Phase 20 (transcript — input dell'AI)
|
||||
- `.planning/phases/20-knowledge-base-cliente/20-CONTEXT.md` — Decisioni schema `client_transcripts`, pattern query, struttura dati che l'AI deve leggere
|
||||
- `src/lib/lead-service.ts` — `getTranscripts(leadId)` → restituisce array con `{id, title, content, call_date}` ordinati per data DESC; il campo `content` è testo integrale, non troncato
|
||||
|
||||
### Dati offerta (input dell'AI)
|
||||
- `src/lib/offer-queries.ts` — `getOfferEditorData(macroId)` → restituisce i dati completi dell'offerta inclusi tier A/B/C con servizi e `public_price`; questa è la funzione da usare per costruire il contesto offerta nel prompt
|
||||
- `src/app/admin/offers/actions.ts` — pattern server actions per offerte (riferimento per nuovo layer proposals)
|
||||
|
||||
### Schema & Migrations
|
||||
- `src/db/schema.ts` — pattern tabelle esistenti: `leads`, `activities`, `clientTranscripts`, `offer_macros` — usare per definire `proposals`
|
||||
- `src/db/migrations/0009_client_transcripts.sql` — ultimo esempio SQL migration a mano (struttura e convenzioni da replicare per `0010_proposals.sql`)
|
||||
|
||||
### UI & Integration Points
|
||||
- `src/components/admin/leads/LeadDetail.tsx` — struttura UI dove va aggiunto il trigger "Genera Preventivo" e la sezione bozza
|
||||
- `src/app/admin/leads/[id]/page.tsx` — page con `Promise.all` per fetch parallele (pattern da seguire, aggiungere fetch proposals)
|
||||
- `src/app/admin/leads/actions.ts` — pattern `requireAdmin` guard + `revalidatePath` (da seguire per le nuove actions proposals)
|
||||
|
||||
### Sicurezza & Architettura
|
||||
- `CLAUDE.md` → sezione Architecture Constraints: `quote_items` MAI esposti via client API; la nuova tabella `proposals` segue lo stesso principio — `content` non esposto via client token senza consenso esplicito Phase 22
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `getTranscripts(leadId)` in `src/lib/lead-service.ts` — pronta, restituisce `content` integrale; concatenare i transcript in ordine cronologico per il prompt AI
|
||||
- `getOfferEditorData(macroId)` in `src/lib/offer-queries.ts` — restituisce tier A/B/C con array di servizi e `public_price` per tier; mappare questi dati nel prompt con nome servizi + prezzi
|
||||
- `nanoid` — già in uso nel progetto per PK; riusare per `proposals.id` e `proposals.slug`
|
||||
- `requireAdmin()` — già in ogni server action, obbligatorio anche per le nuove actions proposals
|
||||
|
||||
### Established Patterns
|
||||
- **Migration a mano**: `CREATE TABLE IF NOT EXISTS`, tipi Postgres espliciti, FK con `ON DELETE CASCADE` (o `SET NULL` se nullable). NON usare `drizzle-kit generate`.
|
||||
- **Prod-first migration**: la migration `0010_proposals.sql` DEVE essere applicata a prod via SSH+docker exec (`ssh -L 54321:localhost:54321 root@178.104.27.55`) PRIMA di pushare il codice che la referenzia.
|
||||
- **Server Actions con revalidatePath**: ogni mutation chiama `revalidatePath()` sul path del lead.
|
||||
- **Promise.all fetch**: `page.tsx` del lead usa `await Promise.all([...])` — aggiungere `getProposals(id)` nello stesso array.
|
||||
|
||||
### Integration Points
|
||||
- `src/app/admin/leads/[id]/page.tsx` — aggiungere `getProposals(id)` nel `Promise.all`, passare `proposals` a `<LeadDetail />`
|
||||
- `src/components/admin/leads/LeadDetail.tsx` — aggiungere sezione "Preventivo" con bottone trigger + form selezione offerta + textarea bozza
|
||||
- `src/db/schema.ts` — aggiungere definizione `proposals` + relazioni Drizzle
|
||||
- `src/lib/lead-service.ts` (o nuovo `src/lib/proposal-service.ts`) — `generateProposal(leadId, offerId)`, `getProposals(leadId)`, `saveProposal(id, content)`
|
||||
- `.env.local` — aggiungere `ANTHROPIC_API_KEY` (richiede configurazione in Coolify per produzione)
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Il preventivo è multi-tier by design: non c'è una scelta del tier nella UI di generazione. Claude scrive 3 opzioni (A/B/C) in un solo documento, il cliente legge e sceglie.
|
||||
- La sezione "Opzione A/B/C" deve mostrare i nomi dei servizi inclusi in modo leggibile (non JSON grezzo), e il `public_price` del tier in modo prominente.
|
||||
- La sezione "Situazione Attuale" è la parte più personalizzata — Claude deve pescare dai transcript specifici insights sul cliente, non frasi generiche. Il prompt deve guidare su questo.
|
||||
- L'admin in fase di review vede l'intero testo del preventivo in una `<textarea>` di altezza generosa (tipo `min-h-96`) e può editare liberamente prima di salvare.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **Scelta tier nel preventivo:** eventualmente l'admin potrebbe scegliere un tier da presentare → Phase 22 o fase futura post v2.2.
|
||||
- **Streaming output AI:** Claude scrive in real-time → futura ottimizzazione UX se la latenza diventa un problema.
|
||||
- **Versioning bozze:** mantenere storico delle generazioni per un lead → futura fase.
|
||||
- **Agente multi-step con tool_use:** architettura più sofisticata dove Claude chiama tool API invece di ricevere dati pre-imbarcati nel prompt → futura fase.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 21-Agente AI — generazione preventivo*
|
||||
*Context gathered: 2026-06-20*
|
||||
@@ -0,0 +1,40 @@
|
||||
# Phase 21: Discussion Log
|
||||
|
||||
**Session:** 2026-06-20
|
||||
**Areas discussed:** 1 of 4 identified (Struttura preventivo)
|
||||
|
||||
---
|
||||
|
||||
## Area: Struttura preventivo
|
||||
|
||||
### Q1 — Tipo di output AI
|
||||
- **Opzioni:** Testo libero personalizzato / Sezioni strutturate fisse / Ibrido intro+sezioni
|
||||
- **Scelta:** Sezioni strutturate fisse
|
||||
- **Note:** L'admin e il cliente hanno bisogno di un documento prevedibile — le sezioni fisse rendono più facile sia il prompt engineering che l'editing successivo.
|
||||
|
||||
### Q2 — Quali sezioni
|
||||
- **Opzioni (multi-select):** Situazione Attuale / La Proposta / Cosa è Incluso / Investimento + Prossimi Passi
|
||||
- **Scelta:** Tutte e 4 le sezioni
|
||||
- **Note:** L'utente ha aggiunto "penso che creo un agente apposta per questa parte" — chiarito che si trattava di un'altra sessione Claude Code, nessuna interferenza con questo progetto.
|
||||
|
||||
### Q3 — Tier: singolo o tutti e 3
|
||||
- **Opzioni:** Un solo tier scelto dall'admin / Tutti e 3 i tier in un documento
|
||||
- **Scelta:** Tutti e 3 i tier in un documento
|
||||
- **Note:** Il preventivo presenta opzioni A/B/C al cliente, che sceglie leggendo.
|
||||
|
||||
---
|
||||
|
||||
## Aree non discusse (Claude's discretion)
|
||||
|
||||
- **Builder location:** Entry point nel LeadDetail esistente
|
||||
- **UX generazione:** Fire-and-wait con spinner (no streaming)
|
||||
- **Editor bozza:** Textarea plain text
|
||||
|
||||
---
|
||||
|
||||
## Idee deferred
|
||||
|
||||
- Scelta tier singolo nel preventivo (post v2.2)
|
||||
- Streaming output AI (ottimizzazione futura)
|
||||
- Versioning bozze
|
||||
- Agente multi-step con tool_use
|
||||
@@ -0,0 +1,72 @@
|
||||
# Phase 21+22 — Agente AI Preventivo + Pagina Pubblica Deck
|
||||
|
||||
**Executed:** 2026-06-20
|
||||
**Status:** Code complete — pending migration 0010 to prod
|
||||
**Model used:** `claude-opus-4-8`
|
||||
**Note:** Le fasi 21 (AI generation) e 22 (public page) sono state eseguite in un'unica sessione per coerenza architetturale.
|
||||
|
||||
---
|
||||
|
||||
## Scope eseguito
|
||||
|
||||
### Fase 21 — Agente AI (AI-01 / AI-02)
|
||||
- `@anthropic-ai/sdk@0.105.0` installato
|
||||
- `ANTHROPIC_API_KEY` aggiunta a `.env.local`
|
||||
- `src/lib/proposal/schema.ts` — Zod schema `ProposalContent` (sezioni AI)
|
||||
- `src/lib/proposal/agent.ts` — `generateProposalContent()` → Claude Opus 4.8, JSON strutturato validato Zod
|
||||
- `src/lib/proposal/assemble.ts` — `assembleProposal()` fonde AI + offerta DB + config consulente
|
||||
- `src/lib/proposal/profile.ts` — config statica consulente (bio, fatti, testimonianze, legal) — **editare con dati reali**
|
||||
- `src/lib/proposal/queries.ts` — `listProposals`, `getProposalById`, `getProposalBySlug`
|
||||
- `src/app/admin/preventivi/actions.ts` — `generateProposalDraft`, `publishProposal`, `updateProposalTitle`, `deleteProposal`
|
||||
- `src/app/admin/preventivi/page.tsx` — lista preventivi admin
|
||||
- `src/app/admin/preventivi/genera/page.tsx` + `GeneraProposalForm.tsx` — builder con pre-fill `?lead_id=X`
|
||||
- `src/app/admin/preventivi/[id]/page.tsx` — review bozza + pubblica + elimina
|
||||
- `src/components/admin/AdminSidebar.tsx` — voce "Preventivi" + CTA globale lime "Genera preventivo"
|
||||
- `src/components/admin/leads/LeadDetail.tsx` — pulsante "Genera preventivo" → `/admin/preventivi/genera?lead_id=X`
|
||||
|
||||
### Fase 22 — Pagina pubblica deck (PUB-01 / PUB-02)
|
||||
- `src/app/preventivo/[slug]/page.tsx` — server component pubblico, gestisce stati draft/published/accepted/rejected
|
||||
- `src/app/preventivo/[slug]/actions.ts` — `acceptProposal` + `rejectProposal` con guard immutabilità `accepted_at`
|
||||
- `src/components/public/proposal/ProposalDeck.tsx` — deck navigabile (frecce ←/→ + dot cliccabili + keyboard), light mode iamcavalli
|
||||
- Sezioni (20+): Cover, Vision, Index, ChapterDivider, Strategist, Facts, Testimonials, ProblemNode, SynthesisDiagram, SolutionNode, SolutionSynthesis, Scope, Deliverables, Timeline, Pricing, StagesRecap, ComparisonMatrix, NextSteps, Accept, Closing
|
||||
|
||||
### Schema DB
|
||||
- `src/db/migrations/0010_proposals.sql` — `CREATE TABLE IF NOT EXISTS proposals (...)` — **applicare a prod via SSH prima del push**
|
||||
- `src/db/schema.ts` — tabella `proposals` + relations + `Proposal`/`NewProposal` types
|
||||
|
||||
---
|
||||
|
||||
## Flusso end-to-end
|
||||
|
||||
```
|
||||
Lead (con transcript) → LeadDetail "Genera preventivo"
|
||||
→ /admin/preventivi/genera?lead_id=X
|
||||
→ seleziona offerta → "Genera con AI"
|
||||
→ server action: legge transcript + offerta → chiama Claude Opus 4.8 → Zod validate
|
||||
→ assembla AssembledProposal (AI + offerta + config) → salva in proposals (draft)
|
||||
→ redirect /admin/preventivi/[id] → review bozza → "Pubblica"
|
||||
→ pagina pubblica /preventivo/[slug] — deck 20+ slide
|
||||
→ cliente sceglie tier A/B/C → accetta → accepted_at IMMUTABILE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pending post-push
|
||||
|
||||
1. **Migration 0010 a prod** via `ssh -L 54321:localhost:54321 root@178.104.27.55` + script node
|
||||
2. **Dati reali in `src/lib/proposal/profile.ts`** — bio, credenziali, testimonianze, contatti, foto URL
|
||||
3. **`ANTHROPIC_API_KEY` in Coolify** (già in `.env.local` per dev)
|
||||
4. **Wave 5 (opzionale)** — badge CRM su accept/reject + email Resend
|
||||
|
||||
---
|
||||
|
||||
## Decisioni architetturali
|
||||
|
||||
| Decisione | Scelta | Motivo |
|
||||
|-----------|--------|--------|
|
||||
| Output AI | JSON strutturato → template fisso | Coerenza visiva garantita, zero rischio HTML rotto |
|
||||
| Bio/testimonianze | File config `profile.ts` | Editing semplice, no UI v1 |
|
||||
| Entry point | LeadDetail + sidebar globale | GSD prescrive LeadDetail; sidebar è additive |
|
||||
| `content` | `jsonb` (non `text`) | Parser automatico postgres-js, type-safe |
|
||||
| Modello | `claude-opus-4-8` | Migliore qualità copywriting strategico |
|
||||
| Phase split | 21+22 eseguite insieme | Dipendenza diretta, nessun vantaggio a splitparle |
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
phase: 22-pagina-pubblica-preventivo
|
||||
plan: "01"
|
||||
status: complete
|
||||
completed_at: "2026-06-20"
|
||||
requirements_satisfied:
|
||||
- PUB-01
|
||||
- PUB-02
|
||||
known_gaps:
|
||||
- PUB-03 (email Resend — non implementata, deferred to backlog)
|
||||
---
|
||||
|
||||
# Plan 22-01 Summary: Pagina Pubblica Preventivo + Deck
|
||||
|
||||
## What Was Built
|
||||
|
||||
Pagina pubblica `/preventivo/[slug]` che presenta la proposta generata come deck interattivo di 20+ slide navigabili (frecce, tasti keyboard, dot navigator). Il cliente sceglie il tier A/B/C e accetta/rifiuta dalla pagina; l'esito si riflette nel DB con `accepted_at` immutabile. PUB-03 (invio email Resend) non implementato — il link va condiviso manualmente.
|
||||
|
||||
## Key Files
|
||||
|
||||
### Created
|
||||
- `src/app/preventivo/[slug]/page.tsx` — server component pubblico; gestisce stati: draft (blocca accesso), published (mostra + accept), accepted/rejected (mostra con stato)
|
||||
- `src/app/preventivo/[slug]/actions.ts` — `acceptProposal` (guard `accepted_at` immutabile + `selected_tier` validato A/B/C), `rejectProposal`
|
||||
- `src/components/public/proposal/ProposalDeck.tsx` — deck client component: navigazione keyboard (←/→), dot cliccabili, topbar fissa (titolo + counter), bottombar fissa (frecce + dots); `h-screen overflow-hidden` per 100vh per slide
|
||||
- `src/components/public/proposal/sections/` — 20 componenti slide:
|
||||
- CoverSection, VisionSection, IndexSection
|
||||
- ChapterDivider (×5), StrategistSection, FactsSection, TestimonialsSection
|
||||
- ProblemNodeSection, SynthesisDiagramSection
|
||||
- SolutionNodeSection, SolutionSynthesisSection
|
||||
- ScopeSection, DeliverablesSection, TimelineSection
|
||||
- PricingSection, StagesRecapSection, ComparisonMatrixSection
|
||||
- NextStepsSection, AcceptSection, ClosingSection
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
| Decisione | Scelta | Motivo |
|
||||
|-----------|--------|--------|
|
||||
| Layout | `h-screen overflow-hidden` per slide | Nessun scroll di pagina, navigazione slide-by-slide |
|
||||
| Navigazione | Keyboard + dots + click frecce | Universale, funziona su desktop e touch |
|
||||
| Accettazione | `accepted_at` immutabile (guard server action) | Pattern coerente con `deliverables.approved_at` |
|
||||
| Tier selezione | Radio A/B/C prima dell'accettazione | Cliente sceglie l'opzione, l'esito è univoco |
|
||||
| Email | Non implementata (PUB-03) | Scope minimo, link condiviso manualmente per ora |
|
||||
|
||||
## Requirements Verified
|
||||
|
||||
- PUB-01: ✅ Proposta visibile come pagina pubblica HTML a `/preventivo/[slug]`, deck 20+ slide
|
||||
- PUB-02: ✅ Cliente accetta/rifiuta dalla pagina; `state` + `accepted_at` + `selected_tier` aggiornati nel DB; admin vede esito nella lista preventivi
|
||||
|
||||
## Known Gap
|
||||
|
||||
- **PUB-03 — Email Resend**: il link preventivo non viene inviato automaticamente. Va copiato dall'admin e condiviso manualmente (es. via WhatsApp/email). Da implementare nella prossima milestone come prima feature.
|
||||
|
||||
## Fix Post-Deploy
|
||||
|
||||
- 2026-06-20: fix `h-screen overflow-hidden` su tutte le slide (erano `min-h-screen`, causavano scroll di pagina)
|
||||
- 2026-06-20: `ANTHROPIC_API_KEY` mancante in Coolify — aggiunta via PHP artisan + redeploy
|
||||
Generated
+63
@@ -8,6 +8,7 @@
|
||||
"name": "clienthub",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.105.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -59,6 +60,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.105.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.105.0.tgz",
|
||||
"integrity": "sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -3802,6 +3824,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
@@ -6365,6 +6393,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
@@ -7342,6 +7376,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -9038,6 +9085,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -9329,6 +9386,12 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.105.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getActivityLog, getUpcomingReminders } from "@/lib/lead-service";
|
||||
import { getActivityLog, getUpcomingReminders, getTranscripts } from "@/lib/lead-service";
|
||||
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
|
||||
import { LeadDetail } from "@/components/admin/leads/LeadDetail";
|
||||
|
||||
@@ -17,8 +17,11 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
notFound();
|
||||
}
|
||||
|
||||
const activities = await getActivityLog(id);
|
||||
const reminders = await getUpcomingReminders(id);
|
||||
const [activities, reminders, transcripts] = await Promise.all([
|
||||
getActivityLog(id),
|
||||
getUpcomingReminders(id),
|
||||
getTranscripts(id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<LeadDetail
|
||||
@@ -27,6 +30,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
reminders={reminders}
|
||||
tags={lead.tags}
|
||||
tagOptions={options.tags}
|
||||
transcripts={transcripts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { leads, quotes, tags } from "@/db/schema";
|
||||
import { leads, quotes, tags, clientTranscripts } from "@/db/schema";
|
||||
import { nanoid } from "nanoid";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
createLeadSchema,
|
||||
@@ -252,3 +253,58 @@ export async function renameLeadTag(oldValue: string, newValue: string) {
|
||||
|
||||
revalidatePath("/admin/leads");
|
||||
}
|
||||
|
||||
// ── Transcript actions (Phase 20 — KB-02) ────────────────────────────────────
|
||||
|
||||
const addTranscriptSchema = z.object({
|
||||
lead_id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
content: z.string().min(1, "Il testo del transcript è obbligatorio"),
|
||||
call_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data non valida (YYYY-MM-DD)"),
|
||||
});
|
||||
|
||||
export async function addTranscript(data: z.infer<typeof addTranscriptSchema>) {
|
||||
await requireAdmin();
|
||||
|
||||
const parsed = addTranscriptSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
const [transcript] = await db
|
||||
.insert(clientTranscripts)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
lead_id: parsed.data.lead_id,
|
||||
title: parsed.data.title || null,
|
||||
content: parsed.data.content,
|
||||
call_date: parsed.data.call_date,
|
||||
})
|
||||
.returning();
|
||||
|
||||
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
|
||||
return { success: true, transcript };
|
||||
} catch (error) {
|
||||
console.error("addTranscript error:", error);
|
||||
return { success: false, error: "Errore nel salvataggio del transcript" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTranscript(transcriptId: string, leadId: string) {
|
||||
await requireAdmin();
|
||||
|
||||
if (!transcriptId) throw new Error("ID transcript richiesto");
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(clientTranscripts)
|
||||
.where(eq(clientTranscripts.id, transcriptId));
|
||||
|
||||
revalidatePath(`/admin/leads/${leadId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("deleteTranscript error:", error);
|
||||
return { success: false, error: "Errore nell'eliminazione del transcript" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getProposalById } from "@/lib/proposal/queries";
|
||||
import { publishProposal, deleteProposal } from "../actions";
|
||||
import { ExternalLink, ArrowLeft, Trash2 } from "lucide-react";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ProposalDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const proposal = await getProposalById(id);
|
||||
if (!proposal) notFound();
|
||||
|
||||
const { content } = proposal;
|
||||
const isDraft = proposal.state === "draft";
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<Link
|
||||
href="/admin/preventivi"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} /> Preventivi
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{proposal.title ?? "Senza titolo"}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<StateBadge state={proposal.state} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Generato il {proposal.createdAt.toLocaleDateString("it-IT")} · {proposal.model ?? "AI"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Link pubblico */}
|
||||
{(proposal.state === "published" || proposal.state === "accepted") && (
|
||||
<a
|
||||
href={`/preventivo/${proposal.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-2 border border-border rounded-md text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Apri deck
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Pubblica */}
|
||||
{isDraft && (
|
||||
<form action={publishProposal.bind(null, id)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Pubblica →
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Elimina (solo draft) */}
|
||||
{isDraft && (
|
||||
<form action={deleteProposal.bind(null, id)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
|
||||
title="Elimina bozza"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link pubblico (draft) */}
|
||||
{isDraft && (
|
||||
<div className="text-xs text-muted-foreground bg-yellow-50 border border-yellow-200 rounded-md px-3 py-2">
|
||||
Bozza — non ancora visibile al cliente. Pubblica per generare il link pubblico.
|
||||
</div>
|
||||
)}
|
||||
{!isDraft && (
|
||||
<div className="text-xs bg-muted border border-border rounded-md px-3 py-2 font-mono">
|
||||
{typeof window === "undefined" ? "" : window.location.origin}/preventivo/{proposal.slug}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview contenuto */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-base font-semibold text-foreground">Anteprima contenuto</h2>
|
||||
|
||||
<Section title="Vision">
|
||||
<p className="font-medium">{content.ai.vision.headline}</p>
|
||||
<p className="text-muted-foreground text-sm mt-1">{content.ai.vision.body}</p>
|
||||
</Section>
|
||||
|
||||
<Section title={`Problemi (${content.ai.problems.length})`}>
|
||||
<div className="space-y-2">
|
||||
{content.ai.problems.map((p) => (
|
||||
<div key={p.id} className="border border-border rounded-md px-3 py-2">
|
||||
<p className="font-medium text-sm">{p.id}. {p.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{p.subtitle}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Scope">
|
||||
<p className="font-medium text-sm">{content.ai.scope.scopeTitle}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{content.ai.scope.scopeBody}</p>
|
||||
</Section>
|
||||
|
||||
<Section title={`Tier offerta (${content.offer.tiers.length})`}>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{content.offer.tiers.map((t) => (
|
||||
<div key={t.id} className="border border-border rounded-md p-3 text-sm">
|
||||
<p className="font-semibold text-primary">{t.tierLetter}</p>
|
||||
<p className="font-medium mt-0.5">{t.publicName}</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t.publicPrice ? `€${t.publicPrice}` : `€${t.servicesTotal} (calcolato)`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: string }) {
|
||||
const map: Record<string, { label: string; className: string }> = {
|
||||
draft: { label: "Bozza", className: "bg-yellow-100 text-yellow-800" },
|
||||
published: { label: "Pubblicato", className: "bg-blue-100 text-blue-800" },
|
||||
accepted: { label: "Accettato", className: "bg-green-100 text-green-800" },
|
||||
rejected: { label: "Rifiutato", className: "bg-red-100 text-red-800" },
|
||||
};
|
||||
const info = map[state] ?? { label: state, className: "bg-gray-100 text-gray-700" };
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${info.className}`}>
|
||||
{info.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { proposals, leads, clients } from "@/db/schema";
|
||||
import { nanoid } from "nanoid";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { generateProposalContent } from "@/lib/proposal/agent";
|
||||
import { assembleProposal } from "@/lib/proposal/assemble";
|
||||
import { getOfferEditorData } from "@/lib/offer-queries";
|
||||
import { getTranscripts } from "@/lib/lead-service";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
// Genera una bozza di preventivo con AI e salva nel DB
|
||||
export async function generateProposalDraft(formData: FormData) {
|
||||
await requireAdmin();
|
||||
|
||||
const leadId = formData.get("lead_id") as string | null;
|
||||
const clientId = formData.get("client_id") as string | null;
|
||||
const offerMacroId = formData.get("offer_macro_id") as string;
|
||||
|
||||
if (!offerMacroId) throw new Error("offer_macro_id richiesto");
|
||||
if (!leadId && !clientId) throw new Error("lead_id o client_id richiesto");
|
||||
|
||||
// Carica dati necessari
|
||||
const offer = await getOfferEditorData(offerMacroId);
|
||||
if (!offer) throw new Error("Offerta non trovata");
|
||||
|
||||
let lead = undefined;
|
||||
let client = undefined;
|
||||
let transcripts: Awaited<ReturnType<typeof getTranscripts>> = [];
|
||||
|
||||
if (leadId) {
|
||||
const [leadRow] = await db
|
||||
.select({ id: leads.id, name: leads.name, email: leads.email, company: leads.company, notes: leads.notes })
|
||||
.from(leads)
|
||||
.where(eq(leads.id, leadId))
|
||||
.limit(1);
|
||||
lead = leadRow;
|
||||
transcripts = await getTranscripts(leadId);
|
||||
} else if (clientId) {
|
||||
const [clientRow] = await db
|
||||
.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name, brief: clients.brief })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.limit(1);
|
||||
client = clientRow;
|
||||
// Trascrizioni del cliente (via client_id)
|
||||
const { clientTranscripts } = await import("@/db/schema");
|
||||
const { eq: drizzleEq, desc } = await import("drizzle-orm");
|
||||
transcripts = await db
|
||||
.select()
|
||||
.from(clientTranscripts)
|
||||
.where(drizzleEq(clientTranscripts.client_id, clientId))
|
||||
.orderBy(desc(clientTranscripts.call_date));
|
||||
}
|
||||
|
||||
const MODEL = "claude-opus-4-8";
|
||||
|
||||
// Chiama agente AI
|
||||
const { content: aiContent } = await generateProposalContent({
|
||||
lead,
|
||||
client,
|
||||
transcripts,
|
||||
offer,
|
||||
});
|
||||
|
||||
// Assembla contenuto completo
|
||||
const assembled = assembleProposal({
|
||||
lead,
|
||||
client,
|
||||
offer,
|
||||
aiContent,
|
||||
model: MODEL,
|
||||
});
|
||||
|
||||
// Salva nel DB
|
||||
const id = nanoid();
|
||||
const slug = nanoid();
|
||||
const title = `${assembled.header.clientName} × ${assembled.header.offerTitle}`;
|
||||
|
||||
await db.insert(proposals).values({
|
||||
id,
|
||||
slug,
|
||||
lead_id: leadId ?? null,
|
||||
client_id: clientId ?? null,
|
||||
offer_macro_id: offerMacroId,
|
||||
title,
|
||||
content: assembled as unknown as Record<string, unknown>,
|
||||
model: MODEL,
|
||||
state: "draft",
|
||||
});
|
||||
|
||||
revalidatePath("/admin/preventivi");
|
||||
redirect(`/admin/preventivi/${id}`);
|
||||
}
|
||||
|
||||
// Pubblica la proposta (draft → published)
|
||||
export async function publishProposal(id: string) {
|
||||
await requireAdmin();
|
||||
|
||||
await db
|
||||
.update(proposals)
|
||||
.set({ state: "published", updated_at: new Date() })
|
||||
.where(and(eq(proposals.id, id), eq(proposals.state, "draft")));
|
||||
|
||||
revalidatePath(`/admin/preventivi/${id}`);
|
||||
revalidatePath("/admin/preventivi");
|
||||
}
|
||||
|
||||
// Aggiorna il titolo della proposta
|
||||
export async function updateProposalTitle(id: string, title: string) {
|
||||
await requireAdmin();
|
||||
|
||||
await db
|
||||
.update(proposals)
|
||||
.set({ title: title.trim(), updated_at: new Date() })
|
||||
.where(eq(proposals.id, id));
|
||||
|
||||
revalidatePath(`/admin/preventivi/${id}`);
|
||||
}
|
||||
|
||||
// Elimina una proposta (solo draft)
|
||||
export async function deleteProposal(id: string) {
|
||||
await requireAdmin();
|
||||
|
||||
await db
|
||||
.delete(proposals)
|
||||
.where(and(eq(proposals.id, id), eq(proposals.state, "draft")));
|
||||
|
||||
revalidatePath("/admin/preventivi");
|
||||
redirect("/admin/preventivi");
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { OfferListCard } from "@/lib/offer-queries";
|
||||
|
||||
type Props = {
|
||||
leads: Array<{ id: string; name: string; company: string | null }>;
|
||||
clients: Array<{ id: string; name: string; brand_name: string }>;
|
||||
offers: OfferListCard[];
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
preselectedLeadId?: string;
|
||||
};
|
||||
|
||||
export function GeneraProposalForm({ leads, clients, offers, action, preselectedLeadId }: Props) {
|
||||
const [subjectType, setSubjectType] = useState<"lead" | "client">(
|
||||
preselectedLeadId ? "lead" : "lead"
|
||||
);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await action(formData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Errore sconosciuto");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 bg-white border border-border rounded-lg p-6">
|
||||
{/* Tipo soggetto */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Soggetto</label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubjectType("lead")}
|
||||
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
|
||||
subjectType === "lead"
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
Lead
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubjectType("client")}
|
||||
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
|
||||
subjectType === "client"
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
Cliente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Select lead o cliente */}
|
||||
{subjectType === "lead" ? (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="lead_id" className="text-sm font-medium text-foreground">
|
||||
Lead
|
||||
</label>
|
||||
<select
|
||||
id="lead_id"
|
||||
name="lead_id"
|
||||
required
|
||||
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">Seleziona un lead…</option>
|
||||
{leads.map((l) => (
|
||||
<option key={l.id} value={l.id} selected={l.id === preselectedLeadId}>
|
||||
{l.name}{l.company ? ` — ${l.company}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="client_id" className="text-sm font-medium text-foreground">
|
||||
Cliente
|
||||
</label>
|
||||
<select
|
||||
id="client_id"
|
||||
name="client_id"
|
||||
required
|
||||
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">Seleziona un cliente…</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.brand_name} ({c.name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Select offerta */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="offer_macro_id" className="text-sm font-medium text-foreground">
|
||||
Offerta
|
||||
</label>
|
||||
<select
|
||||
id="offer_macro_id"
|
||||
name="offer_macro_id"
|
||||
required
|
||||
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">Seleziona un'offerta…</option>
|
||||
{offers.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.internal_name}{o.description ? ` — ${o.description}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Errore */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Info generazione */}
|
||||
<div className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2">
|
||||
L'AI (Claude Opus) legge i transcript del soggetto selezionato e genera una bozza
|
||||
personalizzata. La generazione richiede 30–60 secondi.
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Generazione in corso…
|
||||
</>
|
||||
) : (
|
||||
"Genera con AI →"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getAllLeads } from "@/lib/lead-service";
|
||||
import { getOfferListCards } from "@/lib/offer-queries";
|
||||
import { db } from "@/db";
|
||||
import { clients } from "@/db/schema";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { generateProposalDraft } from "../actions";
|
||||
import { GeneraProposalForm } from "./GeneraProposalForm";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function GeneraPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ lead_id?: string }>;
|
||||
}) {
|
||||
const { lead_id: preselectedLeadId } = await searchParams;
|
||||
|
||||
const [leads, offerCards, allClients] = await Promise.all([
|
||||
getAllLeads(200),
|
||||
getOfferListCards(),
|
||||
db.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name })
|
||||
.from(clients)
|
||||
.orderBy(asc(clients.name)),
|
||||
]);
|
||||
|
||||
const activeOffers = offerCards.filter((o) => !o.is_archived);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Genera preventivo</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Scegli il lead o il cliente e l'offerta. L'AI legge i transcript e genera la bozza.
|
||||
</p>
|
||||
</div>
|
||||
<GeneraProposalForm
|
||||
leads={leads.map((l) => ({ id: l.id, name: l.name, company: l.company }))}
|
||||
clients={allClients}
|
||||
offers={activeOffers}
|
||||
action={generateProposalDraft}
|
||||
preselectedLeadId={preselectedLeadId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Link from "next/link";
|
||||
import { listProposals } from "@/lib/proposal/queries";
|
||||
import { FileText, Plus } from "lucide-react";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
const STATE_LABELS: Record<string, { label: string; className: string }> = {
|
||||
draft: { label: "Bozza", className: "bg-yellow-100 text-yellow-800" },
|
||||
published: { label: "Pubblicato", className: "bg-blue-100 text-blue-800" },
|
||||
accepted: { label: "Accettato", className: "bg-green-100 text-green-800" },
|
||||
rejected: { label: "Rifiutato", className: "bg-red-100 text-red-800" },
|
||||
};
|
||||
|
||||
export default async function PreventiviPage() {
|
||||
const proposals = await listProposals();
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Preventivi</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Preventivi generati con AI e pubblicati ai clienti
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/preventivi/genera"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Genera preventivo
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Lista */}
|
||||
{proposals.length === 0 ? (
|
||||
<div className="text-center py-20 border border-dashed border-border rounded-lg">
|
||||
<FileText size={40} className="mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-sm">Nessun preventivo ancora.</p>
|
||||
<Link
|
||||
href="/admin/preventivi/genera"
|
||||
className="mt-4 inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
Genera il primo preventivo →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Titolo</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Lead / Cliente</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Offerta</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Stato</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Data</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{proposals.map((p) => {
|
||||
const stateInfo = STATE_LABELS[p.state] ?? { label: p.state, className: "bg-gray-100 text-gray-700" };
|
||||
const subject = p.clientName ?? p.leadName ?? "—";
|
||||
return (
|
||||
<tr key={p.id} className="border-b border-border last:border-0 hover:bg-muted/50 transition-colors">
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<Link href={`/admin/preventivi/${p.id}`} className="hover:text-primary transition-colors">
|
||||
{p.title ?? "Senza titolo"}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{subject}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{p.offerName}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${stateInfo.className}`}>
|
||||
{stateInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{p.createdAt.toLocaleDateString("it-IT")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{p.state === "published" || p.state === "accepted" ? (
|
||||
<a
|
||||
href={`/preventivo/${p.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Apri →
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { proposals } from "@/db/schema";
|
||||
import { eq, and, isNull, isNotNull } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
// Guard immutabilità — accepted_at è IMMUTABILE una volta valorizzato
|
||||
async function getProposalForAction(slug: string) {
|
||||
const [row] = await db
|
||||
.select({ id: proposals.id, state: proposals.state, accepted_at: proposals.accepted_at })
|
||||
.from(proposals)
|
||||
.where(eq(proposals.slug, slug))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function acceptProposal(
|
||||
slug: string,
|
||||
tier: string,
|
||||
email?: string,
|
||||
notes?: string
|
||||
) {
|
||||
const proposal = await getProposalForAction(slug);
|
||||
if (!proposal) throw new Error("Proposta non trovata");
|
||||
if (proposal.accepted_at) throw new Error("Proposta già accettata");
|
||||
if (proposal.state !== "published") throw new Error("Proposta non pubblicata");
|
||||
if (!["A", "B", "C"].includes(tier)) throw new Error("Tier non valido");
|
||||
|
||||
await db
|
||||
.update(proposals)
|
||||
.set({
|
||||
state: "accepted",
|
||||
selected_tier: tier,
|
||||
accepted_at: new Date(),
|
||||
client_email: email?.trim() || null,
|
||||
client_notes: notes?.trim() || null,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where(and(eq(proposals.slug, slug), isNull(proposals.accepted_at)));
|
||||
|
||||
revalidatePath(`/preventivo/${slug}`);
|
||||
}
|
||||
|
||||
export async function rejectProposal(slug: string, notes?: string) {
|
||||
const proposal = await getProposalForAction(slug);
|
||||
if (!proposal) throw new Error("Proposta non trovata");
|
||||
if (proposal.accepted_at) throw new Error("Proposta già accettata");
|
||||
if (proposal.state !== "published") throw new Error("Proposta non pubblicata");
|
||||
|
||||
await db
|
||||
.update(proposals)
|
||||
.set({
|
||||
state: "rejected",
|
||||
client_notes: notes?.trim() || null,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where(and(eq(proposals.slug, slug), isNull(proposals.accepted_at)));
|
||||
|
||||
revalidatePath(`/preventivo/${slug}`);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getProposalBySlug } from "@/lib/proposal/queries";
|
||||
import { ProposalDeck } from "@/components/public/proposal/ProposalDeck";
|
||||
import { acceptProposal, rejectProposal } from "./actions";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const proposal = await getProposalBySlug(slug);
|
||||
if (!proposal) return { title: "Preventivo non trovato" };
|
||||
return {
|
||||
title: proposal.title ?? "Documento di Soluzione · iamcavalli",
|
||||
description: `Proposta riservata — ${proposal.content.header.clientName} × ${proposal.content.consultant.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProposalPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const proposal = await getProposalBySlug(slug);
|
||||
if (!proposal) notFound();
|
||||
|
||||
// Proposta non ancora pubblicata
|
||||
if (proposal.state === "draft") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<p className="text-muted-foreground text-sm">Questa proposta non è ancora disponibile.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProposalDeck
|
||||
proposal={proposal}
|
||||
onAccept={acceptProposal}
|
||||
onReject={rejectProposal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,16 +12,18 @@ import {
|
||||
Settings,
|
||||
LogOut,
|
||||
Zap,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||
{ href: "/admin/clients", label: "Clienti", icon: Users },
|
||||
{ href: "/admin/leads", label: "Lead", icon: Zap },
|
||||
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
|
||||
{ href: "/admin/offers", label: "Offerte", icon: Tag },
|
||||
{ href: "/admin/catalog", label: "Catalogo", icon: BookOpen },
|
||||
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
|
||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||
{ href: "/admin/clients", label: "Clienti", icon: Users },
|
||||
{ href: "/admin/leads", label: "Lead", icon: Zap },
|
||||
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
|
||||
{ href: "/admin/preventivi", label: "Preventivi", icon: FileText },
|
||||
{ href: "/admin/offers", label: "Offerte", icon: Tag },
|
||||
{ href: "/admin/catalog", label: "Catalogo", icon: BookOpen },
|
||||
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
|
||||
];
|
||||
|
||||
export function AdminSidebar() {
|
||||
@@ -37,6 +39,17 @@ export function AdminSidebar() {
|
||||
<span className="font-bold text-white tracking-tight text-sm">iamcavalli</span>
|
||||
</div>
|
||||
|
||||
{/* CTA globale — genera preventivo */}
|
||||
<div className="px-3 py-3 border-b border-white/10">
|
||||
<Link
|
||||
href="/admin/preventivi/genera"
|
||||
className="flex items-center justify-center gap-2 w-full px-3 py-2 rounded-md text-xs font-semibold bg-[#DEF168] text-[#1A463C] hover:bg-[#d4e85e] transition-colors"
|
||||
>
|
||||
<FileText size={13} />
|
||||
Genera preventivo
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex-1 px-3 py-4 flex flex-col gap-0.5">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Lead, Activity, Reminder } from "@/db/schema";
|
||||
import { Lead, Activity, Reminder, ClientTranscript } from "@/db/schema";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||
import { addLeadTag, removeLeadTag, renameLeadTag } from "@/app/admin/leads/actions";
|
||||
import { addLeadTag, removeLeadTag, renameLeadTag, deleteTranscript } from "@/app/admin/leads/actions";
|
||||
import { formatDistanceToNow, format } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
import Link from "next/link";
|
||||
import { LogActivityModal } from "./LogActivityModal";
|
||||
import { SendQuoteModal } from "./SendQuoteModal";
|
||||
import { EditLeadModal } from "./LeadForm";
|
||||
import { TranscriptModal } from "./TranscriptModal";
|
||||
|
||||
const ACTIVITY_ICON: Record<string, string> = {
|
||||
call: "📞",
|
||||
@@ -36,16 +38,20 @@ export function LeadDetail({
|
||||
reminders,
|
||||
tags,
|
||||
tagOptions,
|
||||
transcripts,
|
||||
}: {
|
||||
lead: Lead;
|
||||
activities: Activity[];
|
||||
reminders: Reminder[];
|
||||
tags: string[];
|
||||
tagOptions: string[];
|
||||
transcripts: ClientTranscript[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [tagError, setTagError] = useState<string | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
function run(fn: () => Promise<unknown>) {
|
||||
setTagError(null);
|
||||
@@ -59,6 +65,29 @@ export function LeadDetail({
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteTranscript(transcriptId: string) {
|
||||
setDeletingId(transcriptId);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteTranscript(transcriptId, lead.id);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.error("deleteTranscript error:", e);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header + Actions */}
|
||||
@@ -69,7 +98,14 @@ export function LeadDetail({
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<LogActivityModal leadId={lead.id} />
|
||||
<TranscriptModal leadId={lead.id} />
|
||||
<SendQuoteModal leadId={lead.id} />
|
||||
<Link
|
||||
href={`/admin/preventivi/genera?lead_id=${lead.id}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#DEF168] text-[#1A463C] rounded-md text-sm font-semibold hover:bg-[#d4e85e] transition-colors"
|
||||
>
|
||||
Genera preventivo
|
||||
</Link>
|
||||
<EditLeadModal lead={lead} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,6 +230,73 @@ export function LeadDetail({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transcript Call */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Transcript Call</CardTitle>
|
||||
<span className="text-sm text-gray-500">
|
||||
{transcripts.length} {transcripts.length === 1 ? "transcript" : "transcript"}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transcripts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{transcripts.map((t) => {
|
||||
const isExpanded = expandedIds.has(t.id);
|
||||
const formattedDate = format(
|
||||
new Date(t.call_date + "T00:00:00"),
|
||||
"d MMMM yyyy",
|
||||
{ locale: it }
|
||||
);
|
||||
const lines = t.content.split("\n");
|
||||
const preview = lines.slice(0, 3).join("\n").slice(0, 200);
|
||||
const hasMore = t.content.length > preview.length || lines.length > 3;
|
||||
|
||||
return (
|
||||
<div key={t.id} className="border-l-4 border-purple-300 pl-4 pb-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">{formattedDate}</span>
|
||||
{t.title && (
|
||||
<span className="text-gray-600 text-sm">— {t.title}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 whitespace-pre-wrap">
|
||||
{isExpanded ? t.content : preview}
|
||||
{!isExpanded && hasMore && (
|
||||
<span className="text-gray-400">...</span>
|
||||
)}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => toggleExpand(t.id)}
|
||||
className="text-xs text-blue-600 hover:underline mt-1"
|
||||
>
|
||||
{isExpanded ? "Mostra meno" : "Mostra tutto"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 shrink-0"
|
||||
disabled={deletingId === t.id}
|
||||
onClick={() => handleDeleteTranscript(t.id)}
|
||||
>
|
||||
{deletingId === t.id ? "..." : "Elimina"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Nessun transcript registrato</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { addTranscript } from "@/app/admin/leads/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const transcriptSchema = z.object({
|
||||
lead_id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
content: z.string().min(1, "Il testo del transcript è obbligatorio"),
|
||||
call_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data non valida"),
|
||||
});
|
||||
|
||||
type TranscriptInput = z.infer<typeof transcriptSchema>;
|
||||
|
||||
export function TranscriptModal({ leadId }: { leadId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<TranscriptInput>({
|
||||
resolver: zodResolver(transcriptSchema),
|
||||
defaultValues: {
|
||||
lead_id: leadId,
|
||||
title: "",
|
||||
content: "",
|
||||
call_date: new Date().toISOString().split("T")[0],
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: TranscriptInput) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await addTranscript(data);
|
||||
if (result.success) {
|
||||
setOpen(false);
|
||||
form.reset({
|
||||
lead_id: leadId,
|
||||
title: "",
|
||||
content: "",
|
||||
call_date: new Date().toISOString().split("T")[0],
|
||||
});
|
||||
} else {
|
||||
setError(result.error ?? "Errore nel salvataggio");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Aggiungi Transcript
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Aggiungi Transcript Call</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="call_date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Data call</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Titolo (opzionale)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="es. Discovery call"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Transcript</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Incolla qui il testo del transcript..."
|
||||
className="min-h-48 resize-y"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Salvataggio..." : "Salva Transcript"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ProposalDetail } from "@/lib/proposal/queries";
|
||||
import { CoverSection } from "./sections/CoverSection";
|
||||
import { VisionSection } from "./sections/VisionSection";
|
||||
import { IndexSection } from "./sections/IndexSection";
|
||||
import { ChapterDivider } from "./sections/ChapterDivider";
|
||||
import { StrategistSection } from "./sections/StrategistSection";
|
||||
import { FactsSection } from "./sections/FactsSection";
|
||||
import { TestimonialsSection } from "./sections/TestimonialsSection";
|
||||
import { ProblemNodeSection } from "./sections/ProblemNodeSection";
|
||||
import { SynthesisDiagramSection } from "./sections/SynthesisDiagramSection";
|
||||
import { SolutionNodeSection } from "./sections/SolutionNodeSection";
|
||||
import { SolutionSynthesisSection } from "./sections/SolutionSynthesisSection";
|
||||
import { ScopeSection } from "./sections/ScopeSection";
|
||||
import { DeliverablesSection } from "./sections/DeliverablesSection";
|
||||
import { TimelineSection } from "./sections/TimelineSection";
|
||||
import { PricingSection } from "./sections/PricingSection";
|
||||
import { StagesRecapSection } from "./sections/StagesRecapSection";
|
||||
import { ComparisonMatrixSection } from "./sections/ComparisonMatrixSection";
|
||||
import { NextStepsSection } from "./sections/NextStepsSection";
|
||||
import { ClosingSection } from "./sections/ClosingSection";
|
||||
import { AcceptSection } from "./sections/AcceptSection";
|
||||
|
||||
type Props = {
|
||||
proposal: ProposalDetail;
|
||||
onAccept: (slug: string, tier: string, email?: string, notes?: string) => Promise<void>;
|
||||
onReject: (slug: string, notes?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ProposalDeck({ proposal, onAccept, onReject }: Props) {
|
||||
const { content, state, selectedTier, acceptedAt } = proposal;
|
||||
const { ai, consultant, offer, header } = content;
|
||||
const problems = ai.problems;
|
||||
const solutions = ai.solutions;
|
||||
|
||||
// Costruisce la lista di slide dinamicamente
|
||||
const testimonialPages = Math.ceil(consultant.testimonials.length / 9);
|
||||
|
||||
type Slide = { id: string; component: React.ReactNode };
|
||||
|
||||
const slides: Slide[] = [
|
||||
// 01 — Cover
|
||||
{ id: "cover", component: <CoverSection header={header} /> },
|
||||
// 02 — Vision
|
||||
{ id: "vision", component: <VisionSection vision={ai.vision} header={header} /> },
|
||||
// 03 — Sommario
|
||||
{ id: "index", component: <IndexSection /> },
|
||||
// Cap.01 — Lo strategist
|
||||
{ id: "ch01", component: <ChapterDivider number="01" title="Lo strategist." subtitle="Chi vi affianca in questo lavoro, con quali credenziali, con quale metodo e con quali precedenti dimostrabili." /> },
|
||||
{ id: "strategist", component: <StrategistSection consultant={consultant} /> },
|
||||
{ id: "facts", component: <FactsSection consultant={consultant} /> },
|
||||
// Testimonianze (1–N pagine da 9)
|
||||
...Array.from({ length: testimonialPages }, (_, i) => ({
|
||||
id: `testimonials-${i + 1}`,
|
||||
component: (
|
||||
<TestimonialsSection
|
||||
testimonials={consultant.testimonials}
|
||||
page={i + 1}
|
||||
totalPages={testimonialPages}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
// Cap.02 — Il problema
|
||||
{ id: "ch02", component: <ChapterDivider number="02" title="Il problema." subtitle={`${problems.length} nodi rilevati nella discovery. Li chiamiamo per nome, uno per uno.`} /> },
|
||||
// Nodi problema
|
||||
...problems.map((p) => ({
|
||||
id: `problem-${p.id}`,
|
||||
component: <ProblemNodeSection problem={p} />,
|
||||
})),
|
||||
// Sintesi problema
|
||||
{ id: "problem-synthesis", component: <SynthesisDiagramSection synthesis={ai.problemSynthesis} type="problem" /> },
|
||||
// Cap.03 — La soluzione
|
||||
{ id: "ch03", component: <ChapterDivider number="03" title="La soluzione." subtitle="Per ogni nodo, un'elevazione speculare. La struttura riprende l'ordine dei problemi e mostra dove la trasformazione vi porta." /> },
|
||||
// Nodi soluzione
|
||||
...solutions.map((s) => ({
|
||||
id: `solution-${s.id}`,
|
||||
component: <SolutionNodeSection solution={s} />,
|
||||
})),
|
||||
// Sintesi soluzione
|
||||
{ id: "solution-synthesis", component: <SolutionSynthesisSection synthesis={ai.solutionSynthesis} /> },
|
||||
// Cap.04 — L'esecuzione
|
||||
{ id: "ch04", component: <ChapterDivider number="04" title="L'esecuzione." subtitle="Lo scope del lavoro, gli obiettivi, i deliverable, ciò che è incluso e ciò che non lo è. La timeline." /> },
|
||||
{ id: "scope", component: <ScopeSection scope={ai.scope} /> },
|
||||
{ id: "deliverables", component: <DeliverablesSection deliverables={ai.deliverables} /> },
|
||||
{ id: "timeline", component: <TimelineSection timeline={ai.timeline} /> },
|
||||
// Cap.05 — Le opzioni
|
||||
{ id: "ch05", component: <ChapterDivider number="05" title="Le opzioni." subtitle="Il valore di mercato delle attività, le tre opzioni progressive con il loro pricing, e il processo per partire." /> },
|
||||
{ id: "pricing", component: <PricingSection tiers={offer.tiers} /> },
|
||||
// Stages recap + comparison
|
||||
{ id: "stages", component: <StagesRecapSection stages={ai.stagesRecap.stages} /> },
|
||||
{ id: "comparison", component: <ComparisonMatrixSection matrix={ai.comparisonMatrix} tiers={offer.tiers} /> },
|
||||
// Come procedere / Next steps
|
||||
{ id: "nextsteps", component: <NextStepsSection consultant={consultant} header={header} offer={offer} /> },
|
||||
// Accept/Reject (solo se published)
|
||||
...(state === "published"
|
||||
? [{
|
||||
id: "accept",
|
||||
component: (
|
||||
<AcceptSection
|
||||
slug={proposal.slug}
|
||||
tiers={offer.tiers}
|
||||
header={header}
|
||||
onAccept={onAccept}
|
||||
onReject={onReject}
|
||||
/>
|
||||
),
|
||||
}]
|
||||
: []),
|
||||
// Closing
|
||||
{ id: "closing", component: <ClosingSection consultant={consultant} header={header} state={state} selectedTier={selectedTier} acceptedAt={acceptedAt} /> },
|
||||
];
|
||||
|
||||
const total = slides.length;
|
||||
const [current, setCurrent] = useState(0);
|
||||
|
||||
const goTo = useCallback((i: number) => setCurrent(Math.max(0, Math.min(total - 1, i))), [total]);
|
||||
const prev = useCallback(() => goTo(current - 1), [current, goTo]);
|
||||
const next = useCallback(() => goTo(current + 1), [current, goTo]);
|
||||
|
||||
// Navigazione da tastiera
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") next();
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowUp") prev();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [next, prev]);
|
||||
|
||||
// Blocca lo scroll del body mentre la deck è visibile
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative h-screen overflow-hidden bg-white font-sans select-none">
|
||||
{/* Topbar */}
|
||||
<div className="fixed top-0 left-0 right-0 z-20 flex items-center justify-between px-8 py-4 bg-white/90 backdrop-blur-sm border-b border-border">
|
||||
<span className="text-xs font-mono text-muted-foreground tracking-widest uppercase">
|
||||
Documento di Soluzione · {header.clientName} · {header.date}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{String(current + 1).padStart(2, "0")} / {String(total).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Contenuto slide — riempie esattamente lo spazio tra le due barre fisse */}
|
||||
<div className="h-full pt-16 pb-20 overflow-hidden">
|
||||
{slides[current].component}
|
||||
</div>
|
||||
|
||||
{/* Bottom nav */}
|
||||
<div className="fixed bottom-0 left-0 right-0 z-20 flex items-center justify-between px-8 py-4 bg-white/90 backdrop-blur-sm border-t border-border">
|
||||
{/* Frecce */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<button
|
||||
onClick={prev}
|
||||
disabled={current === 0}
|
||||
className="disabled:opacity-30 hover:text-foreground transition-colors"
|
||||
aria-label="Precedente"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={current === total - 1}
|
||||
className="disabled:opacity-30 hover:text-foreground transition-colors"
|
||||
aria-label="Prossimo"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<span className="ml-2 hidden sm:inline">CLICK DOTS</span>
|
||||
</div>
|
||||
|
||||
{/* Dot navigator */}
|
||||
<div className="flex items-center gap-1 overflow-x-auto max-w-[60vw] py-1">
|
||||
{slides.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => goTo(i)}
|
||||
aria-label={`Vai alla slide ${i + 1}`}
|
||||
className={`shrink-0 rounded-full transition-all ${
|
||||
i === current
|
||||
? "w-6 h-2 bg-primary"
|
||||
: "w-2 h-2 bg-border hover:bg-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { AssembledProposal } from "@/lib/proposal/assemble";
|
||||
|
||||
type Props = {
|
||||
slug: string;
|
||||
tiers: AssembledProposal["offer"]["tiers"];
|
||||
header: AssembledProposal["header"];
|
||||
onAccept: (slug: string, tier: string, email?: string, notes?: string) => Promise<void>;
|
||||
onReject: (slug: string, notes?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function AcceptSection({ slug, tiers, header, onAccept, onReject }: Props) {
|
||||
const [selectedTier, setSelectedTier] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [action, setAction] = useState<"accept" | "reject" | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleAccept() {
|
||||
if (!selectedTier) { setError("Seleziona un tier per procedere"); return; }
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await onAccept(slug, selectedTier, email || undefined, notes || undefined);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Errore");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleReject() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await onReject(slug, notes || undefined);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Errore");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
|
||||
Accettazione formale
|
||||
</p>
|
||||
<h2 className="text-5xl font-light text-foreground mt-4">
|
||||
Scegli il tuo <span className="text-primary">tier.</span>
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground mt-4">
|
||||
Seleziona l'opzione che preferisci. La conferma via questo modulo attiva il lavoro.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Selezione tier */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{tiers.map((tier) => (
|
||||
<button
|
||||
key={tier.tierLetter}
|
||||
onClick={() => setSelectedTier(tier.tierLetter)}
|
||||
className={`rounded-xl border-2 p-6 text-left transition-all ${
|
||||
selectedTier === tier.tierLetter
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
<p className="text-2xl font-light text-primary">{tier.tierLetter}</p>
|
||||
<p className="font-medium text-foreground mt-1">{tier.publicName}</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{tier.publicPrice ? `€${parseFloat(tier.publicPrice).toLocaleString("it-IT")}` : `€${parseFloat(tier.servicesTotal).toLocaleString("it-IT")}`}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Email e note */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
|
||||
Email (opzionale)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="tua@email.com"
|
||||
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
|
||||
Note (opzionale)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Note per il consulente…"
|
||||
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
disabled={isPending}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl font-medium hover:bg-primary/90 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{isPending && action === "accept" ? <Loader2 size={16} className="animate-spin" /> : null}
|
||||
Accetto — {selectedTier ? `Opzione ${selectedTier}` : "scegli tier"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setAction("reject"); handleReject(); }}
|
||||
disabled={isPending}
|
||||
className="px-6 py-3 border border-border rounded-xl text-muted-foreground hover:text-destructive hover:border-destructive/40 disabled:opacity-60 transition-colors text-sm"
|
||||
>
|
||||
Non procedo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
{header.consultantName} · Accettazione formale valida {header.validityDays} giorni dalla data di invio.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
type Props = { number: string; title: string; subtitle: string };
|
||||
|
||||
export function ChapterDivider({ number, title, subtitle }: Props) {
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-6">
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Capitolo</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-8xl font-light text-primary">{number}</p>
|
||||
<div className="w-12 h-px bg-primary" />
|
||||
</div>
|
||||
<h2 className="text-5xl font-light text-foreground">{title}</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-lg">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ConsultantProfile } from "@/lib/proposal/profile";
|
||||
import type { AssembledProposal } from "@/lib/proposal/assemble";
|
||||
|
||||
type Props = {
|
||||
consultant: ConsultantProfile;
|
||||
header: AssembledProposal["header"];
|
||||
state: string;
|
||||
selectedTier: string | null;
|
||||
acceptedAt: Date | null;
|
||||
};
|
||||
|
||||
export function ClosingSection({ consultant, header, state, selectedTier, acceptedAt }: Props) {
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-between px-16 py-24">
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Come si parte</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{state === "accepted" && acceptedAt && selectedTier ? (
|
||||
<div className="space-y-4">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-green-100 text-green-800 rounded-full text-sm font-medium">
|
||||
✓ Proposta accettata — Opzione {selectedTier}
|
||||
</div>
|
||||
<h2 className="text-5xl font-light text-foreground">
|
||||
Il kickoff è già <span className="text-primary">in calendario.</span>
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-xl">
|
||||
Hai scelto l'Opzione {selectedTier} il{" "}
|
||||
{acceptedAt.toLocaleDateString("it-IT", { day: "2-digit", month: "long", year: "numeric" })}.
|
||||
Il consulente ti contatterà a breve per definire la data di kickoff.
|
||||
</p>
|
||||
</div>
|
||||
) : state === "rejected" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-100 text-amber-800 rounded-full text-sm font-medium">
|
||||
Proposta non accettata
|
||||
</div>
|
||||
<h2 className="text-5xl font-light text-foreground">Grazie per il tuo tempo.</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Se cambierai idea o vorrai discutere di altri progetti, siamo qui.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-5xl font-light text-foreground">
|
||||
Il kickoff è già <span className="text-primary">in calendario.</span>
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-xl">
|
||||
Una volta confermata l'opzione, la prima sessione viene pianificata entro la settimana successiva.
|
||||
Tutto parte da lì.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{consultant.name} · {consultant.contact.email}
|
||||
{consultant.contact.phone ? ` · ${consultant.contact.phone}` : ""}
|
||||
</p>
|
||||
{consultant.contact.website && (
|
||||
<p className="text-xs font-mono text-muted-foreground">{consultant.contact.website}</p>
|
||||
)}
|
||||
<p className="text-xs font-mono text-muted-foreground mt-4">
|
||||
Documento di Soluzione · {header.clientName} · {header.date}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ComparisonRow } from "@/lib/proposal/schema";
|
||||
import type { AssembledProposal } from "@/lib/proposal/assemble";
|
||||
|
||||
type Props = {
|
||||
matrix: ComparisonRow[];
|
||||
tiers: AssembledProposal["offer"]["tiers"];
|
||||
};
|
||||
|
||||
export function ComparisonMatrixSection({ matrix, tiers }: Props) {
|
||||
const tierLetters = tiers.map((t) => t.tierLetter);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-8">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Riepilogo del lavoro</p>
|
||||
<h2 className="text-5xl font-light text-foreground mt-4">
|
||||
Le voci che <span className="text-primary">compongono</span> ogni opzione.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted border-b border-border">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left font-mono text-xs tracking-widest text-muted-foreground uppercase">
|
||||
Componente del lavoro
|
||||
</th>
|
||||
{tierLetters.map((l) => (
|
||||
<th key={l} className="px-6 py-4 text-center font-mono text-xs tracking-widest text-muted-foreground uppercase w-24">
|
||||
{l}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{matrix.map((row, i) => {
|
||||
const values = [row.inA, row.inB, row.inC];
|
||||
return (
|
||||
<tr key={i} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-foreground">{row.component}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{row.description}</p>
|
||||
</td>
|
||||
{values.slice(0, tierLetters.length).map((included, j) => (
|
||||
<td key={j} className="px-6 py-4 text-center">
|
||||
{included ? (
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-primary" />
|
||||
) : (
|
||||
<span className="inline-block w-3 h-3 rounded-full border-2 border-border" />
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
Le tre opzioni si compongono progressivamente. La C eredita tutto da B, che a sua volta eredita tutto da A.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AssembledProposal } from "@/lib/proposal/assemble";
|
||||
|
||||
type Props = { header: AssembledProposal["header"] };
|
||||
|
||||
export function CoverSection({ header }: Props) {
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-between px-16 py-24">
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
|
||||
Documento di Soluzione
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-6xl font-light leading-tight tracking-tight text-foreground">
|
||||
{header.offerTitle.split(" ").map((word, i, arr) => {
|
||||
const isLast = i === arr.length - 1;
|
||||
return (
|
||||
<span key={i}>
|
||||
{isLast ? <span className="text-primary font-normal">{word}</span> : `${word} `}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</h1>
|
||||
{header.offerDescription && (
|
||||
<p className="text-lg text-muted-foreground max-w-2xl">{header.offerDescription}</p>
|
||||
)}
|
||||
<p className="text-base text-foreground">
|
||||
{header.clientName} × {header.consultantName}
|
||||
</p>
|
||||
<div className="flex items-center gap-6 text-xs font-mono text-muted-foreground">
|
||||
<span>Data: {header.date}</span>
|
||||
<span>·</span>
|
||||
<span>Validità {header.validityDays} giorni</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ProposalContent } from "@/lib/proposal/schema";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
type Props = { deliverables: ProposalContent["deliverables"] };
|
||||
|
||||
export function DeliverablesSection({ deliverables }: Props) {
|
||||
return (
|
||||
<div className="h-full flex items-center px-16 py-24">
|
||||
<div className="grid grid-cols-2 gap-8 w-full">
|
||||
{/* Deliverable */}
|
||||
<div className="border border-primary/20 bg-primary/5 rounded-xl p-8 space-y-4">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<div className="w-5 h-5 rounded-full bg-primary" />
|
||||
</div>
|
||||
<p className="text-xs font-mono tracking-widest text-primary uppercase">Deliverable · Output finali</p>
|
||||
<h3 className="text-lg font-medium text-foreground">Quattro asset progressivi, uno per opzione.</h3>
|
||||
<ul className="space-y-2">
|
||||
{deliverables.deliverables.map((d, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
|
||||
<span className="text-primary mt-0.5">›</span>
|
||||
<span dangerouslySetInnerHTML={{ __html: d }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Out of scope */}
|
||||
<div className="border border-border rounded-xl p-8 space-y-4">
|
||||
<div className="w-10 h-10 rounded-full border-2 border-border flex items-center justify-center text-muted-foreground">
|
||||
∅
|
||||
</div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Confini · Ciò che non è incluso</p>
|
||||
<div className="border border-border rounded-lg p-4 bg-muted/30 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Ciò che non è esplicitamente indicato è da considerarsi escluso.{" "}
|
||||
<span className="italic">A titolo di esempio:</span>
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{deliverables.outOfScope.map((d, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<X size={14} className="mt-0.5 shrink-0" />
|
||||
<span>{d}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ConsultantProfile } from "@/lib/proposal/profile";
|
||||
|
||||
type Props = { consultant: ConsultantProfile };
|
||||
|
||||
export function FactsSection({ consultant }: Props) {
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-12">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Tre fatti</p>
|
||||
<h2 className="text-5xl font-light text-foreground mt-4">
|
||||
Cosa porto al <span className="text-primary">tavolo.</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{consultant.facts.map((fact, i) => (
|
||||
<div key={i} className="border border-border rounded-xl p-8 space-y-4">
|
||||
<p className="text-5xl font-light text-primary">{fact.value}</p>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">{fact.label}</p>
|
||||
<p className="text-sm text-foreground font-medium">{fact.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
const CHAPTERS = [
|
||||
{ n: "01", title: "Lo strategist", subtitle: "Chi vi affianca, con quale metodo e con quali precedenti." },
|
||||
{ n: "02", title: "Il problema", subtitle: "I nodi rilevati nella discovery, ognuno chiamato per nome." },
|
||||
{ n: "03", title: "La soluzione", subtitle: "L'elevazione speculare a ciascun nodo — con il risultato concreto." },
|
||||
{ n: "04", title: "L'esecuzione", subtitle: "Scope, deliverable, ciò che non è incluso, timeline." },
|
||||
{ n: "05", title: "Le opzioni", subtitle: "Tre intensità progressive con valore di mercato e pricing." },
|
||||
];
|
||||
|
||||
export function IndexSection() {
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-8">
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Sommario</p>
|
||||
<h2 className="text-5xl font-light text-foreground">
|
||||
Cinque <span className="text-primary">capitoli.</span>
|
||||
</h2>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{CHAPTERS.map((c) => (
|
||||
<div key={c.n} className="flex items-baseline justify-between py-5 gap-8">
|
||||
<div className="flex items-baseline gap-6">
|
||||
<span className="text-2xl font-mono text-primary">{c.n}</span>
|
||||
<span className="text-xl text-foreground">{c.title}</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground text-right max-w-xs">{c.subtitle}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ConsultantProfile } from "@/lib/proposal/profile";
|
||||
import type { AssembledProposal } from "@/lib/proposal/assemble";
|
||||
|
||||
type Props = {
|
||||
consultant: ConsultantProfile;
|
||||
header: AssembledProposal["header"];
|
||||
offer: AssembledProposal["offer"];
|
||||
};
|
||||
|
||||
export function NextStepsSection({ consultant, header }: Props) {
|
||||
// Calcola la data di scadenza (validityDays dalla data di generazione)
|
||||
const expiryNote = `Valida ${header.validityDays} giorni dalla data di invio.`;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Come procedere ora</p>
|
||||
<h2 className="text-5xl font-light text-foreground mt-4">
|
||||
Tre passaggi <br />
|
||||
<span className="text-primary">per attivare il lavoro.</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{consultant.nextSteps.map((step) => (
|
||||
<div key={step.number} className="flex items-start gap-6">
|
||||
<span className="text-xl font-mono text-primary shrink-0">{step.number}</span>
|
||||
<p className="text-base text-foreground leading-relaxed">
|
||||
{step.highlightedPart ? (
|
||||
<>
|
||||
{step.text.split(step.highlightedPart)[0]}
|
||||
<strong>{step.highlightedPart}</strong>
|
||||
{step.text.split(step.highlightedPart)[1]}
|
||||
</>
|
||||
) : (
|
||||
step.text
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-6 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{consultant.name} · {consultant.contact.email}
|
||||
{consultant.contact.phone ? ` · ${consultant.contact.phone}` : ""}
|
||||
</p>
|
||||
<p className="text-xs font-mono text-muted-foreground">{expiryNote}</p>
|
||||
</div>
|
||||
|
||||
{/* Legale + accettazione */}
|
||||
<div className="grid grid-cols-2 gap-4 text-xs text-muted-foreground">
|
||||
<div className="border border-border rounded-lg p-4 space-y-1">
|
||||
<p className="font-mono uppercase tracking-widest text-foreground text-[10px]">Termini legali</p>
|
||||
<p>{consultant.legal.revisions}</p>
|
||||
<p>{consultant.legal.ip}</p>
|
||||
<p>{consultant.legal.nda}</p>
|
||||
<p>Foro competente: {consultant.legal.forum}</p>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg p-4 space-y-1">
|
||||
<p className="font-mono uppercase tracking-widest text-foreground text-[10px]">Accettazione</p>
|
||||
<p>{consultant.legal.acceptanceInstructions}</p>
|
||||
<p className="mt-2 font-medium text-foreground">{expiryNote}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { AssembledProposal } from "@/lib/proposal/assemble";
|
||||
|
||||
type Props = { tiers: AssembledProposal["offer"]["tiers"] };
|
||||
|
||||
function formatEur(val: string | null | undefined): string {
|
||||
if (!val) return "—";
|
||||
const n = parseFloat(val);
|
||||
return `€${n.toLocaleString("it-IT")}`;
|
||||
}
|
||||
|
||||
export function PricingSection({ tiers }: Props) {
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Le tre opzioni</p>
|
||||
<h2 className="text-5xl font-light text-foreground mt-4">
|
||||
Tre tappe, <span className="text-primary">un solo risultato.</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{tiers.map((tier, i) => {
|
||||
const isHighlighted = i === 1; // tier B highlighted di default
|
||||
const price = tier.publicPrice ?? tier.servicesTotal;
|
||||
const durationLabel = `${tier.durationMonths} ${tier.durationMonths === 1 ? "mese" : "mesi"}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={`rounded-xl p-8 space-y-6 border ${
|
||||
isHighlighted
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
|
||||
Opzione {tier.tierLetter} / {durationLabel}
|
||||
</p>
|
||||
<h3 className="text-xl font-medium text-foreground mt-1">{tier.publicName}</h3>
|
||||
</div>
|
||||
<span className="text-xs font-mono bg-primary/10 text-primary px-2 py-1 rounded">
|
||||
{tier.tierLetter}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-3xl font-light text-primary">{formatEur(price)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tier.durationMonths > 1 ? `${tier.durationMonths} mesi` : "pagamento unico"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Servizi inclusi */}
|
||||
{tier.services.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{tier.services.map((s) => (
|
||||
<li key={s.id} className="flex items-start gap-2 text-sm text-foreground">
|
||||
<span className="text-primary mt-0.5">–</span>
|
||||
<span>{s.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
Le tre opzioni si compongono progressivamente. La C eredita tutto da B, che a sua volta eredita tutto da A.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ProblemNode } from "@/lib/proposal/schema";
|
||||
|
||||
type Props = { problem: ProblemNode };
|
||||
|
||||
export function ProblemNodeSection({ problem }: Props) {
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-8 max-w-4xl">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-destructive uppercase">
|
||||
Problema {problem.id} · {problem.subtitle}
|
||||
</p>
|
||||
<h2 className="text-5xl font-light text-foreground mt-4 leading-tight">
|
||||
{problem.title.split(problem.title.split(" ").slice(-2).join(" "))[0]}
|
||||
<span className="text-destructive">{problem.title.split(" ").slice(-2).join(" ")}</span>
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground mt-4 leading-relaxed max-w-2xl">
|
||||
{problem.body}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-destructive/30 bg-destructive/5 rounded-xl p-6 space-y-2">
|
||||
<p className="text-xs font-mono tracking-widest text-destructive uppercase">Il rischio</p>
|
||||
<p className="text-sm text-foreground leading-relaxed">{problem.risk}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-2 border-border pl-6 space-y-1">
|
||||
<p className="text-sm text-muted-foreground italic leading-relaxed">
|
||||
“{problem.quote}”
|
||||
</p>
|
||||
<p className="text-xs font-mono text-muted-foreground">{problem.quoteAttribution}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ProposalContent } from "@/lib/proposal/schema";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
|
||||
type Props = { scope: ProposalContent["scope"] };
|
||||
|
||||
export function ScopeSection({ scope }: Props) {
|
||||
return (
|
||||
<div className="h-full flex items-center px-16 py-24">
|
||||
<div className="grid grid-cols-2 gap-12 w-full">
|
||||
{/* Scope */}
|
||||
<div className="border border-primary/20 bg-primary/5 rounded-xl p-8 space-y-4">
|
||||
<div className="w-10 h-10 rounded-full border border-primary flex items-center justify-center">
|
||||
<div className="w-5 h-5 rounded-full bg-primary/30" />
|
||||
</div>
|
||||
<p className="text-xs font-mono tracking-widest text-primary uppercase">Scope · Capitolo del lavoro</p>
|
||||
<h3 className="text-xl font-medium text-foreground">{scope.scopeTitle}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{scope.scopeBody}</p>
|
||||
</div>
|
||||
|
||||
{/* Obiettivi */}
|
||||
<div className="border border-border bg-muted/30 rounded-xl p-8 space-y-4">
|
||||
<div className="w-10 h-10 rounded-full border border-border flex items-center justify-center">
|
||||
<div className="w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[10px] border-b-foreground" />
|
||||
</div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Obiettivi · Punti d'arrivo</p>
|
||||
<h3 className="text-xl font-medium text-foreground">Quattro risultati concreti, misurabili al termine.</h3>
|
||||
<ul className="space-y-2">
|
||||
{scope.objectives.map((obj, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
|
||||
<CheckCircle2 size={14} className="text-primary mt-0.5 shrink-0" />
|
||||
<span dangerouslySetInnerHTML={{ __html: obj }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { SolutionNode } from "@/lib/proposal/schema";
|
||||
|
||||
type Props = { solution: SolutionNode };
|
||||
|
||||
export function SolutionNodeSection({ solution }: Props) {
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-8 max-w-4xl">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-primary uppercase">
|
||||
{solution.subtitle}
|
||||
</p>
|
||||
<h2 className="text-5xl font-light text-foreground mt-4 leading-tight">
|
||||
{solution.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="border border-primary/20 bg-primary/5 rounded-xl p-6 space-y-2">
|
||||
<p className="text-xs font-mono tracking-widest text-primary uppercase">La trasformazione</p>
|
||||
<p className="text-sm text-foreground leading-relaxed">{solution.transformation}</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-xl p-6 space-y-3">
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Attraverso cosa</p>
|
||||
<ul className="space-y-2">
|
||||
{solution.throughWhat.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
|
||||
<span className="text-primary mt-0.5">›</span>
|
||||
<span dangerouslySetInnerHTML={{ __html: item }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ProposalContent } from "@/lib/proposal/schema";
|
||||
|
||||
type Props = { synthesis: ProposalContent["solutionSynthesis"] };
|
||||
|
||||
export function SolutionSynthesisSection({ synthesis }: Props) {
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Sintesi della soluzione</p>
|
||||
<h2 className="text-5xl font-light text-foreground mt-4">
|
||||
Una direzione, <span className="text-success">cinque elevazioni.</span>
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground mt-4 max-w-2xl">
|
||||
Da un'unica decisione strategica a monte si declinano cinque movimenti specifici. Il sistema operativo che resta al team interno.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Direzione */}
|
||||
<div className="border border-primary/20 bg-primary/5 rounded-xl p-6 text-center mx-auto max-w-lg">
|
||||
<p className="text-xs font-mono tracking-widest text-primary uppercase mb-2">La direzione</p>
|
||||
<p className="text-sm font-medium text-foreground">{synthesis.direction}</p>
|
||||
</div>
|
||||
|
||||
{/* Connettori */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-px h-8 bg-primary/30" />
|
||||
</div>
|
||||
|
||||
{/* Elevazioni */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{synthesis.elevations.map((e, i) => (
|
||||
<div key={e.id} className="flex-1 border border-border rounded-lg px-4 py-3 text-center">
|
||||
<p className="text-xs font-mono text-muted-foreground mb-1">0{i + 1} →</p>
|
||||
<p className="text-sm font-medium text-foreground">{e.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ProposalContent } from "@/lib/proposal/schema";
|
||||
|
||||
type Props = { stages: ProposalContent["stagesRecap"]["stages"] };
|
||||
|
||||
export function StagesRecapSection({ stages }: Props) {
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Recap del progresso</p>
|
||||
<h2 className="text-5xl font-light text-foreground mt-4">
|
||||
Tre tappe verso <span className="text-primary">la trasformazione.</span>
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground mt-4 max-w-3xl">
|
||||
Le tre opzioni non sono pacchetti scollegati: sono{" "}
|
||||
<strong>tre tappe progressive</strong> dello stesso percorso. Ognuna costruisce sopra la precedente.
|
||||
Insieme portano alla <strong>trasformazione totale</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
{stages.map((stage, i) => (
|
||||
<div key={i} className="flex-1">
|
||||
<div className="border border-primary/20 rounded-xl p-6 space-y-3">
|
||||
<p className="text-4xl font-light text-primary">{stage.number}</p>
|
||||
<p className="text-xs font-mono tracking-widest text-primary uppercase">{stage.label}</p>
|
||||
<h3 className="text-xl font-medium text-foreground">{stage.headline}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{stage.body}</p>
|
||||
<div className="border border-border rounded-md px-3 py-2 bg-muted/30">
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
› {stage.deliverable}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{i < stages.length - 1 && (
|
||||
<div className="hidden" /* arrow between stages — gestito dal grid */ />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ConsultantProfile } from "@/lib/proposal/profile";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
|
||||
type Props = { consultant: ConsultantProfile };
|
||||
|
||||
export function StrategistSection({ consultant }: Props) {
|
||||
return (
|
||||
<div className="h-full flex items-center px-16 py-24">
|
||||
<div className="grid grid-cols-3 gap-12 w-full">
|
||||
{/* Foto */}
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="w-48 h-64 rounded-xl border-2 border-primary bg-muted flex items-center justify-center overflow-hidden">
|
||||
{consultant.photoUrl ? (
|
||||
<img src={consultant.photoUrl} alt={consultant.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">{consultant.name[0]}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div className="col-span-2 space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
|
||||
Strategist · Brand & Business
|
||||
</p>
|
||||
<h2 className="text-4xl font-light text-foreground mt-2">{consultant.name}</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-base font-medium text-foreground leading-relaxed">{consultant.bio}</p>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{consultant.credentials.map((c, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 size={14} className="text-primary mt-0.5 shrink-0" />
|
||||
<span dangerouslySetInnerHTML={{ __html: c }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="p-4 bg-muted rounded-lg text-sm text-foreground leading-relaxed border-l-2 border-primary">
|
||||
{consultant.contact.website && (
|
||||
<span className="font-mono text-xs text-muted-foreground">{consultant.contact.website}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ProposalContent } from "@/lib/proposal/schema";
|
||||
|
||||
type Props = {
|
||||
synthesis: ProposalContent["problemSynthesis"];
|
||||
type: "problem";
|
||||
};
|
||||
|
||||
export function SynthesisDiagramSection({ synthesis }: Props) {
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Sintesi del problema</p>
|
||||
<h2 className="text-5xl font-light text-foreground mt-4">
|
||||
Cinque nodi, <span className="text-destructive">una sola radice.</span>
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground mt-4 max-w-2xl">
|
||||
I cinque nodi convergono su un'unica domanda strategica non ancora risolta. Sciolto quel nodo, gli altri trovano il proprio posto.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Diagramma semplice: nodi → radice comune */}
|
||||
<div className="space-y-8">
|
||||
{/* Nodi */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{synthesis.nodeLabels.map((label, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 border border-border rounded-lg px-4 py-3 text-center"
|
||||
>
|
||||
<p className="text-xs font-mono text-muted-foreground mb-1">0{i + 1} →</p>
|
||||
<p className="text-sm font-medium text-foreground">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Connettori */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-px h-8 bg-destructive/30" />
|
||||
</div>
|
||||
|
||||
{/* Radice */}
|
||||
<div className="mx-auto max-w-lg border border-destructive/30 bg-destructive/5 rounded-xl p-6 text-center">
|
||||
<p className="text-xs font-mono tracking-widest text-destructive uppercase mb-2">
|
||||
La radice comune
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">{synthesis.rootCause}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Testimonial } from "@/lib/proposal/profile";
|
||||
|
||||
type Props = {
|
||||
testimonials: Testimonial[];
|
||||
page: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
export function TestimonialsSection({ testimonials, page, totalPages }: Props) {
|
||||
const perPage = 9;
|
||||
const slice = testimonials.slice((page - 1) * perPage, page * perPage);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col px-16 py-24 space-y-8">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
|
||||
Pagina {page} di {totalPages} · Cosa dicono di me
|
||||
</p>
|
||||
<h2 className="text-4xl font-light text-foreground mt-4">
|
||||
Una selezione dalle <span className="text-primary">testimonianze</span> di chi vi ha preceduto.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 flex-1">
|
||||
{slice.map((t, i) => (
|
||||
<div key={i} className="border border-border rounded-lg p-5 space-y-3 flex flex-col">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary shrink-0">
|
||||
{t.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground leading-tight">{t.name}</p>
|
||||
<p className="text-xs font-mono text-primary">{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground italic leading-relaxed flex-1">
|
||||
“{t.quote}”
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ProposalContent } from "@/lib/proposal/schema";
|
||||
|
||||
type Props = { timeline: ProposalContent["timeline"] };
|
||||
|
||||
const COLOR_MAP = {
|
||||
cyan: { bg: "bg-cyan-400", text: "text-cyan-900" },
|
||||
purple: { bg: "bg-violet-400", text: "text-violet-900" },
|
||||
green: { bg: "bg-emerald-400",text: "text-emerald-900" },
|
||||
};
|
||||
|
||||
export function TimelineSection({ timeline }: Props) {
|
||||
const weeks = Array.from({ length: timeline.totalWeeks }, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">Timeline</p>
|
||||
<h2 className="text-5xl font-light text-foreground mt-4">
|
||||
{timeline.totalWeeks} settimane di <span className="text-primary">progressione.</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Header settimane */}
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: `140px repeat(${timeline.totalWeeks}, 1fr)` }}>
|
||||
<div />
|
||||
{weeks.map((w) => (
|
||||
<div key={w} className="text-center text-xs font-mono text-muted-foreground">W{w}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Righe fase */}
|
||||
{timeline.phases.map((phase, i) => {
|
||||
const colors = COLOR_MAP[phase.color];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="grid gap-2 items-center"
|
||||
style={{ gridTemplateColumns: `140px repeat(${timeline.totalWeeks}, 1fr)` }}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground font-mono truncate pr-2">
|
||||
{i === 0 ? "OPZIONE A" : i === 1 ? "OPZIONE B" : "OPZIONE C"}
|
||||
<br />
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{phase.endWeek - phase.startWeek + 1} settimane
|
||||
</span>
|
||||
</div>
|
||||
{weeks.map((w) => {
|
||||
const active = w >= phase.startWeek && w <= phase.endWeek;
|
||||
const isFirst = w === phase.startWeek;
|
||||
const isLast = w === phase.endWeek;
|
||||
return (
|
||||
<div
|
||||
key={w}
|
||||
className={`h-10 ${active ? `${colors.bg} ${isFirst ? "rounded-l-md" : ""} ${isLast ? "rounded-r-md" : ""}` : ""} flex items-center ${isFirst ? "justify-start pl-3" : ""}`}
|
||||
>
|
||||
{isFirst && (
|
||||
<span className={`text-xs font-medium ${colors.text} whitespace-nowrap`}>
|
||||
{phase.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legenda */}
|
||||
<div className="flex items-center gap-6">
|
||||
{timeline.phases.map((phase, i) => {
|
||||
const colors = COLOR_MAP[phase.color];
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded-sm ${colors.bg}`} />
|
||||
<span className="text-xs text-muted-foreground">{phase.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
› {timeline.disclaimer}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { AssembledProposal } from "@/lib/proposal/assemble";
|
||||
import type { ProposalContent } from "@/lib/proposal/schema";
|
||||
|
||||
type Props = {
|
||||
vision: ProposalContent["vision"];
|
||||
header: AssembledProposal["header"];
|
||||
};
|
||||
|
||||
export function VisionSection({ vision, header }: Props) {
|
||||
const words = vision.headline.split(" ");
|
||||
const mid = Math.floor(words.length / 2);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-8">
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
|
||||
Dove andiamo
|
||||
</p>
|
||||
|
||||
<h2 className="text-5xl font-light leading-tight tracking-tight text-foreground">
|
||||
{words.slice(0, mid).join(" ")}{" "}
|
||||
<span className="text-primary">{words.slice(mid).join(" ")}</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-lg text-muted-foreground max-w-2xl leading-relaxed">
|
||||
{vision.body}
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{header.clientName} × {header.consultantName}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Phase 20: Knowledge Base Cliente — tabella transcript datati per lead/cliente
|
||||
-- Additive only. No drops/truncates (Data Safety LOCKED).
|
||||
-- Applicare a prod via SSH tunnel PRIMA di pushare il codice dipendente (D-03).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_transcripts (
|
||||
id text PRIMARY KEY NOT NULL,
|
||||
lead_id text REFERENCES leads(id) ON DELETE CASCADE,
|
||||
client_id text REFERENCES clients(id) ON DELETE CASCADE,
|
||||
title text,
|
||||
content text NOT NULL,
|
||||
call_date date NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS client_transcripts_lead_id_idx
|
||||
ON client_transcripts (lead_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS client_transcripts_client_id_idx
|
||||
ON client_transcripts (client_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS client_transcripts_call_date_idx
|
||||
ON client_transcripts (call_date DESC);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Phase 21/22: Proposals — preventivo AI generato + pagina pubblica navigabile.
|
||||
-- Additive only. No drops/truncates (Data Safety LOCKED).
|
||||
-- Applicare a prod via SSH tunnel PRIMA di pushare il codice dipendente.
|
||||
--
|
||||
-- content (jsonb): l'intero ProposalContent assemblato (sezioni AI + offerta + config).
|
||||
-- slug: identificatore pubblico URL-safe per /preventivo/[slug].
|
||||
-- selected_tier: 'A' | 'B' | 'C' scelto dal cliente in fase di accettazione.
|
||||
-- accepted_at: IMMUTABILE una volta valorizzato (audit, pattern quotes/deliverables).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proposals (
|
||||
id text PRIMARY KEY NOT NULL,
|
||||
slug text UNIQUE NOT NULL,
|
||||
lead_id text REFERENCES leads(id) ON DELETE SET NULL,
|
||||
client_id text REFERENCES clients(id) ON DELETE SET NULL,
|
||||
offer_macro_id text NOT NULL REFERENCES offer_macros(id) ON DELETE RESTRICT,
|
||||
title text,
|
||||
content jsonb NOT NULL,
|
||||
model text,
|
||||
state text NOT NULL DEFAULT 'draft', -- draft | published | accepted | rejected
|
||||
selected_tier text CHECK (selected_tier IN ('A', 'B', 'C')),
|
||||
accepted_at timestamp with time zone,
|
||||
client_email text,
|
||||
client_notes text,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS proposals_slug_idx ON proposals (slug);
|
||||
CREATE INDEX IF NOT EXISTS proposals_client_id_idx ON proposals (client_id);
|
||||
CREATE INDEX IF NOT EXISTS proposals_lead_id_idx ON proposals (lead_id);
|
||||
CREATE INDEX IF NOT EXISTS proposals_offer_macro_id_idx ON proposals (offer_macro_id);
|
||||
+84
-1
@@ -5,6 +5,7 @@ import {
|
||||
numeric,
|
||||
timestamp,
|
||||
boolean,
|
||||
jsonb,
|
||||
primaryKey,
|
||||
uniqueIndex,
|
||||
index,
|
||||
@@ -498,10 +499,66 @@ export const reminders = pgTable("reminders", {
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
// ============ CLIENT TRANSCRIPTS TABLE (Knowledge Base — Phase 20) ============
|
||||
// Transcript datati delle call, multipli per lead o cliente.
|
||||
// lead_id e client_id sono entrambi nullable (D-01): un transcript può appartenere
|
||||
// a un lead pre-conversione (Phase 20 UI) o a un cliente post-conversione (futuro).
|
||||
export const clientTranscripts = pgTable("client_transcripts", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
lead_id: text("lead_id")
|
||||
.references(() => leads.id, { onDelete: "cascade" }),
|
||||
client_id: text("client_id")
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
title: text("title"),
|
||||
content: text("content").notNull(),
|
||||
call_date: text("call_date").notNull(), // "YYYY-MM-DD" — coerente con <input type="date">
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
// ============ PROPOSALS TABLE (Preventivo AI — Phase 21/22) ============
|
||||
// Preventivo generato dall'agente AI e pubblicato come pagina pubblica
|
||||
// navigabile su /preventivo/[slug]. content (jsonb) contiene l'intero
|
||||
// ProposalContent assemblato (sezioni AI + offerta + config consulente).
|
||||
// lead_id/client_id nullable (ON DELETE SET NULL — non perdere la proposta se
|
||||
// il lead viene rimosso). offer_macro_id RESTRICT — la proposta riferisce
|
||||
// l'offerta sorgente. accepted_at IMMUTABILE una volta valorizzato.
|
||||
export const proposals = pgTable("proposals", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
slug: text("slug")
|
||||
.notNull()
|
||||
.unique()
|
||||
.$defaultFn(() => nanoid()),
|
||||
lead_id: text("lead_id").references(() => leads.id, { onDelete: "set null" }),
|
||||
client_id: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||
offer_macro_id: text("offer_macro_id")
|
||||
.notNull()
|
||||
.references(() => offer_macros.id, { onDelete: "restrict" }),
|
||||
title: text("title"),
|
||||
// Intero ProposalContent assemblato. Validato a runtime via Zod
|
||||
// (src/lib/proposal/schema.ts); jsonb gestito da postgres-js (parse automatico).
|
||||
content: jsonb("content").notNull(),
|
||||
model: text("model"), // es. "claude-opus-4-8"
|
||||
state: text("state").notNull().default("draft"), // draft | published | accepted | rejected
|
||||
selected_tier: text("selected_tier"), // 'A' | 'B' | 'C' — CHECK a livello SQL (migration 0010)
|
||||
accepted_at: timestamp("accepted_at", { withTimezone: true }), // immutable once set
|
||||
client_email: text("client_email"),
|
||||
client_notes: text("client_notes"),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ============ RELATIONS ============
|
||||
|
||||
export const clientsRelations = relations(clients, ({ many }) => ({
|
||||
projects: many(projects),
|
||||
transcripts: many(clientTranscripts),
|
||||
proposals: many(proposals),
|
||||
}));
|
||||
|
||||
export const projectsRelations = relations(projects, ({ one, many }) => ({
|
||||
@@ -632,6 +689,8 @@ export const leadsRelations = relations(leads, ({ many }) => ({
|
||||
quotes: many(quotes),
|
||||
activities: many(activities),
|
||||
reminders: many(reminders),
|
||||
transcripts: many(clientTranscripts),
|
||||
proposals: many(proposals),
|
||||
}));
|
||||
|
||||
export const activitiesRelations = relations(activities, ({ one }) => ({
|
||||
@@ -642,6 +701,17 @@ export const remindersRelations = relations(reminders, ({ one }) => ({
|
||||
lead: one(leads, { fields: [reminders.lead_id], references: [leads.id] }),
|
||||
}));
|
||||
|
||||
export const clientTranscriptsRelations = relations(clientTranscripts, ({ one }) => ({
|
||||
lead: one(leads, {
|
||||
fields: [clientTranscripts.lead_id],
|
||||
references: [leads.id],
|
||||
}),
|
||||
client: one(clients, {
|
||||
fields: [clientTranscripts.client_id],
|
||||
references: [clients.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const quotesRelations = relations(quotes, ({ one, many }) => ({
|
||||
lead: one(leads, { fields: [quotes.lead_id], references: [leads.id] }),
|
||||
client: one(clients, { fields: [quotes.client_id], references: [clients.id] }),
|
||||
@@ -649,6 +719,15 @@ export const quotesRelations = relations(quotes, ({ one, many }) => ({
|
||||
quoteItems: many(quote_items),
|
||||
}));
|
||||
|
||||
export const proposalsRelations = relations(proposals, ({ one }) => ({
|
||||
lead: one(leads, { fields: [proposals.lead_id], references: [leads.id] }),
|
||||
client: one(clients, { fields: [proposals.client_id], references: [clients.id] }),
|
||||
offerMacro: one(offer_macros, {
|
||||
fields: [proposals.offer_macro_id],
|
||||
references: [offer_macros.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// ============ TYPESCRIPT TYPES (for use in API routes and Server Components) ============
|
||||
|
||||
export type Client = typeof clients.$inferSelect;
|
||||
@@ -704,4 +783,8 @@ export type NewLead = typeof leads.$inferInsert;
|
||||
export type Activity = typeof activities.$inferSelect;
|
||||
export type NewActivity = typeof activities.$inferInsert;
|
||||
export type Reminder = typeof reminders.$inferSelect;
|
||||
export type NewReminder = typeof reminders.$inferInsert;
|
||||
export type NewReminder = typeof reminders.$inferInsert;
|
||||
export type ClientTranscript = typeof clientTranscripts.$inferSelect;
|
||||
export type NewClientTranscript = typeof clientTranscripts.$inferInsert;
|
||||
export type Proposal = typeof proposals.$inferSelect;
|
||||
export type NewProposal = typeof proposals.$inferInsert;
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
import { eq, and, isNull, desc, gte, lte, ilike, or } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { leads, activities, reminders, quotes } from "@/db/schema";
|
||||
import { leads, activities, reminders, quotes, clientTranscripts } from "@/db/schema";
|
||||
|
||||
// Get all leads
|
||||
export async function getAllLeads(limit: number = 100, offset: number = 0) {
|
||||
@@ -209,3 +209,13 @@ export async function getQuotesByLeadId(leadId: string) {
|
||||
.where(eq(quotes.lead_id, leadId))
|
||||
.orderBy(desc(quotes.created_at));
|
||||
}
|
||||
|
||||
// Get transcript log for a lead — call_date DESC (D-05)
|
||||
// Returns all fields including full content (Phase 21 reads them in bulk).
|
||||
export async function getTranscripts(leadId: string) {
|
||||
return await db
|
||||
.select()
|
||||
.from(clientTranscripts)
|
||||
.where(eq(clientTranscripts.lead_id, leadId))
|
||||
.orderBy(desc(clientTranscripts.call_date));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { ProposalContentSchema, type ProposalContent } from "./schema";
|
||||
import type { OfferEditorData } from "@/lib/offer-queries";
|
||||
import type { Client, Lead, ClientTranscript } from "@/db/schema";
|
||||
|
||||
const MODEL = "claude-opus-4-8";
|
||||
|
||||
type AgentInput = {
|
||||
// lead o cliente (uno dei due)
|
||||
lead?: Pick<Lead, "id" | "name" | "email" | "company" | "notes">;
|
||||
client?: Pick<Client, "id" | "name" | "brand_name" | "brief">;
|
||||
transcripts: Pick<ClientTranscript, "title" | "content" | "call_date">[];
|
||||
offer: OfferEditorData;
|
||||
};
|
||||
|
||||
function buildSystemPrompt(): string {
|
||||
return `Sei un consulente senior di brand e business strategy con 10+ anni di esperienza.
|
||||
Il tuo compito è analizzare i transcript di call discovery con un cliente e generare il
|
||||
contenuto di un preventivo professionale in italiano.
|
||||
|
||||
REGOLE FONDAMENTALI:
|
||||
- Usa SOLO informazioni reali estratte dai transcript. Non inventare problemi, soluzioni o citazioni.
|
||||
- Le citazioni (quote) nei nodi problema devono essere verbatim — parole esatte dette dal cliente.
|
||||
- I problemi devono emergere dalle parole del cliente, non da assunzioni tue.
|
||||
- Le soluzioni devono specchiare i problemi (stessa sequenza 01–05) e descrivere la trasformazione concreta.
|
||||
- Il tono è professionale ma diretto, mai generico. Usa il lessico del settore del cliente.
|
||||
- Non includere prezzi o importi nel contenuto generato — quelli vengono dal DB dell'offerta.
|
||||
- Rispondi SOLO con JSON valido, nessun testo extra prima o dopo.`;
|
||||
}
|
||||
|
||||
function buildUserPrompt(input: AgentInput): string {
|
||||
const subject = input.client
|
||||
? `Cliente: ${input.client.name} (brand: ${input.client.brand_name})\nBrief: ${input.client.brief}`
|
||||
: `Lead: ${input.lead?.name}${input.lead?.company ? ` — ${input.lead.company}` : ""}${input.lead?.notes ? `\nNote: ${input.lead.notes}` : ""}`;
|
||||
|
||||
const transcriptBlocks = input.transcripts
|
||||
.map(
|
||||
(t, i) =>
|
||||
`=== TRANSCRIPT ${i + 1} — ${t.call_date}${t.title ? ` (${t.title})` : ""} ===\n${t.content}`
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
const offerDescription = `Offerta: ${input.offer.macro.public_name}
|
||||
Descrizione: ${input.offer.macro.description ?? "—"}
|
||||
Promessa di trasformazione: ${input.offer.macro.transformation_promise ?? "—"}
|
||||
Tier disponibili: ${input.offer.tiers.map((t) => `${t.tier_letter} (${t.public_name})`).join(", ")}`;
|
||||
|
||||
return `${subject}
|
||||
|
||||
${offerDescription}
|
||||
|
||||
TRANSCRIPT DELLE CALL:
|
||||
${transcriptBlocks || "(nessun transcript disponibile — genera contenuto basato sul brief)"}
|
||||
|
||||
Genera il contenuto del preventivo come JSON. Segui ESATTAMENTE questo schema TypeScript:
|
||||
|
||||
{
|
||||
vision: { headline: string, body: string },
|
||||
problems: Array<{
|
||||
id: string, // "01" | "02" | ... | "05"
|
||||
title: string, // titolo della slide problema
|
||||
subtitle: string, // es. "LA VOCE" — tema macro
|
||||
body: string, // 2-3 frasi descrittive
|
||||
risk: string, // testo box "IL RISCHIO" — conseguenza se non si risolve
|
||||
quote: string, // citazione verbatim dal transcript
|
||||
quoteAttribution: string // "NOME · DISCOVERY YYYY-MM-DD"
|
||||
}>, // 3–5 problemi
|
||||
problemSynthesis: {
|
||||
rootCause: string, // la domanda strategica comune
|
||||
nodeLabels: string[] // 5 label brevi per il diagramma
|
||||
},
|
||||
solutions: Array<{
|
||||
id: string, // specchia il problem node
|
||||
title: string,
|
||||
subtitle: string, // es. "SOLUZIONE 01 · LA VOCE"
|
||||
transformation: string, // testo box "LA TRASFORMAZIONE"
|
||||
throughWhat: string[] // 3 bullet "ATTRAVERSO COSA"
|
||||
}>,
|
||||
solutionSynthesis: {
|
||||
direction: string,
|
||||
elevations: Array<{ id: string, label: string }> // 5 elementi
|
||||
},
|
||||
scope: {
|
||||
scopeTitle: string,
|
||||
scopeBody: string,
|
||||
objectives: string[] // 3–5 obiettivi misurabili
|
||||
},
|
||||
deliverables: {
|
||||
deliverables: string[], // output principali (tier-agnostic)
|
||||
outOfScope: string[] // 3–5 elementi esclusi
|
||||
},
|
||||
timeline: {
|
||||
totalWeeks: number,
|
||||
phases: Array<{
|
||||
label: string,
|
||||
startWeek: number,
|
||||
endWeek: number,
|
||||
color: "cyan" | "purple" | "green"
|
||||
}>,
|
||||
disclaimer: string
|
||||
},
|
||||
stagesRecap: {
|
||||
stages: Array<{
|
||||
number: string, // "01" | "02" | "03"
|
||||
tierLetter: string, // "A" | "B" | "C"
|
||||
label: string, // es. "OPZIONE A – AUDIT"
|
||||
headline: string, // es. "Si capisce."
|
||||
body: string,
|
||||
deliverable: string
|
||||
}>
|
||||
},
|
||||
comparisonMatrix: Array<{
|
||||
component: string,
|
||||
description: string,
|
||||
inA: boolean,
|
||||
inB: boolean,
|
||||
inC: boolean
|
||||
}> // 4–10 righe
|
||||
}`;
|
||||
}
|
||||
|
||||
export async function generateProposalContent(
|
||||
input: AgentInput
|
||||
): Promise<{ content: ProposalContent; rawJson: string }> {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) throw new Error("ANTHROPIC_API_KEY non configurata");
|
||||
|
||||
const client = new Anthropic({ apiKey });
|
||||
|
||||
const message = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 8192,
|
||||
system: buildSystemPrompt(),
|
||||
messages: [{ role: "user", content: buildUserPrompt(input) }],
|
||||
});
|
||||
|
||||
const rawText =
|
||||
message.content[0].type === "text" ? message.content[0].text : "";
|
||||
|
||||
// Estrae il JSON dalla risposta (gestisce eventuale wrapping in ```)
|
||||
const jsonMatch = rawText.match(/```(?:json)?\s*([\s\S]*?)\s*```/) ||
|
||||
rawText.match(/(\{[\s\S]*\})/);
|
||||
const jsonStr = jsonMatch ? jsonMatch[1] : rawText;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
throw new Error(`L'AI ha prodotto JSON non valido: ${rawText.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const result = ProposalContentSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Contenuto AI non valido: ${result.error.issues.map((i) => i.message).join("; ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return { content: result.data, rawJson: jsonStr };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { ProposalContent } from "./schema";
|
||||
import type { OfferEditorData } from "@/lib/offer-queries";
|
||||
import { CONSULTANT_PROFILE } from "./profile";
|
||||
import type { Client, Lead } from "@/db/schema";
|
||||
|
||||
// Struttura completa salvata in proposals.content (jsonb)
|
||||
export type AssembledProposal = {
|
||||
// Metadati di generazione
|
||||
generatedAt: string; // ISO string
|
||||
model: string;
|
||||
|
||||
// Intestazione (cover + vision)
|
||||
header: {
|
||||
clientName: string; // es. "Yeldo"
|
||||
consultantName: string;
|
||||
offerTitle: string;
|
||||
offerDescription: string | null;
|
||||
date: string; // "DD MMM YYYY" formattata in italiano
|
||||
validityDays: number;
|
||||
};
|
||||
|
||||
// Sezioni generate dall'AI
|
||||
ai: ProposalContent;
|
||||
|
||||
// Profilo consulente (snapshot al momento della generazione)
|
||||
consultant: typeof CONSULTANT_PROFILE;
|
||||
|
||||
// Dati offerta dal DB (snapshot — prezzi al momento della generazione)
|
||||
offer: {
|
||||
macroId: string;
|
||||
publicName: string;
|
||||
tiers: Array<{
|
||||
id: string;
|
||||
tierLetter: string;
|
||||
publicName: string;
|
||||
publicPrice: string | null;
|
||||
servicesTotal: string;
|
||||
durationMonths: number;
|
||||
services: Array<{ id: string; name: string; unitPrice: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type AssembleInput = {
|
||||
lead?: Pick<Lead, "id" | "name" | "company">;
|
||||
client?: Pick<Client, "id" | "name" | "brand_name">;
|
||||
offer: OfferEditorData;
|
||||
aiContent: ProposalContent;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export function assembleProposal(input: AssembleInput): AssembledProposal {
|
||||
const clientName =
|
||||
input.client?.brand_name ?? input.lead?.company ?? input.lead?.name ?? "Cliente";
|
||||
|
||||
const offerTitle = input.offer.macro.public_name;
|
||||
|
||||
// Formatta data in italiano
|
||||
const now = new Date();
|
||||
const date = now.toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
// Snapshot servizi per tier (solo quelli assegnati)
|
||||
const tiers = input.offer.tiers.map((tier) => {
|
||||
const assignedServices = input.offer.availableServices.filter((s) =>
|
||||
tier.assignedServiceIds.includes(s.id)
|
||||
);
|
||||
return {
|
||||
id: tier.id,
|
||||
tierLetter: tier.tier_letter ?? "",
|
||||
publicName: tier.public_name,
|
||||
publicPrice: tier.public_price,
|
||||
servicesTotal: tier.servicesTotal,
|
||||
durationMonths: tier.duration_months,
|
||||
services: assignedServices.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
unitPrice: s.unit_price,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
model: input.model,
|
||||
header: {
|
||||
clientName,
|
||||
consultantName: CONSULTANT_PROFILE.name,
|
||||
offerTitle,
|
||||
offerDescription: input.offer.macro.description ?? null,
|
||||
date,
|
||||
validityDays: CONSULTANT_PROFILE.legal.validityDays,
|
||||
},
|
||||
ai: input.aiContent,
|
||||
consultant: CONSULTANT_PROFILE,
|
||||
offer: {
|
||||
macroId: input.offer.macro.id,
|
||||
publicName: offerTitle,
|
||||
tiers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Profilo consulente iamcavalli — fonte di verità per tutte le proposte.
|
||||
// Editing = modifica diretta di questo file (v1, nessuna UI di gestione).
|
||||
// Foto e immagini via URL esterno (no file hosting v1).
|
||||
|
||||
export type Testimonial = {
|
||||
name: string;
|
||||
role: string;
|
||||
quote: string;
|
||||
photoUrl?: string; // URL esterno, opzionale
|
||||
};
|
||||
|
||||
export type ConsultantFact = {
|
||||
value: string; // es. "20+"
|
||||
label: string; // es. "ANNI DI ESPERIENZA"
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type NextStep = {
|
||||
number: string; // "01" | "02" | "03"
|
||||
text: string;
|
||||
highlightedPart?: string; // parola/data da evidenziare in verde
|
||||
};
|
||||
|
||||
export type ConsultantProfile = {
|
||||
name: string;
|
||||
title: string;
|
||||
bio: string;
|
||||
photoUrl?: string;
|
||||
credentials: string[];
|
||||
facts: [ConsultantFact, ConsultantFact, ConsultantFact]; // esattamente 3
|
||||
contact: {
|
||||
email: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
};
|
||||
// Boilerplate legale e termini — appaiono nella slide "Come si parte"
|
||||
legal: {
|
||||
revisions: string;
|
||||
ip: string;
|
||||
nda: string;
|
||||
forum: string;
|
||||
validityDays: number;
|
||||
acceptanceInstructions: string;
|
||||
};
|
||||
// Passi "Come procedere" (slide finale)
|
||||
nextSteps: [NextStep, NextStep, NextStep];
|
||||
// Testimonial grid (slide 07–09) — mostra tutte, la pagina le impagina in griglie da 9
|
||||
testimonials: Testimonial[];
|
||||
};
|
||||
|
||||
export const CONSULTANT_PROFILE: ConsultantProfile = {
|
||||
name: "Simone Cavalli",
|
||||
title: "Strategist · Brand & Business",
|
||||
bio: "Consulente di personal branding con oltre 10 anni di esperienza nell'aiutare professionisti e aziende a costruire una presenza autorevole e un posizionamento distintivo. Lavoro con founder, manager e liberi professionisti che vogliono trasformare la loro competenza in un brand riconoscibile.",
|
||||
photoUrl: undefined, // → aggiungere URL esterno (es. CDN o Google Drive public)
|
||||
credentials: [
|
||||
"Fondatore di iamcavalli — consulenza di personal branding",
|
||||
"Autore di percorsi formativi su posizionamento e comunicazione",
|
||||
"Ha lavorato con oltre 100 professionisti in Italia",
|
||||
// → aggiungere altre credenziali reali
|
||||
],
|
||||
facts: [
|
||||
{
|
||||
value: "10+",
|
||||
label: "ANNI DI ESPERIENZA",
|
||||
description:
|
||||
"Anni di consulenza strategica su brand personale e business: formazione, consulenza diretta a professionisti e team in scaling.",
|
||||
},
|
||||
{
|
||||
value: "100+",
|
||||
label: "PROFESSIONISTI SEGUITI",
|
||||
description:
|
||||
"Founder, manager e liberi professionisti trasformati in brand riconoscibili nel loro settore.",
|
||||
},
|
||||
{
|
||||
value: "3",
|
||||
label: "PILASTRI DEL METODO",
|
||||
description:
|
||||
"Posizionamento, Comunicazione, Sistema: il percorso strutturato che porta da 'bravo a fare' a 'riconosciuto come il migliore'.",
|
||||
},
|
||||
],
|
||||
contact: {
|
||||
email: "simone@iamcavalli.com", // → verificare email pubblica corretta
|
||||
phone: undefined, // → aggiungere se necessario
|
||||
website: "iamcavalli.com",
|
||||
},
|
||||
legal: {
|
||||
revisions: "Revisioni illimitate entro lo scope concordato.",
|
||||
ip: "Diritti di proprietà intellettuale trasferiti al cliente alla consegna finale.",
|
||||
nda: "NDA reciproco standard a copertura del progetto.",
|
||||
forum: "Foro competente Milano.",
|
||||
validityDays: 14,
|
||||
acceptanceInstructions:
|
||||
"Email di accettazione formale con indicazione del tier scelto. La mail vale come accordo commerciale e attiva la tranche iniziale di pagamento.",
|
||||
},
|
||||
nextSteps: [
|
||||
{
|
||||
number: "01",
|
||||
text: "Conferma del prezzo bloccato entro 7 giorni dall'invio per garantire il pricing proposto.",
|
||||
highlightedPart: undefined, // → sarà calcolato dinamicamente dalla data di pubblicazione
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
text: "Accettazione formale del tier scelto via email entro la scadenza indicata.",
|
||||
highlightedPart: undefined,
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
text: "Se utile prima di scegliere, una call di Q&A (30 min) per chiarimenti sulla proposta e definizione della data di inizio lavori.",
|
||||
highlightedPart: "call di Q&A",
|
||||
},
|
||||
],
|
||||
testimonials: [
|
||||
// → Aggiungere testimonianze reali. Formato:
|
||||
// { name: "Nome Cognome", role: "Professione", quote: "Citazione..." }
|
||||
{
|
||||
name: "Cliente 1",
|
||||
role: "Professione",
|
||||
quote: "→ Aggiungere testimonianza reale.",
|
||||
},
|
||||
{
|
||||
name: "Cliente 2",
|
||||
role: "Professione",
|
||||
quote: "→ Aggiungere testimonianza reale.",
|
||||
},
|
||||
{
|
||||
name: "Cliente 3",
|
||||
role: "Professione",
|
||||
quote: "→ Aggiungere testimonianza reale.",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { proposals, leads, clients, offer_macros } from "@/db/schema";
|
||||
import type { AssembledProposal } from "./assemble";
|
||||
|
||||
export type ProposalListItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string | null;
|
||||
state: string;
|
||||
leadId: string | null;
|
||||
leadName: string | null;
|
||||
clientId: string | null;
|
||||
clientName: string | null;
|
||||
offerMacroId: string;
|
||||
offerName: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export async function listProposals(): Promise<ProposalListItem[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: proposals.id,
|
||||
slug: proposals.slug,
|
||||
title: proposals.title,
|
||||
state: proposals.state,
|
||||
leadId: proposals.lead_id,
|
||||
leadName: leads.name,
|
||||
clientId: proposals.client_id,
|
||||
clientName: clients.name,
|
||||
offerMacroId: proposals.offer_macro_id,
|
||||
offerName: offer_macros.public_name,
|
||||
createdAt: proposals.created_at,
|
||||
updatedAt: proposals.updated_at,
|
||||
})
|
||||
.from(proposals)
|
||||
.leftJoin(leads, eq(proposals.lead_id, leads.id))
|
||||
.leftJoin(clients, eq(proposals.client_id, clients.id))
|
||||
.leftJoin(offer_macros, eq(proposals.offer_macro_id, offer_macros.id))
|
||||
.orderBy(desc(proposals.created_at));
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
slug: r.slug,
|
||||
title: r.title,
|
||||
state: r.state,
|
||||
leadId: r.leadId,
|
||||
leadName: r.leadName,
|
||||
clientId: r.clientId,
|
||||
clientName: r.clientName,
|
||||
offerMacroId: r.offerMacroId,
|
||||
offerName: r.offerName ?? "",
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export type ProposalDetail = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string | null;
|
||||
state: string;
|
||||
leadId: string | null;
|
||||
clientId: string | null;
|
||||
offerMacroId: string;
|
||||
model: string | null;
|
||||
selectedTier: string | null;
|
||||
acceptedAt: Date | null;
|
||||
clientEmail: string | null;
|
||||
clientNotes: string | null;
|
||||
content: AssembledProposal;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export async function getProposalById(id: string): Promise<ProposalDetail | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(proposals)
|
||||
.where(eq(proposals.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
return mapRow(row);
|
||||
}
|
||||
|
||||
export async function getProposalBySlug(slug: string): Promise<ProposalDetail | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(proposals)
|
||||
.where(eq(proposals.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
return mapRow(row);
|
||||
}
|
||||
|
||||
function mapRow(row: typeof proposals.$inferSelect): ProposalDetail {
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
state: row.state,
|
||||
leadId: row.lead_id,
|
||||
clientId: row.client_id,
|
||||
offerMacroId: row.offer_macro_id,
|
||||
model: row.model,
|
||||
selectedTier: row.selected_tier,
|
||||
acceptedAt: row.accepted_at,
|
||||
clientEmail: row.client_email,
|
||||
clientNotes: row.client_notes,
|
||||
content: row.content as AssembledProposal,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ── Schema delle sezioni generate dall'AI ──────────────────────────────────
|
||||
// Le sezioni da OFFER DB (pricing tier) e CONFIG (bio/testimonianze) NON
|
||||
// sono prodotte dall'AI e non fanno parte di questo schema.
|
||||
|
||||
export const ProblemNodeSchema = z.object({
|
||||
id: z.string(), // "01" | "02" | ... | "05"
|
||||
title: z.string(), // es. "La voce non è una. Sono tre."
|
||||
subtitle: z.string(), // es. "LA VOCE"
|
||||
body: z.string(), // paragrafo descrittivo
|
||||
risk: z.string(), // testo del box "IL RISCHIO"
|
||||
quote: z.string(), // citazione verbatim dal transcript
|
||||
quoteAttribution: z.string(), // "NOME COGNOME · DISCOVERY YYYY-MM-DD"
|
||||
});
|
||||
|
||||
export const SolutionNodeSchema = z.object({
|
||||
id: z.string(), // specchia il problem node
|
||||
title: z.string(), // es. "Tre voci coordinate, un solo brand."
|
||||
subtitle: z.string(), // es. "SOLUZIONE 01 · LA VOCE"
|
||||
transformation: z.string(), // testo del box "LA TRASFORMAZIONE"
|
||||
throughWhat: z.array(z.string()), // bullet "ATTRAVERSO COSA" (3 voci)
|
||||
});
|
||||
|
||||
export const DiagramNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
});
|
||||
|
||||
export const ScopeSectionSchema = z.object({
|
||||
scopeTitle: z.string(),
|
||||
scopeBody: z.string(),
|
||||
objectives: z.array(z.string()), // 3–5 bullet con "checkmark" visivo
|
||||
});
|
||||
|
||||
export const DeliverablesSectionSchema = z.object({
|
||||
deliverables: z.array(z.string()), // elenco output finali (tier-agnostic, macro)
|
||||
outOfScope: z.array(z.string()), // ciò che NON è incluso
|
||||
});
|
||||
|
||||
export const TimelineSectionSchema = z.object({
|
||||
totalWeeks: z.number(),
|
||||
phases: z.array(z.object({
|
||||
label: z.string(), // es. "Audit Strategico"
|
||||
startWeek: z.number(),
|
||||
endWeek: z.number(),
|
||||
color: z.enum(["cyan", "purple", "green"]), // palette fissa light-mode
|
||||
})),
|
||||
disclaimer: z.string(),
|
||||
});
|
||||
|
||||
export const StagesRecapSchema = z.object({
|
||||
stages: z.array(z.object({
|
||||
number: z.string(), // "01" | "02" | "03"
|
||||
tierLetter: z.string(), // "A" | "B" | "C"
|
||||
label: z.string(), // es. "OPZIONE A – AUDIT"
|
||||
headline: z.string(), // es. "Si capisce."
|
||||
body: z.string(),
|
||||
deliverable: z.string(), // es. "Audit decision document"
|
||||
})),
|
||||
});
|
||||
|
||||
export const ComparisonRowSchema = z.object({
|
||||
component: z.string(), // es. "Brand Architecture System"
|
||||
description: z.string(), // sottotitolo
|
||||
inA: z.boolean(),
|
||||
inB: z.boolean(),
|
||||
inC: z.boolean(),
|
||||
});
|
||||
|
||||
// Root schema — contenuto generato dall'AI
|
||||
export const ProposalContentSchema = z.object({
|
||||
// Slide "dove andiamo" (02)
|
||||
vision: z.object({
|
||||
headline: z.string(), // es. "Tre voci. Un solo brand."
|
||||
body: z.string(), // paragrafo breve
|
||||
}),
|
||||
|
||||
// Cap.02 — 5 nodi problema estratti dai transcript
|
||||
problems: z.array(ProblemNodeSchema).min(3).max(5),
|
||||
|
||||
// Sintesi problema (5 nodi → 1 radice)
|
||||
problemSynthesis: z.object({
|
||||
rootCause: z.string(), // la domanda strategica non ancora risolta
|
||||
nodeLabels: z.array(z.string()).length(5), // label per il diagramma
|
||||
}),
|
||||
|
||||
// Cap.03 — soluzioni mirror
|
||||
solutions: z.array(SolutionNodeSchema).min(3).max(5),
|
||||
|
||||
// Sintesi soluzione (1 direzione → 5 elevazioni)
|
||||
solutionSynthesis: z.object({
|
||||
direction: z.string(),
|
||||
elevations: z.array(DiagramNodeSchema).length(5),
|
||||
}),
|
||||
|
||||
// Cap.04 — scope, deliverable, timeline
|
||||
scope: ScopeSectionSchema,
|
||||
deliverables: DeliverablesSectionSchema,
|
||||
timeline: TimelineSectionSchema,
|
||||
|
||||
// Slide di recap (3 tappe progressive)
|
||||
stagesRecap: StagesRecapSchema,
|
||||
|
||||
// Tabella comparativa componenti × A/B/C
|
||||
comparisonMatrix: z.array(ComparisonRowSchema).min(4).max(10),
|
||||
});
|
||||
|
||||
export type ProposalContent = z.infer<typeof ProposalContentSchema>;
|
||||
export type ProblemNode = z.infer<typeof ProblemNodeSchema>;
|
||||
export type SolutionNode = z.infer<typeof SolutionNodeSchema>;
|
||||
export type ComparisonRow = z.infer<typeof ComparisonRowSchema>;
|
||||
Reference in New Issue
Block a user