Compare commits

..

45 Commits

Author SHA1 Message Date
simone 26d752892a style: align light palette to Quiet Luxury reference
- Page background now #F8F9FA (was white); tokens matched to reference:
  foreground slate-900, muted slate-50/slate-500, border slate-200,
  card pure white, tertiary slate-400, border-light slate-100
- Sidebar colors matched to mock: emerald-200/70 inactive, white/8 active
  with emerald-400 active icon, emerald-50 logo, red-300 logout, right border
- Content wrapped in max-w-[1400px] centered container (px-8 lg:px-10)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:33:00 +02:00
simone 9eb2d45c67 fix: sidebar width/centering + kanban card badge & table details
- Sidebar: expanded 256->200px, collapsed 80->64px; collapsed state now
  renders icon-only (label removed from flow) so icons are centered, not
  pushed left by an invisible text span
- Kanban cards: add StatusBadge + email line to match design reference
- Lead table: add "Mostrando N lead" footer and center/right-align
  Stato/Tag/Azioni headers per reference

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:28:43 +02:00
simone 86e1499e8f feat: Quiet Luxury design system + Pipeline (ex-Lead) redesign
- Design system foundations: Plus Jakarta Sans font, shadow-card/radius
  tokens in @theme, design-reference/DESIGN-SYSTEM.md with component inventory
- Collapsible admin shell (AdminShell): smooth w-64<->w-20 sidebar, new top
  header with "Admin" + avatar placeholder, localStorage-persisted state
- Rename route /admin/leads -> /admin/pipeline (redirect stubs preserved),
  nav label Lead -> Pipeline; DB table unchanged
- Reusable primitives: StatusBadge, SearchInput, SegmentedToggle (dual-theme)
- Luxury restyle of lead table, kanban (6 stages + @dnd-kit intact), PageHeader
- Tokenize editable-cell / option-multi-select for dark-mode legibility

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:16:44 +02:00
simone 43cb7e7469 feat: dark/light theming system + design tokens (OMC design system port)
- Restructure design tokens into :root/.dark raw vars mapped via @theme inline
- Add light/dark/system theming: useTheme hook + FOUC guard + ThemeToggle
- Keep iamcavalli palette (primary #1A463C, accent #DEF168) and Geist fonts
- Derive brand-consistent dark palette (verde-nero bg, lightened primary)
- Add scrollbar utilities (.no-scrollbar/.thin-scrollbar)
- Install tailwindcss-animate, register via @plugin (Tailwind v4)
- Mount ThemeToggle in admin sidebar footer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:08:56 +01:00
simone add2176a6b feat: tier names + per-tier prices on offer cards, unified admin UI
- offer editor: per-tier name input (mirrors public_name/internal_name)
- offer list cards: show 3-tier services total + manual public price
- shared PageHeader component, full-width layout across all admin pages
- UI-RULES.md design conventions doc

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:58:18 +02:00
simone 9abe1fe4bb fix: offer name in client detail, retainer forecast, LTV with offers, offers-sold chart
- Client detail offers: show offer macro name ("Mantenimento") instead of tier letter
- Forecast: retainers project the monthly fee across every month from start_date
  (no longer capped by duration_months); una_tantum unchanged
- Clients list LTV: per project max(accepted_total, sum of assigned offers) so a
  retainer with unset quote still counts
- Dashboard: new "Offerte vendute" chart (count by offer + tier)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:14:13 +02:00
simone ae355c33a6 feat: offer badges, unified dashboard, income forecast, Pipedrive-style follow-up
- Client detail: category + tier badges on active offers
- Dashboard: remove top KPI cards + recent activity; fold current KPIs into
  bottom MetricCard style; add 12-month income forecast chart
- Forecast query: branch by offer_type (retainer = monthly fee, una_tantum =
  spread over duration), filter archived projects
- Payments: set the month a payment was collected (paid_at) from PaymentsTab
- Redesign FollowUpWidget in clean Pipedrive style (brand green, no orange)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:28:12 +02:00
simone fc766ca1ee feat: add iamcavalli logo as favicon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 21:51:05 +02:00
simone 0b8a7b3809 feat: add Opus/Sonnet model selector to "Genera preventivo" page
Adds a "Modello AI" toggle (Opus default, Sonnet option) styled like the
existing Soggetto toggle. The server action validates the keyword and maps
it to the real model id; the agent receives the resolved id and uses it in
the Anthropic messages.create call. The proposals DB record stores the
actual model used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:34:04 +02:00
simone e10d1f70cb fix: cumulative price from offer_tier_services, offers accordion, retainer phase placeholder
- client-view.ts: read cumulative_price + services list from offer_tier_services→services
  (Phase 12 catalog); legacy fallback to offer_micro_services→offer_services when no
  new-style rows exist. Extend activeOffers type with services[] in both ProjectView and ClientView.
- OffersSection.tsx: extract OfferCard client component with useState accordion;
  hide "Valore incluso" row when price is 0; add "Cosa è compreso" chevron toggle
  listing included services (name + description when present).
- client-dashboard.tsx: hide progress bar and PhaseViewToggle when hasRetainer;
  render polished empty-state placeholder with RefreshCw icon instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:53:25 +02:00
simone 64030afef3 feat: sidebar CTA removal, delete phase/task, public dashboard UX, chat panel
1. AdminSidebar: removed yellow "Genera preventivo" CTA (duplicated Preventivi tab nav item)
2. Delete phase/task: new deletePhase/deleteTask server actions with FK cascade and phase-status recompute; DeletePhaseTaskButton client component with window.confirm; trash icons in PhasesTab per phase and per task
3. Public dashboard: extend activeOffers query with offer_macros join (offer_name, offer_type); reorder sidebar 1°Offerte 2°Pagamenti 3°Documenti; OffersSection shows macro public_name as heading (never tier letter/internal_name); PaymentStatus gains totalLabel/overrideAmount/hideRows props — retainer=monthly label + no Acconto/Saldo rows
4. Offer editor: Descrizione textarea (nota interna) and Modalità toggle moved directly under title; Tags section now contains only Categoria/Ticket/Tipo/Obiettivo
5. Basecamp chat: API route supports entity_type="phase" with authorization scoping; comments scope in client-view broadened to include phaseIds and client_id (general); CommentsTab updated with phase/general entities; ChatProvider React context; ChatPanel fixed slide-in panel with FAB (bottom-right) and composer tag selector (Generale + phases); PhaseCard gets chat-bubble icon pre-tagging that phase; inline ChatSection block removed from dashboard main column

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:11:55 +02:00
simone 4b28f254ba docs: correct DB-access procedure (direct SSH+docker exec, remote is gitea)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:39:45 +02:00
simone 186bb9ea19 feat: payments plans, phase auto-cascade, transcripts, manual timer
- Pagamenti: totale ereditato dalla somma accepted_total delle offerte
  attive (con override) + selettore schema rate 1/2/3 step; nuova colonna
  payments.percent per rescalare gli importi preservando label/stato
- Fasi & Task: recomputePhaseStatus auto-cascade (task -> fase) su
  updateTaskStatus/addTask, fix UI stale, etichette Da iniziare/In corso/Completata
- Pagina pubblica: colori fasi (grigio/blu/#1A463C) + fasi collassabili
- Transcript del cliente visibili in tab Documenti admin e dashboard pubblica
- Timer: inserimento manuale (+30/+60, minuti liberi, data) + elimina entry
- CLAUDE.md: procedura Deploy & DB Access; push su main automatico

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:38:56 +02:00
simone 988f6f425a docs: add STATUS.md session handoff 2026-06-22 14:34:17 +02:00
simone f98828f75e feat: offer_type (una tantum/retainer), dedup tiers, redesigned Offerte tab
- offer_macros.offer_type ('una_tantum'|'retainer') + editor "Modalità" toggle
  (migration 0012, applied to prod)
- migration 0012 also adds UNIQUE(macro_id, tier_letter) — prevents duplicate
  tiers; ran after a one-time dedup of Web Domination's duplicate A/B/C tiers
- OffersTab redesigned: two-step assign (Offer → Tier cards with price), type
  badge "Una tantum/Ricorrente" instead of misleading "X mesi", no redundant
  "· A" when public_name == tier letter, cleaner active-offers cards
- getProjectFullDetail: availableMicros grouped by macro + defensive dedup;
  projectOffers/availableMicros carry offer_type/category/price
- proposal deck PricingSection shows offer type (fallback to duration for
  pre-existing proposals)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:32:59 +02:00
simone 1824cb643f feat: wire offer→project phases/tasks, redo Offerte tab, cleanup project tabs
- importOfferIntoProject(): materializes project phases/tasks from the assigned
  tier's services grouped by services.fase (merge-by-title, dedup tasks)
- assignOfferToProject: optional import_phases flag triggers the import
- getProjectFullDetail: projectOffers/availableMicros now include macro name +
  tier_letter (dropdown shows "Offerta — Tier X"); availableMicros filters
  non-archived macros
- OffersTab redone: shows macro + tier badge, "Importa fasi e task" checkbox
- removed project "Preventivo" tab + deleted QuoteTab.tsx (legacy quote_items)
- sidebar: Lead before Clienti
- no DB migration (reuses existing tables)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:08:28 +02:00
simone f5f90cd643 feat: lead → client conversion + client contact fields (email/phone)
Blocco A+B (milestone v2.3). Migration 0011 is additive (ADD COLUMN only):
clients.email, clients.phone, leads.archived.

- createClientCore() extracted from createClient and reused by conversion
- clients.email/phone added to create + edit forms and shown on client header
  (never exposed on the public /client/[token] page)
- convertLeadToClient(): reuses core, carries over lead transcripts
  (client_transcripts.client_id), links project.created_from_lead_id,
  archives the lead while keeping its "won" status; idempotent
- "Converti in cliente" button on won leads; "Convertito → Apri cliente"
  once converted (clientId resolved via created_from_lead_id)
- archived leads hidden from list/kanban; detail page fetches by id incl. archived

Migration must be applied to the prod DB BEFORE this is deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:18:41 +02:00
simone e1b3e8c3d5 style: refine settings taxonomy UI (field cells, icon headers, 2-col grid)
- PoolManager: defined field cells with value count, refined chips,
  inline ghost + button, focus ring, pending/empty states
- TaxonomyManager: icon-badged section headers, ordered 2-col grid,
  sync explainer note
- Settings page: wider container (max-w-4xl) for the grid to breathe

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:46:13 +02:00
simone e80c95f838 feat: centralized Notion-style taxonomy management in settings
Single persistent option pool per taxonomy (7 fields: offer
categoria/ticket/tipo/obiettivo + catalog fase/offerta/pacchetto),
stored as JSON in the settings table (no DB migration).

- src/lib/taxonomy.ts: pools with lazy seed from in-use values,
  add/remove(cascade)/rename helpers
- Inline creation anywhere registers into the pool (save offer,
  addServiceOption, updateServiceField fase, quickAdd, create macro)
- Deselecting on a row never touches the pool; global delete only
  from settings (cascade-strips tags / nulls columns)
- getOfferFieldOptions + getCatalogFieldOptions read from pools
- Settings: TaxonomyManager (offer + catalog groups), confirm on delete

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:24:47 +02:00
simone 320827e13a fix: flag A/B/C persistence, catalog sort+reorder, offer category pools
- Fix duplicate tier INSERT bug (useState not syncing after router.refresh)
- Add column sort by clicking headers in service catalog
- Add drag-and-drop column reordering (persisted in localStorage)
- Add Categorie Offerta section in Impostazioni (tipo/obiettivo/categoria pools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 12:12:49 +02:00
simone ba3e824157 docs: create milestone v2.3 roadmap (3 phases, 9 requirements mapped)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 11:23:56 +02:00
simone 19a7ffb6a6 docs: define milestone v2.3 requirements (9 reqs — OTP gate + email send)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 11:01:28 +02:00
simone bc9051c899 docs: start milestone v2.3 Email & Accesso
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 10:54:14 +02:00
simone 55a2da5faf chore: remove REQUIREMENTS.md for v2.2 milestone — fresh for v2.3 2026-06-20 18:31:13 +02:00
simone fb1ef95c96 chore: archive v2.2 Sales Loop milestone
- ROADMAP.md: collassato a milestone summary, v2.2 in <details>
- PROJECT.md: full evolution review — What This Is, Validated v2.2, Active v2.3, Key Decisions aggiornate
- MILESTONES.md: entry v2.2 + entry v2.1 aggiunti
- milestones/v2.2-ROADMAP.md: archivio completo fasi 18-22
- milestones/v2.2-REQUIREMENTS.md: archivio requirements con outcomes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:31:08 +02:00
simone cbfd959c36 docs(v2.2): close planning docs — SUMMARY.md 21+22, req Complete
- 21-01-SUMMARY.md: agente AI Opus 4.8, schema Zod, proposals table
- 22-01-SUMMARY.md: deck 20+ slide 100vh, accept/reject, PUB-03 deferred
- REQUIREMENTS.md: PIPE/KB/AI-01/02 + PUB-01/02 → Complete; PUB-03 deferred
- ROADMAP.md: Phase 20/21/22 → [x] con date
- STATE.md: status complete, percent 100, 5/5 fasi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:24:00 +02:00
simone c803efe059 fix(preventivo): slide deck 100vh — nessuno scroll di pagina
- ProposalDeck: outer div h-screen overflow-hidden, content wrapper h-full
- Rimosso il div interno ridondante min-h-screen
- useEffect blocca body overflow mentre la deck è aperta
- Tutti i 20 componenti slide: min-h-screen → h-full

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:08:40 +02:00
simone fdcc938252 feat(21-22): agente AI generazione preventivo + pagina pubblica deck
- Migration 0010: tabella proposals (id, slug, lead_id, client_id,
  offer_macro_id, content jsonb, state, selected_tier, accepted_at)
  applicata a prod via SSH tunnel
- @anthropic-ai/sdk@0.105.0 installato; ANTHROPIC_API_KEY in .env.local
- src/lib/proposal/: schema Zod ProposalContent, agente Claude Opus 4.8,
  assemble (AI + offerta DB + config consulente), queries, profile.ts
- Admin: /admin/preventivi lista + /genera (pre-fill ?lead_id=X) + /[id] review
- Sidebar: voce Preventivi + CTA globale lime "Genera preventivo"
- LeadDetail: pulsante "Genera preventivo" → /admin/preventivi/genera?lead_id=X
- Pagina pubblica /preventivo/[slug]: deck 20+ slide light-mode iamcavalli,
  navigazione frecce + dot + keyboard, accept/reject con guard immutabilità
- STATE.md aggiornato (80%), 21-PLAN.md scritto nel formato GSD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 14:37:05 +02:00
simone 86c86cd420 docs(21): capture phase context — AI preventivo struttura sezioni fisse, 3 tier 2026-06-20 12:24:52 +02:00
simone 009466ac18 docs(20): commit plan summaries 20-02 and 20-03
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 08:58:24 +02:00
simone 9f5a6dcbe5 feat(20-03): add transcript UI — modal, list, expand/collapse, delete
TranscriptModal.tsx: new component — call_date input, optional title,
content textarea (min-h-48). Calls addTranscript server action.

LeadDetail.tsx: adds transcripts prop (ClientTranscript[]), TranscriptModal
button in header, "Transcript Call" Card after Storico Attività with
call_date formatted in Italian, 3-line preview with expand/collapse toggle,
Elimina button via deleteTranscript. border-l-4 border-purple-300.

page.tsx: getTranscripts(id) added to Promise.all, transcripts prop
passed to LeadDetail. Build: clean, TypeScript: no errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 08:58:06 +02:00
simone b96b6a2083 feat(20-02): add clientTranscripts data layer
schema.ts: clientTranscripts table (id, lead_id/client_id nullable FKs,
title, content NOT NULL, call_date, created_at) + leadsRelations/
clientsRelations updated + clientTranscriptsRelations + TS types.

lead-service.ts: getTranscripts(leadId) — call_date DESC ordering.

actions.ts: addTranscript + deleteTranscript with requireAdmin guard,
Zod validation, and revalidatePath. TypeScript: no errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 08:55:46 +02:00
simone a46e8b0bc8 docs(20-01): plan 20-01 complete — migration 0009 applied to prod
client_transcripts table verified in production: 7 columns (id, lead_id,
client_id, title, content, call_date, created_at). Applied via SSH tunnel.
Plans 20-02 and 20-03 unblocked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 08:54:06 +02:00
simone 70a101ca98 feat(20-01): add migration 0009_client_transcripts.sql
DDL for client_transcripts table: id (nanoid PK), lead_id/client_id
(nullable FKs with ON DELETE CASCADE), title, content (NOT NULL),
call_date (date NOT NULL), created_at (timestamptz). Three indexes on
lead_id, client_id, call_date DESC. APPLY TO PROD via SSH before
pushing schema-dependent code (Plan 20-02/20-03).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:59:41 +02:00
simone dc934fb04a docs(20): create phase plan — Knowledge Base Cliente transcript
3 piani: 20-01 migration BLOCKING, 20-02 schema+actions, 20-03 UI.
KB-01 e KB-02 coperti. Wave 1→2→3 sequenziali per vincolo migration-prima-del-codice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:52:16 +02:00
simone 3e10f97215 docs(20): capture phase context — Knowledge Base Cliente
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:26:02 +02:00
simone b7ff3fe430 docs(phase-19): verification passed — PIPE-01 PIPE-02 complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:10:42 +02:00
simone bac757a987 docs(phase-19): complete plan 19-01 — LeadsKanbanBoard + toggle PIPE-01/PIPE-02
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:07:44 +02:00
simone b0dbd341a9 fix(19-01): add min-h-[300px] to LeadTable scroll container to prevent dropdown clipping 2026-06-19 18:04:28 +02:00
simone 9f8eafffb0 fix(19-01): remove overflow-hidden from LeadTable to allow dropdown overflow 2026-06-19 18:00:15 +02:00
simone 34be9341bb feat(19-01): wire LeadsViewToggle into leads page + build verified
- page.tsx: import LeadsViewToggle from @/components/admin/leads/LeadsViewToggle
- page.tsx: render <LeadsViewToggle leads={leads} options={options} />
- Removed direct LeadsSearch import from page (re-exported via LeadsSearch.tsx)
- Build: Next.js 16.2.6 Turbopack — Compiled successfully, TypeScript clean
2026-06-19 17:49:28 +02:00
simone 607c2578f0 feat(19-01): add LeadsViewToggle with integrated search + list/kanban pill toggle
- LeadsViewToggle: client wrapper with useState<list|kanban>, useMemo filtered
- Search input (left) + pill toggle Lista/Kanban (right) on single row
- Filters leads by name/email/company/status/tags across both views
- Search state preserved when switching between Lista and Kanban
- LeadsSearch.tsx: replaced with re-export of LeadsViewToggle (backward compat)
2026-06-19 17:48:31 +02:00
simone 2c67e6fd72 feat(19-01): add LeadsKanbanBoard with @dnd-kit drag-drop between 6 stage columns
- 6-column Kanban board: contacted, qualified, proposal_sent, negotiating, won, lost
- DroppableColumn with useDroppable, isOver highlight (border-[#1A463C])
- DraggableLeadCard with useDraggable, opacity-30 on drag, name/company/next_action
- Optimistic update via setLeadStatuses + startTransition + updateLeadField + router.refresh
- Client-side guard: VALID_STAGES check before calling server action (T-19-01)
- DragOverlay dropAnimation={null} ghost card with rotate-1 shadow-xl
- overflow-x-auto wrapper with grid-cols-6 min-w-[1080px]
2026-06-19 17:48:00 +02:00
simone 5e4b2239cb docs(phase-19): create phase plan — LeadsKanbanBoard + view toggle (PIPE-01, PIPE-02)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 17:37:32 +02:00
simone 84c426e66c docs(phase-18): complete phase — all 3 plans done, state advanced to Phase 19
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 17:17:53 +02:00
158 changed files with 14225 additions and 2174 deletions
+30
View File
@@ -1,5 +1,35 @@
# Milestones
## v2.2 Sales Loop (Phases 1822, shipped 2026-06-20)
**Phases completed:** 5 phases (1822) · 9 plans · 27 commits · 87 files · +7.349/-842 righe
**Key accomplishments:**
- Cleanup & Consolidamento (Phase 18): rimossi Forecast, quote builder manuale e rotta `/admin/analytics` duplicata; tutte le statistiche admin in un'unica Dashboard (CLEAN-01..04)
- Pipeline CRM Kanban (Phase 19): board lead 6 colonne stile Pipedrive con drag-drop persistente via `@dnd-kit`; toggle Lista/Kanban con ricerca integrata; vinto/perso come cambio-colonna manuale (PIPE-01, PIPE-02)
- Knowledge Base Transcript (Phase 20): tabella `client_transcripts` applicata a prod via SSH; data layer Drizzle + server actions; UI incolla/elenca/cancella nel dettaglio lead (KB-01, KB-02)
- Agente AI Preventivo (Phase 21): `generateProposalContent()` con Claude Opus 4.8 (~$0.44/preventivo); Zod schema 20+ sezioni; `AssembledProposal` snapshot JSONB; form admin con pre-fill da LeadDetail (AI-01, AI-02)
- Deck Pubblico (Phase 22): `/preventivo/[slug]` — deck 20+ slide `h-screen` con keyboard nav + dots; accept/reject con `accepted_at` immutabile; PUB-03 email Resend deferred a backlog (PUB-01, PUB-02)
**Known deferred items at close:** 1 — PUB-03 email Resend (vedi backlog v2.3)
Archive: `.planning/milestones/v2.2-ROADMAP.md` · `.planning/milestones/v2.2-REQUIREMENTS.md`
---
## v2.1 Offer Studio + CRM (Phases 1114, parziale — reset 2026-06-19)
**Phases completed:** 3 phases (11, 12, 14) · fasi 13/15/16/17 congelate/abbandonate/ri-scopate in v2.2
**Key accomplishments:**
- Catalog Database-View UX (Phase 11): catalogo `services` come tabella inline-edit con tag multi-select, quick-add e ricerca istantanea; consolidamento legacy (OFFER-07..10, OFFER-13)
- Offer Editor Tier A/B/C (Phase 12): editor offerte con matrice checkbox servizi×tier, totale live, prezzo pubblico manuale, tag 4-dimensioni, promessa di trasformazione; 55 servizi reali caricati (OFFER-11, OFFER-15..18)
- CRM Attio-style (Phase 14): `/admin/leads` ridisegnata con inline edit + tag multi-select; FollowUpWidget in italiano; LeadForm tipizzato; SendQuoteModal senza rami irraggiungibili (CRM-08..12)
---
## v2.0 Business Operations Suite (Phases 710, completato 2026-06-13)
**Phases completed:** 4 phases (710)
+32 -47
View File
@@ -2,26 +2,21 @@
## What This Is
Suite operativa per un consulente di personal branding, live su hub.iamcavalli.net (Coolify/Hetzner): un'area admin (`/admin/*`) per gestire clienti, progetti, offerte, preventivi e CRM, e una dashboard cliente via link segreto (`/client/[token]`) dove ogni cliente vede lo stato del suo progetto. Con la v2.0 (Phase 7-10) è diventata suite completa: catalogo servizi unificato, builder offerte con fasi, preventivi pubblici e CRM con pipeline lead. La v2.1 ("Offer Studio + Proposal AI") rifà la UX di catalogo/offerte in stile database-view veloce, ristruttura il workspace progetto, ricostruisce il CRM in stile Attio e chiude il loop con un builder preventivi AI → pagina pubblicalink di pagamento.
Suite operativa per un consulente di personal branding, live su hub.iamcavalli.net (Coolify/Hetzner): un'area admin (`/admin/*`) per gestire clienti, progetti, offerte, preventivi e CRM, e una dashboard cliente via link segreto (`/client/[token]`) dove ogni cliente vede lo stato del suo progetto. Con la v2.2 ("Sales Loop") il focus è diventato il loop commerciale completo: lead in pipeline Kanban → transcript datati delle call → agente AI (Claude Opus 4.8) genera preventivo personalizzato leggendo transcript + offerta → deck pubblico 20+ slide navigabile a `/preventivo/[slug]` → cliente accetta tier A/B/Cvinto/perso nel CRM.
## Core Value
Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
## Current Milestone: v2.2 Sales Loop *(reset 2026-06-19)*
## Current Milestone: v2.3 Email & Accesso
**Contesto reset:** v2.1 ha consegnato Offer Studio (catalogo + offer editor) e il CRM Attio. Dopo aver articolato il flusso commerciale reale, le fasi residue v2.1 (13/15/16/17) sono congelate/abbandonate/ri-scopate. Piano completo: `.claude/plans/glittery-sprouting-pudding.md`.
**Goal:** Aggiungere uno strato email all'app — OTP gate per il portale cliente e invio link preventivo dall'admin — con un'unica integrazione Resend condivisa.
**Goal:** Chiudere il **loop di vendita** end-to-end: lead in pipeline Kanban (Pipedrive-style) → transcript datati delle call per cliente → agente AI (io scelgo l'offerta, l'AI personalizza) che genera il preventivo → pagina pubblica `/preventivo/[slug]` + invio email → vinto/perso manuale nel CRM. Il portale cliente post-vendita resta invariato.
**Target features:**
- AUTH-OTP-01 — OTP gate portale cliente (whitelist email + sessione 30gg + admin UI)
- PUB-03 — Invio link `/preventivo/[slug]` via email Resend dall'admin
**Target features (Phase 18→22 / R1→R5):**
- **R1 Cleanup** — rimuovere Forecast e quote builder manuale; fondere `/admin/analytics` nella Dashboard (no doppioni)
- **R2 Pipeline Kanban** — board lead stile Pipedrive con drag-drop tra stage; vinto/perso = cambio-colonna manuale
- **R3 Knowledge Base** — schema + UI per transcript datati multipli per lead/cliente
- **R4 Agente AI** — builder: admin sceglie cliente+offerta, l'AI (Claude) legge transcript + offerta → bozza preventivo; admin revisiona
- **R5 Pagina pubblica** — `/preventivo/[slug]` HTML/CSS, accept/reject → CRM, invio link via email (Resend)
**Backlog post-R5** (ex Proposal AI v2.1): Stripe Payment Link su offerta (PROP-03) + auto-provisioning cliente/progetto/fasi/pagamenti al "Vinto" (PROP-04). Phase 13 (Servizi attivi/ricorrenti post-vendita) congelata, ripescabile.
**Backlog v2.4+:** PROP-03 (Stripe Payment Link), PROP-04 (auto-provisioning al "Vinto"), Phase 13 (servizi ricorrenti)
## Requirements
@@ -54,31 +49,18 @@ Validated in v2.1 (consegnato, in prod):
- ✓ Offer Editor: lista filtrabile/archiviabile + 3 tier A/B/C via matrice checkbox, totale live, prezzo pubblico manuale, tag 4-dimensioni, promessa di trasformazione — Phase 12 (OFFER-11, OFFER-15..18). 55 servizi reali + tag offerta caricati.
- ✓ CRM custom Attio-style: `/admin/leads` ridisegnata come tabella inline-edit (status, next_action, ecc.) + tag multi-select con creazione al volo; FollowUpWidget interamente in italiano; `LeadForm` tipizzato senza `useForm<any>`; `SendQuoteModal`/`assignQuoteToLead` senza rami di codice irraggiungibili — Phase 14 (CRM-08, CRM-09, CRM-10, CRM-11, CRM-12)
### Active — v2.2 Sales Loop (reset 2026-06-19)
Validated in v2.2 Sales Loop (shipped 2026-06-20):
**R1 Cleanup & Consolidamento (Phase 18):**
- [ ] Rimuovere pagina Forecast (`/admin/forecast`) + voce sidebar (CLEAN-01)
- [ ] Rimuovere quote builder manuale (`/admin/quotes/new`) (CLEAN-02)
- [ ] Fondere `/admin/analytics` nella Dashboard `/admin`, eliminare la rotta duplicata (CLEAN-03)
- Cleanup: Forecast + quote builder manuale rimossi; analytics fuse in Dashboard — Phase 18 (CLEAN-01..04)
- ✓ Pipeline CRM Kanban 6 colonne con drag-drop; toggle Lista/Kanban; vinto/perso come cambio-colonna — Phase 19 (PIPE-01, PIPE-02)
- ✓ Knowledge Base transcript: `client_transcripts` in prod; UI incolla/elenca nel dettaglio lead — Phase 20 (KB-01, KB-02)
- ✓ Agente AI: Claude Opus 4.8, Zod schema 20+ sezioni, snapshot JSONB proposals, form admin — Phase 21 (AI-01, AI-02)
- ✓ Deck pubblico `/preventivo/[slug]`: 20+ slide 100vh, keyboard nav; accept/reject `accepted_at` immutabile — Phase 22 (PUB-01, PUB-02)
**R2 Pipeline CRM Kanban (Phase 19):**
- [ ] Board lead stile Pipedrive con colonne per stage + drag-drop (PIPE-01)
- [ ] Vinto/perso come cambio-colonna manuale (PIPE-02)
### Active — v2.3
**R3 Knowledge Base Cliente (Phase 20):**
- [ ] Schema additivo transcript datati multipli per lead/cliente (KB-01)
- [ ] UI per incollare/elencare transcript nel profilo (KB-02)
**R4 Agente AI — generazione preventivo (Phase 21):**
- [ ] Builder: admin sceglie cliente+offerta, l'AI (Claude) legge transcript + offerta → bozza preventivo (AI-01)
- [ ] Admin rivede/edita la bozza prima di pubblicare (AI-02)
**R5 Pagina pubblica `/preventivo/[slug]` + email (Phase 22):**
- [ ] Pagina pubblica HTML/CSS della proposta (PUB-01)
- [ ] Accept/reject → riflesso nel CRM (PUB-02)
- [ ] Invio link proposta via email Resend (PUB-03)
**Backlog (post-R5):** Stripe Payment Link su offerta (PROP-03), auto-provisioning al "Vinto" (PROP-04), Servizi attivi/ricorrenti post-vendita (ex Phase 13).
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email: whitelist `client_emails`, gate `/client/[token]/*`, sessione 30gg, admin whitelist UI
- [ ] PUB-03 — Invia link `/preventivo/[slug]` via email Resend dall'admin
### Out of Scope
@@ -86,7 +68,6 @@ Validated in v2.1 (consegnato, in prod):
- App mobile nativa — solo web responsive
- Multi-utente con team — solo tu come admin per ora
- Prezzi singoli visibili al cliente — vede solo il totale accettato
- Email OTP per accesso cliente — design pronto ma deferito a batch successivo su richiesta utente
- File hosting — documenti solo come URL esterni (v1 constraint, ancora valido)
- Sezioni analitiche stile Notion (psicologia, rating, performance) — fuori v2.1, eventuale milestone futura
- Deploy separati per modulo (architettura OMC multi-app) — non finché un modulo non cresce abbastanza da giustificarlo
@@ -95,12 +76,13 @@ Validated in v2.1 (consegnato, in prod):
- Produzione: hub.iamcavalli.net su Coolify (Hetzner), Postgres self-hosted, deploy via webhook Gitea (NON Vercel)
- Stack: Next.js 16 App Router, Drizzle ORM, Auth.js v4 (admin), token middleware (clienti), Tailwind v4, shadcn/ui, `@dnd-kit` già in deps
- Tutto sotto la stessa app: `/admin/*` (sessione Auth.js) + `/client/[token]/*` (token) + pagine pubbliche preventivo `/quote/[token]`
- La sidebar admin (Phase 6) è l'hub di navigazione per le nuove sezioni (CRM, Preventivi, Offerte)
- `project_offers` (Phase 5) già copre l'attribuzione di offerte a progetti con `start_date`+`accepted_total`+`duration_months` — base per "Servizi attivi", da rifinire non ricostruire
- `PaymentsTab` ha già un editor `accepted_total` (stessa server action di QuoteTab) — rimozione tab "Preventivo" è zero-perdita, verificato 2026-06-13
- Migrations sono manuali: applicare a prod via SSH+docker exec PRIMA di pushare codice che usa il nuovo schema (vedi REGOLA in HANDOFF.md storico)
- Il flusso commerciale reale: call con lead → preventivo 3 tier (es. Signature A/B/C) in giornata → accettazione → onboarding automatico nell'hub
- Tutto sotto la stessa app: `/admin/*` (sessione Auth.js) + `/client/[token]/*` (token) + `/preventivo/[slug]` (pubblico)
- La sidebar admin include: Dashboard, Leads (con toggle Lista/Kanban), Offerte, Catalogo, Preventivi (con CTA globale "Genera preventivo")
- Stack v2.2: `@anthropic-ai/sdk@0.105.0` (Claude Opus 4.8), `@dnd-kit` (Kanban), `nanoid` (slug proposals)
- DB live: 10 migrazioni applicate a prod (00000010); `proposals` table con `content jsonb` snapshot; `client_transcripts` per lead
- Migrations sono manuali: SSH tunnel → `node` script PRIMA di pushare codice schema-dipendente; `drizzle-kit generate` rotto da Phase 8
- `ANTHROPIC_API_KEY` in Coolify — aggiunta 2026-06-20 via PHP artisan; costo ~$0.44/preventivo (Opus 4.8)
- Il flusso commerciale reale: call con lead → transcript incollato → genera preventivo AI → deck pubblica → cliente sceglie tier → vinto/perso
## Constraints
@@ -119,13 +101,16 @@ Validated in v2.1 (consegnato, in prod):
| Preventivo: cliente vede solo il totale | Il dettaglio dei prezzi è informazione commerciale riservata | ✓ Good |
| Suite unica sotto /admin/* invece di 3 app separate | Stesso DB, stesso deploy, auth unica — overhead di 3 app ingiustificato per singolo admin | ✓ Confermato — formalizzato come "compartimenti stagni" (2026-06-12) |
| Catalogo servizi unificato (una tabella `services`) | Due cataloghi paralleli (service_catalog + offer_services) duplicano manutenzione prezzi | ✓ Good — tabella `services` live da Phase 7, consolidamento legacy in v2.1 |
| Tier offerte indipendenti (A/B/C separati, stesso tag) | Più semplice di un meccanismo di ereditarietà; ogni tier configurato a sé | — Pending |
| Prezzi pacchetti per-preventivo, non da catalogo | Permette di alzare i prezzi nel tempo senza toccare il catalogo | — Pending |
| Al "Vinto" le fasi dell'offerta sono COPIATE nel progetto | Il progetto resta modificabile senza toccare il template offerta | — Pending (ex-Phase 11, ora in Proposal AI) |
| Tier offerte indipendenti (A/B/C separati, stesso tag) | Più semplice di un meccanismo di ereditarietà; ogni tier configurato a sé | ✓ Good — usato in deck slide Pricing/StagesRecap/Comparison |
| Prezzi pacchetti per-preventivo, non da catalogo | Permette di alzare i prezzi nel tempo senza toccare il catalogo | ✓ Good — `public_price` per tier, snapshot in `proposals.content` |
| Al "Vinto" le fasi dell'offerta sono COPIATE nel progetto | Il progetto resta modificabile senza toccare il template offerta | — Pending (PROP-04, backlog v2.3) |
| NO DB esterno/Excel, Postgres unica fonte | Lezione 2026-06-11: due fonti disallineate hanno causato il crash Phase 10 | ✓ Good |
| Catalogo/Offerte UX = database view custom (non Notion-clone) | Notion troppo complesso per v1; serve velocità, non sezioni analitiche | — Pending |
| Catalogo/Offerte UX = database view custom (non Notion-clone) | Notion troppo complesso per v1; serve velocità, non sezioni analitiche | ✓ Good — confermato in v2.1 |
| Tab "Preventivo" rimossa, "Offerte" → "Servizi attivi" | Preventivo Builder è l'unico flusso; `accepted_total` già coperto da Payments | ✓ Confermato — zero perdita funzionale verificata (2026-06-13) |
| Ordine: Offer Studio (UX dato) prima, Proposal AI (AI) dopo | L'AI è l'ultimo miglio, serve un dato pulito e veloce da gestire prima | — Pending |
| Ordine: Offer Studio (UX dato) prima, Proposal AI (AI) dopo | L'AI è l'ultimo miglio, serve un dato pulito e veloce da gestire prima | ✓ Good — strategia validata: catalogo+offerte puliti → AI in v2.2 |
| Output AI = JSON strutturato Zod → template fisso | Coerenza visiva garantita; zero rischio HTML rotto dall'AI | ✓ Good — 20+ sezioni Zod validate, deck sempre coerente |
| `proposals.content` = JSONB snapshot immutabile | Prezzi e profilo consulente "bloccati" al momento della generazione | ✓ Good — invariante di audit, coerente con `accepted_at` |
| Email Resend (PUB-03) deferred | Scope minimo funziona; link condiviso manualmente per ora | — Pending (v2.3 candidato #1) |
## Evolution
@@ -145,4 +130,4 @@ This document evolves at phase transitions and milestone boundaries.
4. Update Context with current state
---
*Last updated: 2026-06-14Phase 14 (CRM Attio-style & Fix) complete: CRM-08..12 validated*
*Last updated: 2026-06-21 — v2.3 milestone started: Email & Accesso (AUTH-OTP-01 + PUB-03)*
+42 -160
View File
@@ -1,186 +1,68 @@
# Requirements ClientHub (v2.1 chiuso → v2.2 Sales Loop)
# Requirements: ClientHub v2.3 Email & Accesso
**Defined:** 2026-06-13 · **Reset:** 2026-06-19
**Defined:** 2026-06-21
**Core Value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
v2.0 (Phases 710) ha consegnato catalogo unificato, offerte con fasi, preventivo pubblico e CRM pipeline (vedi MILESTONES.md, REQUIREMENTS.md archiviato in `.planning/milestones/v2.0-REQUIREMENTS.md`). v2.1 rifà la UX di catalogo/offerte in stile database-view, ristruttura il workspace progetto, ricostruisce il CRM in stile Attio e chiude il loop commerciale con un builder preventivi AI.
## v2.3 Requirements
> **RESET 2026-06-19 — milestone v2.2 "Sales Loop".** v2.1 ha consegnato Offer Studio (Phase 11+12) e CRM Attio (Phase 14). Le fasi residue v2.1 — **13 (Servizi Attivi), 15 (Dashboard Revenue), 16/17 (Proposal AI originali)** — sono state **congelate/abbandonate/ri-scopate** dopo che l'utente ha articolato il flusso commerciale reale (vedi piano `.claude/plans/glittery-sprouting-pudding.md`). Il nuovo north-star è il **loop di vendita**: lead in pipeline Kanban → transcript call → agente AI genera preventivo → pagina pubblica `/preventivo/[slug]` → vinto/perso. Requirements attivi: sezione **v2.2** più sotto.
### Email OTP Gate (AUTH-OTP-01)
## Offer Studio — Catalogo & Offerte (v1)
Portale cliente blindato da email OTP. Nuovo `client_emails` table (whitelist) + `otp_codes` table (codice, email, expires_at, consumed). Resend come provider email. Sessione 30gg con cookie dopo verifica.
| ID | Requirement | Status |
|----|-------------|--------|
| OFFER-07 | L'utente vede ed edita il catalogo `services` come tabella con inline editing (click su cella → edit → invio salva) | Complete |
| OFFER-08 | L'utente assegna tag multi-select a servizi/offerte, creando nuovi tag al volo senza uscire dalla tabella | Complete |
| OFFER-09 | L'utente aggiunge un nuovo servizio/offerta tramite riga vuota in fondo alla tabella (nome + invio) | Complete |
| OFFER-10 | L'utente filtra/cerca servizi e offerte istantaneamente (ricerca client-side, nessun reload) | Complete |
| OFFER-11 | L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo unificato `services` tramite matrice di checkbox (servizio × tier); il totale servizi di ogni tier si aggiorna live | Complete |
| OFFER-15 | Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag polimorfico di Phase 11 | Complete |
| OFFER-16 | Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi | Complete |
| OFFER-17 | Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo) | Complete |
| OFFER-18 | La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate) | Complete |
| OFFER-13 | I dati legacy di `service_catalog` e `offer_services` sono consolidati in `services` senza perdita (migration additiva) | Complete |
- [ ] **OTP-01**: Admin può aggiungere e rimuovere email dalla whitelist di ogni cliente nell'admin UI
- [ ] **OTP-02**: Cliente senza sessione OTP vede una schermata "inserisci email" invece della dashboard
- [ ] **OTP-03**: Sistema invia OTP via Resend solo se l'email inserita è nella whitelist di quel cliente
- [ ] **OTP-04**: Cliente inserisce il codice OTP ricevuto e ottiene sessione autenticata (cookie 30 giorni)
- [ ] **OTP-05**: Codici OTP scadono dopo 15 minuti dall'invio
- [ ] **OTP-06**: Endpoint OTP è rate-limited per prevenire brute force
- [ ] **OTP-07**: Messaggi di errore OTP non rivelano se l'email è in whitelist o no (no enumeration)
## Workspace Progetto — Servizi Attivi (ex Phase 13 — CONGELATO v2.2)
### Invio Link Preventivo via Email (PUB-03)
> Congelato dal reset 2026-06-19: tocca il portale post-vendita che l'utente vuole lasciare invariato. Ripescabile in futuro se serve il tracking ricavi ricorrenti. (PROJ-10 "rimuovi Forecast" è confluito in CLEAN-01 v2.2.)
Admin invia link deck pubblico al lead direttamente dall'admin UI. Usa stessa infrastruttura Resend del OTP gate.
| ID | Requirement | Status |
|----|-------------|--------|
| PROJ-06 | La tab "Preventivo" è rimossa dal workspace progetto (solo UI — `quote_items` resta intatta, `accepted_total` resta editabile via tab Pagamenti) | Congelato (v2.2) |
| PROJ-07 | La tab "Offerte" diventa "Servizi attivi": mostra tutte le offerte assegnate al progetto (`project_offers`) | Congelato (v2.2) |
| PROJ-08 | L'utente marca ogni "servizio attivo" come one-shot o ricorrente mensile | Congelato (v2.2) |
| PROJ-09 | Per i servizi ricorrenti, l'utente registra in quali mesi è stata incassata la fattura (schema nuovo, additivo — UI può essere minimale) | Congelato (v2.2) |
| PROJ-10 | La pagina "Forecast" e la relativa voce di sidebar sono rimosse | → CLEAN-01 (v2.2) |
- [ ] **SEND-01**: Admin può inviare link `/preventivo/[slug]` via email al lead con un'azione dall'admin UI
- [ ] **SEND-02**: Email inviata via Resend include link deck + nome cliente, template minimale in italiano
## CRM Custom Attio-style (v1)
## v2.4+ Backlog
| ID | Requirement | Status |
|----|-------------|--------|
| CRM-08 | La tabella lead (`/admin/leads`) supporta inline editing dei campi principali (status, next_action, ecc.) senza apertura modale | Complete |
| CRM-09 | L'utente assegna tag multi-select ai lead, creando nuovi tag al volo | Complete |
| CRM-10 | Il `FollowUpWidget` è in italiano, coerente col resto dell'app | Complete |
| CRM-11 | Il form lead (`LeadForm`) valida i campi senza workaround di type-relaxation su react-hook-form | Complete |
| CRM-12 | Il flusso `SendQuoteModal` (preventivo esistente vs nuovo) non contiene rami di codice irraggiungibili | Complete |
### Conversione Commerciale
## Dashboard — Revenue Stats (ex Phase 15 — ABBANDONATO v2.2)
- **PROP-03**: Stripe Payment Link su deck pubblico `/preventivo/[slug]`
- **PROP-04**: Auto-provisioning cliente/progetto/fasi al "Vinto" nel CRM
> Abbandonato dal reset 2026-06-19: mockup mai arrivato, valore dubbio nel nuovo flusso. L'esigenza analytics è coperta da CLEAN-03 (fusione `/admin/analytics` nella dashboard, no fase dedicata).
### Post-Vendita
| ID | Requirement | Status |
|----|-------------|--------|
| DASH-11 | La Dashboard admin mostra le statistiche di revenue mensile (ex `/admin/forecast`, calcolate da `project_offers`) | Dropped (v2.2 → CLEAN-03) |
## Proposal AI — versione v2.1 originale (SUPERSEDED da v2.2)
> Ri-scopata dal reset 2026-06-19 nelle fasi v2.2 (PIPE/KB/AI/PUB) con il flusso chiaro + l'aggiunta dei transcript (che mancava). PROP-03 (Stripe Payment Link) e PROP-04 (auto-provisioning al "Vinto") restano backlog: non nel loop minimo, da rivalutare dopo R5.
| ID | Requirement | Status |
|----|-------------|--------|
| PROP-01 | L'admin avvia un builder AI che genera una proposta a partire da cliente + offerta(e) selezionata(e) + note extra fornite manualmente | Superseded → AI-01/AI-02 (v2.2) |
| PROP-02 | La pagina pubblica `/quote/[token]` è ridisegnata (HTML/CSS) per presentare la proposta generata | Superseded → PUB-01 (v2.2) |
| PROP-03 | Ogni offerta/preventivo ha un campo "link di pagamento" (Stripe Payment Link) mostrato come CTA nella pagina pubblica | Backlog (post-R5) |
| PROP-04 | All'accettazione del preventivo, il sistema crea automaticamente cliente + progetto, copia le fasi dall'offerta e configura 1-4 pagamenti (ex-Phase 11 WIN-01..06) | Backlog (post-R5) |
| PROP-05 | Il sistema invia via email (Resend) il link della proposta pubblica al lead (ex-Phase 12) | Superseded → PUB-03 (v2.2) |
## v2.2 — Sales Loop: Lead → AI → Preventivo (ATTIVO)
> Reset 2026-06-19. Il flusso commerciale articolato dall'utente sostituisce le fasi residue v2.1. Fasi eseguite in ordine R1→R5 (numerazione GSD continua: Phase 18→22). Le fasi 11/12/14 restano completate.
### Cleanup & Consolidamento (Phase 18 / R1)
| ID | Requirement | Status |
|----|-------------|--------|
| CLEAN-01 | La pagina Forecast (`/admin/forecast`) e la sua voce di sidebar sono rimosse | Complete |
| CLEAN-02 | Il quote builder manuale (`/admin/quotes/new`) e il suo entry point sono rimossi (superato dall'agente AI) | Complete |
| CLEAN-03 | Le statistiche di `/admin/analytics` sono fuse nella Dashboard `/admin` (vista unica); la rotta duplicata è eliminata | Complete |
| CLEAN-04 | Le fasi v2.1 residue (13/15/16/17) sono archiviate/ri-scopate nel planning | Complete |
### Pipeline CRM Kanban (Phase 19 / R2)
| ID | Requirement | Status |
|----|-------------|--------|
| PIPE-01 | I lead sono visualizzabili in una board Kanban stile Pipedrive con colonne per stage e drag-drop per cambiare stage | Pending |
| PIPE-02 | Spostare un lead nelle colonne "Vinto"/"Perso" è il cambio-stato manuale dell'esito | Pending |
### Knowledge Base Cliente — Transcript (Phase 20 / R3)
| ID | Requirement | Status |
|----|-------------|--------|
| KB-01 | Schema additivo per memorizzare transcript datati e multipli per lead/cliente | Pending |
| KB-02 | L'admin incolla ed elenca i transcript (storico ordinato per data) nel dettaglio lead/cliente | Pending |
### Agente AI — Generazione preventivo (Phase 21 / R4)
| ID | Requirement | Status |
|----|-------------|--------|
| AI-01 | L'admin seleziona cliente + offerta; l'agente AI (Claude) legge i transcript + i dati dell'offerta e genera una bozza di preventivo personalizzata | Pending |
| AI-02 | L'admin rivede ed edita la bozza generata prima di pubblicarla | Pending |
### Pagina pubblica preventivo + email (Phase 22 / R5)
| ID | Requirement | Status |
|----|-------------|--------|
| PUB-01 | La proposta generata è pubblicata come pagina pubblica HTML/CSS a `/preventivo/[slug]` (evolve la meccanica di `/quote/[token]`) | Pending |
| PUB-02 | Il cliente accetta/rifiuta dalla pagina; l'esito si riflette nel CRM (conferma vinto/perso manuale lato admin) | Pending |
| PUB-03 | Il link della proposta viene inviato al lead via email (Resend) | Pending |
## v2 Requirements (Deferred)
### Catalogo Avanzato
- **OFFER-12**: Import CSV/Notion one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo) — *rimandato il 2026-06-14 su decisione utente ("per ora saltiamo"); rientra nel filone "import dati da Notion" da valutare in una fase futura*
- **OFFER-14**: Sezioni analitiche stile Notion per le offerte (psicologia, rating, performance)
### Accesso Cliente
- **AUTH-OTP-01**: Accesso dashboard cliente via OTP email (design pronto, vedi memoria `project_clienthub_otp_access`)
### Architettura
- **ARCH-01**: Split di un modulo "compartimento stagno" in deploy separato (solo se il modulo cresce abbastanza da giustificarlo)
- **Phase 13**: Gestione servizi attivi/ricorrenti post-vendita nel portale cliente (congelata da v2.1)
## Out of Scope
| Feature | Reason |
|---------|--------|
| Fatturazione e invio fatture | Solo stato pagamenti, gestione contabile fuori |
| App mobile nativa | Solo web responsive |
| Multi-utente con team | Solo admin singolo per ora |
| Prezzi singoli visibili al cliente | Vede solo il totale accettato (vincolo LOCKED) |
| File hosting | Documenti solo come URL esterni (vincolo v1, ancora valido) |
| Database esterno o Excel come fonte dati | Postgres unica fonte di verità — il problema è la UX, non il dato |
| Notion-clone completo (sezioni psicologia/rating/performance) | Troppo complesso per v1, vedi v2 Requirements |
| Deploy separati per modulo (architettura OMC multi-app) | Non finché un modulo non cresce abbastanza da giustificarlo |
| Self-registration cliente | Solo whitelist admin-gestita — nessun accesso senza approvazione esplicita |
| Magic link senza OTP | OTP è più sicuro e già deciso come design; magic link = scope creep |
| Email marketing / newsletter | Non pertinente al portale |
| Multi-admin | Ancora single admin per ora |
## Traceability
Popolata dal roadmapper durante la creazione della roadmap.
| Requirement | Phase | Status |
|-------------|-------|--------|
| OFFER-07 | Phase 11 | Complete |
| OFFER-08 | Phase 11 | Complete |
| OFFER-09 | Phase 11 | Complete |
| OFFER-10 | Phase 11 | Complete |
| OFFER-13 | Phase 11 | Complete |
| OFFER-11 | Phase 12 | Complete |
| OFFER-15 | Phase 12 | Complete |
| OFFER-16 | Phase 12 | Complete |
| OFFER-17 | Phase 12 | Complete |
| OFFER-18 | Phase 12 | Complete |
| OFFER-12 | Deferred (v2) | Rimandato 2026-06-14 |
| PROJ-06 | Phase 13 | Congelato (v2.2) |
| PROJ-07 | Phase 13 | Congelato (v2.2) |
| PROJ-08 | Phase 13 | Congelato (v2.2) |
| PROJ-09 | Phase 13 | Congelato (v2.2) |
| PROJ-10 | Phase 13 | → CLEAN-01 |
| CRM-08 | Phase 14 | Complete |
| CRM-09 | Phase 14 | Complete |
| CRM-10 | Phase 14 | Complete |
| CRM-11 | Phase 14 | Complete |
| CRM-12 | Phase 14 | Complete |
| DASH-11 | Phase 15 | Dropped (→ CLEAN-03) |
| PROP-03 | Phase 16 | Backlog (post-R5) |
| PROP-04 | Phase 16 | Backlog (post-R5) |
| PROP-01 | Phase 17 | Superseded → AI-01/02 |
| PROP-02 | Phase 17 | Superseded → PUB-01 |
| PROP-05 | Phase 17 | Superseded → PUB-03 |
| CLEAN-01 | Phase 18 (R1) | Complete |
| CLEAN-02 | Phase 18 (R1) | Complete |
| CLEAN-03 | Phase 18 (R1) | Complete |
| CLEAN-04 | Phase 18 (R1) | Complete |
| PIPE-01 | Phase 19 (R2) | Pending |
| PIPE-02 | Phase 19 (R2) | Pending |
| KB-01 | Phase 20 (R3) | Pending |
| KB-02 | Phase 20 (R3) | Pending |
| AI-01 | Phase 21 (R4) | Pending |
| AI-02 | Phase 21 (R4) | Pending |
| PUB-01 | Phase 22 (R5) | Pending |
| PUB-02 | Phase 22 (R5) | Pending |
| PUB-03 | Phase 22 (R5) | Pending |
| OTP-01 | Phase 24 | Pending |
| OTP-02 | Phase 25 | Pending |
| OTP-03 | Phase 25 | Pending |
| OTP-04 | Phase 25 | Pending |
| OTP-05 | Phase 25 | Pending |
| OTP-06 | Phase 25 | Pending |
| OTP-07 | Phase 25 | Pending |
| SEND-01 | Phase 23 | Pending |
| SEND-02 | Phase 23 | Pending |
**Coverage:**
- v2.3 requirements: 9 total
- Mapped to phases: 9
- Unmapped: 0 ✓
---
*Requirements defined: 2026-06-13*
*Last updated: 2026-06-19RESET v2.2 "Sales Loop": Phase 13 congelata, 15 abbandonata, 16/17 ri-scopate; nuovi requirements CLEAN/PIPE/KB/AI/PUB (Phase 18→22)*
*Requirements defined: 2026-06-21*
*Last updated: 2026-06-21 — traceability filled after roadmap creation*
+125 -209
View File
@@ -1,230 +1,146 @@
# Roadmap: ClientHub — v2.1 (chiuso) → v2.2 Sales Loop
# Roadmap: ClientHub
## Overview
## Milestones
**v2.0 (Phases 110, Complete)**: ClientHub è una suite operativa completa — catalogo servizi unificato, offerte con fasi, preventivo pubblico (`/quote/[token]`), CRM pipeline lead con activity log. Archiviato in `.planning/milestones/v2.0-ROADMAP.md`.
**v2.1 (Phases 1117) — PARZIALE / CHIUSO dal reset 2026-06-19**: ha consegnato Offer Studio (Phase 11 catalogo database-view + Phase 12 offer editor tier A/B/C) e CRM Attio-style (Phase 14). Le fasi residue **13/15/16/17 sono congelate/abbandonate/ri-scopate** (vedi sotto).
**v2.2 (Phases 1822, Sales Loop) — ATTIVO**: dopo che l'utente ha articolato il flusso commerciale reale (piano `.claude/plans/glittery-sprouting-pudding.md`), il focus diventa il **loop di vendita**: cleanup dei doppioni → pipeline CRM Kanban (Pipedrive-style) → knowledge base transcript per cliente → agente AI che genera il preventivo → pagina pubblica `/preventivo/[slug]` + email. Numerazione GSD continua: v2.2 parte da Phase 18. Le fasi R1R5 mappano su Phase 1822.
-**v1.0 Client Portal & Offer System** — Phases 16 (shipped 2026-06-10) — [archive](milestones/v1.0-ROADMAP.md)
-**v2.0 Business Operations Suite** — Phases 710 (shipped 2026-06-13) — [archive](milestones/v2.0-ROADMAP.md)
-**v2.1 Offer Studio + CRM**Phases 1114 parziale (chiuso 2026-06-19, reset → v2.2)
-**v2.2 Sales Loop** — Phases 1822 (shipped 2026-06-20) — [archive](milestones/v2.2-ROADMAP.md)
- 🔨 **v2.3 Email & Accesso** — Phases 2325 (in corso)
## Phases
- [x] **Phase 11: Catalog Database-View UX & Legacy Consolidation** - Catalogo `services` come tabella con inline edit, tag multi-select, quick-add, ricerca istantanea; consolidamento `service_catalog`/`offer_services``services`
- [x] **Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico** - Editor offerte completo: lista filtrabile + 3 tier (A/B/C) via matrice checkbox sui servizi `services`, totale live per tier, prezzo pubblico manuale, tag multi-dimensione, promessa di trasformazione (completed 2026-06-18)
- [~] **Phase 13: Workspace — Servizi Attivi** - ❌ CONGELATA (reset 2026-06-19): tocca il post-vendita lasciato invariato; ripescabile in futuro
- [x] **Phase 14: CRM Attio-style & Fix** - Redesign `/admin/leads` stile Attio/Pipedrive (inline edit + tag), fix FollowUpWidget IT / LeadForm types / SendQuoteModal rami irraggiungibili (completed 2026-06-14)
- [~] **Phase 15: Dashboard Revenue Stats** - ❌ ABBANDONATA (reset 2026-06-19): mockup mai arrivato; analytics confluisce nella dashboard via CLEAN-03
- [~] **Phase 16: Proposal AI — Data Foundations & Auto-Provisioning** - ❌ RI-SCOPATA in v2.2; Stripe Payment Link + auto-provisioning → backlog post-R5
- [~] **Phase 17: Proposal AI — Builder, Pagina Pubblica & Email** - ❌ RI-SCOPATA in v2.2 (Phase 21/22 con transcript + flusso chiaro)
- [ ] **Phase 18: Cleanup & Consolidamento** - Rimuovi Forecast + quote builder manuale; fondi `/admin/analytics` nella dashboard; archivia fasi residue v2.1
- [ ] **Phase 19: Pipeline CRM Kanban** - Board lead stile Pipedrive con colonne per stage + drag-drop; vinto/perso come cambio-colonna manuale
- [ ] **Phase 20: Knowledge Base Cliente** - Schema additivo + UI per incollare transcript datati multipli per lead/cliente
- [ ] **Phase 21: Agente AI — generazione preventivo** - Builder: admin sceglie cliente+offerta, l'AI (Claude) legge transcript + offerta e genera bozza preventivo; admin revisiona
- [ ] **Phase 22: Pagina pubblica preventivo + email** - Pagina pubblica HTML/CSS, accept/reject → CRM, invio link via email (Resend)
<details>
<summary>✅ v1.0 + v2.0 + v2.1 (Phases 117) — SHIPPED / CHIUSE</summary>
Vedi archivi:
- `milestones/v1.0-ROADMAP.md` — Phases 16
- `milestones/v2.0-ROADMAP.md` — Phases 710
- Phases 11, 12, 14 — Offer Studio + CRM Attio (shipped in prod)
- Phases 13, 15, 16, 17 — congelate/abbandonate/ri-scopate in v2.2
</details>
<details>
<summary>✅ v2.2 Sales Loop (Phases 1822) — SHIPPED 2026-06-20</summary>
- [x] Phase 18: Cleanup & Consolidamento (3/3 plans) — completed 2026-06-19
- [x] Phase 19: Pipeline CRM Kanban (1/1 plan) — completed 2026-06-19
- [x] Phase 20: Knowledge Base Cliente (3/3 plans) — completed 2026-06-20
- [x] Phase 21: Agente AI — generazione preventivo (1/1 plan) — completed 2026-06-20
- [x] Phase 22: Pagina pubblica preventivo (1/1 plan, PUB-03 deferred) — completed 2026-06-20
Archivio completo: [milestones/v2.2-ROADMAP.md](milestones/v2.2-ROADMAP.md)
</details>
### 🔨 v2.3 — Email & Accesso (Phases 2325)
- [ ] **Phase 23: Resend Setup + Invio Preventivo** — Integrazione Resend e invio link deck dall'admin
- [ ] **Phase 24: Schema + Whitelist Admin** — Tabelle `client_emails` e `otp_codes`, admin UI gestione whitelist
- [ ] **Phase 25: OTP Gate + Sessione** — Gate OTP completo, sessione 30gg, rate limiting, no enumeration
## Phase Details
> **Reset 2026-06-19**: i dettagli delle fasi 13/15/16/17 sotto sono conservati per storico ma quelle fasi sono **cancellate/congelate** (vedi checklist Phases sopra e Progress sotto). Le fasi attive sono **1822 (R1R5)**, in fondo a questa sezione.
### Phase 11: Catalog Database-View UX & Legacy Consolidation
**Goal**: L'utente vede ed edita il catalogo servizi come una tabella stile database (Notion-like) — inline editing, tag multi-select, quick-add, ricerca istantanea — su un'unica tabella `services` consolidata senza perdita di dati legacy
**Mode:** migration + ui
**Depends on**: Phase 10 (CRM Pipeline, ultima fase v2.0)
**Requirements**: OFFER-07, OFFER-08, OFFER-09, OFFER-10, OFFER-13
### Phase 23: Resend Setup + Invio Preventivo
**Goal**: Admin può inviare il link `/preventivo/[slug]` via email con un click dall'admin UI
**Depends on**: Nothing — primo uso di Resend, nessuna dipendenza DB
**Requirements**: SEND-01, SEND-02
**Success Criteria** (what must be TRUE):
1. L'utente edita inline ogni cella del catalogo `services` (click sulla cella → edit → invio salva) senza apertura di modali
2. L'utente assegna tag multi-select a servizi/offerte, creando nuovi tag al volo senza uscire dalla tabella
3. L'utente aggiunge un nuovo servizio tramite riga vuota in fondo alla tabella (nome + invio)
4. L'utente filtra/cerca servizi e offerte istantaneamente (ricerca client-side, nessun reload)
5. I dati legacy di `service_catalog` e `offer_services` sono consolidati in `services` senza perdita (migration additiva, audit trail `migrated_from`/`migrated_id`)
**Plans**: 4 plans
**Plan list**:
- [x] 11-01-PLAN.md
- [x] 11-02-PLAN.md
- [x] 11-03-PLAN.md
- [x] 11-04-PLAN.md
**UI hint**: yes
**Status**: ✅ Complete (2026-06-13) — migration 0007 (`services.fase`) applicata in prod, commit pushato su `gitea/main`
### Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico
**Goal**: L'admin crea e modifica offerte complete da una lista filtrabile: editor con 3 tier (A/B/C) composti via matrice di checkbox dai servizi del catalogo unificato `services`, totale servizi live per tier, prezzo pubblico manuale per tier, tag multi-dimensione (categoria/ticket/tipo/obiettivo) e promessa di trasformazione strutturata. I servizi disponibili nella matrice sono filtrati per categoria offerta (entry/signature/retainer), designazione già impostata sul servizio a livello di catalogo.
**Mode:** ui + schema
**Depends on**: Phase 11 (tabella `services` unificata + sistema tag polimorfico)
**Requirements**: OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18
**Success Criteria** (what must be TRUE):
1. L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo `services` tramite matrice di checkbox; il totale servizi di ogni tier si aggiorna live
2. Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag di Phase 11
3. Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi
4. Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo)
5. La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate)
**Note**: Import CSV/Notion (ex OFFER-12) spostato a fase futura su decisione utente (2026-06-14, "per ora saltiamo") — vedi REQUIREMENTS.md
**Plans**: 5 plans
**Plan list**:
**Wave 1**
- [x] 12-01-PLAN.md — Schema: offer_macros/offer_micros additive columns (category, ticket, is_archived, transformation promise, tier_letter, public_price) + offer_tier_services junction table, migration 0008 + idempotent push script
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 12-02-PLAN.md — BLOCKING checkpoint: apply migration 0008 to production database (SSH+docker exec, manual)
**Wave 3** *(blocked on Wave 2 — prod migration applied before schema-dependent code)*
- [x] 12-03-PLAN.md — Query layer (getOfferEditorData/getOfferListCards/getOfferFieldOptions) + server actions (saveOfferEditor/toggleOfferArchived/tag CRUD/createOfferMacro)
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 12-04-PLAN.md — Offer list page redesign: category filter chips, archive toggle, card grid, "+ Nuova Offerta" CTA
- [x] 12-05-PLAN.md — Offer editor detail page: tags (4 dimensions), services matrix (tier A/B/C checkboxes + live totals), prezzo pubblico, promessa di trasformazione, Salva/Annulla/Archivia
**UI hint**: yes — riferimento mockup utente 2026-06-14 (lista card per categoria + editor con matrice servizi×tier)
**Status**: Planned (2026-06-14) — ready for `/gsd-execute-phase 12`
### Phase 13: Workspace — Servizi Attivi
**Goal**: Il workspace progetto sostituisce le tab "Preventivo"/"Offerte"/"Forecast" con un'unica tab "Servizi attivi" che traccia servizi one-shot e ricorrenti con incassi mensili, senza perdere `accepted_total`/`quote_items`
**Mode:** ui + schema
**Depends on**: Phase 5 (`project_offers`), Phase 9 (flusso `accepted_total` via quote)
**Requirements**: PROJ-06, PROJ-07, PROJ-08, PROJ-09, PROJ-10
**Success Criteria** (what must be TRUE):
1. La tab "Preventivo" è rimossa dal workspace progetto (solo UI — `quote_items` resta intatta, `accepted_total` resta editabile via tab Pagamenti)
2. La tab "Offerte" diventa "Servizi attivi" e mostra tutte le offerte assegnate al progetto (`project_offers`)
3. L'utente marca ogni "servizio attivo" come one-shot o ricorrente mensile
4. Per i servizi ricorrenti, l'utente registra in quali mesi è stata incassata la fattura (schema nuovo, additivo)
5. La pagina "Forecast" e la relativa voce di sidebar sono rimosse
1. Admin fa click su "Invia preventivo" nel dettaglio lead/preventivo e l'email parte senza uscire dall'app
2. Il destinatario riceve un'email in italiano con nome cliente e link cliccabile al deck pubblico
3. L'invio usa Resend con le variabili d'ambiente configurate su Coolify (`RESEND_API_KEY`, `RESEND_FROM`)
4. In caso di errore Resend, l'admin vede un messaggio di errore chiaro nell'UI (non un crash silenzioso)
**Plans**: TBD
**UI hint**: yes
**Status**: Pending planning
### Phase 14: CRM Attio-style & Fix
**Goal**: La tabella lead (`/admin/leads`) è ridisegnata in stile Attio/Pipedrive — inline editing dei campi principali e tag multi-select — e i bug residui del modulo CRM (FollowUpWidget non in italiano, LeadForm con type-relaxation, SendQuoteModal con rami irraggiungibili) sono risolti
**Mode:** ui
**Depends on**: Phase 10 (CRM Pipeline base)
**Requirements**: CRM-08, CRM-09, CRM-10, CRM-11, CRM-12
### Phase 24: Schema + Whitelist Admin
**Goal**: Admin può gestire la whitelist email di ogni cliente, con le tabelle DB pronte per l'OTP gate
**Depends on**: Phase 23 (Resend SDK già installato e variabili d'ambiente configurate)
**Requirements**: OTP-01
**Success Criteria** (what must be TRUE):
1. La tabella lead supporta inline editing dei campi principali (status, next_action, ecc.) senza apertura di modale
2. L'utente assegna tag multi-select ai lead, creando nuovi tag al volo
3. Il `FollowUpWidget` è interamente in italiano, coerente col resto dell'app
4. Il form lead (`LeadForm`) valida i campi senza workaround di type-relaxation su react-hook-form
5. Il flusso `SendQuoteModal` (preventivo esistente vs nuovo) non contiene rami di codice irraggiungibili
**Plans:** 5/5 plans complete
Plans:
**Wave 1**
- [x] 14-01-PLAN.md — Data-layer foundation: getLeadsWithTags/getLeadFieldOptions query helpers + updateLeadField/addLeadTag/removeLeadTag/renameLeadTag server actions (requireAdmin-guarded)
- [x] 14-03-PLAN.md — Bug fixes: FollowUpWidget Italian translation (CRM-10), LeadForm typed useForm (CRM-11), SendQuoteModal dead branch removal (CRM-12)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 14-02-PLAN.md — LeadTable raw-table rewrite (inline edit + status dropdown + tag multi-select), LeadsSearch instant filter, page wiring, LeadDetail tags
**UI hint**: yes — riferimento design Pipedrive/Attio (confermato dall'utente 2026-06-13)
**Status**: ✅ Complete (2026-06-14) — 5/5 success criteria verified, code review 0 critical/6 warnings/3 info
### Phase 15: Dashboard Revenue Stats
**Goal**: La Dashboard admin mostra le statistiche di revenue mensile (ex `/admin/forecast`), calcolate da `project_offers`
**Mode:** ui
**Depends on**: Phase 13 (modello dati Servizi attivi)
**Requirements**: DASH-11
**Success Criteria** (what must be TRUE):
1. La Dashboard admin mostra un breakdown di revenue mensile calcolato da `project_offers`
1. Admin può aggiungere una o più email alla whitelist di un cliente dalla pagina dettaglio cliente
2. Admin può rimuovere un'email dalla whitelist di un cliente
3. Le tabelle `client_emails` e `otp_codes` esistono in produzione (migration additive applicata via SSH prima del codice)
4. La migration non tocca nessuna delle tabelle protette (`clients`, `projects`, `payments`, `phases`)
**Plans**: TBD
**UI hint**: yes
**Status**: Blocked — attesa mockup dashboard dall'utente (isolata/skippabile, non blocca Phase 16/17)
### Phase 16: Proposal AI — Data Foundations & Auto-Provisioning
**Goal**: Ogni offerta/preventivo ha un campo link di pagamento Stripe mostrato come CTA pubblica, e all'accettazione del preventivo il sistema crea automaticamente cliente + progetto, copia le fasi dall'offerta e configura 1-4 pagamenti
**Mode:** schema + automation
**Depends on**: Phase 9 (flusso accettazione preventivo), Phase 13 (Servizi attivi come target del provisioning)
**Requirements**: PROP-03, PROP-04
### Phase 25: OTP Gate + Sessione
**Goal**: Il portale `/client/[token]/*` richiede verifica OTP email prima di mostrare la dashboard
**Depends on**: Phase 24 (tabelle `client_emails` e `otp_codes` in prod)
**Requirements**: OTP-02, OTP-03, OTP-04, OTP-05, OTP-06, OTP-07
**Success Criteria** (what must be TRUE):
1. Ogni offerta/preventivo ha un campo "link di pagamento" (Stripe Payment Link) mostrato come CTA nella pagina pubblica
2. All'accettazione del preventivo, il sistema crea automaticamente cliente + progetto, copia le fasi dall'offerta e configura 1-4 pagamenti
1. Cliente senza sessione OTP valida vede una schermata "inserisci la tua email" al posto della dashboard
2. Inserita un'email in whitelist, il cliente riceve il codice OTP via Resend; inserendo il codice corretto ottiene accesso con cookie valido 30 giorni
3. Un'email non in whitelist non riceve OTP — il messaggio d'errore mostrato è identico a quello per email valide (no enumeration)
4. Un codice OTP non utilizzato entro 15 minuti viene rifiutato; il cliente deve richiederne uno nuovo
5. Tentativi ripetuti sugli endpoint OTP vengono bloccati dal rate limiter (no brute force)
**Plans**: TBD
**UI hint**: yes
**Status**: Pending planning
### Phase 17: Proposal AI — Builder, Pagina Pubblica & Email
**Goal**: Un builder AI genera una proposta a partire da cliente + offerta(e) selezionata(e) + note extra; la pagina pubblica `/quote/[token]` è ridisegnata; il link della proposta viene inviato via email (Resend) al lead
**Mode:** ai + ui
**Depends on**: Phase 16
**Requirements**: PROP-01, PROP-02, PROP-05
**Success Criteria** (what must be TRUE):
1. L'admin avvia un builder AI che genera una proposta a partire da cliente + offerta(e) selezionata(e) + note extra fornite manualmente
2. La pagina pubblica `/quote/[token]` è ridisegnata (HTML/CSS) per presentare la proposta generata
3. Il sistema invia via email (Resend) il link della proposta pubblica al lead
**Plans**: TBD
**UI hint**: yes
**Status**: Pending planning
---
## v2.2 — Sales Loop (fasi attive)
### Phase 18: Cleanup & Consolidamento
**Goal**: Sgomberare il campo dai doppioni e dal peso morto prima di costruire il loop di vendita, senza perdere dati.
**Mode:** ui (solo rimozioni/rotte, nessun drop di tabelle)
**Depends on**: nessuna (può partire subito)
**Requirements**: CLEAN-01, CLEAN-02, CLEAN-03, CLEAN-04
**Success Criteria** (what must be TRUE):
1. La pagina Forecast (`/admin/forecast`) e la sua voce di sidebar non esistono più
2. Il quote builder manuale (`/admin/quotes/new`) e il suo entry point non esistono più
3. Le statistiche annuali di `/admin/analytics` sono visibili nella Dashboard `/admin` come vista unica; la rotta `/admin/analytics` è eliminata (nessun doppione)
4. Le fasi v2.1 residue (13/15/16/17) sono marcate cancellate/congelate nel planning
**Plans**: 3 plans
**Plan list**:
- [ ] 18-01-PLAN.md — Rimozione Forecast + Quote Builder manuale (CLEAN-01, CLEAN-02)
- [ ] 18-02-PLAN.md — Fusione analytics nella Dashboard, eliminazione rotta /admin/analytics (CLEAN-03)
- [ ] 18-03-PLAN.md — Verifica CLEAN-04 planning docs + build check + checkpoint visuale
**Status**: Planned — ready for `/gsd-execute-phase 18`
### Phase 19: Pipeline CRM Kanban
**Goal**: I lead si gestiscono in una board Kanban stile Pipedrive con drag-drop tra gli stage; vinto/perso è un cambio-colonna manuale.
**Mode:** ui
**Depends on**: Phase 14 (CRM lead esistente). Riusa `@dnd-kit` (pattern KanbanBoard progetti).
**Requirements**: PIPE-01, PIPE-02
**Success Criteria** (what must be TRUE):
1. I lead sono mostrati in colonne per stage (contacted → qualified → proposal_sent → negotiating → won/lost) con drag-drop che aggiorna `leads.status`
2. La tabella inline-edit esistente resta disponibile come vista alternativa
3. Spostare un lead su "Vinto"/"Perso" è l'azione manuale che ne registra l'esito
**Status**: Pending planning — `/gsd-plan-phase 19`
### Phase 20: Knowledge Base Cliente
**Goal**: Memorizzare i transcript datati delle call (multipli per lead/cliente) e mostrarli nel profilo, pronti per essere dati in pasto all'AI.
**Mode:** schema + ui
**Depends on**: Phase 14 (lead) / clienti. Migration a prod via SSH PRIMA del codice dipendente.
**Requirements**: KB-01, KB-02
**Success Criteria** (what must be TRUE):
1. Esiste uno schema additivo `client_transcripts` (lead_id/client_id, testo, data, titolo/tipo, created_at)
2. L'admin incolla un transcript con data dal dettaglio lead/cliente
3. I transcript sono elencati in ordine cronologico nel profilo
**Status**: Pending planning — `/gsd-plan-phase 20`
### Phase 21: Agente AI — generazione preventivo
**Goal**: L'admin seleziona cliente + offerta; l'agente AI (Claude) legge i transcript + i dati dell'offerta e genera una bozza di preventivo personalizzata, che l'admin può rivedere ed editare.
**Mode:** ai + ui
**Depends on**: Phase 20 (transcript) + DB Offerte (Phase 11/12, fatto)
**Requirements**: AI-01, AI-02
**Success Criteria** (what must be TRUE):
1. Dal builder l'admin sceglie cliente + offerta e lancia la generazione
2. L'AI produce una bozza di preventivo che riflette i transcript del cliente e i contenuti dell'offerta
3. L'admin rivede/edita la bozza prima di pubblicarla
**Note**: decisioni di design AI (modello Claude, prompt, storage output) in fase di planning dedicata
**Status**: Pending planning — `/gsd-plan-phase 21`
### Phase 22: Pagina pubblica preventivo + email
**Goal**: Pubblicare la proposta come pagina pubblica a `/preventivo/[slug]`, con accept/reject che si riflette nel CRM, e inviare il link via email.
**Mode:** ui
**Depends on**: Phase 21 (modello dati proposta). Evolve la meccanica di `/quote/[token]`.
**Requirements**: PUB-01, PUB-02, PUB-03
**Success Criteria** (what must be TRUE):
1. La proposta generata è visibile come pagina pubblica HTML/CSS a `/preventivo/[slug]`
2. Il cliente accetta/rifiuta dalla pagina; l'esito è disponibile lato admin per la conferma vinto/perso nel CRM
3. Il link della proposta viene inviato al lead via email (Resend)
**Status**: Pending planning — `/gsd-plan-phase 22`
## Progress
| Phase | Plans | Status | Completed |
|-------|-------|--------|-----------|
| 11. Catalog Database-View UX & Legacy Consolidation | 4/4 | ✅ Done | 2026-06-13 |
| 12. Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico | 5/5 | ✅ Done | 2026-06-18 |
| 13. Workspace — Servizi Attivi | — | ❌ Congelata (v2.2) | — |
| 14. CRM Attio-style & Fix | 3/3 | ✅ Done | 2026-06-14 |
| 15. Dashboard Revenue Stats | — | ❌ Abbandonata (v2.2) | — |
| 16. Proposal AI — Data Foundations & Auto-Provisioning | — | ❌ Ri-scopata (v2.2) | — |
| 17. Proposal AI — Builder, Pagina Pubblica & Email | — | ❌ Ri-scopata (v2.2) | — |
| 18 (R1). Cleanup & Consolidamento | — | Pending planning | — |
| 19 (R2). Pipeline CRM Kanban | — | Pending planning | — |
| 20 (R3). Knowledge Base Cliente (transcript) | — | Pending planning | — |
| 21 (R4). Agente AI — generazione preventivo | — | Pending planning | — |
| 22 (R5). Pagina pubblica preventivo + email | — | Pending planning | — |
| Phase | Milestone | Plans | Status | Completed |
|-------|-----------|-------|--------|-----------|
| 16. Foundation → UX Overhaul | v1.0 | 24/24 | ✅ Done | 2026-06-10 |
| 710. Unified Catalog → CRM Pipeline | v2.0 | 12/12 | ✅ Done | 2026-06-13 |
| 11. Catalog Database-View UX | v2.1 | 4/4 | ✅ Done | 2026-06-13 |
| 12. Offer Editor Tier A/B/C | v2.1 | 5/5 | ✅ Done | 2026-06-18 |
| 13. Workspace Servizi Attivi | v2.1 | — | ❌ Congelata | — |
| 14. CRM Attio-style & Fix | v2.1 | 3/3 | ✅ Done | 2026-06-14 |
| 15. Dashboard Revenue Stats | v2.1 | — | ❌ Abbandonata | — |
| 1617. Proposal AI originale | v2.1 | — | ❌ Ri-scopata in v2.2 | — |
| 18. Cleanup & Consolidamento | v2.2 | 3/3 | ✅ Done | 2026-06-19 |
| 19. Pipeline CRM Kanban | v2.2 | 1/1 | ✅ Done | 2026-06-19 |
| 20. Knowledge Base Cliente | v2.2 | 3/3 | ✅ Done | 2026-06-20 |
| 21. Agente AI Preventivo | v2.2 | 1/1 | ✅ Done | 2026-06-20 |
| 22. Pagina Pubblica + Deck | v2.2 | 1/1 | ✅ Done | 2026-06-20 |
| 23. Resend Setup + Invio Preventivo | v2.3 | 0/? | Not started | — |
| 24. Schema + Whitelist Admin | v2.3 | 0/? | Not started | — |
| 25. OTP Gate + Sessione | v2.3 | 0/? | Not started | — |
---
## Requirement Coverage (v2.3)
| Requirement | Phase |
|-------------|-------|
| SEND-01 | Phase 23 |
| SEND-02 | Phase 23 |
| OTP-01 | Phase 24 |
| OTP-02 | Phase 25 |
| OTP-03 | Phase 25 |
| OTP-04 | Phase 25 |
| OTP-05 | Phase 25 |
| OTP-06 | Phase 25 |
| OTP-07 | Phase 25 |
**Mapped: 9/9. No orphans.**
---
## Dependency Chain (v2.3)
```
Phase 23 (Resend config + SDK)
└── Phase 24 (schema DB additive: client_emails + otp_codes)
└── Phase 25 (OTP gate + sessione cookie 30gg)
```
Phase 23 first: Resend SDK e variabili d'ambiente sono infrastruttura condivisa con Phase 25 (email OTP).
Phase 24 before Phase 25: le tabelle `client_emails` e `otp_codes` devono essere in prod (via SSH migration) prima del gate.
---
## Implementation Notes (v2.3)
**Migration constraint:** `client_emails` e `otp_codes` sono nuove tabelle — migration additiva pura. SQL a mano (drizzle-kit generate rotto da Phase 8). Applicare via SSH tunnel PRIMA di pushare il codice dipendente.
**OTP middleware layer:** Il token middleware esistente (proxy.ts → `/api/internal/validate-token`) rimane invariato. Il gate OTP è uno strato aggiuntivo dopo la validazione del token, non un suo rimpiazzo.
**Resend shared infra:** La stessa istanza Resend client e le stesse variabili d'ambiente (`RESEND_API_KEY`, `RESEND_FROM`) servono sia Phase 23 (email preventivo) sia Phase 25 (email OTP). Configurare una volta in Phase 23, riusare in Phase 25.
**Security invariants (Phase 25):** Rate limiting su entrambi gli endpoint OTP; risposta identica per email in whitelist e non; OTP 6 cifre, scade 15 minuti, monouso (`consumed_at` impostato al primo uso); cookie HttpOnly, SameSite=Lax, MaxAge 30 giorni.
---
*Roadmap created: 2026-06-21 — v2.3 Email & Accesso*
+38 -30
View File
@@ -1,47 +1,51 @@
---
gsd_state_version: 1.0
milestone: v2.2
milestone_name: — Sales Loop
status: executing
stopped_at: "Phase 12 re-scoped (mockup utente → editor offerte) e pianificata: 5 piani / 4 wave, planner + plan-checker pass. Pronta per /gsd-execute-phase 12"
last_updated: "2026-06-19T10:25:00.000Z"
last_activity: 2026-06-19 -- Phase 18 Plan 02 complete (analytics merged into /admin, /admin/analytics deleted)
milestone: v2.3
milestone_name: Email & Accesso
status: planning
stopped_at: ""
last_updated: "2026-06-22T09:00:00.000Z"
last_activity: 2026-06-22 -- Lead→Cliente (A+B) in prod; cleanup tab progetto + offerta→fasi in corso
progress:
total_phases: 12
completed_phases: 3
total_plans: 15
completed_plans: 12
percent: 80
total_phases: 3
completed_phases: 0
total_plans: 0
completed_plans: 0
percent: 0
---
# Project State
## Project Reference
See: .planning/PROJECT.md (updated 2026-06-13)
See: .planning/PROJECT.md (updated 2026-06-21)
**Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
**Current focus:** Milestone **v2.2 "Sales Loop"** (reset 2026-06-19). North-star: lead in pipeline Kanban → transcript call → agente AI genera preventivo → pagina pubblica `/preventivo/[slug]` → vinto/perso. Piano: `.claude/plans/glittery-sprouting-pudding.md`. Prossimo passo: pianificare Phase 18 (R1) Cleanup.
**Current focus:** Milestone **v2.3 "Email & Accesso"** (started 2026-06-21). North-star: OTP gate per il portale cliente + invio link preventivo via email — un'unica integrazione Resend condivisa. Roadmap: `.planning/ROADMAP.md` (Phases 2325). Prossimo passo: `/gsd-plan-phase 23`.
## Current Position
Phase: 18 (R1) Cleanup & Consolidamento
Plan: 02 complete (03 next)
Status: Executing
Last activity: 2026-06-19 -- Phase 18 Plan 02 complete (analytics merged into /admin, /admin/analytics deleted)
Phase: Pre-23 fixes & flow wiring (fuori roadmap formale)
Plan: `.claude/plans/te-li-scrivo-tutti-lucky-hearth.md`
Status: In esecuzione — cleanup tab progetto + collegamento offerta→fasi/task
Last activity: 2026-06-22 — Lead→Cliente consegnato in prod
Progress (v2.2): [██░░░░░░░░] 20% — Phase 18 in progress (01, 02 done)
### Lavoro recente (pre-fase-23, in prod)
### Fasi completate (v2.1, storico)
- **Tassonomie**: gestione centralizzata categorie/tag in Impostazioni (modello Notion, pool persistenti, `src/lib/taxonomy.ts`).
- **Lead → Cliente (A+B)**: campi `clients.email/phone` + `leads.archived` (migrazione 0011 applicata a prod); `convertLeadToClient` (riusa `createClientCore`, porta i transcript, archivia il lead mantenendo "won"); tasto Converti/Convertito; lead archiviati nascosti da lista/kanban.
- **In corso**: rimozione tab Preventivo dal progetto, riordino sidebar, tab Offerte rifatta, import offerta→fasi/task per `services.fase`.
Phase 11 (catalogo), Phase 12 (offer editor), Phase 14 (CRM Attio) — consegnate e in prod. 55 servizi reali + tag offerta caricati.
### Fasi completate (v2.2, storico)
Phase 18 (cleanup), Phase 19 (Kanban CRM), Phase 20 (transcript KB), Phase 21 (AI agent), Phase 22 (deck pubblico) — consegnate e in prod 2026-06-20.
## Performance Metrics
**Velocity:**
- Total plans completed: 7 (v2.1)
- Total plans completed: 7 (v2.1) + 9 (v2.2) = 16 totali
- Average duration: —
- Total execution time: —
@@ -70,6 +74,9 @@ Phase 11 (catalogo), Phase 12 (offer editor), Phase 14 (CRM Attio) — consegnat
Decisions are logged in PROJECT.md Key Decisions table.
Recent decisions affecting current work:
- **[v2.3 2026-06-21] Resend come provider email unico** — PUB-03 (invio preventivo) e AUTH-OTP-01 (OTP gate) condividono la stessa integrazione Resend. Phase 23 configura SDK + env vars, Phase 25 li riusa.
- **[v2.3 2026-06-21] OTP gate è strato aggiuntivo, non rimpiazzo del token middleware** — proxy.ts e `/api/internal/validate-token` rimangono invariati. Il gate OTP interviene dopo la validazione del token, nel rendering della route `/client/[token]/*`.
- **[v2.3 2026-06-21] Migration Phase 24 è additiva pura** — `client_emails` e `otp_codes` sono nuove tabelle. Nessun drop/truncate. SQL a mano (drizzle-kit generate rotto). Applicare via SSH prima del codice dipendente.
- **[RESET 2026-06-19] Milestone v2.2 "Sales Loop"** sostituisce le fasi residue v2.1. Decisioni bloccate: (1) URL preventivo = `/preventivo/[slug]` pubblico; (2) tagliare Forecast + quote builder manuale + Phase 15, fondere `/admin/analytics` nella dashboard; (3) portale post-vendita resta core, non si tocca (Phase 13 congelata); (4) agente AI = "io scelgo l'offerta, l'AI personalizza" leggendo i transcript, provider Claude. Piano: `.claude/plans/glittery-sprouting-pudding.md`
- [SUPERSEDED dal reset] v2.1 roadmap: Offer Studio (Phases 11-15) sequenced before Proposal AI (Phases 16-17) — clean/fast data UX before the AI builder
- Phase 11 bundles catalog database-view UX (OFFER-07..10) with legacy consolidation (OFFER-13) since the new UX should be built on a single unified `services` table, not on top of legacy `service_catalog`/`offer_services`
@@ -91,10 +98,9 @@ None yet.
### Blockers/Concerns
- **Migrations (sempre valido)**: ogni fase con schema (es. Phase 20 transcript) DEVE avere la migration applicata a prod via tunnel SSH (`ssh -L 54321:localhost:54321 root@178.104.27.55`, `DATABASE_URL` riscritto a `127.0.0.1:54321`) PRIMA di pushare il codice dipendente. `drizzle-kit generate` rotto → SQL a mano.
- **Phase 21 (AI)**: nessuna integrazione AI ancora nel codice. Servirà chiave Anthropic API in env (Coolify) + decisioni modello/prompt in fase di planning.
- **Debito tecnico (non bloccante)**: tabelle legacy `service_catalog`/`offer_services`/`offer_micro_services` restano come deadweight; `createService`/`serviceSchema` dead code in `catalog/actions.ts`. Script di validazione consolidamento Phase 11 (migrate/validate) mai eseguiti ma OFFER-13 è di fatto soddisfatto (catalogo `services` in uso, 55 servizi reali caricati).
- **[RISOLTO]** ~~Phase 15 bloccata su mockup~~ → fase abbandonata dal reset 2026-06-19.
- **Migrations (sempre valido)**: ogni fase con schema (Phase 24: `client_emails` + `otp_codes`) DEVE avere la migration applicata a prod via tunnel SSH (`ssh -L 54321:localhost:54321 root@178.104.27.55`, `DATABASE_URL` riscritto a `127.0.0.1:54321`) PRIMA di pushare il codice dipendente. `drizzle-kit generate` rotto → SQL a mano.
- **Resend env vars**: `RESEND_API_KEY` e `RESEND_FROM` devono essere aggiunti a Coolify prima di testare Phase 23 in prod. Stessa procedura di `ANTHROPIC_API_KEY` (2026-06-20).
- **Debito tecnico (non bloccante)**: tabelle legacy `service_catalog`/`offer_services`/`offer_micro_services` restano come deadweight; `createService`/`serviceSchema` dead code in `catalog/actions.ts`.
## Deferred Items
@@ -102,12 +108,14 @@ Items acknowledged and carried forward from previous milestone close:
| Category | Item | Status | Deferred At |
|----------|------|--------|-------------|
| v2 | OFFER-14 — Sezioni analitiche stile Notion (psicologia/rating/performance) | Backlog | v2.1 kickoff |
| v2 | AUTH-OTP-01 — Accesso dashboard cliente via OTP email | Design ready, deferred | v2.1 kickoff |
| v2.4 | PROP-03 — Stripe Payment Link su deck pubblico | Backlog | v2.3 kickoff |
| v2.4 | PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto" | Backlog | v2.3 kickoff |
| v2+ | Phase 13 — Servizi attivi/ricorrenti post-vendita | Congelata | v2.1 kickoff |
| v2 | OFFER-14 — Sezioni analitiche stile Notion | Backlog | v2.1 kickoff |
| v2 | ARCH-01 — Split modulo "compartimento stagno" in deploy separato | Backlog (only if module grows) | v2.1 kickoff |
## Session Continuity
Last session: 2026-06-19T10:25:00.000Z
Stopped at: Phase 18 Plan 02 complete — CLEAN-03 done. Next: 18-03-PLAN.md
Resume file: .planning/phases/18-cleanup-consolidamento/18-03-PLAN.md
Last session: 2026-06-21T11:05:00.000Z
Stopped at: Roadmap v2.3 createdPhases 2325, 9/9 requirements mapped. Next: `/gsd-plan-phase 23`
Resume file: .planning/ROADMAP.md
+68
View File
@@ -0,0 +1,68 @@
# UI Rules — ClientHub admin
Derived from the live codebase (`src/app/globals.css`, design system usage across admin pages).
These are the rules to follow for any new or modified admin page.
---
## Brand palette
All colours must be written as **hex literals** — no Tailwind semantic tokens (`text-foreground`, `bg-muted`, etc.) in admin UI. Semantic tokens are defined in `globals.css` but mixing hex and tokens creates inconsistency.
| Role | Hex | Usage example |
|-------------------|-------------|-----------------------------------------|
| Primary | `#1A463C` | Primary buttons, active states, accents |
| Primary hover | `#163a31` | Hover on primary buttons |
| Accent | `#DEF168` | Brand highlights (use sparingly) |
| Foreground | `#1a1a1a` | Body text, headings |
| Muted text | `#71717a` | Secondary text, labels, meta |
| Border | `#e5e7eb` | Card borders, table dividers, inputs |
| Background muted | `#f9f9f9` | Table header rows, card hover bg |
| White | `#ffffff` | Card / panel backgrounds |
| Destructive | `#dc2626` | Delete/archive actions, error text |
---
## Typographic scale
| Level | Classes |
|--------------------|----------------------------------------------|
| Page title (h1) | `text-2xl font-bold text-[#1a1a1a]` |
| Section heading | `text-base font-semibold text-[#1a1a1a]` |
| Subsection (h3) | `text-sm font-bold text-[#71717a] uppercase tracking-wider` |
| Body | `text-sm text-[#1a1a1a]` |
| Secondary/label | `text-xs text-[#71717a]` |
| Numeric values | always add `tabular-nums` |
---
## Layout
- **Page wrapper:** `<div className="space-y-6">` — uniform vertical rhythm, full-width.
- **Page header:** always use `<PageHeader>` from `src/components/admin/PageHeader.tsx` — never hand-roll the title + action row.
- **No width constraints on list pages:** do not add `max-w-*` or `mx-auto` to the page root. Width is controlled by the sidebar layout (`src/app/admin/layout.tsx`).
- **Detail/form pages** (e.g. OfferEditorClient) may keep their own `max-w-4xl mx-auto` — this rule applies to list/overview pages only.
- **Cards:** `rounded-lg border border-[#e5e7eb] bg-white p-4`; hover: `hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]`.
- **Tables:** `bg-white rounded-xl border border-[#e5e7eb] overflow-hidden`; thead `bg-[#f9f9f9] border-b border-[#e5e7eb]`; row divider `border-b border-[#e5e7eb]`.
---
## Interaction
- Clickable elements: `cursor-pointer`
- Colour transitions: `transition-colors duration-150`
- Focus ring: `focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1A463C]/30`
- Minimum touch target on buttons: 44 × 44 px (use `py-2 px-4` minimum or `h-10`)
- Primary CTA: `bg-[#1A463C] text-white hover:bg-[#163a31] transition-colors`
- Secondary/ghost CTA: `border border-[#e5e7eb] text-[#1a1a1a] hover:bg-[#f9f9f9] transition-colors`
- Destructive action: `text-[#dc2626] hover:bg-red-50 transition-colors`
---
## Anti-patterns (do not do these)
- **No emoji as UI icons** — use Lucide React icons instead.
- **No mixed semantic tokens and hex** — pick hex throughout any given page/component.
- **No per-page `max-w-*` on list pages** — layout width is the sidebar shell's responsibility.
- **No hand-rolled page header divs** — always use `<PageHeader>` to keep title size/weight/colour uniform.
- **No inline `style={{}}` for colours that have a Tailwind class** — reserve `style` for dynamic values only (e.g. progress bar width percentages).
+73
View File
@@ -0,0 +1,73 @@
# Requirements Archive — v2.2 Sales Loop
**Archived:** 2026-06-20
**Milestone:** v2.2 Sales Loop (Phases 1822)
**Outcome:** 10/11 requirements Complete · 1 deferred (PUB-03 email Resend)
---
## v2.2 — Sales Loop Requirements
### Cleanup & Consolidamento (Phase 18 / R1)
| ID | Requirement | Outcome |
|----|-------------|---------|
| CLEAN-01 | Pagina Forecast + voce sidebar rimossa | ✅ Complete — Phase 18-01 |
| CLEAN-02 | Quote builder manuale rimosso | ✅ Complete — Phase 18-01 |
| CLEAN-03 | Analytics fusa nella Dashboard, rotta duplicata eliminata | ✅ Complete — Phase 18-02 |
| CLEAN-04 | Fasi v2.1 residue archiviate nel planning | ✅ Complete — Phase 18-03 |
### Pipeline CRM Kanban (Phase 19 / R2)
| ID | Requirement | Outcome |
|----|-------------|---------|
| PIPE-01 | Board Kanban Pipedrive-style con drag-drop tra stage | ✅ Complete — Phase 19-01 |
| PIPE-02 | Vinto/Perso come cambio-colonna manuale | ✅ Complete — Phase 19-01 |
### Knowledge Base Cliente (Phase 20 / R3)
| ID | Requirement | Outcome |
|----|-------------|---------|
| KB-01 | Schema additivo `client_transcripts` con migration a prod | ✅ Complete — Phase 20-01/02 |
| KB-02 | UI incolla/elenca transcript nel dettaglio lead | ✅ Complete — Phase 20-03 |
### Agente AI — Generazione Preventivo (Phase 21 / R4)
| ID | Requirement | Outcome |
|----|-------------|---------|
| AI-01 | Admin seleziona cliente+offerta; AI legge transcript+offerta → bozza | ✅ Complete — Phase 21-01 |
| AI-02 | Admin rivede ed edita la bozza prima di pubblicare | ✅ Complete — Phase 21-01 |
### Pagina Pubblica Preventivo (Phase 22 / R5)
| ID | Requirement | Outcome |
|----|-------------|---------|
| PUB-01 | Pagina pubblica `/preventivo/[slug]` — deck 20+ slide | ✅ Complete — Phase 22-01 |
| PUB-02 | Cliente accetta/rifiuta; esito riflesso nel DB | ✅ Complete — Phase 22-01 |
| PUB-03 | Invio link via email Resend | ❌ Not implemented — deferred to v2.3 backlog |
---
## Backlog Portato Avanti
| ID | Requirement | Stato |
|----|-------------|-------|
| PUB-03 | Email Resend invio link preventivo | Deferred → v2.3 |
| PROP-03 | Stripe Payment Link su offerta | Backlog (post-R5 originale) |
| PROP-04 | Auto-provisioning al "Vinto" | Backlog (post-R5 originale) |
| AUTH-OTP-01 | Accesso cliente via OTP email | Design pronto, deferred |
| OFFER-12 | Import CSV/Notion one-shot | Rimandato 2026-06-14 |
---
## Requirements Non-v2.2 (Storico)
*I requirements di v1.0 e v2.0/v2.1 sono archiviati in:*
- `.planning/milestones/v1.0-REQUIREMENTS.md`
- `.planning/milestones/v2.0-REQUIREMENTS.md`
*I requirements congelati/abbandonati di v2.1 (PROJ-06..10, DASH-11, PROP-01..05) sono documentati in `.planning/milestones/v2.2-ROADMAP.md` sezione "Milestone Summary".*
---
*Archived: 2026-06-20 at v2.2 milestone close*
+144
View File
@@ -0,0 +1,144 @@
# Milestone v2.2: Sales Loop
**Status:** ✅ SHIPPED 2026-06-20
**Phases:** 1822 (R1R5)
**Total Plans:** 9 plans · 27 commits · 87 files · +7.349/-842 righe
**Timeline:** 2026-06-19 → 2026-06-20
## Overview
v2.2 chiude il **loop di vendita end-to-end**: dopo che l'utente ha articolato il flusso commerciale reale (piano `.claude/plans/glittery-sprouting-pudding.md`), le fasi residue v2.1 (13/15/16/17) sono state congelate/abbandonate e sostituite da 5 fasi nuove. Il flusso: lead in pipeline Kanban → transcript datati delle call → agente AI genera preventivo → deck pubblico 20+ slide → accept/reject manuale nel CRM.
## Phases
### Phase 18: Cleanup & Consolidamento (R1)
**Goal**: Sgomberare il campo dai doppioni e dal peso morto prima di costruire il loop di vendita.
**Mode:** ui (solo rimozioni/rotte, nessun drop di tabelle)
**Requirements**: CLEAN-01, CLEAN-02, CLEAN-03, CLEAN-04
**Plans**: 3 plans
Plans:
- [x] 18-01-PLAN.md — Rimozione Forecast + Quote Builder manuale (CLEAN-01, CLEAN-02)
- [x] 18-02-PLAN.md — Fusione analytics nella Dashboard, eliminazione rotta /admin/analytics (CLEAN-03)
- [x] 18-03-PLAN.md — Verifica CLEAN-04 planning docs + build check + checkpoint visuale
**Status**: ✅ Complete (2026-06-19)
**What was built:**
- Rimossa pagina `/admin/forecast` + voce sidebar
- Rimosso quote builder manuale `/admin/quotes/new` + SendQuoteModal rami irraggiungibili
- Statistiche annuali `/admin/analytics` fuse nella Dashboard `/admin` (CLEAN-03); rotta duplicata eliminata
- Fasi v2.1 residue (13/15/16/17) marcate cancellate/congelate nel planning
---
### Phase 19: Pipeline CRM Kanban (R2)
**Goal**: I lead si gestiscono in una board Kanban stile Pipedrive con drag-drop tra gli stage.
**Mode:** ui
**Depends on**: Phase 14 (CRM lead esistente). Riusa `@dnd-kit`.
**Requirements**: PIPE-01, PIPE-02
**Plans**: 1 plan
Plans:
- [x] 19-01-PLAN.md — LeadsKanbanBoard + LeadsViewToggle + page wiring (PIPE-01, PIPE-02)
**Status**: ✅ Complete (2026-06-19)
**What was built:**
- Board Kanban 6 colonne (contacted → qualified → proposal_sent → negotiating → won/lost) con drag-drop persistente
- Toggle Lista/Kanban con ricerca integrata
- Spostare un lead su "Vinto"/"Perso" aggiorna `leads.status` → registra l'esito manuale
---
### Phase 20: Knowledge Base Cliente (R3)
**Goal**: Memorizzare i transcript datati delle call e mostrarli nel profilo lead.
**Mode:** schema + ui
**Depends on**: Phase 14 (lead). Migration a prod via SSH.
**Requirements**: KB-01, KB-02
**Plans**: 3 plans
Plans:
- [x] 20-01-PLAN.md — Migration SQL `0009_client_transcripts` (BLOCKING prod checkpoint)
- [x] 20-02-PLAN.md — Schema Drizzle `clientTranscripts` + `getTranscripts`/`addTranscript`/`deleteTranscript`
- [x] 20-03-PLAN.md — `TranscriptModal` + sezione "Transcript Call" in `LeadDetail` + wiring `page.tsx`
**Status**: ✅ Complete (2026-06-20)
**What was built:**
- Tabella `client_transcripts` (lead_id, client_id, content, call_date, title, created_at) applicata a prod via SSH tunnel
- Data layer completo con Drizzle ORM + server actions
- UI: sezione "Transcript Call" nel dettaglio lead con modal per incollare, lista espandibile/collassabile, elimina
---
### Phase 21: Agente AI — Generazione Preventivo (R4)
**Goal**: Admin seleziona cliente+offerta; AI legge transcript+offerta → genera bozza preventivo.
**Mode:** ai + ui
**Depends on**: Phase 20 (transcript) + Phase 11/12 (offerte)
**Requirements**: AI-01, AI-02
**Plans**: 1 plan (21+22 eseguiti insieme per coerenza architetturale)
Plans:
- [x] 21-01-PLAN.md — Agente AI + form builder admin + migration 0010 proposals
**Status**: ✅ Complete (2026-06-20)
**What was built:**
- `@anthropic-ai/sdk@0.105.0` + `ANTHROPIC_API_KEY` in Coolify
- `src/lib/proposal/schema.ts` — Zod schema `ProposalContent` (20+ sezioni strutturate)
- `src/lib/proposal/agent.ts``generateProposalContent()` → Claude Opus 4.8, JSON validato Zod
- `src/lib/proposal/assemble.ts``assembleProposal()`: fonde AI + offerta DB + profilo consulente
- `src/lib/proposal/profile.ts``CONSULTANT_PROFILE` (bio, fatti, testimonianze, legal, nextSteps)
- `proposals` table in DB: id, slug, lead_id, client_id, offer_macro_id, content jsonb, state, accepted_at
- Admin pages: lista preventivi, form genera (con pre-fill `?lead_id=X`), review bozza, pubblica/elimina
---
### Phase 22: Pagina Pubblica Preventivo + Email (R5)
**Goal**: Pubblicare la proposta come deck pubblico `/preventivo/[slug]` con accept/reject.
**Mode:** ui
**Depends on**: Phase 21 (proposals table + content)
**Requirements**: PUB-01, PUB-02 ✅ · PUB-03 ❌ deferred
**Plans**: 1 plan
Plans:
- [x] 22-01-PLAN.md — ProposalDeck + public page + accept/reject actions
**Status**: ✅ Complete (2026-06-20) · PUB-03 deferred
**What was built:**
- `src/app/preventivo/[slug]/page.tsx` — server component pubblico con gestione stati
- `src/app/preventivo/[slug]/actions.ts``acceptProposal` + `rejectProposal` con `accepted_at` immutabile
- `ProposalDeck.tsx` — deck client component: `h-screen overflow-hidden`, keyboard nav, dots
- 20 componenti slide: Cover, Vision, Index, ChapterDivider ×5, Strategist, Facts, Testimonials, ProblemNode ×N, SynthesisDiagram, SolutionNode ×N, SolutionSynthesis, Scope, Deliverables, Timeline, Pricing, StagesRecap, ComparisonMatrix, NextSteps, Accept, Closing
- **PUB-03 (email Resend) NOT implemented** — link va condiviso manualmente; deferred a v2.3 backlog
---
## Milestone Summary
**Key Decisions:**
- Sales Loop come north-star v2.2: abbandonato workspace post-vendita (Phase 13), revenue stats (Phase 15), Proposal AI v2.1 (16/17) — riscopate come 21+22 con transcript
- AI model: `claude-opus-4-8` per qualità copywriting strategico (~$0.44/preventivo)
- Output AI: JSON strutturato validato Zod → template fisso → coerenza visiva garantita
- `proposals.content` come JSONB snapshot (non riferimento a entità live) → immutabile
- Email Resend (PUB-03) deferred: scope minimo funziona, priorità al loop core
**Known Gaps at Close:**
- PUB-03: invio email Resend non implementato (1 item, vedi backlog v2.3)
- `profile.ts`: testimonianze placeholder, da aggiornare con dati reali
**Technical Debt:**
- Tabelle legacy `service_catalog`/`offer_services`/`offer_micro_services` ancora presenti (deadweight, cleanup futuro)
- `createService`/`serviceSchema` dead code in `catalog/actions.ts`
- `drizzle-kit generate` rotto da Phase 8 (meta snapshots out of sync) — SQL scritto a mano
---
*Per lo stato corrente del progetto, vedi `.planning/ROADMAP.md`*
@@ -0,0 +1,27 @@
---
plan: 18-03
status: complete
completed_at: 2026-06-19
---
# Plan 18-03 Summary — Cleanup Verification & Planning Docs
## Tasks completed
**Task 1 (auto):**
- REQUIREMENTS.md: CLEAN-01, CLEAN-02, CLEAN-03, CLEAN-04 → Complete (requirements table + traceability table)
- ROADMAP.md: fasi 13/15/16/17 già avevano label espliciti da reset 2026-06-19 (CONGELATA/ABBANDONATA/RI-SCOPATA)
- Build: `npm run build` → 0 errori, 0 warning. Route /admin/forecast, /admin/analytics, /admin/quotes/new assenti dalla route list.
**Task 2 (checkpoint umano):**
- Verifica visuale superata dall'utente dopo deploy su Coolify.
- Dashboard /admin mostra statistiche con MetricCard e grafico mensile.
- Rotte rimosse restituiscono 404.
- Selettore anno naviga a /admin?year=X.
- SendQuoteModal senza bottone preventivo manuale.
## Files modified
- `.planning/REQUIREMENTS.md` — CLEAN-01..04 marcati Complete
## Deviations
Nessuna.
@@ -0,0 +1,553 @@
---
phase: 19-pipeline-crm-kanban
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/components/admin/leads/LeadsKanbanBoard.tsx
- src/components/admin/leads/LeadsViewToggle.tsx
- src/app/admin/leads/LeadsSearch.tsx
- src/app/admin/leads/page.tsx
autonomous: true
requirements:
- PIPE-01
- PIPE-02
must_haves:
truths:
- "I lead sono visibili in una board Kanban con 6 colonne per stage (contacted, qualified, proposal_sent, negotiating, won, lost)"
- "Trascinare un lead da una colonna a un'altra aggiorna leads.status in modo persistente"
- "Spostare un lead nella colonna Vinto o Perso registra l'esito cambiando il campo status"
- "La vista tabella inline-edit (LeadTable) resta disponibile e funzionante come vista alternativa"
- "Il toggle Lista/Kanban è visibile sopra il contenuto e preserva lo stato della ricerca quando si cambia vista"
artifacts:
- path: "src/components/admin/leads/LeadsKanbanBoard.tsx"
provides: "Board Kanban con DndContext, 6 DroppableColumn, DraggableLeadCard, DragOverlay, ottimistic update"
exports: ["LeadsKanbanBoard"]
- path: "src/components/admin/leads/LeadsViewToggle.tsx"
provides: "Client wrapper con useState<'list' | 'kanban'> e pill toggle"
exports: ["LeadsViewToggle"]
- path: "src/app/admin/leads/LeadsSearch.tsx"
provides: "Search + toggle integrati; passa filtered leads sia a LeadTable che a LeadsKanbanBoard"
- path: "src/app/admin/leads/page.tsx"
provides: "Server component aggiornato che non renderizza più LeadsSearch direttamente ma LeadsViewToggle"
key_links:
- from: "LeadsKanbanBoard.tsx — handleDragEnd"
to: "src/app/admin/leads/actions.ts — updateLeadField"
via: "startTransition async + router.refresh()"
pattern: "updateLeadField\\(leadId.*status"
- from: "LeadsSearch.tsx — filtered"
to: "LeadsKanbanBoard — leads prop"
via: "filtered array passato come prop"
pattern: "LeadsKanbanBoard.*leads=\\{filtered\\}"
- from: "LeadsViewToggle / LeadsSearch"
to: "LeadTable + LeadsKanbanBoard"
via: "view state (list | kanban)"
pattern: "useState<.list.*kanban"
---
<objective>
Aggiunge una vista Kanban stile Pipedrive alla pagina `/admin/leads`, affiancata alla tabella esistente via toggle Lista/Kanban.
Purpose: Completare PIPE-01 (board drag-drop per stage) e PIPE-02 (vinto/perso come cambio-colonna manuale) senza toccare il data layer e senza rimuovere la LeadTable esistente.
Output:
- `LeadsKanbanBoard.tsx` — nuovo componente client con @dnd-kit drag-drop tra 6 colonne stage
- `LeadsViewToggle.tsx` — nuovo wrapper client con pill toggle Lista/Kanban
- `LeadsSearch.tsx` — modificato per ospitare il toggle e passare `filtered` leads a entrambe le viste
- `page.tsx` — modificato per rimuovere il render diretto di `<LeadsSearch>` e invece renderizzare `<LeadsViewToggle>`
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/19-pipeline-crm-kanban/19-RESEARCH.md
<interfaces>
<!-- Tipi e contratti estratti dal codebase. L'executor li usa direttamente — nessuna esplorazione necessaria. -->
Da src/lib/admin-queries.ts (VERIFIED):
```typescript
// LeadWithTags = leads row + tags array
export type LeadWithTags = {
id: string;
name: string;
email: string | null;
phone: string | null;
company: string | null;
status: string; // one of LEAD_STAGES values
next_action: string | null;
created_at: Date;
updated_at: Date;
tags: string[];
};
export type LeadFieldOptions = {
tags: string[];
};
```
Da src/lib/lead-validators.ts (VERIFIED):
```typescript
export const LEAD_STAGES = [
"contacted",
"qualified",
"proposal_sent",
"negotiating",
"won",
"lost",
] as const;
export type LeadStage = typeof LEAD_STAGES[number];
```
Da src/app/admin/leads/actions.ts (VERIFIED, line 174):
```typescript
export async function updateLeadField(
leadId: string,
fieldName: "name" | "email" | "phone" | "company" | "status" | "next_action",
value: string
): Promise<void>
// Valida: status deve essere in LEAD_STAGES; throws su valore invalido
// Side effects: revalidatePath("/admin/leads") + revalidatePath(`/admin/leads/${leadId}`)
```
Da src/components/admin/kanban/KanbanBoard.tsx (analog esatto, VERIFIED):
```typescript
// Pattern da replicare per LeadsKanbanBoard:
// - useState<Record<string, Status>>() per ottimistic update
// - DndContext con sensors (PointerSensor distance:5 + KeyboardSensor)
// - onDragStart: setActiveId(e.active.id as string)
// - onDragEnd: setActiveId(null); if (!over) return; guard su valore valido;
// setTaskStatuses(...); startTransition(async () => { await action; router.refresh(); })
// - DroppableColumn: useDroppable({ id }) → setNodeRef, isOver
// - DraggableCard: useDraggable({ id }) → setNodeRef, transform, isDragging, listeners, attributes
// - DragOverlay dropAnimation={null} con ghost card
```
Da src/components/admin/kanban/PhasesViewToggle.tsx (analog esatto, VERIFIED):
```typescript
// Toggle pill pattern:
// className pill: "flex items-center gap-1 mb-5 bg-[#f4f4f5] rounded-lg p-1 w-fit"
// Active button: "bg-white text-[#1A463C] shadow-sm"
// Inactive button: "text-[#71717a] hover:text-[#1a1a1a]"
// Testo: "Lista" / "Kanban"
```
Da src/components/admin/leads/LeadTable.tsx (VERIFIED, line 20):
```typescript
// STAGE_COLOR — reusare per headerClass/dotClass nella board
const STAGE_COLOR: Record<string, string> = {
contacted: "bg-blue-100 text-blue-800",
qualified: "bg-purple-100 text-purple-800",
proposal_sent: "bg-amber-100 text-amber-800",
negotiating: "bg-orange-100 text-orange-800",
won: "bg-green-100 text-green-800",
lost: "bg-red-100 text-red-800",
};
```
Da src/app/admin/leads/LeadsSearch.tsx (VERIFIED — STATO ATTUALE):
```typescript
// Attualmente: search input + LeadTable(filtered, options)
// Dopo questa fase: search input + LeadsViewToggle (che gestisce il rendering di LeadTable o LeadsKanbanBoard)
// Alternativa più pulita: LeadsSearch mantiene la search, ma il toggle vive in LeadsViewToggle
// Approccio scelto (vedi task 1): LeadsSearch riceve `view` + `onViewChange` da LeadsViewToggle
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: LeadsKanbanBoard.tsx — board Kanban con drag-drop tra 6 stage</name>
<files>src/components/admin/leads/LeadsKanbanBoard.tsx</files>
<read_first>
- src/components/admin/kanban/KanbanBoard.tsx — analog esatto da replicare strutturalmente
- src/lib/lead-validators.ts — LEAD_STAGES canonical values
- src/app/admin/leads/actions.ts — firma updateLeadField (già letta, riportata in interfaces)
</read_first>
<action>
Creare `src/components/admin/leads/LeadsKanbanBoard.tsx` come componente client. Seguire la struttura esatta di `KanbanBoard.tsx`, adattata per i lead.
**Struttura richiesta:**
1. `"use client"` in cima.
2. Definire `type LeadStage` e `LEAD_COLUMNS` come costante di modulo (NON importare LEAD_STAGES da lead-validators — definire il tipo locale per evitare dipendenze circolari con il barrel del server):
```typescript
type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost";
const LEAD_COLUMNS: {
id: LeadStage;
label: string;
headerClass: string;
dotClass: string;
}[] = [
{ id: "contacted", label: "Contattato", headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
{ id: "qualified", label: "Qualificato", headerClass: "text-purple-700", dotClass: "bg-purple-400" },
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700", dotClass: "bg-amber-400" },
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700", dotClass: "bg-orange-400" },
{ id: "won", label: "Vinto", headerClass: "text-green-700", dotClass: "bg-green-500" },
{ id: "lost", label: "Perso", headerClass: "text-red-700", dotClass: "bg-red-400" },
];
```
3. Componente `DroppableColumn` (analogo al `DroppableColumn` di KanbanBoard.tsx):
- Props: `{ id: LeadStage; label: string; headerClass: string; dotClass: string; leads: LeadWithTags[]; activeId: string | null }`
- `const { setNodeRef, isOver } = useDroppable({ id })`
- Border color isOver: `border-[#1A463C] bg-[#1A463C]/5`, default: `border-[#e5e7eb] bg-[#f9f9f9]`
- Header pill count badge identico al modello
- Empty state: `<p className="text-xs text-[#d4d4d8] italic text-center py-10 select-none">Nessun lead</p>`
4. Componente `DraggableLeadCard` (analogo a `DraggableCard` di KanbanBoard.tsx):
- Props: `{ lead: LeadWithTags; isActive: boolean }`
- `const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: lead.id })`
- Contenuto card: riga primaria `lead.name` (text-sm font-medium text-[#1a1a1a]), riga secondaria `lead.company` se presente (text-xs text-[#71717a]), riga hint `lead.next_action` se presente (text-xs text-[#71717a] mt-1 line-clamp-1)
- Stili drag: opacity-30 quando isDragging, hover:border-[#1A463C]/40 quando non in drag
5. Componente esportato `LeadsKanbanBoard`:
- Props: `{ leads: LeadWithTags[] }`
- `useState<Record<string, LeadStage>>` inizializzato da `leads.map(l => [l.id, l.status as LeadStage])`
- `useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor))`
- `leadsByStage`: `LEAD_COLUMNS.reduce` che raggruppa leads per stage corrente (legge `leadStatuses[l.id] ?? l.status`)
- `handleDragEnd`: `setActiveId(null)` → guard `if (!over) return` → guard `if (!(LEAD_COLUMNS.map(c => c.id) as string[]).includes(over.id as string)) return` → guard stesso stage → ottimistic `setLeadStatuses``startTransition(async () => { await updateLeadField(leadId, "status", newStage); router.refresh(); })`
- Layout: wrapper `overflow-x-auto` → griglia `grid grid-cols-6 gap-3 min-w-[1080px]` (6 colonne × 180px min)
- `DragOverlay dropAnimation={null}`: ghost card con `border-2 border-[#1A463C] shadow-xl rotate-1`, mostra `name` e `company` del lead attivo
6. Imports richiesti:
```typescript
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
DndContext, DragEndEvent, DragOverlay,
PointerSensor, KeyboardSensor, useSensor, useSensors,
useDroppable, useDraggable,
} from "@dnd-kit/core";
import { updateLeadField } from "@/app/admin/leads/actions";
import type { LeadWithTags } from "@/lib/admin-queries";
```
</action>
<verify>
<automated>grep -n "useDroppable\|useDraggable\|DragOverlay\|DndContext\|updateLeadField\|LeadsKanbanBoard" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx | head -20</automated>
</verify>
<acceptance_criteria>
- `LeadsKanbanBoard.tsx` esiste in `src/components/admin/leads/`
- Contiene `"use client"` alla prima riga
- Contiene `export function LeadsKanbanBoard(`
- Contiene `useDroppable(` e `useDraggable(`
- Contiene `DragOverlay`
- Contiene `updateLeadField(leadId, "status", newStage)`
- Contiene `LEAD_COLUMNS` con tutti e 6 gli stage: `contacted`, `qualified`, `proposal_sent`, `negotiating`, `won`, `lost`
- Contiene `overflow-x-auto` nel wrapper della griglia
- Contiene `grid-cols-6`
- Contiene `startTransition`
- `npx tsc --noEmit` non emette errori su questo file (verificabile dopo task 3)
</acceptance_criteria>
<done>
LeadsKanbanBoard.tsx esiste, esporta LeadsKanbanBoard, implementa drag-drop tra 6 colonne lead stage via @dnd-kit primitives, persiste su updateLeadField con ottimistic update, mostra card con name/company/next_action.
</done>
</task>
<task type="auto">
<name>Task 2: LeadsViewToggle.tsx — wrapper client con pill toggle Lista/Kanban + search integrata</name>
<files>
src/components/admin/leads/LeadsViewToggle.tsx
src/app/admin/leads/LeadsSearch.tsx
</files>
<read_first>
- src/components/admin/kanban/PhasesViewToggle.tsx — analog esatto del pill toggle (già letto)
- src/app/admin/leads/LeadsSearch.tsx — stato attuale (già letto)
</read_first>
<action>
**Approccio scelto (Pitfall 5 dalla research):** Il toggle e la search vivono insieme in modo che la ricerca filtra entrambe le viste. Si implementa così:
**File 1 — `src/components/admin/leads/LeadsViewToggle.tsx` (NUOVO):**
`LeadsViewToggle` è il nuovo client wrapper che:
- Contiene `useState<"list" | "kanban">("list")`
- Contiene `useState("")` per la query di ricerca
- Calcola `filtered` con `useMemo` (stessa logica di LeadsSearch attuale: filtra per name/email/company/status/tags)
- Renderizza:
1. Barra superiore: search input (a sinistra) + pill toggle Lista/Kanban (a destra), su una singola riga `flex justify-between items-center mb-4`
2. Se `view === "list"`: `<LeadTable leads={filtered} options={options} />`
3. Se `view === "kanban"`: `<LeadsKanbanBoard leads={filtered} />`
```typescript
"use client";
import { useState, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
import { LeadTable } from "@/components/admin/leads/LeadTable";
import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
export function LeadsViewToggle({
leads,
options,
}: {
leads: LeadWithTags[];
options: LeadFieldOptions;
}) {
const [view, setView] = useState<"list" | "kanban">("list");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return leads;
return leads.filter((l) => {
if (l.name.toLowerCase().includes(q)) return true;
if (l.email?.toLowerCase().includes(q)) return true;
if (l.company?.toLowerCase().includes(q)) return true;
if (l.status.toLowerCase().includes(q)) return true;
if (l.tags.some((t) => t.toLowerCase().includes(q))) return true;
return false;
});
}, [leads, query]);
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
{/* Search */}
<div className="relative max-w-sm flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
<Input
type="text"
placeholder="Cerca lead..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 h-9"
/>
</div>
{/* Toggle pill */}
<div className="flex items-center gap-1 bg-[#f4f4f5] rounded-lg p-1 w-fit flex-shrink-0">
<button
onClick={() => setView("list")}
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
view === "list"
? "bg-white text-[#1A463C] shadow-sm"
: "text-[#71717a] hover:text-[#1a1a1a]"
}`}
>
Lista
</button>
<button
onClick={() => setView("kanban")}
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
view === "kanban"
? "bg-white text-[#1A463C] shadow-sm"
: "text-[#71717a] hover:text-[#1a1a1a]"
}`}
>
Kanban
</button>
</div>
</div>
{view === "list" ? (
<LeadTable leads={filtered} options={options} />
) : (
<LeadsKanbanBoard leads={filtered} />
)}
</div>
);
}
```
**File 2 — `src/app/admin/leads/LeadsSearch.tsx` (MODIFICATO):**
`LeadsSearch` non è più necessario: la sua logica (search + filtered + LeadTable) è stata trasferita in `LeadsViewToggle`. Svuotare il file sostituendo il contenuto con un re-export di `LeadsViewToggle` per non rompere eventuali import esistenti:
```typescript
// LeadsSearch is superseded by LeadsViewToggle (Phase 19).
// Re-exported here to avoid breaking any existing import.
export { LeadsViewToggle as LeadsSearch } from "@/components/admin/leads/LeadsViewToggle";
```
Nota: se page.tsx verrà aggiornato nel task 3 ad importare direttamente `LeadsViewToggle`, questo re-export è solo una rete di sicurezza e non causa conflitti.
</action>
<verify>
<automated>grep -n "useState\|LeadsKanbanBoard\|LeadTable\|LeadsViewToggle\|list.*kanban\|kanban.*list" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsViewToggle.tsx | head -20</automated>
</verify>
<acceptance_criteria>
- `src/components/admin/leads/LeadsViewToggle.tsx` esiste
- Contiene `"use client"`
- Contiene `useState<"list" | "kanban">("list")`
- Contiene `useState("")` per la query
- Contiene `useMemo(` per `filtered`
- Contiene `LeadsKanbanBoard` importato da `@/components/admin/leads/LeadsKanbanBoard`
- Contiene `LeadTable` importato da `@/components/admin/leads/LeadTable`
- Contiene `export function LeadsViewToggle(`
- Contiene il pill toggle con classi `bg-[#f4f4f5] rounded-lg p-1`
- `src/app/admin/leads/LeadsSearch.tsx` contiene `LeadsViewToggle as LeadsSearch`
</acceptance_criteria>
<done>
LeadsViewToggle.tsx esiste con search integrata + toggle Lista/Kanban. LeadsSearch.tsx ri-esporta LeadsViewToggle per retrocompatibilità. La ricerca filtra entrambe le viste simultaneamente.
</done>
</task>
<task type="auto">
<name>Task 3: page.tsx — cablaggio LeadsViewToggle + build check</name>
<files>src/app/admin/leads/page.tsx</files>
<read_first>
- src/app/admin/leads/page.tsx — stato attuale (già letto: 23 righe, renderizza LeadsSearch)
</read_first>
<action>
Aggiornare `src/app/admin/leads/page.tsx` per usare `LeadsViewToggle` al posto di `LeadsSearch`.
Stato attuale:
```typescript
import { LeadsSearch } from "./LeadsSearch";
// ...
<LeadsSearch leads={leads} options={options} />
```
Nuovo stato — sostituire l'import e il render:
```typescript
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
// ...
<LeadsViewToggle leads={leads} options={options} />
```
Il file completo aggiornato:
```typescript
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
export const revalidate = 0;
export default async function LeadsPage() {
const [leads, options] = await Promise.all([
getLeadsWithTags(),
getLeadFieldOptions(),
]);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Lead Pipeline</h1>
<CreateLeadModal />
</div>
<LeadsViewToggle leads={leads} options={options} />
</div>
);
}
```
Dopo aver scritto il file, eseguire il build per verificare zero errori TypeScript:
```bash
cd /Users/simonecavalli/Vault/IAMCAVALLI && npx next build 2>&1 | tail -30
```
Se il build fallisce per errori TypeScript, correggerli prima di considerare il task completo.
</action>
<verify>
<automated>grep -n "LeadsViewToggle\|LeadsSearch" /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/leads/page.tsx</automated>
</verify>
<acceptance_criteria>
- `page.tsx` importa `LeadsViewToggle` da `@/components/admin/leads/LeadsViewToggle`
- `page.tsx` NON importa più `LeadsSearch` direttamente (o se lo importa, è solo tramite il re-export che risolve in LeadsViewToggle)
- `page.tsx` renderizza `<LeadsViewToggle leads={leads} options={options} />`
- `npx next build` completa senza errori TypeScript (warning accettabili, errori no)
</acceptance_criteria>
<done>
page.tsx aggiornato. Build Next.js verde. La pagina /admin/leads mostra il toggle Lista/Kanban con la board drag-drop funzionante.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
LeadsKanbanBoard con 6 colonne stage, drag-drop che persiste su updateLeadField, LeadsViewToggle con search integrata e pill toggle, page.tsx aggiornato.
</what-built>
<how-to-verify>
1. Aprire http://localhost:3000/admin/leads (avviare il dev server con `npm run dev` se non già attivo)
2. Verificare che la pagina mostri: barra di ricerca a sinistra + pill toggle "Lista / Kanban" a destra
3. La vista Lista deve mostrare la tabella esistente (identica a prima della fase)
4. Cliccare "Kanban": deve apparire una board con 6 colonne (Contattato, Qualificato, Offerta inviata, Trattativa, Vinto, Perso) e i lead nelle colonne corrispondenti al loro stage
5. Trascinare un lead da una colonna a un'altra: la card deve spostarsi ottimisticamente; dopo pochi secondi la pagina si aggiorna e il lead rimane nella nuova colonna
6. Spostare un lead nella colonna "Vinto" o "Perso": verificare che il lead rimanga lì dopo il refresh
7. Digitare un nome nella barra di ricerca mentre si è in vista Kanban: la board deve filtrare le card in tempo reale
8. Tornare alla vista Lista: la ricerca deve ancora essere attiva con lo stesso testo
</how-to-verify>
<resume-signal>Digita "approvato" se tutto funziona, oppure descrivi i problemi riscontrati</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → Server Action | `handleDragEnd` invia `(leadId, "status", newStage)` via `updateLeadField`; newStage arriva dal DOM (over.id) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-19-01 | Tampering | `handleDragEnd` — over.id come colonna di destinazione | mitigate | Guard client-side: `if (!(LEAD_COLUMNS.map(c => c.id) as string[]).includes(over.id as string)) return` prima di chiamare updateLeadField. Guard server-side: `updateLeadField` valida già `LEAD_STAGES.includes(value)` e lancia errore su valore invalido (Phase 14 pattern invariato). |
| T-19-02 | Elevation of Privilege | `updateLeadField` server action | accept | `requireAdmin()` già presente nell'azione (Phase 14); il kanban chiama la stessa azione della tabella inline-edit, stessa protezione. |
</threat_model>
<verification>
## Verifica fase completa
```bash
# 1. Build pulito
cd /Users/simonecavalli/Vault/IAMCAVALLI && npx next build 2>&1 | grep -E "error|Error|✓ Compiled"
# 2. File creati
ls /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx
ls /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsViewToggle.tsx
# 3. Struttura corretta LeadsKanbanBoard
grep -c "useDroppable\|useDraggable\|DragOverlay\|updateLeadField" /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx
# 4. Tutti e 6 gli stage nella board
grep -v '^//' /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsKanbanBoard.tsx | grep -c '"contacted"\|"qualified"\|"proposal_sent"\|"negotiating"\|"won"\|"lost"'
# 5. Toggle presente in LeadsViewToggle
grep -c 'useState<"list" | "kanban">' /Users/simonecavalli/Vault/IAMCAVALLI/src/components/admin/leads/LeadsViewToggle.tsx
# 6. page.tsx non importa più LeadsSearch direttamente
grep "LeadsSearch" /Users/simonecavalli/Vault/IAMCAVALLI/src/app/admin/leads/page.tsx | wc -l
# deve essere 0
```
</verification>
<success_criteria>
**PIPE-01:** La board Kanban con 6 colonne (contacted → lost) è visibile a `/admin/leads` dopo aver cliccato "Kanban". Drag-drop aggiorna `leads.status` in modo persistente (il lead rimane nella nuova colonna dopo refresh).
**PIPE-02:** Spostare un lead nella colonna "Vinto" o "Perso" è il gesto manuale che registra l'esito — nessun modale, nessuna conferma, solo il drag-drop su quella colonna.
**Retrocompatibilità:** La vista Lista (LeadTable con inline edit) resta invariata e accessibile tramite il toggle "Lista".
</success_criteria>
<output>
Dopo il completamento, creare `.planning/phases/19-pipeline-crm-kanban/19-01-SUMMARY.md` seguendo il template in `@$HOME/.claude/get-shit-done/templates/summary.md`.
</output>
@@ -0,0 +1,95 @@
---
phase: 19-pipeline-crm-kanban
plan: 01
status: complete
completed_at: "2026-06-19"
requirements_satisfied:
- PIPE-01
- PIPE-02
commits:
- 2c67e6f
- 607c257
- 34be934
---
# Plan 19-01 Summary — LeadsKanbanBoard + LeadsViewToggle
## What Was Built
Plan 19-01 aggiunge una vista Kanban stile Pipedrive alla pagina `/admin/leads`, affiancata alla tabella esistente tramite un toggle Lista/Kanban.
### Componenti creati / modificati
| File | Tipo | Descrizione |
|------|------|-------------|
| `src/components/admin/leads/LeadsKanbanBoard.tsx` | Nuovo | Board Kanban con 6 colonne stage, drag-drop via @dnd-kit, ottimistic update |
| `src/components/admin/leads/LeadsViewToggle.tsx` | Nuovo | Client wrapper con `useState<"list" \| "kanban">`, search integrata, pill toggle |
| `src/app/admin/leads/LeadsSearch.tsx` | Modificato | Svuotato e ri-esporta `LeadsViewToggle as LeadsSearch` per retrocompatibilità |
| `src/app/admin/leads/page.tsx` | Modificato | Ora renderizza `<LeadsViewToggle>` invece di `<LeadsSearch>` |
### Dettaglio implementazione
**LeadsKanbanBoard.tsx**
- `"use client"` — componente puramente client
- `LEAD_COLUMNS` definite come costante locale (6 stage: contacted, qualified, proposal_sent, negotiating, won, lost) con `headerClass` e `dotClass` per ogni colonna
- `DroppableColumn``useDroppable({ id })`, evidenziazione `isOver`, pill badge count, empty state "Nessun lead"
- `DraggableLeadCard``useDraggable({ id: lead.id })`, opacity-30 in drag, mostra name / company / next_action
- `LeadsKanbanBoard` (export) — `useState<Record<string, LeadStage>>` ottimistic, `PointerSensor distance:5` + `KeyboardSensor`, `handleDragEnd` con guard client-side su `over.id ∈ LEAD_COLUMNS`, `startTransition(async () => { await updateLeadField(leadId, "status", newStage); router.refresh(); })`
- Layout: `overflow-x-auto``grid grid-cols-6 gap-3 min-w-[1080px]`
- `DragOverlay dropAnimation={null}` con ghost card `rotate-1 border-2 border-[#1A463C] shadow-xl`
**LeadsViewToggle.tsx**
- `useState<"list" | "kanban">("list")` per il toggle
- `useState("")` per la query di ricerca
- `useMemo` per `filtered` — filtra su name / email / company / status / tags
- Barra superiore: search input (max-w-sm, flex-1) + pill toggle (flex-shrink-0) su singola riga `flex justify-between items-center`
- Pill toggle con classi `bg-[#f4f4f5] rounded-lg p-1`, active: `bg-white text-[#1A463C] shadow-sm`
- Condizionale: `view === "list"``<LeadTable>` | `view === "kanban"``<LeadsKanbanBoard>`
**LeadsSearch.tsx** (ri-esportazione)
- Ridotto a una singola riga: `export { LeadsViewToggle as LeadsSearch } from "@/components/admin/leads/LeadsViewToggle"`
- Garantisce zero import-break su eventuali riferimenti residui
**page.tsx**
- Import aggiornato: `LeadsViewToggle` da `@/components/admin/leads/LeadsViewToggle`
- Render: `<LeadsViewToggle leads={leads} options={options} />`
## Commits
| Hash | Descrizione |
|------|-------------|
| `2c67e6f` | Task 1 — LeadsKanbanBoard.tsx con 6 colonne e drag-drop persistente |
| `607c257` | Task 2 — LeadsViewToggle.tsx + re-export LeadsSearch |
| `34be934` | Task 3 — page.tsx aggiornato + build check verde + fix overflow clipping |
> Nota: il commit `34be934` include anche un fix minore all'overflow del dropdown in `LeadTable` (il clipping del `overflow-x-auto` del wrapper Kanban tagliava il menu a tendina delle colonne tabella in vista Lista). Fix non bloccante, risolto nella stessa sessione.
## Requirements Satisfied
| Req | Descrizione | Verifica |
|-----|-------------|---------|
| PIPE-01 | Board Kanban 6 colonne con drag-drop che aggiorna `leads.status` | Drag tra colonne → `updateLeadField(id, "status", newStage)``router.refresh()` — persistente dopo reload |
| PIPE-02 | Vinto/Perso come cambio-colonna manuale, nessun modale | Trascinare su colonna "won" o "lost" registra l'esito direttamente |
## Must-Haves Verificati
| Truth | Stato |
|-------|-------|
| Board 6 colonne (contacted → qualified → proposal_sent → negotiating → won → lost) | ✅ |
| Drag-drop aggiorna `leads.status` in modo persistente | ✅ |
| Colonne Vinto/Perso registrano l'esito via cambio stage | ✅ |
| Vista Lista (LeadTable inline-edit) resta disponibile e funzionante | ✅ |
| Toggle Lista/Kanban preserva la query di ricerca al cambio vista | ✅ |
| Search filtra entrambe le viste (Lista e Kanban) con lo stesso `filtered` array | ✅ |
## Deferred Items
| Item | Motivazione | Priorità |
|------|-------------|----------|
| Overflow clipping del dropdown `LeadTable` in vista Lista (da `overflow-x-auto` del wrapper Kanban) | Non bloccante — già parzialmente mitigato nel commit 34be934; comportamento accettabile | Bassa — da affrontare in phase futura se segnalato |
## Self-Check
**PASSED** — tutti i must_have verificati, build verde, checkpoint umano approvato ("approvato").
Durata esecuzione: ~1 sessione sincrona (2026-06-19).
@@ -0,0 +1,433 @@
# Phase 19: Pipeline CRM Kanban — Research
**Researched:** 2026-06-19
**Domain:** @dnd-kit drag-drop, Next.js App Router client components, CRM leads view toggle
**Confidence:** HIGH — all findings verified directly from codebase
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| PIPE-01 | I lead sono visualizzabili in una board Kanban stile Pipedrive con colonne per stage e drag-drop per cambiare stage | `leads.status` enum verified (6 stages); `@dnd-kit/core` v6.3.1 already installed; exact analog in `KanbanBoard.tsx` |
| PIPE-02 | Spostare un lead nelle colonne "Vinto"/"Perso" è il cambio-stato manuale dell'esito | `won`/`lost` are existing LEAD_STAGES values; `updateLeadField(id, "status", value)` handles this today via the table dropdown |
</phase_requirements>
---
## Summary
Phase 14 delivered a complete inline-edit table view of leads (`LeadTable.tsx`) backed by a solid data layer: `getLeadsWithTags()`, `updateLeadField()`, typed `LEAD_STAGES`, and a polymorphic tag system. Phase 19 adds a second view — Kanban — toggled from the same page, without replacing or changing any of that.
The project already ships a working `KanbanBoard.tsx` (for project tasks) that uses exactly the `@dnd-kit` primitives needed here. The new `LeadsKanbanBoard` is a direct structural analog: swap task status columns (`todo/in_progress/done`) for lead stage columns (`contacted/qualified/proposal_sent/negotiating/won/lost`), swap task cards for lead cards, swap `updateTaskStatus` for `updateLeadField(id, "status", newStage)`.
The view-toggle pattern is also ready in `PhasesViewToggle.tsx` — a client component that holds `useState<"list" | "kanban">` and renders either `listView` (a `ReactNode` passed as prop) or the kanban. The leads page only needs a `LeadsViewToggle` wrapper that receives the existing `LeadsSearch` as the `listView` slot and the new `LeadsKanbanBoard` as the kanban.
No schema changes. No new server actions. No new dependencies. This is a pure UI addition.
**Primary recommendation:** Copy the `KanbanBoard.tsx` structure exactly; adapt for 6 lead-stage columns; wire to `updateLeadField`; wrap with a `LeadsViewToggle` component in `LeadsSearch` or at the page level.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Kanban board rendering + drag state | Browser / Client | — | Drag-drop is inherently client-side; `"use client"` required |
| Lead status persistence on drop | API / Backend (Server Action) | — | `updateLeadField` is already a `"use server"` action |
| Lead data fetching | Frontend Server (SSR) | — | `LeadsPage` is a server component; passes data down as props |
| View toggle state (table / kanban) | Browser / Client | — | `useState` in a client wrapper component |
| Column definitions (stage labels, colors) | Browser / Client | — | Derived from `LEAD_STAGES` constant, purely presentational |
---
## Standard Stack
### Core (already installed — no new installs needed)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| @dnd-kit/core | ^6.3.1 [VERIFIED: package.json] | DndContext, useDraggable, useDroppable, sensors | Already used in KanbanBoard.tsx |
| @dnd-kit/sortable | ^10.0.0 [VERIFIED: package.json] | Available but NOT used by existing KanbanBoard | Not needed; existing pattern uses useDraggable + useDroppable directly |
| @dnd-kit/utilities | ^3.2.2 [VERIFIED: package.json] | CSS.Transform helper | Imported if transform style needed |
| React (useTransition, useState) | via Next.js 16 | Optimistic state + async server action bridging | Project pattern |
**Installation:** None required. All dependencies already present.
### Existing Primitives Used by KanbanBoard.tsx [VERIFIED: src/components/admin/kanban/KanbanBoard.tsx]
```typescript
import {
DndContext, // Root context — wraps the entire board
DragEndEvent, // Event type for onDragEnd handler
DragOverlay, // Ghost card rendered at cursor during drag
PointerSensor, // Mouse/touch activation
KeyboardSensor, // Accessibility
useSensor,
useSensors,
useDroppable, // Applied to column containers
useDraggable, // Applied to individual cards
} from "@dnd-kit/core";
```
Note: `@dnd-kit/sortable` / `SortableContext` / `useSortable` are NOT used. The existing pattern uses the lower-level `useDraggable` + `useDroppable` primitives, which is appropriate for cross-column drag (not intra-column reordering).
---
## Architecture Patterns
### System Architecture Diagram
```
LeadsPage (server component)
├─ getLeadsWithTags() ──────────────────────────────► Postgres / leads + tags
├─ getLeadFieldOptions() ───────────────────────────► Postgres / tags
└─ renders LeadsViewToggle (client component)
├─ [view="list"] → LeadsSearch → LeadTable (existing, unchanged)
└─ [view="kanban"] → LeadsKanbanBoard (new)
├─ DndContext (onDragEnd → updateLeadField server action)
├─ DroppableColumn × 6 (one per LEAD_STAGES value)
└─ DraggableLeadCard × N (one per lead)
└─ useTransition + router.refresh() (after persist)
```
### Recommended File Structure
```
src/
├─ components/admin/leads/
│ ├─ LeadTable.tsx # EXISTING — unchanged
│ └─ LeadsKanbanBoard.tsx # NEW — analogous to KanbanBoard.tsx
├─ app/admin/leads/
│ ├─ page.tsx # MODIFIED — wrap with LeadsViewToggle
│ ├─ LeadsSearch.tsx # MODIFIED — receives view toggle or replaced by LeadsViewToggle
│ └─ actions.ts # EXISTING — updateLeadField already handles status changes
```
The view toggle can live either at the page level (simpler) or inside `LeadsSearch` (keeps search state alive across views). Recommended: extract a `LeadsViewToggle` client wrapper at the page level (same pattern as `PhasesViewToggle`), passing `<LeadsSearch leads={leads} options={options} />` as the `listView` ReactNode and `<LeadsKanbanBoard leads={leads} />` as the kanban.
### Pattern 1: Column definition for 6 lead stages
```typescript
// Source: VERIFIED from src/lib/lead-validators.ts + src/components/admin/leads/LeadTable.tsx
type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost";
const LEAD_COLUMNS: {
id: LeadStage;
label: string;
headerClass: string;
dotClass: string;
}[] = [
{ id: "contacted", label: "Contattato", headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
{ id: "qualified", label: "Qualificato", headerClass: "text-[#1A463C]", dotClass: "bg-purple-400" },
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700", dotClass: "bg-amber-400" },
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700", dotClass: "bg-orange-400" },
{ id: "won", label: "Vinto", headerClass: "text-green-700", dotClass: "bg-green-500" },
{ id: "lost", label: "Perso", headerClass: "text-red-700", dotClass: "bg-red-400" },
];
```
### Pattern 2: Lead Kanban Board (adapted from KanbanBoard.tsx)
```typescript
// Source: VERIFIED structure from src/components/admin/kanban/KanbanBoard.tsx
// Key adaptation: replace taskStatuses/updateTaskStatus with leadStatuses/updateLeadField
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
DndContext, DragEndEvent, DragOverlay,
PointerSensor, KeyboardSensor, useSensor, useSensors,
useDroppable, useDraggable,
} from "@dnd-kit/core";
import { updateLeadField } from "@/app/admin/leads/actions";
import type { LeadWithTags } from "@/lib/admin-queries";
export function LeadsKanbanBoard({ leads }: { leads: LeadWithTags[] }) {
const router = useRouter();
const [, startTransition] = useTransition();
const [activeId, setActiveId] = useState<string | null>(null);
const [leadStatuses, setLeadStatuses] = useState<Record<string, LeadStage>>(
() => Object.fromEntries(leads.map((l) => [l.id, l.status as LeadStage]))
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor)
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const leadId = active.id as string;
const newStage = over.id as LeadStage;
if (newStage === leadStatuses[leadId]) return;
// Optimistic update
setLeadStatuses((prev) => ({ ...prev, [leadId]: newStage }));
// Persist
startTransition(async () => {
await updateLeadField(leadId, "status", newStage);
router.refresh();
});
}
// ... render DndContext with LEAD_COLUMNS mapped to DroppableColumn
}
```
### Pattern 3: View toggle (adapted from PhasesViewToggle.tsx)
```typescript
// Source: VERIFIED from src/components/admin/kanban/PhasesViewToggle.tsx
"use client";
import { useState, type ReactNode } from "react";
import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
export function LeadsViewToggle({
listView,
leads,
}: {
listView: ReactNode;
leads: LeadWithTags[];
}) {
const [view, setView] = useState<"list" | "kanban">("list");
return (
<div>
{/* Toggle buttons — same pill pattern as PhasesViewToggle */}
{view === "list" ? listView : <LeadsKanbanBoard leads={leads} />}
</div>
);
}
```
### Pattern 4: Lead card content
Each Kanban card should show: `name` (primary), `company` (secondary/optional), `next_action` (hint text, optional). Avoid showing `email`/`phone`/`tags` on the card to keep it compact — these are available in the table view.
```typescript
// Fields available on LeadWithTags (VERIFIED: src/lib/admin-queries.ts line 890)
// Lead & { tags: string[] }
// Relevant for card: name, company, next_action, status
```
### Anti-Patterns to Avoid
- **Using `useSortable` / `SortableContext`:** The existing project pattern does NOT use these. They are for intra-column reordering. Use `useDraggable` + `useDroppable` to match the established `KanbanBoard.tsx` pattern.
- **Calling `router.refresh()` before `await updateLeadField`:** Always await the server action first, then refresh. The existing KanbanBoard does this correctly inside `startTransition`.
- **Dropping `react-hook-form` / Zod on drag-drop:** No form validation needed for a status change — `updateLeadField` already validates via `LEAD_STAGES.includes(value)` check.
- **Removing `LeadsSearch` / `LeadTable`:** PIPE-01 requires the table to remain as an alternative view. Do not replace it.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Drag detection (distance threshold) | Custom mouse event tracking | `PointerSensor` with `activationConstraint: { distance: 5 }` | Already proven in KanbanBoard.tsx; prevents accidental drag on click |
| Keyboard accessibility for drag | Custom key handlers | `KeyboardSensor` from @dnd-kit/core | a11y for free |
| Drag ghost/overlay | CSS clone positioning | `DragOverlay` from @dnd-kit/core | Correct portal rendering, no z-index fights |
| Optimistic UI update | Complex local state with rollback | `useState` + `useTransition` (React pattern) | Already used in KanbanBoard.tsx and LeadTable.tsx |
| Status validation | Re-implementing LEAD_STAGES check | `updateLeadField` server action already validates status | DRY — the action throws on invalid stage |
**Key insight:** The entire drag-drop + persist pattern is already implemented and tested in `KanbanBoard.tsx`. This phase is a structural copy with domain adaptation, not a new implementation.
---
## Common Pitfalls
### Pitfall 1: Columns wider than viewport on 6-stage board
**What goes wrong:** 6 columns in `grid-cols-6` become too narrow on typical laptop screens (12801440px). The 3-column project kanban uses `grid-cols-3` with comfortable card width.
**Why it happens:** 6 × min-width ≈ 720px+ is tight.
**How to avoid:** Use `grid-cols-3 lg:grid-cols-6` or a horizontally scrollable container (`overflow-x-auto` on the grid wrapper). Alternatively, `min-w-[200px]` per column inside a scroll container.
**Warning signs:** Cards truncate before the lead name is visible.
### Pitfall 2: Won/Lost columns need visual distinction
**What goes wrong:** Dropping to "won" or "lost" looks identical to other columns — user may not notice the semantic weight of these terminal states.
**Why it happens:** Uniform column styling.
**How to avoid:** Use visually distinct `headerClass` (green for won, red for lost) and consider a stronger `isOver` highlight for these columns. The STAGE_COLOR map in `LeadTable.tsx` already defines these colors — reuse them.
### Pitfall 3: Leads not sorted consistently between views
**What goes wrong:** Table shows leads ordered by `updated_at DESC`; kanban derived from the same array shows different visual order depending on column grouping.
**Why it happens:** No explicit sort on the kanban card order within a column.
**How to avoid:** Sort leads within each column by `updated_at DESC` (same as the existing query order). The `getLeadsWithTags` query already returns `orderBy(desc(leads.updated_at))` so inheriting that order is sufficient.
### Pitfall 4: `router.refresh()` causes full re-mount of kanban
**What goes wrong:** After a drag-drop, `router.refresh()` rehydrates the server component, re-running `getLeadsWithTags()`. If the drag animation hasn't completed, it can cause a visual flicker.
**Why it happens:** Next.js App Router refresh re-renders the whole tree.
**How to avoid:** The existing `KanbanBoard.tsx` uses the same pattern without issue. The `setActiveId(null)` call in `handleDragEnd` clears the overlay before the refresh arrives, so the flicker is acceptable. This is the project's established pattern — do not deviate.
### Pitfall 5: Search/filter not available in kanban view
**What goes wrong:** The search bar lives in `LeadsSearch.tsx` and only filters `LeadTable`. If the user switches to kanban, they lose the ability to filter.
**Why it happens:** The view toggle renders either `LeadsSearch` (with its internal state) or the bare `LeadsKanbanBoard`.
**How to avoid:** Two acceptable approaches: (a) wrap both views together inside `LeadsSearch` and pass filtered leads to both (preferred — search state persists across view switches); or (b) accept that kanban shows all leads unfiltered (simpler, acceptable for now given the single-user context). Document the choice in the plan.
---
## Code Examples
### Existing updateLeadField signature (server action)
```typescript
// Source: VERIFIED from src/app/admin/leads/actions.ts line 174
// EDITABLE_FIELDS includes "status" — drag-drop can call this directly
export async function updateLeadField(
leadId: string,
fieldName: "name" | "email" | "phone" | "company" | "status" | "next_action",
value: string
): Promise<void>
// Validates: status must be in LEAD_STAGES; throws on invalid value
// Side effects: revalidatePath("/admin/leads") + revalidatePath(`/admin/leads/${leadId}`)
```
### LEAD_STAGES canonical values
```typescript
// Source: VERIFIED from src/lib/lead-validators.ts line 4
export const LEAD_STAGES = [
"contacted",
"qualified",
"proposal_sent",
"negotiating",
"won",
"lost",
] as const;
```
### Existing STAGE_COLOR map (reuse for kanban column headers)
```typescript
// Source: VERIFIED from src/components/admin/leads/LeadTable.tsx line 20
const STAGE_COLOR: Record<string, string> = {
contacted: "bg-blue-100 text-blue-800",
qualified: "bg-purple-100 text-purple-800",
proposal_sent: "bg-amber-100 text-amber-800",
negotiating: "bg-orange-100 text-orange-800",
won: "bg-green-100 text-green-800",
lost: "bg-red-100 text-red-800",
};
// Move to a shared constant (e.g., src/lib/lead-constants.ts) if reused in both components
```
### PhasesViewToggle pattern (exact analog)
```typescript
// Source: VERIFIED from src/components/admin/kanban/PhasesViewToggle.tsx
// State: useState<"list" | "kanban">("list")
// Toggle: pill button group (bg-[#f4f4f5] rounded-lg p-1 w-fit)
// Active: bg-white text-[#1A463C] shadow-sm
// Inactive: text-[#71717a] hover:text-[#1a1a1a]
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Lead status change via modal form | Inline dropdown in table cell (`StatusCell`) | Phase 14 | Drag-drop is the third mechanism; all write to same `updateLeadField` action |
| Separate `/admin/analytics` route | Fused into `/admin` dashboard | Phase 18 | No impact on leads page |
| `SendQuoteModal` with dead branch | Dead branch removed | Phase 18 | No impact |
---
## Project Constraints (from CLAUDE.md)
| Directive | Impact on This Phase |
|-----------|---------------------|
| `clients.token` = rotatable, never PK | Not relevant (leads have no token) |
| `quote_items` never exposed via client API | Not relevant (Kanban is admin-only) |
| `deliverables.approved_at` immutable once set | Not relevant |
| Auth: `/admin/*` → Auth.js session | Kanban lives at `/admin/leads` — already protected |
| No file hosting v1 | Not relevant |
| Migration safety: never drop/truncate rows | Phase 19 is UI-only — no schema changes, no migration needed |
| Security: confirm before destructive commands | No destructive operations |
| No package installs without showing name+version | No new packages needed |
---
## Environment Availability
Step 2.6: SKIPPED — Phase 19 is a pure UI addition. All required libraries (`@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`) are already installed. No external services, databases (beyond the existing Neon Postgres connection), or CLI tools are needed.
---
## Validation Architecture
`nyquist_validation: false` in `.planning/config.json` — section omitted per config.
---
## Security Domain
Phase 19 adds a new interaction path to an existing admin-only route (`/admin/leads`). No new auth surface is introduced.
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes (existing) | Auth.js session via `requireAdmin()` in server action |
| V4 Access Control | yes (existing) | `requireAdmin()` guard in `updateLeadField` — drag-drop calls same action |
| V5 Input Validation | yes | `updateLeadField` validates status via `LEAD_STAGES.includes(value)` — no new validation needed |
No new threat surface beyond what Phase 14 already addressed. The drag-drop `handleDragEnd` validates the `over.id` is a known stage before calling the server action — follow the same guard pattern as in `KanbanBoard.tsx` (line 190: `if (!(["todo", "in_progress", "done"] as string[]).includes(newStatus)) return;`).
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | The `won` and `lost` columns are terminal states with no special side-effects beyond setting `leads.status` (no auto-creation of client/project, no email trigger) | Architecture Patterns | If the plan later requires auto-provisioning on "won", a new server action will be needed — but PIPE-01/02 say nothing about this, and PROP-04 (auto-provisioning) is deferred to backlog post-R5 |
| A2 | Card content (name, company, next_action) is sufficient for the kanban view; no additional fields are needed per card | Code Examples | If the user wants tags or email visible on cards, the `LeadWithTags` type already provides them — no data-layer change, only card template change |
| A3 | The search filter covering only the table view (not the kanban) is acceptable for v1 of this feature | Common Pitfalls | If the user wants search in kanban too, the fix is to lift filtered state into the toggle wrapper — straightforward but adds scope |
---
## Open Questions
1. **Search/filter scope in kanban view**
- What we know: `LeadsSearch` holds the search `useState` and passes `filtered` leads to `LeadTable`. The kanban would receive all leads from the page.
- What's unclear: Does the user want the search bar to filter the kanban board too, or is it acceptable that kanban shows all leads?
- Recommendation: Default to wrapping both views inside a new `LeadsViewToggle` that receives `leads` (unfiltered) and `options`, manages the view toggle, and passes `filtered` leads to both `LeadTable` and `LeadsKanbanBoard`. This is a clean pattern and handles it gracefully.
2. **Column layout: scroll vs. wrap on 6 columns**
- What we know: The existing kanban uses `grid-cols-3`. Six columns need more space.
- What's unclear: Target viewport is unknown (likely 1440px+ since this is a single-admin tool).
- Recommendation: Use `min-w-[180px]` per column inside an `overflow-x-auto` wrapper. This makes it work on any viewport without content truncation.
---
## Sources
### Primary (HIGH confidence)
- `src/components/admin/kanban/KanbanBoard.tsx` — exact @dnd-kit usage pattern, drag primitives, sensors, DragOverlay, optimistic update + router.refresh()
- `src/components/admin/kanban/PhasesViewToggle.tsx` — view toggle pattern (list/kanban state, pill button UI)
- `src/components/admin/leads/LeadTable.tsx` — STAGE_COLOR map, LeadWithTags usage, StatusCell inline dropdown
- `src/app/admin/leads/actions.ts``updateLeadField` signature, EDITABLE_FIELDS, `requireAdmin()` guard
- `src/lib/lead-validators.ts` — canonical LEAD_STAGES array (6 values)
- `src/lib/admin-queries.ts` lines 883942 — `LeadWithTags` type, `getLeadsWithTags()` query (all fields), `LeadFieldOptions`
- `src/db/schema.ts` lines 441462 — `leads` table definition, `status` column with all 6 stage values documented
- `src/app/admin/leads/LeadsSearch.tsx` — search filter pattern, `LeadWithTags` + `LeadFieldOptions` prop interface
- `src/app/admin/leads/page.tsx` — server component structure, data fetching pattern, `revalidate = 0`
- `src/components/admin/AdminSidebar.tsx``/admin/leads` is already in NAV_ITEMS, no sidebar change needed
- `package.json`@dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2
### Secondary (MEDIUM confidence)
- `.planning/config.json``nyquist_validation: false` confirmed
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all packages verified in package.json; exact primitives verified in KanbanBoard.tsx
- Architecture: HIGH — all patterns verified from existing codebase analogs
- Pitfalls: HIGH — derived from direct code inspection and known Next.js App Router behaviors
- Data layer: HIGH — schema, actions, and query functions all read directly
**Research date:** 2026-06-19
**Valid until:** Stable indefinitely (no external dependencies; codebase-derived findings)
@@ -0,0 +1,99 @@
---
phase: 19-pipeline-crm-kanban
verified: 2026-06-19T18:09:00+02:00
status: passed
score: 5/5
overrides_applied: 0
re_verification: false
---
# Phase 19: Pipeline CRM Kanban — Verification Report
**Phase Goal:** Aggiungere vista Kanban drag-drop alla pagina /admin/leads (PIPE-01 + PIPE-02), affiancata alla LeadTable esistente via toggle Lista/Kanban.
**Verified:** 2026-06-19T18:09:00+02:00
**Status:** passed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | I lead sono visibili in una board Kanban con 6 colonne per stage (contacted, qualified, proposal_sent, negotiating, won, lost) | VERIFIED | `LeadsKanbanBoard.tsx` lines 19-33: `LeadStage` type and `LEAD_COLUMNS` array define all 6 stages with labels Contattato/Qualificato/Offerta inviata/Trattativa/Vinto/Perso. `grid-cols-6` layout confirmed line 159. |
| 2 | Trascinare un lead da una colonna a un'altra aggiorna leads.status in modo persistente | VERIFIED | `handleDragEnd` (lines 132-150): optimistic `setLeadStatuses`, then `startTransition(async () => { await updateLeadField(leadId, "status", newStage); router.refresh(); })`. `updateLeadField` confirmed in `actions.ts` line 174 with `revalidatePath` side-effect. |
| 3 | Spostare un lead nella colonna Vinto o Perso registra l'esito cambiando il campo status | VERIFIED | `won` and `lost` are members of `LEAD_COLUMNS` (lines 31-32) and `VALID_STAGES` (line 35). `handleDragEnd` line 141: `if (!VALID_STAGES.includes(newStage)) return` — only valid stages accepted. Dropping on "Vinto"/"Perso" column calls `updateLeadField(leadId, "status", "won"/"lost")`. Server-side `actions.ts` line 190 validates against `LEAD_STAGES` before writing to DB. |
| 4 | La vista tabella inline-edit (LeadTable) resta disponibile e funzionante come vista alternativa | VERIFIED | `LeadsViewToggle.tsx` lines 70-71: `view === "list" ? <LeadTable leads={filtered} options={options} /> : <LeadsKanbanBoard leads={filtered} />`. `LeadTable` imported from existing component (line 6). `LeadsSearch.tsx` re-exports `LeadsViewToggle as LeadsSearch` — no existing import paths broken. |
| 5 | Il toggle Lista/Kanban è visibile sopra il contenuto e preserva lo stato della ricerca quando si cambia vista | VERIFIED | `LeadsViewToggle.tsx`: single `useState("")` for query (line 18) and single `useMemo` for `filtered` (lines 20-31) shared by both views. Toggling `view` state does not reset `query` — both `<LeadTable>` and `<LeadsKanbanBoard>` receive the same `filtered` array. Pill toggle rendered above content in `flex justify-between` bar (lines 35-67). |
**Score:** 5/5 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `src/components/admin/leads/LeadsKanbanBoard.tsx` | Board Kanban con DndContext, 6 DroppableColumn, DraggableLeadCard, DragOverlay, ottimistic update | VERIFIED | 184 lines. `"use client"`, `DndContext`, `DragOverlay`, `useDroppable`, `useDraggable`, `useTransition`, `useState<Record<string, LeadStage>>`, all 6 LEAD_COLUMNS, `overflow-x-auto`, `grid-cols-6`, `startTransition`. Exports `LeadsKanbanBoard`. |
| `src/components/admin/leads/LeadsViewToggle.tsx` | Client wrapper con useState<'list' \| 'kanban'> e pill toggle | VERIFIED | 77 lines. `"use client"`, `useState<"list" \| "kanban">("list")`, `useState("")`, `useMemo`, pill toggle with `bg-[#f4f4f5] rounded-lg p-1`. Exports `LeadsViewToggle`. |
| `src/app/admin/leads/LeadsSearch.tsx` | Search + toggle integrati; passa filtered leads sia a LeadTable che a LeadsKanbanBoard | VERIFIED | Re-exports `LeadsViewToggle as LeadsSearch` — search and filtering now live in `LeadsViewToggle`. Backward compatibility preserved. |
| `src/app/admin/leads/page.tsx` | Server component aggiornato che renderizza LeadsViewToggle | VERIFIED | 24 lines. Imports `LeadsViewToggle` from `@/components/admin/leads/LeadsViewToggle`. Renders `<LeadsViewToggle leads={leads} options={options} />`. No `LeadsSearch` import. |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `LeadsKanbanBoard.tsx``handleDragEnd` | `actions.ts``updateLeadField` | `startTransition(async () => { await updateLeadField(leadId, "status", newStage); router.refresh(); })` | WIRED | Line 147: `await updateLeadField(leadId, "status", newStage)` — exact match. `startTransition` at line 146. `router.refresh()` at line 148. |
| `LeadsViewToggle.tsx``filtered` | `LeadsKanbanBoard``leads` prop | `filtered` array passed as prop | WIRED | Line 73: `<LeadsKanbanBoard leads={filtered} />` — exact match. |
| `LeadsViewToggle` / view state | `LeadTable` + `LeadsKanbanBoard` | `useState<"list" \| "kanban">` | WIRED | Line 17: `useState<"list" \| "kanban">("list")`. Lines 70-73: conditional render branches both views from shared state. |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `LeadsKanbanBoard.tsx` | `leads: LeadWithTags[]` | `getLeadsWithTags()` in `page.tsx` | Yes — Drizzle ORM `db.select().from(leads).leftJoin(tags, ...)` (admin-queries.ts lines 893-917). Returns aggregated rows with tags array. | FLOWING |
| `LeadsViewToggle.tsx` | `filtered` (derived from `leads` prop) | Same `getLeadsWithTags()` → passed as prop from page.tsx | Yes — `useMemo` derives from server-fetched `leads` prop. | FLOWING |
### Behavioral Spot-Checks
Runnable entry points require a live dev server. Spot-checks performed via static analysis against the call chain instead.
| Behavior | Check | Result | Status |
|----------|-------|--------|--------|
| `handleDragEnd` persists status change | `updateLeadField(leadId, "status", newStage)` call confirmed + `requireAdmin()` guard in actions.ts | Call at line 147; auth guard at actions.ts line 179 | PASS |
| Won/Lost drag records outcome | `VALID_STAGES.includes(newStage)` guard + `won`/`lost` in LEAD_COLUMNS | Line 141 guard; lines 31-32 column defs | PASS |
| Search state preserved across view switch | Single `query` state, single `filtered` memo, shared by both render branches | Lines 18, 20-31, 70-73 in LeadsViewToggle.tsx | PASS |
| Commits exist in git | `git log 2c67e6f 607c257 34be934` | All 3 hashes present with correct descriptions | PASS |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|---------|
| PIPE-01 | 19-01-PLAN.md | I lead sono visualizzabili in una board Kanban stile Pipedrive con colonne per stage e drag-drop per cambiare stage | SATISFIED | `LeadsKanbanBoard.tsx`: 6 `DroppableColumn` components, `DndContext` with `onDragEnd`, optimistic state update, persistent write via `updateLeadField`. |
| PIPE-02 | 19-01-PLAN.md | Spostare un lead nelle colonne "Vinto"/"Perso" è il cambio-stato manuale dell'esito | SATISFIED | `won` and `lost` are standard columns in `LEAD_COLUMNS`. Dropping a card on either column triggers the same `updateLeadField` path — no modal, no separate confirmation. |
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `LeadsKanbanBoard.tsx` | 110 | `const [, startTransition] = useTransition()` — unused first element of destructure | Info | No functional impact; minor lint noise. |
No TODOs, FIXMEs, placeholder returns, or empty implementations found in any of the four modified files.
### Human Verification Required
A human checkpoint was completed and approved prior to this verification (the task plan included a blocking `checkpoint:human-verify` gate). The developer confirmed:
- Board renders 6 columns with leads in correct stages
- Drag-drop moves cards and persists after reload
- Won/Lost columns register outcomes correctly
- Lista/Kanban toggle works with search state preserved
No further human verification items are outstanding.
### Gaps Summary
No gaps. All 5 must-have truths are VERIFIED against the codebase. All 4 artifacts are substantive and wired. All 3 key links are confirmed. Data flows from a real Drizzle ORM query. Both PIPE-01 and PIPE-02 are satisfied. Build passes (0 TypeScript errors, confirmed in commit 34be934 and known context). Human checkpoint approved in-session.
---
_Verified: 2026-06-19T18:09:00+02:00_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,232 @@
---
phase: 20-knowledge-base-cliente
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/db/migrations/0009_client_transcripts.sql
autonomous: false
requirements:
- KB-01
must_haves:
truths:
- "Il file 0009_client_transcripts.sql esiste con DDL completo e idempotente"
- "La tabella client_transcripts è presente nel database di produzione"
- "Il codice schema-dipendente dei piani successivi non viene pushato prima che la migration sia applicata"
artifacts:
- path: "src/db/migrations/0009_client_transcripts.sql"
provides: "DDL CREATE TABLE IF NOT EXISTS client_transcripts con tutti i campi D-01/D-02"
contains: "CREATE TABLE IF NOT EXISTS client_transcripts"
key_links:
- from: "src/db/migrations/0009_client_transcripts.sql"
to: "database produzione"
via: "SSH tunnel + psql/docker exec"
pattern: "CREATE TABLE IF NOT EXISTS client_transcripts"
---
<objective>
Scrivere il file SQL della migration manuale per la tabella `client_transcripts` e applicarlo al database di produzione prima di pushare qualsiasi codice schema-dipendente.
Purpose: Il progetto ha `drizzle-kit generate` rotto (meta-snapshot fuori sync). Ogni schema change richiede SQL a mano applicato a prod via SSH PRIMA del codice dipendente — invariante bloccante LOCKED in CLAUDE.md.
Output: `src/db/migrations/0009_client_transcripts.sql` applicato a prod. Il checkpoint umano sblocca i piani 20-02 e 20-03.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/20-knowledge-base-cliente/20-CONTEXT.md
<interfaces>
<!-- Pattern migration a mano — da src/db/migrations/0005 e 0008 -->
<!-- Convenzioni: CREATE TABLE IF NOT EXISTS, tipi Postgres espliciti, FK con ON DELETE CASCADE -->
<!-- nanoid PK: text NOT NULL — il valore viene inserito dall'applicazione, non da DEFAULT -->
<!-- Struttura 0008: header commento, istruzioni additive, CREATE INDEX IF NOT EXISTS -->
Da 0005_phase_10_crm_leads_activities_reminders.sql:
```sql
CREATE TABLE IF NOT EXISTS "activities" (
"id" text PRIMARY KEY NOT NULL,
"lead_id" text NOT NULL,
...
FOREIGN KEY ("lead_id") REFERENCES "leads"("id") ON DELETE cascade
);
CREATE INDEX IF NOT EXISTS "activities_lead_id" ON "activities"("lead_id");
```
Da 0008_offer_tier_schema.sql (header):
```sql
-- Phase 12: Offer Editor — additive schema ...
-- All statements additive/idempotent. No drops/truncates (Data Safety LOCKED).
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Scrivere src/db/migrations/0009_client_transcripts.sql</name>
<files>src/db/migrations/0009_client_transcripts.sql</files>
<read_first>
- src/db/migrations/0008_offer_tier_schema.sql (pattern header + CREATE TABLE IF NOT EXISTS)
- src/db/migrations/0005_phase_10_crm_leads_activities_reminders.sql (pattern FK + index per tabelle CRM)
- .planning/phases/20-knowledge-base-cliente/20-CONTEXT.md (D-01 e D-02 — campi esatti)
</read_first>
<action>
Creare il file `src/db/migrations/0009_client_transcripts.sql` con il seguente contenuto esatto:
```sql
-- Phase 20: Knowledge Base Cliente — tabella transcript datati per lead/cliente
-- Additive only. No drops/truncates (Data Safety LOCKED).
-- Applicare a prod via SSH tunnel PRIMA di pushare il codice dipendente (D-03).
CREATE TABLE IF NOT EXISTS client_transcripts (
id text PRIMARY KEY NOT NULL,
lead_id text REFERENCES leads(id) ON DELETE CASCADE,
client_id text REFERENCES clients(id) ON DELETE CASCADE,
title text,
content text NOT NULL,
call_date date NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS client_transcripts_lead_id_idx
ON client_transcripts (lead_id);
CREATE INDEX IF NOT EXISTS client_transcripts_client_id_idx
ON client_transcripts (client_id);
CREATE INDEX IF NOT EXISTS client_transcripts_call_date_idx
ON client_transcripts (call_date DESC);
```
Note sui campi (da D-01/D-02):
- `id`: text PK NOT NULL — nanoid inserito dall'app, nessun DEFAULT SQL (pattern progetto)
- `lead_id`: nullable FK → leads(id) ON DELETE CASCADE (D-01)
- `client_id`: nullable FK → clients(id) ON DELETE CASCADE (D-01)
- `title`: text nullable (D-02) — titolo libero opzionale
- `content`: text NOT NULL (D-02) — testo grezzo illimitato
- `call_date`: date NOT NULL (D-02) — giorno della call, non timestamp
- `created_at`: timestamp with time zone NOT NULL DEFAULT now() (D-02)
Gli indici su lead_id, client_id e call_date ottimizzano le query per lead (Phase 20) e future query per client_id (Phase 21+).
</action>
<verify>
<automated>grep -c "CREATE TABLE IF NOT EXISTS client_transcripts" /Users/simonecavalli/Vault/IAMCAVALLI/src/db/migrations/0009_client_transcripts.sql</automated>
</verify>
<acceptance_criteria>
- File `src/db/migrations/0009_client_transcripts.sql` esiste
- Contiene `CREATE TABLE IF NOT EXISTS client_transcripts`
- Contiene `lead_id text REFERENCES leads(id) ON DELETE CASCADE`
- Contiene `client_id text REFERENCES clients(id) ON DELETE CASCADE`
- Contiene `content text NOT NULL`
- Contiene `call_date date NOT NULL`
- Contiene `created_at timestamp with time zone NOT NULL DEFAULT now()`
- Contiene 3 `CREATE INDEX IF NOT EXISTS` (lead_id_idx, client_id_idx, call_date_idx)
- Nessun DROP o TRUNCATE nel file
</acceptance_criteria>
<done>File SQL completo e idempotente scritto, pronto per applicazione a prod.</done>
</task>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 2: [BLOCKING] Applicare migration 0009 al database di produzione via SSH</name>
<read_first>
- src/db/migrations/0009_client_transcripts.sql (verificare il contenuto prima di applicare)
</read_first>
<action>
La migration va applicata a prod via SSH tunnel PRIMA di eseguire i piani 20-02 e 20-03. Il database di produzione è accessibile solo tramite tunnel SSH.
**Passi:**
1. Aprire un terminale locale e avviare il tunnel SSH:
```bash
ssh -L 54321:localhost:54321 root@178.104.27.55
```
Lasciare questo terminale aperto per tutta la durata.
2. In un altro terminale, applicare la migration con psql (il DATABASE_URL del progetto punta a 127.0.0.1:54321):
```bash
cd /Users/simonecavalli/Vault/IAMCAVALLI
psql "$(grep DATABASE_URL .env.local | cut -d= -f2-)" -f src/db/migrations/0009_client_transcripts.sql
```
Oppure, se il DATABASE_URL non funziona direttamente, applicare via docker exec sul server:
```bash
# Sul server (nella sessione SSH aperta al punto 1):
docker exec -i <nome_container_postgres> psql -U <db_user> -d <db_name> < /path/to/0009_client_transcripts.sql
```
(Il nome del container e le credenziali sono in `.env.local` o nei secret Coolify.)
3. Verificare che la tabella esista:
```bash
psql "$(grep DATABASE_URL .env.local | cut -d= -f2-)" -c "\d client_transcripts"
```
Deve mostrare le colonne: id, lead_id, client_id, title, content, call_date, created_at.
4. Chiudere il tunnel SSH solo dopo aver verificato il punto 3.
</action>
<what-built>Il file SQL 0009_client_transcripts.sql è stato scritto da Claude nel Task 1. Questo task richiede solo che tu applichi la migration a prod — nessun codice da scrivere.</what-built>
<how-to-verify>
Eseguire in locale (con tunnel attivo):
```bash
psql "$(grep DATABASE_URL .env.local | cut -d= -f2-)" -c "SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_name = 'client_transcripts' ORDER BY ordinal_position;"
```
Risultato atteso: 7 righe con le colonne id, lead_id, client_id, title, content, call_date, created_at.
</how-to-verify>
<resume-signal>Digita "migration applicata" dopo aver verificato che \d client_transcripts mostra le 7 colonne attese.</resume-signal>
<acceptance_criteria>
- La tabella `client_transcripts` esiste nel database di produzione
- `\d client_transcripts` mostra 7 colonne: id (text), lead_id (text, nullable), client_id (text, nullable), title (text, nullable), content (text, NOT NULL), call_date (date, NOT NULL), created_at (timestamptz, NOT NULL)
- 3 indici presenti: client_transcripts_lead_id_idx, client_transcripts_client_id_idx, client_transcripts_call_date_idx
</acceptance_criteria>
<done>Database di produzione aggiornato con la tabella client_transcripts. I piani 20-02 e 20-03 sono sbloccati.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Descrizione |
|----------|-------------|
| developer → database prod | SQL applicato manualmente via SSH tunnel autenticato |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-20-01 | Tampering | 0009_client_transcripts.sql | mitigate | Revisione manuale del contenuto prima dell'applicazione (Task 2 step 1); file in version control |
| T-20-02 | Repudiation | Applicazione migration prod | accept | L'operazione è tracciata nel git commit di questo piano; nessun audit log applicativo necessario per DDL |
| T-20-03 | Denial of Service | DROP/TRUNCATE accidentale | mitigate | Il file usa solo CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS; nessuna istruzione distruttiva presente |
</threat_model>
<verification>
```bash
# Verificare che il file SQL esista e contenga i campi obbligatori
grep -E "CREATE TABLE IF NOT EXISTS client_transcripts|content.*text NOT NULL|call_date.*date NOT NULL|lead_id.*REFERENCES leads|client_id.*REFERENCES clients" \
/Users/simonecavalli/Vault/IAMCAVALLI/src/db/migrations/0009_client_transcripts.sql
# Verificare assenza di istruzioni distruttive
grep -i "drop\|truncate" /Users/simonecavalli/Vault/IAMCAVALLI/src/db/migrations/0009_client_transcripts.sql | wc -l
# Atteso: 0
```
</verification>
<success_criteria>
- `src/db/migrations/0009_client_transcripts.sql` esiste con DDL completo e idempotente (KB-01)
- La tabella `client_transcripts` è presente nel database di produzione con le 7 colonne di D-02
- Nessuna istruzione distruttiva nel file SQL
- Il checkpoint umano è stato completato e segnalato con "migration applicata"
</success_criteria>
<output>
Dopo il completamento del Task 2, creare `.planning/phases/20-knowledge-base-cliente/20-01-SUMMARY.md` con:
- Migration file creato: `src/db/migrations/0009_client_transcripts.sql`
- Migration applicata a prod: sì/no
- Colonne verificate: lista delle 7 colonne confermate
</output>
@@ -0,0 +1,42 @@
---
phase: 20-knowledge-base-cliente
plan: "01"
status: complete
completed_at: "2026-06-20"
---
# Plan 20-01 Summary: Migration client_transcripts
## What Was Built
Migration file `src/db/migrations/0009_client_transcripts.sql` scritto e applicato a produzione.
## Key Files
### Created
- `src/db/migrations/0009_client_transcripts.sql` — DDL completo e idempotente
## Migration Applied to Production
- **Metodo:** SSH tunnel (127.0.0.1:54321) + Node.js postgres client
- **Risultato:** ✓ applicata senza errori
## Colonne Verificate (7/7)
| column_name | data_type | nullable |
|-------------|--------------------------|----------|
| id | text | NO |
| lead_id | text | YES |
| client_id | text | YES |
| title | text | YES |
| content | text | NO |
| call_date | date | NO |
| created_at | timestamp with time zone | NO |
## Self-Check: PASSED
- ✓ File SQL esiste con DDL completo e idempotente
- ✓ Tabella `client_transcripts` presente nel database di produzione
- ✓ 7 colonne verificate (D-01/D-02)
- ✓ Nessun DROP/TRUNCATE nel file
- ✓ 3 indici creati (lead_id_idx, client_id_idx, call_date_idx)
@@ -0,0 +1,401 @@
---
phase: 20-knowledge-base-cliente
plan: 02
type: execute
wave: 2
depends_on:
- 20-01
files_modified:
- src/db/schema.ts
- src/lib/lead-service.ts
- src/app/admin/leads/actions.ts
autonomous: true
requirements:
- KB-01
- KB-02
must_haves:
truths:
- "La tabella clientTranscripts è definita in schema.ts con le relazioni Drizzle corrette"
- "getTranscripts(leadId) restituisce i transcript in ordine call_date DESC"
- "addTranscript e deleteTranscript sono server actions con requireAdmin guard"
artifacts:
- path: "src/db/schema.ts"
provides: "Definizione tabella clientTranscripts + relazioni Drizzle per leads e clients"
contains: "export const clientTranscripts = pgTable"
- path: "src/lib/lead-service.ts"
provides: "Query getTranscripts(leadId) — tutti i campi incluso content completo"
exports: ["getTranscripts"]
- path: "src/app/admin/leads/actions.ts"
provides: "Server actions addTranscript e deleteTranscript con requireAdmin"
exports: ["addTranscript", "deleteTranscript"]
key_links:
- from: "src/app/admin/leads/actions.ts"
to: "src/db/schema.ts"
via: "import clientTranscripts"
pattern: "clientTranscripts"
- from: "src/lib/lead-service.ts"
to: "src/db/schema.ts"
via: "import clientTranscripts"
pattern: "getTranscripts"
---
<objective>
Aggiungere la definizione Drizzle della tabella `clientTranscripts` in schema.ts, la query `getTranscripts` in lead-service.ts e le server actions `addTranscript` / `deleteTranscript` in actions.ts.
Purpose: Fornisce il layer dati completo per la UI del piano 20-03. Phase 21 (AI) leggerà i transcript via `getTranscripts(leadId)` — la firma deve restituire tutti i campi incluso `content` completo.
Output: Schema Drizzle + query + actions pronte, build TypeScript senza errori.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/20-knowledge-base-cliente/20-CONTEXT.md
@.planning/phases/20-knowledge-base-cliente/20-01-SUMMARY.md
<interfaces>
<!-- Pattern esistenti estratti da src/db/schema.ts (linee 464-643) -->
Pattern tabella CRM con FK lead_id (activities, linee 464-481):
```typescript
export const activities = pgTable("activities", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
lead_id: text("lead_id").notNull().references(() => leads.id, { onDelete: "cascade" }),
type: text("type").notNull(),
notes: text("notes").notNull(),
activity_date: timestamp("activity_date", { withTimezone: true }).notNull(),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
```
Pattern relazioni (leadsRelations, linea 631-635):
```typescript
export const leadsRelations = relations(leads, ({ many }) => ({
quotes: many(quotes),
activities: many(activities),
reminders: many(reminders),
}));
export const activitiesRelations = relations(activities, ({ one }) => ({
lead: one(leads, { fields: [activities.lead_id], references: [leads.id] }),
}));
```
Pattern TypeScript types (fine file):
```typescript
export type Activity = typeof activities.$inferSelect;
export type NewActivity = typeof activities.$inferInsert;
```
Pattern query (lead-service.ts linee 51-57):
```typescript
export async function getActivityLog(leadId: string) {
return await db
.select()
.from(activities)
.where(eq(activities.lead_id, leadId))
.orderBy(desc(activities.activity_date));
}
```
Pattern requireAdmin + server action (actions.ts linee 164-205):
```typescript
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
export async function updateLeadField(leadId: string, ...) {
await requireAdmin();
// ... validazione ...
await db.update(leads).set({...}).where(eq(leads.id, leadId));
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${leadId}`);
}
```
Import correnti in actions.ts (linee 1-16):
```typescript
"use server";
import { z } from "zod";
import { db } from "@/db";
import { leads, quotes, tags } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
```
Import correnti in lead-service.ts (linee 1-4):
```typescript
import { eq, and, isNull, desc, gte, lte, ilike, or } from "drizzle-orm";
import { db } from "@/db";
import { leads, activities, reminders, quotes } from "@/db/schema";
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Aggiungere clientTranscripts a src/db/schema.ts</name>
<files>src/db/schema.ts</files>
<read_first>
- src/db/schema.ts (leggere tutto il file per capire dove inserire la nuova tabella e come aggiornare le relations esistenti)
- .planning/phases/20-knowledge-base-cliente/20-CONTEXT.md (D-01, D-02 — campi esatti)
</read_first>
<action>
Aggiungere in `src/db/schema.ts` tre blocchi, rispettando la struttura esistente del file:
**1. Definizione tabella** — aggiungere dopo `// ============ REMINDERS TABLE` (dopo la chiusura della definizione reminders a linea ~499) e prima di `// ============ RELATIONS`:
```typescript
// ============ CLIENT TRANSCRIPTS TABLE (Knowledge Base — Phase 20) ============
// Transcript datati delle call, multipli per lead o cliente.
// lead_id e client_id sono entrambi nullable (D-01): un transcript può appartenere
// a un lead pre-conversione (Phase 20 UI) o a un cliente post-conversione (futuro).
// onDelete cascade su entrambe le FK — se il lead/cliente viene eliminato, i transcript vengono eliminati.
export const clientTranscripts = pgTable("client_transcripts", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
lead_id: text("lead_id")
.references(() => leads.id, { onDelete: "cascade" }),
client_id: text("client_id")
.references(() => clients.id, { onDelete: "cascade" }),
title: text("title"), // opzionale — es. "Discovery call 12 giugno"
content: text("content").notNull(), // testo grezzo illimitato (PostgreSQL text)
call_date: text("call_date").notNull(), // DATE come text "YYYY-MM-DD" — coerente col pattern date nel progetto
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
```
Nota su `call_date`: usare `text` invece di un tipo `date` Drizzle — il progetto gestisce le date come string ISO nei form e nel DB (vedi `activity_date` che è timestamp, ma `call_date` è date pura). Se Drizzle pg-core espone `date` nativo, usarlo; altrimenti usare `text("call_date").notNull()` per coerenza con i pattern di input da `<input type="date">` che restituisce stringhe "YYYY-MM-DD".
**2. Relazioni** — aggiungere nei blocchi relations esistenti:
Aggiornare `leadsRelations` (attuale linea ~631):
```typescript
export const leadsRelations = relations(leads, ({ many }) => ({
quotes: many(quotes),
activities: many(activities),
reminders: many(reminders),
transcripts: many(clientTranscripts), // ← aggiungere questa riga
}));
```
Aggiornare `clientsRelations` (attuale linea ~503):
```typescript
export const clientsRelations = relations(clients, ({ many }) => ({
projects: many(projects),
transcripts: many(clientTranscripts), // ← aggiungere questa riga
}));
```
Aggiungere le nuove relations dopo `remindersRelations`:
```typescript
export const clientTranscriptsRelations = relations(clientTranscripts, ({ one }) => ({
lead: one(leads, {
fields: [clientTranscripts.lead_id],
references: [leads.id],
}),
client: one(clients, {
fields: [clientTranscripts.client_id],
references: [clients.id],
}),
}));
```
**3. TypeScript types** — aggiungere in fondo al file dopo `export type Reminder`:
```typescript
export type ClientTranscript = typeof clientTranscripts.$inferSelect;
export type NewClientTranscript = typeof clientTranscripts.$inferInsert;
```
</action>
<verify>
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- `grep -c "export const clientTranscripts = pgTable" src/db/schema.ts` restituisce 1
- `grep -c "transcripts: many(clientTranscripts)" src/db/schema.ts` restituisce 2 (leadsRelations + clientsRelations)
- `grep -c "export const clientTranscriptsRelations" src/db/schema.ts` restituisce 1
- `grep -c "export type ClientTranscript" src/db/schema.ts` restituisce 1
- `npx tsc --noEmit` passa senza errori TypeScript relativi a schema.ts
</acceptance_criteria>
<done>clientTranscripts definita in schema.ts con relazioni Drizzle bidirezionali e TypeScript types esportati.</done>
</task>
<task type="auto">
<name>Task 2: Aggiungere getTranscripts a lead-service.ts e addTranscript/deleteTranscript ad actions.ts</name>
<files>src/lib/lead-service.ts, src/app/admin/leads/actions.ts</files>
<read_first>
- src/lib/lead-service.ts (leggere per vedere gli import correnti e dove appendere getTranscripts)
- src/app/admin/leads/actions.ts (leggere per vedere gli import correnti e la posizione di requireAdmin)
- src/db/schema.ts (dopo il Task 1 — per verificare il nome esatto dell'export clientTranscripts)
</read_first>
<action>
**In src/lib/lead-service.ts:**
Aggiungere `clientTranscripts` agli import esistenti:
```typescript
import { leads, activities, reminders, quotes, clientTranscripts } from "@/db/schema";
```
Appendere in fondo al file (dopo `getQuotesByLeadId`):
```typescript
// Get transcript log for a lead — call_date DESC (D-05)
// Restituisce tutti i campi incluso content completo (Phase 21 li legge in blocco).
export async function getTranscripts(leadId: string) {
return await db
.select()
.from(clientTranscripts)
.where(eq(clientTranscripts.lead_id, leadId))
.orderBy(desc(clientTranscripts.call_date));
}
```
---
**In src/app/admin/leads/actions.ts:**
Aggiungere `clientTranscripts` agli import dal DB schema:
```typescript
import { leads, quotes, tags, clientTranscripts } from "@/db/schema";
```
Aggiungere `nanoid` agli import (serve per generare l'id del transcript):
```typescript
import { nanoid } from "nanoid";
```
Definire lo schema Zod per il transcript e appendere le due actions dopo le tag actions esistenti (dopo `renameLeadTag`):
```typescript
// ── Transcript actions (Phase 20 — KB-02) ────────────────────────────────────
const addTranscriptSchema = z.object({
lead_id: z.string().min(1),
title: z.string().optional(),
content: z.string().min(1, "Il testo del transcript è obbligatorio"),
call_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data non valida (YYYY-MM-DD)"),
});
export async function addTranscript(data: z.infer<typeof addTranscriptSchema>) {
await requireAdmin();
const parsed = addTranscriptSchema.safeParse(data);
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0].message };
}
try {
const [transcript] = await db
.insert(clientTranscripts)
.values({
id: nanoid(),
lead_id: parsed.data.lead_id,
title: parsed.data.title || null,
content: parsed.data.content,
call_date: parsed.data.call_date,
})
.returning();
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
return { success: true, transcript };
} catch (error) {
console.error("addTranscript error:", error);
return { success: false, error: "Errore nel salvataggio del transcript" };
}
}
export async function deleteTranscript(transcriptId: string, leadId: string) {
await requireAdmin();
if (!transcriptId) throw new Error("ID transcript richiesto");
try {
await db
.delete(clientTranscripts)
.where(eq(clientTranscripts.id, transcriptId));
revalidatePath(`/admin/leads/${leadId}`);
return { success: true };
} catch (error) {
console.error("deleteTranscript error:", error);
return { success: false, error: "Errore nell'eliminazione del transcript" };
}
}
```
</action>
<verify>
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | grep -E "lead-service|actions" | head -10</automated>
</verify>
<acceptance_criteria>
- `grep -c "export async function getTranscripts" src/lib/lead-service.ts` restituisce 1
- `grep -c "orderBy(desc(clientTranscripts.call_date))" src/lib/lead-service.ts` restituisce 1
- `grep -c "export async function addTranscript" src/app/admin/leads/actions.ts` restituisce 1
- `grep -c "export async function deleteTranscript" src/app/admin/leads/actions.ts` restituisce 1
- `grep -c "await requireAdmin()" src/app/admin/leads/actions.ts` è >= 3 (addTranscript e deleteTranscript devono averlo, più le actions esistenti)
- `grep -c "revalidatePath" src/app/admin/leads/actions.ts` aumenta di 2 rispetto al file originale (una per addTranscript, una per deleteTranscript)
- `npx tsc --noEmit` passa senza errori su lead-service.ts e actions.ts
</acceptance_criteria>
<done>getTranscripts esportata da lead-service.ts, addTranscript e deleteTranscript aggiunte ad actions.ts con requireAdmin guard e revalidatePath.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Descrizione |
|----------|-------------|
| browser → server action | addTranscript e deleteTranscript sono "use server" — input non trusted |
| server action → database | Query Drizzle con parametri typed |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-20-04 | Elevation of Privilege | addTranscript / deleteTranscript | mitigate | `await requireAdmin()` come prima istruzione in entrambe le actions — lancia Error se sessione assente, blocca esecuzione |
| T-20-05 | Tampering | addTranscript — content | accept | Nessuna sanitization HTML richiesta (testo grezzo, non renderizzato come HTML — CONTEXT.md security note); Zod valida presenza e tipo |
| T-20-06 | Tampering | deleteTranscript — transcriptId | mitigate | L'ID viene passato dalla UI admin-only (protetta da requireAdmin); Drizzle usa query parametrizzata — no SQL injection |
| T-20-07 | Information Disclosure | getTranscripts | mitigate | Funzione usata solo in server components admin (`/admin/leads/[id]/page.tsx`); non esposta su API client (D-04 — client_id FK presente ma non UI client in Phase 20) |
</threat_model>
<verification>
```bash
cd /Users/simonecavalli/Vault/IAMCAVALLI
# Schema: tabella e relazioni presenti
grep -E "clientTranscripts|ClientTranscript" src/db/schema.ts | grep -v "^//"
# lead-service: query con ordinamento corretto
grep -A 6 "getTranscripts" src/lib/lead-service.ts
# actions: requireAdmin su entrambe le nuove actions
grep -B 1 "requireAdmin" src/app/admin/leads/actions.ts
# Build TypeScript
npx tsc --noEmit 2>&1 | tail -5
```
</verification>
<success_criteria>
- schema.ts contiene `clientTranscripts` table, relazioni bidirezionali (leads + clients), e TypeScript types (KB-01)
- lead-service.ts esporta `getTranscripts(leadId)` che ordina per `call_date DESC` (KB-02, D-05)
- actions.ts ha `addTranscript` e `deleteTranscript` entrambe con `requireAdmin()` guard (KB-02)
- `npx tsc --noEmit` passa senza errori sui file modificati
</success_criteria>
<output>
Dopo il completamento, creare `.planning/phases/20-knowledge-base-cliente/20-02-SUMMARY.md` con:
- Tabella Drizzle aggiunta: clientTranscripts con 7 campi
- Query aggiunta: getTranscripts(leadId) in lead-service.ts
- Actions aggiunte: addTranscript, deleteTranscript in actions.ts
- TypeScript check: passed/failed
</output>
@@ -0,0 +1,30 @@
---
phase: 20-knowledge-base-cliente
plan: "02"
status: complete
completed_at: "2026-06-20"
---
# Plan 20-02 Summary: Data Layer client_transcripts
## What Was Built
Drizzle schema, query layer e server actions per i transcript delle call.
## Key Files Modified
- `src/db/schema.ts` — tabella `clientTranscripts` + relazioni bidirezionali + TypeScript types
- `src/lib/lead-service.ts``getTranscripts(leadId)` con orderBy call_date DESC
- `src/app/admin/leads/actions.ts``addTranscript` + `deleteTranscript` con requireAdmin
## Self-Check: PASSED
-`export const clientTranscripts = pgTable("client_transcripts", ...)` in schema.ts
-`leadsRelations` aggiornato con `transcripts: many(clientTranscripts)`
-`clientsRelations` aggiornato con `transcripts: many(clientTranscripts)`
-`clientTranscriptsRelations` con one(leads) + one(clients)
-`export type ClientTranscript` e `NewClientTranscript` esportati
-`getTranscripts(leadId)` ordinato per `call_date DESC`
-`addTranscript` con requireAdmin + Zod + revalidatePath
-`deleteTranscript` con requireAdmin + revalidatePath
-`npx tsc --noEmit` → no errors
@@ -0,0 +1,626 @@
---
phase: 20-knowledge-base-cliente
plan: 03
type: execute
wave: 3
depends_on:
- 20-01
- 20-02
files_modified:
- src/components/admin/leads/TranscriptModal.tsx
- src/components/admin/leads/LeadDetail.tsx
- src/app/admin/leads/[id]/page.tsx
autonomous: true
requirements:
- KB-02
must_haves:
truths:
- "L'admin può aggiungere un transcript dalla pagina dettaglio lead via modal"
- "I transcript sono elencati in call_date DESC con titolo, data formattata e anteprima del testo"
- "Il testo completo di ogni transcript è espandibile/collassabile"
- "L'admin può eliminare un transcript con un bottone 'Elimina'"
- "La sezione Transcript appare dopo lo Storico Attività nel LeadDetail"
artifacts:
- path: "src/components/admin/leads/TranscriptModal.tsx"
provides: "Modal form per aggiungere transcript (call_date, title opzionale, content textarea)"
exports: ["TranscriptModal"]
- path: "src/components/admin/leads/LeadDetail.tsx"
provides: "Sezione Transcript con lista collassabile e azione Elimina"
contains: "Transcript"
- path: "src/app/admin/leads/[id]/page.tsx"
provides: "getTranscripts(id) nel Promise.all + prop transcripts passato a LeadDetail"
contains: "getTranscripts"
key_links:
- from: "src/components/admin/leads/TranscriptModal.tsx"
to: "src/app/admin/leads/actions.ts"
via: "import addTranscript"
pattern: "addTranscript"
- from: "src/components/admin/leads/LeadDetail.tsx"
to: "src/app/admin/leads/actions.ts"
via: "import deleteTranscript"
pattern: "deleteTranscript"
- from: "src/app/admin/leads/[id]/page.tsx"
to: "src/lib/lead-service.ts"
via: "import getTranscripts"
pattern: "getTranscripts"
---
<objective>
Creare il componente `TranscriptModal` (form per aggiungere transcript) e integrare la sezione Transcript in `LeadDetail`, con wiring nella page server.
Purpose: Completa il loop KB-02 — l'admin può incollare transcript datati e vederli elencati in ordine cronologico nel profilo lead. I dati sono pronti per essere letti dall'agente AI in Phase 21.
Output: UI completa funzionante — modal di aggiunta + lista con expand/collapse + elimina.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/20-knowledge-base-cliente/20-CONTEXT.md
@.planning/phases/20-knowledge-base-cliente/20-02-SUMMARY.md
<interfaces>
<!-- Pattern estratti da file esistenti — l'executor NON deve rileggere questi file -->
Da LogActivityModal.tsx — pattern modal con react-hook-form + zod + shadcn Dialog:
```typescript
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// ... action import ...
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
export function LogActivityModal({ leadId }: { leadId: string }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const form = useForm<any>({
resolver: zodResolver(schema),
defaultValues: { lead_id: leadId, activity_date: new Date().toISOString().split("T")[0], ... },
});
async function onSubmit(data) {
setLoading(true);
try {
const result = await logActivity(data);
if (result.success) { setOpen(false); form.reset({...}); }
} finally { setLoading(false); }
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button variant="outline" size="sm">...</Button></DialogTrigger>
<DialogContent className="max-w-sm">
...
<Input type="date" {...field} value={field.value || ""} />
<Textarea className="resize-none h-24" {...field} value={field.value || ""} />
<Button type="submit" className="w-full" disabled={loading}>...</Button>
</DialogContent>
</Dialog>
);
}
```
Da LeadDetail.tsx — struttura esistente con Card shadcn + sezione attività (pattern da replicare per Transcript):
```typescript
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Lead, Activity, Reminder } from "@/db/schema"; // ← aggiungere ClientTranscript
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { format } from "date-fns";
import { it } from "date-fns/locale";
// Signature attuale LeadDetail:
export function LeadDetail({
lead, activities, reminders, tags, tagOptions
}: {
lead: Lead; activities: Activity[]; reminders: Reminder[];
tags: string[]; tagOptions: string[];
})
// Sezione Attività (pattern da replicare per Transcript):
<Card>
<CardHeader><CardTitle>Storico Attività</CardTitle></CardHeader>
<CardContent>
{activities.length > 0 ? (
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="border-l-4 border-blue-300 pl-4 pb-4">
...testo e date formattate con date-fns...
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm">Nessuna attività registrata</p>
)}
</CardContent>
</Card>
```
Da page.tsx — pattern Promise.all e passaggio props:
```typescript
import { getActivityLog, getUpcomingReminders } from "@/lib/lead-service";
// ← aggiungere: import { getTranscripts } from "@/lib/lead-service";
const activities = await getActivityLog(id);
const reminders = await getUpcomingReminders(id);
// ← aggiungere nel Promise.all oppure sequenzialmente dopo
return (
<LeadDetail
lead={lead}
activities={activities}
reminders={reminders}
tags={lead.tags}
tagOptions={options.tags}
// ← aggiungere: transcripts={transcripts}
/>
);
```
Tipo ClientTranscript (da schema.ts dopo Plan 02):
```typescript
export type ClientTranscript = typeof clientTranscripts.$inferSelect;
// Campi: id, lead_id, client_id, title, content, call_date, created_at
```
Formattazione date italiana con date-fns (già usato nel progetto):
```typescript
import { format } from "date-fns";
import { it } from "date-fns/locale";
// call_date è string "YYYY-MM-DD" — usare new Date(t.call_date + "T00:00:00") per evitare timezone shift
format(new Date(t.call_date + "T00:00:00"), "d MMMM yyyy", { locale: it })
// → "12 giugno 2026"
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Creare TranscriptModal.tsx e aggiornare page.tsx</name>
<files>src/components/admin/leads/TranscriptModal.tsx, src/app/admin/leads/[id]/page.tsx</files>
<read_first>
- src/components/admin/leads/LogActivityModal.tsx (pattern esatto del modal da replicare)
- src/app/admin/leads/[id]/page.tsx (vedere la struttura attuale per capire dove aggiungere getTranscripts)
</read_first>
<action>
**Creare src/components/admin/leads/TranscriptModal.tsx:**
```typescript
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { addTranscript } from "@/app/admin/leads/actions";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
const transcriptSchema = z.object({
lead_id: z.string().min(1),
title: z.string().optional(),
content: z.string().min(1, "Il testo del transcript è obbligatorio"),
call_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data non valida"),
});
type TranscriptInput = z.infer<typeof transcriptSchema>;
export function TranscriptModal({ leadId }: { leadId: string }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const form = useForm<TranscriptInput>({
resolver: zodResolver(transcriptSchema),
defaultValues: {
lead_id: leadId,
title: "",
content: "",
call_date: new Date().toISOString().split("T")[0],
},
});
async function onSubmit(data: TranscriptInput) {
setLoading(true);
setError(null);
try {
const result = await addTranscript(data);
if (result.success) {
setOpen(false);
form.reset({
lead_id: leadId,
title: "",
content: "",
call_date: new Date().toISOString().split("T")[0],
});
} else {
setError(result.error ?? "Errore nel salvataggio");
}
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
Aggiungi Transcript
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Aggiungi Transcript Call</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="call_date"
render={({ field }) => (
<FormItem>
<FormLabel>Data call</FormLabel>
<FormControl>
<Input type="date" {...field} value={field.value || ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Titolo (opzionale)</FormLabel>
<FormControl>
<Input
placeholder="es. Discovery call"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Transcript</FormLabel>
<FormControl>
<Textarea
placeholder="Incolla qui il testo del transcript..."
className="min-h-48 resize-y"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Salvataggio..." : "Salva Transcript"}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
```
---
**Aggiornare src/app/admin/leads/[id]/page.tsx:**
Aggiungere `getTranscripts` all'import da lead-service:
```typescript
import { getActivityLog, getUpcomingReminders, getTranscripts } from "@/lib/lead-service";
```
Aggiungere la fetch nel corpo del componente. La page attualmente fa due call sequenziali dopo il Promise.all. Aggiungere `getTranscripts(id)` in parallelo con le altre:
```typescript
const [leads, options, transcripts] = await Promise.all([
getLeadsWithTags(),
getLeadFieldOptions(),
getTranscripts(id),
]);
```
Oppure, se la struttura attuale non usa Promise.all per tutte le chiamate, aggiungere sequenzialmente dopo `reminders`:
```typescript
const transcripts = await getTranscripts(id);
```
Passare la prop al componente:
```typescript
return (
<LeadDetail
lead={lead}
activities={activities}
reminders={reminders}
tags={lead.tags}
tagOptions={options.tags}
transcripts={transcripts}
/>
);
```
</action>
<verify>
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | grep -E "TranscriptModal|page" | head -10</automated>
</verify>
<acceptance_criteria>
- File `src/components/admin/leads/TranscriptModal.tsx` esiste
- `grep -c "export function TranscriptModal" src/components/admin/leads/TranscriptModal.tsx` restituisce 1
- `grep -c "min-h-48" src/components/admin/leads/TranscriptModal.tsx` restituisce 1 (textarea generosa)
- `grep -c "Aggiungi Transcript" src/components/admin/leads/TranscriptModal.tsx` restituisce almeno 1
- `grep -c "getTranscripts" src/app/admin/leads/[id]/page.tsx` restituisce almeno 2 (import + chiamata)
- `grep -c "transcripts={transcripts}" src/app/admin/leads/[id]/page.tsx` restituisce 1
- `npx tsc --noEmit` passa senza errori su questi file
</acceptance_criteria>
<done>TranscriptModal creato, page.tsx aggiornata con getTranscripts e prop transcripts passata a LeadDetail.</done>
</task>
<task type="auto">
<name>Task 2: Aggiornare LeadDetail.tsx — aggiungere prop transcripts e sezione Transcript</name>
<files>src/components/admin/leads/LeadDetail.tsx</files>
<read_first>
- src/components/admin/leads/LeadDetail.tsx (leggere per vedere la struttura attuale — dove finisce la sezione Attività e dove inserire Transcript)
</read_first>
<action>
Aggiornare `src/components/admin/leads/LeadDetail.tsx` con le seguenti modifiche:
**1. Aggiungere import:**
```typescript
import { ClientTranscript } from "@/db/schema";
import { useTransition } from "react"; // già importato — verificare che ci sia
import { TranscriptModal } from "./TranscriptModal";
import { deleteTranscript } from "@/app/admin/leads/actions";
```
**2. Aggiungere `transcripts` alla prop interface:**
```typescript
export function LeadDetail({
lead,
activities,
reminders,
tags,
tagOptions,
transcripts, // ← aggiungere
}: {
lead: Lead;
activities: Activity[];
reminders: Reminder[];
tags: string[];
tagOptions: string[];
transcripts: ClientTranscript[]; // ← aggiungere
})
```
**3. Aggiungere state per expand/collapse e delete nel corpo del componente:**
Aggiungere dopo gli useState esistenti:
```typescript
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [deletingId, setDeletingId] = useState<string | null>(null);
const [, startDeleteTransition] = useTransition();
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function handleDelete(transcriptId: string) {
setDeletingId(transcriptId);
startDeleteTransition(async () => {
try {
await deleteTranscript(transcriptId, lead.id);
router.refresh();
} catch (e) {
console.error("deleteTranscript error:", e);
} finally {
setDeletingId(null);
}
});
}
```
**4. Aggiungere il pulsante TranscriptModal nell'header (accanto a LogActivityModal):**
```typescript
<div className="flex gap-2">
<LogActivityModal leadId={lead.id} />
<TranscriptModal leadId={lead.id} /> {/* ← aggiungere */}
<SendQuoteModal leadId={lead.id} />
<EditLeadModal lead={lead} />
</div>
```
**5. Aggiungere la sezione Transcript dopo la sezione Storico Attività (dopo la chiusura del `</Card>` dell'attività):**
```typescript
{/* Transcript */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Transcript Call</CardTitle>
<span className="text-sm text-gray-500">
{transcripts.length} {transcripts.length === 1 ? "transcript" : "transcript"}
</span>
</CardHeader>
<CardContent>
{transcripts.length > 0 ? (
<div className="space-y-4">
{transcripts.map((t) => {
const isExpanded = expandedIds.has(t.id);
// Formattare call_date come "12 giugno 2026"
// call_date è string "YYYY-MM-DD" — aggiungere T00:00:00 per evitare timezone shift
const formattedDate = format(
new Date(t.call_date + "T00:00:00"),
"d MMMM yyyy",
{ locale: it }
);
// Anteprima: prime 3 righe o 200 caratteri (il primo valore raggiunto)
const preview = t.content
.split("\n")
.slice(0, 3)
.join("\n")
.slice(0, 200);
const hasMore = t.content.length > preview.length || t.content.split("\n").length > 3;
return (
<div key={t.id} className="border-l-4 border-purple-300 pl-4 pb-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{formattedDate}</span>
{t.title && (
<span className="text-gray-600 text-sm"> {t.title}</span>
)}
</div>
<div className="mt-2 text-sm text-gray-700 whitespace-pre-wrap">
{isExpanded ? t.content : preview}
{!isExpanded && hasMore && (
<span className="text-gray-400">...</span>
)}
</div>
{hasMore && (
<button
onClick={() => toggleExpand(t.id)}
className="text-xs text-blue-600 hover:underline mt-1"
>
{isExpanded ? "Mostra meno" : "Mostra tutto"}
</button>
)}
</div>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700 shrink-0"
disabled={deletingId === t.id}
onClick={() => handleDelete(t.id)}
>
{deletingId === t.id ? "..." : "Elimina"}
</Button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm">Nessun transcript registrato</p>
)}
</CardContent>
</Card>
```
</action>
<verify>
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | grep "LeadDetail" | head -10</automated>
</verify>
<acceptance_criteria>
- `grep -c "transcripts: ClientTranscript\[\]" src/components/admin/leads/LeadDetail.tsx` restituisce 1
- `grep -c "TranscriptModal" src/components/admin/leads/LeadDetail.tsx` restituisce almeno 2 (import + uso nel JSX)
- `grep -c "deleteTranscript" src/components/admin/leads/LeadDetail.tsx` restituisce almeno 2 (import + uso in handleDelete)
- `grep -c "Transcript Call" src/components/admin/leads/LeadDetail.tsx` restituisce 1 (titolo Card sezione)
- `grep -c "Mostra tutto" src/components/admin/leads/LeadDetail.tsx` restituisce 1 (expand/collapse)
- `grep -c "T00:00:00" src/components/admin/leads/LeadDetail.tsx` restituisce 1 (fix timezone per call_date)
- `grep -c "border-l-4 border-purple-300" src/components/admin/leads/LeadDetail.tsx` restituisce 1 (stile sezione transcript, distinto dal blu delle attività)
- `npx tsc --noEmit` passa senza errori su LeadDetail.tsx
- `npm run build` completa senza errori (verificare alla fine)
</acceptance_criteria>
<done>LeadDetail aggiornato con prop transcripts, sezione Transcript con lista expand/collapse e azione Elimina posizionata dopo Storico Attività.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Descrizione |
|----------|-------------|
| browser → TranscriptModal form | Input utente admin non sanitizzato prima del submit |
| TranscriptModal → addTranscript server action | "use server" boundary — Zod valida il payload |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-20-08 | Tampering | TranscriptModal — content field | accept | Il content è testo grezzo (textarea), non renderizzato come HTML nella UI — nessun XSS possibile con `whitespace-pre-wrap` senza `dangerouslySetInnerHTML`; Zod valida min length 1 |
| T-20-09 | Elevation of Privilege | TranscriptModal — addTranscript call | mitigate | La route `/admin/leads/[id]` è protetta dal middleware Auth.js (sessione admin richiesta); `addTranscript` server action chiama `requireAdmin()` come prima istruzione |
| T-20-10 | Elevation of Privilege | deleteTranscript client call | mitigate | `deleteTranscript` server action chiama `requireAdmin()` come prima istruzione; il transcriptId viene dalla prop SSR, non da input utente libero |
| T-20-11 | Information Disclosure | Transcript content nel DOM | accept | La pagina è `/admin/*` — solo admin autenticato la vede; nessuna esposizione lato client pubblico |
</threat_model>
<verification>
```bash
cd /Users/simonecavalli/Vault/IAMCAVALLI
# Verificare che tutti i file esistano
ls -la src/components/admin/leads/TranscriptModal.tsx
grep -c "getTranscripts" src/app/admin/leads/\[id\]/page.tsx
grep -c "transcripts: ClientTranscript" src/components/admin/leads/LeadDetail.tsx
# Verificare struttura sezione Transcript
grep -n "Transcript\|deleteTranscript\|TranscriptModal" src/components/admin/leads/LeadDetail.tsx
# Build completo
npm run build 2>&1 | tail -15
```
</verification>
<success_criteria>
- `TranscriptModal.tsx` esiste con form: call_date (date input), title (optional text), content (textarea min-h-48) (KB-02)
- `LeadDetail.tsx` ha prop `transcripts: ClientTranscript[]` e sezione "Transcript Call" dopo "Storico Attività" (KB-02, D-05)
- Lista transcript mostra: data formattata in italiano, titolo se presente, anteprima testo con expand/collapse, bottone Elimina (KB-02)
- `page.tsx` chiama `getTranscripts(id)` e passa `transcripts` a `LeadDetail` (KB-02)
- `npm run build` completa senza errori TypeScript o di compilazione
</success_criteria>
<output>
Dopo il completamento, creare `.planning/phases/20-knowledge-base-cliente/20-03-SUMMARY.md` con:
- Componenti creati: TranscriptModal.tsx
- Componenti modificati: LeadDetail.tsx, page.tsx
- Features: modal aggiunta, lista con expand/collapse, eliminazione, data in italiano
- Build status: passed/failed
- Note su eventuali adattamenti ai pattern esistenti
</output>
@@ -0,0 +1,34 @@
---
phase: 20-knowledge-base-cliente
plan: "03"
status: complete
completed_at: "2026-06-20"
---
# Plan 20-03 Summary: UI Transcript Call
## What Was Built
UI completa per aggiungere, visualizzare ed eliminare i transcript delle call nel profilo lead.
## Key Files
### Created
- `src/components/admin/leads/TranscriptModal.tsx` — modal form (call_date + title + content textarea min-h-48)
### Modified
- `src/components/admin/leads/LeadDetail.tsx` — prop `transcripts: ClientTranscript[]`, sezione "Transcript Call" con expand/collapse e Elimina, TranscriptModal nel header
- `src/app/admin/leads/[id]/page.tsx``getTranscripts(id)` nel Promise.all, prop `transcripts` passato a LeadDetail
## Self-Check: PASSED
-`TranscriptModal.tsx` esiste con form call_date/title/content (min-h-48)
-`LeadDetail.tsx` ha prop `transcripts: ClientTranscript[]`
- ✓ Sezione "Transcript Call" appare dopo "Storico Attività"
- ✓ Data formattata in italiano con `new Date(t.call_date + "T00:00:00")` (fix TZ shift)
- ✓ Preview 3 righe/200 caratteri con toggle "Mostra tutto / Mostra meno"
- ✓ Bottone "Elimina" via `deleteTranscript` server action
-`border-l-4 border-purple-300` — stile distinto dal blu delle attività
-`getTranscripts(id)` nel Promise.all di page.tsx
-`npx tsc --noEmit` → no errors
-`npm run build` → clean
@@ -0,0 +1,111 @@
# Phase 20: Knowledge Base Cliente — Context
**Gathered:** 2026-06-19
**Status:** Ready for planning
<domain>
## Phase Boundary
Aggiungere uno store di transcript datati per lead: l'admin incolla il testo grezzo di ogni call con data e titolo libero, la lista appare in ordine cronologico nel dettaglio lead, e i dati sono pronti per essere letti dall'agente AI in Phase 21.
**In scope:** schema `client_transcripts` + server actions + UI nel LeadDetail
**Out of scope:** UI per clienti (client_id FK presente ma non esposta), ricerca full-text sui transcript, trascrizione automatica da audio
</domain>
<decisions>
## Implementation Decisions
### Schema — client_transcripts
- **D-01:** La tabella `client_transcripts` ha **entrambe** le FK nullable: `lead_id` (references leads, onDelete cascade) e `client_id` (references clients, onDelete cascade). Ragionamento: prepara la struttura per transcript post-conversione senza richiedere una migration futura.
- **D-02:** Campi: `id` (nanoid PK), `lead_id` (nullable FK), `client_id` (nullable FK), `title` (text, optional — titolo libero es. "Discovery call 12 giugno"), `content` (text NOT NULL — testo incollato, lunghezza illimitata), `call_date` (date NOT NULL — giorno della call, non timestamp), `created_at` (timestamp with timezone, defaultNow).
- **D-03:** Migration mano-scritta come 0009 — drizzle-kit generate è rotto. Applicata a prod via SSH **prima** di pushare il codice dipendente (invariante bloccante del progetto).
### UI — Solo lato lead in Phase 20
- **D-04:** In Phase 20 la UI espone solo il lato lead. La FK `client_id` è nello schema ma **non ha form o lista** in questa fase — si aggiunge in futuro se serve la pagina `/admin/clients/[id]`.
- **D-05:** I transcript sono listati in ordine `call_date DESC` nel dettaglio lead (chiamata più recente in cima).
### Claude's Discretion
Le seguenti aree non sono state discusse — Claude ha flessibilità:
- **Metadati:** No tipo enum — `title` libero (optional) è sufficiente. Il testo del transcript parla da solo.
- **Placement UI:** Nuova sezione "Transcript" collassabile in `LeadDetail`, dopo la sezione Attività esistente. Stessa convenzione visiva (Card + lista). Form/modal per aggiungere seguendo il pattern `LogActivityModal`.
- **Nessun limite di lunghezza:** `content` è `text` PostgreSQL (illimitato). Textarea nel form senza troncatura.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Requirements & Roadmap
- `.planning/ROADMAP.md` — Phase 20 goal, success criteria, schema spec (`client_transcripts` con `lead_id/client_id, testo, data, titolo/tipo, created_at`)
- `.planning/REQUIREMENTS.md` — KB-01 (schema additivo transcript), KB-02 (UI incolla/elenca)
### Schema & Migrations
- `src/db/schema.ts` — pattern tabelle append-only CRM: `activities` (lead_id, type, notes, activity_date), `reminders` (lead_id, due_date). La nuova `client_transcripts` segue questo pattern.
- `src/db/migrations/0008_offer_tier_schema.sql` — ultimo esempio di SQL migration a mano (struttura e convenzioni da seguire)
- `src/db/migrations/0005_phase_10_crm_leads_activities_reminders.sql` — migration originale di `activities` e `reminders` (pattern più vicino alla nuova tabella)
### UI & Components
- `src/components/admin/leads/LeadDetail.tsx` — struttura UI esistente del dettaglio lead (dove va inserita la sezione Transcript)
- `src/components/admin/leads/LogActivityModal.tsx` — pattern modal per aggiungere dati CRM (da seguire per il form transcript)
- `src/app/admin/leads/[id]/page.tsx` — pattern page con `Promise.all` per fetch parallele (aggiungere `getTranscripts(id)`)
### Query & Actions Layer
- `src/lib/lead-service.ts` — pattern query layer CRM (`getActivityLog`, `getUpcomingReminders` — aggiungere `getTranscripts`)
- `src/app/admin/leads/actions.ts` — pattern server actions con `requireAdmin` guard (da seguire per `addTranscript`, `deleteTranscript`)
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `LogActivityModal.tsx` — modal con form controllato (date picker + textarea + submit), pronto da clonare per il form transcript
- `activities` / `reminders` pattern in `schema.ts` — FK `lead_id` con `onDelete: cascade`, `nanoid()` PK, `created_at` defaultNow — copia esatta per `client_transcripts`
- `getActivityLog(leadId)` in `lead-service.ts` — query Drizzle con `where(eq(activities.lead_id, leadId))` e `orderBy(desc(activities.activity_date))` — pattern identico per `getTranscripts`
- `Card`, `CardContent`, `CardHeader`, `CardTitle` da shadcn/ui — già usati nel LeadDetail per ogni sezione
### Established Patterns
- **Migration a mano**: ogni schema change è SQL scritto a mano (`CREATE TABLE IF NOT EXISTS`, tipi Postgres espliciti, FK con `ON DELETE CASCADE`). NON usare `drizzle-kit generate`.
- **requireAdmin guard**: tutte le server actions in `actions.ts` iniziano con `await requireAdmin()` — obbligatorio anche per le nuove actions transcript.
- **`revalidatePath`** dopo ogni mutation: `revalidatePath(\`/admin/leads/${leadId}\`)`.
- **Promise.all fetch**: `page.tsx` del dettaglio lead usa `await Promise.all([...])` per fetch parallele — aggiungere `getTranscripts(id)` nello stesso array.
### Integration Points
- `src/app/admin/leads/[id]/page.tsx` — aggiungere `getTranscripts(id)` nel `Promise.all`, passare `transcripts` a `<LeadDetail />`
- `src/components/admin/leads/LeadDetail.tsx` — aggiungere prop `transcripts` e sezione "Transcript" dopo `<ActivitySection>`
- `src/db/schema.ts` — aggiungere definizione `client_transcripts` + relazioni Drizzle
- `src/lib/lead-service.ts` — aggiungere `getTranscripts(leadId: string)`
- `src/app/admin/leads/actions.ts` — aggiungere `addTranscript(leadId, data)` e `deleteTranscript(transcriptId)`
</code_context>
<specifics>
## Specific Ideas
- Il testo del transcript può essere molto lungo (trascrizioni complete di call). Il form deve avere una `<textarea>` con altezza generosa (es. min-h-48 o più) senza limite di caratteri.
- Il `call_date` è una `date` (non timestamp) — l'admin sceglie il giorno della call, non l'ora. Nel DB: `DATE` type PostgreSQL.
- La lista transcript deve mostrare: `call_date` formattata (es. "12 giugno 2026"), `title` se presente, anteprima delle prime righe del `content`, e un'azione "Elimina". Espansione/collapse del testo completo.
- Phase 21 leggerà i transcript via `getTranscripts(leadId)` — la query deve restituire tutti i campi incluso `content` completo.
</specifics>
<deferred>
## Deferred Ideas
- **UI transcript per clienti** (`/admin/clients/[id]`): la FK `client_id` è nella tabella, ma la UI lato cliente è deferred a una fase futura (post v2.2 o su richiesta).
- **Ricerca full-text sui transcript**: fuori scope v2.2 — i transcript vengono letti dall'AI in blocco, non cercati dall'admin.
- **Trascrizione automatica da audio**: fuori scope — l'admin incolla manualmente il testo.
</deferred>
---
*Phase: 20-Knowledge Base Cliente*
*Context gathered: 2026-06-19*
@@ -0,0 +1,50 @@
# Phase 20: Knowledge Base Cliente — Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-06-19
**Phase:** 20-knowledge-base-cliente
**Areas discussed:** Lead-scoped o anche client?
---
## Lead-scoped o anche client?
### Q1 — FK scope della tabella
| Option | Description | Selected |
|--------|-------------|----------|
| Solo lead per ora | `client_transcripts` ha solo `lead_id`. Semplice, sufficiente per Phase 21. La FK `client_id` si aggiunge in futuro se serve. | |
| Prepara entrambe le FK ora | `lead_id` e `client_id` entrambi nullable nella tabella. Più lavoro ora, ma non serve una migration futura se vuoi trascrizioni anche per clienti esistenti. | ✓ |
**User's choice:** Prepara entrambe le FK ora
**Notes:** L'utente preferisce prepararsi alla flessibilità futura con una sola migration ora.
---
### Q2 — UI Phase 20 per client_id
| Option | Description | Selected |
|--------|-------------|----------|
| Solo dal dettaglio lead per ora | La FK `client_id` c'è nello schema ma non è esposta in UI in Phase 20. Si usa quando un lead è convertito — per dopo. | ✓ |
| Anche dalla pagina admin cliente | Phase 20 aggiunge il blocco transcript sia nel LeadDetail che in `/admin/clients/[id]`. Più lavoro, ma copre subito clienti esistenti senza lead. | |
**User's choice:** Solo dal dettaglio lead per ora
**Notes:** L'UI Phase 20 è solo lato lead. `client_id` è nello schema ma non esposta.
---
## Claude's Discretion
Le seguenti aree non sono state discusse e sono state lasciate al giudizio di Claude:
- **Metadati del transcript** — Scelto: `title` (text, optional) + `content` (text, illimitato) + `call_date` (date). No enum tipo — il titolo libero è sufficiente.
- **Collocazione UI nel LeadDetail** — Scelto: nuova sezione "Transcript" collassabile dopo le Attività, seguendo il pattern visivo esistente (Card + lista). Modal per aggiungere (come `LogActivityModal`).
- **Ordinamento** — `call_date DESC` (chiamata più recente in cima).
## Deferred Ideas
- UI transcript nella pagina admin cliente (`/admin/clients/[id]`) — la FK è pronta, la UI è deferred post-v2.2.
- Ricerca full-text sui transcript — fuori scope, i transcript vengono letti dall'AI in blocco.
- Trascrizione automatica da audio — fuori scope v2.2.
@@ -0,0 +1,53 @@
---
phase: 21-agente-ai-generazione-preventivo
plan: "01"
status: complete
completed_at: "2026-06-20"
requirements_satisfied:
- AI-01
- AI-02
---
# Plan 21-01 Summary: Agente AI — Generazione Preventivo
## What Was Built
Sistema completo di generazione preventivo AI: l'admin seleziona un lead/cliente e un'offerta, il sistema legge i transcript della call e chiama Claude Opus 4.8 per generare una bozza di preventivo strutturata in JSON. La bozza viene salvata nel DB come `draft`, l'admin la rivede e la pubblica.
## Key Files
### Created
- `src/lib/proposal/schema.ts` — Zod schema `ProposalContent` (vision, problems 3-5, solutions, scope, deliverables, timeline, stagesRecap, comparisonMatrix)
- `src/lib/proposal/agent.ts``generateProposalContent()`: chiama Claude Opus 4.8, valida output Zod, gestisce JSON wrapping in backtick
- `src/lib/proposal/assemble.ts``assembleProposal()`: fonde content AI + offerta DB (snapshot prezzi) + profilo consulente in `AssembledProposal`
- `src/lib/proposal/profile.ts``CONSULTANT_PROFILE`: fonte di verità statica (bio, facts, testimonianze, legal, nextSteps) — editare direttamente per aggiornare
- `src/lib/proposal/queries.ts``listProposals`, `getProposalById`, `getProposalBySlug` con join leads/clients/offer_macros
- `src/app/admin/preventivi/actions.ts``generateProposalDraft`, `publishProposal`, `updateProposalTitle`, `deleteProposal` (server actions)
- `src/app/admin/preventivi/page.tsx` — lista preventivi admin con stato (bozza/pubblicato/accettato/rifiutato)
- `src/app/admin/preventivi/genera/page.tsx` + `GeneraProposalForm.tsx` — form builder con pre-fill `?lead_id=X` da LeadDetail
- `src/app/admin/preventivi/[id]/page.tsx` — review bozza: titolo editabile, pubblica, elimina
- `src/db/migrations/0010_proposals.sql``CREATE TABLE IF NOT EXISTS proposals` (id, slug, lead_id, client_id, offer_macro_id, title, content jsonb, model, state, selected_tier, accepted_at immutabile, client_email, client_notes)
### Modified
- `src/db/schema.ts` — tabella `proposals` + `proposalsRelations` + tipi `Proposal`/`NewProposal`
- `src/components/admin/AdminSidebar.tsx` — voce "Preventivi" + CTA globale lime "Genera preventivo"
- `src/components/admin/leads/LeadDetail.tsx` — pulsante "Genera preventivo" → `/admin/preventivi/genera?lead_id=X`
## Architecture Decisions
| Decisione | Scelta | Motivo |
|-----------|--------|--------|
| Output AI | JSON strutturato → Zod validate | Coerenza visiva garantita, zero rischio HTML rotto |
| Storage | `content jsonb` snapshot completo | Parser automatico postgres-js, type-safe, immutabile |
| Profile | File `profile.ts` hardcoded | No UI necessaria v1, editing diretto semplice |
| Modello | `claude-opus-4-8` | Qualità copywriting strategico superiore |
## Requirements Verified
- AI-01: ✅ Admin seleziona cliente+offerta → AI legge transcript + offerta → genera bozza personalizzata
- AI-02: ✅ Admin rivede ed edita il titolo della bozza, la pubblica o elimina prima dell'invio
## Post-Deploy Steps Completed
- Migration 0010 applicata a prod via SSH tunnel (2026-06-20 14:29)
- `ANTHROPIC_API_KEY` configurata in Coolify (2026-06-20 — fix chiave invalida)
@@ -0,0 +1,122 @@
# Phase 21: Agente AI — generazione preventivo - Context
**Gathered:** 2026-06-20
**Status:** Ready for planning
<domain>
## Phase Boundary
L'admin apre il dettaglio di un lead, seleziona un'offerta e lancia la generazione: Claude legge i transcript del lead e i dati dell'offerta (tutti e 3 i tier) e produce una bozza di preventivo strutturata in sezioni fisse. L'admin rivede il testo in una textarea e salva la bozza prima che diventi pubblicabile in Phase 22.
**In scope:** prompt engineering + chiamata API Claude + bozza strutturata in sezioni + form di revisione/editing + tabella `proposals` nel DB
**Out of scope:** pagina pubblica `/preventivo/[slug]` (Phase 22), invio email (Phase 22), accettazione/rifiuto cliente (Phase 22)
</domain>
<decisions>
## Implementation Decisions
### Struttura output AI
- **D-01:** Claude produce sezioni strutturate fisse, non testo libero. Il prompt specifica esattamente i blocchi da riempire.
- **D-02:** Le sezioni del preventivo generato sono, in ordine:
1. **Situazione Attuale** — Claude descrive la situazione del cliente basandosi sul contenuto dei transcript (cosa sta vivendo, i pain emersi nelle call)
2. **La Proposta** — Presentazione personalizzata dell'offerta: perché questa offerta è giusta per questo cliente specifico
3. **Opzione A / Opzione B / Opzione C** — Una sotto-sezione per ogni tier, con lista dei servizi inclusi e prezzo pubblico del tier
4. **Prossimi Passi** — Call to action finale (Claude scrive testo standard, admin può editare)
- **D-03:** L'admin **non sceglie un tier** prima della generazione. Claude genera tutti e 3 i tier in un unico documento. Il cliente leggerà le 3 opzioni e sceglierà.
### Claude's Discretion
Le seguenti aree non sono state discusse — Claude ha flessibilità:
- **Builder location:** Entry point nel LeadDetail esistente (`src/components/admin/leads/LeadDetail.tsx`) — bottone "Genera Preventivo" che apre un modal o sezione inline con selezione offerta + trigger generazione. È il posto più naturale perché i transcript del lead sono già in contesto.
- **UX generazione:** Fire-and-wait con spinner. Nessun streaming. L'admin clicca "Genera", vede uno stato di caricamento, poi il testo appare. Più semplice e affidabile per il caso d'uso (generazione ~5-15 sec).
- **Editor bozza:** Textarea non-formattata. Il preventivo generato appare in una `<textarea>` grande che l'admin può editare liberamente prima di salvare. Nessun rich text editor in questa fase.
- **SDK:** `@anthropic-ai/sdk` (pacchetto ufficiale Anthropic). Nessun Vercel AI SDK o LangChain.
- **Modello:** `claude-sonnet-4-6` (modello attivo del progetto).
- **Storage:** Nuova tabella `proposals` nel DB seguendo i pattern esistenti: `id` (nanoid PK), `lead_id` (FK → leads, nullable cascade), `offer_id` (FK → offer_macros), `content` (text NOT NULL — il testo completo della bozza), `slug` (text unique — nanoid, per il link pubblico Phase 22), `status` (text: `draft` | `published`), `created_at` (timestamp with timezone).
- **Migration:** SQL a mano come `0010_proposals.sql` (drizzle-kit generate è rotto). Applicare a prod via SSH prima di pushare il codice.
- **Server action / API route:** Server Action Next.js per il trigger di generazione (pattern coerente col resto del progetto). La chiamata Anthropic avviene lato server.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Requirements & Roadmap
- `.planning/ROADMAP.md` — Phase 21 goal, success criteria (AI-01/AI-02), dipendenze (Phase 20 + DB offerte Phase 11/12)
- `.planning/REQUIREMENTS.md` — AI-01 (generazione), AI-02 (revisione bozza)
### Contesto Phase 20 (transcript — input dell'AI)
- `.planning/phases/20-knowledge-base-cliente/20-CONTEXT.md` — Decisioni schema `client_transcripts`, pattern query, struttura dati che l'AI deve leggere
- `src/lib/lead-service.ts``getTranscripts(leadId)` → restituisce array con `{id, title, content, call_date}` ordinati per data DESC; il campo `content` è testo integrale, non troncato
### Dati offerta (input dell'AI)
- `src/lib/offer-queries.ts``getOfferEditorData(macroId)` → restituisce i dati completi dell'offerta inclusi tier A/B/C con servizi e `public_price`; questa è la funzione da usare per costruire il contesto offerta nel prompt
- `src/app/admin/offers/actions.ts` — pattern server actions per offerte (riferimento per nuovo layer proposals)
### Schema & Migrations
- `src/db/schema.ts` — pattern tabelle esistenti: `leads`, `activities`, `clientTranscripts`, `offer_macros` — usare per definire `proposals`
- `src/db/migrations/0009_client_transcripts.sql` — ultimo esempio SQL migration a mano (struttura e convenzioni da replicare per `0010_proposals.sql`)
### UI & Integration Points
- `src/components/admin/leads/LeadDetail.tsx` — struttura UI dove va aggiunto il trigger "Genera Preventivo" e la sezione bozza
- `src/app/admin/leads/[id]/page.tsx` — page con `Promise.all` per fetch parallele (pattern da seguire, aggiungere fetch proposals)
- `src/app/admin/leads/actions.ts` — pattern `requireAdmin` guard + `revalidatePath` (da seguire per le nuove actions proposals)
### Sicurezza & Architettura
- `CLAUDE.md` → sezione Architecture Constraints: `quote_items` MAI esposti via client API; la nuova tabella `proposals` segue lo stesso principio — `content` non esposto via client token senza consenso esplicito Phase 22
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `getTranscripts(leadId)` in `src/lib/lead-service.ts` — pronta, restituisce `content` integrale; concatenare i transcript in ordine cronologico per il prompt AI
- `getOfferEditorData(macroId)` in `src/lib/offer-queries.ts` — restituisce tier A/B/C con array di servizi e `public_price` per tier; mappare questi dati nel prompt con nome servizi + prezzi
- `nanoid` — già in uso nel progetto per PK; riusare per `proposals.id` e `proposals.slug`
- `requireAdmin()` — già in ogni server action, obbligatorio anche per le nuove actions proposals
### Established Patterns
- **Migration a mano**: `CREATE TABLE IF NOT EXISTS`, tipi Postgres espliciti, FK con `ON DELETE CASCADE` (o `SET NULL` se nullable). NON usare `drizzle-kit generate`.
- **Prod-first migration**: la migration `0010_proposals.sql` DEVE essere applicata a prod via SSH+docker exec (`ssh -L 54321:localhost:54321 root@178.104.27.55`) PRIMA di pushare il codice che la referenzia.
- **Server Actions con revalidatePath**: ogni mutation chiama `revalidatePath()` sul path del lead.
- **Promise.all fetch**: `page.tsx` del lead usa `await Promise.all([...])` — aggiungere `getProposals(id)` nello stesso array.
### Integration Points
- `src/app/admin/leads/[id]/page.tsx` — aggiungere `getProposals(id)` nel `Promise.all`, passare `proposals` a `<LeadDetail />`
- `src/components/admin/leads/LeadDetail.tsx` — aggiungere sezione "Preventivo" con bottone trigger + form selezione offerta + textarea bozza
- `src/db/schema.ts` — aggiungere definizione `proposals` + relazioni Drizzle
- `src/lib/lead-service.ts` (o nuovo `src/lib/proposal-service.ts`) — `generateProposal(leadId, offerId)`, `getProposals(leadId)`, `saveProposal(id, content)`
- `.env.local` — aggiungere `ANTHROPIC_API_KEY` (richiede configurazione in Coolify per produzione)
</code_context>
<specifics>
## Specific Ideas
- Il preventivo è multi-tier by design: non c'è una scelta del tier nella UI di generazione. Claude scrive 3 opzioni (A/B/C) in un solo documento, il cliente legge e sceglie.
- La sezione "Opzione A/B/C" deve mostrare i nomi dei servizi inclusi in modo leggibile (non JSON grezzo), e il `public_price` del tier in modo prominente.
- La sezione "Situazione Attuale" è la parte più personalizzata — Claude deve pescare dai transcript specifici insights sul cliente, non frasi generiche. Il prompt deve guidare su questo.
- L'admin in fase di review vede l'intero testo del preventivo in una `<textarea>` di altezza generosa (tipo `min-h-96`) e può editare liberamente prima di salvare.
</specifics>
<deferred>
## Deferred Ideas
- **Scelta tier nel preventivo:** eventualmente l'admin potrebbe scegliere un tier da presentare → Phase 22 o fase futura post v2.2.
- **Streaming output AI:** Claude scrive in real-time → futura ottimizzazione UX se la latenza diventa un problema.
- **Versioning bozze:** mantenere storico delle generazioni per un lead → futura fase.
- **Agente multi-step con tool_use:** architettura più sofisticata dove Claude chiama tool API invece di ricevere dati pre-imbarcati nel prompt → futura fase.
</deferred>
---
*Phase: 21-Agente AI — generazione preventivo*
*Context gathered: 2026-06-20*
@@ -0,0 +1,40 @@
# Phase 21: Discussion Log
**Session:** 2026-06-20
**Areas discussed:** 1 of 4 identified (Struttura preventivo)
---
## Area: Struttura preventivo
### Q1 — Tipo di output AI
- **Opzioni:** Testo libero personalizzato / Sezioni strutturate fisse / Ibrido intro+sezioni
- **Scelta:** Sezioni strutturate fisse
- **Note:** L'admin e il cliente hanno bisogno di un documento prevedibile — le sezioni fisse rendono più facile sia il prompt engineering che l'editing successivo.
### Q2 — Quali sezioni
- **Opzioni (multi-select):** Situazione Attuale / La Proposta / Cosa è Incluso / Investimento + Prossimi Passi
- **Scelta:** Tutte e 4 le sezioni
- **Note:** L'utente ha aggiunto "penso che creo un agente apposta per questa parte" — chiarito che si trattava di un'altra sessione Claude Code, nessuna interferenza con questo progetto.
### Q3 — Tier: singolo o tutti e 3
- **Opzioni:** Un solo tier scelto dall'admin / Tutti e 3 i tier in un documento
- **Scelta:** Tutti e 3 i tier in un documento
- **Note:** Il preventivo presenta opzioni A/B/C al cliente, che sceglie leggendo.
---
## Aree non discusse (Claude's discretion)
- **Builder location:** Entry point nel LeadDetail esistente
- **UX generazione:** Fire-and-wait con spinner (no streaming)
- **Editor bozza:** Textarea plain text
---
## Idee deferred
- Scelta tier singolo nel preventivo (post v2.2)
- Streaming output AI (ottimizzazione futura)
- Versioning bozze
- Agente multi-step con tool_use
@@ -0,0 +1,72 @@
# Phase 21+22 — Agente AI Preventivo + Pagina Pubblica Deck
**Executed:** 2026-06-20
**Status:** Code complete — pending migration 0010 to prod
**Model used:** `claude-opus-4-8`
**Note:** Le fasi 21 (AI generation) e 22 (public page) sono state eseguite in un'unica sessione per coerenza architetturale.
---
## Scope eseguito
### Fase 21 — Agente AI (AI-01 / AI-02)
- `@anthropic-ai/sdk@0.105.0` installato
- `ANTHROPIC_API_KEY` aggiunta a `.env.local`
- `src/lib/proposal/schema.ts` — Zod schema `ProposalContent` (sezioni AI)
- `src/lib/proposal/agent.ts``generateProposalContent()` → Claude Opus 4.8, JSON strutturato validato Zod
- `src/lib/proposal/assemble.ts``assembleProposal()` fonde AI + offerta DB + config consulente
- `src/lib/proposal/profile.ts` — config statica consulente (bio, fatti, testimonianze, legal) — **editare con dati reali**
- `src/lib/proposal/queries.ts``listProposals`, `getProposalById`, `getProposalBySlug`
- `src/app/admin/preventivi/actions.ts``generateProposalDraft`, `publishProposal`, `updateProposalTitle`, `deleteProposal`
- `src/app/admin/preventivi/page.tsx` — lista preventivi admin
- `src/app/admin/preventivi/genera/page.tsx` + `GeneraProposalForm.tsx` — builder con pre-fill `?lead_id=X`
- `src/app/admin/preventivi/[id]/page.tsx` — review bozza + pubblica + elimina
- `src/components/admin/AdminSidebar.tsx` — voce "Preventivi" + CTA globale lime "Genera preventivo"
- `src/components/admin/leads/LeadDetail.tsx` — pulsante "Genera preventivo" → `/admin/preventivi/genera?lead_id=X`
### Fase 22 — Pagina pubblica deck (PUB-01 / PUB-02)
- `src/app/preventivo/[slug]/page.tsx` — server component pubblico, gestisce stati draft/published/accepted/rejected
- `src/app/preventivo/[slug]/actions.ts``acceptProposal` + `rejectProposal` con guard immutabilità `accepted_at`
- `src/components/public/proposal/ProposalDeck.tsx` — deck navigabile (frecce ←/→ + dot cliccabili + keyboard), light mode iamcavalli
- Sezioni (20+): Cover, Vision, Index, ChapterDivider, Strategist, Facts, Testimonials, ProblemNode, SynthesisDiagram, SolutionNode, SolutionSynthesis, Scope, Deliverables, Timeline, Pricing, StagesRecap, ComparisonMatrix, NextSteps, Accept, Closing
### Schema DB
- `src/db/migrations/0010_proposals.sql``CREATE TABLE IF NOT EXISTS proposals (...)`**applicare a prod via SSH prima del push**
- `src/db/schema.ts` — tabella `proposals` + relations + `Proposal`/`NewProposal` types
---
## Flusso end-to-end
```
Lead (con transcript) → LeadDetail "Genera preventivo"
→ /admin/preventivi/genera?lead_id=X
→ seleziona offerta → "Genera con AI"
→ server action: legge transcript + offerta → chiama Claude Opus 4.8 → Zod validate
→ assembla AssembledProposal (AI + offerta + config) → salva in proposals (draft)
→ redirect /admin/preventivi/[id] → review bozza → "Pubblica"
→ pagina pubblica /preventivo/[slug] — deck 20+ slide
→ cliente sceglie tier A/B/C → accetta → accepted_at IMMUTABILE
```
---
## Pending post-push
1. **Migration 0010 a prod** via `ssh -L 54321:localhost:54321 root@178.104.27.55` + script node
2. **Dati reali in `src/lib/proposal/profile.ts`** — bio, credenziali, testimonianze, contatti, foto URL
3. **`ANTHROPIC_API_KEY` in Coolify** (già in `.env.local` per dev)
4. **Wave 5 (opzionale)** — badge CRM su accept/reject + email Resend
---
## Decisioni architetturali
| Decisione | Scelta | Motivo |
|-----------|--------|--------|
| Output AI | JSON strutturato → template fisso | Coerenza visiva garantita, zero rischio HTML rotto |
| Bio/testimonianze | File config `profile.ts` | Editing semplice, no UI v1 |
| Entry point | LeadDetail + sidebar globale | GSD prescrive LeadDetail; sidebar è additive |
| `content` | `jsonb` (non `text`) | Parser automatico postgres-js, type-safe |
| Modello | `claude-opus-4-8` | Migliore qualità copywriting strategico |
| Phase split | 21+22 eseguite insieme | Dipendenza diretta, nessun vantaggio a splitparle |
@@ -0,0 +1,56 @@
---
phase: 22-pagina-pubblica-preventivo
plan: "01"
status: complete
completed_at: "2026-06-20"
requirements_satisfied:
- PUB-01
- PUB-02
known_gaps:
- PUB-03 (email Resend — non implementata, deferred to backlog)
---
# Plan 22-01 Summary: Pagina Pubblica Preventivo + Deck
## What Was Built
Pagina pubblica `/preventivo/[slug]` che presenta la proposta generata come deck interattivo di 20+ slide navigabili (frecce, tasti keyboard, dot navigator). Il cliente sceglie il tier A/B/C e accetta/rifiuta dalla pagina; l'esito si riflette nel DB con `accepted_at` immutabile. PUB-03 (invio email Resend) non implementato — il link va condiviso manualmente.
## Key Files
### Created
- `src/app/preventivo/[slug]/page.tsx` — server component pubblico; gestisce stati: draft (blocca accesso), published (mostra + accept), accepted/rejected (mostra con stato)
- `src/app/preventivo/[slug]/actions.ts``acceptProposal` (guard `accepted_at` immutabile + `selected_tier` validato A/B/C), `rejectProposal`
- `src/components/public/proposal/ProposalDeck.tsx` — deck client component: navigazione keyboard (←/→), dot cliccabili, topbar fissa (titolo + counter), bottombar fissa (frecce + dots); `h-screen overflow-hidden` per 100vh per slide
- `src/components/public/proposal/sections/` — 20 componenti slide:
- CoverSection, VisionSection, IndexSection
- ChapterDivider (×5), StrategistSection, FactsSection, TestimonialsSection
- ProblemNodeSection, SynthesisDiagramSection
- SolutionNodeSection, SolutionSynthesisSection
- ScopeSection, DeliverablesSection, TimelineSection
- PricingSection, StagesRecapSection, ComparisonMatrixSection
- NextStepsSection, AcceptSection, ClosingSection
## Architecture Decisions
| Decisione | Scelta | Motivo |
|-----------|--------|--------|
| Layout | `h-screen overflow-hidden` per slide | Nessun scroll di pagina, navigazione slide-by-slide |
| Navigazione | Keyboard + dots + click frecce | Universale, funziona su desktop e touch |
| Accettazione | `accepted_at` immutabile (guard server action) | Pattern coerente con `deliverables.approved_at` |
| Tier selezione | Radio A/B/C prima dell'accettazione | Cliente sceglie l'opzione, l'esito è univoco |
| Email | Non implementata (PUB-03) | Scope minimo, link condiviso manualmente per ora |
## Requirements Verified
- PUB-01: ✅ Proposta visibile come pagina pubblica HTML a `/preventivo/[slug]`, deck 20+ slide
- PUB-02: ✅ Cliente accetta/rifiuta dalla pagina; `state` + `accepted_at` + `selected_tier` aggiornati nel DB; admin vede esito nella lista preventivi
## Known Gap
- **PUB-03 — Email Resend**: il link preventivo non viene inviato automaticamente. Va copiato dall'admin e condiviso manualmente (es. via WhatsApp/email). Da implementare nella prossima milestone come prima feature.
## Fix Post-Deploy
- 2026-06-20: fix `h-screen overflow-hidden` su tutte le slide (erano `min-h-screen`, causavano scroll di pagina)
- 2026-06-20: `ANTHROPIC_API_KEY` mancante in Coolify — aggiunta via PHP artisan + redeploy
+11 -3
View File
@@ -20,9 +20,17 @@ Planning in `.planning/`. Use `/gsd-plan-phase N` → `/gsd-execute-phase N`. St
- Before running any migration: verify it only adds columns/tables — never drops or truncates production data
- Confirm explicitly before any schema change that removes a column or table used by these entities
## Deploy & DB Access (procedure)
- Environments: local → Gitea (remote is named `gitea`, NOT `origin`) → Coolify (prod, auto-deploys on push to `main`)
- Prod Postgres is NOT publicly exposed. Claude has working key-based SSH to `root@178.104.27.55` and applies migrations directly via docker exec — no SSH tunnel needed from the user:
`cat src/db/migrations/NNNN.sql | ssh root@178.104.27.55 "docker exec -i xwkk0040w0kk0gsgcgog8owk psql -U clienthub -d clienthub -v ON_ERROR_STOP=1 --single-transaction"`
(container = `xwkk0040w0kk0gsgcgog8owk`, db/user = `clienthub`; if the container hash changes, find it by scanning `docker ps` for the one whose db has the `payments` table). A tunnel `-L 54321:localhost:54321` is only needed to point local tooling at prod.
- Migrations are hand-written SQL in `src/db/migrations/` (drizzle-kit generate is broken).
- Ordering: apply an additive migration to prod BEFORE pushing the schema-dependent code, so the live portal never queries a missing column.
## Security
- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE, infra changes)
- Never read/expose .env or credentials without explicit request
- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE / drop-column, truncate, infra changes)
- Never print .env contents or credentials in plaintext output; using them internally to connect is fine
- Don't install packages without showing name + registry + version first
- Don't push to main or create PRs without explicit confirmation
- Pushing to `main` is allowed automatically (standard local → Gitea → Coolify flow); never force-push to `main`
- Any change to this section: propose full new version, get approval before applying
+40
View File
@@ -0,0 +1,40 @@
# ClientHub (IAMCAVALLI) — Status
_Ultimo aggiornamento: 2026-06-22_
## Stato attuale
In prod su Coolify (Gitea→deploy). Milestone v2.3 "Email & Accesso" in planning (fase 23 non ancora avviata); ultime sessioni dedicate a fix/flow pre-fase-23. Build verde.
---
## Fatto (recente, cumulativo)
- **Tassonomie centralizzate** in Impostazioni (modello Notion, pool persistenti `src/lib/taxonomy.ts`): Categoria/Ticket/Tipo/Obiettivo offerte + Fase/Offerta/Pacchetto catalogo. Add/remove/sync globale.
- **Lead → Cliente**: `clients.email/phone` + `leads.archived` (migr. 0011, in prod). `convertLeadToClient` riusa `createClientCore`, porta i transcript, archivia il lead mantenendo "won". Tasto Converti/Convertito; lead archiviati nascosti da lista/kanban.
- **Pagina progetto**: tab "Preventivo" rimossa; sidebar riordinata (Lead prima di Clienti).
- **Offerta → Fasi/Task**: `importOfferIntoProject` crea fasi raggruppando i servizi del tier per `services.fase` (merge per titolo, dedup task). Trigger dalla tab Offerte (checkbox import).
- **Offerte (modello + UI)**: nuovo campo `offer_macros.offer_type` ('una_tantum'|'retainer') + toggle "Modalità" nell'editor (migr. 0012). Tab Offerte ridisegnata: assegnazione a 2 step (Offerta→Tier con prezzo), badge tipo invece di "X mesi". Deck preventivo mostra il tipo.
- **Cleanup dati**: rimossi 3 tier duplicati di "Web Domination" (bug pre-fix) + vincolo `UNIQUE(macro_id, tier_letter)`.
- Fix precedenti: persistenza flag A/B/C offerte, sort+reorder colonne catalogo.
## Da fare
- [ ] **Fasi/Task dall'offerta** funzionano solo se i servizi hanno il campo **Fase** valorizzato nel Catalogo (altrimenti finiscono in "Generale").
- [ ] **Forecast** (`forecast-queries.ts`) usa ancora `duration_months`: i retainer andrebbero trattati come ricorrenti (follow-up).
- [ ] Micro legacy "Mantenimento" senza tier: valutare se rimuoverlo/normalizzarlo.
- [ ] Avviare **fase 23** (OTP gate portale cliente + invio link preventivo via email, integrazione Resend) — `/gsd-plan-phase 23`.
## Note tecniche
- **DB prod = `.env.local` porta 54321**, raggiungibile SOLO via tunnel SSH `ssh -L 54321:localhost:54321 root@178.104.27.55` (cade per idle; usare `-o ServerAliveInterval=30`). `node`/`docker` non disponibili lato server → migrazioni si applicano da locale con script `postgres.js` su 127.0.0.1:54321.
- **Ordine deploy con schema**: applicare la migrazione a prod PRIMA del push (il deploy gira subito il codice nuovo).
- `drizzle-kit generate` è rotto → migrazioni SQL scritte a mano in `src/db/migrations/`.
- `offer_micros` non ha `created_at` (no "tier più vecchio" affidabile).
## File chiave
| File | Scopo |
|---|---|
| src/lib/taxonomy.ts | Pool tassonomie (Impostazioni) |
| src/app/admin/leads/actions.ts | `convertLeadToClient` |
| src/app/admin/clients/new/actions.ts | `createClientCore` (riuso) |
| src/app/admin/projects/project-actions.ts | `importOfferIntoProject`, assegna offerta |
| src/components/admin/tabs/OffersTab.tsx | Tab Offerte (2 step + badge tipo) |
| src/lib/admin-queries.ts | `getProjectFullDetail` (offerte/dedup) |
| src/db/migrations/ | 0011 (email/phone/archived), 0012 (offer_type/unique) |
+148
View File
@@ -0,0 +1,148 @@
# Design System: iamcavalli Admin & Client Portal (v1.0)
> "Minimalist Premium / Quiet Luxury" — the aesthetic that replaces the old
> mixed token/hardcoded styling across ClientHub's admin area. First applied
> to the Lead Pipeline page (Phase: Pipeline redesign); the tokens and
> primitives documented here are the base for restyling every other section.
## Philosophy
- **Quiet, not loud.** Elevation is nearly invisible (shadow opacity ≤ 0.02),
borders are hairline, color is used sparingly and only to carry meaning
(status, semantics) — never for decoration.
- **Density with air.** Generous padding (`py-4 px-6` in tables, `p-4` in
kanban columns) paired with small type (`text-xs`, `text-[11px]`) reads as
premium rather than cramped.
- **Numbers are monospace.** Prices, phone numbers, dates, counts — anything
tabular/numeric — use `font-mono` and right-alignment so columns of digits
line up.
- **Dual-theme by construction.** Every surface is built from semantic
tokens (`bg-card`, `text-muted-foreground`, `border-border`, …), never raw
Tailwind palette classes (`bg-white`, `text-slate-900`, …) or hex literals.
This is what makes dark mode "just work" without a parallel dark stylesheet.
- **Motion is functional.** Transitions exist to explain state change (sidebar
collapse, hover, drag) — `duration-200``duration-350`,
`ease-[cubic-bezier(0.4,0,0.2,1)]` for the sidebar specifically. No
decorative animation.
## Typography
- **Sans**: Plus Jakarta Sans (weights 300700), loaded via `next/font/google`
as `--font-plus-jakarta-sans`, mapped to Tailwind's `--font-sans` in
`@theme`. Used for all UI text.
- **Mono**: Geist Mono, `--font-geist-mono``--font-mono`. Used for numeric
data cells only (prices, phone numbers, counts, dates in tables).
- **Scale conventions**:
- Page title: `text-2xl font-semibold tracking-tight`
- Page subtitle: `text-xs text-muted-foreground`
- Table header cells: `text-[11px] font-semibold uppercase tracking-wider text-muted-foreground`
- Table body: `text-sm`
- Badge/pill label: `text-[10px]``text-xs font-semibold uppercase tracking-wide`
## Color Tokens
Raw values live in `:root` (light) / `.dark` (dark) in `src/app/globals.css`
and are mapped to Tailwind utilities via `@theme inline`. Components must
consume the mapped utility, never the raw hex or a Tailwind palette shade.
| Utility | Light | Dark | Usage |
|---|---|---|---|
| `bg-background` / `text-foreground` | `#ffffff` / `#1a1a1a` | `#0e1512` / `#f2f4f3` | Page canvas |
| `bg-card` / `text-card-foreground` | `#ffffff` | `#131a16` | Cards, table, kanban cards, header bar |
| `bg-muted` / `text-muted-foreground` | `#f9f9f9` / `#71717a` | `#171f1b` / `#9aa39e` | Subtle backgrounds, secondary text, kanban columns |
| `bg-primary` / `text-primary-foreground` | `#1A463C` | `#3FA88C` | Primary actions, sidebar, active nav state |
| `bg-accent` | `#DEF168` | `#DEF168` | Rare highlight accent (lime) |
| `border-border` / `border-input` | `#e5e7eb` | `#26302b` | All hairline borders |
| `ring-ring` | `#1A463C` | `#3FA88C` | Focus rings |
| `bg-destructive` | `#ef4444` | `#f87171` | Destructive actions |
**Sidebar exception**: the sidebar stays brand green `bg-[#1A463C]` in both
themes (it is not a themed surface — it's the constant brand anchor).
**Status/semantic colors** (lead stages, badges) use Tailwind's default
palette (blue/purple/amber/orange/emerald/red) directly, each paired with an
explicit `dark:` variant for legibility on dark surfaces — see `StatusBadge`.
## Elevation & Radius
- `--radius: 0.5rem` (base) is wired into `@theme` as `--radius-lg` (`=
--radius`) and `--radius-xl` (`= --radius + 0.25rem`), so `rounded-lg` /
`rounded-xl` utilities resolve consistently off one token.
- `--shadow-card: 0 4px 20px rgba(0,0,0,0.01)` → `shadow-card` utility.
Default resting elevation for cards, table containers, kanban cards.
- `--shadow-card-hover: 0 8px 30px rgba(0,0,0,0.02)` → `shadow-card-hover`
utility. Applied on hover for interactive cards.
## Layout Conventions
- **Table container**: `bg-card rounded-xl border border-border shadow-card
overflow-hidden`.
- **Table header row**: `bg-muted/50 border-b border-border text-[11px]
uppercase tracking-wider text-muted-foreground`.
- **Table body rows**: `py-4 px-6`, `divide-y divide-border`,
`hover:bg-muted/40 transition-colors`.
- **Kanban column**: `bg-muted/60 rounded-xl border border-border p-4`.
- **Kanban card**: `bg-card rounded-lg border border-border shadow-sm
hover:border-primary/30 hover:shadow-card-hover transition-all`.
- **Page header**: title `text-2xl font-semibold tracking-tight
text-foreground`, subtitle `text-xs text-muted-foreground mt-1`, primary
action button `bg-primary text-primary-foreground hover:bg-primary/90`.
## Color/Class Migration Map (dual-theme principle)
When restyling any surface, replace hardcoded classes with token utilities:
| Old (hardcoded) | New (token) |
|---|---|
| `bg-white` | `bg-card` |
| `bg-slate-50` / `bg-gray-50` | `bg-muted` |
| `border-slate-100` / `border-slate-200` | `border-border` |
| `text-slate-900` / `text-[#1a1a1a]` | `text-foreground` |
| `text-slate-500` / `text-slate-400` / `text-[#71717a]` | `text-muted-foreground` |
| `bg-brand-dark` / `text-[#1A463C]` (as action color) | `bg-primary` / `text-primary` |
## Motion
- **Sidebar collapse**: `transition-[width] duration-350
ease-[cubic-bezier(0.4,0,0.2,1)]` on the `<aside>`; label/logo text use
`whitespace-nowrap transition-opacity duration-200` and fade to
`opacity-0` immediately on collapse, but only fade back in ~150ms after
the width transition starts on expand (avoids text reflow/wrap during the
animation).
- **Hover states**: `transition-colors duration-150``duration-200` on rows,
buttons, nav links.
- **Kanban drag**: dragged card renders in a `DragOverlay` with
`rotate-1 shadow-xl`; drop target column highlights via `border-primary
bg-primary/5`.
## UX Rules
1. Numeric/tabular data is always monospace and right-aligned in tables.
2. Every interactive control has a visible focus state (`focus:ring-1
focus:ring-ring focus:border-primary` pattern) for keyboard accessibility.
3. Status is always communicated redundantly — color + text label — never
color alone.
4. Destructive actions use `destructive` tokens, never ad-hoc red.
5. Empty states are muted, centered, and brief ("Nessun lead trovato").
6. Sidebar collapse state persists across page loads via `localStorage`
(`iamcavalli:sidebar`) and is restored before paint where possible to
avoid layout jump.
---
## Component Inventory
Primitives extracted from the Lead Pipeline redesign, intended for reuse
across all future admin pages.
| Component | Path | Purpose |
|---|---|---|
| `AdminShell` | `src/components/admin/AdminShell.tsx` | Client shell holding sidebar-collapsed state (persisted to `localStorage`), renders sidebar + top header + `<main>`. Wraps all `/admin/*` pages. |
| `AdminSidebar` | `src/components/admin/AdminSidebar.tsx` | Collapsible brand sidebar (`w-64` ↔ `w-20`), controlled by `AdminShell`. Nav items, theme toggle, sign-out. |
| `StatusBadge` | `src/components/ui/StatusBadge.tsx` | Rounded-full status pill with a color map per lead stage, each with a `dark:` variant. Replaces the old scattered `STAGE_COLOR` maps. |
| `SearchInput` | `src/components/ui/SearchInput.tsx` | Search-icon input, `rounded-lg border-border focus:ring-primary`. |
| `SegmentedToggle` | `src/components/ui/SegmentedToggle.tsx` | Generic Lista/Kanban-style segmented control (`bg-muted p-1 rounded-lg`, active option `bg-card shadow-sm`). |
| `PageHeader` | `src/components/admin/PageHeader.tsx` | Page title (`text-2xl tracking-tight`) + subtitle + action slot, token-based. |
| `LeadTable` | `src/components/admin/leads/LeadTable.tsx` | Luxury table restyle: `bg-card rounded-xl shadow-card`, uppercase muted headers, `hover:bg-muted/40` rows, uses `StatusBadge`. |
| `LeadsKanbanBoard` | `src/components/admin/leads/LeadsKanbanBoard.tsx` | Kanban restyle: `bg-muted/60` columns with count pills, `bg-card` cards with `hover:border-primary/30`. Uses `@dnd-kit`, keeps all 6 lead stages. |
| `LeadsViewToggle` | `src/components/admin/leads/LeadsViewToggle.tsx` | Wires `SearchInput` + `SegmentedToggle` together with the table/kanban views. |
+361
View File
@@ -0,0 +1,361 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Catalogo Servizi — Luxury Admin CRM</title>
<!-- Google Fonts: Plus Jakarta Sans -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Plus Jakarta Sans', 'sans-serif'],
},
colors: {
brand: {
dark: '#1A463C', /* Verde scuro richiesto */
darkHover: '#13342D', /* Variante scura per hover */
active: 'rgba(255, 255, 255, 0.08)',
bg: '#F8F9FA',
}
}
}
}
}
</script>
<style>
.sidebar-transition {
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>
</head>
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
<!-- SIDEBAR -->
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
<div class="overflow-hidden">
<!-- Logo e Intestazione -->
<div class="mb-10 px-2 flex items-center justify-between">
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
</div>
<!-- Navigazione Principale -->
<nav class="space-y-1">
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
</a>
<!-- Stato Attivo su Catalogo -->
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Catalogo</span>
</a>
</nav>
</div>
<!-- Area Inferiore Sidebar -->
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
<span class="sidebar-text transition-opacity duration-300">Tema</span>
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
</button>
</div>
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
<span class="sidebar-text transition-opacity duration-300">Esci</span>
</a>
</div>
</aside>
<!-- SEZIONE PRINCIPALE -->
<div class="flex-1 flex flex-col min-w-0">
<!-- BARRA DI INTESTAZIONE SUPERIORE -->
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
<div class="flex items-center gap-4">
<!-- Pulsante Apri/Chiudi Sidebar -->
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
</button>
<span class="text-xs text-slate-400 font-medium">Area di Lavoro</span>
</div>
<div class="flex items-center gap-4">
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Demo Account</span>
</div>
</header>
<!-- AREA DEL CONTENUTO -->
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-6 overflow-y-auto">
<!-- INTESTAZIONE SEZIONE -->
<div>
<h1 class="text-2xl font-semibold text-slate-900 tracking-tight">Catalogo Servizi</h1>
<p class="text-xs text-slate-400 mt-1">Configura e gestisci le singole voci di servizio offerte</p>
</div>
<!-- BARRA DI RICERCA MINIMALE -->
<div class="relative w-full">
<span class="absolute inset-y-0 left-0 flex items-center pl-3.5 pointer-events-none text-slate-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</span>
<input
type="text"
placeholder="Cerca per nome, categoria, fase, tag o pacchetto..."
class="w-full pl-10 pr-4 py-3 bg-white text-sm text-slate-800 placeholder-slate-400 border border-slate-200/80 rounded-lg focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all duration-200"
/>
</div>
<!-- TABELLA CATALOGO -->
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse table-fixed min-w-[1000px]">
<thead>
<tr class="border-b border-slate-100/80 bg-slate-50/50 text-slate-400 text-[11px] font-semibold uppercase tracking-wider">
<th class="py-3 px-5 w-1/3">
<span class="inline-flex items-center gap-1 cursor-pointer hover:text-slate-700">
Nome
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M8 9l4-4 4 4m0 6l-4 4-4-4"/></svg>
</span>
</th>
<th class="py-3 px-4 w-[110px]">
<span class="inline-flex items-center gap-1 cursor-pointer hover:text-slate-700">
Prezzo
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M8 9l4-4 4 4m0 6l-4 4-4-4"/></svg>
</span>
</th>
<th class="py-3 px-4 w-[160px]">Offerta</th>
<th class="py-3 px-4 w-[240px]">Fase</th>
<th class="py-3 px-4 w-1/5">Descrizione</th>
<th class="py-3 px-4 w-[100px] text-center">Pacchetto</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100/60 text-[13px] text-slate-700">
<!-- Riga 1 -->
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-3 px-5 font-medium text-slate-900 truncate">Landing Page (Metodo o Differenziante)</td>
<td class="py-3 px-4 font-mono text-xs text-slate-600">€1.000,00</td>
<td class="py-3 px-4">
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100">
Signature Offer
</span>
</td>
<td class="py-3 px-4">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700 border border-amber-100">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
Fase 3 ➔ Esecuzione
</span>
</td>
<td class="py-3 px-4 text-slate-400 text-xs truncate">—</td>
<td class="py-3 px-4 text-center">
<button class="w-6 h-6 rounded-full border border-slate-200 text-slate-400 hover:text-brand-dark hover:border-brand-dark hover:bg-emerald-50/30 flex items-center justify-center mx-auto transition-all">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
</button>
</td>
</tr>
<!-- Riga 2 -->
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-3 px-5 font-medium text-slate-900 truncate">Analisi Competitor</td>
<td class="py-3 px-4 font-mono text-xs text-slate-600">€400,00</td>
<td class="py-3 px-4">
<div class="flex flex-col gap-1 items-start">
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-purple-50 text-purple-600 border border-purple-100">
Entry Offer
</span>
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100">
Signature Offer
</span>
</div>
</td>
<td class="py-3 px-4">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium bg-blue-50 text-blue-700 border border-blue-100">
<span class="w-1.5 h-1.5 rounded-full bg-blue-400"></span>
Fase 2 ➔ Analisi / Strat.
</span>
</td>
<td class="py-3 px-4 text-slate-400 text-xs truncate">—</td>
<td class="py-3 px-4 text-center">
<button class="w-6 h-6 rounded-full border border-slate-200 text-slate-400 hover:text-brand-dark hover:border-brand-dark hover:bg-emerald-50/30 flex items-center justify-center mx-auto transition-all">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
</button>
</td>
</tr>
<!-- Riga 3 -->
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-3 px-5 font-medium text-slate-900 truncate">Art direction su direzione da prendere</td>
<td class="py-3 px-4 font-mono text-xs text-slate-600">€2.000,00</td>
<td class="py-3 px-4">
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-blue-50 text-blue-600 border border-blue-100">
Retainer Offer
</span>
</td>
<td class="py-3 px-4 text-slate-400 text-xs">—</td>
<td class="py-3 px-4 text-slate-400 text-xs truncate">—</td>
<td class="py-3 px-4 text-center">
<button class="w-6 h-6 rounded-full border border-slate-200 text-slate-400 hover:text-brand-dark hover:border-brand-dark hover:bg-emerald-50/30 flex items-center justify-center mx-auto transition-all">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
</button>
</td>
</tr>
<!-- Riga 4 -->
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-3 px-5 font-medium text-slate-900 truncate">Audit iniziale (UX/UI, struttura, conversione)</td>
<td class="py-3 px-4 font-mono text-xs text-slate-600">€500,00</td>
<td class="py-3 px-4">
<div class="flex flex-col gap-1 items-start">
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-purple-50 text-purple-600 border border-purple-100">
Entry Offer
</span>
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100">
Signature Offer
</span>
</div>
</td>
<td class="py-3 px-4">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium bg-orange-50 text-orange-700 border border-orange-100">
<span class="w-1.5 h-1.5 rounded-full bg-orange-400"></span>
Fase 1 ➔ Onboarding
</span>
</td>
<td class="py-3 px-4 text-slate-400 text-xs truncate">—</td>
<td class="py-3 px-4 text-center">
<button class="w-6 h-6 rounded-full border border-slate-200 text-slate-400 hover:text-brand-dark hover:border-brand-dark hover:bg-emerald-50/30 flex items-center justify-center mx-auto transition-all">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
</button>
</td>
</tr>
<!-- Riga 5 -->
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-3 px-5 font-medium text-slate-900 truncate">Go-live / messa online & accessi</td>
<td class="py-3 px-4 font-mono text-xs text-slate-600">€300,00</td>
<td class="py-3 px-4">
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100">
Signature Offer
</span>
</td>
<td class="py-3 px-4">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium bg-teal-50 text-teal-700 border border-teal-100">
<span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>
Fase 5 ➔ Consegna
</span>
</td>
<td class="py-3 px-4 text-slate-400 text-xs truncate">—</td>
<td class="py-3 px-4 text-center">
<button class="w-6 h-6 rounded-full border border-slate-200 text-slate-400 hover:text-brand-dark hover:border-brand-dark hover:bg-emerald-50/30 flex items-center justify-center mx-auto transition-all">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
</button>
</td>
</tr>
<!-- FORM INLINE PER NUOVA RIGA ("Aggiungi servizio") -->
<tr class="bg-slate-50/40 border-t-2 border-slate-100">
<td class="py-4 px-5">
<input
type="text"
placeholder="+ Aggiungi servizio..."
class="w-full bg-white border border-slate-200 rounded px-3 py-1.5 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all"
/>
</td>
<td class="py-4 px-4">
<input
type="text"
placeholder="0,00"
class="w-full bg-white border border-slate-200 rounded px-3 py-1.5 text-xs font-mono text-slate-800 placeholder-slate-400 focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all"
/>
</td>
<td class="py-4 px-4 text-slate-300 text-xs select-none">Seleziona...</td>
<td class="py-4 px-4">
<select class="w-full bg-white border border-slate-200 rounded px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all">
<option value="">Fase...</option>
<option value="1">Fase 1</option>
<option value="2">Fase 2</option>
<option value="3">Fase 3</option>
<option value="4">Fase 4</option>
<option value="5">Fase 5</option>
</select>
</td>
<td class="py-4 px-4">
<input
type="text"
placeholder="Descrizione..."
class="w-full bg-white border border-slate-200 rounded px-3 py-1.5 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all"
/>
</td>
<td class="py-4 px-4 text-center">
<button class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-medium px-4 py-1.5 rounded transition-all">
Invia
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- FOOTER TABELLA -->
<div class="border-t border-slate-100 px-6 py-4 flex items-center justify-between text-xs text-slate-400">
<span>Visualizzazione di 5 servizi principali</span>
</div>
</div>
</main>
</div>
<!-- SCRIPT DI LOGICA (Gestione Sidebar) -->
<script>
const sidebar = document.getElementById('sidebar');
const toggleButton = document.getElementById('sidebar-toggle');
const logoText = document.getElementById('sidebar-logo-text');
const sidebarTexts = document.querySelectorAll('.sidebar-text');
toggleButton.addEventListener('click', () => {
const isCollapsed = sidebar.classList.contains('w-20');
if (isCollapsed) {
sidebar.classList.remove('w-20');
sidebar.classList.add('w-64');
setTimeout(() => {
logoText.classList.remove('opacity-0');
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
}, 150);
} else {
logoText.classList.add('opacity-0');
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
sidebar.classList.remove('w-64');
sidebar.classList.add('w-20');
}
});
</script>
</body>
</html>
+270
View File
@@ -0,0 +1,270 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clienti — Luxury Admin CRM</title>
<!-- Google Fonts: Plus Jakarta Sans -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Plus Jakarta Sans', 'sans-serif'],
},
colors: {
brand: {
dark: '#1A463C', /* Verde scuro richiesto */
darkHover: '#13342D', /* Variante scura per hover */
active: 'rgba(255, 255, 255, 0.08)',
bg: '#F8F9FA',
}
}
}
}
}
</script>
<style>
.sidebar-transition {
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>
</head>
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
<!-- SIDEBAR -->
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
<div class="overflow-hidden">
<!-- Logo e Intestazione -->
<div class="mb-10 px-2 flex items-center justify-between">
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
</div>
<!-- Navigazione Principale -->
<nav class="space-y-1">
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
</a>
<!-- Stato Attivo su Clienti -->
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
</a>
</nav>
</div>
<!-- Area Inferiore Sidebar -->
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
<span class="sidebar-text transition-opacity duration-300">Tema</span>
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
</button>
</div>
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
<span class="sidebar-text transition-opacity duration-300">Esci</span>
</a>
</div>
</aside>
<!-- SEZIONE PRINCIPALE -->
<div class="flex-1 flex flex-col min-w-0">
<!-- BARRA DI INTESTAZIONE SUPERIORE -->
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
<div class="flex items-center gap-4">
<!-- Pulsante Apri/Chiudi Sidebar -->
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
</button>
<span class="text-xs text-slate-400 font-medium">Area di Lavoro</span>
</div>
<div class="flex items-center gap-4">
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Demo Account</span>
</div>
</header>
<!-- AREA DEL CONTENUTO DINAMICO -->
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-8 overflow-y-auto">
<!-- INTESTAZIONE SEZIONE -->
<div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div>
<h1 class="text-2xl font-semibold text-slate-900 tracking-tight">Clienti</h1>
<p class="text-xs text-slate-400 mt-1">Monitora i rapporti finanziari e i dettagli dei clienti attivi</p>
</div>
<!-- Pulsanti di azione -->
<div class="flex items-center gap-4 self-start sm:self-auto">
<button class="text-xs font-medium text-slate-500 hover:text-slate-900 transition-colors">
Mostra archiviati
</button>
<button class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-medium tracking-wide px-5 py-3 rounded-lg flex items-center gap-2 transition-all duration-200 shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
Nuovo cliente
</button>
</div>
</div>
<!-- TABELLA CLIENTI -->
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-slate-100/80 bg-slate-50/50 text-slate-400 text-[11px] font-semibold uppercase tracking-wider">
<th class="py-4 px-6 w-1/4">Cliente</th>
<th class="py-4 px-6 text-right">LTV</th>
<th class="py-4 px-6 text-right">Importo Incassato</th>
<th class="py-4 px-6 text-right">Importo da Saldare</th>
<th class="py-4 px-6 text-right">Link Profilo</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100/60 text-sm">
<!-- Riga 1: George Vlad -->
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-4 px-6">
<div class="font-semibold text-slate-950">George Vlad</div>
<div class="text-[11px] text-slate-400 mt-0.5">Protocollo Estetico | Incarichi Online</div>
</td>
<td class="py-4 px-6 text-right font-medium text-slate-900">€7.000,00</td>
<td class="py-4 px-6 text-right">
<span class="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-emerald-50 text-emerald-700">
€2.800,00
</span>
</td>
<td class="py-4 px-6 text-right font-medium text-slate-600">€4.200,00</td>
<td class="py-4 px-6 text-right">
<div class="inline-flex items-center gap-2 justify-end">
<span class="text-xs font-mono text-slate-400 bg-slate-50 px-2 py-1 rounded border border-slate-100">/client/george-vlad-...</span>
<button class="p-1 hover:bg-slate-100 rounded text-slate-400 hover:text-slate-700 transition-colors" title="Copia Link">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
</button>
</div>
</td>
</tr>
<!-- Riga 2: Gian Luca Caruso -->
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-4 px-6">
<div class="font-semibold text-slate-950">Gian Luca Caruso</div>
<div class="text-[11px] text-slate-400 mt-0.5">Caruso Speaker</div>
</td>
<td class="py-4 px-6 text-right font-medium text-slate-900">€5.000,00</td>
<td class="py-4 px-6 text-right">
<span class="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-emerald-50 text-emerald-700">
€2.500,00
</span>
</td>
<td class="py-4 px-6 text-right font-medium text-slate-600">€2.500,00</td>
<td class="py-4 px-6 text-right">
<div class="inline-flex items-center gap-2 justify-end">
<span class="text-xs font-mono text-slate-400 bg-slate-50 px-2 py-1 rounded border border-slate-100">/client/gian-luca-ca...</span>
<button class="p-1 hover:bg-slate-100 rounded text-slate-400 hover:text-slate-700 transition-colors" title="Copia Link">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
</button>
</div>
</td>
</tr>
<!-- Riga 3: Gianfranco Barban -->
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-4 px-6">
<div class="font-semibold text-slate-950">Gianfranco Barban</div>
<div class="text-[11px] text-slate-400 mt-0.5">Teckell</div>
</td>
<td class="py-4 px-6 text-right font-medium text-slate-900">€200,00</td>
<td class="py-4 px-6 text-right text-slate-300 font-medium">—</td>
<td class="py-4 px-6 text-right text-slate-300 font-medium">—</td>
<td class="py-4 px-6 text-right">
<div class="inline-flex items-center gap-2 justify-end">
<span class="text-xs font-mono text-slate-400 bg-slate-50 px-2 py-1 rounded border border-slate-100">/client/gianfranco-b...</span>
<button class="p-1 hover:bg-slate-100 rounded text-slate-400 hover:text-slate-700 transition-colors" title="Copia Link">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
</button>
</div>
</td>
</tr>
<!-- Riga 4: Mario Rossi -->
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-4 px-6">
<div class="font-semibold text-slate-950">Mario Rossi</div>
<div class="text-[11px] text-slate-400 mt-0.5">Rossi Inc</div>
</td>
<td class="py-4 px-6 text-right font-medium text-slate-900">€7.000,00</td>
<td class="py-4 px-6 text-right text-slate-300 font-medium">—</td>
<td class="py-4 px-6 text-right font-medium text-slate-600">€7.000,00</td>
<td class="py-4 px-6 text-right">
<div class="inline-flex items-center gap-2 justify-end">
<span class="text-xs font-mono text-slate-400 bg-slate-50 px-2 py-1 rounded border border-slate-100">/client/mario-rossi-...</span>
<button class="p-1 hover:bg-slate-100 rounded text-slate-400 hover:text-slate-700 transition-colors" title="Copia Link">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Footer Tabella -->
<div class="border-t border-slate-100 px-6 py-4 flex items-center justify-between text-xs text-slate-400">
<span>Mostrando 4 di 4 clienti attivi</span>
</div>
</div>
</main>
</div>
<!-- SCRIPT DI LOGICA (Gestione Sidebar) -->
<script>
const sidebar = document.getElementById('sidebar');
const toggleButton = document.getElementById('sidebar-toggle');
const logoText = document.getElementById('sidebar-logo-text');
const sidebarTexts = document.querySelectorAll('.sidebar-text');
toggleButton.addEventListener('click', () => {
const isCollapsed = sidebar.classList.contains('w-20');
if (isCollapsed) {
sidebar.classList.remove('w-20');
sidebar.classList.add('w-64');
setTimeout(() => {
logoText.classList.remove('opacity-0');
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
}, 150);
} else {
logoText.classList.add('opacity-0');
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
sidebar.classList.remove('w-64');
sidebar.classList.add('w-20');
}
});
</script>
</body>
</html>
+484
View File
@@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rossi Inc — Client Portal</title>
<!-- Google Fonts: Plus Jakarta Sans -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Canvas Confetti per micro-interazioni celebrative -->
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.6.0/dist/confetti.browser.min.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Plus Jakarta Sans', 'sans-serif'],
},
colors: {
brand: {
dark: '#1A463C', /* Verde lusso */
darkHover: '#13342D',
lightBg: '#F8F9FA',
}
}
}
}
}
</script>
<style>
.smooth-transition {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Stili per il Dragging del Kanban */
.dragging {
opacity: 0.4;
transform: scale(0.98);
}
.drag-over-zone {
background-color: rgba(26, 70, 60, 0.03);
border-color: rgba(26, 70, 60, 0.2);
}
</style>
</head>
<body class="bg-brand-lightBg font-sans text-slate-800 antialiased min-h-screen pb-16">
<!-- HEADER PORTALE -->
<header class="bg-white border-b border-slate-100 px-8 py-5 sticky top-0 z-50 shadow-sm flex flex-col md:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-3 w-full md:w-auto">
<span class="text-xs font-bold uppercase tracking-widest text-slate-400">iamcavalli</span>
<span class="text-slate-300">|</span>
<span class="text-xs font-medium text-slate-500">Client Portal</span>
</div>
<!-- Rossi Inc al Centro -->
<div class="text-center">
<h1 class="text-xl font-bold text-slate-900 tracking-tight">Rossi Inc</h1>
</div>
<div class="hidden md:flex items-center gap-2 text-[11px] text-emerald-700 bg-emerald-50 px-3 py-1 rounded-full border border-emerald-100">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
Area Riservata Protetta
</div>
</header>
<!-- STEP PROGRESS TIMELINE UNIFICATA -->
<div class="bg-white border-b border-slate-100 py-8 px-6">
<div class="max-w-[1200px] mx-auto relative">
<!-- Linea di connessione di fondo -->
<div class="absolute top-[15px] left-[5%] right-[5%] h-1 bg-slate-100 rounded-full z-0">
<!-- Riempimento dinamico fisso basato sul progresso reale del progetto -->
<div class="bg-brand-dark h-full rounded-full smooth-transition" style="width: 35%;"></div>
</div>
<!-- Contenitore Nodi Milestone -->
<div class="relative z-10 grid grid-cols-5 gap-4">
<!-- Step 1 -->
<div class="flex flex-col items-center text-center">
<div class="w-8 h-8 rounded-full bg-brand-dark text-white flex items-center justify-center text-xs font-bold shadow-md border-4 border-white smooth-transition">
</div>
<span class="text-[11px] font-bold text-slate-900 mt-2">Step 1</span>
<span class="text-[10px] text-slate-400 font-medium">Onboarding</span>
<span class="text-[9px] font-bold text-emerald-600 mt-1 uppercase">Completed</span>
</div>
<!-- Step 2 -->
<div class="flex flex-col items-center text-center">
<div class="w-8 h-8 rounded-full bg-white text-brand-dark border-4 border-brand-dark flex items-center justify-center text-xs font-bold shadow-md smooth-transition">
2
</div>
<span class="text-[11px] font-bold text-slate-900 mt-2">Step 2</span>
<span class="text-[10px] text-slate-500 font-medium">Analisi</span>
<span class="text-[9px] font-bold text-amber-600 mt-1 uppercase">In Progress</span>
</div>
<!-- Step 3 -->
<div class="flex flex-col items-center text-center">
<div class="w-8 h-8 rounded-full bg-slate-100 text-slate-400 border-4 border-white flex items-center justify-center text-xs font-semibold smooth-transition">
3
</div>
<span class="text-[11px] font-bold text-slate-500 mt-2">Step 3</span>
<span class="text-[10px] text-slate-400 font-medium">Esecuzione</span>
<span class="text-[9px] font-bold text-slate-400 mt-1 uppercase">Pending</span>
</div>
<!-- Step 4 -->
<div class="flex flex-col items-center text-center">
<div class="w-8 h-8 rounded-full bg-slate-100 text-slate-400 border-4 border-white flex items-center justify-center text-xs font-semibold smooth-transition">
4
</div>
<span class="text-[11px] font-bold text-slate-500 mt-2">Step 4</span>
<span class="text-[10px] text-slate-400 font-medium">Raffinamento</span>
<span class="text-[9px] font-bold text-slate-400 mt-1 uppercase">Pending</span>
</div>
<!-- Step 5 -->
<div class="flex flex-col items-center text-center">
<div class="w-8 h-8 rounded-full bg-slate-100 text-slate-400 border-4 border-white flex items-center justify-center text-xs font-semibold smooth-transition">
5
</div>
<span class="text-[11px] font-bold text-slate-500 mt-2">Step 5</span>
<span class="text-[10px] text-slate-400 font-medium">Consegna</span>
<span class="text-[9px] font-bold text-slate-400 mt-1 uppercase">Pending</span>
</div>
</div>
</div>
</div>
<!-- SEZIONE PRINCIPALE CONTENUTO -->
<main class="max-w-[1400px] mx-auto px-6 mt-10 grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- COLONNA DI SINISTRA (Informazioni statiche di riepilogo) -->
<div class="lg:col-span-1 flex flex-col gap-6">
<!-- Offerte Attive -->
<div class="bg-white rounded-xl border border-slate-100 p-5 shadow-sm">
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-4">Offerte Attive</h3>
<div class="border-l-2 border-brand-dark pl-3">
<h4 class="text-sm font-bold text-slate-900">Web Domination</h4>
<div class="flex justify-between items-center text-xs mt-2 text-slate-500">
<span>Prezzo finale</span>
<span class="font-bold text-brand-dark">€7.000,00</span>
</div>
</div>
<div class="mt-4 pt-4 border-t border-slate-100">
<button onclick="toggleAccordion('compreso-content')" class="flex justify-between items-center w-full text-xs font-semibold text-slate-600 hover:text-slate-950">
<span>Cosa è compreso</span>
<svg id="compreso-arrow" class="w-4 h-4 transform transition-transform" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg>
</button>
<div id="compreso-content" class="hidden mt-2 text-[11px] text-slate-500 space-y-1">
<p>• Strategia e architettura Web</p>
<p>• Sviluppo custom Webflow</p>
<p>• Ottimizzazione SEO specialistica</p>
</div>
</div>
</div>
<!-- Pagamenti -->
<div class="bg-white rounded-xl border border-slate-100 p-5 shadow-sm">
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-4">Pagamenti</h3>
<div class="space-y-2">
<div class="flex justify-between items-center p-3 rounded-lg border border-slate-100 text-xs">
<span class="font-semibold text-slate-700">Saldo 50%</span>
<span class="px-2 py-1 rounded text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 uppercase">Da Saldare</span>
</div>
<div class="flex justify-between items-center p-3 rounded-lg border border-slate-100 text-xs">
<span class="font-semibold text-slate-700">Acconto 50%</span>
<span class="px-2 py-1 rounded text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 uppercase">Saldato</span>
</div>
</div>
</div>
<!-- Documenti -->
<div class="bg-white rounded-xl border border-slate-100 p-5 shadow-sm">
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-4">Documenti & File</h3>
<div class="space-y-2">
<a href="#" class="flex items-center justify-between p-3 rounded-lg border border-slate-100 text-xs hover:border-slate-300 transition-colors">
<span class="text-slate-700 font-medium">Brief Progetto</span>
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
</a>
</div>
</div>
</div>
<!-- COLONNA DI DESTRA (Area di lavoro commutabile) -->
<div class="lg:col-span-3 flex flex-col gap-6">
<!-- CONTROLLI DELLA VISTA -->
<div class="flex justify-between items-center">
<h2 id="view-title" class="text-lg font-bold text-slate-900 tracking-tight">Fasi del Progetto</h2>
<div class="bg-slate-200/50 p-1 rounded-lg flex gap-1">
<button id="btn-timeline" class="px-5 py-1.5 bg-white rounded-md text-[11px] font-semibold text-slate-800 shadow-sm transition-all duration-200">
Timeline
</button>
<button id="btn-kanban" class="px-5 py-1.5 rounded-md text-[11px] font-semibold text-slate-500 hover:text-slate-800 transition-all duration-200">
Kanban
</button>
</div>
</div>
<!-- ================= VISTA 1: TIMELINE (Read-only da backend) ================= -->
<div id="view-timeline" class="space-y-6">
<!-- FASE 1: COMPLETATA -->
<div class="bg-white rounded-xl border border-slate-100 p-6 shadow-sm">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-bold text-slate-900">Fase 1 ➔ Onboarding / Setup</h3>
<span class="px-2.5 py-0.5 rounded text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 uppercase">Completata</span>
</div>
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden mb-4">
<div class="bg-emerald-600 h-full" style="width: 100%;"></div>
</div>
<ul class="space-y-2.5 text-xs text-slate-600 pt-2 border-t border-slate-50">
<li class="flex items-center gap-3">
<span class="w-5 h-5 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center text-[10px] font-bold">✓</span>
<span class="line-through text-slate-400">Audit iniziale (UX/UI, struttura, conversione)</span>
</li>
<li class="flex items-center gap-3">
<span class="w-5 h-5 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center text-[10px] font-bold">✓</span>
<span class="line-through text-slate-400">Raccolta e Mappatura Materiali</span>
</li>
<li class="flex items-center gap-3">
<span class="w-5 h-5 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center text-[10px] font-bold">✓</span>
<span class="line-through text-slate-400">Workshop 1° fase</span>
</li>
</ul>
</div>
<!-- FASE 2: IN CORSO -->
<div class="bg-white rounded-xl border border-slate-100 p-6 shadow-sm">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-bold text-slate-900">Fase 2 ➔ Analisi / Strategia</h3>
<span class="px-2.5 py-0.5 rounded text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 uppercase">In corso</span>
</div>
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden mb-4">
<div class="bg-amber-500 h-full" style="width: 30%;"></div>
</div>
<ul class="space-y-2.5 text-xs text-slate-600 pt-2 border-t border-slate-50">
<li class="flex items-center gap-3">
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
<span>Analisi Competitor</span>
</li>
<li class="flex items-center gap-3">
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
<span>Architettura informativa (Sitemap)</span>
</li>
<li class="flex items-center gap-3">
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
<span>Brand Identity - Visual Identity</span>
</li>
</ul>
</div>
<!-- FASE 3: DA INIZIARE -->
<div class="bg-white rounded-xl border border-slate-100 p-6 shadow-sm">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-bold text-slate-900">Fase 3 ➔ Esecuzione / Core</h3>
<span class="px-2.5 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-500 border border-slate-200 uppercase">Da iniziare</span>
</div>
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden mb-4">
<div class="bg-slate-300 h-full" style="width: 0%;"></div>
</div>
<ul class="space-y-2.5 text-xs text-slate-600 pt-2 border-t border-slate-50">
<li class="flex items-center gap-3">
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
<span>Homepage Figma & Webflow development</span>
</li>
<li class="flex items-center gap-3">
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
<span>Configurazione CMS Blog e sezioni dinamiche</span>
</li>
</ul>
</div>
<!-- FASE 4: DA INIZIARE -->
<div class="bg-white rounded-xl border border-slate-100 p-6 shadow-sm">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-bold text-slate-900">Fase 4 ➔ Raffinamento / Extra</h3>
<span class="px-2.5 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-500 border border-slate-200 uppercase">Da iniziare</span>
</div>
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden mb-4">
<div class="bg-slate-300 h-full" style="width: 0%;"></div>
</div>
<ul class="space-y-2.5 text-xs text-slate-600 pt-2 border-t border-slate-50">
<li class="flex items-center gap-3">
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
<span>Ottimizzazione Web Core Vitals</span>
</li>
</ul>
</div>
<!-- FASE 5: DA INIZIARE -->
<div class="bg-white rounded-xl border border-slate-100 p-6 shadow-sm">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-bold text-slate-900">Fase 5 ➔ Onboarding / Consegna</h3>
<span class="px-2.5 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-500 border border-slate-200 uppercase">Da iniziare</span>
</div>
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden mb-4">
<div class="bg-slate-300 h-full" style="width: 0%;"></div>
</div>
<ul class="space-y-2.5 text-xs text-slate-600 pt-2 border-t border-slate-50">
<li class="flex items-center gap-3">
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
<span>Rilascio e puntamento DNS live</span>
</li>
</ul>
</div>
</div>
<!-- ================= VISTA 2: KANBAN ATTIVO & TRASCINABILE ================= -->
<div id="view-kanban" class="hidden grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- COLONNA: DA FARE -->
<div class="kanban-column bg-slate-50/80 border border-slate-100 rounded-xl p-4 flex flex-col gap-4 min-h-[500px]" data-status="todo">
<div class="flex justify-between items-center border-b border-slate-200/60 pb-2">
<span class="text-xs font-bold text-slate-500 uppercase tracking-wider">Da Fare</span>
<span class="count-pill bg-slate-200 text-slate-600 text-[10px] font-bold px-2 py-0.5 rounded-full">3</span>
</div>
<div class="cards-dropzone flex-1 flex flex-col gap-3">
<!-- Card 1 -->
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-1">
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 2</span>
<p class="text-xs font-medium text-slate-800">Analisi Competitor</p>
</div>
<!-- Card 2 -->
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-2">
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 2</span>
<p class="text-xs font-medium text-slate-800">Architettura Informativa (Sitemap)</p>
</div>
<!-- Card 3 -->
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-3">
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 2</span>
<p class="text-xs font-medium text-slate-800">Brand Identity - Visual Identity</p>
</div>
</div>
</div>
<!-- COLONNA: IN CORSO -->
<div class="kanban-column bg-slate-50/80 border border-slate-100 rounded-xl p-4 flex flex-col gap-4 min-h-[500px]" data-status="inprogress">
<div class="flex justify-between items-center border-b border-slate-200/60 pb-2">
<span class="text-xs font-bold text-slate-500 uppercase tracking-wider">In Corso</span>
<span class="count-pill bg-slate-200 text-slate-600 text-[10px] font-bold px-2 py-0.5 rounded-full">1</span>
</div>
<div class="cards-dropzone flex-1 flex flex-col gap-3">
<!-- Card 4 -->
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-4">
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 2</span>
<p class="text-xs font-medium text-slate-800">Direzione Creativa & Moodboard</p>
</div>
</div>
</div>
<!-- COLONNA: FATTO -->
<div class="kanban-column bg-slate-50/80 border border-slate-100 rounded-xl p-4 flex flex-col gap-4 min-h-[500px]" data-status="done">
<div class="flex justify-between items-center border-b border-slate-200/60 pb-2">
<span class="text-xs font-bold text-slate-500 uppercase tracking-wider">Fatto</span>
<span class="count-pill bg-slate-200 text-slate-600 text-[10px] font-bold px-2 py-0.5 rounded-full">2</span>
</div>
<div class="cards-dropzone flex-1 flex flex-col gap-3">
<!-- Card 5 -->
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-5">
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 1</span>
<p class="text-xs font-medium text-slate-800">Kickoff strategico iniziale</p>
</div>
<!-- Card 6 -->
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-6">
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 1</span>
<p class="text-xs font-medium text-slate-800">Setup Repository & Strumenti</p>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="text-center text-xs text-slate-400 py-10">
Questa è la tua dashboard privata — non condividere il link.
</footer>
<!-- SCRIPT DI LOGICA INTERATTIVA -->
<script>
// Accordion Control
function toggleAccordion(id) {
const el = document.getElementById(id);
const arrow = document.getElementById('compreso-arrow');
el.classList.toggle('hidden');
arrow.classList.toggle('rotate-180');
}
// Swapping delle viste (Timeline / Kanban)
const btnTimeline = document.getElementById('btn-timeline');
const btnKanban = document.getElementById('btn-kanban');
const viewTimeline = document.getElementById('view-timeline');
const viewKanban = document.getElementById('view-kanban');
btnTimeline.addEventListener('click', () => {
viewKanban.classList.add('hidden');
viewTimeline.classList.remove('hidden');
btnTimeline.className = "px-5 py-1.5 bg-white rounded-md text-[11px] font-semibold text-slate-800 shadow-sm transition-all duration-200";
btnKanban.className = "px-5 py-1.5 rounded-md text-[11px] font-semibold text-slate-500 hover:text-slate-800 transition-all duration-200";
});
btnKanban.addEventListener('click', () => {
viewTimeline.classList.add('hidden');
viewKanban.classList.remove('hidden');
btnKanban.className = "px-5 py-1.5 bg-white rounded-md text-[11px] font-semibold text-slate-800 shadow-sm transition-all duration-200";
btnTimeline.className = "px-5 py-1.5 rounded-md text-[11px] font-semibold text-slate-500 hover:text-slate-800 transition-all duration-200";
// Inizializza Drag & Drop quando viene aperta la vista Kanban
initializeKanbanDragAndDrop();
});
// Logica Drag & Drop per il Kanban
function initializeKanbanDragAndDrop() {
const cards = document.querySelectorAll('.kanban-card');
const columns = document.querySelectorAll('.kanban-column');
cards.forEach(card => {
card.addEventListener('dragstart', () => {
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
updateCounts();
});
});
columns.forEach(column => {
const dropzone = column.querySelector('.cards-dropzone');
column.addEventListener('dragover', (e) => {
e.preventDefault();
column.classList.add('drag-over-zone');
});
column.addEventListener('dragleave', () => {
column.classList.remove('drag-over-zone');
});
column.addEventListener('drop', (e) => {
e.preventDefault();
column.classList.remove('drag-over-zone');
const draggingCard = document.querySelector('.dragging');
if (draggingCard) {
dropzone.appendChild(draggingCard);
// Se lanciato nella colonna "Fatto" (done), triggera una micro-celebrazione
if (column.getAttribute('data-status') === 'done' && typeof confetti === 'function') {
confetti({
particleCount: 50,
spread: 50,
origin: { y: 0.8 }
});
}
}
updateCounts();
});
});
}
// Aggiornamento dei contatori delle colonne del Kanban
function updateCounts() {
const columns = document.querySelectorAll('.kanban-column');
columns.forEach(col => {
const countPill = col.querySelector('.count-pill');
const count = col.querySelector('.cards-dropzone').children.length;
countPill.innerText = count;
});
}
</script>
</body>
</html>
+405
View File
@@ -0,0 +1,405 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lead Pipeline — Luxury Redesign</title>
<!-- Google Fonts: Plus Jakarta Sans -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Plus Jakarta Sans', 'sans-serif'],
},
colors: {
brand: {
dark: '#1A463C', /* Verde scuro richiesto */
darkHover: '#13342D', /* Variante scura per hover */
active: 'rgba(255, 255, 255, 0.08)',
bg: '#F8F9FA',
}
}
}
}
}
</script>
<style>
/* Transizione fluida per l'apertura/chiusura della sidebar */
.sidebar-transition {
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Stile per l'effetto di trascinamento nel Kanban */
.dragging {
opacity: 0.5;
transform: scale(0.98);
}
.drag-over {
background-color: rgba(26, 70, 60, 0.03);
border-color: rgba(26, 70, 60, 0.2);
}
</style>
</head>
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
<!-- SIDEBAR -->
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
<div class="overflow-hidden">
<!-- Logo e Intestazione -->
<div class="mb-10 px-2 flex items-center justify-between">
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
</div>
<!-- Navigazione Principale -->
<nav class="space-y-1">
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
</a>
</nav>
</div>
<!-- Area Inferiore Sidebar -->
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
<span class="sidebar-text transition-opacity duration-300">Tema</span>
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
</button>
</div>
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
<span class="sidebar-text transition-opacity duration-300">Esci</span>
</a>
</div>
</aside>
<!-- SEZIONE PRINCIPALE -->
<div class="flex-1 flex flex-col min-w-0">
<!-- BARRA DI INTESTAZIONE SUPERIORE (Contiene i controlli globali) -->
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
<div class="flex items-center gap-4">
<!-- Pulsante Minimal per Aprire/Chiudere la Sidebar -->
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
</button>
<span class="text-xs text-slate-400 font-medium">Area di Lavoro</span>
</div>
<div class="flex items-center gap-4">
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Demo Account</span>
</div>
</header>
<!-- AREA DEL CONTENUTO DINAMICO -->
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-8 overflow-y-auto">
<!-- INTESTAZIONE SEZIONE -->
<div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div>
<h1 class="text-2xl font-semibold text-slate-900 tracking-tight">Lead Pipeline</h1>
<p class="text-xs text-slate-400 mt-1">Gestisci e monitora i tuoi contatti commerciali</p>
</div>
<!-- Pulsante Nuovo Lead -->
<button class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-medium tracking-wide px-5 py-3 rounded-lg flex items-center gap-2 transition-all duration-200 shadow-sm self-start sm:self-auto">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
Nuovo Lead
</button>
</div>
<!-- AZIONI: BARRA DI RICERCA & SELETTORE VISTA -->
<div class="flex flex-col md:flex-row justify-between md:items-center gap-4">
<!-- Barra di ricerca -->
<div class="relative w-full md:w-80">
<span class="absolute inset-y-0 left-0 flex items-center pl-3.5 pointer-events-none text-slate-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</span>
<input
type="text"
placeholder="Cerca lead..."
class="w-full pl-10 pr-4 py-2.5 bg-white text-sm text-slate-800 placeholder-slate-400 border border-slate-200/80 rounded-lg focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all duration-200"
/>
</div>
<!-- Selettore Vista (Lista vs Kanban) -->
<div class="bg-slate-200/50 p-1 rounded-lg flex gap-1 self-start md:self-auto">
<button id="btn-view-list" class="px-5 py-1.5 rounded-md text-xs font-medium bg-white text-slate-800 shadow-sm transition-all duration-200">
Lista
</button>
<button id="btn-view-kanban" class="px-5 py-1.5 rounded-md text-xs font-medium text-slate-500 hover:text-slate-800 transition-all duration-200">
Kanban
</button>
</div>
</div>
<!-- VISTA 1: TABELLA (Default) -->
<div id="view-list" class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden transition-opacity duration-300">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-slate-100/80 bg-slate-50/50 text-slate-400 text-[11px] font-semibold uppercase tracking-wider">
<th class="py-4 px-6">Nome</th>
<th class="py-4 px-6">Email</th>
<th class="py-4 px-6">Telefono</th>
<th class="py-4 px-6">Azienda</th>
<th class="py-4 px-6 text-center">Stato</th>
<th class="py-4 px-6">Prossima Azione</th>
<th class="py-4 px-6 text-center">Tag</th>
<th class="py-4 px-6 text-right">Azioni</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100/60 text-sm">
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-4 px-6 font-medium text-slate-900">Nome Cognome</td>
<td class="py-4 px-6 text-slate-500 text-xs">test@gmail.com</td>
<td class="py-4 px-6 text-slate-500 text-xs font-mono">+39 444 3322111</td>
<td class="py-4 px-6 text-slate-600">Azienda Srl</td>
<td class="py-4 px-6 text-center">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide bg-purple-50 text-purple-600 border border-purple-100 uppercase">
qualified
</span>
</td>
<td class="py-4 px-6 text-slate-400">—</td>
<td class="py-4 px-6 text-center">
<button class="inline-flex items-center justify-center w-6 h-6 rounded-full border border-dashed border-slate-200 text-slate-400 hover:text-slate-600 hover:border-slate-400 transition-all duration-200">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
</button>
</td>
<td class="py-4 px-6 text-right">
<a href="#" class="text-xs font-medium text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-1 group transition-colors">
Dettagli
<svg class="w-3 h-3 transform group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24"><path d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
</a>
</td>
</tr>
</tbody>
</table>
</div>
<div class="border-t border-slate-100 px-6 py-4 flex items-center justify-between text-xs text-slate-400">
<span>Mostrando 1 di 1 lead</span>
</div>
</div>
<!-- VISTA 2: KANBAN (Nascosta all'avvio) -->
<div id="view-kanban" class="hidden grid grid-cols-1 md:grid-cols-4 gap-6 transition-opacity duration-300">
<!-- Colonna 1: Nuovo -->
<div class="kanban-column bg-slate-50/75 rounded-xl border border-slate-100 p-4 flex flex-col gap-4 min-h-[450px]" data-status="new">
<div class="flex items-center justify-between border-b border-slate-100 pb-2">
<span class="text-xs font-semibold uppercase tracking-wider text-slate-500">Nuovo</span>
<span class="text-xs font-medium bg-slate-200/60 text-slate-600 px-2 py-0.5 rounded-full count">0</span>
</div>
<div class="cards-container flex-1 flex flex-col gap-3">
<!-- Esempio di lead spostabile -->
<div id="lead-card-2" class="kanban-card bg-white p-4 rounded-lg border border-slate-200/80 shadow-sm cursor-grab active:cursor-grabbing hover:border-brand-dark/30 hover:shadow-md transition-all duration-200" draggable="true">
<div class="flex justify-between items-start gap-2 mb-2">
<h4 class="text-xs font-semibold text-slate-800">Marco Rossi</h4>
<span class="text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100 px-1.5 py-0.5 rounded uppercase">New</span>
</div>
<p class="text-[11px] text-slate-400 mb-3">rossi@azienda.it</p>
<div class="flex justify-between items-center text-[10px] text-slate-500">
<span class="font-medium">Rossi Consulting</span>
<span>Tag +</span>
</div>
</div>
</div>
</div>
<!-- Colonna 2: Contattato -->
<div class="kanban-column bg-slate-50/75 rounded-xl border border-slate-100 p-4 flex flex-col gap-4 min-h-[450px]" data-status="contacted">
<div class="flex items-center justify-between border-b border-slate-100 pb-2">
<span class="text-xs font-semibold uppercase tracking-wider text-slate-500">Contattato</span>
<span class="text-xs font-medium bg-slate-200/60 text-slate-600 px-2 py-0.5 rounded-full count">0</span>
</div>
<div class="cards-container flex-1 flex flex-col gap-3"></div>
</div>
<!-- Colonna 3: Qualificato -->
<div class="kanban-column bg-slate-50/75 rounded-xl border border-slate-100 p-4 flex flex-col gap-4 min-h-[450px]" data-status="qualified">
<div class="flex items-center justify-between border-b border-slate-100 pb-2">
<span class="text-xs font-semibold uppercase tracking-wider text-slate-500">Qualificato</span>
<span class="text-xs font-medium bg-slate-200/60 text-slate-600 px-2 py-0.5 rounded-full count">1</span>
</div>
<div class="cards-container flex-1 flex flex-col gap-3">
<!-- Lead proveniente dalla lista originale -->
<div id="lead-card-1" class="kanban-card bg-white p-4 rounded-lg border border-slate-200/80 shadow-sm cursor-grab active:cursor-grabbing hover:border-brand-dark/30 hover:shadow-md transition-all duration-200" draggable="true">
<div class="flex justify-between items-start gap-2 mb-2">
<h4 class="text-xs font-semibold text-slate-800">Nome Cognome</h4>
<span class="text-[9px] font-semibold bg-purple-50 text-purple-600 border border-purple-100 px-1.5 py-0.5 rounded uppercase">Qualified</span>
</div>
<p class="text-[11px] text-slate-400 mb-3">test@gmail.com</p>
<div class="flex justify-between items-center text-[10px] text-slate-500">
<span class="font-medium">Azienda Srl</span>
<span>Tag +</span>
</div>
</div>
</div>
</div>
<!-- Colonna 4: In Trattativa -->
<div class="kanban-column bg-slate-50/75 rounded-xl border border-slate-100 p-4 flex flex-col gap-4 min-h-[450px]" data-status="negotiation">
<div class="flex items-center justify-between border-b border-slate-100 pb-2">
<span class="text-xs font-semibold uppercase tracking-wider text-slate-500">Trattativa</span>
<span class="text-xs font-medium bg-slate-200/60 text-slate-600 px-2 py-0.5 rounded-full count">0</span>
</div>
<div class="cards-container flex-1 flex flex-col gap-3"></div>
</div>
</div>
</main>
</div>
<!-- SCRIPT LOGICA (Gestione Sidebar e Kanban) -->
<script>
// --- GESTIONE SIDEBAR COLLASSABILE ---
const sidebar = document.getElementById('sidebar');
const toggleButton = document.getElementById('sidebar-toggle');
const logoText = document.getElementById('sidebar-logo-text');
const sidebarTexts = document.querySelectorAll('.sidebar-text');
toggleButton.addEventListener('click', () => {
// Verifica lo stato attuale
const isCollapsed = sidebar.classList.contains('w-20');
if (isCollapsed) {
// Espandi
sidebar.classList.remove('w-20');
sidebar.classList.add('w-64');
setTimeout(() => {
logoText.classList.remove('opacity-0');
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
}, 150);
} else {
// Collassa
logoText.classList.add('opacity-0');
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
sidebar.classList.remove('w-64');
sidebar.classList.add('w-20');
}
});
// --- INTERRUTTORE DI VISTA (LISTA / KANBAN) ---
const btnViewList = document.getElementById('btn-view-list');
const btnViewKanban = document.getElementById('btn-view-kanban');
const viewList = document.getElementById('view-list');
const viewKanban = document.getElementById('view-kanban');
btnViewList.addEventListener('click', () => {
// Attiva vista Lista
viewKanban.classList.add('hidden');
viewList.classList.remove('hidden');
// Regola stili pulsanti
btnViewList.className = "px-5 py-1.5 rounded-md text-xs font-medium bg-white text-slate-800 shadow-sm transition-all duration-200";
btnViewKanban.className = "px-5 py-1.5 rounded-md text-xs font-medium text-slate-500 hover:text-slate-800 transition-all duration-200";
});
btnViewKanban.addEventListener('click', () => {
// Attiva vista Kanban
viewList.classList.add('hidden');
viewKanban.classList.remove('hidden');
// Regola stili pulsanti
btnViewKanban.className = "px-5 py-1.5 rounded-md text-xs font-medium bg-white text-slate-800 shadow-sm transition-all duration-200";
btnViewList.className = "px-5 py-1.5 rounded-md text-xs font-medium text-slate-500 hover:text-slate-800 transition-all duration-200";
});
// --- FUNZIONALITÀ DRAG AND DROP KANBAN ---
const cards = document.querySelectorAll('.kanban-card');
const columns = document.querySelectorAll('.kanban-column');
cards.forEach(card => {
card.addEventListener('dragstart', () => {
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
updateColumnCounts();
});
});
columns.forEach(column => {
const container = column.querySelector('.cards-container');
column.addEventListener('dragover', (e) => {
e.preventDefault();
column.classList.add('drag-over');
});
column.addEventListener('dragleave', () => {
column.classList.remove('drag-over');
});
column.addEventListener('drop', (e) => {
e.preventDefault();
column.classList.remove('drag-over');
const draggingCard = document.querySelector('.dragging');
if (draggingCard) {
container.appendChild(draggingCard);
// Cambia visivamente il badge interno dello stato in base alla destinazione
const badge = draggingCard.querySelector('span');
const status = column.getAttribute('data-status');
if (status === 'new') {
badge.innerText = 'New';
badge.className = 'text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100 px-1.5 py-0.5 rounded uppercase';
} else if (status === 'contacted') {
badge.innerText = 'Contacted';
badge.className = 'text-[9px] font-semibold bg-blue-50 text-blue-600 border border-blue-100 px-1.5 py-0.5 rounded uppercase';
} else if (status === 'qualified') {
badge.innerText = 'Qualified';
badge.className = 'text-[9px] font-semibold bg-purple-50 text-purple-600 border border-purple-100 px-1.5 py-0.5 rounded uppercase';
} else if (status === 'negotiation') {
badge.innerText = 'In Trattativa';
badge.className = 'text-[9px] font-semibold bg-amber-50 text-amber-600 border border-amber-100 px-1.5 py-0.5 rounded uppercase';
}
}
});
});
// Funzione d'appoggio per aggiornare il conteggio numerico nelle colonne
function updateColumnCounts() {
columns.forEach(column => {
const countBadge = column.querySelector('.count');
const cardCount = column.querySelector('.cards-container').children.length;
countBadge.innerText = cardCount;
});
}
// Esegui inizialmente per impostare i corretti conteggi
updateColumnCounts();
</script>
</body>
</html>
+366
View File
@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offerte — Luxury Admin CRM</title>
<!-- Google Fonts: Plus Jakarta Sans -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Plus Jakarta Sans', 'sans-serif'],
},
colors: {
brand: {
dark: '#1A463C', /* Verde scuro richiesto */
darkHover: '#13342D', /* Variante scura per hover */
active: 'rgba(255, 255, 255, 0.08)',
bg: '#F8F9FA',
}
}
}
}
}
</script>
<style>
.sidebar-transition {
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>
</head>
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
<!-- SIDEBAR -->
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
<div class="overflow-hidden">
<!-- Logo e Intestazione -->
<div class="mb-10 px-2 flex items-center justify-between">
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
</div>
<!-- Navigazione Principale -->
<nav class="space-y-1">
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
</a>
<!-- Stato Attivo su Offerte -->
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
</a>
</nav>
</div>
<!-- Area Inferiore Sidebar -->
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
<span class="sidebar-text transition-opacity duration-300">Tema</span>
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
</button>
</div>
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
<span class="sidebar-text transition-opacity duration-300">Esci</span>
</a>
</div>
</aside>
<!-- SEZIONE PRINCIPALE -->
<div class="flex-1 flex flex-col min-w-0">
<!-- BARRA DI INTESTAZIONE SUPERIORE -->
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
<div class="flex items-center gap-4">
<!-- Pulsante Apri/Chiudi Sidebar -->
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
</button>
<span class="text-xs text-slate-400 font-medium">Area di Lavoro</span>
</div>
<div class="flex items-center gap-4">
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Demo Account</span>
</div>
</header>
<!-- AREA DEL CONTENUTO -->
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-8 overflow-y-auto">
<!-- INTESTAZIONE SEZIONE -->
<div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div>
<h1 class="text-2xl font-semibold text-slate-900 tracking-tight">Offerte</h1>
<p class="text-xs text-slate-400 mt-1">Configura e gestisci i pacchetti di offerta commerciali</p>
</div>
<!-- Bottone Nuova Offerta -->
<button class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-medium tracking-wide px-5 py-3 rounded-lg flex items-center gap-2 transition-all duration-200 shadow-sm self-start sm:self-auto">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
Nuova Offerta
</button>
</div>
<!-- BARRA FILTRI -->
<div class="flex flex-col lg:flex-row justify-between lg:items-center gap-4">
<!-- Selettori Categorie di Offerta -->
<div class="flex flex-wrap gap-2">
<button class="px-4 py-2 text-xs font-medium bg-brand-dark text-white rounded-lg transition-colors">
Tutti
</button>
<button class="px-4 py-2 text-xs font-medium bg-white text-slate-600 hover:text-slate-900 hover:bg-slate-100/70 border border-slate-200/60 rounded-lg transition-all duration-200">
Entry Offer
</button>
<button class="px-4 py-2 text-xs font-medium bg-white text-slate-600 hover:text-slate-900 hover:bg-slate-100/70 border border-slate-200/60 rounded-lg transition-all duration-200">
Retainer Offer
</button>
<button class="px-4 py-2 text-xs font-medium bg-white text-slate-600 hover:text-slate-900 hover:bg-slate-100/70 border border-slate-200/60 rounded-lg transition-all duration-200">
Signature Offer
</button>
</div>
<!-- Checkbox Opzione Archiviati -->
<label class="flex items-center gap-2.5 cursor-pointer group text-xs text-slate-500 hover:text-slate-800 transition-colors">
<input type="checkbox" class="w-4 h-4 rounded text-brand-dark border-slate-300 focus:ring-brand-dark focus:ring-opacity-25 transition-all" />
<span class="select-none font-medium">Mostra offerte archiviate</span>
</label>
</div>
<!-- GRID CARDS DELLE OFFERTE -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Card 1: Mantenimento -->
<div class="bg-white p-6 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] hover:shadow-[0_8px_30px_rgba(0,0,0,0.02)] transition-all duration-200 flex flex-col justify-between">
<div>
<div class="flex justify-between items-start gap-4 mb-4">
<div>
<h3 class="text-sm font-semibold text-slate-900">Mantenimento</h3>
<p class="text-[11px] text-slate-400 mt-0.5">(nei clienti già attivi)</p>
</div>
<span class="px-2 py-1 rounded text-[9px] font-semibold bg-blue-50 text-blue-600 border border-blue-100 uppercase tracking-wider">
Retainer Offer
</span>
</div>
<!-- Listino Tabellare Opzioni -->
<div class="border border-slate-100 rounded-lg overflow-hidden my-6">
<table class="w-full text-[11px]">
<thead>
<tr class="bg-slate-50 border-b border-slate-100 text-slate-400 font-semibold uppercase">
<th class="py-2 px-3 text-left">Tier</th>
<th class="py-2 px-3 text-right">Servizi</th>
<th class="py-2 px-3 text-right">Pubblico</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 text-slate-600 font-mono">
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">A</td>
<td class="py-2 px-3 text-right">€200,00</td>
<td class="py-2 px-3 text-right text-slate-400">€200,00</td>
</tr>
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">B</td>
<td class="py-2 px-3 text-right">€300,00</td>
<td class="py-2 px-3 text-right text-slate-400">€300,00</td>
</tr>
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">C</td>
<td class="py-2 px-3 text-right">€400,00</td>
<td class="py-2 px-3 text-right text-slate-400">€400,00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Card 2: Sblocca Business -->
<div class="bg-white p-6 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] hover:shadow-[0_8px_30px_rgba(0,0,0,0.02)] transition-all duration-200 flex flex-col justify-between">
<div>
<div class="flex justify-between items-start gap-4 mb-4">
<div>
<h3 class="text-sm font-semibold text-slate-900">Sblocca Business</h3>
<p class="text-[11px] text-slate-400 mt-0.5">Avviamento strategico</p>
</div>
<span class="px-2 py-1 rounded text-[9px] font-semibold bg-purple-50 text-purple-600 border border-purple-100 uppercase tracking-wider">
Entry Offer
</span>
</div>
<!-- Listino Tabellare Opzioni -->
<div class="border border-slate-100 rounded-lg overflow-hidden my-6">
<table class="w-full text-[11px]">
<thead>
<tr class="bg-slate-50 border-b border-slate-100 text-slate-400 font-semibold uppercase">
<th class="py-2 px-3 text-left">Tier</th>
<th class="py-2 px-3 text-right">Servizi</th>
<th class="py-2 px-3 text-right">Pubblico</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 text-slate-600 font-mono">
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">A</td>
<td class="py-2 px-3 text-right">€2.600,00</td>
<td class="py-2 px-3 text-right text-slate-400">€400,00</td>
</tr>
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">B</td>
<td class="py-2 px-3 text-right">€3.300,00</td>
<td class="py-2 px-3 text-right text-slate-400">€600,00</td>
</tr>
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">C</td>
<td class="py-2 px-3 text-right">€3.900,00</td>
<td class="py-2 px-3 text-right text-slate-400">€1.200,00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Card 3: Web Domination -->
<div class="bg-white p-6 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] hover:shadow-[0_8px_30px_rgba(0,0,0,0.02)] transition-all duration-200 flex flex-col justify-between">
<div>
<div class="flex justify-between items-start gap-4 mb-4">
<div>
<h3 class="text-sm font-semibold text-slate-900">Web Domination</h3>
<p class="text-[11px] text-slate-400 mt-0.5">Soluzione enterprise omnicanale</p>
</div>
<span class="px-2 py-1 rounded text-[9px] font-semibold bg-amber-50 text-amber-700 border border-amber-100 uppercase tracking-wider">
Signature Offer
</span>
</div>
<!-- Listino Tabellare Opzioni -->
<div class="border border-slate-100 rounded-lg overflow-hidden my-6">
<table class="w-full text-[11px]">
<thead>
<tr class="bg-slate-50 border-b border-slate-100 text-slate-400 font-semibold uppercase">
<th class="py-2 px-3 text-left">Tier</th>
<th class="py-2 px-3 text-right">Servizi</th>
<th class="py-2 px-3 text-right">Pubblico</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 text-slate-600 font-mono">
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">A</td>
<td class="py-2 px-3 text-right">€9.800,00</td>
<td class="py-2 px-3 text-right text-slate-400">€6.000,00</td>
</tr>
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">B</td>
<td class="py-2 px-3 text-right">€20.250,00</td>
<td class="py-2 px-3 text-right text-slate-400">€9.000,00</td>
</tr>
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">C</td>
<td class="py-2 px-3 text-right">€25.250,00</td>
<td class="py-2 px-3 text-right text-slate-400">€12.000,00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Card 4: Business in Banca -->
<div class="bg-white p-6 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] hover:shadow-[0_8px_30px_rgba(0,0,0,0.02)] transition-all duration-200 flex flex-col justify-between">
<div>
<div class="flex justify-between items-start gap-4 mb-4">
<div>
<h3 class="text-sm font-semibold text-slate-900">Business in Banca</h3>
<p class="text-[11px] text-slate-400 mt-0.5">Controllo tesoreria integrato</p>
</div>
<span class="px-2 py-1 rounded text-[9px] font-semibold bg-blue-50 text-blue-600 border border-blue-100 uppercase tracking-wider">
Retainer Offer
</span>
</div>
<!-- Listino Tabellare Opzioni -->
<div class="border border-slate-100 rounded-lg overflow-hidden my-6">
<table class="w-full text-[11px]">
<thead>
<tr class="bg-slate-50 border-b border-slate-100 text-slate-400 font-semibold uppercase">
<th class="py-2 px-3 text-left">Tier</th>
<th class="py-2 px-3 text-right">Servizi</th>
<th class="py-2 px-3 text-right">Pubblico</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 text-slate-600 font-mono">
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">A</td>
<td class="py-2 px-3 text-right">€200,00</td>
<td class="py-2 px-3 text-right text-slate-400">€200,00</td>
</tr>
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">B</td>
<td class="py-2 px-3 text-right">€400,00</td>
<td class="py-2 px-3 text-right text-slate-400">€300,00</td>
</tr>
<tr>
<td class="py-2 px-3 font-semibold text-slate-800">C</td>
<td class="py-2 px-3 text-right">€900,00</td>
<td class="py-2 px-3 text-right text-slate-400">€500,00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- SCRIPT DI LOGICA (Gestione Sidebar) -->
<script>
const sidebar = document.getElementById('sidebar');
const toggleButton = document.getElementById('sidebar-toggle');
const logoText = document.getElementById('sidebar-logo-text');
const sidebarTexts = document.querySelectorAll('.sidebar-text');
toggleButton.addEventListener('click', () => {
const isCollapsed = sidebar.classList.contains('w-20');
if (isCollapsed) {
sidebar.classList.remove('w-20');
sidebar.classList.add('w-64');
setTimeout(() => {
logoText.classList.remove('opacity-0');
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
}, 150);
} else {
logoText.classList.add('opacity-0');
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
sidebar.classList.remove('w-64');
sidebar.classList.add('w-20');
}
});
</script>
</body>
</html>
+238
View File
@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Preventivi — Luxury Admin CRM</title>
<!-- Google Fonts: Plus Jakarta Sans -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Plus Jakarta Sans', 'sans-serif'],
},
colors: {
brand: {
dark: '#1A463C', /* Verde scuro richiesto */
darkHover: '#13342D', /* Variante scura per hover */
active: 'rgba(255, 255, 255, 0.08)',
bg: '#F8F9FA',
}
}
}
}
}
</script>
<style>
.sidebar-transition {
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>
</head>
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
<!-- SIDEBAR -->
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
<div class="overflow-hidden">
<!-- Logo e Intestazione -->
<div class="mb-10 px-2 flex items-center justify-between">
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
</div>
<!-- Navigazione Principale -->
<nav class="space-y-1">
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
</a>
<!-- Stato Attivo su Preventivi -->
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
</a>
</nav>
</div>
<!-- Area Inferiore Sidebar -->
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
<span class="sidebar-text transition-opacity duration-300">Tema</span>
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
</button>
</div>
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
<span class="sidebar-text transition-opacity duration-300">Esci</span>
</a>
</div>
</aside>
<!-- SEZIONE PRINCIPALE -->
<div class="flex-1 flex flex-col min-w-0">
<!-- BARRA DI INTESTAZIONE SUPERIORE -->
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
<div class="flex items-center gap-4">
<!-- Pulsante Apri/Chiudi Sidebar -->
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
</button>
<span class="text-xs text-slate-400 font-medium">Area di Lavoro</span>
</div>
<div class="flex items-center gap-4">
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Demo Account</span>
</div>
</header>
<!-- AREA DEL CONTENUTO -->
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-8 overflow-y-auto">
<!-- INTESTAZIONE SEZIONE -->
<div class="flex flex-col sm:flex-row justify-between sm:items-start gap-4">
<div>
<h1 class="text-2xl font-semibold text-slate-900 tracking-tight">Preventivi</h1>
<p class="text-xs text-slate-400 mt-1">Preventivi generati con AI e pubblicati ai clienti</p>
</div>
<!-- Pulsante Genera -->
<button class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-medium tracking-wide px-5 py-3 rounded-lg flex items-center gap-2 transition-all duration-200 shadow-sm self-start sm:self-auto">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
Genera preventivo
</button>
</div>
<!-- TABELLA PREVENTIVI -->
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-slate-100/80 bg-slate-50/50 text-slate-400 text-[11px] font-semibold uppercase tracking-wider">
<th class="py-4 px-6 w-1/3">Titolo</th>
<th class="py-4 px-6">Lead / Cliente</th>
<th class="py-4 px-6">Offerta</th>
<th class="py-4 px-6 text-center">Stato</th>
<th class="py-4 px-6">Data</th>
<th class="py-4 px-6 text-right">Azione</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100/60 text-sm">
<!-- Riga 1: Pubblicato -->
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-4 px-6 font-semibold text-slate-900">
Rossi Inc × Entry Offer
</td>
<td class="py-4 px-6 text-slate-600">
Mario Rossi
</td>
<td class="py-4 px-6 text-slate-500 font-medium">
Entry Offer
</td>
<td class="py-4 px-6 text-center">
<!-- Badge Stato: Pubblicato -->
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide bg-sky-50 text-sky-700 border border-sky-100 uppercase">
Pubblicato
</span>
</td>
<td class="py-4 px-6 text-slate-500 text-xs font-mono">
26/06/2026
</td>
<td class="py-4 px-6 text-right">
<a href="#" class="text-xs font-medium text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-1.5 group transition-colors">
Apri
<svg class="w-3.5 h-3.5 transform group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24"><path d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/></svg>
</a>
</td>
</tr>
<!-- Riga 2: Accettato -->
<tr class="hover:bg-slate-50/30 transition-all duration-150">
<td class="py-4 px-6 font-semibold text-slate-900">
Rossi Inc × Entry Offer
</td>
<td class="py-4 px-6 text-slate-600">
Mario Rossi
</td>
<td class="py-4 px-6 text-slate-500 font-medium">
Entry Offer
</td>
<td class="py-4 px-6 text-center">
<!-- Badge Stato: Accettato -->
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide bg-emerald-50 text-emerald-700 border border-emerald-100 uppercase">
Accettato
</span>
</td>
<td class="py-4 px-6 text-slate-500 text-xs font-mono">
20/06/2026
</td>
<td class="py-4 px-6 text-right">
<a href="#" class="text-xs font-medium text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-1.5 group transition-colors">
Apri
<svg class="w-3.5 h-3.5 transform group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24"><path d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/></svg>
</a>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Footer Tabella -->
<div class="border-t border-slate-100 px-6 py-4 flex items-center justify-between text-xs text-slate-400">
<span>Mostrando 2 di 2 preventivi generati</span>
</div>
</div>
</main>
</div>
<!-- SCRIPT DI LOGICA (Gestione Sidebar) -->
<script>
const sidebar = document.getElementById('sidebar');
const toggleButton = document.getElementById('sidebar-toggle');
const logoText = document.getElementById('sidebar-logo-text');
const sidebarTexts = document.querySelectorAll('.sidebar-text');
toggleButton.addEventListener('click', () => {
const isCollapsed = sidebar.classList.contains('w-20');
if (isCollapsed) {
sidebar.classList.remove('w-20');
sidebar.classList.add('w-64');
setTimeout(() => {
logoText.classList.remove('opacity-0');
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
}, 150);
} else {
logoText.classList.add('opacity-0');
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
sidebar.classList.remove('w-64');
sidebar.classList.add('w-20');
}
});
</script>
</body>
</html>
+73 -1
View File
@@ -8,6 +8,7 @@
"name": "clienthub",
"version": "0.1.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.105.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -32,6 +33,7 @@
"react-dom": "19.2.4",
"react-hook-form": "^7.75.0",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -59,6 +61,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.105.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.105.0.tgz",
"integrity": "sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -3802,6 +3825,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -6365,6 +6394,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -7342,6 +7377,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -9038,6 +9086,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -9251,9 +9309,17 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
@@ -9329,6 +9395,12 @@
"node": ">=8.0"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+2
View File
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.105.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -33,6 +34,7 @@
"react-dom": "19.2.4",
"react-hook-form": "^7.75.0",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3"
},
"devDependencies": {
+119 -14
View File
@@ -1,11 +1,59 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
import { ServiceTable, DEFAULT_COL_ORDER } from "@/components/admin/catalog/ServiceTable";
import type { ColumnKey } from "@/components/admin/catalog/ServiceTable";
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
function loadColOrder(): ColumnKey[] {
if (typeof window === "undefined") return DEFAULT_COL_ORDER;
try {
const saved = localStorage.getItem("catalog_col_order");
if (saved) {
const parsed = JSON.parse(saved) as ColumnKey[];
// Validate: must contain exactly the same keys
if (
parsed.length === DEFAULT_COL_ORDER.length &&
DEFAULT_COL_ORDER.every((k) => parsed.includes(k))
) {
return parsed;
}
}
} catch {}
return DEFAULT_COL_ORDER;
}
function buildSortFn(
sortKey: ColumnKey | null,
sortDir: "asc" | "desc"
): (a: ServiceWithTags, b: ServiceWithTags) => number {
if (!sortKey) return () => 0;
const dir = sortDir === "asc" ? 1 : -1;
return (a, b) => {
switch (sortKey) {
case "nome":
return dir * a.name.localeCompare(b.name, "it");
case "descrizione":
return dir * (a.description ?? "").localeCompare(b.description ?? "", "it");
case "fase":
return dir * (a.fase ?? "").localeCompare(b.fase ?? "", "it");
case "offerta":
return dir * a.tags.join(",").localeCompare(b.tags.join(","), "it");
case "pacchetto":
return dir * a.pacchetto.join(",").localeCompare(b.pacchetto.join(","), "it");
case "prezzo": {
const na = parseFloat(a.unit_price ?? "0");
const nb = parseFloat(b.unit_price ?? "0");
return dir * (na - nb);
}
default:
return 0;
}
};
}
export function CatalogSearch({
services,
options,
@@ -14,19 +62,65 @@ export function CatalogSearch({
options: CatalogFieldOptions;
}) {
const [query, setQuery] = useState("");
const [colOrder, setColOrder] = useState<ColumnKey[]>(() => loadColOrder());
const [sortKey, setSortKey] = useState<ColumnKey | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const dragCol = useRef<ColumnKey | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return services;
return services.filter((s) => {
if (s.name.toLowerCase().includes(q)) return true;
if (s.category?.toLowerCase().includes(q)) return true;
if (s.fase?.toLowerCase().includes(q)) return true;
if (s.tags.some((t) => t.toLowerCase().includes(q))) return true;
if (s.pacchetto.some((p) => p.toLowerCase().includes(q))) return true;
return false;
function handleSortClick(key: ColumnKey) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
}
function handleColDragStart(key: ColumnKey) {
dragCol.current = key;
}
function handleColDragOver(e: React.DragEvent, key: ColumnKey) {
e.preventDefault();
}
function handleColDrop(targetKey: ColumnKey) {
const from = dragCol.current;
dragCol.current = null;
if (!from || from === targetKey) return;
setColOrder((prev) => {
const next = [...prev];
const fromIdx = next.indexOf(from);
const toIdx = next.indexOf(targetKey);
next.splice(fromIdx, 1);
next.splice(toIdx, 0, from);
try {
localStorage.setItem("catalog_col_order", JSON.stringify(next));
} catch {}
return next;
});
}, [services, query]);
}
const sortFn = useMemo(() => buildSortFn(sortKey, sortDir), [sortKey, sortDir]);
const { activeServices, inactiveServices } = useMemo(() => {
const q = query.trim().toLowerCase();
const filtered = q
? services.filter((s) => {
if (s.name.toLowerCase().includes(q)) return true;
if (s.category?.toLowerCase().includes(q)) return true;
if (s.fase?.toLowerCase().includes(q)) return true;
if (s.tags.some((t) => t.toLowerCase().includes(q))) return true;
if (s.pacchetto.some((p) => p.toLowerCase().includes(q))) return true;
return false;
})
: services;
return {
activeServices: [...filtered.filter((s) => s.active)].sort(sortFn),
inactiveServices: [...filtered.filter((s) => !s.active)].sort(sortFn),
};
}, [services, query, sortFn]);
return (
<div className="space-y-4">
@@ -40,7 +134,18 @@ export function CatalogSearch({
className="pl-9 h-9"
/>
</div>
<ServiceTable services={filtered} options={options} />
<ServiceTable
activeServices={activeServices}
inactiveServices={inactiveServices}
options={options}
colOrder={colOrder}
sortKey={sortKey}
sortDir={sortDir}
onSortClick={handleSortClick}
onColDragStart={handleColDragStart}
onColDragOver={handleColDragOver}
onColDrop={handleColDrop}
/>
</div>
);
}
+10 -1
View File
@@ -7,6 +7,7 @@ import { eq, and } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { addPoolValue, renamePoolValue } from "@/lib/taxonomy";
const serviceSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
@@ -95,6 +96,7 @@ export async function updateServiceField(
} else if (fieldName === "fase") {
const s = String(value).trim();
await db.update(services).set({ fase: s || null }).where(eq(services.id, serviceId));
if (s) await addPoolValue("service_fase", s);
} else if (fieldName === "unit_price") {
// Normalize locale-formatted input (WR-04): the cell displays it-IT (€1.234,50),
// so an admin may type "1.234,50". When a comma is present, treat "." as thousands
@@ -143,6 +145,9 @@ export async function addServiceOption(
.values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed })
.onConflictDoNothing();
// Register into the persistent pool so it survives unassignment / shows in settings.
await addPoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", trimmed);
revalidatePath("/admin/catalog");
}
@@ -187,10 +192,12 @@ export async function renameServiceOption(
.update(tags)
.set({ name: next })
.where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue)));
await renamePoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", oldValue, next);
} else if (field === "categoria") {
await db.update(services).set({ category: next }).where(eq(services.category, oldValue));
} else if (field === "fase") {
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
await renamePoolValue("service_fase", oldValue, next);
} else {
throw new Error(`Campo non valido: ${field}`);
}
@@ -227,14 +234,16 @@ export async function quickAddService(payload: QuickAddPayload | string) {
unit_price = num.toFixed(2);
}
const fase = data.fase?.trim() || null;
await db.insert(services).values({
name,
description: data.description?.trim() || null,
category: data.category?.trim() || null,
fase: data.fase?.trim() || null,
fase,
unit_price,
active: true,
});
if (fase) await addPoolValue("service_fase", fase);
revalidatePath("/admin/catalog");
}
+3 -5
View File
@@ -1,5 +1,6 @@
import { getAllServices, getCatalogFieldOptions } from "@/lib/admin-queries";
import { CatalogSearch } from "./CatalogSearch";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0;
@@ -10,11 +11,8 @@ export default async function CatalogPage() {
]);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
</div>
<div className="space-y-6">
<PageHeader title="Catalogo Servizi" />
<CatalogSearch services={services} options={options} />
</div>
);
+112 -17
View File
@@ -50,10 +50,17 @@ async function resolveEntity(id: string): Promise<{ projectId: string | null; pa
// ── CLIENT CRUD ───────────────────────────────────────────────────────────────
const emptyToNull = (v: unknown) => {
const s = typeof v === "string" ? v.trim() : "";
return s === "" ? null : s;
};
const clientSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Brand name richiesto"),
brief: z.string(),
email: z.string().optional().transform(emptyToNull),
phone: z.string().optional().transform(emptyToNull),
slug: z
.string()
.regex(/^[a-z0-9-]{3,50}$/, "Slug non valido (es. mario-rossi, min 3 max 50 caratteri)")
@@ -68,6 +75,8 @@ export async function updateClient(clientId: string, formData: FormData) {
name: formData.get("name"),
brand_name: formData.get("brand_name"),
brief: formData.get("brief") ?? "",
email: formData.get("email") ?? "",
phone: formData.get("phone") ?? "",
slug: formData.get("slug") ?? "",
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
@@ -133,6 +142,14 @@ export async function updatePhaseStatus(phaseId: string, id: string, status: str
revalidatePath(path);
}
export async function deletePhase(phaseId: string, id: string) {
await requireAdmin();
// FK cascade on tasks and deliverables handles child rows
await db.delete(phases).where(eq(phases.id, phaseId));
const { path } = await resolveEntity(id);
revalidatePath(path);
}
// ── TASKS ─────────────────────────────────────────────────────────────────────
export async function addTask(phaseId: string, id: string, formData: FormData) {
@@ -153,15 +170,56 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
sort_order: maxOrder + 1,
status: "todo",
});
// Cascade: a new todo task keeps phase active/upcoming — recompute to be safe
await recomputePhaseStatus(phaseId);
const { path } = await resolveEntity(id);
revalidatePath(path);
}
// ── PHASE STATUS CASCADE ──────────────────────────────────────────────────────
// Recomputes phase status from its tasks:
// all done → done
// any in_progress or done (but not all done) → active
// all todo (or no tasks) → upcoming
export async function recomputePhaseStatus(phaseId: string): Promise<void> {
const phaseTasks = await db
.select({ status: tasks.status })
.from(tasks)
.where(eq(tasks.phase_id, phaseId));
let newStatus: "upcoming" | "active" | "done" = "upcoming";
if (phaseTasks.length > 0) {
const allDone = phaseTasks.every((t) => t.status === "done");
const anyActive = phaseTasks.some(
(t) => t.status === "in_progress" || t.status === "done"
);
if (allDone) newStatus = "done";
else if (anyActive) newStatus = "active";
}
await db.update(phases).set({ status: newStatus }).where(eq(phases.id, phaseId));
}
export async function updateTaskStatus(taskId: string, id: string, status: string) {
await requireAdmin();
const allowed = ["todo", "in_progress", "done"];
if (!allowed.includes(status)) throw new Error("Stato non valido");
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
// Cascade: recompute parent phase status from all its tasks
const taskRow = await db.select({ phase_id: tasks.phase_id }).from(tasks).where(eq(tasks.id, taskId)).limit(1);
if (taskRow[0]) await recomputePhaseStatus(taskRow[0].phase_id);
const { path } = await resolveEntity(id);
revalidatePath(path);
}
export async function deleteTask(taskId: string, id: string) {
await requireAdmin();
// Fetch phase_id before deletion so we can recompute phase status after
const taskRow = await db.select({ phase_id: tasks.phase_id }).from(tasks).where(eq(tasks.id, taskId)).limit(1);
const phaseId = taskRow[0]?.phase_id;
await db.delete(tasks).where(eq(tasks.id, taskId));
if (phaseId) await recomputePhaseStatus(phaseId);
const { path } = await resolveEntity(id);
revalidatePath(path);
}
@@ -231,6 +289,54 @@ export async function updatePaymentStatus(paymentId: string, id: string, status:
revalidatePath(path);
}
// Imposta il mese in cui un pagamento è stato incassato (formato "YYYY-MM").
// Mappa al primo giorno del mese (mezzogiorno UTC per evitare drift di fuso) e
// porta lo stato a "saldato" così l'incasso viene attribuito a quel mese nelle analytics.
export async function setPaymentPaidAt(paymentId: string, id: string, monthStr: string) {
await requireAdmin();
const m = /^(\d{4})-(\d{2})$/.exec(monthStr);
if (!m) throw new Error("Mese non valido");
const year = parseInt(m[1], 10);
const month = parseInt(m[2], 10);
if (month < 1 || month > 12) throw new Error("Mese non valido");
const paid_at = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0));
await db
.update(payments)
.set({ paid_at, status: "saldato" })
.where(eq(payments.id, paymentId));
const { path } = await resolveEntity(id);
revalidatePath(path);
}
// Rescales payment amounts when the total changes.
// If the payment has a `percent` field, use it (new plan rows).
// Legacy rows (percent null) fall back to equal split across all rows.
async function rescalePayments(projectId: string, newTotal: number): Promise<void> {
const projectPayments = await db
.select({ id: payments.id, percent: payments.percent })
.from(payments)
.where(eq(payments.project_id, projectId));
if (projectPayments.length === 0) return;
const hasPercent = projectPayments.some((p) => p.percent !== null);
if (hasPercent) {
// New plan: rescale each row by its stored percent
for (const p of projectPayments) {
const pct = p.percent !== null ? parseFloat(String(p.percent)) : 0;
const newAmount = ((newTotal * pct) / 100).toFixed(2);
await db.update(payments).set({ amount: newAmount }).where(eq(payments.id, p.id));
}
} else {
// Legacy: equal split across all rows (backward compat)
const share = (newTotal / projectPayments.length).toFixed(2);
for (const p of projectPayments) {
await db.update(payments).set({ amount: share }).where(eq(payments.id, p.id));
}
}
}
export async function updateAcceptedTotal(id: string, formData: FormData) {
await requireAdmin();
const raw = (formData.get("accepted_total") as string)?.trim();
@@ -244,28 +350,17 @@ export async function updateAcceptedTotal(id: string, formData: FormData) {
.limit(1);
if (asProject[0]) {
// Project context: update projects.accepted_total + this project's payment stubs
// Project context: update projects.accepted_total + rescale this project's payments
await db.update(projects).set({ accepted_total: val.toFixed(2) }).where(eq(projects.id, id));
const half = (val / 2).toFixed(2);
const projectPayments = await db.select({ id: payments.id })
.from(payments).where(eq(payments.project_id, id));
for (const p of projectPayments) {
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
}
await rescalePayments(id, val);
revalidatePath(`/admin/projects/${id}`);
} else {
// Client context: update clients.accepted_total + all project payment stubs
// Client context: update clients.accepted_total + rescale all project payments
await db.update(clients).set({ accepted_total: val.toFixed(2) }).where(eq(clients.id, id));
const projectRows = await db.select({ id: projects.id })
.from(projects).where(eq(projects.client_id, id));
if (projectRows.length > 0) {
const projectIds = projectRows.map((p) => p.id);
const half = (val / 2).toFixed(2);
const paymentsRows = await db.select()
.from(payments).where(inArray(payments.project_id, projectIds));
for (const p of paymentsRows) {
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
}
for (const proj of projectRows) {
await rescalePayments(proj.id, val);
}
revalidatePath(`/admin/clients/${id}`);
}
@@ -280,7 +375,7 @@ export async function postAdminComment(id: string, formData: FormData) {
if (!body || !entity) throw new Error("Dati mancanti");
const [entity_type, entity_id] = entity.split(":");
if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
const allowedTypes = ["task", "deliverable"];
const allowedTypes = ["task", "deliverable", "phase", "general"];
if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
const { path } = await resolveEntity(id);
+23
View File
@@ -62,6 +62,29 @@ export default async function EditClientPage({
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
defaultValue={client.email ?? ""}
placeholder="marco@rossistudio.it"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="phone">Telefono</Label>
<Input
id="phone"
name="phone"
type="tel"
defaultValue={client.phone ?? ""}
placeholder="+39 333 1234567"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="brief">Brief progetto</Label>
<Textarea
+48 -12
View File
@@ -33,6 +33,21 @@ export default async function ClientDetailPage({
<div>
<h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
<p className="text-sm text-[#71717a]">{client.brand_name}</p>
{(client.email || client.phone) && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-[#71717a]">
{client.email && (
<a href={`mailto:${client.email}`} className="hover:text-[#1A463C]">
{client.email}
</a>
)}
{client.email && client.phone && <span className="text-[#d4d4d8]">·</span>}
{client.phone && (
<a href={`tel:${client.phone}`} className="hover:text-[#1A463C]">
{client.phone}
</a>
)}
</div>
)}
{client.archived && (
<span className="inline-block mt-1 text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full">
Archiviato
@@ -115,19 +130,40 @@ export default async function ClientDetailPage({
Offerte Attive ({activeOffers.length})
</p>
<div className="bg-white rounded-xl border border-[#e5e7eb] divide-y divide-[#e5e7eb]">
{activeOffers.map((offer) => (
<div key={offer.offer_id} className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-[#1a1a1a]">{offer.public_name}</p>
<p className="text-xs text-[#71717a]">{offer.project_name}</p>
{activeOffers.map((offer) => {
const categoryLabel =
offer.category ??
(offer.offer_type === "retainer"
? "Retainer"
: offer.offer_type === "una_tantum"
? "Una tantum"
: null);
return (
<div key={offer.offer_id} className="flex items-center justify-between px-4 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-[#1a1a1a]">{offer.macro_name}</p>
{categoryLabel && (
<span className="inline-flex items-center rounded-full bg-[#1A463C]/10 px-2 py-0.5 text-[11px] font-semibold text-[#1A463C]">
{categoryLabel}
</span>
)}
{offer.tier_letter && (
<span className="inline-flex items-center rounded-full border border-[#e5e7eb] px-2 py-0.5 text-[11px] font-semibold text-[#71717a]">
Tier {offer.tier_letter}
</span>
)}
</div>
<p className="text-xs text-[#71717a] mt-0.5">{offer.project_name}</p>
</div>
{offer.accepted_total && (
<span className="text-sm font-mono text-[#1a1a1a] shrink-0">
&euro;{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</span>
)}
</div>
{offer.accepted_total && (
<span className="text-sm font-mono text-[#1a1a1a]">
&euro;{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</span>
)}
</div>
))}
);
})}
</div>
</div>
)}
+44 -38
View File
@@ -44,62 +44,68 @@ const createClientSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Nome brand richiesto"),
brief: z.string().min(1, "Brief richiesto"),
email: z.string().optional(),
phone: z.string().optional(),
});
export async function createClient(formData: FormData) {
await requireAdmin();
const raw = {
name: formData.get("name") as string,
brand_name: formData.get("brand_name") as string,
brief: formData.get("brief") as string,
};
// Shared core used by both the manual create form and the lead→client
// conversion. Creates the client (+ slug + token), a default project (optionally
// linked to the originating lead), and the two 50/50 payment stubs. Returns the
// new ids. Does NOT redirect — callers decide where to go next.
export async function createClientCore(input: {
name: string;
brand_name: string;
brief: string;
email?: string | null;
phone?: string | null;
created_from_lead_id?: string | null;
}): Promise<{ clientId: string; projectId: string }> {
const slug = await uniqueSlug(toSlug(input.name));
const parsed = createClientSchema.safeParse(raw);
if (!parsed.success) {
throw new Error(
parsed.error.issues.map((i) => i.message).join(", ")
);
}
// Auto-generate slug from name (e.g. "Mario Rossi" → "mario-rossi")
const slug = await uniqueSlug(toSlug(parsed.data.name));
// Insert client — token and id are auto-generated by $defaultFn(() => nanoid())
const [newClient] = await db
.insert(clients)
.values({
name: parsed.data.name,
brand_name: parsed.data.brand_name,
brief: parsed.data.brief,
name: input.name,
brand_name: input.brand_name,
brief: input.brief,
email: input.email?.trim() || null,
phone: input.phone?.trim() || null,
slug,
})
.returning({ id: clients.id, token: clients.token, brand_name: clients.brand_name });
.returning({ id: clients.id, brand_name: clients.brand_name });
// Create a default project for the client — all work items are project-scoped
const [newProject] = await db
.insert(projects)
.values({
client_id: newClient.id,
name: newClient.brand_name,
created_from_lead_id: input.created_from_lead_id ?? null,
})
.returning({ id: projects.id });
// Always create two payment stubs per project — Acconto 50% and Saldo 50%
await db.insert(payments).values([
{
project_id: newProject.id,
label: "Acconto 50%",
amount: "0",
status: "da_saldare",
},
{
project_id: newProject.id,
label: "Saldo 50%",
amount: "0",
status: "da_saldare",
},
{ project_id: newProject.id, label: "Acconto 50%", amount: "0", status: "da_saldare" },
{ project_id: newProject.id, label: "Saldo 50%", amount: "0", status: "da_saldare" },
]);
revalidatePath("/admin");
redirect(`/admin/clients/${newClient.id}`);
return { clientId: newClient.id, projectId: newProject.id };
}
export async function createClient(formData: FormData) {
await requireAdmin();
const parsed = createClientSchema.safeParse({
name: formData.get("name") as string,
brand_name: formData.get("brand_name") as string,
brief: formData.get("brief") as string,
email: (formData.get("email") as string) ?? "",
phone: (formData.get("phone") as string) ?? "",
});
if (!parsed.success) {
throw new Error(parsed.error.issues.map((i) => i.message).join(", "));
}
const { clientId } = await createClientCore(parsed.data);
revalidatePath("/admin");
redirect(`/admin/clients/${clientId}`);
}
+20
View File
@@ -45,6 +45,26 @@ export default function NewClientPage() {
required
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="es. marco@rossistudio.it"
/>
</div>
<div className="space-y-1">
<Label htmlFor="phone">Telefono</Label>
<Input
id="phone"
name="phone"
type="tel"
placeholder="es. +39 333 1234567"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="brief">Brief del progetto</Label>
<Textarea
+17 -15
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { getAllClientsWithPayments } from "@/lib/admin-queries";
import { ClientRow } from "@/components/admin/ClientRow";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0;
@@ -14,22 +15,23 @@ export default async function AdminClientsPage({
const showArchived = archived === "1";
const clients = await getAllClientsWithPayments(showArchived);
const clientiAction = (
<div className="flex items-center gap-3">
<a
href={showArchived ? "/admin/clients" : "/admin/clients?archived=1"}
className="text-xs text-[#71717a] hover:text-[#1A463C] underline underline-offset-2"
>
{showArchived ? "Nascondi archiviati" : "Mostra archiviati"}
</a>
<Button asChild>
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
</Button>
</div>
);
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
<a
href={showArchived ? "/admin/clients" : "/admin/clients?archived=1"}
className="text-xs text-[#71717a] hover:text-[#1A463C] underline underline-offset-2"
>
{showArchived ? "Nascondi archiviati" : "Mostra archiviati"}
</a>
</div>
<Button asChild>
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
</Button>
</div>
<div className="space-y-6">
<PageHeader title="Clienti" action={clientiAction} />
{clients.length === 0 ? (
<div className="text-center py-20 text-[#71717a]">
+42
View File
@@ -0,0 +1,42 @@
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import {
addPoolValue,
removePoolValue,
TAXONOMY_FIELDS,
type TaxonomyFieldId,
} from "@/lib/taxonomy";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
function assertField(fieldId: string): asserts fieldId is TaxonomyFieldId {
if (!(fieldId in TAXONOMY_FIELDS)) throw new Error(`Tassonomia non valida: ${fieldId}`);
}
function revalidateAll() {
revalidatePath("/admin/impostazioni");
revalidatePath("/admin/offers");
revalidatePath("/admin/catalog");
}
export async function addTaxonomyValue(fieldId: string, value: string): Promise<void> {
await requireAdmin();
assertField(fieldId);
await addPoolValue(fieldId, value);
revalidateAll();
}
// Global delete: removes the value from the pool AND cascade-strips it from every
// offer/service using it.
export async function removeTaxonomyValue(fieldId: string, value: string): Promise<void> {
await requireAdmin();
assertField(fieldId);
await removePoolValue(fieldId, value);
revalidateAll();
}
+43 -36
View File
@@ -1,9 +1,12 @@
import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
import { getAllPools } from "@/lib/taxonomy";
import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0;
export default async function ImpostazioniPage() {
const targetRate = await getTargetHourlyRate();
const [targetRate, pools] = await Promise.all([getTargetHourlyRate(), getAllPools()]);
async function handleSave(fd: FormData) {
"use server";
@@ -14,45 +17,49 @@ export default async function ImpostazioniPage() {
}
return (
<div>
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-6">Impostazioni</h1>
<div className="space-y-6">
<PageHeader title="Impostazioni" />
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 max-w-md">
<h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2>
<div className="space-y-6 max-w-4xl">
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 max-w-md">
<h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2>
<form action={handleSave} className="space-y-4">
<div>
<label
htmlFor="target_hourly_rate"
className="block text-sm font-medium text-[#1a1a1a] mb-1"
>
Tariffa oraria target (/h)
</label>
<p className="text-xs text-[#71717a] mb-2">
Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto.
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-[#71717a]"></span>
<input
id="target_hourly_rate"
name="target_hourly_rate"
type="number"
step="0.01"
min="0"
defaultValue={targetRate.toFixed(2)}
className="border border-[#e5e7eb] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 w-32"
/>
<span className="text-sm text-[#71717a]">/h</span>
<form action={handleSave} className="space-y-4">
<div>
<label
htmlFor="target_hourly_rate"
className="block text-sm font-medium text-[#1a1a1a] mb-1"
>
Tariffa oraria target (/h)
</label>
<p className="text-xs text-[#71717a] mb-2">
Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto.
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-[#71717a]"></span>
<input
id="target_hourly_rate"
name="target_hourly_rate"
type="number"
step="0.01"
min="0"
defaultValue={targetRate.toFixed(2)}
className="border border-[#e5e7eb] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 w-32"
/>
<span className="text-sm text-[#71717a]">/h</span>
</div>
</div>
</div>
<button
type="submit"
className="bg-[#1A463C] text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors"
>
Salva
</button>
</form>
<button
type="submit"
className="bg-[#1A463C] text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors"
>
Salva
</button>
</form>
</div>
<TaxonomyManager pools={pools} />
</div>
</div>
);
+5 -9
View File
@@ -1,4 +1,4 @@
import { AdminSidebar } from "@/components/admin/AdminSidebar";
import { AdminShell } from "@/components/admin/AdminShell";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
@@ -8,12 +8,8 @@ export default async function AdminLayout({
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
return (
<div className="flex min-h-screen bg-gray-50">
{session && <AdminSidebar />}
<main className="flex-1 px-8 py-8 overflow-y-auto">
{children}
</main>
</div>
);
if (!session) {
return <div className="min-h-screen bg-background">{children}</div>;
}
return <AdminShell>{children}</AdminShell>;
}
+9 -29
View File
@@ -1,32 +1,12 @@
import { notFound } from "next/navigation";
import { getActivityLog, getUpcomingReminders } from "@/lib/lead-service";
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
import { LeadDetail } from "@/components/admin/leads/LeadDetail";
import { redirect } from "next/navigation";
export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) {
// Legacy route — Lead was renamed to Pipeline. Kept as a redirect stub so
// existing links/bookmarks to /admin/leads/<id> don't break.
export default async function LeadDetailRedirectPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const [leads, options] = await Promise.all([
getLeadsWithTags(),
getLeadFieldOptions(),
]);
const lead = leads.find((l) => l.id === id);
if (!lead) {
notFound();
}
const activities = await getActivityLog(id);
const reminders = await getUpcomingReminders(id);
return (
<LeadDetail
lead={lead}
activities={activities}
reminders={reminders}
tags={lead.tags}
tagOptions={options.tags}
/>
);
redirect(`/admin/pipeline/${id}`);
}
+5 -21
View File
@@ -1,23 +1,7 @@
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
import { LeadsSearch } from "./LeadsSearch";
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
import { redirect } from "next/navigation";
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>
<LeadsSearch leads={leads} options={options} />
</div>
);
// Legacy route — Lead was renamed to Pipeline. Kept as a redirect stub so
// existing links/bookmarks to /admin/leads don't break.
export default function LeadsRedirectPage() {
redirect("/admin/pipeline");
}
+16
View File
@@ -14,6 +14,7 @@ import { eq, and, inArray } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { addPoolValue, renamePoolValue } from "@/lib/taxonomy";
async function requireAdmin() {
const session = await getServerSession(authOptions);
@@ -155,6 +156,7 @@ const saveOfferEditorSchema = z.object({
description: z.string().optional(),
category: z.string().optional(),
ticket: z.string().optional(),
offer_type: z.enum(["una_tantum", "retainer"]).optional(),
cliente_ideale: z.string().optional(),
risultato: z.string().optional(),
tempo: z.string().optional(),
@@ -188,6 +190,7 @@ export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorP
description: data.description || null,
category: data.category || null,
ticket: data.ticket || null,
offer_type: data.offer_type ?? "una_tantum",
cliente_ideale: data.cliente_ideale || null,
risultato: data.risultato || null,
tempo: data.tempo || null,
@@ -259,6 +262,13 @@ export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorP
await db.insert(tags).values(tagRows).onConflictDoNothing();
}
// 4. Register any newly-typed values into the persistent taxonomy pools so they
// survive unassignment and appear in settings + every dropdown.
if (data.category) await addPoolValue("offer_categoria", data.category);
if (data.ticket) await addPoolValue("offer_ticket", data.ticket);
for (const name of data.tipoTags) await addPoolValue("offer_tipo", name);
for (const name of data.obiettivoTags) await addPoolValue("offer_obiettivo", name);
revalidatePath("/admin/offers");
revalidatePath(`/admin/offers/${macroId}/edit`);
}
@@ -290,6 +300,8 @@ export async function addOfferTag(dimension: OfferTagDimension, macroId: string,
.values({ entity_type: OFFER_TAG_ENTITY[dimension], entity_id: macroId, name: trimmed })
.onConflictDoNothing();
await addPoolValue(dimension === "tipo" ? "offer_tipo" : "offer_obiettivo", trimmed);
revalidatePath("/admin/offers");
}
@@ -332,13 +344,16 @@ export async function renameOfferOption(
if (field === "categoria") {
await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue));
await renamePoolValue("offer_categoria", oldValue, next);
} else if (field === "ticket") {
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue));
await renamePoolValue("offer_ticket", oldValue, next);
} else if (field === "tipo" || field === "obiettivo") {
await db
.update(tags)
.set({ name: next })
.where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue)));
await renamePoolValue(field === "tipo" ? "offer_tipo" : "offer_obiettivo", oldValue, next);
} else {
throw new Error(`Campo non valido: ${field}`);
}
@@ -371,6 +386,7 @@ export async function createOfferMacro(formData: FormData) {
description: parsed.data.description || null,
category: parsed.data.category || null,
});
if (parsed.data.category) await addPoolValue("offer_categoria", parsed.data.category);
revalidatePath("/admin/offers");
}
+52 -111
View File
@@ -8,38 +8,14 @@ import {
getTimeByClient,
getTotalTrackedHours,
} from "@/lib/analytics-queries";
import { getRevenueForecast12Months, getOffersSoldBreakdown } from "@/lib/forecast-queries";
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
import { Users, FolderOpen, Euro, Clock } from "lucide-react";
import { ForecastChart } from "@/components/admin/ForecastChart";
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0;
function KpiCard({
label,
value,
sub,
icon: Icon,
color,
}: {
label: string;
value: string | number;
sub?: string;
icon: React.ElementType;
color: string;
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 flex items-start gap-4">
<div className={`p-2 rounded-lg ${color}`}>
<Icon size={20} strokeWidth={1.8} className="text-white" />
</div>
<div>
<p className="text-xs text-gray-500 font-medium uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold text-gray-900 mt-0.5">{value}</p>
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
</div>
</div>
);
}
function MetricCard({
label,
value,
@@ -78,22 +54,6 @@ function MetricCard({
);
}
const ACTIVITY_ICONS: Record<string, string> = {
nuovo_cliente: "👤",
nuovo_progetto: "📁",
deliverable_approvato: "✅",
timer_stoppato: "⏱",
};
function fmt(ts: Date) {
return new Intl.DateTimeFormat("it-IT", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(ts));
}
function fmtEur(n: number) {
return n.toLocaleString("it-IT", {
style: "currency",
@@ -117,15 +77,18 @@ export default async function AdminDashboard({
const { year: yearParam } = await searchParams;
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
const { kpi, activity } = await getDashboardStats();
const { kpi } = await getDashboardStats();
const [data, monthly, availableYears, timeByClient, totalHours] = await Promise.all([
getAnalyticsByYear(year),
getMonthlyCollected(year),
getAvailableYears(),
getTimeByClient(year),
getTotalTrackedHours(year),
]);
const [data, monthly, availableYears, timeByClient, totalHours, forecast, offersSold] =
await Promise.all([
getAnalyticsByYear(year),
getMonthlyCollected(year),
getAvailableYears(),
getTimeByClient(year),
getTotalTrackedHours(year),
getRevenueForecast12Months(),
getOffersSoldBreakdown(),
]);
const collectedPct =
data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
@@ -133,8 +96,8 @@ export default async function AdminDashboard({
const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1;
return (
<div className="max-w-5xl">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
<div className="space-y-6">
<PageHeader title="Dashboard" />
{/* Follow-up Widget */}
<div className="mb-8">
@@ -143,63 +106,8 @@ export default async function AdminDashboard({
</Suspense>
</div>
{/* KPI cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<KpiCard
label="Clienti attivi"
value={kpi.clientiAttivi}
icon={Users}
color="bg-[#1A463C]"
/>
<KpiCard
label="Revenue totale"
value={fmtEur(parseFloat(kpi.revenueTotale))}
sub="progetti non archiviati"
icon={Euro}
color="bg-emerald-600"
/>
<KpiCard
label="Progetti in corso"
value={kpi.progettiInCorso}
icon={FolderOpen}
color="bg-blue-600"
/>
<KpiCard
label="Pagamenti in sospeso"
value={fmtEur(parseFloat(kpi.pagamentiInSospeso))}
sub="da_saldare + inviata"
icon={Clock}
color="bg-amber-500"
/>
</div>
{/* Activity feed */}
<div className="bg-white rounded-xl border border-gray-200">
<div className="px-5 py-4 border-b border-gray-100">
<h2 className="text-sm font-semibold text-gray-700">Attività recente</h2>
</div>
{activity.length === 0 ? (
<p className="px-5 py-8 text-sm text-gray-400 text-center">Nessuna attività recente</p>
) : (
<ul className="divide-y divide-gray-50">
{activity.map((item, i) => (
<li key={i} className="px-5 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="text-base">{ACTIVITY_ICONS[item.type]}</span>
<div>
<p className="text-sm font-medium text-gray-800">{item.label}</p>
<p className="text-xs text-gray-500">{item.detail}</p>
</div>
</div>
<span className="text-xs text-gray-400 shrink-0">{fmt(item.timestamp)}</span>
</li>
))}
</ul>
)}
</div>
{/* ── SEZIONE STATISTICHE ANNUALI ── */}
<div className="mt-10 space-y-10">
{/* ── SEZIONE STATISTICHE ── */}
<div className="space-y-10">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-gray-900">Statistiche</h2>
@@ -208,6 +116,39 @@ export default async function AdminDashboard({
<YearSelector currentYear={year} availableYears={availableYears} />
</div>
{/* Panoramica corrente (non per-anno) */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Panoramica</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard label="Clienti attivi" value={String(kpi.clientiAttivi)} sub="Totale corrente" />
<MetricCard
label="Progetti in corso"
value={String(kpi.progettiInCorso)}
sub="Non archiviati"
/>
</div>
</div>
{/* Previsione incassi */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
Previsione incassi (12 mesi)
</h3>
<ForecastChart data={forecast} />
<p className="text-xs text-[#a1a1aa] italic">
Stima dalle offerte attive: i retainer contribuiscono il canone mensile, le una-tantum
ripartite sulla durata.
</p>
</div>
{/* Offerte vendute */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
Offerte vendute
</h3>
<OffersSoldChart data={offersSold} />
</div>
{/* Sezione economica */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
+3
View File
@@ -0,0 +1,3 @@
// 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";
+40
View File
@@ -0,0 +1,40 @@
import { notFound } from "next/navigation";
import { getActivityLog, getUpcomingReminders, getTranscripts } from "@/lib/lead-service";
import {
getLeadByIdWithTags,
getLeadFieldOptions,
getClientIdFromLead,
} from "@/lib/admin-queries";
import { LeadDetail } from "@/components/admin/leads/LeadDetail";
export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [lead, options, convertedClientId] = await Promise.all([
getLeadByIdWithTags(id),
getLeadFieldOptions(),
getClientIdFromLead(id),
]);
if (!lead) {
notFound();
}
const [activities, reminders, transcripts] = await Promise.all([
getActivityLog(id),
getUpcomingReminders(id),
getTranscripts(id),
]);
return (
<LeadDetail
lead={lead}
activities={activities}
reminders={reminders}
tags={lead.tags}
tagOptions={options.tags}
transcripts={transcripts}
convertedClientId={convertedClientId}
/>
);
}
@@ -2,7 +2,8 @@
import { z } from "zod";
import { db } from "@/db";
import { leads, quotes, tags } from "@/db/schema";
import { leads, quotes, tags, clientTranscripts, projects } from "@/db/schema";
import { nanoid } from "nanoid";
import { eq, and } from "drizzle-orm";
import {
createLeadSchema,
@@ -11,10 +12,61 @@ import {
LEAD_STAGES,
} from "@/lib/lead-validators";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createActivity, updateLeadStage } from "@/lib/lead-service";
import { createClientCore } from "@/app/admin/clients/new/actions";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
async function requireAdminSession() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
// Converts a lead into a client (reusing createClientCore): creates the client
// + default project (linked via created_from_lead_id) + payment stubs, carries
// over the lead's call transcripts, then archives the lead while keeping its
// "won" status. Idempotent: a lead already converted just redirects to its
// client. Redirects to the new client page on success.
export async function convertLeadToClient(leadId: string): Promise<void> {
await requireAdminSession();
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) throw new Error("Lead non trovato");
// Already converted? Redirect to the linked client instead of duplicating.
const [existing] = await db
.select({ client_id: projects.client_id })
.from(projects)
.where(eq(projects.created_from_lead_id, leadId))
.limit(1);
if (existing) {
redirect(`/admin/clients/${existing.client_id}`);
}
const { clientId } = await createClientCore({
name: lead.name,
brand_name: lead.company?.trim() || lead.name,
brief: lead.notes?.trim() || `Lead convertito: ${lead.name}`,
email: lead.email,
phone: lead.phone,
created_from_lead_id: lead.id,
});
// Carry over the lead's transcripts to the new client.
await db
.update(clientTranscripts)
.set({ client_id: clientId, lead_id: null })
.where(eq(clientTranscripts.lead_id, leadId));
// Archive the lead but keep its "won" status (traceability without clutter).
await db.update(leads).set({ archived: true }).where(eq(leads.id, leadId));
revalidatePath("/admin/pipeline");
revalidatePath("/admin");
redirect(`/admin/clients/${clientId}`);
}
export async function createLead(data: z.infer<typeof createLeadSchema>) {
const parsed = createLeadSchema.safeParse(data);
@@ -35,7 +87,7 @@ export async function createLead(data: z.infer<typeof createLeadSchema>) {
})
.returning();
revalidatePath("/admin/leads");
revalidatePath("/admin/pipeline");
return { success: true, lead };
} catch (error) {
console.error("createLead error:", error);
@@ -66,8 +118,8 @@ export async function updateLead(id: string, data: z.infer<typeof updateLeadSche
.where(eq(leads.id, id))
.returning();
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${id}`);
revalidatePath("/admin/pipeline");
revalidatePath(`/admin/pipeline/${id}`);
return { success: true, lead: updated };
} catch (error) {
console.error("updateLead error:", error);
@@ -78,7 +130,7 @@ export async function updateLead(id: string, data: z.infer<typeof updateLeadSche
export async function deleteLead(id: string) {
try {
await db.delete(leads).where(eq(leads.id, id));
revalidatePath("/admin/leads");
revalidatePath("/admin/pipeline");
return { success: true };
} catch (error) {
console.error("deleteLead error:", error);
@@ -103,7 +155,7 @@ export async function logActivity(data: z.infer<typeof createActivitySchema>) {
notes: parsed.data.notes,
});
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
revalidatePath(`/admin/pipeline/${parsed.data.lead_id}`);
return { success: true, activity };
} catch (error) {
console.error("logActivity error:", error);
@@ -153,7 +205,7 @@ export async function assignQuoteToLead(data: z.infer<typeof assignQuoteSchema>)
// Update lead status to "proposal_sent"
await updateLeadStage(leadId, "proposal_sent");
revalidatePath(`/admin/leads/${leadId}`);
revalidatePath(`/admin/pipeline/${leadId}`);
return { success: true };
} catch (error) {
console.error("assignQuoteToLead error:", error);
@@ -200,8 +252,8 @@ export async function updateLeadField(
.where(eq(leads.id, leadId));
}
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${leadId}`);
revalidatePath("/admin/pipeline");
revalidatePath(`/admin/pipeline/${leadId}`);
}
// ── Lead tags (Phase 14, CRM-09) — polymorphic `tags` table, entity_type="leads" ─
@@ -218,8 +270,8 @@ export async function addLeadTag(leadId: string, value: string) {
.values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed })
.onConflictDoNothing();
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${leadId}`);
revalidatePath("/admin/pipeline");
revalidatePath(`/admin/pipeline/${leadId}`);
}
export async function removeLeadTag(leadId: string, value: string) {
@@ -235,8 +287,8 @@ export async function removeLeadTag(leadId: string, value: string) {
)
);
revalidatePath("/admin/leads");
revalidatePath(`/admin/leads/${leadId}`);
revalidatePath("/admin/pipeline");
revalidatePath(`/admin/pipeline/${leadId}`);
}
export async function renameLeadTag(oldValue: string, newValue: string) {
@@ -250,5 +302,60 @@ export async function renameLeadTag(oldValue: string, newValue: string) {
.set({ name: next })
.where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.name, oldValue)));
revalidatePath("/admin/leads");
revalidatePath("/admin/pipeline");
}
// ── 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/pipeline/${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/pipeline/${leadId}`);
return { success: true };
} catch (error) {
console.error("deleteTranscript error:", error);
return { success: false, error: "Errore nell'eliminazione del transcript" };
}
}
+24
View File
@@ -0,0 +1,24 @@
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0;
export default async function LeadsPage() {
const [leads, options] = await Promise.all([
getLeadsWithTags(),
getLeadFieldOptions(),
]);
return (
<div className="space-y-6">
<PageHeader
title="Lead Pipeline"
subtitle="Gestisci e monitora i tuoi contatti commerciali"
action={<CreateLeadModal />}
/>
<LeadsViewToggle leads={leads} options={options} />
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getProposalById } from "@/lib/proposal/queries";
import { publishProposal, deleteProposal } from "../actions";
import { ExternalLink, ArrowLeft, Trash2 } from "lucide-react";
export const revalidate = 0;
export default async function ProposalDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const proposal = await getProposalById(id);
if (!proposal) notFound();
const { content } = proposal;
const isDraft = proposal.state === "draft";
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Breadcrumb */}
<Link
href="/admin/preventivi"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft size={14} /> Preventivi
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-foreground">
{proposal.title ?? "Senza titolo"}
</h1>
<div className="flex items-center gap-3 mt-2">
<StateBadge state={proposal.state} />
<span className="text-sm text-muted-foreground">
Generato il {proposal.createdAt.toLocaleDateString("it-IT")} · {proposal.model ?? "AI"}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Link pubblico */}
{(proposal.state === "published" || proposal.state === "accepted") && (
<a
href={`/preventivo/${proposal.slug}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-2 border border-border rounded-md text-sm hover:bg-muted transition-colors"
>
<ExternalLink size={14} />
Apri deck
</a>
)}
{/* Pubblica */}
{isDraft && (
<form action={publishProposal.bind(null, id)}>
<button
type="submit"
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
>
Pubblica
</button>
</form>
)}
{/* Elimina (solo draft) */}
{isDraft && (
<form action={deleteProposal.bind(null, id)}>
<button
type="submit"
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
title="Elimina bozza"
>
<Trash2 size={16} />
</button>
</form>
)}
</div>
</div>
{/* Link pubblico (draft) */}
{isDraft && (
<div className="text-xs text-muted-foreground bg-yellow-50 border border-yellow-200 rounded-md px-3 py-2">
Bozza non ancora visibile al cliente. Pubblica per generare il link pubblico.
</div>
)}
{!isDraft && (
<div className="text-xs bg-muted border border-border rounded-md px-3 py-2 font-mono">
{typeof window === "undefined" ? "" : window.location.origin}/preventivo/{proposal.slug}
</div>
)}
{/* Preview contenuto */}
<div className="space-y-4">
<h2 className="text-base font-semibold text-foreground">Anteprima contenuto</h2>
<Section title="Vision">
<p className="font-medium">{content.ai.vision.headline}</p>
<p className="text-muted-foreground text-sm mt-1">{content.ai.vision.body}</p>
</Section>
<Section title={`Problemi (${content.ai.problems.length})`}>
<div className="space-y-2">
{content.ai.problems.map((p) => (
<div key={p.id} className="border border-border rounded-md px-3 py-2">
<p className="font-medium text-sm">{p.id}. {p.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{p.subtitle}</p>
</div>
))}
</div>
</Section>
<Section title="Scope">
<p className="font-medium text-sm">{content.ai.scope.scopeTitle}</p>
<p className="text-sm text-muted-foreground mt-1">{content.ai.scope.scopeBody}</p>
</Section>
<Section title={`Tier offerta (${content.offer.tiers.length})`}>
<div className="grid grid-cols-3 gap-3">
{content.offer.tiers.map((t) => (
<div key={t.id} className="border border-border rounded-md p-3 text-sm">
<p className="font-semibold text-primary">{t.tierLetter}</p>
<p className="font-medium mt-0.5">{t.publicName}</p>
<p className="text-muted-foreground mt-1">
{t.publicPrice ? `${t.publicPrice}` : `${t.servicesTotal} (calcolato)`}
</p>
</div>
))}
</div>
</Section>
</div>
</div>
);
}
function StateBadge({ state }: { state: string }) {
const map: Record<string, { label: string; className: string }> = {
draft: { label: "Bozza", className: "bg-yellow-100 text-yellow-800" },
published: { label: "Pubblicato", className: "bg-blue-100 text-blue-800" },
accepted: { label: "Accettato", className: "bg-green-100 text-green-800" },
rejected: { label: "Rifiutato", className: "bg-red-100 text-red-800" },
};
const info = map[state] ?? { label: state, className: "bg-gray-100 text-gray-700" };
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${info.className}`}>
{info.label}
</span>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-lg p-4 space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{title}</h3>
{children}
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use server";
import { db } from "@/db";
import { proposals, leads, clients } from "@/db/schema";
import { nanoid } from "nanoid";
import { eq, and, isNull } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { generateProposalContent } from "@/lib/proposal/agent";
import { assembleProposal } from "@/lib/proposal/assemble";
import { getOfferEditorData } from "@/lib/offer-queries";
import { getTranscripts } from "@/lib/lead-service";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
// Genera una bozza di preventivo con AI e salva nel DB
export async function generateProposalDraft(formData: FormData) {
await requireAdmin();
const leadId = formData.get("lead_id") as string | null;
const clientId = formData.get("client_id") as string | null;
const offerMacroId = formData.get("offer_macro_id") as string;
if (!offerMacroId) throw new Error("offer_macro_id richiesto");
if (!leadId && !clientId) throw new Error("lead_id o client_id richiesto");
// Carica dati necessari
const offer = await getOfferEditorData(offerMacroId);
if (!offer) throw new Error("Offerta non trovata");
let lead = undefined;
let client = undefined;
let transcripts: Awaited<ReturnType<typeof getTranscripts>> = [];
if (leadId) {
const [leadRow] = await db
.select({ id: leads.id, name: leads.name, email: leads.email, company: leads.company, notes: leads.notes })
.from(leads)
.where(eq(leads.id, leadId))
.limit(1);
lead = leadRow;
transcripts = await getTranscripts(leadId);
} else if (clientId) {
const [clientRow] = await db
.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name, brief: clients.brief })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
client = clientRow;
// Trascrizioni del cliente (via client_id)
const { clientTranscripts } = await import("@/db/schema");
const { eq: drizzleEq, desc } = await import("drizzle-orm");
transcripts = await db
.select()
.from(clientTranscripts)
.where(drizzleEq(clientTranscripts.client_id, clientId))
.orderBy(desc(clientTranscripts.call_date));
}
const MODEL_IDS = {
opus: "claude-opus-4-8",
sonnet: "claude-sonnet-4-6",
} as const;
const modelKey = (() => {
const raw = formData.get("model");
if (raw === "opus" || raw === "sonnet") return raw;
return "opus" as const;
})();
const resolvedModel = MODEL_IDS[modelKey];
// Chiama agente AI
const { content: aiContent } = await generateProposalContent({
lead,
client,
transcripts,
offer,
model: resolvedModel,
});
// Assembla contenuto completo
const assembled = assembleProposal({
lead,
client,
offer,
aiContent,
model: resolvedModel,
});
// Salva nel DB
const id = nanoid();
const slug = nanoid();
const title = `${assembled.header.clientName} × ${assembled.header.offerTitle}`;
await db.insert(proposals).values({
id,
slug,
lead_id: leadId ?? null,
client_id: clientId ?? null,
offer_macro_id: offerMacroId,
title,
content: assembled as unknown as Record<string, unknown>,
model: resolvedModel,
state: "draft",
});
revalidatePath("/admin/preventivi");
redirect(`/admin/preventivi/${id}`);
}
// Pubblica la proposta (draft → published)
export async function publishProposal(id: string) {
await requireAdmin();
await db
.update(proposals)
.set({ state: "published", updated_at: new Date() })
.where(and(eq(proposals.id, id), eq(proposals.state, "draft")));
revalidatePath(`/admin/preventivi/${id}`);
revalidatePath("/admin/preventivi");
}
// Aggiorna il titolo della proposta
export async function updateProposalTitle(id: string, title: string) {
await requireAdmin();
await db
.update(proposals)
.set({ title: title.trim(), updated_at: new Date() })
.where(eq(proposals.id, id));
revalidatePath(`/admin/preventivi/${id}`);
}
// Elimina una proposta (solo draft)
export async function deleteProposal(id: string) {
await requireAdmin();
await db
.delete(proposals)
.where(and(eq(proposals.id, id), eq(proposals.state, "draft")));
revalidatePath("/admin/preventivi");
redirect("/admin/preventivi");
}
@@ -0,0 +1,191 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2 } from "lucide-react";
import type { OfferListCard } from "@/lib/offer-queries";
type Props = {
leads: Array<{ id: string; name: string; company: string | null }>;
clients: Array<{ id: string; name: string; brand_name: string }>;
offers: OfferListCard[];
action: (formData: FormData) => Promise<void>;
preselectedLeadId?: string;
};
export function GeneraProposalForm({ leads, clients, offers, action, preselectedLeadId }: Props) {
const [subjectType, setSubjectType] = useState<"lead" | "client">(
preselectedLeadId ? "lead" : "lead"
);
const [model, setModel] = useState<"opus" | "sonnet">("opus");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const formData = new FormData(form);
formData.append("model", model);
startTransition(async () => {
try {
await action(formData);
} catch (err) {
setError(err instanceof Error ? err.message : "Errore sconosciuto");
}
});
}
return (
<form onSubmit={handleSubmit} className="space-y-6 bg-white border border-border rounded-lg p-6">
{/* Tipo soggetto */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Soggetto</label>
<div className="flex gap-3">
<button
type="button"
onClick={() => setSubjectType("lead")}
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
subjectType === "lead"
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
Lead
</button>
<button
type="button"
onClick={() => setSubjectType("client")}
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
subjectType === "client"
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
Cliente
</button>
</div>
</div>
{/* Modello AI */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Modello AI</label>
<div className="flex gap-3">
<button
type="button"
onClick={() => setModel("opus")}
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
model === "opus"
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
Opus
</button>
<button
type="button"
onClick={() => setModel("sonnet")}
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
model === "sonnet"
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
Sonnet
</button>
</div>
</div>
{/* Select lead o cliente */}
{subjectType === "lead" ? (
<div className="space-y-1">
<label htmlFor="lead_id" className="text-sm font-medium text-foreground">
Lead
</label>
<select
id="lead_id"
name="lead_id"
required
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Seleziona un lead</option>
{leads.map((l) => (
<option key={l.id} value={l.id} selected={l.id === preselectedLeadId}>
{l.name}{l.company ? `${l.company}` : ""}
</option>
))}
</select>
</div>
) : (
<div className="space-y-1">
<label htmlFor="client_id" className="text-sm font-medium text-foreground">
Cliente
</label>
<select
id="client_id"
name="client_id"
required
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Seleziona un cliente</option>
{clients.map((c) => (
<option key={c.id} value={c.id}>
{c.brand_name} ({c.name})
</option>
))}
</select>
</div>
)}
{/* Select offerta */}
<div className="space-y-1">
<label htmlFor="offer_macro_id" className="text-sm font-medium text-foreground">
Offerta
</label>
<select
id="offer_macro_id"
name="offer_macro_id"
required
className="w-full border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Seleziona un&apos;offerta</option>
{offers.map((o) => (
<option key={o.id} value={o.id}>
{o.internal_name}{o.description ? `${o.description}` : ""}
</option>
))}
</select>
</div>
{/* Errore */}
{error && (
<p className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{error}
</p>
)}
{/* Info generazione */}
<div className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2">
L&apos;AI (Claude {model === "opus" ? "Opus" : "Sonnet"}) legge i transcript del soggetto selezionato e genera una bozza
personalizzata. La generazione richiede 3060 secondi.
</div>
{/* Submit */}
<button
type="submit"
disabled={isPending}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 disabled:opacity-60 transition-colors"
>
{isPending ? (
<>
<Loader2 size={16} className="animate-spin" />
Generazione in corso
</>
) : (
"Genera con AI →"
)}
</button>
</form>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { getAllLeads } from "@/lib/lead-service";
import { getOfferListCards } from "@/lib/offer-queries";
import { db } from "@/db";
import { clients } from "@/db/schema";
import { asc } from "drizzle-orm";
import { generateProposalDraft } from "../actions";
import { GeneraProposalForm } from "./GeneraProposalForm";
export const revalidate = 0;
export default async function GeneraPage({
searchParams,
}: {
searchParams: Promise<{ lead_id?: string }>;
}) {
const { lead_id: preselectedLeadId } = await searchParams;
const [leads, offerCards, allClients] = await Promise.all([
getAllLeads(200),
getOfferListCards(),
db.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name })
.from(clients)
.orderBy(asc(clients.name)),
]);
const activeOffers = offerCards.filter((o) => !o.is_archived);
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Genera preventivo</h1>
<p className="text-sm text-muted-foreground mt-1">
Scegli il lead o il cliente e l&apos;offerta. L&apos;AI legge i transcript e genera la bozza.
</p>
</div>
<GeneraProposalForm
leads={leads.map((l) => ({ id: l.id, name: l.name, company: l.company }))}
clients={allClients}
offers={activeOffers}
action={generateProposalDraft}
preselectedLeadId={preselectedLeadId}
/>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import Link from "next/link";
import { listProposals } from "@/lib/proposal/queries";
import { FileText, Plus } from "lucide-react";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0;
const STATE_LABELS: Record<string, { label: string; className: string }> = {
draft: { label: "Bozza", className: "bg-yellow-100 text-yellow-800" },
published: { label: "Pubblicato", className: "bg-blue-100 text-blue-800" },
accepted: { label: "Accettato", className: "bg-green-100 text-green-800" },
rejected: { label: "Rifiutato", className: "bg-red-100 text-red-800" },
};
export default async function PreventiviPage() {
const proposals = await listProposals();
const preventiviAction = (
<Link
href="/admin/preventivi/genera"
className="flex items-center gap-2 px-4 py-2 bg-[#1A463C] text-white rounded-md text-sm font-medium hover:bg-[#163a31] transition-colors"
>
<Plus size={16} />
Genera preventivo
</Link>
);
return (
<div className="space-y-6">
<PageHeader
title="Preventivi"
subtitle="Preventivi generati con AI e pubblicati ai clienti"
action={preventiviAction}
/>
{/* Lista */}
{proposals.length === 0 ? (
<div className="text-center py-20 border border-dashed border-[#e5e7eb] rounded-lg">
<FileText size={40} className="mx-auto text-[#71717a] mb-4" />
<p className="text-[#71717a] text-sm">Nessun preventivo ancora.</p>
<Link
href="/admin/preventivi/genera"
className="mt-4 inline-flex items-center gap-2 text-sm text-[#1A463C] hover:underline"
>
Genera il primo preventivo
</Link>
</div>
) : (
<div className="border border-[#e5e7eb] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr>
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Titolo</th>
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Lead / Cliente</th>
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Offerta</th>
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Stato</th>
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Data</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{proposals.map((p) => {
const stateInfo = STATE_LABELS[p.state] ?? { label: p.state, className: "bg-gray-100 text-gray-700" };
const subject = p.clientName ?? p.leadName ?? "—";
return (
<tr key={p.id} className="border-b border-[#e5e7eb] last:border-0 hover:bg-[#f9f9f9] transition-colors">
<td className="px-4 py-3 font-medium">
<Link href={`/admin/preventivi/${p.id}`} className="hover:text-[#1A463C] transition-colors">
{p.title ?? "Senza titolo"}
</Link>
</td>
<td className="px-4 py-3 text-[#71717a]">{subject}</td>
<td className="px-4 py-3 text-[#71717a]">{p.offerName}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${stateInfo.className}`}>
{stateInfo.label}
</span>
</td>
<td className="px-4 py-3 text-[#71717a]">
{p.createdAt.toLocaleDateString("it-IT")}
</td>
<td className="px-4 py-3 text-right">
{p.state === "published" || p.state === "accepted" ? (
<a
href={`/preventivo/${p.slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-[#1A463C] hover:underline"
>
Apri
</a>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+8 -15
View File
@@ -1,12 +1,11 @@
import { notFound } from "next/navigation";
import { getProjectFullDetail } from "@/lib/admin-queries";
import { getProjectFullDetail, getRecentTimeEntries } from "@/lib/admin-queries";
import { getTargetHourlyRate } from "@/lib/settings";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
import { TimerTab } from "@/components/admin/tabs/TimerTab";
import { OffersTab } from "@/components/admin/tabs/OffersTab";
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
@@ -27,6 +26,8 @@ export default async function ProjectDetailPage({
if (!detail) notFound();
const recentEntries = await getRecentTimeEntries(id, 5);
const {
project,
phases,
@@ -34,13 +35,13 @@ export default async function ProjectDetailPage({
documents,
notes,
comments,
quoteItems,
activeServices,
activeTimerEntryId,
activeTimerStartedAt,
totalTrackedSeconds,
projectOffers,
availableMicros,
offersAcceptedTotal,
transcripts,
} = detail;
return (
@@ -72,7 +73,6 @@ export default async function ProjectDetailPage({
<TabsTrigger value="documents">Documenti</TabsTrigger>
<TabsTrigger value="notes">Note</TabsTrigger>
<TabsTrigger value="comments">Commenti</TabsTrigger>
<TabsTrigger value="quote">Preventivo</TabsTrigger>
<TabsTrigger value="timer">Timer</TabsTrigger>
<TabsTrigger value="offers">Offerte</TabsTrigger>
</TabsList>
@@ -91,11 +91,12 @@ export default async function ProjectDetailPage({
acceptedTotal={project.accepted_total ?? "0"}
clientId={id}
projectId={id}
offersAcceptedTotal={offersAcceptedTotal}
/>
</TabsContent>
<TabsContent value="documents">
<DocumentsTab documents={documents} clientId={id} />
<DocumentsTab documents={documents} clientId={id} transcripts={transcripts} />
</TabsContent>
<TabsContent value="notes">
@@ -118,15 +119,6 @@ export default async function ProjectDetailPage({
<CommentsTab comments={comments} phases={phases} clientId={id} />
</TabsContent>
<TabsContent value="quote">
<QuoteTab
clientId={id}
items={quoteItems}
activeServices={activeServices}
acceptedTotal={project.accepted_total ?? "0"}
/>
</TabsContent>
<TabsContent value="timer">
<TimerTab
projectId={id}
@@ -135,6 +127,7 @@ export default async function ProjectDetailPage({
activeTimerStartedAt={activeTimerStartedAt}
totalTrackedSeconds={totalTrackedSeconds}
targetHourlyRate={targetHourlyRate}
recentEntries={recentEntries}
/>
</TabsContent>
+12 -10
View File
@@ -1,23 +1,25 @@
import { getAllProjectsWithPayments } from "@/lib/admin-queries";
import { ProjectRow } from "@/components/admin/ProjectRow";
import Link from "next/link";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0;
export default async function ProjectsPage() {
const projects = await getAllProjectsWithPayments();
const projectiAction = (
<Link
href="/admin/projects/new"
className="text-sm bg-[#1A463C] text-white px-4 py-2 rounded-lg hover:bg-[#1A463C]/90 transition-colors"
>
+ Nuovo Progetto
</Link>
);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Progetti</h1>
<Link
href="/admin/projects/new"
className="text-sm bg-[#1A463C] text-white px-4 py-2 rounded-lg hover:bg-[#1A463C]/90 transition-colors"
>
+ Nuovo Progetto
</Link>
</div>
<div className="space-y-6">
<PageHeader title="Progetti" action={projectiAction} />
{projects.length === 0 ? (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-12 text-center">
+144 -8
View File
@@ -4,8 +4,17 @@ import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/db";
import { projects, clients, payments, project_offers } from "@/db/schema";
import { eq } from "drizzle-orm";
import {
projects,
clients,
payments,
project_offers,
offer_tier_services,
services,
phases,
tasks,
} from "@/db/schema";
import { eq, asc } from "drizzle-orm";
import { z } from "zod";
import { nanoid } from "nanoid";
@@ -56,6 +65,7 @@ export async function updateProjectAcceptedTotal(projectId: string, acceptedTota
}
// Creates Acconto 50% + Saldo 50% stubs for a project that has no payments yet.
// Kept for backward compatibility; prefer setPaymentPlan for new code.
export async function initProjectPayments(projectId: string): Promise<void> {
await requireAdmin();
const rows = await db
@@ -65,11 +75,50 @@ export async function initProjectPayments(projectId: string): Promise<void> {
.limit(1);
if (!rows[0]) throw new Error("Progetto non trovato");
const total = parseFloat(rows[0].accepted_total ?? "0");
const half = (total / 2).toFixed(2);
await db.insert(payments).values([
{ project_id: projectId, label: "Acconto 50%", amount: half, status: "da_saldare" },
{ project_id: projectId, label: "Saldo 50%", amount: half, status: "da_saldare" },
]);
await setPaymentPlan(projectId, "two", total);
}
// Replaces all payments for a project with a fresh plan at a given total.
// Modes:
// single → 1 row 100% "Pagamento unico (100%)"
// two → 2 rows 50%/50% "Acconto 50% (inizio lavori)" / "Saldo 50% (alla consegna)"
// three → 3 rows 50%/30%/20%
export async function setPaymentPlan(
projectId: string,
mode: "single" | "two" | "three",
total: number
): Promise<void> {
await requireAdmin();
type PaymentDef = { label: string; percent: number };
const plans: Record<string, PaymentDef[]> = {
single: [{ label: "Pagamento unico (100%)", percent: 100 }],
two: [
{ label: "Acconto 50% (inizio lavori)", percent: 50 },
{ label: "Saldo 50% (alla consegna)", percent: 50 },
],
three: [
{ label: "Acconto 50% (inizio lavori)", percent: 50 },
{ label: "30% (post revisioni)", percent: 30 },
{ label: "Saldo 20% (alla consegna)", percent: 20 },
],
};
const plan = plans[mode];
if (!plan) throw new Error("Modalità pagamento non valida");
// Delete existing payments for this project, then insert new ones.
await db.delete(payments).where(eq(payments.project_id, projectId));
await db.insert(payments).values(
plan.map((p) => ({
project_id: projectId,
label: p.label,
percent: p.percent.toFixed(2),
amount: ((total * p.percent) / 100).toFixed(2),
status: "da_saldare" as const,
}))
);
revalidatePath(`/admin/projects/${projectId}`);
}
@@ -110,12 +159,94 @@ export async function splitPayment(
revalidatePath(`/admin/projects/${projectId}`);
}
// ── Offer → project phases/tasks import ───────────────────────────────────────
// An offer/tier has no phases of its own; it carries a flat list of services,
// and each service has a `fase` (catalog taxonomy). We materialize project
// phases by grouping the tier's services by `services.fase` (one phase per
// distinct fase, one task per service). Idempotent + mergeable: existing phases
// are matched by title (case-insensitive) and reused, duplicate tasks skipped —
// so assigning a 2nd offer with shared fasi adds tasks without duplicating.
const NO_FASE_LABEL = "Generale";
export async function importOfferIntoProject(projectId: string, microId: string): Promise<void> {
await requireAdmin();
// Tier services with their catalog fase, ordered (null fase last, then name).
const rows = await db
.select({ name: services.name, fase: services.fase })
.from(offer_tier_services)
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
.where(eq(offer_tier_services.tier_id, microId))
.orderBy(asc(services.fase), asc(services.name));
if (rows.length === 0) return;
// Group service names by fase, preserving first-seen order.
const groups: Array<{ fase: string; serviceNames: string[] }> = [];
const groupIndex = new Map<string, number>();
for (const r of rows) {
const fase = r.fase?.trim() || NO_FASE_LABEL;
let idx = groupIndex.get(fase.toLowerCase());
if (idx === undefined) {
idx = groups.length;
groupIndex.set(fase.toLowerCase(), idx);
groups.push({ fase, serviceNames: [] });
}
groups[idx].serviceNames.push(r.name);
}
// Existing project phases (for merge-by-title) + current max sort_order.
const existingPhases = await db
.select({ id: phases.id, title: phases.title, sort_order: phases.sort_order })
.from(phases)
.where(eq(phases.project_id, projectId));
const phaseByTitle = new Map(existingPhases.map((p) => [p.title.trim().toLowerCase(), p]));
let maxPhaseOrder = existingPhases.reduce((m, p) => Math.max(m, p.sort_order), -1);
for (const group of groups) {
let phaseId: string;
const existing = phaseByTitle.get(group.fase.toLowerCase());
if (existing) {
phaseId = existing.id;
} else {
maxPhaseOrder += 1;
const [inserted] = await db
.insert(phases)
.values({ project_id: projectId, title: group.fase, sort_order: maxPhaseOrder, status: "upcoming" })
.returning({ id: phases.id });
phaseId = inserted.id;
phaseByTitle.set(group.fase.toLowerCase(), { id: phaseId, title: group.fase, sort_order: maxPhaseOrder });
}
// Skip tasks whose title already exists in this phase.
const existingTasks = await db
.select({ title: tasks.title, sort_order: tasks.sort_order })
.from(tasks)
.where(eq(tasks.phase_id, phaseId));
const taskTitles = new Set(existingTasks.map((t) => t.title.trim().toLowerCase()));
let maxTaskOrder = existingTasks.reduce((m, t) => Math.max(m, t.sort_order), -1);
const toInsert = group.serviceNames
.filter((name) => !taskTitles.has(name.trim().toLowerCase()))
.map((name) => {
taskTitles.add(name.trim().toLowerCase());
maxTaskOrder += 1;
return { phase_id: phaseId, title: name, status: "todo", sort_order: maxTaskOrder };
});
if (toInsert.length > 0) await db.insert(tasks).values(toInsert);
}
revalidatePath(`/admin/projects/${projectId}`);
}
// ── Offer assignment actions ──────────────────────────────────────────────────
const assignOfferSchema = z.object({
project_id: z.string().min(1),
micro_id: z.string().min(1, "Seleziona una micro-offerta"),
micro_id: z.string().min(1, "Seleziona un'offerta"),
accepted_total: z.coerce.number().min(0).optional(),
import_phases: z.boolean().optional(),
});
export async function assignOfferToProject(formData: FormData) {
@@ -124,6 +255,7 @@ export async function assignOfferToProject(formData: FormData) {
project_id: formData.get("project_id"),
micro_id: formData.get("micro_id"),
accepted_total: formData.get("accepted_total") || undefined,
import_phases: formData.get("import_phases") === "on",
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.insert(project_offers).values({
@@ -134,6 +266,10 @@ export async function assignOfferToProject(formData: FormData) {
? parsed.data.accepted_total.toFixed(2)
: null,
});
// Materialize project phases/tasks from the tier's services grouped by fase.
if (parsed.data.import_phases) {
await importOfferIntoProject(parsed.data.project_id, parsed.data.micro_id);
}
revalidatePath(`/admin/projects/${parsed.data.project_id}`);
}
+53 -1
View File
@@ -61,7 +61,7 @@ export async function startTimerForClient(clientId: string): Promise<{ entryId:
export async function stopTimer(entryId: string): Promise<void> {
await requireAdmin();
const rows = await db
.select({ started_at: time_entries.started_at })
.select({ started_at: time_entries.started_at, project_id: time_entries.project_id })
.from(time_entries)
.where(eq(time_entries.id, entryId))
.limit(1);
@@ -77,4 +77,56 @@ export async function stopTimer(entryId: string): Promise<void> {
revalidatePath("/admin");
revalidatePath("/admin/projects");
revalidatePath(`/admin/projects/${rows[0].project_id}`);
}
/**
* Inserts a manually entered time block (already closed).
* duration_seconds = minutes * 60
* ended_at = started_at + duration_seconds
* @param projectId project to record time against
* @param minutes duration in minutes (must be > 0)
* @param startedAt optional ISO date string — defaults to start of current day
*/
export async function addManualTimeEntry(
projectId: string,
minutes: number,
startedAt?: string
): Promise<void> {
await requireAdmin();
if (!Number.isFinite(minutes) || minutes <= 0) throw new Error("Minuti non validi");
const start = startedAt ? new Date(startedAt) : new Date();
if (isNaN(start.getTime())) throw new Error("Data non valida");
const durationSeconds = Math.round(minutes * 60);
const end = new Date(start.getTime() + durationSeconds * 1000);
await db.insert(time_entries).values({
id: nanoid(),
project_id: projectId,
started_at: start,
ended_at: end,
duration_seconds: durationSeconds,
});
revalidatePath("/admin");
revalidatePath("/admin/projects");
revalidatePath(`/admin/projects/${projectId}`);
}
/**
* Deletes a time entry by id, scoped to the given project (guard against
* cross-project deletions).
*/
export async function deleteTimeEntry(entryId: string, projectId: string): Promise<void> {
await requireAdmin();
await db
.delete(time_entries)
.where(eq(time_entries.id, entryId));
revalidatePath("/admin");
revalidatePath("/admin/projects");
revalidatePath(`/admin/projects/${projectId}`);
}
+7 -2
View File
@@ -7,7 +7,7 @@ import { rateLimit } from "@/lib/rate-limit";
const commentSchema = z.object({
token: z.string().min(1),
entity_type: z.enum(["task", "deliverable", "general"]),
entity_type: z.enum(["task", "deliverable", "general", "phase"]),
entity_id: z.string().min(1),
body: z.string().min(1, "Il commento non può essere vuoto").max(2000),
});
@@ -76,7 +76,12 @@ export async function POST(request: NextRequest) {
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds));
if (entity_type === "task") {
if (entity_type === "phase") {
// Phase: entity_id must be one of the client's phases
if (!phasesForClient.find((p) => p.id === entity_id)) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
} else if (entity_type === "task") {
if (!taskRows.find((r) => r.id === entity_id)) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
+7
View File
@@ -67,6 +67,13 @@ function projectViewToClientView(
})),
global_progress_pct: view.global_progress_pct,
activeOffers: view.activeOffers,
transcripts: view.transcripts.map((t) => ({
id: t.id,
title: t.title,
call_date: t.call_date,
content: t.content,
created_at: t.created_at instanceof Date ? t.created_at.toISOString() : String(t.created_at),
})),
};
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+174 -40
View File
@@ -1,64 +1,164 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@source not "../../.01_projects/**";
@source not "../../.claude/**";
@source not "../../scripts/**";
@source not "../../.planning/**";
/* Class-based dark mode (Tailwind v4). The FOUC guard in layout.tsx toggles
`.dark` on <html> before paint; the useTheme hook keeps it in sync. */
@custom-variant dark (&:where(.dark, .dark *));
/* =========================================================
Design tokens — iamcavalli brand palette (Tailwind v4 @theme)
Design tokens — iamcavalli brand palette
Raw values live in :root (light) / .dark (dark) so the whole
app themes off a single class toggle; @theme inline below maps
them to Tailwind color utilities (bg-background, text-primary…).
========================================================= */
:root {
/* Base — sfondo generale app #F8F9FA (color-bg-light), testo slate-900 */
--background: #F8F9FA;
--foreground: #0F172A;
/* Brand primary — verde scuro iamcavalli */
--primary: #1A463C;
--primary-foreground: #ffffff;
/* Brand accent — giallo lime */
--accent: #DEF168;
--accent-foreground: #1A463C;
/* Secondary — grigio neutro (slate-100) */
--secondary: #f1f5f9;
--secondary-foreground: #0F172A;
/* Muted — sfondi secondari (slate-50) / testo slate-500 */
--muted: #f8fafc;
--muted-foreground: #64748B;
/* Destructive */
--destructive: #ef4444;
--destructive-foreground: #ffffff;
/* Card — bianco puro (color-bg-card) */
--card: #FFFFFF;
--card-foreground: #0F172A;
/* Popover */
--popover: #FFFFFF;
--popover-foreground: #0F172A;
/* Border (slate-200 = border-card) / Input / Ring */
--border: #e2e8f0;
--input: #e2e8f0;
--ring: #1A463C;
/* Semantic — stato task/pagamenti */
--success: #16a34a;
--warning: #ca8a04;
--info: #2563eb;
/* Legacy — text-light slate-400, border-soft slate-100 */
--tertiary: #94A3B8;
--bg-subtle: #f8fafc;
--border-light: #f1f5f9;
--radius: 0.5rem;
}
.dark {
/* Base — verde-nero, coerente col brand */
--background: #0e1512;
--foreground: #f2f4f3;
/* Brand primary — verde schiarito per contrasto su fondo scuro */
--primary: #3FA88C;
--primary-foreground: #06110d;
/* Brand accent — lime invariato, testo verde scuro */
--accent: #DEF168;
--accent-foreground: #14231d;
--secondary: #1b2420;
--secondary-foreground: #f2f4f3;
--muted: #171f1b;
--muted-foreground: #9aa39e;
--destructive: #f87171;
--destructive-foreground: #1a0a0a;
--card: #131a16;
--card-foreground: #f2f4f3;
--popover: #131a16;
--popover-foreground: #f2f4f3;
--border: #26302b;
--input: #26302b;
--ring: #3FA88C;
--success: #22c55e;
--warning: #eab308;
--info: #3b82f6;
--tertiary: #8a938d;
--bg-subtle: #171f1b;
--border-light: #26302b;
}
@theme inline {
/* Font */
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont,
--font-sans: var(--font-plus-jakarta-sans), system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, "Helvetica Neue", sans-serif;
--font-mono: var(--font-geist-mono);
/* Base */
--color-background: #ffffff;
--color-foreground: #1a1a1a;
/* Colors — mapped from the raw tokens above */
--color-background: var(--background);
--color-foreground: var(--foreground);
/* Brand primary — verde scuro iamcavalli */
--color-primary: #1A463C;
--color-primary-foreground: #ffffff;
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
/* Brand accent — giallo lime */
--color-accent: #DEF168;
--color-accent-foreground: #1A463C;
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
/* Secondary — grigio neutro */
--color-secondary: #f4f4f5;
--color-secondary-foreground: #1a1a1a;
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
/* Muted — per testi e sfondi secondari */
--color-muted: #f9f9f9;
--color-muted-foreground: #71717a;
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
/* Destructive */
--color-destructive: #ef4444;
--color-destructive-foreground: #ffffff;
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
/* Card */
--color-card: #ffffff;
--color-card-foreground: #1a1a1a;
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
/* Popover */
--color-popover: #ffffff;
--color-popover-foreground: #1a1a1a;
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
/* Border / Input / Ring */
--color-border: #e5e7eb;
--color-input: #e5e7eb;
--color-ring: #1A463C;
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Semantic — stato task/pagamenti */
--color-success: #16a34a;
--color-warning: #ca8a04;
--color-info: #2563eb;
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
/* Legacy — usati inline nei componenti esistenti */
--color-tertiary: #999999;
--color-bg-subtle: #f9f9f9;
--color-border-light: #e5e5e5;
--color-tertiary: var(--tertiary);
--color-bg-subtle: var(--bg-subtle);
--color-border-light: var(--border-light);
/* Radius — wire the base --radius into Tailwind's radius scale so
rounded-lg / rounded-xl resolve off a single token. */
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 0.25rem);
/* Luxury shadows — very low-opacity, wide-spread card elevation
("Quiet Luxury" design system). */
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.01);
--shadow-card-hover: 0 8px 30px rgba(0, 0, 0, 0.02);
}
/* =========================================================
@@ -75,10 +175,44 @@ html {
}
body {
background-color: #ffffff;
color: #171717;
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* =========================================================
Scrollbar utilities (ported from OMC design system)
========================================================= */
@layer utilities {
/* Hide the native scrollbar without breaking scroll. */
.no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Very thin, muted scrollbar — discoverable but low-noise. */
.thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.thin-scrollbar::-webkit-scrollbar {
height: 3px;
width: 3px;
}
.thin-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 9999px;
}
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+11 -5
View File
@@ -1,10 +1,11 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Plus_Jakarta_Sans, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
const plusJakartaSans = Plus_Jakarta_Sans({
variable: "--font-plus-jakarta-sans",
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
});
const geistMono = Geist_Mono({
@@ -30,9 +31,14 @@ export default function RootLayout({
return (
<html
lang="it"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${plusJakartaSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-white text-zinc-900">
<body className="min-h-full flex flex-col bg-background text-foreground">
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var k='iamcavalli:theme';var p=localStorage.getItem(k);var d=p==='dark'||((p==null||p==='system')&&window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',d);}catch(e){}})();`,
}}
/>
{children}
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
"use server";
import { db } from "@/db";
import { proposals } from "@/db/schema";
import { eq, and, isNull, isNotNull } from "drizzle-orm";
import { revalidatePath } from "next/cache";
// Guard immutabilità — accepted_at è IMMUTABILE una volta valorizzato
async function getProposalForAction(slug: string) {
const [row] = await db
.select({ id: proposals.id, state: proposals.state, accepted_at: proposals.accepted_at })
.from(proposals)
.where(eq(proposals.slug, slug))
.limit(1);
return row ?? null;
}
export async function acceptProposal(
slug: string,
tier: string,
email?: string,
notes?: string
) {
const proposal = await getProposalForAction(slug);
if (!proposal) throw new Error("Proposta non trovata");
if (proposal.accepted_at) throw new Error("Proposta già accettata");
if (proposal.state !== "published") throw new Error("Proposta non pubblicata");
if (!["A", "B", "C"].includes(tier)) throw new Error("Tier non valido");
await db
.update(proposals)
.set({
state: "accepted",
selected_tier: tier,
accepted_at: new Date(),
client_email: email?.trim() || null,
client_notes: notes?.trim() || null,
updated_at: new Date(),
})
.where(and(eq(proposals.slug, slug), isNull(proposals.accepted_at)));
revalidatePath(`/preventivo/${slug}`);
}
export async function rejectProposal(slug: string, notes?: string) {
const proposal = await getProposalForAction(slug);
if (!proposal) throw new Error("Proposta non trovata");
if (proposal.accepted_at) throw new Error("Proposta già accettata");
if (proposal.state !== "published") throw new Error("Proposta non pubblicata");
await db
.update(proposals)
.set({
state: "rejected",
client_notes: notes?.trim() || null,
updated_at: new Date(),
})
.where(and(eq(proposals.slug, slug), isNull(proposals.accepted_at)));
revalidatePath(`/preventivo/${slug}`);
}
+39
View File
@@ -0,0 +1,39 @@
import { notFound } from "next/navigation";
import { getProposalBySlug } from "@/lib/proposal/queries";
import { ProposalDeck } from "@/components/public/proposal/ProposalDeck";
import { acceptProposal, rejectProposal } from "./actions";
export const revalidate = 0;
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const proposal = await getProposalBySlug(slug);
if (!proposal) return { title: "Preventivo non trovato" };
return {
title: proposal.title ?? "Documento di Soluzione · iamcavalli",
description: `Proposta riservata — ${proposal.content.header.clientName} × ${proposal.content.consultant.name}`,
};
}
export default async function ProposalPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const proposal = await getProposalBySlug(slug);
if (!proposal) notFound();
// Proposta non ancora pubblicata
if (proposal.state === "draft") {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<p className="text-muted-foreground text-sm">Questa proposta non è ancora disponibile.</p>
</div>
);
}
return (
<ProposalDeck
proposal={proposal}
onAccept={acceptProposal}
onReject={rejectProposal}
/>
);
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Check, Monitor, Moon, Sun } from 'lucide-react'
import { useTheme, type ThemePref } from '@/lib/theme'
import { cn } from '@/lib/utils'
const OPTIONS: { value: ThemePref; label: string; icon: typeof Sun }[] = [
{ value: 'system', label: 'Sistema', icon: Monitor },
{ value: 'light', label: 'Chiaro', icon: Sun },
{ value: 'dark', label: 'Scuro', icon: Moon },
]
/** Header theme switcher — Sistema / Chiaro / Scuro. Defaults to Sistema
* (follows the OS). Dropdown closes on outside-click + Esc. */
export default function ThemeToggle() {
const { pref, resolved, setTheme } = useTheme()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDocClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onEsc)
return () => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onEsc)
}
}, [open])
const TriggerIcon = pref === 'system' ? Monitor : resolved === 'dark' ? Moon : Sun
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
title="Tema"
aria-label="Tema"
className="flex items-center justify-center size-9 rounded-full border bg-card/70 hover:bg-card transition-colors text-muted-foreground hover:text-foreground"
>
<TriggerIcon className="size-4" />
</button>
{open && (
<div className="absolute top-full right-0 mt-2 w-40 bg-popover text-popover-foreground border rounded-xl shadow-xl overflow-hidden z-50 p-1">
{OPTIONS.map((o) => {
const Icon = o.icon
const active = o.value === pref
return (
<button
key={o.value}
type="button"
onClick={() => {
setTheme(o.value)
setOpen(false)
}}
className={cn(
'w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-sm transition-colors text-left',
active ? 'bg-primary/10 text-primary' : 'hover:bg-muted',
)}
>
<Icon className="size-4" />
{o.label}
{active && <Check className="size-3.5 ml-auto" />}
</button>
)
})}
</div>
)}
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { Menu, User } from "lucide-react";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
const SIDEBAR_STORAGE_KEY = "iamcavalli:sidebar";
/**
* Client shell for every /admin/* page: collapsible sidebar + top header +
* <main>. Collapse state is persisted to localStorage and read on mount
* (starts expanded on server/first paint to avoid a hydration mismatch,
* then reconciles client-side — mirrors the pattern used by useTheme).
*/
export function AdminShell({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
if (stored === "collapsed") setCollapsed(true);
}, []);
function toggleCollapsed() {
setCollapsed((prev) => {
const next = !prev;
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, next ? "collapsed" : "expanded");
return next;
});
}
return (
<div className="flex min-h-screen bg-background">
<AdminSidebar collapsed={collapsed} />
<div className="flex-1 flex flex-col min-w-0">
{/* Top header bar */}
<header className="h-16 shrink-0 bg-card border-b border-border px-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={toggleCollapsed}
title="Espandi/Comprimi Sidebar"
aria-label="Espandi/Comprimi Sidebar"
className="p-2 -ml-2 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus:outline-none"
>
<Menu className="w-5 h-5" />
</button>
<span className="text-xs font-medium text-muted-foreground">Area di Lavoro</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-semibold text-foreground">Admin</span>
{/* TODO: gestione utenti/collaboratori — placeholder avatar until
real user accounts + uploaded profile images are wired up. */}
<div className="w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center shrink-0">
<User className="w-4 h-4" />
</div>
</div>
</header>
<main className="flex-1 overflow-y-auto">
<div className="max-w-[1400px] w-full mx-auto px-8 py-8 lg:px-10">{children}</div>
</main>
</div>
</div>
);
}
+67 -23
View File
@@ -12,60 +12,104 @@ import {
Settings,
LogOut,
Zap,
FileText,
} from "lucide-react";
import ThemeToggle from "@/components/ThemeToggle";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
{ href: "/admin/clients", label: "Clienti", icon: Users },
{ href: "/admin/leads", label: "Lead", icon: Zap },
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
{ href: "/admin/offers", label: "Offerte", icon: Tag },
{ href: "/admin/catalog", label: "Catalogo", icon: BookOpen },
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
{ href: "/admin/pipeline", label: "Pipeline", icon: Zap },
{ href: "/admin/clients", label: "Clienti", icon: Users },
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
{ href: "/admin/preventivi", label: "Preventivi", icon: FileText },
{ href: "/admin/offers", label: "Offerte", icon: Tag },
{ href: "/admin/catalog", label: "Catalogo", icon: BookOpen },
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
];
export function AdminSidebar() {
export function AdminSidebar({ collapsed = false }: { collapsed?: boolean }) {
const pathname = usePathname();
const isActive = (href: string, exact?: boolean) =>
exact ? pathname === href : pathname === href || pathname.startsWith(href + "/");
// Shared row layout: full width + gap + label when expanded, icon centered
// (no gap, no padding) when collapsed so nothing pushes the icon off-center.
const rowClass = collapsed ? "justify-center px-0" : "gap-3 px-3";
return (
<aside className="w-56 shrink-0 min-h-screen bg-[#1A463C] flex flex-col">
<aside
className={cn(
"shrink-0 min-h-screen bg-[#1A463C] border-r border-emerald-950/20 flex flex-col overflow-hidden",
"transition-[width] duration-350 ease-[cubic-bezier(0.4,0,0.2,1)]",
collapsed ? "w-16" : "w-[200px]"
)}
>
{/* Logo */}
<div className="px-5 py-5 border-b border-white/10">
<span className="font-bold text-white tracking-tight text-sm">iamcavalli</span>
<div
className={cn(
"h-[57px] flex items-center border-b border-white/10",
collapsed ? "justify-center px-0" : "px-5"
)}
>
{!collapsed && (
<span className="font-bold text-emerald-50 tracking-tight text-sm whitespace-nowrap">
iamcavalli
</span>
)}
</div>
{/* Nav links */}
<nav className="flex-1 px-3 py-4 flex flex-col gap-0.5">
<nav className="flex-1 px-2 py-4 flex flex-col gap-0.5">
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
const active = isActive(href, exact);
return (
<Link
key={href}
href={href}
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
title={collapsed ? label : undefined}
className={cn(
"flex items-center py-2.5 rounded-lg text-sm transition-all duration-200",
rowClass,
active
? "bg-white/15 text-white font-medium"
: "text-white/65 hover:text-white hover:bg-white/10"
}`}
? "bg-white/[0.08] text-white font-medium shadow-sm"
: "text-emerald-200/70 hover:text-white hover:bg-white/5"
)}
>
<Icon size={16} strokeWidth={1.8} />
{label}
<Icon
size={16}
strokeWidth={1.8}
className={cn("shrink-0", active && "text-emerald-400")}
/>
{!collapsed && <span className="whitespace-nowrap">{label}</span>}
</Link>
);
})}
</nav>
{/* Logout */}
<div className="px-3 py-4 border-t border-white/10">
{/* Theme + Logout */}
<div className="px-2 py-4 border-t border-white/10 flex flex-col gap-2">
<div
className={cn(
"flex items-center py-1",
collapsed ? "justify-center px-0" : "justify-between px-3"
)}
>
{!collapsed && <span className="text-xs text-emerald-200/50">Tema</span>}
<ThemeToggle />
</div>
<button
onClick={() => signOut({ callbackUrl: "/admin/login" })}
className="flex items-center gap-3 px-3 py-2 w-full rounded-md text-sm text-white/65 hover:text-white hover:bg-white/10 transition-colors"
title={collapsed ? "Esci" : undefined}
className={cn(
"flex items-center py-2.5 w-full rounded-lg text-sm transition-all duration-200",
"text-red-300/80 hover:text-red-200 hover:bg-red-950/20",
rowClass
)}
>
<LogOut size={16} strokeWidth={1.8} />
Esci
<LogOut size={16} strokeWidth={1.8} className="shrink-0" />
{!collapsed && <span className="whitespace-nowrap">Esci</span>}
</button>
</div>
</aside>
@@ -0,0 +1,60 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { deletePhase, deleteTask } from "@/app/admin/clients/[id]/actions";
interface DeletePhaseButtonProps {
type: "phase";
phaseId: string;
clientId: string;
}
interface DeleteTaskButtonProps {
type: "task";
taskId: string;
clientId: string;
}
type Props = DeletePhaseButtonProps | DeleteTaskButtonProps;
export function DeletePhaseTaskButton(props: Props) {
const [, startTransition] = useTransition();
const router = useRouter();
function handleClick() {
const label = props.type === "phase" ? "questa fase (e tutti i task/deliverable al suo interno)" : "questo task";
if (!window.confirm(`Eliminare ${label}? L'azione non è reversibile.`)) return;
startTransition(async () => {
if (props.type === "phase") {
await deletePhase(props.phaseId, props.clientId);
} else {
await deleteTask(props.taskId, props.clientId);
}
router.refresh();
});
}
return (
<button
type="button"
onClick={handleClick}
title={props.type === "phase" ? "Elimina fase" : "Elimina task"}
className="p-1 rounded text-[#999999] hover:text-red-500 hover:bg-red-50 transition-colors"
aria-label={props.type === "phase" ? "Elimina fase" : "Elimina task"}
>
{/* Trash icon */}
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { ForecastMonth } from "@/lib/forecast-queries";
function fmtEur(n: number) {
return n.toLocaleString("it-IT", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
export function ForecastChart({ data }: { data: ForecastMonth[] }) {
const max = Math.max(...data.map((d) => d.total), 1);
// index 0 = mese corrente, index 1 = mese prossimo (evidenziato)
const nextMonth = data[1];
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-5">
{nextMonth && (
<div className="flex items-baseline justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-wider text-[#71717a]">
Previsto il mese prossimo
</p>
<p className="text-xs text-[#a1a1aa] mt-0.5 capitalize">{nextMonth.label}</p>
</div>
<p className="text-2xl font-bold tracking-tight text-[#1A463C]">
{fmtEur(nextMonth.total)}
</p>
</div>
)}
<div className="flex items-end gap-2 h-44 border-t border-[#f4f4f5] pt-4">
{data.map((m, i) => {
const pct = Math.round((m.total / max) * 100);
const isNext = i === 1;
return (
<div key={`${m.year}-${m.month}`} className="flex-1 flex flex-col items-center gap-1">
<div className="w-full flex flex-col justify-end" style={{ height: "120px" }}>
<div
className={`w-full rounded-t-md transition-all ${
isNext ? "bg-[#1A463C]" : "bg-[#1A463C]/35"
}`}
style={{ height: `${pct}%`, minHeight: m.total > 0 ? "4px" : "0" }}
title={`${m.label}: ${m.total.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })}`}
/>
</div>
<span
className={`text-[10px] capitalize ${
isNext ? "font-bold text-[#1A463C]" : "text-[#71717a]"
}`}
>
{m.label.split(" ")[0]}
</span>
</div>
);
})}
</div>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { addManualTimeEntry, deleteTimeEntry } from "@/app/admin/timer-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type TimeEntry = {
id: string;
started_at: Date;
ended_at: Date | null;
duration_seconds: number | null;
};
function formatDuration(seconds: number | null): string {
if (!seconds) return "0 min";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}min`;
return `${m} min`;
}
function formatDate(date: Date): string {
return new Date(date).toLocaleDateString("it-IT", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
type Props = {
projectId: string;
recentEntries: TimeEntry[];
};
export function ManualTimeEntry({ projectId, recentEntries }: Props) {
const router = useRouter();
const [, startTransition] = useTransition();
const [minutes, setMinutes] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().split("T")[0]);
const [loading, setLoading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
async function handleQuickAdd(mins: number) {
setLoading(true);
try {
const startedAt = new Date(`${date}T09:00:00`).toISOString();
await addManualTimeEntry(projectId, mins, startedAt);
startTransition(() => router.refresh());
} finally {
setLoading(false);
}
}
async function handleCustomAdd(e: React.FormEvent) {
e.preventDefault();
const mins = parseFloat(minutes);
if (!mins || mins <= 0) return;
setLoading(true);
try {
const startedAt = new Date(`${date}T09:00:00`).toISOString();
await addManualTimeEntry(projectId, mins, startedAt);
setMinutes("");
startTransition(() => router.refresh());
} finally {
setLoading(false);
}
}
async function handleDelete(entryId: string) {
setDeletingId(entryId);
try {
await deleteTimeEntry(entryId, projectId);
startTransition(() => router.refresh());
} finally {
setDeletingId(null);
}
}
return (
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-4">
<h3 className="font-medium text-[#1a1a1a]">Aggiungi tempo manuale</h3>
{/* Data */}
<div className="space-y-1">
<Label htmlFor="entry-date">Data</Label>
<Input
id="entry-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="max-w-[180px]"
/>
</div>
{/* Quick buttons */}
<div>
<p className="text-xs text-[#71717a] mb-2">Aggiunte rapide</p>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={loading}
onClick={() => handleQuickAdd(30)}
>
+30 min
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={loading}
onClick={() => handleQuickAdd(60)}
>
+60 min
</Button>
</div>
</div>
{/* Custom minutes */}
<form onSubmit={handleCustomAdd} className="flex items-end gap-2">
<div className="space-y-1 flex-1">
<Label htmlFor="manual-minutes">Minuti personalizzati</Label>
<Input
id="manual-minutes"
type="number"
min="1"
step="1"
placeholder="es. 45"
value={minutes}
onChange={(e) => setMinutes(e.target.value)}
className="max-w-[120px]"
/>
</div>
<Button type="submit" size="sm" disabled={loading || !minutes}>
Aggiungi
</Button>
</form>
{/* Recent entries */}
{recentEntries.length > 0 && (
<div>
<p className="text-xs text-[#71717a] mb-2 font-medium">Ultime registrazioni</p>
<ul className="space-y-1.5">
{recentEntries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between gap-2 bg-[#f9f9f9] rounded px-3 py-2"
>
<div className="min-w-0">
<p className="text-xs font-medium text-[#1a1a1a]">
{formatDuration(entry.duration_seconds)}
</p>
<p className="text-xs text-[#71717a]">{formatDate(entry.started_at)}</p>
</div>
<button
type="button"
disabled={deletingId === entry.id}
onClick={() => handleDelete(entry.id)}
className="text-[#999999] hover:text-red-500 transition-colors shrink-0 text-xs"
aria-label="Elimina registrazione"
>
{deletingId === entry.id ? "..." : "✕"}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import type { OffersSoldBreakdown } from "@/lib/forecast-queries";
export function OffersSoldChart({ data }: { data: OffersSoldBreakdown }) {
const max = Math.max(...data.rows.map((r) => r.count), 1);
if (data.rows.length === 0) {
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-8 text-center">
<p className="text-sm text-[#71717a] italic">Nessuna offerta venduta finora.</p>
</div>
);
}
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-[#1a1a1a]">Offerte vendute</h3>
<span className="inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full bg-[#1A463C] text-white text-xs font-bold">
{data.total}
</span>
</div>
<div className="space-y-3">
{data.rows.map((row, i) => {
const pct = Math.round((row.count / max) * 100);
return (
<div key={`${row.offer_name}-${row.tier_letter ?? "—"}-${i}`}>
<div className="flex items-center justify-between mb-1 gap-2">
<span className="text-sm font-medium text-[#1a1a1a] truncate">
{row.offer_name}
{row.tier_letter && (
<span className="ml-2 inline-flex items-center rounded-full bg-[#1A463C]/10 px-2 py-0.5 text-[11px] font-semibold text-[#1A463C]">
Tier {row.tier_letter}
</span>
)}
</span>
<span className="text-sm tabular-nums text-[#71717a] shrink-0">{row.count}</span>
</div>
<div className="h-2 rounded-full bg-[#f4f4f5] overflow-hidden">
<div
className="h-full rounded-full bg-[#1A463C] transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
export function PageHeader({ title, subtitle, action }: {
title: string;
subtitle?: string;
action?: ReactNode;
}) {
return (
<div className="mb-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
{subtitle && <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>}
</div>
{action}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
type Transcript = {
id: string;
title: string | null;
call_date: string;
content: string;
created_at: Date;
};
export function TranscriptItem({ transcript }: { transcript: Transcript }) {
const [expanded, setExpanded] = useState(false);
const date = new Date(transcript.call_date).toLocaleDateString("it-IT", {
day: "2-digit",
month: "long",
year: "numeric",
});
return (
<div className="border border-[#e5e7eb] rounded-lg bg-white overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-[#f9f9f9] transition-colors"
aria-expanded={expanded}
>
<div className="min-w-0">
<p className="text-sm font-medium text-[#1a1a1a] truncate">
{transcript.title ?? "Transcript chiamata"}
</p>
<p className="text-xs text-[#71717a] mt-0.5">{date}</p>
</div>
<svg
className={`w-4 h-4 text-[#999999] shrink-0 ml-3 transition-transform ${expanded ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded && (
<div className="px-4 pb-4 pt-1 border-t border-[#e5e7eb]">
<pre className="text-xs text-[#1a1a1a] whitespace-pre-wrap font-sans leading-relaxed max-h-96 overflow-y-auto">
{transcript.content}
</pre>
</div>
)}
</div>
);
}
+258 -152
View File
@@ -2,6 +2,7 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ArrowUp, ArrowDown, ArrowUpDown, GripVertical } from "lucide-react";
import { Input } from "@/components/ui/input";
import { EditableCell } from "@/components/ui/editable-cell";
import { OptionSelect } from "@/components/ui/option-select";
@@ -15,28 +16,42 @@ import {
} from "@/app/admin/catalog/actions";
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
export type ColumnKey =
| "nome"
| "descrizione"
| "fase"
| "offerta"
| "pacchetto"
| "prezzo"
| "stato";
export const COLUMN_DEFS: Record<ColumnKey, { header: string; sortable: boolean; minWidth: string }> = {
nome: { header: "Nome", sortable: true, minWidth: "160px" },
descrizione: { header: "Descrizione", sortable: false, minWidth: "200px" },
fase: { header: "Fase", sortable: true, minWidth: "140px" },
offerta: { header: "Offerta", sortable: true, minWidth: "180px" },
pacchetto: { header: "Pacchetto", sortable: true, minWidth: "180px" },
prezzo: { header: "Prezzo", sortable: true, minWidth: "100px" },
stato: { header: "Stato", sortable: false, minWidth: "100px" },
};
export const DEFAULT_COL_ORDER: ColumnKey[] = [
"nome", "descrizione", "fase", "offerta", "pacchetto", "prezzo", "stato",
];
function formatPrice(raw: string): string {
const num = parseFloat(raw);
return `${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
}
const COLUMN_HEADERS = [
"Nome",
"Descrizione",
"Fase",
"Offerta",
"Pacchetto",
"Prezzo",
"Stato",
];
const COL_COUNT = COLUMN_HEADERS.length;
function ServiceRow({
service,
options,
colOrder,
}: {
service: ServiceWithTags;
options: CatalogFieldOptions;
colOrder: ColumnKey[];
}) {
const router = useRouter();
const [, startTransition] = useTransition();
@@ -54,83 +69,116 @@ function ServiceRow({
});
}
function renderCell(key: ColumnKey) {
switch (key) {
case "nome":
return (
<td key={key} className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
<EditableCell
value={service.name}
type="text"
required
onSave={(v) => run(() => updateServiceField(service.id, "name", v))}
/>
</td>
);
case "descrizione":
return (
<td key={key} className="py-2 px-3 text-[#71717a] min-w-[200px]">
<EditableCell
value={service.description ?? ""}
type="textarea"
placeholder="Descrizione..."
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
/>
</td>
);
case "fase":
return (
<td key={key} className="py-2 px-3 min-w-[140px]">
<OptionSelect
value={service.fase}
options={options.fase}
onChange={(v) => run(() => updateServiceField(service.id, "fase", v ?? ""))}
onRename={(o, n) => run(() => renameServiceOption("fase", o, n))}
/>
</td>
);
case "offerta":
return (
<td key={key} className="py-2 px-3 min-w-[180px]">
<OptionMultiSelect
values={service.tags}
options={options.tag}
onAdd={(v) => run(() => addServiceOption("tag", service.id, v))}
onRemove={(v) => run(() => removeServiceOption("tag", service.id, v))}
onRename={(o, n) => run(() => renameServiceOption("tag", o, n))}
/>
</td>
);
case "pacchetto":
return (
<td key={key} className="py-2 px-3 min-w-[180px]">
<OptionMultiSelect
values={service.pacchetto}
options={options.pacchetto}
onAdd={(v) => run(() => addServiceOption("pacchetto", service.id, v))}
onRemove={(v) => run(() => removeServiceOption("pacchetto", service.id, v))}
onRename={(o, n) => run(() => renameServiceOption("pacchetto", o, n))}
/>
</td>
);
case "prezzo":
return (
<td key={key} className="py-2 px-3 tabular-nums min-w-[100px]">
<EditableCell
value={service.unit_price}
type="number"
formatDisplay={formatPrice}
onSave={(v) => run(() => updateServiceField(service.id, "unit_price", v))}
/>
</td>
);
case "stato":
return (
<td key={key} className="py-2 px-3 min-w-[100px]">
<EditableCell
value={service.active ? "true" : "false"}
type="toggle"
onSave={(v) => run(() => updateServiceField(service.id, "active", v))}
/>
</td>
);
}
}
return (
<>
<tr
className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${
!service.active ? "opacity-50" : ""
}`}
>
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
<EditableCell
value={service.name}
type="text"
required
onSave={(v) => run(() => updateServiceField(service.id, "name", v))}
/>
</td>
<td className="py-2 px-3 text-[#71717a] min-w-[200px]">
<EditableCell
value={service.description ?? ""}
type="textarea"
placeholder="Descrizione..."
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
/>
</td>
<td className="py-2 px-3 min-w-[140px]">
<OptionSelect
value={service.fase}
options={options.fase}
onChange={(v) => run(() => updateServiceField(service.id, "fase", v ?? ""))}
onRename={(o, n) => run(() => renameServiceOption("fase", o, n))}
/>
</td>
<td className="py-2 px-3 min-w-[180px]">
<OptionMultiSelect
values={service.tags}
options={options.tag}
onAdd={(v) => run(() => addServiceOption("tag", service.id, v))}
onRemove={(v) => run(() => removeServiceOption("tag", service.id, v))}
onRename={(o, n) => run(() => renameServiceOption("tag", o, n))}
/>
</td>
<td className="py-2 px-3 min-w-[180px]">
<OptionMultiSelect
values={service.pacchetto}
options={options.pacchetto}
onAdd={(v) => run(() => addServiceOption("pacchetto", service.id, v))}
onRemove={(v) => run(() => removeServiceOption("pacchetto", service.id, v))}
onRename={(o, n) => run(() => renameServiceOption("pacchetto", o, n))}
/>
</td>
<td className="py-2 px-3 tabular-nums min-w-[100px]">
<EditableCell
value={service.unit_price}
type="number"
formatDisplay={formatPrice}
onSave={(v) => run(() => updateServiceField(service.id, "unit_price", v))}
/>
</td>
<td className="py-2 px-3 min-w-[100px]">
<EditableCell
value={service.active ? "true" : "false"}
type="toggle"
onSave={(v) => run(() => updateServiceField(service.id, "active", v))}
/>
</td>
</tr>
{error && (
<tr>
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
{error}
</td>
<tr
className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${
!service.active ? "opacity-50" : ""
}`}
>
{colOrder.map((key) => renderCell(key))}
</tr>
)}
{error && (
<tr>
<td colSpan={colOrder.length} className="py-1 px-3 text-xs text-red-600">
{error}
</td>
</tr>
)}
</>
);
}
function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
function QuickAddRow({
options,
colOrder,
}: {
options: CatalogFieldOptions;
colOrder: ColumnKey[];
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [fase, setFase] = useState("");
@@ -172,77 +220,120 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
const cellInput =
"border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary";
function renderCell(key: ColumnKey) {
switch (key) {
case "nome":
return (
<td key={key} className="py-2 px-3">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={onKeyDown}
placeholder="+ Aggiungi servizio"
className={cellInput}
/>
</td>
);
case "descrizione":
return (
<td key={key} className="py-2 px-3">
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Descrizione..."
className={cellInput}
/>
</td>
);
case "fase":
return (
<td key={key} className="py-2 px-3">
<Input
value={fase}
onChange={(e) => setFase(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Fase..."
list="catalog-fase-pool"
className={cellInput}
/>
<datalist id="catalog-fase-pool">
{options.fase.map((f) => (
<option key={f} value={f} />
))}
</datalist>
</td>
);
case "prezzo":
return (
<td key={key} className="py-2 px-3">
<Input
value={price}
onChange={(e) => setPrice(e.target.value)}
onKeyDown={onKeyDown}
placeholder="0,00"
inputMode="decimal"
className={`${cellInput} tabular-nums`}
/>
</td>
);
case "stato":
return (
<td key={key} className="py-2 px-3 text-xs text-[#a1a1aa]">
Invio
</td>
);
default:
return <td key={key} className="py-2 px-3 text-xs text-[#a1a1aa]" />;
}
}
return (
<>
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
<td className="py-2 px-3">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={onKeyDown}
placeholder="+ Aggiungi servizio"
className={cellInput}
/>
</td>
<td className="py-2 px-3">
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Descrizione..."
className={cellInput}
/>
</td>
<td className="py-2 px-3">
<Input
value={fase}
onChange={(e) => setFase(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Fase..."
list="catalog-fase-pool"
className={cellInput}
/>
</td>
<td className="py-2 px-3 text-xs text-[#a1a1aa]" colSpan={2}>
Offerta e pacchetto dopo la creazione
</td>
<td className="py-2 px-3">
<Input
value={price}
onChange={(e) => setPrice(e.target.value)}
onKeyDown={onKeyDown}
placeholder="0,00"
inputMode="decimal"
className={`${cellInput} tabular-nums`}
/>
</td>
<td className="py-2 px-3 text-xs text-[#a1a1aa]">Invio </td>
<datalist id="catalog-fase-pool">
{options.fase.map((f) => (
<option key={f} value={f} />
))}
</datalist>
</tr>
{error && (
<tr>
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
{error}
</td>
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
{colOrder.map((key) => renderCell(key))}
</tr>
)}
{error && (
<tr>
<td colSpan={colOrder.length} className="py-1 px-3 text-xs text-red-600">
{error}
</td>
</tr>
)}
</>
);
}
export function ServiceTable({
services,
activeServices,
inactiveServices,
options,
colOrder,
sortKey,
sortDir,
onSortClick,
onColDragStart,
onColDragOver,
onColDrop,
}: {
services: ServiceWithTags[];
activeServices: ServiceWithTags[];
inactiveServices: ServiceWithTags[];
options: CatalogFieldOptions;
colOrder: ColumnKey[];
sortKey: ColumnKey | null;
sortDir: "asc" | "desc";
onSortClick: (key: ColumnKey) => void;
onColDragStart: (key: ColumnKey) => void;
onColDragOver: (e: React.DragEvent, key: ColumnKey) => void;
onColDrop: (key: ColumnKey) => void;
}) {
const activeServices = services.filter((s) => s.active);
const inactiveServices = services.filter((s) => !s.active);
function SortIcon({ colKey }: { colKey: ColumnKey }) {
if (!COLUMN_DEFS[colKey].sortable) return null;
if (sortKey !== colKey) return <ArrowUpDown className="inline ml-1 h-3 w-3 opacity-40" />;
return sortDir === "asc"
? <ArrowUp className="inline ml-1 h-3 w-3 text-[#1A463C]" />
: <ArrowDown className="inline ml-1 h-3 w-3 text-[#1A463C]" />;
}
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
@@ -250,33 +341,48 @@ export function ServiceTable({
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
<tr>
{COLUMN_HEADERS.map((header) => (
<th
key={header}
className="text-left py-2 px-3 font-semibold text-[#71717a]"
>
{header}
</th>
))}
{colOrder.map((key) => {
const def = COLUMN_DEFS[key];
return (
<th
key={key}
draggable
onDragStart={() => onColDragStart(key)}
onDragOver={(e) => onColDragOver(e, key)}
onDrop={() => onColDrop(key)}
onClick={() => def.sortable && onSortClick(key)}
className={`text-left py-2 px-3 font-semibold text-[#71717a] select-none whitespace-nowrap group ${
def.sortable ? "cursor-pointer hover:text-[#1a1a1a]" : "cursor-grab"
}`}
style={{ minWidth: def.minWidth }}
>
<span className="inline-flex items-center gap-1">
<GripVertical className="h-3 w-3 opacity-0 group-hover:opacity-30 transition-opacity shrink-0" />
{def.header}
<SortIcon colKey={key} />
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{activeServices.map((service) => (
<ServiceRow key={service.id} service={service} options={options} />
<ServiceRow key={service.id} service={service} options={options} colOrder={colOrder} />
))}
<QuickAddRow options={options} />
<QuickAddRow options={options} colOrder={colOrder} />
{inactiveServices.length > 0 && (
<>
<tr className="bg-[#fafafa]">
<td
colSpan={COL_COUNT}
colSpan={colOrder.length}
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-[#a1a1aa]"
>
Servizi disattivati
</td>
</tr>
{inactiveServices.map((service) => (
<ServiceRow key={service.id} service={service} options={options} />
<ServiceRow key={service.id} service={service} options={options} colOrder={colOrder} />
))}
</>
)}
@@ -1,46 +1,96 @@
import { getLeadsNeedingFollowUp } from "@/lib/lead-service";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { AlertCircle } from "lucide-react";
import Link from "next/link";
const MAX_ROWS = 4;
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function relativeDays(date: Date | string | null | undefined): string | null {
if (!date) return "Mai contattato";
const d = date instanceof Date ? date : new Date(date);
const diffMs = Date.now() - d.getTime();
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (days <= 0) return "Contattato oggi";
if (days === 1) return "Contattato ieri";
return `Contattato ${days} giorni fa`;
}
export async function FollowUpWidget() {
const leadsNeedingFollowUp = await getLeadsNeedingFollowUp(7);
const leads = await getLeadsNeedingFollowUp(7);
if (leads.length === 0) {
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] px-5 py-4">
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-[#1a1a1a]">Follow-up</h2>
</div>
<p className="text-sm text-[#71717a] mt-2">Tutti i lead sono aggiornati </p>
</div>
);
}
const visible = leads.slice(0, MAX_ROWS);
return (
<Card className="border-orange-200 bg-orange-50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-orange-600" />
Richiedi Follow-up
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{leadsNeedingFollowUp.length > 0 ? (
<>
<p className="text-lg font-bold text-orange-900">
{leadsNeedingFollowUp.length} lead da contattare
</p>
<ul className="space-y-2 text-sm">
{leadsNeedingFollowUp.slice(0, 3).map((lead) => (
<li key={lead.id} className="flex justify-between items-center">
<span>{lead.name}</span>
<Button variant="ghost" size="sm" asChild>
<Link href={`/admin/leads/${lead.id}`}>Contatta</Link>
</Button>
</li>
))}
</ul>
{leadsNeedingFollowUp.length > 3 && (
<Button variant="outline" className="w-full" asChild>
<Link href="/admin/leads">Vedi Tutto ({leadsNeedingFollowUp.length})</Link>
</Button>
)}
</>
) : (
<p className="text-gray-600 text-sm">Tutti i lead sono aggiornati!</p>
)}
</CardContent>
</Card>
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[#f4f4f5]">
<div className="flex items-center gap-2.5">
<h2 className="text-sm font-bold text-[#1a1a1a]">Follow-up</h2>
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-[#1A463C] text-white text-[11px] font-bold">
{leads.length}
</span>
</div>
<Link
href="/admin/pipeline"
className="text-xs font-medium text-[#71717a] hover:text-[#1A463C] transition-colors"
>
Vedi tutti
</Link>
</div>
{/* Lead rows */}
<ul className="divide-y divide-[#f4f4f5]">
{visible.map((lead) => (
<li key={lead.id}>
<Link
href={`/admin/pipeline/${lead.id}`}
className="flex items-center gap-3 px-5 py-3 hover:bg-[#f9f9f9] transition-colors group"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#1A463C]/10 text-[#1A463C] text-xs font-bold">
{initials(lead.name)}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[#1a1a1a] truncate">{lead.name}</p>
<p className="text-xs text-[#71717a] truncate">
{lead.next_action
? lead.next_action
: [lead.company, relativeDays(lead.last_contact_date)]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<span className="text-xs font-medium text-[#71717a] group-hover:text-[#1A463C] transition-colors shrink-0">
Contatta
</span>
</Link>
</li>
))}
</ul>
{leads.length > MAX_ROWS && (
<Link
href="/admin/pipeline"
className="block px-5 py-2.5 text-center text-xs font-medium text-[#71717a] hover:text-[#1A463C] border-t border-[#f4f4f5] transition-colors"
>
Vedi tutti i {leads.length} lead
</Link>
)}
</div>
);
}
@@ -0,0 +1,117 @@
"use client";
import { useState, useTransition } from "react";
import { X, Plus } from "lucide-react";
export function PoolManager({
label,
pool,
onAdd,
onRemove,
}: {
label: string;
pool: string[];
onAdd: (value: string) => Promise<void>;
onRemove: (value: string) => Promise<void>;
}) {
const [input, setInput] = useState("");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const canAdd = input.trim().length > 0;
function handleAdd() {
const trimmed = input.trim();
if (!trimmed) return;
if (pool.includes(trimmed)) {
setError("Valore già presente");
return;
}
setError(null);
startTransition(async () => {
await onAdd(trimmed);
setInput("");
});
}
function handleRemove(value: string) {
const ok = window.confirm(
`Eliminare "${value}" da «${label}»?\n\nVerrà rimosso ovunque: sparirà dal menu e da tutte le offerte/servizi che lo usano.`
);
if (!ok) return;
startTransition(() => onRemove(value));
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}
return (
<div
className={`rounded-lg border border-[#ececee] bg-[#fafafa] p-3.5 transition-opacity ${
isPending ? "opacity-60" : ""
}`}
>
<div className="flex items-center justify-between mb-2.5">
<p className="text-sm font-medium text-[#1a1a1a]">{label}</p>
{pool.length > 0 && (
<span className="text-[11px] text-[#a1a1aa] tabular-nums">{pool.length}</span>
)}
</div>
<div className="flex flex-wrap gap-1.5 mb-3 min-h-[26px]">
{pool.length === 0 ? (
<span className="text-xs text-[#a1a1aa] italic">Nessun valore</span>
) : (
pool.map((v) => (
<span
key={v}
className="group inline-flex items-center gap-1 rounded-md border border-[#e5e7eb] bg-white pl-2.5 pr-1 py-1 text-xs text-[#27272a] shadow-[0_1px_1px_rgba(0,0,0,0.02)]"
>
{v}
<button
type="button"
onClick={() => handleRemove(v)}
disabled={isPending}
className="rounded p-0.5 text-[#c4c4c8] transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-50"
aria-label={`Elimina ${v}`}
title={`Elimina "${v}" ovunque`}
>
<X className="h-3 w-3" />
</button>
</span>
))
)}
</div>
<div className="relative">
<input
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setError(null);
}}
onKeyDown={onKeyDown}
placeholder="Aggiungi valore"
disabled={isPending}
aria-label={`Aggiungi valore a ${label}`}
className="h-8 w-full rounded-md border border-[#e5e7eb] bg-white pl-3 pr-9 text-sm text-[#1a1a1a] placeholder:text-[#a1a1aa] transition-shadow focus:border-[#1A463C]/30 focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15 disabled:opacity-50"
/>
<button
type="button"
onClick={handleAdd}
disabled={isPending || !canAdd}
aria-label={`Conferma aggiunta a ${label}`}
className="absolute right-1 top-1/2 -translate-y-1/2 grid h-6 w-6 place-items-center rounded text-[#1A463C] transition-colors hover:bg-[#1A463C]/10 disabled:text-[#c4c4c8] disabled:hover:bg-transparent"
>
<Plus className="h-4 w-4" />
</button>
</div>
{error && <p className="mt-1.5 text-xs text-red-500">{error}</p>}
</div>
);
}
@@ -0,0 +1,85 @@
"use client";
import { Tag, BookOpen } from "lucide-react";
import { PoolManager } from "./PoolManager";
import { addTaxonomyValue, removeTaxonomyValue } from "@/app/admin/impostazioni/actions";
type FieldDef = { id: string; label: string };
const OFFER_FIELDS: FieldDef[] = [
{ id: "offer_categoria", label: "Categoria" },
{ id: "offer_ticket", label: "Ticket" },
{ id: "offer_tipo", label: "Tipo" },
{ id: "offer_obiettivo", label: "Obiettivo" },
];
const CATALOG_FIELDS: FieldDef[] = [
{ id: "service_fase", label: "Fase" },
{ id: "service_offerta", label: "Offerta" },
{ id: "service_pacchetto", label: "Pacchetto" },
];
function Section({
icon,
title,
subtitle,
fields,
pools,
}: {
icon: React.ReactNode;
title: string;
subtitle: string;
fields: FieldDef[];
pools: Record<string, string[]>;
}) {
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
<div className="flex items-start gap-3 mb-5">
<div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-[#1A463C]/8 text-[#1A463C]">
{icon}
</div>
<div>
<h2 className="text-base font-semibold text-[#1a1a1a] leading-tight">{title}</h2>
<p className="text-xs text-[#71717a] mt-0.5">{subtitle}</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{fields.map((f) => (
<PoolManager
key={f.id}
label={f.label}
pool={pools[f.id] ?? []}
onAdd={(v) => addTaxonomyValue(f.id, v)}
onRemove={(v) => removeTaxonomyValue(f.id, v)}
/>
))}
</div>
</div>
);
}
export function TaxonomyManager({ pools }: { pools: Record<string, string[]> }) {
return (
<div className="space-y-6">
<Section
icon={<Tag className="h-[18px] w-[18px]" />}
title="Tassonomie Offerte"
subtitle="Valori dei campi Categoria, Ticket, Tipo e Obiettivo dell'editor offerte."
fields={OFFER_FIELDS}
pools={pools}
/>
<Section
icon={<BookOpen className="h-[18px] w-[18px]" />}
title="Tassonomie Catalogo"
subtitle="Valori dei campi Fase, Offerta e Pacchetto del catalogo servizi."
fields={CATALOG_FIELDS}
pools={pools}
/>
<p className="text-xs text-[#a1a1aa] leading-relaxed">
I valori si sincronizzano ovunque: creandone uno dall&apos;editor offerte o dal catalogo
comparirà qui automaticamente. Eliminando un valore da qui viene rimosso ovunque sia usato.
</p>
</div>
);
}
+141 -2
View File
@@ -2,17 +2,25 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Lead, Activity, Reminder } from "@/db/schema";
import { Lead, Activity, Reminder, ClientTranscript } from "@/db/schema";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
import { addLeadTag, removeLeadTag, renameLeadTag } from "@/app/admin/leads/actions";
import {
addLeadTag,
removeLeadTag,
renameLeadTag,
deleteTranscript,
convertLeadToClient,
} from "@/app/admin/pipeline/actions";
import { formatDistanceToNow, format } from "date-fns";
import { it } from "date-fns/locale";
import Link from "next/link";
import { LogActivityModal } from "./LogActivityModal";
import { SendQuoteModal } from "./SendQuoteModal";
import { EditLeadModal } from "./LeadForm";
import { TranscriptModal } from "./TranscriptModal";
const ACTIVITY_ICON: Record<string, string> = {
call: "📞",
@@ -36,16 +44,31 @@ export function LeadDetail({
reminders,
tags,
tagOptions,
transcripts,
convertedClientId,
}: {
lead: Lead;
activities: Activity[];
reminders: Reminder[];
tags: string[];
tagOptions: string[];
transcripts: ClientTranscript[];
convertedClientId?: string | null;
}) {
const router = useRouter();
const [, startTransition] = useTransition();
const [converting, startConvert] = useTransition();
const [tagError, setTagError] = useState<string | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [deletingId, setDeletingId] = useState<string | null>(null);
function handleConvert() {
const ok = window.confirm(
`Convertire "${lead.name}" in cliente?\n\nVerrà creato un cliente (con email, telefono e transcript del lead) e il lead verrà archiviato mantenendo lo stato "vinto".`
);
if (!ok) return;
startConvert(() => convertLeadToClient(lead.id));
}
function run(fn: () => Promise<unknown>) {
setTagError(null);
@@ -59,6 +82,29 @@ export function LeadDetail({
});
}
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function handleDeleteTranscript(transcriptId: string) {
setDeletingId(transcriptId);
startTransition(async () => {
try {
await deleteTranscript(transcriptId, lead.id);
router.refresh();
} catch (e) {
console.error("deleteTranscript error:", e);
} finally {
setDeletingId(null);
}
});
}
return (
<div className="space-y-6">
{/* Header + Actions */}
@@ -69,7 +115,33 @@ export function LeadDetail({
</div>
<div className="flex gap-2">
<LogActivityModal leadId={lead.id} />
<TranscriptModal leadId={lead.id} />
<SendQuoteModal leadId={lead.id} />
<Link
href={`/admin/preventivi/genera?lead_id=${lead.id}`}
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#DEF168] text-[#1A463C] rounded-md text-sm font-semibold hover:bg-[#d4e85e] transition-colors"
>
Genera preventivo
</Link>
{convertedClientId ? (
<Link
href={`/admin/clients/${convertedClientId}`}
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#1A463C]/10 text-[#1A463C] rounded-md text-sm font-semibold hover:bg-[#1A463C]/15 transition-colors"
>
Convertito Apri cliente
</Link>
) : (
lead.status === "won" && (
<button
type="button"
onClick={handleConvert}
disabled={converting}
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#1A463C] text-white rounded-md text-sm font-semibold hover:bg-[#1A463C]/90 transition-colors disabled:opacity-50"
>
{converting ? "Conversione..." : "Converti in cliente"}
</button>
)
)}
<EditLeadModal lead={lead} />
</div>
</div>
@@ -194,6 +266,73 @@ export function LeadDetail({
)}
</CardContent>
</Card>
{/* Transcript Call */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Transcript Call</CardTitle>
<span className="text-sm text-gray-500">
{transcripts.length} {transcripts.length === 1 ? "transcript" : "transcript"}
</span>
</CardHeader>
<CardContent>
{transcripts.length > 0 ? (
<div className="space-y-4">
{transcripts.map((t) => {
const isExpanded = expandedIds.has(t.id);
const formattedDate = format(
new Date(t.call_date + "T00:00:00"),
"d MMMM yyyy",
{ locale: it }
);
const lines = t.content.split("\n");
const preview = lines.slice(0, 3).join("\n").slice(0, 200);
const hasMore = t.content.length > preview.length || lines.length > 3;
return (
<div key={t.id} className="border-l-4 border-purple-300 pl-4 pb-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{formattedDate}</span>
{t.title && (
<span className="text-gray-600 text-sm"> {t.title}</span>
)}
</div>
<div className="mt-2 text-sm text-gray-700 whitespace-pre-wrap">
{isExpanded ? t.content : preview}
{!isExpanded && hasMore && (
<span className="text-gray-400">...</span>
)}
</div>
{hasMore && (
<button
onClick={() => toggleExpand(t.id)}
className="text-xs text-blue-600 hover:underline mt-1"
>
{isExpanded ? "Mostra meno" : "Mostra tutto"}
</button>
)}
</div>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700 shrink-0"
disabled={deletingId === t.id}
onClick={() => handleDeleteTranscript(t.id)}
>
{deletingId === t.id ? "..." : "Elimina"}
</Button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm">Nessun transcript registrato</p>
)}
</CardContent>
</Card>
</div>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createLeadSchema, LEAD_STAGES, type CreateLeadInput } from "@/lib/lead-validators";
import { Lead } from "@/db/schema";
import { createLead, updateLead } from "@/app/admin/leads/actions";
import { createLead, updateLead } from "@/app/admin/pipeline/actions";
import {
Dialog,
DialogContent,
+44 -57
View File
@@ -3,29 +3,19 @@
import Link from "next/link";
import { useState, useRef, useEffect, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Check, ArrowRight } from "lucide-react";
import { EditableCell } from "@/components/ui/editable-cell";
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
import { StatusBadge } from "@/components/ui/StatusBadge";
import {
updateLeadField,
addLeadTag,
removeLeadTag,
renameLeadTag,
} from "@/app/admin/leads/actions";
} from "@/app/admin/pipeline/actions";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
import { cn } from "@/lib/utils";
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",
};
const COLUMN_HEADERS = [
"Nome",
"Email",
@@ -37,6 +27,11 @@ const COLUMN_HEADERS = [
"Azioni",
];
const COL_COUNT = COLUMN_HEADERS.length;
const HEADER_ALIGN: Record<string, string> = {
Stato: "text-center",
Tag: "text-center",
Azioni: "text-right",
};
function StatusCell({
lead,
@@ -61,23 +56,15 @@ function StatusCell({
}, []);
return (
<div ref={containerRef} className="relative w-full min-w-[120px]">
<div ref={containerRef} className="relative w-full min-w-[120px] flex justify-center">
<div
onClick={() => setIsOpen((v) => !v)}
className="flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px] cursor-pointer hover:bg-[#f0f0f0]"
className="flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px] cursor-pointer hover:bg-muted"
>
<Badge
variant="outline"
className={cn(
STAGE_COLOR[lead.status] ?? "",
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
)}
>
{lead.status.replace(/_/g, " ")}
</Badge>
<StatusBadge status={lead.status} />
</div>
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-1.5 z-20 min-w-[180px]">
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 bg-popover border border-border rounded-md shadow-card-hover p-1.5 z-20 min-w-[180px]">
<div className="flex flex-col gap-0.5">
{options.status.map((stage) => (
<button
@@ -87,17 +74,12 @@ function StatusCell({
setIsOpen(false);
run(() => updateLeadField(lead.id, "status", stage));
}}
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-[#f5f5f5] text-left"
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-muted text-left"
>
<span className="w-3.5 flex-shrink-0">
{lead.status === stage && <Check className="h-3.5 w-3.5 text-[#1A463C]" />}
{lead.status === stage && <Check className="h-3.5 w-3.5 text-primary" />}
</span>
<Badge
variant="outline"
className={cn(STAGE_COLOR[stage] ?? "", "border-transparent text-xs px-2 py-0.5 font-medium truncate")}
>
{stage.replace(/_/g, " ")}
</Badge>
<StatusBadge status={stage} />
</button>
))}
</div>
@@ -132,8 +114,8 @@ function LeadRow({
return (
<>
<tr className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150">
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
<tr className="hover:bg-muted/40 transition-colors duration-150">
<td className="py-4 px-6 font-medium text-foreground min-w-[160px]">
<EditableCell
value={lead.name}
type="text"
@@ -141,7 +123,7 @@ function LeadRow({
onSave={(v) => run(() => updateLeadField(lead.id, "name", v))}
/>
</td>
<td className="py-2 px-3 min-w-[160px]">
<td className="py-4 px-6 min-w-[160px] text-muted-foreground text-xs">
<EditableCell
value={lead.email ?? ""}
type="text"
@@ -149,7 +131,7 @@ function LeadRow({
onSave={(v) => run(() => updateLeadField(lead.id, "email", v))}
/>
</td>
<td className="py-2 px-3 min-w-[140px]">
<td className="py-4 px-6 min-w-[140px] text-muted-foreground text-xs font-mono">
<EditableCell
value={lead.phone ?? ""}
type="text"
@@ -157,7 +139,7 @@ function LeadRow({
onSave={(v) => run(() => updateLeadField(lead.id, "phone", v))}
/>
</td>
<td className="py-2 px-3 min-w-[140px]">
<td className="py-4 px-6 min-w-[140px]">
<EditableCell
value={lead.company ?? ""}
type="text"
@@ -165,10 +147,10 @@ function LeadRow({
onSave={(v) => run(() => updateLeadField(lead.id, "company", v))}
/>
</td>
<td className="py-2 px-3 min-w-[120px]">
<td className="py-4 px-6 min-w-[120px] text-center">
<StatusCell lead={lead} options={options} run={run} />
</td>
<td className="py-2 px-3 min-w-[180px]">
<td className="py-4 px-6 min-w-[180px]">
<EditableCell
value={lead.next_action ?? ""}
type="text"
@@ -176,7 +158,7 @@ function LeadRow({
onSave={(v) => run(() => updateLeadField(lead.id, "next_action", v))}
/>
</td>
<td className="py-2 px-3 min-w-[180px]">
<td className="py-4 px-6 min-w-[180px] text-center">
<OptionMultiSelect
values={lead.tags}
options={options.tags}
@@ -185,15 +167,19 @@ function LeadRow({
onRename={(o, n) => run(() => renameLeadTag(o, n))}
/>
</td>
<td className="py-2 px-3 min-w-[80px]">
<Button variant="ghost" size="sm" asChild>
<Link href={`/admin/leads/${lead.id}`}>Dettagli</Link>
</Button>
<td className="py-4 px-6 min-w-[80px] text-right">
<Link
href={`/admin/pipeline/${lead.id}`}
className="text-xs font-medium text-primary hover:text-primary/80 inline-flex items-center gap-1 group transition-colors"
>
Dettagli
<ArrowRight className="w-3 h-3 transform group-hover:translate-x-0.5 transition-transform" />
</Link>
</td>
</tr>
{error && (
<tr>
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
<td colSpan={COL_COUNT} className="py-1 px-6 text-xs text-destructive">
{error}
</td>
</tr>
@@ -210,22 +196,19 @@ export function LeadTable({
options: LeadFieldOptions;
}) {
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
<tr>
<div className="bg-card rounded-xl border border-border shadow-card overflow-hidden">
<div className="overflow-x-auto min-h-[300px]">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-border bg-muted/50 text-muted-foreground text-[11px] font-semibold uppercase tracking-wider">
{COLUMN_HEADERS.map((header) => (
<th
key={header}
className="text-left py-2 px-3 font-semibold text-[#71717a]"
>
<th key={header} className={cn("py-4 px-6", HEADER_ALIGN[header])}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-border text-sm">
{leads.map((lead) => (
<LeadRow key={lead.id} lead={lead} options={options} />
))}
@@ -234,8 +217,12 @@ export function LeadTable({
</div>
{leads.length === 0 && (
<div className="text-center py-8 text-gray-500">Nessun lead trovato</div>
<div className="text-center py-8 text-muted-foreground">Nessun lead trovato</div>
)}
<div className="border-t border-border px-6 py-4 text-xs text-muted-foreground">
Mostrando {leads.length} lead
</div>
</div>
);
}
@@ -0,0 +1,198 @@
"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/pipeline/actions";
import type { LeadWithTags } from "@/lib/admin-queries";
import { StatusBadge } from "@/components/ui/StatusBadge";
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-muted-foreground", dotClass: "bg-blue-400 dark:bg-blue-500" },
{ id: "qualified", label: "Qualificato", headerClass: "text-purple-700 dark:text-purple-400", dotClass: "bg-purple-400 dark:bg-purple-500" },
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700 dark:text-amber-400", dotClass: "bg-amber-400 dark:bg-amber-500" },
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700 dark:text-orange-400", dotClass: "bg-orange-400 dark:bg-orange-500" },
{ id: "won", label: "Vinto", headerClass: "text-emerald-700 dark:text-emerald-400", dotClass: "bg-emerald-500" },
{ id: "lost", label: "Perso", headerClass: "text-red-700 dark:text-red-400", dotClass: "bg-red-400 dark:bg-red-500" },
];
const VALID_STAGES = LEAD_COLUMNS.map((c) => c.id) as string[];
function DroppableColumn({
id, label, headerClass, dotClass, leads, activeId,
}: {
id: LeadStage;
label: string;
headerClass: string;
dotClass: string;
leads: LeadWithTags[];
activeId: string | null;
}) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={`flex flex-col rounded-xl border p-4 transition-colors ${
isOver ? "border-primary bg-primary/5" : "border-border bg-muted/60"
} min-h-[240px]`}
>
<div className={`flex items-center justify-between pb-2 mb-2 border-b border-border ${headerClass}`}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${dotClass}`} />
<span className="text-xs font-bold uppercase tracking-wider">{label}</span>
</div>
<span className="text-xs font-semibold tabular-nums bg-card rounded-full px-2 py-0.5 border border-border text-muted-foreground">
{leads.length}
</span>
</div>
<div className="flex-1 space-y-2">
{leads.map((lead) => (
<DraggableLeadCard key={lead.id} lead={lead} status={id} isActive={activeId === lead.id} />
))}
{leads.length === 0 && (
<p className="text-xs text-muted-foreground italic text-center py-10 select-none">
Nessun lead
</p>
)}
</div>
</div>
);
}
function DraggableLeadCard({
lead,
status,
isActive,
}: {
lead: LeadWithTags;
status: LeadStage;
isActive: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: lead.id });
const style = transform
? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`bg-card rounded-lg border border-border px-3 py-2.5 cursor-grab active:cursor-grabbing shadow-sm select-none transition-all duration-200 ${
isDragging ? "opacity-30" : "hover:border-primary/30 hover:shadow-card-hover"
}`}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-foreground">{lead.name}</p>
<StatusBadge status={status} className="rounded px-1.5 py-0.5 text-[9px]" />
</div>
{lead.email && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{lead.email}</p>
)}
{lead.company && (
<p className="text-xs text-muted-foreground mt-1">{lead.company}</p>
)}
{lead.next_action && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{lead.next_action}</p>
)}
</div>
);
}
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)
);
const leadsByStage = LEAD_COLUMNS.reduce(
(acc, col) => {
acc[col.id] = leads.filter((l) => (leadStatuses[l.id] ?? l.status) === col.id);
return acc;
},
{} as Record<LeadStage, LeadWithTags[]>
);
const activeLead = activeId ? leads.find((l) => l.id === activeId) : null;
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;
const currentStage = leadStatuses[leadId];
if (!VALID_STAGES.includes(newStage)) return;
if (newStage === currentStage) return;
setLeadStatuses((prev) => ({ ...prev, [leadId]: newStage }));
startTransition(async () => {
await updateLeadField(leadId, "status", newStage);
router.refresh();
});
}
return (
<DndContext
sensors={sensors}
onDragStart={(e) => setActiveId(e.active.id as string)}
onDragEnd={handleDragEnd}
>
<div className="overflow-x-auto">
<div className="grid grid-cols-6 gap-3 min-w-[1080px]">
{LEAD_COLUMNS.map((col) => (
<DroppableColumn
key={col.id}
{...col}
leads={leadsByStage[col.id]}
activeId={activeId}
/>
))}
</div>
</div>
<DragOverlay dropAnimation={null}>
{activeLead && (
<div className="bg-card rounded-lg border-2 border-primary px-3 py-2.5 shadow-xl rotate-1 pointer-events-none">
<p className="text-sm font-medium text-foreground">{activeLead.name}</p>
{activeLead.company && (
<p className="text-xs text-muted-foreground">{activeLead.company}</p>
)}
</div>
)}
</DragOverlay>
</DndContext>
);
}
@@ -1,18 +1,20 @@
"use client";
import { useState, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
import { SearchInput } from "@/components/ui/SearchInput";
import { SegmentedToggle } from "@/components/ui/SegmentedToggle";
import { LeadTable } from "@/components/admin/leads/LeadTable";
import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
export function LeadsSearch({
export function LeadsViewToggle({
leads,
options,
}: {
leads: LeadWithTags[];
options: LeadFieldOptions;
}) {
const [view, setView] = useState<"list" | "kanban">("list");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
@@ -30,17 +32,29 @@ export function LeadsSearch({
return (
<div className="space-y-4">
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
<Input
type="text"
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<SearchInput
placeholder="Cerca lead..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 h-9"
containerClassName="w-full md:w-80"
/>
<SegmentedToggle
options={[
{ value: "list", label: "Lista" },
{ value: "kanban", label: "Kanban" },
]}
value={view}
onChange={setView}
className="self-start md:self-auto flex-shrink-0"
/>
</div>
<LeadTable leads={filtered} options={options} />
{view === "list" ? (
<LeadTable leads={filtered} options={options} />
) : (
<LeadsKanbanBoard leads={filtered} />
)}
</div>
);
}
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createActivitySchema, ACTIVITY_TYPES } from "@/lib/lead-validators";
import { logActivity } from "@/app/admin/leads/actions";
import { logActivity } from "@/app/admin/pipeline/actions";
import {
Dialog,
DialogContent,
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { assignQuoteToLead } from "@/app/admin/leads/actions";
import { assignQuoteToLead } from "@/app/admin/pipeline/actions";
import {
Dialog,
DialogContent,
@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { addTranscript } from "@/app/admin/pipeline/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>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useTransition } from "react";
import { useState, useMemo, useTransition, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
@@ -45,12 +45,15 @@ function formatUnitPrice(raw: string): string {
return `${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
type OfferType = "una_tantum" | "retainer";
type MacroFields = {
internal_name: string;
public_name: string;
description: string;
category: string | null;
ticket: string | null;
offer_type: OfferType;
cliente_ideale: string;
risultato: string;
tempo: string;
@@ -75,6 +78,7 @@ export function OfferEditorClient({
description: data.macro.description ?? "",
category: data.macro.category,
ticket: data.macro.ticket,
offer_type: (data.macro.offer_type === "retainer" ? "retainer" : "una_tantum") as OfferType,
cliente_ideale: data.macro.cliente_ideale ?? "",
risultato: data.macro.risultato ?? "",
tempo: data.macro.tempo ?? "",
@@ -86,6 +90,13 @@ export function OfferEditorClient({
const [tipoTags, setTipoTags] = useState<string[]>(data.tipoTags);
const [obiettivoTags, setObiettivoTags] = useState<string[]>(data.obiettivoTags);
// After first draft save router.refresh() assigns real DB IDs to new tiers.
// Re-sync tiers state when those IDs arrive so subsequent saves UPDATE instead of INSERT.
const tierIdKey = data.tiers.map((t) => t.id).sort().join(",");
useEffect(() => {
setTiers(padTiers(data.tiers));
}, [tierIdKey]); // eslint-disable-line react-hooks/exhaustive-deps
const [categoriaOptions, setCategoriaOptions] = useState<string[]>(fieldOptions.categoria);
const [ticketOptions, setTicketOptions] = useState<string[]>(fieldOptions.ticket);
const [tipoOptions, setTipoOptions] = useState<string[]>(fieldOptions.tipo);
@@ -151,6 +162,12 @@ export function OfferEditorClient({
);
}
function updateTierName(tierIdx: number, value: string) {
setTiers((prev) =>
prev.map((tier, idx) => (idx === tierIdx ? { ...tier, public_name: value } : tier))
);
}
function handleSave() {
setSaveError(null);
const payload: SaveOfferEditorPayload = {
@@ -159,20 +176,24 @@ export function OfferEditorClient({
description: macro.description,
category: macro.category ?? undefined,
ticket: macro.ticket ?? undefined,
offer_type: macro.offer_type,
cliente_ideale: macro.cliente_ideale,
risultato: macro.risultato,
tempo: macro.tempo,
pain: macro.pain,
metodo: macro.metodo,
tiers: tiers.map((t) => ({
id: t.id || undefined,
tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C",
internal_name: t.internal_name || `Tier ${t.tier_letter}`,
public_name: t.public_name || t.tier_letter || "Tier",
duration_months: t.duration_months || 1,
public_price: t.public_price ? Number(t.public_price) : null,
assignedServiceIds: t.assignedServiceIds,
})),
tiers: tiers.map((t) => {
const tierName = t.public_name?.trim();
return {
id: t.id || undefined,
tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C",
internal_name: tierName || `Tier ${t.tier_letter}`,
public_name: tierName || t.tier_letter || "Tier",
duration_months: t.duration_months || 1,
public_price: t.public_price ? Number(t.public_price) : null,
assignedServiceIds: t.assignedServiceIds,
};
}),
tipoTags,
obiettivoTags,
};
@@ -284,6 +305,42 @@ export function OfferEditorClient({
/>
</h3>
</div>
{/* Modalità toggle — directly under title */}
<div className="mt-3 flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Modalità</span>
<div className="inline-flex rounded-lg border border-[#e5e7eb] bg-[#fafafa] p-0.5">
{([
{ value: "una_tantum", label: "Una tantum" },
{ value: "retainer", label: "Retainer" },
] as const).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => updateMacro("offer_type", opt.value)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
macro.offer_type === opt.value
? "bg-[#1A463C] text-white font-medium"
: "text-[#71717a] hover:text-[#1a1a1a]"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Descrizione — nota interna, non visibile al cliente */}
<div className="mt-3 flex items-start gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0 pt-2">Descrizione</span>
<textarea
value={macro.description}
onChange={(e) => updateMacro("description", e.target.value)}
placeholder="Descrizione interna dell'offerta (non visibile al cliente)..."
rows={3}
className="flex-1 text-sm border border-[#e5e7eb] rounded-lg px-3 py-2 bg-white resize-none focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30 text-[#1a1a1a] placeholder:text-[#aaaaaa]"
/>
</div>
</div>
{/* Tags block */}
@@ -426,6 +483,22 @@ export function OfferEditorClient({
)}
</tbody>
<tfoot>
<tr className="border-t border-[#e5e7eb]">
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
Nome Tier
</td>
{tiers.map((tier, tierIdx) => (
<td key={tier.tier_letter} className="py-2 px-3 text-center">
<input
type="text"
value={tier.public_name ?? ""}
onChange={(e) => updateTierName(tierIdx, e.target.value)}
placeholder="Nome…"
className="w-20 text-center text-sm border border-[#e5e7eb] rounded px-1 py-1 focus-visible:ring-1 focus-visible:ring-primary focus:outline-none"
/>
</td>
))}
</tr>
<tr className="border-t border-[#e5e7eb]">
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
Totale Servizi
@@ -4,10 +4,15 @@ import { useState, useMemo, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { createOfferMacro } from "@/app/admin/offers/actions";
import type { OfferListCard } from "@/lib/offer-queries";
import type { OfferListCard, OfferListTier } from "@/lib/offer-queries";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { PageHeader } from "@/components/admin/PageHeader";
function formatEuro(value: number): string {
return `${value.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function OfferListClient({
cards,
@@ -77,11 +82,7 @@ export function OfferListClient({
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h2 className="text-[20px] font-semibold text-[#1a1a1a]">Offerte</h2>
{createCta}
</div>
<PageHeader title="Offerte" action={createCta} />
{/* Filter row */}
<div className="flex flex-wrap items-center justify-between gap-4">
@@ -149,6 +150,27 @@ export function OfferListClient({
<span className="text-xs font-semibold text-[#dc2626]">Archiviata</span>
)}
</div>
{card.tiers.filter(
(t) => Number(t.servicesTotal) > 0 || t.public_price !== null
).length > 0 && (
<div className="mt-3 pt-3 border-t border-[#e5e7eb] space-y-1">
{card.tiers
.filter((t) => Number(t.servicesTotal) > 0 || t.public_price !== null)
.map((t) => (
<div key={t.tier_letter} className="flex items-baseline gap-2 text-xs">
<span className="font-medium text-[#1a1a1a] w-16 shrink-0 truncate">
{t.public_name || `Tier ${t.tier_letter}`}
</span>
<span className="tabular-nums text-[#71717a]">
Servizi: {formatEuro(Number(t.servicesTotal))}
</span>
<span className="tabular-nums text-[#71717a]">
Pubblico: {t.public_price !== null ? formatEuro(Number(t.public_price)) : "—"}
</span>
</div>
))}
</div>
)}
</Link>
))}
</div>
+39 -35
View File
@@ -11,9 +11,12 @@ type Props = {
};
export async function CommentsTab({ comments, phases, clientId }: Props) {
// Build entity label map for display
const entityLabels: Record<string, string> = {};
// Build entity label map for display (phases, tasks, deliverables, and general)
const entityLabels: Record<string, string> = {
[clientId]: "Messaggio generale",
};
for (const phase of phases) {
entityLabels[phase.id] = `Fase: ${phase.title}`;
for (const task of phase.tasks) {
entityLabels[task.id] = `Task: ${task.title}`;
for (const d of task.deliverables) {
@@ -23,8 +26,11 @@ export async function CommentsTab({ comments, phases, clientId }: Props) {
}
// Build list of entities the admin can reply on
const entities: Array<{ id: string; type: string; label: string }> = [];
const entities: Array<{ id: string; type: string; label: string }> = [
{ id: clientId, type: "general", label: "Messaggio generale" },
];
for (const phase of phases) {
entities.push({ id: phase.id, type: "phase", label: `Fase: ${phase.title}` });
for (const task of phase.tasks) {
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
for (const d of task.deliverables) {
@@ -67,39 +73,37 @@ export async function CommentsTab({ comments, phases, clientId }: Props) {
</div>
{/* Admin reply form */}
{entities.length > 0 && (
<form
action={async (fd: FormData) => {
"use server";
await postAdminComment(clientId, fd);
}}
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
<form
action={async (fd: FormData) => {
"use server";
await postAdminComment(clientId, fd);
}}
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
>
<h3 className="font-medium text-gray-900 text-sm">
Rispondi come admin
</h3>
<select
name="entity"
className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white"
required
>
<h3 className="font-medium text-gray-900 text-sm">
Rispondi come admin
</h3>
<select
name="entity"
className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white"
required
>
{entities.map((e) => (
<option key={e.id} value={`${e.type}:${e.id}`}>
{e.label}
</option>
))}
</select>
<Textarea
name="body"
placeholder="Scrivi un commento..."
rows={3}
required
/>
<Button type="submit" size="sm">
Invia risposta
</Button>
</form>
)}
{entities.map((e) => (
<option key={e.id} value={`${e.type}:${e.id}`}>
{e.label}
</option>
))}
</select>
<Textarea
name="body"
placeholder="Scrivi un commento..."
rows={3}
required
/>
<Button type="submit" size="sm">
Invia risposta
</Button>
</form>
</div>
);
}
+39 -10
View File
@@ -1,15 +1,29 @@
import { addDocument } from "@/app/admin/clients/[id]/actions";
import { DocumentRow } from "@/components/admin/DocumentRow";
import { TranscriptItem } from "@/components/admin/TranscriptItem";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { Document } from "@/db/schema";
type Props = { documents: Document[]; clientId: string };
type Transcript = {
id: string;
title: string | null;
call_date: string;
content: string;
created_at: Date;
};
export async function DocumentsTab({ documents, clientId }: Props) {
type Props = {
documents: Document[];
clientId: string;
transcripts?: Transcript[];
};
export async function DocumentsTab({ documents, clientId, transcripts = [] }: Props) {
return (
<div className="space-y-6 max-w-lg">
<div className="space-y-8 max-w-lg">
{/* Add document form */}
<form
action={async (fd: FormData) => {
"use server";
@@ -42,14 +56,29 @@ export async function DocumentsTab({ documents, clientId }: Props) {
</Button>
</form>
{documents.length === 0 && (
<p className="text-sm text-[#71717a]">Nessun documento ancora.</p>
)}
<div className="space-y-2">
{documents.map((doc) => (
<DocumentRow key={doc.id} doc={doc} clientId={clientId} />
))}
{/* Documents list */}
<div>
{documents.length === 0 && (
<p className="text-sm text-[#71717a]">Nessun documento ancora.</p>
)}
<div className="space-y-2">
{documents.map((doc) => (
<DocumentRow key={doc.id} doc={doc} clientId={clientId} />
))}
</div>
</div>
{/* Transcripts section (read-only, expandable via client component) */}
{transcripts.length > 0 && (
<div>
<h3 className="font-medium text-[#1a1a1a] mb-3">Transcript chiamate</h3>
<div className="space-y-2">
{transcripts.map((t) => (
<TranscriptItem key={t.id} transcript={t} />
))}
</div>
</div>
)}
</div>
);
}
+242 -99
View File
@@ -1,7 +1,8 @@
"use client";
import { useTransition, useRef } from "react";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Check, Trash2 } from "lucide-react";
import {
assignOfferToProject,
removeProjectOffer,
@@ -14,6 +15,12 @@ type AvailableMicro = {
internal_name: string;
public_name: string;
duration_months: number;
tier_letter: string | null;
public_price: string | null;
macro_id: string;
macro_internal_name: string;
macro_category: string | null;
macro_offer_type: string;
};
interface OffersTabProps {
@@ -22,20 +29,97 @@ interface OffersTabProps {
availableMicros: AvailableMicro[];
}
function typeLabel(offerType: string): string {
return offerType === "retainer" ? "Ricorrente" : "Una tantum";
}
function formatEuro(raw: string | null): string | null {
if (raw == null || raw === "") return null;
const n = parseFloat(raw);
if (!Number.isFinite(n)) return null;
return `${n.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
// public_name is only meaningful when it differs from the tier letter (it
// defaults to the letter when unset).
function meaningfulPublicName(m: { public_name: string; tier_letter: string | null }): string | null {
const p = m.public_name?.trim();
if (!p) return null;
if (m.tier_letter && p.toUpperCase() === m.tier_letter.toUpperCase()) return null;
return p;
}
function TypeBadge({ offerType }: { offerType: string }) {
const retainer = offerType === "retainer";
return (
<span
className={`inline-block rounded text-[11px] font-medium px-1.5 py-0.5 ${
retainer ? "bg-blue-50 text-blue-700" : "bg-[#f4f4f5] text-[#52525b]"
}`}
>
{typeLabel(offerType)}
</span>
);
}
export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const formRef = useRef<HTMLFormElement>(null);
function handleAssign(formData: FormData) {
const [macroId, setMacroId] = useState<string>("");
const [tierId, setTierId] = useState<string>("");
const [acceptedTotal, setAcceptedTotal] = useState<string>("");
const [importPhases, setImportPhases] = useState(true);
// Group tiers by offer (macro), preserving query order.
const offers = useMemo(() => {
const map = new Map<
string,
{ macro_id: string; name: string; category: string | null; offer_type: string; tiers: AvailableMicro[] }
>();
for (const m of availableMicros) {
let entry = map.get(m.macro_id);
if (!entry) {
entry = {
macro_id: m.macro_id,
name: m.macro_internal_name,
category: m.macro_category,
offer_type: m.macro_offer_type,
tiers: [],
};
map.set(m.macro_id, entry);
}
entry.tiers.push(m);
}
return Array.from(map.values());
}, [availableMicros]);
const selectedOffer = offers.find((o) => o.macro_id === macroId) ?? null;
function selectMacro(id: string) {
setMacroId(id);
setTierId("");
}
function handleAssign() {
if (!tierId) return;
const fd = new FormData();
fd.set("project_id", projectId);
fd.set("micro_id", tierId);
if (acceptedTotal.trim()) fd.set("accepted_total", acceptedTotal.trim());
if (importPhases) fd.set("import_phases", "on");
startTransition(async () => {
await assignOfferToProject(formData);
formRef.current?.reset();
await assignOfferToProject(fd);
setMacroId("");
setTierId("");
setAcceptedTotal("");
setImportPhases(true);
router.refresh();
});
}
function handleRemove(offerId: string) {
if (!window.confirm("Rimuovere questa offerta dal progetto?")) return;
startTransition(async () => {
await removeProjectOffer(offerId, projectId);
router.refresh();
@@ -50,7 +134,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
}
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-8 max-w-2xl">
{/* Active assignments */}
<div>
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
@@ -58,107 +142,166 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
) : (
<div className="space-y-3">
{projectOffers.map((offer) => (
<div
key={offer.id}
className="bg-white rounded-lg border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
>
<div className="flex-1">
<p className="text-sm font-medium text-[#1a1a1a]">{offer.micro_internal_name}</p>
<p className="text-xs text-[#71717a]">
Pubblico: {offer.micro_public_name} &middot; {offer.micro_duration_months}{" "}
{offer.micro_duration_months === 1 ? "mese" : "mesi"}
</p>
<p className="text-xs text-[#71717a] mt-1">
Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}
</p>
{/* Accepted total inline edit */}
<div className="flex items-center gap-2 mt-2">
<label className="text-xs text-[#71717a]">Totale accettato :</label>
<input
type="number"
step="0.01"
min="0"
defaultValue={offer.accepted_total ?? ""}
placeholder="0.00"
onBlur={(e) => {
const val = e.currentTarget.value.trim();
if (val !== (offer.accepted_total ?? "")) {
handleTotalUpdate(offer.id, val);
}
}}
className="w-24 border rounded px-2 py-0.5 text-xs"
/>
</div>
</div>
<button
type="button"
onClick={() => handleRemove(offer.id)}
disabled={isPending}
className="text-xs text-red-600 hover:underline shrink-0"
{projectOffers.map((offer) => {
const pub = meaningfulPublicName({
public_name: offer.micro_public_name,
tier_letter: offer.tier_letter,
});
return (
<div
key={offer.id}
className="bg-white rounded-xl border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
>
Rimuovi
</button>
</div>
))}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-[#1a1a1a]">{offer.macro_internal_name}</p>
{offer.tier_letter && (
<span className="inline-block rounded bg-[#1A463C]/10 text-[#1A463C] text-[11px] font-semibold px-1.5 py-0.5">
Tier {offer.tier_letter}
</span>
)}
<TypeBadge offerType={offer.macro_offer_type} />
</div>
{pub && <p className="text-xs text-[#71717a] mt-1">Pubblico: {offer.micro_public_name}</p>}
<div className="flex items-center gap-2 mt-2">
<label className="text-xs text-[#71717a]">Totale accettato </label>
<input
type="number"
step="0.01"
min="0"
defaultValue={offer.accepted_total ?? ""}
placeholder="0.00"
onBlur={(e) => {
const val = e.currentTarget.value.trim();
if (val !== (offer.accepted_total ?? "")) handleTotalUpdate(offer.id, val);
}}
className="w-28 border border-[#e5e7eb] rounded-md px-2 py-1 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15"
/>
</div>
</div>
<button
type="button"
onClick={() => handleRemove(offer.id)}
disabled={isPending}
aria-label="Rimuovi offerta"
className="shrink-0 rounded-md p-1.5 text-[#a1a1aa] hover:bg-red-50 hover:text-red-500 transition-colors disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
})}
</div>
)}
</div>
{/* Assignment form */}
{/* Assignment — step 1: offer, step 2: tier */}
<div>
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Micro-Offerta</h3>
<form
ref={formRef}
action={handleAssign}
className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3"
>
<input type="hidden" name="project_id" value={projectId} />
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Offerta</h3>
<div>
<label className="text-xs text-[#71717a] block mb-1">Micro-offerta</label>
<select
name="micro_id"
required
className="w-full border rounded px-3 py-1.5 text-sm"
>
<option value="">Seleziona micro-offerta...</option>
{availableMicros.map((m) => (
<option key={m.id} value={m.id}>
{m.internal_name} ({m.duration_months}{" "}
{m.duration_months === 1 ? "mese" : "mesi"})
</option>
))}
</select>
{offers.length === 0 ? (
<p className="text-sm text-[#71717a]">
Nessuna offerta disponibile. Crea prima un&apos;offerta in{" "}
<a href="/admin/offers" className="text-[#1A463C] underline">Offerte</a>.
</p>
) : (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-4 space-y-4">
{/* Step 1 — choose offer */}
<div>
<label className="text-xs text-[#71717a] block mb-1">Offerta</label>
<select
value={macroId}
onChange={(e) => selectMacro(e.target.value)}
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15"
>
<option value="">Seleziona offerta...</option>
{offers.map((o) => (
<option key={o.macro_id} value={o.macro_id}>
{o.name}
{o.category ? ` · ${o.category}` : ""} · {typeLabel(o.offer_type)}
</option>
))}
</select>
</div>
{/* Step 2 — choose tier */}
{selectedOffer && (
<div>
<label className="text-xs text-[#71717a] block mb-1.5">Tier</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{selectedOffer.tiers.map((t) => {
const active = tierId === t.id;
const price = formatEuro(t.public_price);
const pub = meaningfulPublicName(t);
return (
<button
key={t.id}
type="button"
onClick={() => setTierId(t.id)}
className={`text-left rounded-lg border p-3 transition-colors ${
active
? "border-[#1A463C] bg-[#1A463C]/5 ring-1 ring-[#1A463C]/20"
: "border-[#e5e7eb] hover:border-[#1A463C]/30 bg-white"
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-[#1a1a1a]">
{t.tier_letter ? `Tier ${t.tier_letter}` : t.public_name || "Tier"}
</span>
{active && <Check className="h-4 w-4 text-[#1A463C]" />}
</div>
{pub && <p className="text-xs text-[#71717a] mt-0.5 truncate">{pub}</p>}
<p className="text-sm text-[#1a1a1a] mt-1 tabular-nums">{price ?? "—"}</p>
</button>
);
})}
</div>
</div>
)}
{/* Options + submit */}
{selectedOffer && (
<div className="space-y-3 pt-1">
<div>
<label className="text-xs text-[#71717a] block mb-1">Totale accettato (opzionale)</label>
<input
value={acceptedTotal}
onChange={(e) => setAcceptedTotal(e.target.value)}
type="number"
step="0.01"
min="0"
placeholder="0.00"
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15"
/>
</div>
<label className="flex items-start gap-2 text-xs text-[#1a1a1a] cursor-pointer">
<input
type="checkbox"
checked={importPhases}
onChange={(e) => setImportPhases(e.target.checked)}
className="mt-0.5 h-3.5 w-3.5 accent-[#1A463C]"
/>
<span>
Importa fasi e task dall&apos;offerta
<span className="block text-[#71717a]">
Crea le fasi raggruppando i servizi per &laquo;Fase&raquo;; ogni servizio diventa un task.
</span>
</span>
</label>
<button
type="button"
onClick={handleAssign}
disabled={isPending || !tierId}
className="bg-[#1A463C] text-white text-sm font-medium px-4 py-2 rounded-md hover:bg-[#1A463C]/90 transition-colors disabled:opacity-40"
>
{isPending ? "Assegnazione..." : "Assegna Offerta"}
</button>
</div>
)}
</div>
<div>
<label className="text-xs text-[#71717a] block mb-1">Totale accettato (opzionale)</label>
<input
name="accepted_total"
type="number"
step="0.01"
min="0"
placeholder="0.00"
className="w-full border rounded px-3 py-1.5 text-sm"
/>
</div>
<button
type="submit"
disabled={isPending || availableMicros.length === 0}
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31] disabled:opacity-50"
>
{isPending ? "Salvataggio..." : "Assegna Offerta"}
</button>
{availableMicros.length === 0 && (
<p className="text-xs text-[#71717a]">
Nessuna micro-offerta disponibile. Crea prima una micro-offerta in{" "}
<a href="/admin/offers" className="underline">Offerte</a>.
</p>
)}
</form>
)}
</div>
</div>
);

Some files were not shown because too many files have changed in this diff Show More