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:
@@ -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 (1280–1440px). 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 883–942 — `LeadWithTags` type, `getLeadsWithTags()` query (all fields), `LeadFieldOptions`
|
||||
- `src/db/schema.ts` lines 441–462 — `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*
|
||||
+40
@@ -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
|
||||
Reference in New Issue
Block a user