docs(planning): archivia v2.1/v2.2/v2.3 e documenta v2.4

.planning/ documentava in dettaglio cio che era vecchio e per niente cio
che e in produzione: le fasi 11-22 (v2.1 e v2.2, chiuse a giugno) erano
ancora in phases/ mentre v1.0 e v2.0 stavano gia in milestones/, e il
lavoro degli ultimi due mesi - gate OTP e ciclo di vita dei retainer, cioe
quello che gira su hub.iamcavalli.net - non aveva nessuna cartella.

- phases/{11,12,14} -> milestones/v2.1-phases/, phases/{18..22} ->
  milestones/v2.2-phases/. Ora phases/ contiene solo la milestone in
  corso, che e quello che state.cjs conta per il progresso
- v2.1-ROADMAP.md ricostruito: era l'unica milestone senza archivio,
  interrotta dal reset del 19/06 e mai chiusa formalmente
- v2.3-ROADMAP.md + v2.3-REQUIREMENTS.md: v2.3 e stata eseguita fuori dal
  ciclo GSD, non esistono PLAN/SUMMARY per fase. L'archivio E la doc
- REQUIREMENTS.md riscritto per v2.4 con il backlog reale
- phases/13 e phases/26: SUMMARY ricostruiti da commit, migration e
  STATUS.md. 26 e il primo numero libero
- research/: cancellate 4 varianti dello stesso PITFALLS e FEATURES/
  SUMMARY, superati da PROJECT.md. Diverse anti-feature erano ormai
  contraddette dai fatti (il Kanban e stato costruito in Phase 19,
  l'email in v2.3, il time tracking esiste)
- cancellati UI-RULES.md e DESIGN-SYSTEM.md (CLAUDE.md li dichiara
  superseded: impongono l'inverso della regola attuale) e HANDOFF.md,
  fermo al 13/06
- SECURITY-*.md -> security/: audit chiuso, ma i report restano la doc di
  cosa e stato ruotato e perche
- PROJECT.md/MILESTONES.md/ROADMAP.md allineati: milestone corrente v2.4,
  sessione OTP 90gg non 30, migrazioni fino alla 0016

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 22:38:36 +02:00
parent 187550fedf
commit f7eb7eec23
86 changed files with 1659 additions and 2859 deletions
@@ -0,0 +1,233 @@
---
phase: 18-cleanup-consolidamento
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/components/admin/AdminSidebar.tsx
- src/app/admin/forecast/page.tsx
- src/app/admin/quotes/new/page.tsx
- src/components/admin/leads/SendQuoteModal.tsx
- src/app/admin/projects/project-actions.ts
autonomous: true
requirements:
- CLEAN-01
- CLEAN-02
must_haves:
truths:
- "La rotta /admin/forecast non è più raggiungibile (404 o redirect)"
- "La voce 'Forecast' non appare più nella sidebar admin"
- "La rotta /admin/quotes/new non è più raggiungibile"
- "SendQuoteModal non ha più il bottone che naviga a /admin/quotes/new"
- "project-actions.ts non chiama più revalidatePath('/admin/forecast')"
artifacts:
- path: "src/components/admin/AdminSidebar.tsx"
provides: "Sidebar senza voce Forecast"
contains: "NON deve contenere href: \"/admin/forecast\""
- path: "src/app/admin/forecast/page.tsx"
provides: "File eliminato (rotta rimossa)"
- path: "src/app/admin/quotes/new/page.tsx"
provides: "File eliminato (rotta rimossa)"
- path: "src/components/admin/leads/SendQuoteModal.tsx"
provides: "Modal senza bottone navigazione a quotes/new"
- path: "src/app/admin/projects/project-actions.ts"
provides: "Server actions senza revalidatePath forecast"
key_links:
- from: "AdminSidebar.tsx NAV_ITEMS"
to: "/admin/forecast"
via: "href nel NAV_ITEMS array"
pattern: "forecast"
- from: "SendQuoteModal.tsx"
to: "/admin/quotes/new"
via: "window.location.href"
pattern: "quotes/new"
---
<objective>
Rimuovere le rotte /admin/forecast e /admin/quotes/new e tutti i loro entry point.
Purpose: CLEAN-01 e CLEAN-02 — eliminare feature obsolete prima del Sales Loop. Il forecast è sostituito dal loop AI; il quote builder manuale è sostituito dall'agente AI in Phase 21.
Output: Due rotte rimosse, sidebar pulita, SendQuoteModal senza entry point obsoleto, project-actions.ts senza revalidatePath forecast.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Rimuovi rotta Forecast — file, sidebar, revalidatePath (CLEAN-01)</name>
<read_first>
- src/components/admin/AdminSidebar.tsx — array NAV_ITEMS, riga con href: "/admin/forecast"
- src/app/admin/forecast/page.tsx — conferma contenuto prima di eliminare
- src/app/admin/projects/project-actions.ts — righe 138, 145, 161 con revalidatePath("/admin/forecast")
</read_first>
<files>
src/components/admin/AdminSidebar.tsx,
src/app/admin/projects/project-actions.ts
</files>
<action>
1. In AdminSidebar.tsx: rimuovi la riga dell'oggetto Forecast dall'array NAV_ITEMS:
`{ href: "/admin/forecast", label: "Forecast", icon: TrendingUp },`
Rimuovi anche l'import `TrendingUp` da lucide-react se non è usato altrove nel file.
2. In project-actions.ts: rimuovi le tre chiamate `revalidatePath("/admin/forecast")` alle righe ~138, ~145, ~161.
Ogni funzione che la contiene (addProjectOffer, removeProjectOffer, updateProjectOfferTotal)
mantiene le proprie altre revalidatePath (es. `/admin/projects/${projectId}`) — togli SOLO le righe forecast.
3. Elimina il file della rotta forecast:
- src/app/admin/forecast/page.tsx
Elimina anche la directory se è rimasta vuota:
- src/app/admin/forecast/ (directory)
NON toccare: tabelle DB, lib/forecast-queries.ts (deadweight tollerabile, non blocca nulla),
nessun'altra pagina o componente.
</action>
<verify>
<automated>
grep -r "admin/forecast" /Users/simonecavalli/Vault/IAMCAVALLI/src --include="*.tsx" --include="*.ts" | grep -v "node_modules" | grep -v ".next" | wc -l
</automated>
Risultato atteso: 0 righe (nessun riferimento a /admin/forecast rimasto nel codice sorgente).
Verifica aggiuntiva sidebar:
grep "TrendingUp\|forecast" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/AdminSidebar.tsx
Risultato atteso: nessun output (zero match).
</verify>
<acceptance_criteria>
- grep -r "admin/forecast" src/ produce 0 risultati (esclusi node_modules e .next)
- Il file src/app/admin/forecast/page.tsx non esiste più
- AdminSidebar.tsx non contiene la stringa "forecast" né "TrendingUp" (se rimosso)
- project-actions.ts non contiene revalidatePath("/admin/forecast")
</acceptance_criteria>
<done>
La voce Forecast è assente dalla sidebar, la rotta /admin/forecast è 404, nessun server action
chiama revalidatePath su quella rotta.
</done>
</task>
<task type="auto">
<name>Task 2: Rimuovi rotta Quote Builder manuale e il suo entry point (CLEAN-02)</name>
<read_first>
- src/app/admin/quotes/new/page.tsx — conferma contenuto prima di eliminare
- src/components/admin/leads/SendQuoteModal.tsx — riga ~120 con window.location.href = /admin/quotes/new
- src/components/admin/quotes/QuoteBuilderForm.tsx — identifica che è usato solo da quotes/new
- src/components/admin/quotes/OfferSelector.tsx — identifica che è usato solo da QuoteBuilderForm
- src/components/admin/quotes/PriceOverrideInput.tsx — stesso
- src/components/admin/quotes/QuotePreview.tsx — stesso
</read_first>
<files>
src/app/admin/quotes/new/page.tsx,
src/components/admin/leads/SendQuoteModal.tsx,
src/components/admin/quotes/QuoteBuilderForm.tsx,
src/components/admin/quotes/OfferSelector.tsx,
src/components/admin/quotes/PriceOverrideInput.tsx,
src/components/admin/quotes/QuotePreview.tsx
</files>
<action>
1. In SendQuoteModal.tsx: trova il blocco che naviga a /admin/quotes/new:
`window.location.href = /admin/quotes/new?lead_id=${leadId};`
Rimuovi il pulsante / branch che lo contiene. Se il modal ha una logica "crea nuovo preventivo"
che porta a quella rotta, rimuovi quella branch. Mantieni tutto il resto del modal intatto
(il flusso "preventivo esistente" non va toccato).
Prima di modificare, leggi il file per capire la struttura esatta del branch da eliminare.
2. Elimina i file della rotta e i componenti esclusivi:
- src/app/admin/quotes/new/page.tsx
- src/components/admin/quotes/QuoteBuilderForm.tsx
- src/components/admin/quotes/OfferSelector.tsx
- src/components/admin/quotes/PriceOverrideInput.tsx
- src/components/admin/quotes/QuotePreview.tsx
Elimina le directory rimaste vuote:
- src/app/admin/quotes/new/ (directory)
- src/components/admin/quotes/ (directory, solo se completamente vuota dopo la rimozione)
NON toccare: src/app/admin/quotes/ se esistono altre sotto-rotte (verifica prima),
lib/admin-queries.ts (getAllOfferMacrosWithMicros — usata altrove), DB.
ATTENZIONE — verifica preventiva: prima di eliminare src/components/admin/quotes/,
esegui: grep -r "quotes/" src/components --include="*.tsx" --include="*.ts"
per confermare che i quattro componenti sopra non sono importati da altri file al di fuori di quotes/new.
Se ci sono altri consumer, NON eliminare il componente e segnala in SUMMARY.
</action>
<verify>
<automated>
grep -r "quotes/new" /Users/simonecavalli/Vault/IAMCAVALLI/src --include="*.tsx" --include="*.ts" | grep -v "node_modules" | grep -v ".next" | wc -l
</automated>
Risultato atteso: 0 righe.
Verifica esistenza file:
test -f /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/quotes/new/page.tsx && echo "ESISTE_ANCORA" || echo "OK_RIMOSSO"
Risultato atteso: OK_RIMOSSO
Verifica SendQuoteModal:
grep "quotes/new" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/SendQuoteModal.tsx
Risultato atteso: nessun output.
</verify>
<acceptance_criteria>
- grep -r "quotes/new" src/ produce 0 risultati
- src/app/admin/quotes/new/page.tsx non esiste più
- src/components/admin/quotes/QuoteBuilderForm.tsx non esiste più
- SendQuoteModal.tsx non contiene la stringa "quotes/new"
</acceptance_criteria>
<done>
La rotta /admin/quotes/new è 404, il pulsante di navigazione nel SendQuoteModal è rimosso,
i componenti esclusivi del quote builder sono eliminati.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin UI → file system | Operazioni di eliminazione file irreversibili |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-01 | Tampering | project-actions.ts | accept | Rimozione di sole righe revalidatePath inerte — nessun path di dati toccato; le funzioni restano funzionali |
| T-18-02 | Denial of Service | SendQuoteModal.tsx | mitigate | Leggere il file prima di modificare per evitare di rompere il flusso "preventivo esistente" che resta necessario |
</threat_model>
<verification>
Al termine del piano:
1. `npm run build` deve completare senza errori TypeScript (nessun import rotto)
2. `grep -r "admin/forecast" src/` → 0 risultati
3. `grep -r "quotes/new" src/` → 0 risultati
4. La sidebar admin non mostra la voce "Forecast"
</verification>
<success_criteria>
- CLEAN-01: /admin/forecast → 404, sidebar senza voce Forecast, project-actions.ts senza revalidatePath forecast
- CLEAN-02: /admin/quotes/new → 404, SendQuoteModal senza navigazione a quotes/new, componenti quotes/ rimossi
- Build TypeScript pulita (nessun import broken)
</success_criteria>
<output>
After completion, create `.planning/phases/18-cleanup-consolidamento/18-01-SUMMARY.md`
</output>
@@ -0,0 +1,156 @@
---
phase: 18-cleanup-consolidamento
plan: "01"
subsystem: ui
tags: [next.js, admin, cleanup, sidebar, server-actions]
requires: []
provides:
- Forecast route removed (/admin/forecast → 404)
- Admin sidebar without Forecast entry
- project-actions.ts without revalidatePath forecast calls
- Quote builder route removed (/admin/quotes/new → 404)
- SendQuoteModal without navigation to quotes/new
- QuoteBuilderForm, OfferSelector, PriceOverrideInput, QuotePreview components deleted
affects: [phase-21-ai-quote-agent, any future admin nav changes]
tech-stack:
added: []
patterns:
- "Dead-code deletion: remove page files + directories + all entry points atomically before Sales Loop phase"
key-files:
created: []
modified:
- src/components/admin/AdminSidebar.tsx
- src/app/admin/projects/project-actions.ts
- src/components/admin/leads/SendQuoteModal.tsx
- src/lib/admin-queries.ts
deleted:
- src/app/admin/forecast/page.tsx
- src/app/admin/quotes/new/page.tsx
- src/app/admin/quotes/new/actions.ts
- src/components/admin/quotes/QuoteBuilderForm.tsx
- src/components/admin/quotes/OfferSelector.tsx
- src/components/admin/quotes/PriceOverrideInput.tsx
- src/components/admin/quotes/QuotePreview.tsx
key-decisions:
- "Kept lib/forecast-queries.ts and lib/quote-actions.ts as tolerable deadweight — no broken imports, no consumer"
- "SendQuoteModal Tabs UI replaced with flat single-form layout (only 'existing' flow remains)"
- "quotes/new/actions.ts (re-export shim) deleted along with page since its only consumer was QuoteBuilderForm"
- "Stale JSDoc comment in admin-queries.ts referencing /admin/quotes/new cleaned up (Rule 1 auto-fix)"
patterns-established:
- "Cleanup tasks: always grep for all entry points (revalidatePath, navigation, JSDoc) before marking done"
requirements-completed: [CLEAN-01, CLEAN-02]
duration: 12min
completed: 2026-06-19
---
# Phase 18 Plan 01: Cleanup — Remove Forecast and Manual Quote Builder Routes Summary
**Deleted /admin/forecast and /admin/quotes/new routes with all entry points (sidebar, revalidatePath, navigation button, exclusive components) to clear the codebase for the Sales Loop AI phase.**
## Performance
- **Duration:** ~12 min
- **Started:** 2026-06-19T10:04:00Z
- **Completed:** 2026-06-19T10:16:00Z
- **Tasks:** 2
- **Files modified/deleted:** 11
## Accomplishments
- Removed Forecast route: deleted `src/app/admin/forecast/page.tsx`, removed sidebar nav entry and `TrendingUp` import, stripped 3 `revalidatePath("/admin/forecast")` calls from project-actions.ts
- Removed Quote Builder route: deleted `quotes/new/page.tsx`, `quotes/new/actions.ts`, and all 4 exclusive components (QuoteBuilderForm, OfferSelector, PriceOverrideInput, QuotePreview)
- Simplified SendQuoteModal from two-tab layout to single flat form — "Crea Nuovo" tab and `window.location` navigation removed entirely; "existing quote" flow unchanged
- Build passes clean: 0 TypeScript errors, 0 broken imports
## Task Commits
1. **Task 1: Rimuovi rotta Forecast** - `7d88409` (chore)
2. **Task 2: Rimuovi rotta Quote Builder** - `268f56c` (chore)
**Plan metadata:** (see final commit below)
## Files Created/Modified
- `src/components/admin/AdminSidebar.tsx` - Removed Forecast NAV_ITEMS entry and TrendingUp import
- `src/app/admin/projects/project-actions.ts` - Removed 3 revalidatePath("/admin/forecast") calls
- `src/components/admin/leads/SendQuoteModal.tsx` - Removed "Crea Nuovo" tab and quotes/new navigation
- `src/lib/admin-queries.ts` - Removed stale JSDoc comment referencing /admin/quotes/new
**Deleted:**
- `src/app/admin/forecast/page.tsx`
- `src/app/admin/quotes/new/page.tsx`
- `src/app/admin/quotes/new/actions.ts`
- `src/components/admin/quotes/QuoteBuilderForm.tsx`
- `src/components/admin/quotes/OfferSelector.tsx`
- `src/components/admin/quotes/PriceOverrideInput.tsx`
- `src/components/admin/quotes/QuotePreview.tsx`
## Decisions Made
- Kept `lib/forecast-queries.ts` and `lib/quote-actions.ts` intact — they have no broken imports and are tolerable deadweight; removing them was explicitly out of scope per the plan
- Deleted `quotes/new/actions.ts` (not listed in plan) because it was a server re-export shim with `QuoteBuilderForm` as its only consumer — Rule 3 auto-fix (would have blocked clean directory removal)
- Stale JSDoc comment in `admin-queries.ts` updated — Rule 1 auto-fix (stale reference counts as dead code)
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Removed stale JSDoc comment referencing deleted route**
- **Found during:** Task 2 (final grep verification)
- **Issue:** `admin-queries.ts` line 862 had a `* Used by: /admin/quotes/new form (OfferSelector dropdown)` comment — the route no longer exists
- **Fix:** Removed the stale line from the JSDoc block
- **Files modified:** src/lib/admin-queries.ts
- **Verification:** `grep -r "quotes/new" src/` → 0 results
- **Committed in:** 268f56c (Task 2 commit)
**2. [Rule 3 - Blocking] Deleted quotes/new/actions.ts (unlisted file)**
- **Found during:** Task 2 — directory was non-empty after deleting page.tsx
- **Issue:** `src/app/admin/quotes/new/actions.ts` was a re-export shim (`export { createQuote } from "@/lib/quote-actions"`) with QuoteBuilderForm as its only consumer. It would leave a broken directory if not removed.
- **Fix:** Confirmed no other consumers via grep, then deleted the file
- **Files modified:** src/app/admin/quotes/new/actions.ts (deleted)
- **Verification:** `grep -r "createQuote" src/` shows only lib/quote-actions.ts (the source) — no broken references
- **Committed in:** 268f56c (Task 2 commit)
---
**Total deviations:** 2 auto-fixed (1 Rule 1 stale comment, 1 Rule 3 blocking unlisted file)
**Impact on plan:** Both auto-fixes necessary for completeness and clean directory state. No scope creep.
## Issues Encountered
- A linter hook reverted `SendQuoteModal.tsx` after the first Write. On re-read, the linter had actually applied the intended changes (removed Tabs imports, removed tab state, flattened to single form). Confirmed the file was correct before staging.
## User Setup Required
None — no external service configuration required.
## Next Phase Readiness
- CLEAN-01 and CLEAN-02 complete: codebase is clear of forecast and manual quote builder dead routes
- Ready for Phase 21 (AI Quote Agent) which will provide the replacement quote creation flow
- `lib/forecast-queries.ts` and `lib/quote-actions.ts` remain as inert deadweight — can be cleaned in a future pass if desired
---
*Phase: 18-cleanup-consolidamento*
*Completed: 2026-06-19*
## Self-Check: PASSED
- `src/components/admin/AdminSidebar.tsx` — FOUND (modified)
- `src/app/admin/projects/project-actions.ts` — FOUND (modified)
- `src/components/admin/leads/SendQuoteModal.tsx` — FOUND (modified)
- `src/lib/admin-queries.ts` — FOUND (modified)
- `src/app/admin/forecast/page.tsx` — MISSING (deleted as intended)
- `src/app/admin/quotes/new/page.tsx` — MISSING (deleted as intended)
- Commit 7d88409 — FOUND
- Commit 268f56c — FOUND
- `grep -r "admin/forecast" src/` → 0 results
- `grep -r "quotes/new" src/` → 0 results
- Build: 0 errors
@@ -0,0 +1,345 @@
---
phase: 18-cleanup-consolidamento
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- src/app/admin/page.tsx
- src/app/admin/analytics/page.tsx
- src/components/admin/YearSelector.tsx
autonomous: true
requirements:
- CLEAN-03
must_haves:
truths:
- "La Dashboard /admin mostra le statistiche annuali (MetricCard fatturato, grafico mensile, time tracking)"
- "La rotta /admin/analytics non esiste più (404)"
- "Il selettore anno naviga a /admin?year=X, non a /admin/analytics?year=X"
- "Non esiste più il file src/app/admin/analytics/page.tsx"
artifacts:
- path: "src/app/admin/page.tsx"
provides: "Dashboard con sezione Statistiche integrata"
contains: "getAnalyticsByYear"
- path: "src/app/admin/analytics/page.tsx"
provides: "File eliminato"
- path: "src/components/admin/YearSelector.tsx"
provides: "YearSelector che naviga a /admin?year=Y"
contains: "router.push(`/admin?year=${y}`)"
key_links:
- from: "src/app/admin/page.tsx"
to: "lib/analytics-queries"
via: "import getAnalyticsByYear, getMonthlyCollected, getAvailableYears, getTimeByClient, getTotalTrackedHours"
pattern: "analytics-queries"
- from: "YearSelector"
to: "/admin"
via: "router.push"
pattern: "router.push.*admin"
---
<objective>
Fondere le statistiche di /admin/analytics nella Dashboard /admin ed eliminare la rotta duplicata.
Purpose: CLEAN-03 — unica vista admin per statistiche. Elimina il doppione /admin/analytics e porta
tutto il suo contenuto (MetricCard economiche + MonthlyChart + time tracking per cliente) nella dashboard
esistente, sotto le KPI card e il feed attività già presenti.
Output: /admin mostra statistiche annuali, /admin/analytics → 404, YearSelector aggiornato.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
</context>
<interfaces>
<!-- Estratto da src/app/admin/analytics/page.tsx — contratti da portare nella dashboard -->
Imports da copiare in admin/page.tsx:
```typescript
import {
getAnalyticsByYear,
getMonthlyCollected,
getAvailableYears,
getTimeByClient,
getTotalTrackedHours,
} from "@/lib/analytics-queries";
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
```
Funzioni helper da copiare in admin/page.tsx:
```typescript
function fmtEur(n: number) { /* formatta in EUR it-IT */ }
function fmtSeconds(s: number): string { /* h m format */ }
```
ATTENZIONE: admin/page.tsx ha già una funzione `fmtEur` (ma accetta `string`, non `number`).
Rinominare una delle due o unificarle — preferire la versione che accetta `number` (più robusta)
e aggiornare i consumer nel file.
Componente MetricCard (da analytics/page.tsx) — da spostare in admin/page.tsx:
```typescript
function MetricCard({ label, value, sub, accent }: {
label: string; value: string; sub?: string; accent?: boolean;
}) { ... }
```
Questo componente è diverso da KpiCard già presente in admin/page.tsx:
KpiCard ha icona + colore; MetricCard ha stile accent verde o bianco con bordo.
Mantieni ENTRAMBI (scopi diversi): KpiCard per le 4 metriche operative in cima,
MetricCard per le statistiche economiche annuali nella nuova sezione.
Firma del componente analytics/page.tsx (async, searchParams):
```typescript
export default async function AnalyticsPage({
searchParams,
}: {
searchParams: Promise<{ year?: string }>;
}) {
const { year: yearParam } = await searchParams;
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
...
}
```
La stessa logica `year` va aggiunta al componente AdminDashboard in admin/page.tsx.
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Integra statistiche annuali nella Dashboard (CLEAN-03)</name>
<read_first>
- src/app/admin/page.tsx — struttura attuale (KpiCard, FollowUpWidget, activity feed, fmtEur esistente)
- src/app/admin/analytics/page.tsx — tutto il contenuto da portare (già in contesto da discovery)
- src/components/admin/YearSelector.tsx — YearSelector e MonthlyChart (già in contesto da discovery)
</read_first>
<files>
src/app/admin/page.tsx,
src/components/admin/YearSelector.tsx
</files>
<action>
STEP 1 — Aggiorna AdminDashboard in src/app/admin/page.tsx:
a) Aggiungi alla firma del componente il parametro searchParams (come in analytics/page.tsx):
```typescript
export default async function AdminDashboard({
searchParams,
}: {
searchParams: Promise<{ year?: string }>;
}) {
const { year: yearParam } = await searchParams;
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
```
b) Aggiungi gli import mancanti in cima al file:
```typescript
import {
getAnalyticsByYear,
getMonthlyCollected,
getAvailableYears,
getTimeByClient,
getTotalTrackedHours,
} from "@/lib/analytics-queries";
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
```
c) Aggiungi le query analytics al Promise.all esistente (o crea un secondo await per separare
le query di diversa frequenza — è accettabile avere due await distinti per chiarezza):
```typescript
const [data, monthly, availableYears, timeByClient, totalHours] = await Promise.all([
getAnalyticsByYear(year),
getMonthlyCollected(year),
getAvailableYears(),
getTimeByClient(year),
getTotalTrackedHours(year),
]);
```
d) Copia la funzione MetricCard da analytics/page.tsx nel file (accanto a KpiCard).
e) Risolvi il conflitto fmtEur:
- La versione attuale in admin/page.tsx accetta `string` e fa `parseFloat(val)`.
- Quella in analytics/page.tsx accetta `number`.
- Unifica in una sola funzione che accetta `number`, aggiorna i consumer esistenti
nel file che passavano una stringa (es. `fmtEur(kpi.revenueTotale)` e
`fmtEur(kpi.pagamentiInSospeso)`) convertendo a number dove necessario,
oppure mantieni entrambe con nomi distinti (`fmtEurStr` / `fmtEurNum`).
Scegli l'approccio più pulito senza rompere i KPI card esistenti.
f) Aggiungi la sezione Statistiche DOPO il feed attività esistente (mantieni tutto quello
che c'era prima intatto — FollowUpWidget, KPI card, activity feed):
```tsx
{/* ── SEZIONE STATISTICHE ANNUALI ── */}
<div className="mt-10 space-y-10">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-gray-900">Statistiche</h2>
<p className="text-sm text-gray-400 mt-0.5">Panoramica per anno</p>
</div>
<YearSelector currentYear={year} availableYears={availableYears} />
</div>
{/* Sezione economica */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard label="Contrattualizzato" value={fmtEur(data.contracted)}
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"}`} accent />
<MetricCard label="Incassato" value={fmtEur(data.collected)}
sub={`${collectedPct}% del contrattualizzato`} />
<MetricCard label="Da incassare" value={fmtEur(data.pending)} sub="Tutti gli anni" />
<MetricCard label="Clienti acquisiti" value={String(data.clientsAcquired)} sub={`Anno ${year}`} />
</div>
<MonthlyChart data={monthly} year={year} />
</div>
{/* Sezione time tracking */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Tempo tracciato</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard label="Ore totali" value={`${totalHours}h`} sub={`Anno ${year}`} accent />
</div>
{/* ... tabella ore per cliente (copia da analytics/page.tsx) ... */}
</div>
</div>
```
Ricopia fedelmente il blocco "ore per cliente" da analytics/page.tsx (righe 127-160)
nella sezione time tracking della dashboard.
Calcola collectedPct prima del return:
```typescript
const collectedPct = data.contracted > 0
? Math.round((data.collected / data.contracted) * 100) : 0;
const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1;
```
STEP 2 — Aggiorna YearSelector per navigare verso /admin:
In src/components/admin/YearSelector.tsx, riga ~18:
```typescript
// PRIMA:
router.push(`/admin/analytics?year=${y}`);
// DOPO:
router.push(`/admin?year=${y}`);
```
NON modificare: MonthlyChart (rimane nello stesso file), struttura esistente della dashboard
(FollowUpWidget, KPI card, activity feed — tutto invariato sopra la nuova sezione).
</action>
<verify>
<automated>
grep "getAnalyticsByYear\|getMonthlyCollected\|getAvailableYears" /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/page.tsx | wc -l
</automated>
Risultato atteso: 3 (tutte e tre le query importate e usate).
Verifica YearSelector:
grep "router.push" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/YearSelector.tsx
Risultato atteso: contiene "/admin?year=" e NON "/admin/analytics".
</verify>
<acceptance_criteria>
- src/app/admin/page.tsx importa da @/lib/analytics-queries
- src/app/admin/page.tsx importa YearSelector e MonthlyChart
- YearSelector.tsx naviga a /admin?year=Y (non a /admin/analytics)
- Il file compila senza errori TypeScript
</acceptance_criteria>
<done>
La Dashboard mostra le statistiche annuali con selettore anno; il selettore naviga
correttamente a /admin?year=X.
</done>
</task>
<task type="auto">
<name>Task 2: Elimina rotta /admin/analytics (CLEAN-03)</name>
<read_first>
- src/app/admin/analytics/page.tsx — conferma che non è più necessario (Task 1 ha già portato
tutto il contenuto nella dashboard)
</read_first>
<files>
src/app/admin/analytics/page.tsx
</files>
<action>
1. Verifica preventiva — assicurati che nessun altro file nel progetto importi da
src/app/admin/analytics/page.tsx direttamente (è una page, non un modulo, quindi
non dovrebbe avere consumer diretti — ma controlla):
grep -r "admin/analytics" src/ --include="*.tsx" --include="*.ts"
Se trovi riferimenti diversi da YearSelector (già aggiornato in Task 1), risolvili prima.
2. Elimina:
- src/app/admin/analytics/page.tsx
- Directory src/app/admin/analytics/ (se vuota dopo la rimozione del file)
NON eliminare: lib/analytics-queries.ts (ancora importata dalla dashboard),
src/components/admin/YearSelector.tsx (ancora usata dalla dashboard).
</action>
<verify>
<automated>
test -f /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/analytics/page.tsx && echo "ESISTE_ANCORA" || echo "OK_RIMOSSO"
</automated>
Risultato atteso: OK_RIMOSSO
Verifica nessun riferimento rimasto:
grep -r "admin/analytics" /Users/simonecavalli/Vault/IAMCAVALLI/src --include="*.tsx" --include="*.ts" | grep -v "node_modules" | grep -v ".next"
Risultato atteso: 0 righe.
</verify>
<acceptance_criteria>
- src/app/admin/analytics/page.tsx non esiste più
- grep -r "admin/analytics" src/ produce 0 risultati
- npm run build completa senza errori
</acceptance_criteria>
<done>
La rotta /admin/analytics è eliminata. Navigarci restituisce 404.
Tutte le statistiche sono accessibili alla Dashboard /admin.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin/page.tsx → analytics-queries | Aggiunta di query server-side già esistenti — nessuna nuova superficie |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-03 | Information Disclosure | admin/page.tsx | accept | Le statistiche erano già accessibili a /admin/analytics — stessa autenticazione Auth.js, nessuna nuova esposizione |
| T-18-04 | Tampering | YearSelector.tsx | accept | Il cambio da /admin/analytics a /admin nella navigazione è una semplice redirect di path — nessun dato mutato |
</threat_model>
<verification>
Al termine del piano:
1. `npm run build` completa senza errori TypeScript
2. `grep -r "admin/analytics" src/` → 0 risultati
3. La Dashboard /admin mostra le sezioni Statistiche con selettore anno
4. Navigare a /admin/analytics restituisce 404
5. YearSelector porta a /admin?year=X quando si cambia anno
</verification>
<success_criteria>
- CLEAN-03: /admin mostra le statistiche annuali (MetricCard + MonthlyChart + time tracking)
- /admin/analytics → 404 (rotta eliminata)
- YearSelector naviga a /admin?year=X
- Build TypeScript pulita
</success_criteria>
<output>
After completion, create `.planning/phases/18-cleanup-consolidamento/18-02-SUMMARY.md`
</output>
@@ -0,0 +1,113 @@
---
phase: 18-cleanup-consolidamento
plan: "02"
subsystem: ui
tags: [nextjs, analytics, dashboard, react, tailwind]
# Dependency graph
requires:
- phase: analytics-queries
provides: getAnalyticsByYear, getMonthlyCollected, getAvailableYears, getTimeByClient, getTotalTrackedHours
provides:
- Admin dashboard at /admin integrates annual statistics (MetricCard, MonthlyChart, time tracking per client)
- /admin/analytics route deleted — returns 404
- YearSelector navigates to /admin?year=Y
affects: [18-cleanup-consolidamento, admin-ui, analytics]
# Tech tracking
tech-stack:
added: []
patterns:
- Server page accepts searchParams for year filtering
- Analytics queries co-located with dashboard queries in same async page
key-files:
created: []
modified:
- src/app/admin/page.tsx
- src/components/admin/YearSelector.tsx
deleted:
- src/app/admin/analytics/page.tsx
key-decisions:
- "Unified fmtEur to accept number (analytics version); KPI card callers wrapped with parseFloat() for string DB values"
- "Two separate awaits in AdminDashboard: getDashboardStats() first, then analytics Promise.all — acceptable for clarity"
- "Stale .next cache (OfferSelector.tsx reference) cleared before final build — pre-existing issue, not introduced by this plan"
patterns-established:
- "Statistics section appended after activity feed — existing dashboard structure preserved above new section"
requirements-completed:
- CLEAN-03
# Metrics
duration: 15min
completed: 2026-06-19
---
# Phase 18 Plan 02: Consolidate Analytics Dashboard Summary
**Merged /admin/analytics statistics (MetricCard, MonthlyChart, per-client time tracking) into /admin dashboard and deleted the duplicate route**
## Performance
- **Duration:** ~15 min
- **Started:** 2026-06-19T10:04:00Z
- **Completed:** 2026-06-19T10:19:00Z
- **Tasks:** 2
- **Files modified:** 3 (2 modified, 1 deleted)
## Accomplishments
- AdminDashboard now shows annual statistics section below activity feed: MetricCard economic metrics, MonthlyChart, and per-client time tracking bars
- /admin/analytics route deleted — navigating there returns 404
- YearSelector updated to navigate to /admin?year=Y instead of /admin/analytics?year=Y
- fmtEur unified to number-accepting version (more robust); existing KPI card string values wrapped with parseFloat()
## Task Commits
1. **Task 1: Integra statistiche annuali nella Dashboard** — included in `14bdbab` (feat)
2. **Task 2: Elimina rotta /admin/analytics** — included in `14bdbab` (feat)
**Plan metadata:** (docs commit below)
## Files Created/Modified
- `src/app/admin/page.tsx` — Added analytics imports, searchParams, analytics queries, MetricCard, fmtSeconds, fmtEur(number), statistics section
- `src/components/admin/YearSelector.tsx` — Updated router.push from /admin/analytics to /admin
- `src/app/admin/analytics/page.tsx` — Deleted
## Decisions Made
- Unified `fmtEur` to the `number` version from analytics (cleaner, no `parseFloat` internally). Updated the two KPI card callers that received string values from DB to pass `parseFloat(kpi.revenueTotale)` and `parseFloat(kpi.pagamentiInSospeso)`.
- Used two sequential awaits (getDashboardStats first, then analytics Promise.all) rather than one large combined Promise.all — avoids mixing query concerns and follows plan guidance for acceptable two-await pattern.
- Preserved all existing dashboard content (FollowUpWidget, KPI cards, activity feed) exactly; statistics section appended at the bottom with `mt-10`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Cleared stale .next cache referencing deleted OfferSelector.tsx**
- **Found during:** Build verification after Task 2
- **Issue:** Stale `.next/tsconfig.tsbuildinfo` incremental cache listed `src/components/admin/quotes/OfferSelector.tsx` as a root file. That file had been deleted in a prior session. TypeScript errored with "File not found — root file specified for compilation." The pre-existing cache was masking this because prior builds hit cached output.
- **Fix:** Ran `rm -rf .next` to clear the stale incremental cache, then rebuilt clean.
- **Files modified:** .next/ (cache only — not tracked in git)
- **Verification:** Clean build passes with TypeScript check and all routes compiled correctly.
- **Committed in:** n/a (cache not committed)
---
**Total deviations:** 1 auto-fixed (Rule 3 - blocking)
**Impact on plan:** Required to get a clean build. Pre-existing issue unrelated to this plan's scope. .next/ is gitignored so no source changes.
## Issues Encountered
- Stale `.next` incremental TypeScript cache referenced `OfferSelector.tsx` (deleted in a prior session). Cleared cache, rebuilt clean. Build passes correctly.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- /admin is the single analytics entry point — ready for 18-03 and any future admin UI work
- No stale routes remain in the analytics path
- Build is clean
---
*Phase: 18-cleanup-consolidamento*
*Completed: 2026-06-19*
@@ -0,0 +1,197 @@
---
phase: 18-cleanup-consolidamento
plan: 03
type: execute
wave: 2
depends_on:
- "18-01"
- "18-02"
autonomous: false
files_modified:
- .planning/ROADMAP.md
- .planning/REQUIREMENTS.md
requirements:
- CLEAN-04
must_haves:
truths:
- "Le fasi 13/15/16/17 sono marcate esplicitamente come cancellate/congelate/ri-scopate nel ROADMAP.md"
- "REQUIREMENTS.md riflette lo stato aggiornato dei requirement corrispondenti"
- "CLEAN-04 è marcato Done in REQUIREMENTS.md"
- "La build del progetto passa dopo le rimozioni dei piani precedenti"
artifacts:
- path: ".planning/ROADMAP.md"
provides: "Fasi 13/15/16/17 con status esplicito cancellate/congelate"
contains: "CONGELATA\|ABBANDONATA\|RI-SCOPATA"
- path: ".planning/REQUIREMENTS.md"
provides: "CLEAN-04 marcato Complete"
key_links:
- from: "ROADMAP.md fasi 13/15/16/17"
to: "STATUS field"
via: "checklist e label espliciti"
pattern: "Congelata|Abbandonata|Ri-scopata"
---
<objective>
Verificare formalmente il completamento di CLEAN-04 e validare la build post-cleanup.
Purpose: CLEAN-04 — le fasi v2.1 residue sono già state archiviate/marcate nel reset 2026-06-19.
Questo piano le verifica, le sigla formalmente nel planning e valida che i piani 01/02
non abbiano introdotto errori di build.
Output: CLEAN-04 marcato Done, build TypeScript verificata, check umano che la dashboard funziona.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/STATE.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Verifica CLEAN-04 e aggiorna planning docs</name>
<read_first>
- .planning/ROADMAP.md — sezione fasi 13/15/16/17, verifica che abbiano label/status esplicito
- .planning/REQUIREMENTS.md — sezione CLEAN-04, verifica stato attuale
</read_first>
<files>
.planning/ROADMAP.md,
.planning/REQUIREMENTS.md
</files>
<action>
STEP 1 — Verifica ROADMAP.md:
Leggi le voci delle fasi 13, 15, 16, 17 nel ROADMAP.md.
Devono avere tutte uno status esplicito. Controlla che la checklist mostri:
- Phase 13: [~] con label "❌ CONGELATA (reset 2026-06-19)"
- Phase 15: [~] con label "❌ ABBANDONATA (reset 2026-06-19)"
- Phase 16: [~] con label "❌ RI-SCOPATA in v2.2"
- Phase 17: [~] con label "❌ RI-SCOPATA in v2.2"
Se uno di questi status manca o è incompleto, aggiungilo/completalo.
Aggiungi anche una nota nella tabella Progress in fondo con la data di chiusura 2026-06-19
dove mancante.
STEP 2 — Aggiorna REQUIREMENTS.md:
Nella tabella "Cleanup & Consolidamento (Phase 18 / R1)":
- CLEAN-01: cambia Status da "Pending" a "Complete"
- CLEAN-02: cambia Status da "Pending" a "Complete"
- CLEAN-03: cambia Status da "Pending" a "Complete"
- CLEAN-04: cambia Status da "In corso (questo reset)" a "Complete"
Nella tabella Traceability in fondo:
- CLEAN-01 | Phase 18 (R1) | Complete
- CLEAN-02 | Phase 18 (R1) | Complete
- CLEAN-03 | Phase 18 (R1) | Complete
- CLEAN-04 | Phase 18 (R1) | Complete
STEP 3 — Build verification:
Esegui: cd /Users/simonecavalli/Vault/IAMCAVALLI && npm run build
Se la build fallisce:
- Leggi l'output dell'errore
- Identifica il file con import rotto
- Risolvi (probabilmente un import di componente eliminato o una funzione non trovata)
- Ri-esegui npm run build fino a successo
Registra nel SUMMARY se ci sono stati fix necessari.
</action>
<verify>
<automated>
grep "CLEAN-04" /Users/simonecavalli/Vault/IAMCAVALLI/.planning/REQUIREMENTS.md
</automated>
Risultato atteso: riga contenente "CLEAN-04" e "Complete".
Verifica fasi archiviate:
grep -E "Phase 13|Phase 15|Phase 16|Phase 17" /Users/simonecavalli/Vault/IAMCAVALLI/.planning/ROADMAP.md | grep -E "CONGELATA|ABBANDONATA|RI-SCOPATA" | wc -l
Risultato atteso: 4 (tutte e quattro le fasi con label esplicito).
</verify>
<acceptance_criteria>
- REQUIREMENTS.md: CLEAN-04 è "Complete"
- REQUIREMENTS.md: CLEAN-01, CLEAN-02, CLEAN-03 sono "Complete"
- ROADMAP.md: fasi 13/15/16/17 hanno label di stato esplicito (CONGELATA/ABBANDONATA/RI-SCOPATA)
- npm run build completa senza errori
</acceptance_criteria>
<done>
Tutti i requisiti CLEAN-01..04 sono marcati Complete nei planning docs.
La build TypeScript è pulita. Phase 18 è formalmente chiusa.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Checkpoint: Verifica visuale Dashboard e assenza rotte rimosse</name>
<what-built>
Piano 01 ha rimosso /admin/forecast e /admin/quotes/new.
Piano 02 ha fuso le statistiche di /admin/analytics nella Dashboard /admin
e rimosso la rotta /admin/analytics.
Il selettore anno nella dashboard naviga a /admin?year=X.
</what-built>
<how-to-verify>
1. Avvia il dev server: `npm run dev` (o `npx next dev`)
2. Vai su http://localhost:3000/admin — verifica:
- Sidebar: NON compare la voce "Forecast"
- Dashboard: in fondo alla pagina compare la sezione "Statistiche" con selettore anno,
MetricCard economiche (Contrattualizzato / Incassato / Da incassare / Clienti acquisiti),
grafico mensile a barre, sezione ore per cliente
- KPI card esistenti (Clienti attivi, Revenue totale, Progetti in corso, Pagamenti in sospeso)
sono ancora presenti in cima
- FollowUpWidget e feed attività sono ancora presenti
3. Vai su http://localhost:3000/admin/forecast → deve restituire 404
4. Vai su http://localhost:3000/admin/analytics → deve restituire 404
5. Vai su http://localhost:3000/admin/quotes/new → deve restituire 404
6. Clicca sul selettore anno nella sezione Statistiche → cambia anno e verifica che
l'URL diventa /admin?year=XXXX (non /admin/analytics)
7. Nel dettaglio di un lead, apri il modal "Invia preventivo" → verifica che NON
compare più il bottone per creare un nuovo preventivo manuale
</how-to-verify>
<resume-signal>
Scrivi "ok" se tutto funziona correttamente.
Se trovi problemi, descrivi cosa non funziona e l'esecuzione correggerà prima di chiudere la fase.
</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| planning docs → stato del progetto | Aggiornamento documenti di tracking — nessuna superficie di attacco |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-05 | Repudiation | ROADMAP.md / REQUIREMENTS.md | accept | I planning docs sono in git — ogni modifica è tracciata e reversibile |
</threat_model>
<verification>
Al termine del piano:
1. REQUIREMENTS.md: CLEAN-01, CLEAN-02, CLEAN-03, CLEAN-04 → tutti "Complete"
2. ROADMAP.md: fasi 13/15/16/17 con status esplicito
3. npm run build → 0 errori
4. Checkpoint umano superato (Dashboard mostra statistiche, rotte rimosse → 404)
</verification>
<success_criteria>
- CLEAN-04: fasi v2.1 residue formalmente chiuse nei planning docs
- Planning docs aggiornati con stati finali Phase 18
- Build pulita confermata
- Verifica visuale umana completata
</success_criteria>
<output>
After completion, create `.planning/phases/18-cleanup-consolidamento/18-03-SUMMARY.md`
</output>
@@ -0,0 +1,27 @@
---
plan: 18-03
status: complete
completed_at: 2026-06-19
---
# Plan 18-03 Summary — Cleanup Verification & Planning Docs
## Tasks completed
**Task 1 (auto):**
- REQUIREMENTS.md: CLEAN-01, CLEAN-02, CLEAN-03, CLEAN-04 → Complete (requirements table + traceability table)
- ROADMAP.md: fasi 13/15/16/17 già avevano label espliciti da reset 2026-06-19 (CONGELATA/ABBANDONATA/RI-SCOPATA)
- Build: `npm run build` → 0 errori, 0 warning. Route /admin/forecast, /admin/analytics, /admin/quotes/new assenti dalla route list.
**Task 2 (checkpoint umano):**
- Verifica visuale superata dall'utente dopo deploy su Coolify.
- Dashboard /admin mostra statistiche con MetricCard e grafico mensile.
- Rotte rimosse restituiscono 404.
- Selettore anno naviga a /admin?year=X.
- SendQuoteModal senza bottone preventivo manuale.
## Files modified
- `.planning/REQUIREMENTS.md` — CLEAN-01..04 marcati Complete
## Deviations
Nessuna.
@@ -0,0 +1,553 @@
---
phase: 19-pipeline-crm-kanban
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/components/admin/leads/LeadsKanbanBoard.tsx
- src/components/admin/leads/LeadsViewToggle.tsx
- src/app/admin/leads/LeadsSearch.tsx
- src/app/admin/leads/page.tsx
autonomous: true
requirements:
- PIPE-01
- PIPE-02
must_haves:
truths:
- "I lead sono visibili in una board Kanban con 6 colonne per stage (contacted, qualified, proposal_sent, negotiating, won, lost)"
- "Trascinare un lead da una colonna a un'altra aggiorna leads.status in modo persistente"
- "Spostare un lead nella colonna Vinto o Perso registra l'esito cambiando il campo status"
- "La vista tabella inline-edit (LeadTable) resta disponibile e funzionante come vista alternativa"
- "Il toggle Lista/Kanban è visibile sopra il contenuto e preserva lo stato della ricerca quando si cambia vista"
artifacts:
- path: "src/components/admin/leads/LeadsKanbanBoard.tsx"
provides: "Board Kanban con DndContext, 6 DroppableColumn, DraggableLeadCard, DragOverlay, ottimistic update"
exports: ["LeadsKanbanBoard"]
- path: "src/components/admin/leads/LeadsViewToggle.tsx"
provides: "Client wrapper con useState<'list' | 'kanban'> e pill toggle"
exports: ["LeadsViewToggle"]
- path: "src/app/admin/leads/LeadsSearch.tsx"
provides: "Search + toggle integrati; passa filtered leads sia a LeadTable che a LeadsKanbanBoard"
- path: "src/app/admin/leads/page.tsx"
provides: "Server component aggiornato che non renderizza più LeadsSearch direttamente ma LeadsViewToggle"
key_links:
- from: "LeadsKanbanBoard.tsx — handleDragEnd"
to: "src/app/admin/leads/actions.ts — updateLeadField"
via: "startTransition async + router.refresh()"
pattern: "updateLeadField\\(leadId.*status"
- from: "LeadsSearch.tsx — filtered"
to: "LeadsKanbanBoard — leads prop"
via: "filtered array passato come prop"
pattern: "LeadsKanbanBoard.*leads=\\{filtered\\}"
- from: "LeadsViewToggle / LeadsSearch"
to: "LeadTable + LeadsKanbanBoard"
via: "view state (list | kanban)"
pattern: "useState<.list.*kanban"
---
<objective>
Aggiunge una vista Kanban stile Pipedrive alla pagina `/admin/leads`, affiancata alla tabella esistente via toggle Lista/Kanban.
Purpose: Completare PIPE-01 (board drag-drop per stage) e PIPE-02 (vinto/perso come cambio-colonna manuale) senza toccare il data layer e senza rimuovere la LeadTable esistente.
Output:
- `LeadsKanbanBoard.tsx` — nuovo componente client con @dnd-kit drag-drop tra 6 colonne stage
- `LeadsViewToggle.tsx` — nuovo wrapper client con pill toggle Lista/Kanban
- `LeadsSearch.tsx` — modificato per ospitare il toggle e passare `filtered` leads a entrambe le viste
- `page.tsx` — modificato per rimuovere il render diretto di `<LeadsSearch>` e invece renderizzare `<LeadsViewToggle>`
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/19-pipeline-crm-kanban/19-RESEARCH.md
<interfaces>
<!-- Tipi e contratti estratti dal codebase. L'executor li usa direttamente — nessuna esplorazione necessaria. -->
Da src/lib/admin-queries.ts (VERIFIED):
```typescript
// LeadWithTags = leads row + tags array
export type LeadWithTags = {
id: string;
name: string;
email: string | null;
phone: string | null;
company: string | null;
status: string; // one of LEAD_STAGES values
next_action: string | null;
created_at: Date;
updated_at: Date;
tags: string[];
};
export type LeadFieldOptions = {
tags: string[];
};
```
Da src/lib/lead-validators.ts (VERIFIED):
```typescript
export const LEAD_STAGES = [
"contacted",
"qualified",
"proposal_sent",
"negotiating",
"won",
"lost",
] as const;
export type LeadStage = typeof LEAD_STAGES[number];
```
Da src/app/admin/leads/actions.ts (VERIFIED, line 174):
```typescript
export async function updateLeadField(
leadId: string,
fieldName: "name" | "email" | "phone" | "company" | "status" | "next_action",
value: string
): Promise<void>
// Valida: status deve essere in LEAD_STAGES; throws su valore invalido
// Side effects: revalidatePath("/admin/leads") + revalidatePath(`/admin/leads/${leadId}`)
```
Da src/components/admin/kanban/KanbanBoard.tsx (analog esatto, VERIFIED):
```typescript
// Pattern da replicare per LeadsKanbanBoard:
// - useState<Record<string, Status>>() per ottimistic update
// - DndContext con sensors (PointerSensor distance:5 + KeyboardSensor)
// - onDragStart: setActiveId(e.active.id as string)
// - onDragEnd: setActiveId(null); if (!over) return; guard su valore valido;
// setTaskStatuses(...); startTransition(async () => { await action; router.refresh(); })
// - DroppableColumn: useDroppable({ id }) → setNodeRef, isOver
// - DraggableCard: useDraggable({ id }) → setNodeRef, transform, isDragging, listeners, attributes
// - DragOverlay dropAnimation={null} con ghost card
```
Da src/components/admin/kanban/PhasesViewToggle.tsx (analog esatto, VERIFIED):
```typescript
// Toggle pill pattern:
// className pill: "flex items-center gap-1 mb-5 bg-[#f4f4f5] rounded-lg p-1 w-fit"
// Active button: "bg-white text-[#1A463C] shadow-sm"
// Inactive button: "text-[#71717a] hover:text-[#1a1a1a]"
// Testo: "Lista" / "Kanban"
```
Da src/components/admin/leads/LeadTable.tsx (VERIFIED, line 20):
```typescript
// STAGE_COLOR — reusare per headerClass/dotClass nella board
const STAGE_COLOR: Record<string, string> = {
contacted: "bg-blue-100 text-blue-800",
qualified: "bg-purple-100 text-purple-800",
proposal_sent: "bg-amber-100 text-amber-800",
negotiating: "bg-orange-100 text-orange-800",
won: "bg-green-100 text-green-800",
lost: "bg-red-100 text-red-800",
};
```
Da src/app/admin/leads/LeadsSearch.tsx (VERIFIED — STATO ATTUALE):
```typescript
// Attualmente: search input + LeadTable(filtered, options)
// Dopo questa fase: search input + LeadsViewToggle (che gestisce il rendering di LeadTable o LeadsKanbanBoard)
// Alternativa più pulita: LeadsSearch mantiene la search, ma il toggle vive in LeadsViewToggle
// Approccio scelto (vedi task 1): LeadsSearch riceve `view` + `onViewChange` da LeadsViewToggle
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: LeadsKanbanBoard.tsx — board Kanban con drag-drop tra 6 stage</name>
<files>src/components/admin/leads/LeadsKanbanBoard.tsx</files>
<read_first>
- src/components/admin/kanban/KanbanBoard.tsx — analog esatto da replicare strutturalmente
- src/lib/lead-validators.ts — LEAD_STAGES canonical values
- src/app/admin/leads/actions.ts — firma updateLeadField (già letta, riportata in interfaces)
</read_first>
<action>
Creare `src/components/admin/leads/LeadsKanbanBoard.tsx` come componente client. Seguire la struttura esatta di `KanbanBoard.tsx`, adattata per i lead.
**Struttura richiesta:**
1. `"use client"` in cima.
2. Definire `type LeadStage` e `LEAD_COLUMNS` come costante di modulo (NON importare LEAD_STAGES da lead-validators — definire il tipo locale per evitare dipendenze circolari con il barrel del server):
```typescript
type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost";
const LEAD_COLUMNS: {
id: LeadStage;
label: string;
headerClass: string;
dotClass: string;
}[] = [
{ id: "contacted", label: "Contattato", headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
{ id: "qualified", label: "Qualificato", headerClass: "text-purple-700", dotClass: "bg-purple-400" },
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700", dotClass: "bg-amber-400" },
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700", dotClass: "bg-orange-400" },
{ id: "won", label: "Vinto", headerClass: "text-green-700", dotClass: "bg-green-500" },
{ id: "lost", label: "Perso", headerClass: "text-red-700", dotClass: "bg-red-400" },
];
```
3. Componente `DroppableColumn` (analogo al `DroppableColumn` di KanbanBoard.tsx):
- Props: `{ id: LeadStage; label: string; headerClass: string; dotClass: string; leads: LeadWithTags[]; activeId: string | null }`
- `const { setNodeRef, isOver } = useDroppable({ id })`
- Border color isOver: `border-[#1A463C] bg-[#1A463C]/5`, default: `border-[#e5e7eb] bg-[#f9f9f9]`
- Header pill count badge identico al modello
- Empty state: `<p className="text-xs text-[#d4d4d8] italic text-center py-10 select-none">Nessun lead</p>`
4. Componente `DraggableLeadCard` (analogo a `DraggableCard` di KanbanBoard.tsx):
- Props: `{ lead: LeadWithTags; isActive: boolean }`
- `const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: lead.id })`
- Contenuto card: riga primaria `lead.name` (text-sm font-medium text-[#1a1a1a]), riga secondaria `lead.company` se presente (text-xs text-[#71717a]), riga hint `lead.next_action` se presente (text-xs text-[#71717a] mt-1 line-clamp-1)
- Stili drag: opacity-30 quando isDragging, hover:border-[#1A463C]/40 quando non in drag
5. Componente esportato `LeadsKanbanBoard`:
- Props: `{ leads: LeadWithTags[] }`
- `useState<Record<string, LeadStage>>` inizializzato da `leads.map(l => [l.id, l.status as LeadStage])`
- `useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor))`
- `leadsByStage`: `LEAD_COLUMNS.reduce` che raggruppa leads per stage corrente (legge `leadStatuses[l.id] ?? l.status`)
- `handleDragEnd`: `setActiveId(null)` → guard `if (!over) return` → guard `if (!(LEAD_COLUMNS.map(c => c.id) as string[]).includes(over.id as string)) return` → guard stesso stage → ottimistic `setLeadStatuses``startTransition(async () => { await updateLeadField(leadId, "status", newStage); router.refresh(); })`
- Layout: wrapper `overflow-x-auto` → griglia `grid grid-cols-6 gap-3 min-w-[1080px]` (6 colonne × 180px min)
- `DragOverlay dropAnimation={null}`: ghost card con `border-2 border-[#1A463C] shadow-xl rotate-1`, mostra `name` e `company` del lead attivo
6. Imports richiesti:
```typescript
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
DndContext, DragEndEvent, DragOverlay,
PointerSensor, KeyboardSensor, useSensor, useSensors,
useDroppable, useDraggable,
} from "@dnd-kit/core";
import { updateLeadField } from "@/app/admin/leads/actions";
import type { LeadWithTags } from "@/lib/admin-queries";
```
</action>
<verify>
<automated>grep -n "useDroppable\|useDraggable\|DragOverlay\|DndContext\|updateLeadField\|LeadsKanbanBoard" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx | head -20</automated>
</verify>
<acceptance_criteria>
- `LeadsKanbanBoard.tsx` esiste in `src/components/admin/leads/`
- Contiene `"use client"` alla prima riga
- Contiene `export function LeadsKanbanBoard(`
- Contiene `useDroppable(` e `useDraggable(`
- Contiene `DragOverlay`
- Contiene `updateLeadField(leadId, "status", newStage)`
- Contiene `LEAD_COLUMNS` con tutti e 6 gli stage: `contacted`, `qualified`, `proposal_sent`, `negotiating`, `won`, `lost`
- Contiene `overflow-x-auto` nel wrapper della griglia
- Contiene `grid-cols-6`
- Contiene `startTransition`
- `npx tsc --noEmit` non emette errori su questo file (verificabile dopo task 3)
</acceptance_criteria>
<done>
LeadsKanbanBoard.tsx esiste, esporta LeadsKanbanBoard, implementa drag-drop tra 6 colonne lead stage via @dnd-kit primitives, persiste su updateLeadField con ottimistic update, mostra card con name/company/next_action.
</done>
</task>
<task type="auto">
<name>Task 2: LeadsViewToggle.tsx — wrapper client con pill toggle Lista/Kanban + search integrata</name>
<files>
src/components/admin/leads/LeadsViewToggle.tsx
src/app/admin/leads/LeadsSearch.tsx
</files>
<read_first>
- src/components/admin/kanban/PhasesViewToggle.tsx — analog esatto del pill toggle (già letto)
- src/app/admin/leads/LeadsSearch.tsx — stato attuale (già letto)
</read_first>
<action>
**Approccio scelto (Pitfall 5 dalla research):** Il toggle e la search vivono insieme in modo che la ricerca filtra entrambe le viste. Si implementa così:
**File 1 — `src/components/admin/leads/LeadsViewToggle.tsx` (NUOVO):**
`LeadsViewToggle` è il nuovo client wrapper che:
- Contiene `useState<"list" | "kanban">("list")`
- Contiene `useState("")` per la query di ricerca
- Calcola `filtered` con `useMemo` (stessa logica di LeadsSearch attuale: filtra per name/email/company/status/tags)
- Renderizza:
1. Barra superiore: search input (a sinistra) + pill toggle Lista/Kanban (a destra), su una singola riga `flex justify-between items-center mb-4`
2. Se `view === "list"`: `<LeadTable leads={filtered} options={options} />`
3. Se `view === "kanban"`: `<LeadsKanbanBoard leads={filtered} />`
```typescript
"use client";
import { useState, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
import { LeadTable } from "@/components/admin/leads/LeadTable";
import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
export function LeadsViewToggle({
leads,
options,
}: {
leads: LeadWithTags[];
options: LeadFieldOptions;
}) {
const [view, setView] = useState<"list" | "kanban">("list");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return leads;
return leads.filter((l) => {
if (l.name.toLowerCase().includes(q)) return true;
if (l.email?.toLowerCase().includes(q)) return true;
if (l.company?.toLowerCase().includes(q)) return true;
if (l.status.toLowerCase().includes(q)) return true;
if (l.tags.some((t) => t.toLowerCase().includes(q))) return true;
return false;
});
}, [leads, query]);
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
{/* Search */}
<div className="relative max-w-sm flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
<Input
type="text"
placeholder="Cerca lead..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 h-9"
/>
</div>
{/* Toggle pill */}
<div className="flex items-center gap-1 bg-[#f4f4f5] rounded-lg p-1 w-fit flex-shrink-0">
<button
onClick={() => setView("list")}
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
view === "list"
? "bg-white text-[#1A463C] shadow-sm"
: "text-[#71717a] hover:text-[#1a1a1a]"
}`}
>
Lista
</button>
<button
onClick={() => setView("kanban")}
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
view === "kanban"
? "bg-white text-[#1A463C] shadow-sm"
: "text-[#71717a] hover:text-[#1a1a1a]"
}`}
>
Kanban
</button>
</div>
</div>
{view === "list" ? (
<LeadTable leads={filtered} options={options} />
) : (
<LeadsKanbanBoard leads={filtered} />
)}
</div>
);
}
```
**File 2 — `src/app/admin/leads/LeadsSearch.tsx` (MODIFICATO):**
`LeadsSearch` non è più necessario: la sua logica (search + filtered + LeadTable) è stata trasferita in `LeadsViewToggle`. Svuotare il file sostituendo il contenuto con un re-export di `LeadsViewToggle` per non rompere eventuali import esistenti:
```typescript
// LeadsSearch is superseded by LeadsViewToggle (Phase 19).
// Re-exported here to avoid breaking any existing import.
export { LeadsViewToggle as LeadsSearch } from "@/components/admin/leads/LeadsViewToggle";
```
Nota: se page.tsx verrà aggiornato nel task 3 ad importare direttamente `LeadsViewToggle`, questo re-export è solo una rete di sicurezza e non causa conflitti.
</action>
<verify>
<automated>grep -n "useState\|LeadsKanbanBoard\|LeadTable\|LeadsViewToggle\|list.*kanban\|kanban.*list" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsViewToggle.tsx | head -20</automated>
</verify>
<acceptance_criteria>
- `src/components/admin/leads/LeadsViewToggle.tsx` esiste
- Contiene `"use client"`
- Contiene `useState<"list" | "kanban">("list")`
- Contiene `useState("")` per la query
- Contiene `useMemo(` per `filtered`
- Contiene `LeadsKanbanBoard` importato da `@/components/admin/leads/LeadsKanbanBoard`
- Contiene `LeadTable` importato da `@/components/admin/leads/LeadTable`
- Contiene `export function LeadsViewToggle(`
- Contiene il pill toggle con classi `bg-[#f4f4f5] rounded-lg p-1`
- `src/app/admin/leads/LeadsSearch.tsx` contiene `LeadsViewToggle as LeadsSearch`
</acceptance_criteria>
<done>
LeadsViewToggle.tsx esiste con search integrata + toggle Lista/Kanban. LeadsSearch.tsx ri-esporta LeadsViewToggle per retrocompatibilità. La ricerca filtra entrambe le viste simultaneamente.
</done>
</task>
<task type="auto">
<name>Task 3: page.tsx — cablaggio LeadsViewToggle + build check</name>
<files>src/app/admin/leads/page.tsx</files>
<read_first>
- src/app/admin/leads/page.tsx — stato attuale (già letto: 23 righe, renderizza LeadsSearch)
</read_first>
<action>
Aggiornare `src/app/admin/leads/page.tsx` per usare `LeadsViewToggle` al posto di `LeadsSearch`.
Stato attuale:
```typescript
import { LeadsSearch } from "./LeadsSearch";
// ...
<LeadsSearch leads={leads} options={options} />
```
Nuovo stato — sostituire l'import e il render:
```typescript
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
// ...
<LeadsViewToggle leads={leads} options={options} />
```
Il file completo aggiornato:
```typescript
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
export const revalidate = 0;
export default async function LeadsPage() {
const [leads, options] = await Promise.all([
getLeadsWithTags(),
getLeadFieldOptions(),
]);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Lead Pipeline</h1>
<CreateLeadModal />
</div>
<LeadsViewToggle leads={leads} options={options} />
</div>
);
}
```
Dopo aver scritto il file, eseguire il build per verificare zero errori TypeScript:
```bash
cd /Users/simonecavalli/Vault/IAMCAVALLI && npx next build 2>&1 | tail -30
```
Se il build fallisce per errori TypeScript, correggerli prima di considerare il task completo.
</action>
<verify>
<automated>grep -n "LeadsViewToggle\|LeadsSearch" /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/leads/page.tsx</automated>
</verify>
<acceptance_criteria>
- `page.tsx` importa `LeadsViewToggle` da `@/components/admin/leads/LeadsViewToggle`
- `page.tsx` NON importa più `LeadsSearch` direttamente (o se lo importa, è solo tramite il re-export che risolve in LeadsViewToggle)
- `page.tsx` renderizza `<LeadsViewToggle leads={leads} options={options} />`
- `npx next build` completa senza errori TypeScript (warning accettabili, errori no)
</acceptance_criteria>
<done>
page.tsx aggiornato. Build Next.js verde. La pagina /admin/leads mostra il toggle Lista/Kanban con la board drag-drop funzionante.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
LeadsKanbanBoard con 6 colonne stage, drag-drop che persiste su updateLeadField, LeadsViewToggle con search integrata e pill toggle, page.tsx aggiornato.
</what-built>
<how-to-verify>
1. Aprire http://localhost:3000/admin/leads (avviare il dev server con `npm run dev` se non già attivo)
2. Verificare che la pagina mostri: barra di ricerca a sinistra + pill toggle "Lista / Kanban" a destra
3. La vista Lista deve mostrare la tabella esistente (identica a prima della fase)
4. Cliccare "Kanban": deve apparire una board con 6 colonne (Contattato, Qualificato, Offerta inviata, Trattativa, Vinto, Perso) e i lead nelle colonne corrispondenti al loro stage
5. Trascinare un lead da una colonna a un'altra: la card deve spostarsi ottimisticamente; dopo pochi secondi la pagina si aggiorna e il lead rimane nella nuova colonna
6. Spostare un lead nella colonna "Vinto" o "Perso": verificare che il lead rimanga lì dopo il refresh
7. Digitare un nome nella barra di ricerca mentre si è in vista Kanban: la board deve filtrare le card in tempo reale
8. Tornare alla vista Lista: la ricerca deve ancora essere attiva con lo stesso testo
</how-to-verify>
<resume-signal>Digita "approvato" se tutto funziona, oppure descrivi i problemi riscontrati</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → Server Action | `handleDragEnd` invia `(leadId, "status", newStage)` via `updateLeadField`; newStage arriva dal DOM (over.id) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-01 | Tampering | `handleDragEnd` — over.id come colonna di destinazione | mitigate | Guard client-side: `if (!(LEAD_COLUMNS.map(c => c.id) as string[]).includes(over.id as string)) return` prima di chiamare updateLeadField. Guard server-side: `updateLeadField` valida già `LEAD_STAGES.includes(value)` e lancia errore su valore invalido (Phase 14 pattern invariato). |
| T-19-02 | Elevation of Privilege | `updateLeadField` server action | accept | `requireAdmin()` già presente nell'azione (Phase 14); il kanban chiama la stessa azione della tabella inline-edit, stessa protezione. |
</threat_model>
<verification>
## Verifica fase completa
```bash
# 1. Build pulito
cd /Users/simonecavalli/Vault/IAMCAVALLI && npx next build 2>&1 | grep -E "error|Error|✓ Compiled"
# 2. File creati
ls /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx
ls /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsViewToggle.tsx
# 3. Struttura corretta LeadsKanbanBoard
grep -c "useDroppable\|useDraggable\|DragOverlay\|updateLeadField" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx
# 4. Tutti e 6 gli stage nella board
grep -v '^//' /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx | grep -c '"contacted"\|"qualified"\|"proposal_sent"\|"negotiating"\|"won"\|"lost"'
# 5. Toggle presente in LeadsViewToggle
grep -c 'useState<"list" | "kanban">' /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsViewToggle.tsx
# 6. page.tsx non importa più LeadsSearch direttamente
grep "LeadsSearch" /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/leads/page.tsx | wc -l
# deve essere 0
```
</verification>
<success_criteria>
**PIPE-01:** La board Kanban con 6 colonne (contacted → lost) è visibile a `/admin/leads` dopo aver cliccato "Kanban". Drag-drop aggiorna `leads.status` in modo persistente (il lead rimane nella nuova colonna dopo refresh).
**PIPE-02:** Spostare un lead nella colonna "Vinto" o "Perso" è il gesto manuale che registra l'esito — nessun modale, nessuna conferma, solo il drag-drop su quella colonna.
**Retrocompatibilità:** La vista Lista (LeadTable con inline edit) resta invariata e accessibile tramite il toggle "Lista".
</success_criteria>
<output>
Dopo il completamento, creare `.planning/phases/19-pipeline-crm-kanban/19-01-SUMMARY.md` seguendo il template in `@$HOME/.claude/get-shit-done/templates/summary.md`.
</output>
@@ -0,0 +1,95 @@
---
phase: 19-pipeline-crm-kanban
plan: 01
status: complete
completed_at: "2026-06-19"
requirements_satisfied:
- PIPE-01
- PIPE-02
commits:
- 2c67e6f
- 607c257
- 34be934
---
# Plan 19-01 Summary — LeadsKanbanBoard + LeadsViewToggle
## What Was Built
Plan 19-01 aggiunge una vista Kanban stile Pipedrive alla pagina `/admin/leads`, affiancata alla tabella esistente tramite un toggle Lista/Kanban.
### Componenti creati / modificati
| File | Tipo | Descrizione |
|------|------|-------------|
| `src/components/admin/leads/LeadsKanbanBoard.tsx` | Nuovo | Board Kanban con 6 colonne stage, drag-drop via @dnd-kit, ottimistic update |
| `src/components/admin/leads/LeadsViewToggle.tsx` | Nuovo | Client wrapper con `useState<"list" \| "kanban">`, search integrata, pill toggle |
| `src/app/admin/leads/LeadsSearch.tsx` | Modificato | Svuotato e ri-esporta `LeadsViewToggle as LeadsSearch` per retrocompatibilità |
| `src/app/admin/leads/page.tsx` | Modificato | Ora renderizza `<LeadsViewToggle>` invece di `<LeadsSearch>` |
### Dettaglio implementazione
**LeadsKanbanBoard.tsx**
- `"use client"` — componente puramente client
- `LEAD_COLUMNS` definite come costante locale (6 stage: contacted, qualified, proposal_sent, negotiating, won, lost) con `headerClass` e `dotClass` per ogni colonna
- `DroppableColumn``useDroppable({ id })`, evidenziazione `isOver`, pill badge count, empty state "Nessun lead"
- `DraggableLeadCard``useDraggable({ id: lead.id })`, opacity-30 in drag, mostra name / company / next_action
- `LeadsKanbanBoard` (export) — `useState<Record<string, LeadStage>>` ottimistic, `PointerSensor distance:5` + `KeyboardSensor`, `handleDragEnd` con guard client-side su `over.id ∈ LEAD_COLUMNS`, `startTransition(async () => { await updateLeadField(leadId, "status", newStage); router.refresh(); })`
- Layout: `overflow-x-auto``grid grid-cols-6 gap-3 min-w-[1080px]`
- `DragOverlay dropAnimation={null}` con ghost card `rotate-1 border-2 border-[#1A463C] shadow-xl`
**LeadsViewToggle.tsx**
- `useState<"list" | "kanban">("list")` per il toggle
- `useState("")` per la query di ricerca
- `useMemo` per `filtered` — filtra su name / email / company / status / tags
- Barra superiore: search input (max-w-sm, flex-1) + pill toggle (flex-shrink-0) su singola riga `flex justify-between items-center`
- Pill toggle con classi `bg-[#f4f4f5] rounded-lg p-1`, active: `bg-white text-[#1A463C] shadow-sm`
- Condizionale: `view === "list"``<LeadTable>` | `view === "kanban"``<LeadsKanbanBoard>`
**LeadsSearch.tsx** (ri-esportazione)
- Ridotto a una singola riga: `export { LeadsViewToggle as LeadsSearch } from "@/components/admin/leads/LeadsViewToggle"`
- Garantisce zero import-break su eventuali riferimenti residui
**page.tsx**
- Import aggiornato: `LeadsViewToggle` da `@/components/admin/leads/LeadsViewToggle`
- Render: `<LeadsViewToggle leads={leads} options={options} />`
## Commits
| Hash | Descrizione |
|------|-------------|
| `2c67e6f` | Task 1 — LeadsKanbanBoard.tsx con 6 colonne e drag-drop persistente |
| `607c257` | Task 2 — LeadsViewToggle.tsx + re-export LeadsSearch |
| `34be934` | Task 3 — page.tsx aggiornato + build check verde + fix overflow clipping |
> Nota: il commit `34be934` include anche un fix minore all'overflow del dropdown in `LeadTable` (il clipping del `overflow-x-auto` del wrapper Kanban tagliava il menu a tendina delle colonne tabella in vista Lista). Fix non bloccante, risolto nella stessa sessione.
## Requirements Satisfied
| Req | Descrizione | Verifica |
|-----|-------------|---------|
| PIPE-01 | Board Kanban 6 colonne con drag-drop che aggiorna `leads.status` | Drag tra colonne → `updateLeadField(id, "status", newStage)``router.refresh()` — persistente dopo reload |
| PIPE-02 | Vinto/Perso come cambio-colonna manuale, nessun modale | Trascinare su colonna "won" o "lost" registra l'esito direttamente |
## Must-Haves Verificati
| Truth | Stato |
|-------|-------|
| Board 6 colonne (contacted → qualified → proposal_sent → negotiating → won → lost) | ✅ |
| Drag-drop aggiorna `leads.status` in modo persistente | ✅ |
| Colonne Vinto/Perso registrano l'esito via cambio stage | ✅ |
| Vista Lista (LeadTable inline-edit) resta disponibile e funzionante | ✅ |
| Toggle Lista/Kanban preserva la query di ricerca al cambio vista | ✅ |
| Search filtra entrambe le viste (Lista e Kanban) con lo stesso `filtered` array | ✅ |
## Deferred Items
| Item | Motivazione | Priorità |
|------|-------------|----------|
| Overflow clipping del dropdown `LeadTable` in vista Lista (da `overflow-x-auto` del wrapper Kanban) | Non bloccante — già parzialmente mitigato nel commit 34be934; comportamento accettabile | Bassa — da affrontare in phase futura se segnalato |
## Self-Check
**PASSED** — tutti i must_have verificati, build verde, checkpoint umano approvato ("approvato").
Durata esecuzione: ~1 sessione sincrona (2026-06-19).
@@ -0,0 +1,433 @@
# Phase 19: Pipeline CRM Kanban — Research
**Researched:** 2026-06-19
**Domain:** @dnd-kit drag-drop, Next.js App Router client components, CRM leads view toggle
**Confidence:** HIGH — all findings verified directly from codebase
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| PIPE-01 | I lead sono visualizzabili in una board Kanban stile Pipedrive con colonne per stage e drag-drop per cambiare stage | `leads.status` enum verified (6 stages); `@dnd-kit/core` v6.3.1 already installed; exact analog in `KanbanBoard.tsx` |
| PIPE-02 | Spostare un lead nelle colonne "Vinto"/"Perso" è il cambio-stato manuale dell'esito | `won`/`lost` are existing LEAD_STAGES values; `updateLeadField(id, "status", value)` handles this today via the table dropdown |
</phase_requirements>
---
## Summary
Phase 14 delivered a complete inline-edit table view of leads (`LeadTable.tsx`) backed by a solid data layer: `getLeadsWithTags()`, `updateLeadField()`, typed `LEAD_STAGES`, and a polymorphic tag system. Phase 19 adds a second view — Kanban — toggled from the same page, without replacing or changing any of that.
The project already ships a working `KanbanBoard.tsx` (for project tasks) that uses exactly the `@dnd-kit` primitives needed here. The new `LeadsKanbanBoard` is a direct structural analog: swap task status columns (`todo/in_progress/done`) for lead stage columns (`contacted/qualified/proposal_sent/negotiating/won/lost`), swap task cards for lead cards, swap `updateTaskStatus` for `updateLeadField(id, "status", newStage)`.
The view-toggle pattern is also ready in `PhasesViewToggle.tsx` — a client component that holds `useState<"list" | "kanban">` and renders either `listView` (a `ReactNode` passed as prop) or the kanban. The leads page only needs a `LeadsViewToggle` wrapper that receives the existing `LeadsSearch` as the `listView` slot and the new `LeadsKanbanBoard` as the kanban.
No schema changes. No new server actions. No new dependencies. This is a pure UI addition.
**Primary recommendation:** Copy the `KanbanBoard.tsx` structure exactly; adapt for 6 lead-stage columns; wire to `updateLeadField`; wrap with a `LeadsViewToggle` component in `LeadsSearch` or at the page level.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Kanban board rendering + drag state | Browser / Client | — | Drag-drop is inherently client-side; `"use client"` required |
| Lead status persistence on drop | API / Backend (Server Action) | — | `updateLeadField` is already a `"use server"` action |
| Lead data fetching | Frontend Server (SSR) | — | `LeadsPage` is a server component; passes data down as props |
| View toggle state (table / kanban) | Browser / Client | — | `useState` in a client wrapper component |
| Column definitions (stage labels, colors) | Browser / Client | — | Derived from `LEAD_STAGES` constant, purely presentational |
---
## Standard Stack
### Core (already installed — no new installs needed)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| @dnd-kit/core | ^6.3.1 [VERIFIED: package.json] | DndContext, useDraggable, useDroppable, sensors | Already used in KanbanBoard.tsx |
| @dnd-kit/sortable | ^10.0.0 [VERIFIED: package.json] | Available but NOT used by existing KanbanBoard | Not needed; existing pattern uses useDraggable + useDroppable directly |
| @dnd-kit/utilities | ^3.2.2 [VERIFIED: package.json] | CSS.Transform helper | Imported if transform style needed |
| React (useTransition, useState) | via Next.js 16 | Optimistic state + async server action bridging | Project pattern |
**Installation:** None required. All dependencies already present.
### Existing Primitives Used by KanbanBoard.tsx [VERIFIED: src/components/admin/kanban/KanbanBoard.tsx]
```typescript
import {
DndContext, // Root context — wraps the entire board
DragEndEvent, // Event type for onDragEnd handler
DragOverlay, // Ghost card rendered at cursor during drag
PointerSensor, // Mouse/touch activation
KeyboardSensor, // Accessibility
useSensor,
useSensors,
useDroppable, // Applied to column containers
useDraggable, // Applied to individual cards
} from "@dnd-kit/core";
```
Note: `@dnd-kit/sortable` / `SortableContext` / `useSortable` are NOT used. The existing pattern uses the lower-level `useDraggable` + `useDroppable` primitives, which is appropriate for cross-column drag (not intra-column reordering).
---
## Architecture Patterns
### System Architecture Diagram
```
LeadsPage (server component)
├─ getLeadsWithTags() ──────────────────────────────► Postgres / leads + tags
├─ getLeadFieldOptions() ───────────────────────────► Postgres / tags
└─ renders LeadsViewToggle (client component)
├─ [view="list"] → LeadsSearch → LeadTable (existing, unchanged)
└─ [view="kanban"] → LeadsKanbanBoard (new)
├─ DndContext (onDragEnd → updateLeadField server action)
├─ DroppableColumn × 6 (one per LEAD_STAGES value)
└─ DraggableLeadCard × N (one per lead)
└─ useTransition + router.refresh() (after persist)
```
### Recommended File Structure
```
src/
├─ components/admin/leads/
│ ├─ LeadTable.tsx # EXISTING — unchanged
│ └─ LeadsKanbanBoard.tsx # NEW — analogous to KanbanBoard.tsx
├─ app/admin/leads/
│ ├─ page.tsx # MODIFIED — wrap with LeadsViewToggle
│ ├─ LeadsSearch.tsx # MODIFIED — receives view toggle or replaced by LeadsViewToggle
│ └─ actions.ts # EXISTING — updateLeadField already handles status changes
```
The view toggle can live either at the page level (simpler) or inside `LeadsSearch` (keeps search state alive across views). Recommended: extract a `LeadsViewToggle` client wrapper at the page level (same pattern as `PhasesViewToggle`), passing `<LeadsSearch leads={leads} options={options} />` as the `listView` ReactNode and `<LeadsKanbanBoard leads={leads} />` as the kanban.
### Pattern 1: Column definition for 6 lead stages
```typescript
// Source: VERIFIED from src/lib/lead-validators.ts + src/components/admin/leads/LeadTable.tsx
type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost";
const LEAD_COLUMNS: {
id: LeadStage;
label: string;
headerClass: string;
dotClass: string;
}[] = [
{ id: "contacted", label: "Contattato", headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
{ id: "qualified", label: "Qualificato", headerClass: "text-[#1A463C]", dotClass: "bg-purple-400" },
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700", dotClass: "bg-amber-400" },
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700", dotClass: "bg-orange-400" },
{ id: "won", label: "Vinto", headerClass: "text-green-700", dotClass: "bg-green-500" },
{ id: "lost", label: "Perso", headerClass: "text-red-700", dotClass: "bg-red-400" },
];
```
### Pattern 2: Lead Kanban Board (adapted from KanbanBoard.tsx)
```typescript
// Source: VERIFIED structure from src/components/admin/kanban/KanbanBoard.tsx
// Key adaptation: replace taskStatuses/updateTaskStatus with leadStatuses/updateLeadField
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
DndContext, DragEndEvent, DragOverlay,
PointerSensor, KeyboardSensor, useSensor, useSensors,
useDroppable, useDraggable,
} from "@dnd-kit/core";
import { updateLeadField } from "@/app/admin/leads/actions";
import type { LeadWithTags } from "@/lib/admin-queries";
export function LeadsKanbanBoard({ leads }: { leads: LeadWithTags[] }) {
const router = useRouter();
const [, startTransition] = useTransition();
const [activeId, setActiveId] = useState<string | null>(null);
const [leadStatuses, setLeadStatuses] = useState<Record<string, LeadStage>>(
() => Object.fromEntries(leads.map((l) => [l.id, l.status as LeadStage]))
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor)
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const leadId = active.id as string;
const newStage = over.id as LeadStage;
if (newStage === leadStatuses[leadId]) return;
// Optimistic update
setLeadStatuses((prev) => ({ ...prev, [leadId]: newStage }));
// Persist
startTransition(async () => {
await updateLeadField(leadId, "status", newStage);
router.refresh();
});
}
// ... render DndContext with LEAD_COLUMNS mapped to DroppableColumn
}
```
### Pattern 3: View toggle (adapted from PhasesViewToggle.tsx)
```typescript
// Source: VERIFIED from src/components/admin/kanban/PhasesViewToggle.tsx
"use client";
import { useState, type ReactNode } from "react";
import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
export function LeadsViewToggle({
listView,
leads,
}: {
listView: ReactNode;
leads: LeadWithTags[];
}) {
const [view, setView] = useState<"list" | "kanban">("list");
return (
<div>
{/* Toggle buttons — same pill pattern as PhasesViewToggle */}
{view === "list" ? listView : <LeadsKanbanBoard leads={leads} />}
</div>
);
}
```
### Pattern 4: Lead card content
Each Kanban card should show: `name` (primary), `company` (secondary/optional), `next_action` (hint text, optional). Avoid showing `email`/`phone`/`tags` on the card to keep it compact — these are available in the table view.
```typescript
// Fields available on LeadWithTags (VERIFIED: src/lib/admin-queries.ts line 890)
// Lead & { tags: string[] }
// Relevant for card: name, company, next_action, status
```
### Anti-Patterns to Avoid
- **Using `useSortable` / `SortableContext`:** The existing project pattern does NOT use these. They are for intra-column reordering. Use `useDraggable` + `useDroppable` to match the established `KanbanBoard.tsx` pattern.
- **Calling `router.refresh()` before `await updateLeadField`:** Always await the server action first, then refresh. The existing KanbanBoard does this correctly inside `startTransition`.
- **Dropping `react-hook-form` / Zod on drag-drop:** No form validation needed for a status change — `updateLeadField` already validates via `LEAD_STAGES.includes(value)` check.
- **Removing `LeadsSearch` / `LeadTable`:** PIPE-01 requires the table to remain as an alternative view. Do not replace it.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Drag detection (distance threshold) | Custom mouse event tracking | `PointerSensor` with `activationConstraint: { distance: 5 }` | Already proven in KanbanBoard.tsx; prevents accidental drag on click |
| Keyboard accessibility for drag | Custom key handlers | `KeyboardSensor` from @dnd-kit/core | a11y for free |
| Drag ghost/overlay | CSS clone positioning | `DragOverlay` from @dnd-kit/core | Correct portal rendering, no z-index fights |
| Optimistic UI update | Complex local state with rollback | `useState` + `useTransition` (React pattern) | Already used in KanbanBoard.tsx and LeadTable.tsx |
| Status validation | Re-implementing LEAD_STAGES check | `updateLeadField` server action already validates status | DRY — the action throws on invalid stage |
**Key insight:** The entire drag-drop + persist pattern is already implemented and tested in `KanbanBoard.tsx`. This phase is a structural copy with domain adaptation, not a new implementation.
---
## Common Pitfalls
### Pitfall 1: Columns wider than viewport on 6-stage board
**What goes wrong:** 6 columns in `grid-cols-6` become too narrow on typical laptop screens (12801440px). The 3-column project kanban uses `grid-cols-3` with comfortable card width.
**Why it happens:** 6 × min-width ≈ 720px+ is tight.
**How to avoid:** Use `grid-cols-3 lg:grid-cols-6` or a horizontally scrollable container (`overflow-x-auto` on the grid wrapper). Alternatively, `min-w-[200px]` per column inside a scroll container.
**Warning signs:** Cards truncate before the lead name is visible.
### Pitfall 2: Won/Lost columns need visual distinction
**What goes wrong:** Dropping to "won" or "lost" looks identical to other columns — user may not notice the semantic weight of these terminal states.
**Why it happens:** Uniform column styling.
**How to avoid:** Use visually distinct `headerClass` (green for won, red for lost) and consider a stronger `isOver` highlight for these columns. The STAGE_COLOR map in `LeadTable.tsx` already defines these colors — reuse them.
### Pitfall 3: Leads not sorted consistently between views
**What goes wrong:** Table shows leads ordered by `updated_at DESC`; kanban derived from the same array shows different visual order depending on column grouping.
**Why it happens:** No explicit sort on the kanban card order within a column.
**How to avoid:** Sort leads within each column by `updated_at DESC` (same as the existing query order). The `getLeadsWithTags` query already returns `orderBy(desc(leads.updated_at))` so inheriting that order is sufficient.
### Pitfall 4: `router.refresh()` causes full re-mount of kanban
**What goes wrong:** After a drag-drop, `router.refresh()` rehydrates the server component, re-running `getLeadsWithTags()`. If the drag animation hasn't completed, it can cause a visual flicker.
**Why it happens:** Next.js App Router refresh re-renders the whole tree.
**How to avoid:** The existing `KanbanBoard.tsx` uses the same pattern without issue. The `setActiveId(null)` call in `handleDragEnd` clears the overlay before the refresh arrives, so the flicker is acceptable. This is the project's established pattern — do not deviate.
### Pitfall 5: Search/filter not available in kanban view
**What goes wrong:** The search bar lives in `LeadsSearch.tsx` and only filters `LeadTable`. If the user switches to kanban, they lose the ability to filter.
**Why it happens:** The view toggle renders either `LeadsSearch` (with its internal state) or the bare `LeadsKanbanBoard`.
**How to avoid:** Two acceptable approaches: (a) wrap both views together inside `LeadsSearch` and pass filtered leads to both (preferred — search state persists across view switches); or (b) accept that kanban shows all leads unfiltered (simpler, acceptable for now given the single-user context). Document the choice in the plan.
---
## Code Examples
### Existing updateLeadField signature (server action)
```typescript
// Source: VERIFIED from src/app/admin/leads/actions.ts line 174
// EDITABLE_FIELDS includes "status" — drag-drop can call this directly
export async function updateLeadField(
leadId: string,
fieldName: "name" | "email" | "phone" | "company" | "status" | "next_action",
value: string
): Promise<void>
// Validates: status must be in LEAD_STAGES; throws on invalid value
// Side effects: revalidatePath("/admin/leads") + revalidatePath(`/admin/leads/${leadId}`)
```
### LEAD_STAGES canonical values
```typescript
// Source: VERIFIED from src/lib/lead-validators.ts line 4
export const LEAD_STAGES = [
"contacted",
"qualified",
"proposal_sent",
"negotiating",
"won",
"lost",
] as const;
```
### Existing STAGE_COLOR map (reuse for kanban column headers)
```typescript
// Source: VERIFIED from src/components/admin/leads/LeadTable.tsx line 20
const STAGE_COLOR: Record<string, string> = {
contacted: "bg-blue-100 text-blue-800",
qualified: "bg-purple-100 text-purple-800",
proposal_sent: "bg-amber-100 text-amber-800",
negotiating: "bg-orange-100 text-orange-800",
won: "bg-green-100 text-green-800",
lost: "bg-red-100 text-red-800",
};
// Move to a shared constant (e.g., src/lib/lead-constants.ts) if reused in both components
```
### PhasesViewToggle pattern (exact analog)
```typescript
// Source: VERIFIED from src/components/admin/kanban/PhasesViewToggle.tsx
// State: useState<"list" | "kanban">("list")
// Toggle: pill button group (bg-[#f4f4f5] rounded-lg p-1 w-fit)
// Active: bg-white text-[#1A463C] shadow-sm
// Inactive: text-[#71717a] hover:text-[#1a1a1a]
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Lead status change via modal form | Inline dropdown in table cell (`StatusCell`) | Phase 14 | Drag-drop is the third mechanism; all write to same `updateLeadField` action |
| Separate `/admin/analytics` route | Fused into `/admin` dashboard | Phase 18 | No impact on leads page |
| `SendQuoteModal` with dead branch | Dead branch removed | Phase 18 | No impact |
---
## Project Constraints (from CLAUDE.md)
| Directive | Impact on This Phase |
|-----------|---------------------|
| `clients.token` = rotatable, never PK | Not relevant (leads have no token) |
| `quote_items` never exposed via client API | Not relevant (Kanban is admin-only) |
| `deliverables.approved_at` immutable once set | Not relevant |
| Auth: `/admin/*` → Auth.js session | Kanban lives at `/admin/leads` — already protected |
| No file hosting v1 | Not relevant |
| Migration safety: never drop/truncate rows | Phase 19 is UI-only — no schema changes, no migration needed |
| Security: confirm before destructive commands | No destructive operations |
| No package installs without showing name+version | No new packages needed |
---
## Environment Availability
Step 2.6: SKIPPED — Phase 19 is a pure UI addition. All required libraries (`@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`) are already installed. No external services, databases (beyond the existing Neon Postgres connection), or CLI tools are needed.
---
## Validation Architecture
`nyquist_validation: false` in `.planning/config.json` — section omitted per config.
---
## Security Domain
Phase 19 adds a new interaction path to an existing admin-only route (`/admin/leads`). No new auth surface is introduced.
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes (existing) | Auth.js session via `requireAdmin()` in server action |
| V4 Access Control | yes (existing) | `requireAdmin()` guard in `updateLeadField` — drag-drop calls same action |
| V5 Input Validation | yes | `updateLeadField` validates status via `LEAD_STAGES.includes(value)` — no new validation needed |
No new threat surface beyond what Phase 14 already addressed. The drag-drop `handleDragEnd` validates the `over.id` is a known stage before calling the server action — follow the same guard pattern as in `KanbanBoard.tsx` (line 190: `if (!(["todo", "in_progress", "done"] as string[]).includes(newStatus)) return;`).
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | The `won` and `lost` columns are terminal states with no special side-effects beyond setting `leads.status` (no auto-creation of client/project, no email trigger) | Architecture Patterns | If the plan later requires auto-provisioning on "won", a new server action will be needed — but PIPE-01/02 say nothing about this, and PROP-04 (auto-provisioning) is deferred to backlog post-R5 |
| A2 | Card content (name, company, next_action) is sufficient for the kanban view; no additional fields are needed per card | Code Examples | If the user wants tags or email visible on cards, the `LeadWithTags` type already provides them — no data-layer change, only card template change |
| A3 | The search filter covering only the table view (not the kanban) is acceptable for v1 of this feature | Common Pitfalls | If the user wants search in kanban too, the fix is to lift filtered state into the toggle wrapper — straightforward but adds scope |
---
## Open Questions
1. **Search/filter scope in kanban view**
- What we know: `LeadsSearch` holds the search `useState` and passes `filtered` leads to `LeadTable`. The kanban would receive all leads from the page.
- What's unclear: Does the user want the search bar to filter the kanban board too, or is it acceptable that kanban shows all leads?
- Recommendation: Default to wrapping both views inside a new `LeadsViewToggle` that receives `leads` (unfiltered) and `options`, manages the view toggle, and passes `filtered` leads to both `LeadTable` and `LeadsKanbanBoard`. This is a clean pattern and handles it gracefully.
2. **Column layout: scroll vs. wrap on 6 columns**
- What we know: The existing kanban uses `grid-cols-3`. Six columns need more space.
- What's unclear: Target viewport is unknown (likely 1440px+ since this is a single-admin tool).
- Recommendation: Use `min-w-[180px]` per column inside an `overflow-x-auto` wrapper. This makes it work on any viewport without content truncation.
---
## Sources
### Primary (HIGH confidence)
- `src/components/admin/kanban/KanbanBoard.tsx` — exact @dnd-kit usage pattern, drag primitives, sensors, DragOverlay, optimistic update + router.refresh()
- `src/components/admin/kanban/PhasesViewToggle.tsx` — view toggle pattern (list/kanban state, pill button UI)
- `src/components/admin/leads/LeadTable.tsx` — STAGE_COLOR map, LeadWithTags usage, StatusCell inline dropdown
- `src/app/admin/leads/actions.ts``updateLeadField` signature, EDITABLE_FIELDS, `requireAdmin()` guard
- `src/lib/lead-validators.ts` — canonical LEAD_STAGES array (6 values)
- `src/lib/admin-queries.ts` lines 883942 — `LeadWithTags` type, `getLeadsWithTags()` query (all fields), `LeadFieldOptions`
- `src/db/schema.ts` lines 441462 — `leads` table definition, `status` column with all 6 stage values documented
- `src/app/admin/leads/LeadsSearch.tsx` — search filter pattern, `LeadWithTags` + `LeadFieldOptions` prop interface
- `src/app/admin/leads/page.tsx` — server component structure, data fetching pattern, `revalidate = 0`
- `src/components/admin/AdminSidebar.tsx``/admin/leads` is already in NAV_ITEMS, no sidebar change needed
- `package.json`@dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2
### Secondary (MEDIUM confidence)
- `.planning/config.json``nyquist_validation: false` confirmed
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all packages verified in package.json; exact primitives verified in KanbanBoard.tsx
- Architecture: HIGH — all patterns verified from existing codebase analogs
- Pitfalls: HIGH — derived from direct code inspection and known Next.js App Router behaviors
- Data layer: HIGH — schema, actions, and query functions all read directly
**Research date:** 2026-06-19
**Valid until:** Stable indefinitely (no external dependencies; codebase-derived findings)
@@ -0,0 +1,99 @@
---
phase: 19-pipeline-crm-kanban
verified: 2026-06-19T18:09:00+02:00
status: passed
score: 5/5
overrides_applied: 0
re_verification: false
---
# Phase 19: Pipeline CRM Kanban — Verification Report
**Phase Goal:** Aggiungere vista Kanban drag-drop alla pagina /admin/leads (PIPE-01 + PIPE-02), affiancata alla LeadTable esistente via toggle Lista/Kanban.
**Verified:** 2026-06-19T18:09:00+02:00
**Status:** passed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | I lead sono visibili in una board Kanban con 6 colonne per stage (contacted, qualified, proposal_sent, negotiating, won, lost) | VERIFIED | `LeadsKanbanBoard.tsx` lines 19-33: `LeadStage` type and `LEAD_COLUMNS` array define all 6 stages with labels Contattato/Qualificato/Offerta inviata/Trattativa/Vinto/Perso. `grid-cols-6` layout confirmed line 159. |
| 2 | Trascinare un lead da una colonna a un'altra aggiorna leads.status in modo persistente | VERIFIED | `handleDragEnd` (lines 132-150): optimistic `setLeadStatuses`, then `startTransition(async () => { await updateLeadField(leadId, "status", newStage); router.refresh(); })`. `updateLeadField` confirmed in `actions.ts` line 174 with `revalidatePath` side-effect. |
| 3 | Spostare un lead nella colonna Vinto o Perso registra l'esito cambiando il campo status | VERIFIED | `won` and `lost` are members of `LEAD_COLUMNS` (lines 31-32) and `VALID_STAGES` (line 35). `handleDragEnd` line 141: `if (!VALID_STAGES.includes(newStage)) return` — only valid stages accepted. Dropping on "Vinto"/"Perso" column calls `updateLeadField(leadId, "status", "won"/"lost")`. Server-side `actions.ts` line 190 validates against `LEAD_STAGES` before writing to DB. |
| 4 | La vista tabella inline-edit (LeadTable) resta disponibile e funzionante come vista alternativa | VERIFIED | `LeadsViewToggle.tsx` lines 70-71: `view === "list" ? <LeadTable leads={filtered} options={options} /> : <LeadsKanbanBoard leads={filtered} />`. `LeadTable` imported from existing component (line 6). `LeadsSearch.tsx` re-exports `LeadsViewToggle as LeadsSearch` — no existing import paths broken. |
| 5 | Il toggle Lista/Kanban è visibile sopra il contenuto e preserva lo stato della ricerca quando si cambia vista | VERIFIED | `LeadsViewToggle.tsx`: single `useState("")` for query (line 18) and single `useMemo` for `filtered` (lines 20-31) shared by both views. Toggling `view` state does not reset `query` — both `<LeadTable>` and `<LeadsKanbanBoard>` receive the same `filtered` array. Pill toggle rendered above content in `flex justify-between` bar (lines 35-67). |
**Score:** 5/5 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `src/components/admin/leads/LeadsKanbanBoard.tsx` | Board Kanban con DndContext, 6 DroppableColumn, DraggableLeadCard, DragOverlay, ottimistic update | VERIFIED | 184 lines. `"use client"`, `DndContext`, `DragOverlay`, `useDroppable`, `useDraggable`, `useTransition`, `useState<Record<string, LeadStage>>`, all 6 LEAD_COLUMNS, `overflow-x-auto`, `grid-cols-6`, `startTransition`. Exports `LeadsKanbanBoard`. |
| `src/components/admin/leads/LeadsViewToggle.tsx` | Client wrapper con useState<'list' \| 'kanban'> e pill toggle | VERIFIED | 77 lines. `"use client"`, `useState<"list" \| "kanban">("list")`, `useState("")`, `useMemo`, pill toggle with `bg-[#f4f4f5] rounded-lg p-1`. Exports `LeadsViewToggle`. |
| `src/app/admin/leads/LeadsSearch.tsx` | Search + toggle integrati; passa filtered leads sia a LeadTable che a LeadsKanbanBoard | VERIFIED | Re-exports `LeadsViewToggle as LeadsSearch` — search and filtering now live in `LeadsViewToggle`. Backward compatibility preserved. |
| `src/app/admin/leads/page.tsx` | Server component aggiornato che renderizza LeadsViewToggle | VERIFIED | 24 lines. Imports `LeadsViewToggle` from `@/components/admin/leads/LeadsViewToggle`. Renders `<LeadsViewToggle leads={leads} options={options} />`. No `LeadsSearch` import. |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `LeadsKanbanBoard.tsx``handleDragEnd` | `actions.ts``updateLeadField` | `startTransition(async () => { await updateLeadField(leadId, "status", newStage); router.refresh(); })` | WIRED | Line 147: `await updateLeadField(leadId, "status", newStage)` — exact match. `startTransition` at line 146. `router.refresh()` at line 148. |
| `LeadsViewToggle.tsx``filtered` | `LeadsKanbanBoard``leads` prop | `filtered` array passed as prop | WIRED | Line 73: `<LeadsKanbanBoard leads={filtered} />` — exact match. |
| `LeadsViewToggle` / view state | `LeadTable` + `LeadsKanbanBoard` | `useState<"list" \| "kanban">` | WIRED | Line 17: `useState<"list" \| "kanban">("list")`. Lines 70-73: conditional render branches both views from shared state. |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `LeadsKanbanBoard.tsx` | `leads: LeadWithTags[]` | `getLeadsWithTags()` in `page.tsx` | Yes — Drizzle ORM `db.select().from(leads).leftJoin(tags, ...)` (admin-queries.ts lines 893-917). Returns aggregated rows with tags array. | FLOWING |
| `LeadsViewToggle.tsx` | `filtered` (derived from `leads` prop) | Same `getLeadsWithTags()` → passed as prop from page.tsx | Yes — `useMemo` derives from server-fetched `leads` prop. | FLOWING |
### Behavioral Spot-Checks
Runnable entry points require a live dev server. Spot-checks performed via static analysis against the call chain instead.
| Behavior | Check | Result | Status |
|----------|-------|--------|--------|
| `handleDragEnd` persists status change | `updateLeadField(leadId, "status", newStage)` call confirmed + `requireAdmin()` guard in actions.ts | Call at line 147; auth guard at actions.ts line 179 | PASS |
| Won/Lost drag records outcome | `VALID_STAGES.includes(newStage)` guard + `won`/`lost` in LEAD_COLUMNS | Line 141 guard; lines 31-32 column defs | PASS |
| Search state preserved across view switch | Single `query` state, single `filtered` memo, shared by both render branches | Lines 18, 20-31, 70-73 in LeadsViewToggle.tsx | PASS |
| Commits exist in git | `git log 2c67e6f 607c257 34be934` | All 3 hashes present with correct descriptions | PASS |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|---------|
| PIPE-01 | 19-01-PLAN.md | I lead sono visualizzabili in una board Kanban stile Pipedrive con colonne per stage e drag-drop per cambiare stage | SATISFIED | `LeadsKanbanBoard.tsx`: 6 `DroppableColumn` components, `DndContext` with `onDragEnd`, optimistic state update, persistent write via `updateLeadField`. |
| PIPE-02 | 19-01-PLAN.md | Spostare un lead nelle colonne "Vinto"/"Perso" è il cambio-stato manuale dell'esito | SATISFIED | `won` and `lost` are standard columns in `LEAD_COLUMNS`. Dropping a card on either column triggers the same `updateLeadField` path — no modal, no separate confirmation. |
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `LeadsKanbanBoard.tsx` | 110 | `const [, startTransition] = useTransition()` — unused first element of destructure | Info | No functional impact; minor lint noise. |
No TODOs, FIXMEs, placeholder returns, or empty implementations found in any of the four modified files.
### Human Verification Required
A human checkpoint was completed and approved prior to this verification (the task plan included a blocking `checkpoint:human-verify` gate). The developer confirmed:
- Board renders 6 columns with leads in correct stages
- Drag-drop moves cards and persists after reload
- Won/Lost columns register outcomes correctly
- Lista/Kanban toggle works with search state preserved
No further human verification items are outstanding.
### Gaps Summary
No gaps. All 5 must-have truths are VERIFIED against the codebase. All 4 artifacts are substantive and wired. All 3 key links are confirmed. Data flows from a real Drizzle ORM query. Both PIPE-01 and PIPE-02 are satisfied. Build passes (0 TypeScript errors, confirmed in commit 34be934 and known context). Human checkpoint approved in-session.
---
_Verified: 2026-06-19T18:09:00+02:00_
_Verifier: Claude (gsd-verifier)_
@@ -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