Compare commits

...

23 Commits

Author SHA1 Message Date
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
90 changed files with 5313 additions and 2069 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 -217
View File
@@ -1,238 +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)
- [x] **Phase 18: Cleanup & Consolidamento** - Rimuovi Forecast + quote builder manuale; fondi `/admin/analytics` nella dashboard; archivia fasi residue v2.1
- [x] **Phase 19: Pipeline CRM Kanban** - Board lead stile Pipedrive con colonne per stage + drag-drop; vinto/perso come cambio-colonna manuale
- [ ] **Phase 20: Knowledge Base Cliente** - Schema additivo + UI per incollare transcript datati multipli per lead/cliente
- [ ] **Phase 21: Agente AI — generazione preventivo** - Builder: admin sceglie cliente+offerta, l'AI (Claude) legge transcript + offerta e genera bozza preventivo; admin revisiona
- [ ] **Phase 22: Pagina pubblica preventivo + email** - Pagina pubblica HTML/CSS, accept/reject → CRM, invio link via email (Resend)
<details>
<summary>✅ v1.0 + v2.0 + v2.1 (Phases 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**:
- [x] 18-01-PLAN.md — Rimozione Forecast + Quote Builder manuale (CLEAN-01, CLEAN-02)
- [x] 18-02-PLAN.md — Fusione analytics nella Dashboard, eliminazione rotta /admin/analytics (CLEAN-03)
- [x] 18-03-PLAN.md — Verifica CLEAN-04 planning docs + build check + checkpoint visuale
**Status**: ✅ Complete (2026-06-19) — CLEAN-01..04 verificati, build verde
### Phase 19: Pipeline CRM Kanban
**Goal**: I lead si gestiscono in una board Kanban stile Pipedrive con drag-drop tra gli stage; vinto/perso è un cambio-colonna manuale.
**Mode:** ui
**Depends on**: Phase 14 (CRM lead esistente). Riusa `@dnd-kit` (pattern KanbanBoard progetti).
**Requirements**: PIPE-01, PIPE-02
**Success Criteria** (what must be TRUE):
1. I lead sono mostrati in colonne per stage (contacted → qualified → proposal_sent → negotiating → won/lost) con drag-drop che aggiorna `leads.status`
2. La tabella inline-edit esistente resta disponibile come vista alternativa
3. Spostare un lead su "Vinto"/"Perso" è l'azione manuale che ne registra l'esito
**Plans**: 1 plan
**Plan list**:
- [x] 19-01-PLAN.md — LeadsKanbanBoard + LeadsViewToggle + page wiring (PIPE-01, PIPE-02)
**Status**: ✅ Complete (2026-06-19) — board 6 colonne, drag-drop persistente, toggle Lista/Kanban, search integrata
### Phase 20: Knowledge Base Cliente
**Goal**: Memorizzare i transcript datati delle call (multipli per lead/cliente) e mostrarli nel profilo, pronti per essere dati in pasto all'AI.
**Mode:** schema + ui
**Depends on**: Phase 14 (lead) / clienti. Migration a prod via SSH PRIMA del codice dipendente.
**Requirements**: KB-01, KB-02
**Success Criteria** (what must be TRUE):
1. Esiste uno schema additivo `client_transcripts` (lead_id/client_id, testo, data, titolo/tipo, created_at)
2. L'admin incolla un transcript con data dal dettaglio lead/cliente
3. I transcript sono elencati in ordine cronologico nel profilo
**Plans:** 3 plans
Plans:
- [ ] 20-01-PLAN.md — Migration SQL 0009_client_transcripts (BLOCKING checkpoint prod)
- [ ] 20-02-PLAN.md — Schema Drizzle + getTranscripts + addTranscript/deleteTranscript actions
- [ ] 20-03-PLAN.md — TranscriptModal + sezione LeadDetail + wiring page.tsx
**Status**: Planned (2026-06-19) — ready for `/gsd-execute-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 | 3/3 | ✅ Done | 2026-06-19 |
| 19 (R2). Pipeline CRM Kanban | 1/1 | ✅ Done | 2026-06-19 |
| 20 (R3). Knowledge Base Cliente (transcript) | 3 plans | Planned (2026-06-19) | — |
| 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 21+22 codice completo — pending: (1) migration 0010 a prod via SSH tunnel; (2) dati reali in profile.ts"
last_updated: "2026-06-20T14:00:00.000Z"
last_activity: 2026-06-20 -- Phase 21+22 eseguite insieme — agente AI Opus 4.8 + deck pubblico 20+ slide + entry point LeadDetail + sidebar globale
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: 5
completed_phases: 4
total_plans: 7
completed_plans: 6
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: eseguire Phase 19 (R2) Pipeline CRM Kanban.
**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: 20 (R3) Knowledge Base Cliente — ready to execute
Plan: 3 piani (20-01 migration, 20-02 data layer, 20-03 UI)
Status: Ready to execute
Last activity: 2026-06-19 -- Phase 20 planned (3 piani, verification passed — KB-01/KB-02)
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): [████░░░░░░] 40% — 2/5 fasi complete
### 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-19T18:05:00.000Z
Stopped at: Phase 19 complete — 19-01-SUMMARY.md scritto, PIPE-01/PIPE-02 verificati, checkpoint umano approvato. Next: /gsd-plan-phase 20
Resume file: .planning/phases/19-pipeline-crm-kanban/19-01-SUMMARY.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,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,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) |
+10 -1
View File
@@ -33,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": {
@@ -9308,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",
+1
View File
@@ -34,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>
)}
+43 -37
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" },
]);
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/${newClient.id}`);
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();
}
+44 -37
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,46 +17,50 @@ 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>
);
}
}
+9 -5
View File
@@ -1,18 +1,21 @@
import { notFound } from "next/navigation";
import { getActivityLog, getUpcomingReminders, getTranscripts } from "@/lib/lead-service";
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
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 [leads, options] = await Promise.all([
getLeadsWithTags(),
const [lead, options, convertedClientId] = await Promise.all([
getLeadByIdWithTags(id),
getLeadFieldOptions(),
getClientIdFromLead(id),
]);
const lead = leads.find((l) => l.id === id);
if (!lead) {
notFound();
}
@@ -31,6 +34,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
tags={lead.tags}
tagOptions={options.tags}
transcripts={transcripts}
convertedClientId={convertedClientId}
/>
);
}
+52 -1
View File
@@ -2,7 +2,7 @@
import { z } from "zod";
import { db } from "@/db";
import { leads, quotes, tags, clientTranscripts } from "@/db/schema";
import { leads, quotes, tags, clientTranscripts, projects } from "@/db/schema";
import { nanoid } from "nanoid";
import { eq, and } from "drizzle-orm";
import {
@@ -12,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/leads");
revalidatePath("/admin");
redirect(`/admin/clients/${clientId}`);
}
export async function createLead(data: z.infer<typeof createLeadSchema>) {
const parsed = createLeadSchema.safeParse(data);
+2 -5
View File
@@ -1,6 +1,7 @@
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;
@@ -12,11 +13,7 @@ export default async function LeadsPage() {
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>
<PageHeader title="Lead Pipeline" action={<CreateLeadModal />} />
<LeadsViewToggle leads={leads} options={options} />
</div>
);
+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>
+14 -3
View File
@@ -62,7 +62,17 @@ export async function generateProposalDraft(formData: FormData) {
.orderBy(desc(clientTranscripts.call_date));
}
const MODEL = "claude-opus-4-8";
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({
@@ -70,6 +80,7 @@ export async function generateProposalDraft(formData: FormData) {
client,
transcripts,
offer,
model: resolvedModel,
});
// Assembla contenuto completo
@@ -78,7 +89,7 @@ export async function generateProposalDraft(formData: FormData) {
client,
offer,
aiContent,
model: MODEL,
model: resolvedModel,
});
// Salva nel DB
@@ -94,7 +105,7 @@ export async function generateProposalDraft(formData: FormData) {
offer_macro_id: offerMacroId,
title,
content: assembled as unknown as Record<string, unknown>,
model: MODEL,
model: resolvedModel,
state: "draft",
});
@@ -16,6 +16,7 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
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);
@@ -25,6 +26,8 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
const form = e.currentTarget;
const formData = new FormData(form);
formData.append("model", model);
startTransition(async () => {
try {
await action(formData);
@@ -65,6 +68,35 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
</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">
@@ -135,7 +167,7 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
{/* Info generazione */}
<div className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2">
L&apos;AI (Claude Opus) legge i transcript del soggetto selezionato e genera una bozza
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>
+34 -34
View File
@@ -1,6 +1,7 @@
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;
@@ -14,47 +15,46 @@ const STATE_LABELS: Record<string, { label: string; className: string }> = {
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="max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Preventivi</h1>
<p className="text-sm text-muted-foreground mt-1">
Preventivi generati con AI e pubblicati ai clienti
</p>
</div>
<Link
href="/admin/preventivi/genera"
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
Genera preventivo
</Link>
</div>
<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-border rounded-lg">
<FileText size={40} className="mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground text-sm">Nessun preventivo ancora.</p>
<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-primary hover:underline"
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-border rounded-lg overflow-hidden">
<div className="border border-[#e5e7eb] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted border-b border-border">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Titolo</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Lead / Cliente</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Offerta</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Stato</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Data</th>
<th className="px-4 py-3 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>
@@ -63,20 +63,20 @@ export default async function PreventiviPage() {
const stateInfo = STATE_LABELS[p.state] ?? { label: p.state, className: "bg-gray-100 text-gray-700" };
const subject = p.clientName ?? p.leadName ?? "—";
return (
<tr key={p.id} className="border-b border-border last:border-0 hover:bg-muted/50 transition-colors">
<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-primary transition-colors">
<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-muted-foreground">{subject}</td>
<td className="px-4 py-3 text-muted-foreground">{p.offerName}</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-muted-foreground">
<td className="px-4 py-3 text-[#71717a]">
{p.createdAt.toLocaleDateString("it-IT")}
</td>
<td className="px-4 py-3 text-right">
@@ -85,7 +85,7 @@ export default async function PreventiviPage() {
href={`/preventivo/${p.slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
className="text-xs text-[#1A463C] hover:underline"
>
Apri
</a>
+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}`);
}
+54 -2
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

+164 -40
View File
@@ -1,64 +1,154 @@
@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 */
--background: #ffffff;
--foreground: #1a1a1a;
/* Brand primary — verde scuro iamcavalli */
--primary: #1A463C;
--primary-foreground: #ffffff;
/* Brand accent — giallo lime */
--accent: #DEF168;
--accent-foreground: #1A463C;
/* Secondary — grigio neutro */
--secondary: #f4f4f5;
--secondary-foreground: #1a1a1a;
/* Muted — per testi e sfondi secondari */
--muted: #f9f9f9;
--muted-foreground: #71717a;
/* Destructive */
--destructive: #ef4444;
--destructive-foreground: #ffffff;
/* Card */
--card: #ffffff;
--card-foreground: #1a1a1a;
/* Popover */
--popover: #ffffff;
--popover-foreground: #1a1a1a;
/* Border / Input / Ring */
--border: #e5e7eb;
--input: #e5e7eb;
--ring: #1A463C;
/* Semantic — stato task/pagamenti */
--success: #16a34a;
--warning: #ca8a04;
--info: #2563eb;
/* Legacy — usati inline nei componenti esistenti */
--tertiary: #999999;
--bg-subtle: #f9f9f9;
--border-light: #e5e5e5;
--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,
"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);
}
/* =========================================================
@@ -75,10 +165,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

+6 -1
View File
@@ -32,7 +32,12 @@ export default function RootLayout({
lang="it"
className={`${geistSans.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>
+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>
)
}
+8 -14
View File
@@ -14,11 +14,12 @@ import {
Zap,
FileText,
} from "lucide-react";
import ThemeToggle from "@/components/ThemeToggle";
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/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 },
@@ -39,17 +40,6 @@ export function AdminSidebar() {
<span className="font-bold text-white tracking-tight text-sm">iamcavalli</span>
</div>
{/* CTA globale — genera preventivo */}
<div className="px-3 py-3 border-b border-white/10">
<Link
href="/admin/preventivi/genera"
className="flex items-center justify-center gap-2 w-full px-3 py-2 rounded-md text-xs font-semibold bg-[#DEF168] text-[#1A463C] hover:bg-[#d4e85e] transition-colors"
>
<FileText size={13} />
Genera preventivo
</Link>
</div>
{/* Nav links */}
<nav className="flex-1 px-3 py-4 flex flex-col gap-0.5">
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
@@ -71,8 +61,12 @@ export function AdminSidebar() {
})}
</nav>
{/* Logout */}
<div className="px-3 py-4 border-t border-white/10">
{/* Theme + Logout */}
<div className="px-3 py-4 border-t border-white/10 flex flex-col gap-2">
<div className="flex items-center justify-between px-3">
<span className="text-xs text-white/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"
@@ -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 items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-[#1a1a1a]">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-[#71717a]">{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/leads"
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/leads/${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/leads"
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>
);
}
+37 -1
View File
@@ -7,7 +7,13 @@ 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, deleteTranscript } from "@/app/admin/leads/actions";
import {
addLeadTag,
removeLeadTag,
renameLeadTag,
deleteTranscript,
convertLeadToClient,
} from "@/app/admin/leads/actions";
import { formatDistanceToNow, format } from "date-fns";
import { it } from "date-fns/locale";
import Link from "next/link";
@@ -39,6 +45,7 @@ export function LeadDetail({
tags,
tagOptions,
transcripts,
convertedClientId,
}: {
lead: Lead;
activities: Activity[];
@@ -46,13 +53,23 @@ export function LeadDetail({
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);
startTransition(async () => {
@@ -106,6 +123,25 @@ export function LeadDetail({
>
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>
@@ -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>
+40 -36
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>
);
}
}
+40 -11
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>
);
+194 -40
View File
@@ -1,8 +1,13 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
updatePaymentStatus,
updateAcceptedTotal,
setPaymentPaidAt,
} from "@/app/admin/clients/[id]/actions";
import { initProjectPayments } from "@/app/admin/projects/project-actions";
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -14,6 +19,7 @@ type Props = {
acceptedTotal: string;
clientId: string;
projectId?: string; // set only from project detail page — enables init & split
offersAcceptedTotal?: number; // sum of accepted_total from active offers
};
const statusLabels: Record<string, string> = {
@@ -22,28 +28,132 @@ const statusLabels: Record<string, string> = {
saldato: "Saldato",
};
export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId }: Props) {
// paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per <input type="month">
function toMonthValue(paidAt: Date | string | null | undefined): string {
if (!paidAt) {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
const d = paidAt instanceof Date ? paidAt : new Date(paidAt);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
type PlanMode = "single" | "two" | "three";
const planOptions: { mode: PlanMode; label: string; description: string }[] = [
{ mode: "single", label: "Pagamento unico", description: "100% in un'unica soluzione" },
{ mode: "two", label: "2 step", description: "Acconto 50% + Saldo 50%" },
{ mode: "three", label: "3 step", description: "50% + 30% + 20%" },
];
export function PaymentsTab({
payments,
acceptedTotal,
clientId,
projectId,
offersAcceptedTotal = 0,
}: Props) {
const router = useRouter();
const [overrideValue, setOverrideValue] = useState(acceptedTotal);
const [planLoading, setPlanLoading] = useState<PlanMode | null>(null);
const [statusLoading, setStatusLoading] = useState<string | null>(null);
const currentTotal = parseFloat(acceptedTotal) || 0;
const offersTotal = offersAcceptedTotal;
const hasOffers = offersTotal > 0;
async function handleUseOffersTotal() {
if (!projectId) return;
setOverrideValue(offersTotal.toFixed(2));
const fd = new FormData();
fd.set("accepted_total", offersTotal.toFixed(2));
await updateAcceptedTotal(projectId, fd);
router.refresh();
}
async function handleSaveTotal(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("accepted_total", overrideValue);
await updateAcceptedTotal(clientId, fd);
router.refresh();
}
async function handleSetPlan(mode: PlanMode) {
if (!projectId) return;
setPlanLoading(mode);
try {
const total = parseFloat(overrideValue) || currentTotal;
await setPaymentPlan(projectId, mode, total);
router.refresh();
} finally {
setPlanLoading(null);
}
}
async function handleStatusUpdate(paymentId: string, status: string) {
setStatusLoading(paymentId);
try {
await updatePaymentStatus(paymentId, clientId, status);
router.refresh();
} finally {
setStatusLoading(null);
}
}
async function handlePaidMonthUpdate(paymentId: string, monthStr: string) {
if (!monthStr) return;
setStatusLoading(paymentId);
try {
await setPaymentPaidAt(paymentId, clientId, monthStr);
router.refresh();
} finally {
setStatusLoading(null);
}
}
return (
<div className="space-y-6 max-w-md">
{/* Accepted total */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
<form
action={async (fd: FormData) => {
"use server";
await updateAcceptedTotal(clientId, fd);
}}
className="flex items-end gap-3"
>
{/* Totale preventivo */}
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
<h3 className="font-medium text-gray-900">Totale preventivo</h3>
{/* Totale ereditato dalle offerte */}
{hasOffers && (
<div className="flex items-center justify-between bg-[#f9f9f9] border border-[#e5e7eb] rounded-md px-3 py-2 gap-3">
<span className="text-sm text-[#71717a]">
Ereditato dalle offerte attive:{" "}
<span className="font-semibold text-[#1a1a1a]">
{" "}
{offersTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</span>
</span>
{projectId && (
<Button
type="button"
size="sm"
variant="outline"
onClick={handleUseOffersTotal}
className="shrink-0 text-xs"
>
Usa questo totale
</Button>
)}
</div>
)}
{/* Override manuale */}
<form onSubmit={handleSaveTotal} className="flex items-end gap-3">
<div className="space-y-1 flex-1">
<Label htmlFor="accepted_total">Importo ()</Label>
<Label htmlFor="accepted_total">Importo override ()</Label>
<Input
id="accepted_total"
name="accepted_total"
type="number"
step="0.01"
min="0"
defaultValue={acceptedTotal}
value={overrideValue}
onChange={(e) => setOverrideValue(e.target.value)}
className="max-w-xs"
/>
</div>
@@ -51,40 +161,71 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
Salva
</Button>
</form>
<p className="text-xs text-gray-400 mt-2">
Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
</p>
</div>
{/* Selettore schema rate */}
{projectId && (
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
<h3 className="font-medium text-gray-900">Schema di pagamento</h3>
<div className="flex gap-2 flex-wrap">
{planOptions.map(({ mode, label, description }) => (
<button
key={mode}
type="button"
onClick={() => handleSetPlan(mode)}
disabled={planLoading !== null}
className={`flex-1 min-w-[120px] border rounded-lg px-3 py-2.5 text-left transition-colors ${
planLoading === mode
? "border-[#1A463C] bg-[#1A463C]/5 opacity-70"
: "border-[#e5e7eb] hover:border-[#1A463C] hover:bg-[#1A463C]/5"
}`}
>
<p className="text-sm font-medium text-[#1a1a1a]">{label}</p>
<p className="text-xs text-[#71717a] mt-0.5">{description}</p>
</button>
))}
</div>
<p className="text-xs text-[#71717a]">
Selezionare uno schema cancella le rate esistenti e crea le nuove in base al totale corrente.
</p>
</div>
)}
{/* Empty state — project has no payments yet */}
{projectId && payments.length === 0 && (
<div className="bg-white border border-dashed border-gray-300 rounded-lg p-6 text-center">
<p className="text-sm text-[#71717a] mb-4">
Nessun pagamento configurato. Crea le rate standard Acconto e Saldo.
Nessun pagamento configurato. Scegli uno schema sopra o crea le rate standard.
</p>
<form
action={async () => {
"use server";
await initProjectPayments(projectId);
}}
<Button
type="button"
size="sm"
onClick={() => handleSetPlan("two")}
disabled={planLoading !== null}
>
<Button type="submit" size="sm">
Crea Acconto &amp; Saldo
</Button>
</form>
Crea Acconto &amp; Saldo (50/50)
</Button>
</div>
)}
{/* Payment rows */}
{payments.map((p) => {
const amount = parseFloat(p.amount);
const pct = p.percent !== null && p.percent !== undefined
? parseFloat(String(p.percent))
: null;
return (
<div
key={p.id}
className="bg-white border border-gray-200 rounded-lg p-4"
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium text-gray-900">{p.label}</h3>
<div>
<h3 className="font-medium text-gray-900">{p.label}</h3>
{pct !== null && (
<p className="text-xs text-[#71717a]">{pct}% del totale</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">
{" "}
@@ -101,16 +242,14 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
)}
</div>
</div>
<form
action={async (fd: FormData) => {
"use server";
await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
}}
className="flex items-center gap-2"
>
<div className="flex items-center gap-2">
<select
name="status"
defaultValue={p.status}
disabled={statusLoading === p.id}
onChange={async (e) => {
await handleStatusUpdate(p.id, e.target.value);
}}
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
>
{Object.entries(statusLabels).map(([val, label]) => (
@@ -119,13 +258,28 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
</option>
))}
</select>
<Button type="submit" size="sm" variant="outline">
Aggiorna
</Button>
</form>
{statusLoading === p.id && (
<span className="text-xs text-[#71717a]">...</span>
)}
</div>
{p.status === "saldato" && (
<div className="flex items-center gap-2 mt-2">
<Label htmlFor={`paid-${p.id}`} className="text-xs text-[#71717a] shrink-0">
Incassato nel mese
</Label>
<input
id={`paid-${p.id}`}
type="month"
defaultValue={toMonthValue(p.paid_at)}
disabled={statusLoading === p.id}
onChange={(e) => handlePaidMonthUpdate(p.id, e.target.value)}
className="text-sm border border-gray-200 rounded px-2 py-1 bg-white"
/>
</div>
)}
</div>
);
})}
</div>
);
}
}
+64 -57
View File
@@ -6,6 +6,7 @@ import {
} from "@/app/admin/clients/[id]/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
import type { ClientFullDetail } from "@/lib/admin-queries";
type Props = {
@@ -20,8 +21,8 @@ const taskStatusOptions = [
];
const phaseStatusOptions = [
{ value: "upcoming", label: "In arrivo" },
{ value: "active", label: "Attiva" },
{ value: "upcoming", label: "Da iniziare" },
{ value: "active", label: "In corso" },
{ value: "done", label: "Completata" },
];
@@ -58,32 +59,35 @@ export async function PhasesTab({ phases, clientId }: Props) {
>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
<form
action={async (fd: FormData) => {
"use server";
await updatePhaseStatus(
phase.id,
clientId,
fd.get("status") as string
);
}}
className="flex items-center gap-2"
>
<select
name="status"
defaultValue={phase.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
<div className="flex items-center gap-2">
<form
action={async (fd: FormData) => {
"use server";
await updatePhaseStatus(
phase.id,
clientId,
fd.get("status") as string
);
}}
className="flex items-center gap-2"
>
{phaseStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<Button type="submit" variant="ghost" size="sm" className="text-xs">
Salva
</Button>
</form>
<select
name="status"
defaultValue={phase.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
>
{phaseStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<Button type="submit" variant="ghost" size="sm" className="text-xs">
Salva
</Button>
</form>
<DeletePhaseTaskButton type="phase" phaseId={phase.id} clientId={clientId} />
</div>
</div>
{/* Tasks */}
@@ -94,37 +98,40 @@ export async function PhasesTab({ phases, clientId }: Props) {
className="flex items-center justify-between pl-3 border-l-2 border-gray-100"
>
<span className="text-sm text-gray-800">{task.title}</span>
<form
action={async (fd: FormData) => {
"use server";
await updateTaskStatus(
task.id,
clientId,
fd.get("status") as string
);
}}
className="flex items-center gap-1"
>
<select
name="status"
defaultValue={task.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
<div className="flex items-center gap-1">
<form
action={async (fd: FormData) => {
"use server";
await updateTaskStatus(
task.id,
clientId,
fd.get("status") as string
);
}}
className="flex items-center gap-1"
>
{taskStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<Button
type="submit"
variant="ghost"
size="sm"
className="text-xs px-1"
>
</Button>
</form>
<select
name="status"
defaultValue={task.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
>
{taskStatusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<Button
type="submit"
variant="ghost"
size="sm"
className="text-xs px-1"
>
</Button>
</form>
<DeletePhaseTaskButton type="task" taskId={task.id} clientId={clientId} />
</div>
</div>
))}
</div>
-333
View File
@@ -1,333 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
addQuoteItem,
removeQuoteItem,
updateAcceptedTotal,
} from "@/app/admin/clients/[id]/quote-actions";
import type { QuoteItemWithLabel } from "@/lib/admin-queries";
import type { Service } from "@/db/schema";
type Props = {
clientId: string;
items: QuoteItemWithLabel[];
activeServices: Service[];
acceptedTotal: string;
};
export function QuoteTab({ clientId, items, activeServices, acceptedTotal }: Props) {
const [showCustom, setShowCustom] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const [totalError, setTotalError] = useState<string | null>(null);
// For catalog mode: pre-fill unit_price when service is selected
const [selectedServicePrice, setSelectedServicePrice] = useState<string>("");
const [, startTransition] = useTransition();
const router = useRouter();
const calculatedTotal = items.reduce(
(sum, item) => sum + parseFloat(item.subtotal),
0
);
function handleAddItem(fd: FormData) {
setAddError(null);
startTransition(async () => {
try {
await addQuoteItem(clientId, fd);
router.refresh();
} catch (e) {
setAddError(e instanceof Error ? e.message : "Errore nell'aggiunta");
}
});
}
function handleRemove(quoteItemId: string) {
startTransition(async () => {
await removeQuoteItem(quoteItemId, clientId);
router.refresh();
});
}
function handleSaveTotal(fd: FormData) {
setTotalError(null);
startTransition(async () => {
try {
await updateAcceptedTotal(clientId, fd);
router.refresh();
} catch (e) {
setTotalError(
e instanceof Error ? e.message : "Errore nel salvataggio"
);
}
});
}
return (
<div className="space-y-6 max-w-2xl">
{/* Section 1: Add items */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">
Aggiungi voci
</h3>
{!showCustom ? (
/* Catalog mode */
<form action={handleAddItem} className="space-y-3">
<input type="hidden" name="custom_label" value="" />
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[180px] space-y-1">
<Label htmlFor="service_id">Seleziona dal catalogo</Label>
<select
name="service_id"
id="service_id"
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]/30"
onChange={(e) => {
const svc = activeServices.find((s) => s.id === e.target.value);
setSelectedServicePrice(
svc ? parseFloat(svc.unit_price).toFixed(2) : ""
);
}}
required
>
<option value=""> Scegli servizio </option>
{activeServices.map((s) => (
<option key={s.id} value={s.id}>
{s.name} (
{parseFloat(s.unit_price).toLocaleString("it-IT", {
minimumFractionDigits: 2,
})}
)
</option>
))}
</select>
</div>
<div className="w-28 space-y-1">
<Label htmlFor="unit_price_catalog">Prezzo unit.</Label>
<Input
id="unit_price_catalog"
name="unit_price"
type="number"
step="0.01"
min="0.01"
value={selectedServicePrice}
onChange={(e) => setSelectedServicePrice(e.target.value)}
placeholder="0.00"
required
/>
</div>
<div className="w-20 space-y-1">
<Label htmlFor="quantity_catalog">Qty</Label>
<Input
id="quantity_catalog"
name="quantity"
type="number"
step="0.01"
min="0.01"
defaultValue="1"
required
/>
</div>
<Button
type="submit"
size="sm"
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
>
Aggiungi
</Button>
</div>
<button
type="button"
onClick={() => {
setShowCustom(true);
setAddError(null);
}}
className="text-xs text-[#71717a] hover:text-[#1a1a1a] underline"
>
Oppure aggiungi voce libera
</button>
{addError && <p className="text-xs text-red-600">{addError}</p>}
</form>
) : (
/* Freeform mode */
<form action={handleAddItem} className="space-y-3">
<input type="hidden" name="service_id" value="" />
<div className="space-y-1">
<Label htmlFor="custom_label">Nome voce</Label>
<Input
id="custom_label"
name="custom_label"
placeholder="es. Consulenza extra, Spese viaggi"
required
/>
</div>
<div className="flex gap-3 flex-wrap">
<div className="flex-1 min-w-[120px] space-y-1">
<Label htmlFor="unit_price_custom">Prezzo unitario ()</Label>
<Input
id="unit_price_custom"
name="unit_price"
type="number"
step="0.01"
min="0.01"
placeholder="0.00"
required
/>
</div>
<div className="w-20 space-y-1">
<Label htmlFor="quantity_custom">Qty</Label>
<Input
id="quantity_custom"
name="quantity"
type="number"
step="0.01"
min="0.01"
defaultValue="1"
required
/>
</div>
</div>
{addError && <p className="text-xs text-red-600">{addError}</p>}
<div className="flex gap-2">
<Button
type="submit"
size="sm"
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
>
Aggiungi voce libera
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setShowCustom(false);
setAddError(null);
}}
>
Torna al catalogo
</Button>
</div>
</form>
)}
</div>
{/* Section 2: Quote items table + calculated total */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4">
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
Voci preventivo
</h3>
{items.length === 0 ? (
<p className="text-sm text-[#71717a] py-4 text-center">
Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.
</p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-[#e5e7eb]">
<tr>
<th className="text-left py-2 px-2 font-medium text-[#71717a]">
Voce
</th>
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
Qty
</th>
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
Prezzo unit.
</th>
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
Subtotale
</th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.id}
className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]"
>
<td className="py-2 px-2 text-[#1a1a1a]">{item.label}</td>
<td className="py-2 px-2 text-right tabular-nums">
{parseFloat(item.quantity).toLocaleString("it-IT", {
minimumFractionDigits: 2,
})}
</td>
<td className="py-2 px-2 text-right tabular-nums font-mono">
{parseFloat(item.unit_price).toLocaleString("it-IT", {
minimumFractionDigits: 2,
})}
</td>
<td className="py-2 px-2 text-right tabular-nums font-mono font-medium">
{parseFloat(item.subtotal).toLocaleString("it-IT", {
minimumFractionDigits: 2,
})}
</td>
<td className="py-2 px-2 text-right">
<button
type="button"
onClick={() => handleRemove(item.id)}
className="text-xs text-[#71717a] hover:text-red-600 transition-colors"
>
Rimuovi
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 pt-3 border-t border-[#e5e7eb] flex justify-end">
<p className="font-bold text-[#1a1a1a] tabular-nums">
Totale calcolato:
{calculatedTotal.toLocaleString("it-IT", {
minimumFractionDigits: 2,
})}
</p>
</div>
</>
)}
</div>
{/* Section 3: Accepted total */}
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-3">
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">
Totale accettato dal cliente
</h3>
<form action={handleSaveTotal} className="flex items-end gap-3">
<div className="flex-1 max-w-[200px] space-y-1">
<Label htmlFor="accepted_total">Importo ()</Label>
<Input
id="accepted_total"
name="accepted_total"
type="number"
step="0.01"
min="0"
defaultValue={parseFloat(acceptedTotal).toFixed(2)}
/>
</div>
<Button
type="submit"
size="sm"
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
>
Salva
</Button>
</form>
{totalError && <p className="text-xs text-red-600">{totalError}</p>}
<p className="text-xs text-[#71717a]">
Il cliente vede solo questo importo, non le singole voci del preventivo.
</p>
</div>
</div>
);
}
+13 -1
View File
@@ -2,6 +2,14 @@
import { TimerCell } from "@/components/admin/TimerCell";
import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard";
import { ManualTimeEntry } from "@/components/admin/ManualTimeEntry";
type TimeEntry = {
id: string;
started_at: Date;
ended_at: Date | null;
duration_seconds: number | null;
};
type TimerTabProps = {
projectId: string;
@@ -10,6 +18,7 @@ type TimerTabProps = {
activeTimerStartedAt: Date | null;
totalTrackedSeconds: number;
targetHourlyRate: number;
recentEntries: TimeEntry[];
};
export function TimerTab({
@@ -19,6 +28,7 @@ export function TimerTab({
activeTimerStartedAt,
totalTrackedSeconds,
targetHourlyRate,
recentEntries,
}: TimerTabProps) {
return (
<div className="space-y-6 max-w-sm">
@@ -33,6 +43,8 @@ export function TimerTab({
/>
</div>
<ManualTimeEntry projectId={projectId} recentEntries={recentEntries} />
<ProfitabilityCard
acceptedTotal={acceptedTotal}
totalTrackedSeconds={totalTrackedSeconds}
@@ -40,4 +52,4 @@ export function TimerTab({
/>
</div>
);
}
}
+132 -84
View File
@@ -1,13 +1,16 @@
import type { ClientView } from '@/lib/client-view';
import type { Comment } from '@/db/schema';
import { Progress } from '@/components/ui/progress';
import { RefreshCw } from 'lucide-react';
import { PhaseTimeline } from './phase-timeline';
import { PaymentStatus } from './payment-status';
import { DocumentsSection } from './documents-section';
import { NotesSection } from './notes-section';
import { TranscriptsSection } from './transcripts-section';
import { PhaseViewToggle } from './client/kanban/PhaseViewToggle';
import { ChatSection } from './client/ChatSection';
import { OffersSection } from './client/OffersSection';
import { ChatProvider } from './client/ChatProvider';
import { ChatPanel } from './client/ChatPanel';
interface ClientDashboardProps {
view: ClientView;
@@ -16,94 +19,139 @@ interface ClientDashboardProps {
}
export function ClientDashboard({ view, token, comments }: ClientDashboardProps) {
// Determine payment display mode based on active offers
const retainerOffer = view.activeOffers?.find((o) => o.offer_type === "retainer");
const hasRetainer = !!retainerOffer;
const paymentLabel = hasRetainer ? "Totale Pagamento Mensile" : "Totale Investimento";
const paymentOverrideAmount = hasRetainer && retainerOffer?.accepted_total
? retainerOffer.accepted_total
: undefined;
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-5">
<div className="flex items-center">
<span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0">
iamcavalli
</span>
<h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight">
{view.client.brand_name}
</h1>
<div className="w-28 shrink-0" />
<ChatProvider phases={view.phases} clientId={view.client.id}>
<div className="min-h-screen bg-white">
{/* Header */}
<header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-5">
<div className="flex items-center">
<span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0">
iamcavalli
</span>
<h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight">
{view.client.brand_name}
</h1>
<div className="w-28 shrink-0" />
</div>
</div>
</header>
{/* Barra progresso globale — nascosta per retainer (fasi non tracciate) */}
{!hasRetainer && (
<section className="bg-[#f9f9f9] border-b border-[#e5e5e5]">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-[#1a1a1a]">Avanzamento Progetto</p>
<p className="text-sm font-bold text-[#1a1a1a]">{view.global_progress_pct}%</p>
</div>
<Progress value={view.global_progress_pct} className="h-2" />
</div>
</section>
)}
{/* Layout principale */}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* ── Sidebar sinistra ── */}
<aside className="w-full lg:w-72 shrink-0 order-2 lg:order-1">
<div className="lg:sticky lg:top-[89px] space-y-6">
{/* 1° Offerte Attive */}
{view.activeOffers && view.activeOffers.length > 0 && (
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Offerte Attive</h2>
<OffersSection offers={view.activeOffers} />
</div>
)}
{/* 2° Pagamenti */}
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Pagamenti</h2>
<PaymentStatus
accepted_total={view.client.accepted_total}
payments={view.payments}
totalLabel={paymentLabel}
overrideAmount={paymentOverrideAmount}
hideRows={hasRetainer}
/>
</div>
{/* 3° Documenti & File */}
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Documenti & File</h2>
<DocumentsSection documents={view.documents} />
</div>
{view.notes.length > 0 && (
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Note & Decisioni</h2>
<NotesSection notes={view.notes} />
</div>
)}
{view.transcripts.length > 0 && (
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Transcript Chiamate</h2>
<TranscriptsSection transcripts={view.transcripts} />
</div>
)}
</div>
</aside>
{/* ── Contenuto principale ── */}
<main className="flex-1 min-w-0 order-1 lg:order-2 space-y-12">
{hasRetainer ? (
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-4">
Fasi del Progetto
</h2>
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-[#d4d4d4] bg-[#fafafa] px-8 py-12 text-center">
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-[#f0f0f0]">
<RefreshCw className="h-5 w-5 text-[#71717a]" />
</span>
<div className="space-y-1.5">
<p className="text-sm font-semibold text-[#1a1a1a]">Servizio in abbonamento ricorrente</p>
<p className="text-xs text-[#71717a] max-w-xs leading-relaxed">
Non ci sono fasi di progetto da tracciare. Le fasi compaiono qui quando viene avviata un&apos;offerta a progetto.
</p>
</div>
</div>
</div>
) : (
<PhaseViewToggle
timelineView={<PhaseTimeline phases={view.phases} token={token} />}
phases={view.phases}
token={token}
/>
)}
{/* Chat "Messaggi & Revisioni" moved to slide-in panel (FAB bottom-right) */}
</main>
</div>
</div>
</header>
{/* Barra progresso globale */}
<section className="bg-[#f9f9f9] border-b border-[#e5e5e5]">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-[#1a1a1a]">Avanzamento Progetto</p>
<p className="text-sm font-bold text-[#1a1a1a]">{view.global_progress_pct}%</p>
<footer className="border-t border-[#e5e5e5] bg-[#f9f9f9] mt-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
<p className="text-xs text-[#999999] text-center">
Questa è la tua dashboard privata non condividere il link.
</p>
</div>
<Progress value={view.global_progress_pct} className="h-2" />
</div>
</section>
</footer>
{/* Layout principale */}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* ── Sidebar sinistra ── */}
<aside className="w-full lg:w-72 shrink-0 order-2 lg:order-1">
<div className="lg:sticky lg:top-[89px] space-y-6">
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Pagamenti</h2>
<PaymentStatus accepted_total={view.client.accepted_total} payments={view.payments} />
</div>
{view.activeOffers && view.activeOffers.length > 0 && (
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Offerte Attive</h2>
<OffersSection offers={view.activeOffers} />
</div>
)}
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Documenti & File</h2>
<DocumentsSection documents={view.documents} />
</div>
{view.notes.length > 0 && (
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Note & Decisioni</h2>
<NotesSection notes={view.notes} />
</div>
)}
</div>
</aside>
{/* ── Contenuto principale ── */}
<main className="flex-1 min-w-0 order-1 lg:order-2 space-y-12">
<PhaseViewToggle
timelineView={<PhaseTimeline phases={view.phases} token={token} />}
phases={view.phases}
token={token}
/>
{/* Chat revisioni */}
<div>
<h2 className="text-xl font-bold text-[#1a1a1a] mb-4">Messaggi & Revisioni</h2>
<ChatSection
clientId={view.client.id}
phases={view.phases}
token={token}
comments={comments}
/>
</div>
</main>
</div>
{/* Floating chat panel — FAB + slide-in panel */}
<ChatPanel token={token} comments={comments} />
</div>
<footer className="border-t border-[#e5e5e5] bg-[#f9f9f9] mt-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
<p className="text-xs text-[#999999] text-center">
Questa è la tua dashboard privata non condividere il link.
</p>
</div>
</footer>
</div>
</ChatProvider>
);
}
}
+260
View File
@@ -0,0 +1,260 @@
"use client";
import { useState, useTransition, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { Comment } from "@/db/schema";
import { useChatContext } from "./ChatProvider";
// TODO: Email-on-tag (admin→client notification when admin posts on a phase/task) is OUT OF SCOPE
// Hook point: after db.insert(comments) in /src/app/api/client/comment/route.ts and
// postAdminComment in /src/app/admin/clients/[id]/actions.ts — send Resend email here.
function formatTime(ts: Date | string): string {
const d = typeof ts === "string" ? new Date(ts) : ts;
return d.toLocaleString("it-IT", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
}
interface ChatPanelProps {
token: string;
comments: Comment[];
}
export function ChatPanel({ token, comments }: ChatPanelProps) {
const { isOpen, selectedPhaseId, phases, clientId, openChat, closeChat } = useChatContext();
const [body, setBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [, startTransition] = useTransition();
const router = useRouter();
const bottomRef = useRef<HTMLDivElement>(null);
// Build entity list: Generale + phases
type EntityOption = { id: string; type: "general" | "phase"; label: string };
const entityOptions: EntityOption[] = [
{ id: clientId, type: "general", label: "Generale" },
...phases.map((p) => ({ id: p.id, type: "phase" as const, label: p.title })),
];
// Build label map for displaying tags on existing messages
const labelMap = new Map<string, string>();
labelMap.set(clientId, "Generale");
for (const p of phases) {
labelMap.set(p.id, p.title);
for (const t of p.tasks) {
labelMap.set(t.id, `${p.title}${t.title}`);
for (const d of t.deliverables) {
labelMap.set(d.id, `${t.title}${d.title}`);
}
}
}
// Current selection: sync with selectedPhaseId from context
const [currentEntityId, setCurrentEntityId] = useState<string>(clientId);
useEffect(() => {
setCurrentEntityId(selectedPhaseId ?? clientId);
}, [selectedPhaseId, clientId]);
// Sort all comments chronologically
const sorted = [...comments].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
useEffect(() => {
if (isOpen) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [isOpen, comments.length]);
function handleSend(e: React.FormEvent) {
e.preventDefault();
const trimmed = body.trim();
if (!trimmed) return;
const selectedEntity = entityOptions.find((en) => en.id === currentEntityId);
if (!selectedEntity) return;
setError(null);
startTransition(async () => {
try {
const res = await fetch("/api/client/comment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
entity_type: selectedEntity.type,
entity_id: selectedEntity.id,
body: trimmed,
}),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Errore nell'invio");
return;
}
setBody("");
router.refresh();
} catch {
setError("Errore di rete");
}
});
}
return (
<>
{/* FAB — floating action button bottom-right */}
<button
type="button"
onClick={() => (isOpen ? closeChat() : openChat())}
aria-label="Apri messaggi"
className="fixed bottom-6 right-6 z-40 w-14 h-14 rounded-full bg-[#1A463C] text-white shadow-lg hover:bg-[#163a31] transition-colors flex items-center justify-center"
>
{isOpen ? (
/* X close icon */
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
/* Chat bubble + plus SVG */
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={1.8} viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.83L3 20l1.09-3.27C3.39 15.56 3 13.83 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
</button>
{/* Overlay — subtle backdrop on mobile */}
{isOpen && (
<div
className="fixed inset-0 z-30 bg-black/10 lg:hidden"
onClick={closeChat}
aria-hidden="true"
/>
)}
{/* Slide-in panel */}
<div
className={`fixed top-0 right-0 h-full z-40 w-full sm:w-[420px] bg-white shadow-2xl flex flex-col transition-transform duration-300 ease-in-out ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
aria-label="Messaggi & Revisioni"
role="dialog"
aria-modal="true"
>
{/* Panel header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[#e5e7eb] shrink-0">
<h2 className="text-base font-bold text-[#1a1a1a]">Messaggi & Revisioni</h2>
<button
type="button"
onClick={closeChat}
aria-label="Chiudi"
className="p-1.5 rounded-md text-[#71717a] hover:text-[#1a1a1a] hover:bg-[#f4f4f5] transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Chat feed */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{sorted.length === 0 && (
<p className="text-sm text-[#71717a] italic text-center py-10">
Nessun messaggio ancora. Scrivi qui sotto per iniziare.
</p>
)}
{sorted.map((c) => {
const isClient = c.author === "client";
const entityLabel = labelMap.get(c.entity_id);
const showTag = c.entity_id !== clientId && entityLabel;
return (
<div
key={c.id}
className={`flex flex-col gap-1 ${isClient ? "items-end" : "items-start"}`}
>
{/* Author + tag */}
<div className={`flex items-center gap-2 ${isClient ? "flex-row-reverse" : ""}`}>
<span
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full ${
isClient
? "bg-[#DEF168] text-[#1A463C]"
: "bg-[#1A463C] text-white"
}`}
>
{isClient ? "Tu" : "iamcavalli"}
</span>
{showTag && (
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-2 py-0.5 rounded-full truncate max-w-[160px]">
{entityLabel}
</span>
)}
</div>
{/* Bubble */}
<div
className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
isClient
? "bg-[#1A463C] text-white rounded-tr-sm"
: "bg-[#f4f4f5] text-[#1a1a1a] rounded-tl-sm"
}`}
>
{c.body}
</div>
{/* Timestamp */}
<span className="text-[10px] text-[#71717a]">{formatTime(c.created_at)}</span>
</div>
);
})}
<div ref={bottomRef} />
</div>
{/* Divider */}
<div className="border-t border-[#e5e7eb]" />
{/* Composer */}
<form onSubmit={handleSend} className="p-3 space-y-2 bg-[#fafafa] shrink-0">
{/* Tag selector: Generale + phases */}
<select
value={currentEntityId}
onChange={(e) => setCurrentEntityId(e.target.value)}
className="w-full text-xs border border-[#e5e7eb] rounded-lg px-3 py-1.5 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
>
{entityOptions.map((en) => (
<option key={en.id} value={en.id}>
{en.label}
</option>
))}
</select>
<div className="flex gap-2">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend(e as unknown as React.FormEvent);
}
}}
placeholder="Scrivi un messaggio… (Invio per inviare)"
rows={2}
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"
/>
<button
type="submit"
disabled={!body.trim()}
className="self-end px-4 py-2 rounded-lg bg-[#1A463C] text-white text-sm font-semibold disabled:opacity-40 hover:bg-[#1A463C]/90 transition-colors"
>
Invia
</button>
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
</form>
</div>
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { createContext, useContext, useState, useCallback } from "react";
import type { ClientView } from "@/lib/client-view";
interface ChatContextValue {
isOpen: boolean;
selectedPhaseId: string | null; // null = "Generale"
phases: ClientView["phases"];
clientId: string;
openChat: (phaseId?: string) => void;
closeChat: () => void;
}
const ChatContext = createContext<ChatContextValue | null>(null);
export function useChatContext() {
const ctx = useContext(ChatContext);
if (!ctx) throw new Error("useChatContext must be used inside ChatProvider");
return ctx;
}
export function ChatProvider({
children,
phases,
clientId,
}: {
children: React.ReactNode;
phases: ClientView["phases"];
clientId: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
const openChat = useCallback((phaseId?: string) => {
setSelectedPhaseId(phaseId ?? null);
setIsOpen(true);
}, []);
const closeChat = useCallback(() => {
setIsOpen(false);
}, []);
return (
<ChatContext.Provider value={{ isOpen, selectedPhaseId, phases, clientId, openChat, closeChat }}>
{children}
</ChatContext.Provider>
);
}
+84 -20
View File
@@ -1,39 +1,103 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
interface IncludedService {
name: string;
description: string | null;
}
interface ActiveOffer {
id: string;
public_name: string; // micro offer public name only (security: see T-05-10)
public_name: string; // micro offer public name — NOT shown to client (T-05-10)
offer_name: string; // macro public_name — shown as heading
offer_type: string; // una_tantum | retainer
cumulative_price: string; // sum of service prices
accepted_total: string | null;
services: IncludedService[];
}
interface OffersSectionProps {
offers: ActiveOffer[];
}
function OfferCard({ offer }: { offer: ActiveOffer }) {
const [open, setOpen] = useState(false);
const price = parseFloat(offer.cumulative_price);
const hasPrice = price > 0;
const hasServices = offer.services.length > 0;
return (
<div className="bg-white rounded-lg border border-[#e5e5e5] overflow-hidden">
{/* Main info rows */}
<div className="p-4 space-y-2">
{/* Heading: macro public name */}
<p className="text-sm font-semibold text-[#1a1a1a]">{offer.offer_name}</p>
<div className="space-y-1">
{/* "Valore incluso" — hidden when 0 */}
{hasPrice && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#71717a]">Valore incluso</span>
<span className="text-xs font-mono text-[#1a1a1a]">
{price.toFixed(2)}
</span>
</div>
)}
{/* "Prezzo finale" */}
{offer.accepted_total && (
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-[#1a1a1a]">Prezzo finale</span>
<span className="text-sm font-bold text-[#1A463C]">
{parseFloat(offer.accepted_total).toFixed(2)}
</span>
</div>
)}
</div>
</div>
{/* Accordion "Cosa è compreso" — only when there are services */}
{hasServices && (
<div className="border-t border-[#e5e5e5]">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 text-xs font-semibold text-[#1a1a1a] hover:bg-[#f9f9f9] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1A463C] focus-visible:ring-inset"
aria-expanded={open}
>
<span>Cosa è compreso</span>
<ChevronDown
className={`w-3.5 h-3.5 text-[#71717a] transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<ul className="px-4 pb-3 space-y-2">
{offer.services.map((svc, i) => (
<li key={i} className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-[#1a1a1a]">{svc.name}</span>
{svc.description && (
<span className="text-[11px] text-[#71717a] leading-snug">{svc.description}</span>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
export function OffersSection({ offers }: OffersSectionProps) {
if (offers.length === 0) return null;
return (
<div className="space-y-3">
{offers.map((offer) => (
<div key={offer.id} className="bg-white rounded-lg border border-[#e5e5e5] p-4">
<p className="text-sm font-semibold text-[#1a1a1a]">{offer.public_name}</p>
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-[#71717a]">Valore incluso</span>
<span className="text-xs font-mono text-[#1a1a1a]">
{parseFloat(offer.cumulative_price).toFixed(2)}
</span>
</div>
{offer.accepted_total && (
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-[#1a1a1a]">Prezzo finale</span>
<span className="text-sm font-bold text-[#1A463C]">
{parseFloat(offer.accepted_total).toFixed(2)}
</span>
</div>
)}
</div>
</div>
<OfferCard key={offer.id} offer={offer} />
))}
</div>
);
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { useState } from "react";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { ApproveButton } from "@/components/client/ApproveButton";
import { useChatContext } from "@/components/client/ChatProvider";
import type { ClientView } from "@/lib/client-view";
type Phase = ClientView["phases"][number];
const phaseStatusLabel: Record<"upcoming" | "active" | "done", string> = {
upcoming: "Da iniziare",
active: "In corso",
done: "Completata",
};
const phaseStatusStyle: Record<"upcoming" | "active" | "done", string> = {
upcoming: "border-transparent bg-[#999999] text-white",
active: "border-transparent bg-[#3b82f6] text-white",
done: "border-transparent bg-[#1A463C] text-white",
};
function PhaseStatusIcon({ status }: { status: "upcoming" | "active" | "done" }) {
if (status === "done") {
return (
<svg className="w-5 h-5 text-[#1A463C]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
);
}
if (status === "active") {
return (
<svg className="w-5 h-5 text-[#3b82f6]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-5 h-5 text-[#999999]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<circle cx={12} cy={12} r={9} />
</svg>
);
}
function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) {
if (status === "done") {
return (
<svg className="w-4 h-4 text-[#1A463C] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
);
}
if (status === "in_progress") {
return (
<svg className="w-4 h-4 text-[#3b82f6] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-4 h-4 text-[#999999] shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<circle cx={12} cy={12} r={9} />
</svg>
);
}
export function PhaseCard({
phase,
token,
defaultOpen,
}: {
phase: Phase;
token: string;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const { openChat } = useChatContext();
const doneCount = phase.tasks.filter((t) => t.status === "done").length;
return (
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-5">
{/* Header — always visible, click to toggle */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full text-left"
aria-expanded={open}
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5 min-w-0">
<PhaseStatusIcon status={phase.status} />
<h3 className="text-base font-bold text-[#1a1a1a] leading-snug">{phase.title}</h3>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge className={`text-xs ${phaseStatusStyle[phase.status]}`}>
{phaseStatusLabel[phase.status]}
</Badge>
{/* Chat bubble — opens panel pre-tagged with this phase */}
<button
type="button"
onClick={(e) => {
e.stopPropagation(); // don't toggle accordion
openChat(phase.id);
}}
aria-label="Apri chat per questa fase"
className="p-1 rounded-md text-[#999999] hover:text-[#1A463C] hover:bg-[#f0f7f4] transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={1.8} viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.83L3 20l1.09-3.27C3.39 15.56 3 13.83 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</button>
<svg
className={`w-4 h-4 text-[#999999] transition-transform ${open ? "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>
</div>
</div>
{/* Progress bar — always visible */}
<div className="mb-1">
<div className="flex justify-between items-center mb-1.5">
<p className="text-xs text-[#666666] font-medium">{doneCount} di {phase.tasks.length} task</p>
<p className="text-xs font-semibold text-[#1a1a1a]">{phase.progress_pct}%</p>
</div>
<Progress value={phase.progress_pct} className="h-1.5" />
</div>
</button>
{/* Collapsible task list */}
{open && (
<div className="mt-4">
{phase.tasks.length === 0 ? (
<p className="text-xs text-[#999999] italic">Nessun task ancora configurato.</p>
) : (
<ul className="space-y-3">
{phase.tasks.map((task) => (
<li key={task.id} className="flex items-start gap-2.5">
<TaskStatusIcon status={task.status} />
<div className="flex-1 min-w-0">
<p
className={`text-sm leading-snug ${
task.status === "done"
? "line-through text-[#999999]"
: "text-[#1a1a1a]"
}`}
>
{task.title}
</p>
{task.description && (
<p className="text-xs text-[#999999] mt-0.5 leading-snug">
{task.description}
</p>
)}
{task.deliverables.length > 0 && (
<ul className="mt-1.5 space-y-2">
{task.deliverables.map((d) => (
<li
key={d.id}
className="bg-[#f9f9f9] rounded px-3 py-2 flex items-center justify-between gap-2"
>
<span className="text-xs text-[#666666] truncate font-medium">
{d.title}
</span>
{(d.status === "pending" ||
d.status === "submitted" ||
d.approved_at !== null) && (
<ApproveButton
deliverableId={d.id}
token={token}
approvedAt={d.approved_at}
/>
)}
</li>
))}
</ul>
)}
</div>
</li>
))}
</ul>
)}
</div>
)}
</Card>
);
}
+54 -39
View File
@@ -5,6 +5,12 @@ import { Badge } from '@/components/ui/badge';
interface PaymentStatusProps {
accepted_total: string;
payments: ClientView['payments'];
/** Label for the total row. Defaults to "Totale Investimento" */
totalLabel?: string;
/** Override the displayed amount (retainer: show offer accepted_total, not project total) */
overrideAmount?: string;
/** When true, hide individual payment rows (Acconto/Saldo) — used for retainer offers */
hideRows?: boolean;
}
type PaymentStatusValue = 'da_saldare' | 'inviata' | 'saldato';
@@ -30,8 +36,15 @@ const statusConfig: Record<
},
};
export function PaymentStatus({ accepted_total, payments }: PaymentStatusProps) {
const totalFormatted = parseFloat(accepted_total || '0').toLocaleString('it-IT', {
export function PaymentStatus({
accepted_total,
payments,
totalLabel = 'Totale Investimento',
overrideAmount,
hideRows = false,
}: PaymentStatusProps) {
const displayAmount = overrideAmount ?? accepted_total;
const totalFormatted = parseFloat(displayAmount || '0').toLocaleString('it-IT', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
@@ -40,52 +53,54 @@ export function PaymentStatus({ accepted_total, payments }: PaymentStatusProps)
return (
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none overflow-hidden">
{/* Totale accettato — unico importo visibile al cliente (LOCKED) */}
{/* Totale — unico importo visibile al cliente (LOCKED) */}
<div className="px-6 py-5 border-b border-[#e5e5e5] bg-[#f9f9f9]">
<p className="text-xs font-semibold text-[#666666] uppercase tracking-wider mb-1">
Totale Preventivo Accettato
{totalLabel}
</p>
<p className="text-3xl font-bold text-[#1a1a1a] tracking-tight">
{totalFormatted}
</p>
</div>
{/* Righe pagamento: solo etichetta + stato, MAI importo singolo */}
<div className="px-6 py-4 space-y-3">
{payments.length === 0 ? (
<p className="text-sm text-[#999999] italic py-2">
Nessun piano di pagamento configurato.
</p>
) : (
payments.map((payment) => {
const status = payment.status as PaymentStatusValue;
const config = statusConfig[status] ?? statusConfig.da_saldare;
{/* Righe pagamento: solo etichetta + stato, MAI importo singolo — omesse se hideRows */}
{!hideRows && (
<div className="px-6 py-4 space-y-3">
{payments.length === 0 ? (
<p className="text-sm text-[#999999] italic py-2">
Nessun piano di pagamento configurato.
</p>
) : (
payments.map((payment) => {
const status = payment.status as PaymentStatusValue;
const config = statusConfig[status] ?? statusConfig.da_saldare;
return (
<div
key={payment.id}
className="flex items-center justify-between gap-4 py-2"
>
{/* Indicatore dot + etichetta */}
<div className="flex items-center gap-2.5">
<span
className={`w-2 h-2 rounded-full shrink-0 ${config.dotClass}`}
aria-hidden="true"
/>
<p className="text-sm font-medium text-[#1a1a1a]">
{payment.label}
</p>
return (
<div
key={payment.id}
className="flex items-center justify-between gap-4 py-2"
>
{/* Indicatore dot + etichetta */}
<div className="flex items-center gap-2.5">
<span
className={`w-2 h-2 rounded-full shrink-0 ${config.dotClass}`}
aria-hidden="true"
/>
<p className="text-sm font-medium text-[#1a1a1a]">
{payment.label}
</p>
</div>
{/* Badge stato — nessun importo */}
<Badge className={`text-xs shrink-0 ${config.badgeClass}`}>
{config.label}
</Badge>
</div>
{/* Badge stato — nessun importo */}
<Badge className={`text-xs shrink-0 ${config.badgeClass}`}>
{config.label}
</Badge>
</div>
);
})
)}
</div>
);
})
)}
</div>
)}
{/* Nota informativa */}
<div className="px-6 py-3 border-t border-[#e5e5e5] bg-[#f9f9f9]">
@@ -95,4 +110,4 @@ export function PaymentStatus({ accepted_total, payments }: PaymentStatusProps)
</div>
</Card>
);
}
}
+18 -111
View File
@@ -1,70 +1,11 @@
import type { ClientView } from '@/lib/client-view';
import { Progress } from '@/components/ui/progress';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ApproveButton } from './client/ApproveButton';
import { PhaseCard } from './client/PhaseCard';
interface PhaseTimelineProps {
phases: ClientView['phases'];
token: string;
}
function PhaseStatusIcon({ status }: { status: 'upcoming' | 'active' | 'done' }) {
if (status === 'done') {
return (
<svg className="w-5 h-5 text-[#16a34a]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
);
}
if (status === 'active') {
return (
<svg className="w-5 h-5 text-[#1A463C]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-5 h-5 text-[#999999]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<circle cx={12} cy={12} r={9} />
</svg>
);
}
function TaskStatusIcon({ status }: { status: 'todo' | 'in_progress' | 'done' }) {
if (status === 'done') {
return (
<svg className="w-4 h-4 text-[#16a34a] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
);
}
if (status === 'in_progress') {
return (
<svg className="w-4 h-4 text-[#DEF168] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-4 h-4 text-[#999999] shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<circle cx={12} cy={12} r={9} />
</svg>
);
}
const phaseStatusLabel: Record<'upcoming' | 'active' | 'done', string> = {
upcoming: 'In arrivo',
active: 'In corso',
done: 'Completata',
};
const phaseStatusStyle: Record<'upcoming' | 'active' | 'done', string> = {
upcoming: 'border-transparent bg-[#999999] text-white',
active: 'border-transparent bg-[#1A463C] text-white',
done: 'border-transparent bg-[#16a34a] text-white',
};
export function PhaseTimeline({ phases, token }: PhaseTimelineProps) {
if (phases.length === 0) {
return <p className="text-sm text-[#999999] italic">Nessuna fase ancora configurata.</p>;
@@ -73,71 +14,37 @@ export function PhaseTimeline({ phases, token }: PhaseTimelineProps) {
return (
<div className="space-y-0">
{phases.map((phase, index) => {
const doneCount = phase.tasks.filter((t) => t.status === 'done').length;
const isLast = index === phases.length - 1;
// done phases start collapsed; active and upcoming start expanded
const defaultOpen = phase.status !== 'done';
return (
<div key={phase.id} className="flex gap-5">
<div className="flex flex-col items-center">
<div className="w-10 h-10 rounded-full bg-white border-2 border-[#e5e5e5] flex items-center justify-center shrink-0 z-10">
<PhaseStatusIcon status={phase.status} />
{phase.status === 'done' ? (
<svg className="w-5 h-5 text-[#1A463C]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
) : phase.status === 'active' ? (
<svg className="w-5 h-5 text-[#3b82f6]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-5 h-5 text-[#999999]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<circle cx={12} cy={12} r={9} />
</svg>
)}
</div>
{!isLast && <div className="flex-1 w-px bg-[#e5e5e5] my-2" style={{ minHeight: '2rem' }} />}
</div>
<div className={`flex-1 ${isLast ? 'pb-0' : 'pb-6'}`}>
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-5">
<div className="flex items-start justify-between gap-3 mb-4">
<h3 className="text-base font-bold text-[#1a1a1a] leading-snug">{phase.title}</h3>
<Badge className={`text-xs shrink-0 ${phaseStatusStyle[phase.status]}`}>
{phaseStatusLabel[phase.status]}
</Badge>
</div>
<div className="mb-5">
<div className="flex justify-between items-center mb-1.5">
<p className="text-xs text-[#666666] font-medium">{doneCount} di {phase.tasks.length} task</p>
<p className="text-xs font-semibold text-[#1a1a1a]">{phase.progress_pct}%</p>
</div>
<Progress value={phase.progress_pct} className="h-1.5" />
</div>
{phase.tasks.length === 0 ? (
<p className="text-xs text-[#999999] italic">Nessun task ancora configurato.</p>
) : (
<ul className="space-y-3">
{phase.tasks.map((task) => (
<li key={task.id} className="flex items-start gap-2.5">
<TaskStatusIcon status={task.status} />
<div className="flex-1 min-w-0">
<p className={`text-sm leading-snug ${task.status === 'done' ? 'line-through text-[#999999]' : 'text-[#1a1a1a]'}`}>
{task.title}
</p>
{task.description && (
<p className="text-xs text-[#999999] mt-0.5 leading-snug">{task.description}</p>
)}
{task.deliverables.length > 0 && (
<ul className="mt-1.5 space-y-2">
{task.deliverables.map((d) => (
<li key={d.id} className="bg-[#f9f9f9] rounded px-3 py-2 flex items-center justify-between gap-2">
<span className="text-xs text-[#666666] truncate font-medium">{d.title}</span>
{(d.status === 'pending' || d.status === 'submitted' || d.approved_at !== null) && (
<ApproveButton deliverableId={d.id} token={token} approvedAt={d.approved_at} />
)}
</li>
))}
</ul>
)}
</div>
</li>
))}
</ul>
)}
</Card>
<PhaseCard phase={phase} token={token} defaultOpen={defaultOpen} />
</div>
</div>
);
})}
</div>
);
}
}
@@ -87,7 +87,7 @@ export function ProposalDeck({ proposal, onAccept, onReject }: Props) {
{ id: "timeline", component: <TimelineSection timeline={ai.timeline} /> },
// Cap.05 — Le opzioni
{ id: "ch05", component: <ChapterDivider number="05" title="Le opzioni." subtitle="Il valore di mercato delle attività, le tre opzioni progressive con il loro pricing, e il processo per partire." /> },
{ id: "pricing", component: <PricingSection tiers={offer.tiers} /> },
{ id: "pricing", component: <PricingSection tiers={offer.tiers} offerType={offer.offerType} /> },
// Stages recap + comparison
{ id: "stages", component: <StagesRecapSection stages={ai.stagesRecap.stages} /> },
{ id: "comparison", component: <ComparisonMatrixSection matrix={ai.comparisonMatrix} tiers={offer.tiers} /> },
@@ -1,6 +1,9 @@
import type { AssembledProposal } from "@/lib/proposal/assemble";
type Props = { tiers: AssembledProposal["offer"]["tiers"] };
type Props = {
tiers: AssembledProposal["offer"]["tiers"];
offerType?: string; // una_tantum | retainer — undefined for proposals generated before this field
};
function formatEur(val: string | null | undefined): string {
if (!val) return "—";
@@ -8,7 +11,8 @@ function formatEur(val: string | null | undefined): string {
return `${n.toLocaleString("it-IT")}`;
}
export function PricingSection({ tiers }: Props) {
export function PricingSection({ tiers, offerType }: Props) {
const isRetainer = offerType === "retainer";
return (
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
<div>
@@ -23,6 +27,15 @@ export function PricingSection({ tiers }: Props) {
const isHighlighted = i === 1; // tier B highlighted di default
const price = tier.publicPrice ?? tier.servicesTotal;
const durationLabel = `${tier.durationMonths} ${tier.durationMonths === 1 ? "mese" : "mesi"}`;
// Prefer the offer type label; fall back to duration for legacy proposals.
const eyebrowMeta = offerType ? (isRetainer ? "Ricorrente" : "Una tantum") : durationLabel;
const priceNote = offerType
? isRetainer
? "Ricorrente · rinnovo 12 mesi"
: "pagamento unico"
: tier.durationMonths > 1
? `${tier.durationMonths} mesi`
: "pagamento unico";
return (
<div
@@ -36,7 +49,7 @@ export function PricingSection({ tiers }: Props) {
<div className="flex items-start justify-between">
<div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
Opzione {tier.tierLetter} / {durationLabel}
Opzione {tier.tierLetter} / {eyebrowMeta}
</p>
<h3 className="text-xl font-medium text-foreground mt-1">{tier.publicName}</h3>
</div>
@@ -47,9 +60,7 @@ export function PricingSection({ tiers }: Props) {
<div className="space-y-1">
<p className="text-3xl font-light text-primary">{formatEur(price)}</p>
<p className="text-xs text-muted-foreground">
{tier.durationMonths > 1 ? `${tier.durationMonths} mesi` : "pagamento unico"}
</p>
<p className="text-xs text-muted-foreground">{priceNote}</p>
</div>
{/* Servizi inclusi */}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
type Transcript = {
id: string;
title: string | null;
call_date: string;
content: string;
created_at: string | Date;
};
function TranscriptCard({ 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 (
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-0 overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-[#f9f9f9] transition-colors group"
aria-expanded={expanded}
>
<div className="flex items-center gap-3 min-w-0">
{/* Icona microfono */}
<svg
className="w-4 h-4 text-[#999999] shrink-0"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
/>
</svg>
<div className="min-w-0">
<p className="text-sm font-medium text-[#1a1a1a] truncate group-hover:text-[#1A463C] transition-colors">
{transcript.title ?? "Transcript chiamata"}
</p>
<p className="text-xs text-[#999999] mt-0.5">{date}</p>
</div>
</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-5 pb-5 pt-1 border-t border-[#e5e5e5]">
<pre className="text-xs text-[#444444] whitespace-pre-wrap font-sans leading-relaxed max-h-80 overflow-y-auto">
{transcript.content}
</pre>
</div>
)}
</Card>
);
}
interface TranscriptsSectionProps {
transcripts: Transcript[];
}
export function TranscriptsSection({ transcripts }: TranscriptsSectionProps) {
if (transcripts.length === 0) {
return (
<p className="text-sm text-[#999999] italic">
Nessun transcript disponibile.
</p>
);
}
return (
<div className="space-y-2">
{transcripts.map((t) => (
<TranscriptCard key={t.id} transcript={t} />
))}
</div>
);
}
@@ -0,0 +1,8 @@
-- Additive: client contact fields (email/phone) + lead archive flag.
-- Reuse: lead.email/phone carry over to the client on conversion; email also
-- feeds the v2.3 Email & Accesso (OTP) milestone. leads.archived hides a
-- converted lead from the pipeline while keeping its "won" status.
-- No drops/truncates. Safe to re-run.
ALTER TABLE clients ADD COLUMN IF NOT EXISTS email text;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS phone text;
ALTER TABLE leads ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
@@ -0,0 +1,16 @@
-- Additive: offer_type on offer_macros (una_tantum | retainer) + dedup guard.
-- The UNIQUE constraint must be added AFTER the one-time dedup of duplicate
-- offer_micros (same macro_id + tier_letter), otherwise it fails on existing dupes.
-- NULLS NOT DISTINCT treats two NULL tier_letter rows under the same macro as a
-- conflict too; if that is undesired for legacy non-tier micros, switch to the
-- partial-index variant below. No drops/truncates.
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS offer_type text NOT NULL DEFAULT 'una_tantum';
-- Run ONLY after dedup. Prevents future duplicate tiers within an offer.
ALTER TABLE offer_micros
ADD CONSTRAINT offer_micros_macro_tier_unique UNIQUE NULLS NOT DISTINCT (macro_id, tier_letter);
-- Alternative (allows multiple NULL-tier micros per macro):
-- CREATE UNIQUE INDEX offer_micros_macro_tier_unique
-- ON offer_micros (macro_id, tier_letter) WHERE tier_letter IS NOT NULL;
@@ -0,0 +1,6 @@
-- Additive: add percent column to payments for rescaling by payment plan.
-- nullable — existing rows keep percent NULL (legacy fallback in updateAcceptedTotal).
-- Apply to prod via SSH tunnel BEFORE pushing schema-dependent code.
-- No drops, no truncates, no data loss.
ALTER TABLE payments ADD COLUMN IF NOT EXISTS percent numeric(5,2);
+9
View File
@@ -21,6 +21,10 @@ export const clients = pgTable("clients", {
name: text("name").notNull(),
brand_name: text("brand_name").notNull(),
brief: text("brief").notNull(),
// Contact fields (Phase v2.3) — carried over from a converted lead and used
// by the Email & Accesso (OTP) milestone. Admin-only, never exposed publicly.
email: text("email"),
phone: text("phone"),
// token is SEPARATE from id — rotatable secret for client link access
token: text("token")
.notNull()
@@ -150,6 +154,7 @@ export const payments = pgTable("payments", {
.references(() => projects.id, { onDelete: "cascade" }),
label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%"
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
percent: numeric("percent", { precision: 5, scale: 2 }), // nullable — % of total (for rescaling); null = legacy row
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
paid_at: timestamp("paid_at", { withTimezone: true }),
});
@@ -275,6 +280,7 @@ export const offer_macros = pgTable("offer_macros", {
description: text("description"), // short description shown on offer cards
category: text("category"), // single-select "Categoria" (Entry/Signature/Retainer Offer) — OFFER-15/18
ticket: text("ticket"), // single-select "Ticket" (Low/Mid/High Ticket) — OFFER-15
offer_type: text("offer_type").notNull().default("una_tantum"), // una_tantum | retainer (v2.3)
is_archived: boolean("is_archived").notNull().default(false), // OFFER-18 archive flag
cliente_ideale: text("cliente_ideale"), // "Aiuto: [Cliente Ideale]"
risultato: text("risultato"), // "A ottenere: [Risultato]"
@@ -454,6 +460,9 @@ export const leads = pgTable("leads", {
next_action: text("next_action"),
next_action_date: timestamp("next_action_date", { withTimezone: true }),
notes: text("notes"),
// Archive flag (v2.3) — a converted lead is hidden from the pipeline while
// keeping its "won" status, so the board doesn't fill up over time.
archived: boolean("archived").notNull().default(false),
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
+205 -32
View File
@@ -22,8 +22,10 @@ import {
quotes,
leads,
tags,
clientTranscripts,
} from "@/db/schema";
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
import { getPool } from "@/lib/taxonomy";
import { LEAD_STAGES } from "@/lib/lead-validators";
import type {
Client,
@@ -116,7 +118,7 @@ export async function getAllClientsWithPayments(
}));
}
const [allPayments, activeEntries, totals] = await Promise.all([
const [allPayments, activeEntries, totals, offerTotals] = await Promise.all([
db.select().from(payments).where(inArray(payments.project_id, projectIds)),
db
@@ -136,8 +138,23 @@ export async function getAllClientsWithPayments(
.from(time_entries)
.where(inArray(time_entries.project_id, projectIds))
.groupBy(time_entries.project_id),
db
.select({
project_id: project_offers.project_id,
total: sql<string>`coalesce(sum(${project_offers.accepted_total}), 0)`,
})
.from(project_offers)
.where(inArray(project_offers.project_id, projectIds))
.groupBy(project_offers.project_id),
]);
// Somma offerte accettate per progetto (per arricchire l'LTV)
const projectOffersTotalMap = new Map<string, number>();
for (const row of offerTotals) {
projectOffersTotalMap.set(row.project_id, parseFloat(row.total ?? "0") || 0);
}
// Aggregate project-scoped data back to client level
const clientPaymentsMap = new Map<string, typeof allPayments>();
for (const payment of allPayments) {
@@ -168,8 +185,14 @@ export async function getAllClientsWithPayments(
const projectBrands = clientProjectsList
.filter((p) => !p.archived)
.map((p) => p.name);
// LTV per progetto = max(totale preventivo impostato, somma offerte assegnate).
// Copre il caso "preventivo non impostato + offerta attiva" senza doppi conteggi.
const ltv = clientProjectsList
.reduce((sum, p) => sum + parseFloat(p.accepted_total ?? "0"), 0)
.reduce((sum, p) => {
const acceptedTotal = parseFloat(p.accepted_total ?? "0") || 0;
const offersTotal = projectOffersTotalMap.get(p.id) ?? 0;
return sum + Math.max(acceptedTotal, offersTotal);
}, 0)
.toFixed(2);
return {
id: c.id,
@@ -209,6 +232,9 @@ export type ProjectOfferWithMicro = {
micro_internal_name: string;
micro_public_name: string;
micro_duration_months: number;
tier_letter: string | null;
macro_internal_name: string;
macro_offer_type: string;
start_date: Date;
accepted_total: string | null;
created_at: Date;
@@ -420,32 +446,21 @@ export type CatalogFieldOptions = {
};
export async function getCatalogFieldOptions(): Promise<CatalogFieldOptions> {
const tagRows = await db
.selectDistinct({ name: tags.name, type: tags.entity_type })
.from(tags)
.where(inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY]));
// fase / Offerta (tag) / Pacchetto read from the centralized, persistent
// taxonomy pools (src/lib/taxonomy.ts). `categoria` (services.category) is
// hidden in the catalog UI and stays derived from in-use values.
const [tag, pacchetto, fase, catRows] = await Promise.all([
getPool("service_offerta"),
getPool("service_pacchetto"),
getPool("service_fase"),
db.selectDistinct({ value: services.category }).from(services),
]);
// Distinct category / fase values (single-select pools); nulls filtered below.
const catRows = await db
.selectDistinct({ value: services.category })
.from(services);
const faseRows = await db
.selectDistinct({ value: services.fase })
.from(services);
const categoria = Array.from(
new Set(catRows.map((r) => r.value).filter((v): v is string => !!v && v.trim().length > 0))
).sort((a, b) => a.localeCompare(b, "it"));
const sortUnique = (arr: (string | null)[]) =>
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
(a, b) => a.localeCompare(b, "it")
);
return {
tag: sortUnique(tagRows.filter((r) => r.type === TAG_ENTITY).map((r) => r.name)),
pacchetto: sortUnique(
tagRows.filter((r) => r.type === PACCHETTO_ENTITY).map((r) => r.name)
),
categoria: sortUnique(catRows.map((r) => r.value)),
fase: sortUnique(faseRows.map((r) => r.value)),
};
return { tag, pacchetto, categoria, fase };
}
// ── ProjectWithPayments — used by /admin/projects list ───────────────────────
@@ -558,7 +573,27 @@ export type ProjectFullDetail = {
activeTimerStartedAt: Date | null;
totalTrackedSeconds: number;
projectOffers: ProjectOfferWithMicro[];
availableMicros: Array<{ id: string; internal_name: string; public_name: string; duration_months: number }>;
/** Sum of accepted_total across all active project offers — used as default for payment plan */
offersAcceptedTotal: number;
availableMicros: Array<{
id: string;
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;
}>;
transcripts: Array<{
id: string;
title: string | null;
call_date: string;
content: string;
created_at: Date;
}>;
};
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
@@ -605,7 +640,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows] =
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows, transcriptsRows] =
await Promise.all([
db.select().from(payments).where(eq(payments.project_id, id)),
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
@@ -634,7 +669,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
.from(time_entries)
.where(eq(time_entries.project_id, id)),
// Query A: project offers for this project joined with micro info
// Query A: project offers for this project joined with micro + macro info
db
.select({
id: project_offers.id,
@@ -643,24 +678,54 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
micro_internal_name: offer_micros.internal_name,
micro_public_name: offer_micros.public_name,
micro_duration_months: offer_micros.duration_months,
tier_letter: offer_micros.tier_letter,
macro_internal_name: offer_macros.internal_name,
macro_offer_type: offer_macros.offer_type,
start_date: project_offers.start_date,
accepted_total: project_offers.accepted_total,
created_at: project_offers.created_at,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(eq(project_offers.project_id, id))
.orderBy(asc(project_offers.created_at)),
// Query B: all active micro-offers (for the assignment dropdown)
// Query B: all tiers of non-archived offers (for the assignment dropdown).
// ordered oldest-first per (macro, tier) so client-side dedup keeps the
// oldest until the DB cleanup removes genuine duplicates.
db
.select({
id: offer_micros.id,
internal_name: offer_micros.internal_name,
public_name: offer_micros.public_name,
duration_months: offer_micros.duration_months,
tier_letter: offer_micros.tier_letter,
public_price: offer_micros.public_price,
macro_id: offer_micros.macro_id,
macro_internal_name: offer_macros.internal_name,
macro_category: offer_macros.category,
macro_offer_type: offer_macros.offer_type,
})
.from(offer_micros)
.orderBy(asc(offer_micros.internal_name)),
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(eq(offer_macros.is_archived, false))
.orderBy(
asc(offer_macros.internal_name),
asc(offer_micros.tier_letter),
asc(offer_micros.id)
),
// Query C: transcripts linked to the project's client
db
.select({
id: clientTranscripts.id,
title: clientTranscripts.title,
call_date: clientTranscripts.call_date,
content: clientTranscripts.content,
created_at: clientTranscripts.created_at,
})
.from(clientTranscripts)
.where(eq(clientTranscripts.client_id, project.client_id))
.orderBy(desc(clientTranscripts.call_date)),
]);
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
@@ -683,6 +748,21 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
})),
}));
// Defensive dedup: until the DB cleanup removes genuine duplicate tiers,
// keep one micro per (macro_id, tier_letter) for the assignment dropdown.
const seenTier = new Set<string>();
const dedupedMicros = availableMicrosRows.filter((m) => {
const key = `${m.macro_id}::${m.tier_letter ?? `_null_${m.id}`}`;
if (seenTier.has(key)) return false;
seenTier.add(key);
return true;
});
const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce(
(sum, o) => sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
0
);
return {
project: { ...project, client } as ProjectFullDetail["project"],
phases: phasesWithTasks,
@@ -696,10 +776,40 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
availableMicros: availableMicrosRows,
offersAcceptedTotal,
availableMicros: dedupedMicros,
transcripts: transcriptsRows,
};
}
// ── Recent time entries — used by TimerTab for the manual-entry recent list ───
export type RecentTimeEntry = {
id: string;
started_at: Date;
ended_at: Date | null;
duration_seconds: number | null;
};
export async function getRecentTimeEntries(
projectId: string,
limit = 5
): Promise<RecentTimeEntry[]> {
// sql<boolean>`ended_at IS NOT NULL` used to filter closed entries
// (Drizzle's isNotNull is available in v0.28+ but we use sql for safety)
return db
.select({
id: time_entries.id,
started_at: time_entries.started_at,
ended_at: time_entries.ended_at,
duration_seconds: time_entries.duration_seconds,
})
.from(time_entries)
.where(and(eq(time_entries.project_id, projectId), sql`${time_entries.ended_at} IS NOT NULL`))
.orderBy(desc(time_entries.started_at))
.limit(limit);
}
// ── ClientWithProjects — used by client detail showing project cards ──────────
export type ClientWithProjects = Client & {
@@ -747,6 +857,10 @@ export type ClientActiveOfferSummary = {
project_id: string;
project_name: string;
public_name: string; // offer_micros.public_name — NEVER internal_name
macro_name: string; // offer_macros.internal_name — admin-only offer name ("Mantenimento")
tier_letter: string | null; // A | B | C
category: string | null; // Entry | Signature | Retainer (offer_macros.category)
offer_type: string; // una_tantum | retainer (fallback for category)
accepted_total: string | null;
};
@@ -767,10 +881,15 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
offer_id: project_offers.id,
project_id: project_offers.project_id,
public_name: offer_micros.public_name,
macro_name: offer_macros.internal_name,
tier_letter: offer_micros.tier_letter,
category: offer_macros.category,
offer_type: offer_macros.offer_type,
accepted_total: project_offers.accepted_total,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(inArray(project_offers.project_id, projectIds))
.orderBy(asc(project_offers.created_at));
@@ -779,6 +898,10 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
project_id: r.project_id,
project_name: projectNameMap.get(r.project_id) ?? "—",
public_name: r.public_name,
macro_name: r.macro_name,
tier_letter: r.tier_letter,
category: r.category,
offer_type: r.offer_type,
accepted_total: r.accepted_total ? String(r.accepted_total) : null,
}));
}
@@ -902,6 +1025,7 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
next_action: leads.next_action,
next_action_date: leads.next_action_date,
notes: leads.notes,
archived: leads.archived,
created_at: leads.created_at,
updated_at: leads.updated_at,
tag_name: tags.name,
@@ -914,6 +1038,7 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
eq(tags.entity_type, LEADS_TAG_ENTITY)
)
)
.where(eq(leads.archived, false)) // converted leads are archived → hidden from pipeline
.orderBy(desc(leads.updated_at), asc(tags.name));
const leadMap = new Map<string, LeadWithTags>();
@@ -925,6 +1050,54 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
return Array.from(leadMap.values());
}
// Single lead by id WITH tags — does NOT filter archived (the detail page must
// still show a converted/archived lead).
export async function getLeadByIdWithTags(leadId: string): Promise<LeadWithTags | null> {
const rows = await db
.select({
id: leads.id,
name: leads.name,
email: leads.email,
phone: leads.phone,
company: leads.company,
status: leads.status,
last_contact_date: leads.last_contact_date,
next_action: leads.next_action,
next_action_date: leads.next_action_date,
notes: leads.notes,
archived: leads.archived,
created_at: leads.created_at,
updated_at: leads.updated_at,
tag_name: tags.name,
})
.from(leads)
.leftJoin(
tags,
and(eq(tags.entity_id, leads.id), eq(tags.entity_type, LEADS_TAG_ENTITY))
)
.where(eq(leads.id, leadId));
if (rows.length === 0) return null;
let result: LeadWithTags | null = null;
for (const row of rows) {
const { tag_name, ...leadFields } = row;
if (!result) result = { ...leadFields, tags: [] };
if (tag_name) result.tags.push(tag_name);
}
return result;
}
// If this lead was converted, returns the client id of the project created from
// it (projects.created_from_lead_id), else null.
export async function getClientIdFromLead(leadId: string): Promise<string | null> {
const [row] = await db
.select({ client_id: projects.client_id })
.from(projects)
.where(eq(projects.created_from_lead_id, leadId))
.limit(1);
return row?.client_id ?? null;
}
export type LeadFieldOptions = { status: string[]; tags: string[] };
export async function getLeadFieldOptions(): Promise<LeadFieldOptions> {
+117 -18
View File
@@ -1,6 +1,6 @@
import { eq, inArray, asc, sql } from "drizzle-orm";
import { eq, inArray, asc, desc, sql } from "drizzle-orm";
import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services } from "@/db/schema";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
/**
* ClientView: Legacy shape used by ClientDashboard component.
@@ -55,8 +55,18 @@ export interface ClientView {
activeOffers?: Array<{
id: string;
public_name: string;
offer_name: string; // offer_macros.public_name — macro-level name shown to client
offer_type: string; // una_tantum | retainer
cumulative_price: string;
accepted_total: string | null;
services: Array<{ name: string; description: string | null }>;
}>;
transcripts: Array<{
id: string;
title: string | null;
call_date: string;
content: string;
created_at: string;
}>;
}
@@ -118,8 +128,18 @@ export interface ProjectView {
activeOffers?: Array<{
id: string;
public_name: string; // offer_micros.public_name — NEVER internal_name
cumulative_price: string; // sum of assigned offer_services.price
offer_name: string; // offer_macros.public_name — macro-level name shown to client
offer_type: string; // una_tantum | retainer
cumulative_price: string; // sum of assigned services.unit_price (new) or offer_services.price (legacy)
accepted_total: string | null;
services: Array<{ name: string; description: string | null }>; // included services list
}>;
transcripts: Array<{
id: string;
title: string | null;
call_date: string;
content: string;
created_at: Date;
}>;
}
@@ -279,37 +299,115 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
const projectOfferRows = await db
.select({
id: project_offers.id,
public_name: offer_micros.public_name, // EXPLICITLY public_name, never internal_name
public_name: offer_micros.public_name, // EXPLICITLY public_name, never internal_name
offer_name: offer_macros.public_name, // macro-level public name — NEVER internal_name
offer_type: offer_macros.offer_type, // una_tantum | retainer
accepted_total: project_offers.accepted_total,
micro_id: project_offers.micro_id,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(eq(project_offers.project_id, projectId));
// Cumulative price per micro (sum of assigned services)
// Cumulative price + service list per micro.
// Primary: offer_tier_services → services (Phase 12 catalog). Fallback: legacy offer_micro_services → offer_services.
const microIds = projectOfferRows.map((o) => o.micro_id);
const cumulativePriceRows = microIds.length === 0 ? [] : await db
// New-style: services from offer_tier_services joined to unified services catalog
const tierServiceRows = microIds.length === 0 ? [] : await db
.select({
tier_id: offer_tier_services.tier_id,
name: services.name,
description: services.description,
unit_price: services.unit_price,
})
.from(offer_tier_services)
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
.where(inArray(offer_tier_services.tier_id, microIds));
// Group by tier_id for quick lookup
const tierServicesMap = new Map<string, Array<{ name: string; description: string | null; unit_price: string }>>();
for (const row of tierServiceRows) {
const existing = tierServicesMap.get(row.tier_id) ?? [];
existing.push({ name: row.name, description: row.description, unit_price: row.unit_price });
tierServicesMap.set(row.tier_id, existing);
}
// Determine which micros have NO entries in the new table → need legacy fallback
const microsNeedingLegacy = microIds.filter((id) => !tierServicesMap.has(id));
// Legacy fallback: offer_micro_services → offer_services (for old offers with no tier_services rows)
const legacyPriceRows = microsNeedingLegacy.length === 0 ? [] : await db
.select({
micro_id: offer_micro_services.micro_id,
cumulative_price: sql<string>`coalesce(sum(${offer_services.price}::numeric), 0)`,
name: offer_services.name,
price: offer_services.price,
})
.from(offer_micro_services)
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
.where(inArray(offer_micro_services.micro_id, microIds))
.groupBy(offer_micro_services.micro_id);
.where(inArray(offer_micro_services.micro_id, microsNeedingLegacy));
const cumulMap = new Map(cumulativePriceRows.map((r) => [r.micro_id, r.cumulative_price]));
const legacyServicesMap = new Map<string, Array<{ name: string; description: string | null; price: string }>>();
for (const row of legacyPriceRows) {
const existing = legacyServicesMap.get(row.micro_id) ?? [];
existing.push({ name: row.name, description: null, price: row.price });
legacyServicesMap.set(row.micro_id, existing);
}
const activeOffers = projectOfferRows.map((o) => ({
id: o.id,
public_name: o.public_name,
cumulative_price: cumulMap.get(o.micro_id) ?? "0",
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
}));
const activeOffers = projectOfferRows.map((o) => {
const newStyleServices = tierServicesMap.get(o.micro_id);
const legacyServices = legacyServicesMap.get(o.micro_id);
// Comments scoped to tasks and deliverables of this project
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
let cumulative_price: string;
let servicesList: Array<{ name: string; description: string | null }>;
if (newStyleServices && newStyleServices.length > 0) {
// Use new catalog prices
const total = newStyleServices.reduce((sum, s) => sum + parseFloat(s.unit_price ?? "0"), 0);
cumulative_price = total.toFixed(2);
servicesList = newStyleServices.map((s) => ({ name: s.name, description: s.description }));
} else if (legacyServices && legacyServices.length > 0) {
// Legacy fallback
const total = legacyServices.reduce((sum, s) => sum + parseFloat(s.price ?? "0"), 0);
cumulative_price = total.toFixed(2);
servicesList = legacyServices.map((s) => ({ name: s.name, description: null }));
} else {
cumulative_price = "0";
servicesList = [];
}
return {
id: o.id,
public_name: o.public_name,
offer_name: o.offer_name,
offer_type: o.offer_type,
cumulative_price,
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
services: servicesList,
};
});
// Transcripts linked to the project's client (post lead→client conversion)
const transcriptsRows = await db
.select({
id: clientTranscripts.id,
title: clientTranscripts.title,
call_date: clientTranscripts.call_date,
content: clientTranscripts.content,
created_at: clientTranscripts.created_at,
})
.from(clientTranscripts)
.where(eq(clientTranscripts.client_id, project.client_id))
.orderBy(desc(clientTranscripts.call_date));
// Comments scoped to: general (client_id), phases, tasks, and deliverables of this project
const allEntityIds = [
project.client_id, // general messages entity_id = client_id
...phaseIds, // phase-tagged messages
...taskIds,
...deliverablesRows.map((d) => d.id),
];
const commentsRows =
allEntityIds.length === 0
? []
@@ -352,5 +450,6 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
comments: commentsRows,
global_progress_pct,
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
transcripts: transcriptsRows,
};
}
+3 -86
View File
@@ -1,6 +1,6 @@
import { db } from "@/db";
import { clients, projects, payments, deliverables, time_entries } from "@/db/schema";
import { eq, and, isNotNull, isNull, desc, sum, count } from "drizzle-orm";
import { clients, projects, payments } from "@/db/schema";
import { eq, sum, count } from "drizzle-orm";
export type KpiStats = {
clientiAttivi: number;
@@ -9,16 +9,8 @@ export type KpiStats = {
progettiInCorso: number; // projects not archived
};
export type ActivityItem = {
type: "nuovo_cliente" | "nuovo_progetto" | "deliverable_approvato" | "timer_stoppato";
label: string;
detail: string;
timestamp: Date;
};
export type DashboardStats = {
kpi: KpiStats;
activity: ActivityItem[];
};
export async function getDashboardStats(): Promise<DashboardStats> {
@@ -59,80 +51,5 @@ export async function getDashboardStats(): Promise<DashboardStats> {
progettiInCorso: progettiRow?.count ?? 0,
};
// ── Activity feed ─────────────────────────────────────────────────────────────
// Fetch recent events from multiple tables, merge and sort
const [recentClients, recentProjects, recentApprovals, recentTimers] = await Promise.all([
db
.select({ id: clients.id, name: clients.name, created_at: clients.created_at })
.from(clients)
.orderBy(desc(clients.created_at))
.limit(5),
db
.select({ id: projects.id, name: projects.name, created_at: projects.created_at })
.from(projects)
.orderBy(desc(projects.created_at))
.limit(5),
db
.select({ id: deliverables.id, title: deliverables.title, approved_at: deliverables.approved_at })
.from(deliverables)
.where(isNotNull(deliverables.approved_at))
.orderBy(desc(deliverables.approved_at))
.limit(5),
db
.select({
id: time_entries.id,
ended_at: time_entries.ended_at,
duration_seconds: time_entries.duration_seconds,
})
.from(time_entries)
.where(
and(
isNotNull(time_entries.ended_at),
isNotNull(time_entries.duration_seconds)
)
)
.orderBy(desc(time_entries.ended_at))
.limit(5),
]);
const activity: ActivityItem[] = [
...recentClients.map((c) => ({
type: "nuovo_cliente" as const,
label: "Nuovo cliente",
detail: c.name,
timestamp: c.created_at,
})),
...recentProjects.map((p) => ({
type: "nuovo_progetto" as const,
label: "Nuovo progetto",
detail: p.name,
timestamp: p.created_at,
})),
...recentApprovals
.filter((d): d is typeof d & { approved_at: Date } => d.approved_at !== null)
.map((d) => ({
type: "deliverable_approvato" as const,
label: "Deliverable approvato",
detail: d.title,
timestamp: d.approved_at,
})),
...recentTimers
.filter(
(t): t is typeof t & { ended_at: Date; duration_seconds: number } =>
t.ended_at !== null && t.duration_seconds !== null
)
.map((t) => ({
type: "timer_stoppato" as const,
label: "Timer stoppato",
detail: `${Math.round(t.duration_seconds / 60)} min`,
timestamp: t.ended_at,
})),
]
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, 10);
return { kpi, activity };
return { kpi };
}
+65 -12
View File
@@ -1,6 +1,6 @@
import { db } from "@/db";
import { project_offers, offer_micros } from "@/db/schema";
import { eq } from "drizzle-orm";
import { project_offers, offer_micros, offer_macros, projects } from "@/db/schema";
import { eq, sql, desc } from "drizzle-orm";
export type ForecastMonth = {
year: number;
@@ -9,16 +9,58 @@ export type ForecastMonth = {
total: number;
};
export type OffersSoldRow = {
offer_name: string; // offer_macros.internal_name
tier_letter: string | null;
count: number;
};
export type OffersSoldBreakdown = {
rows: OffersSoldRow[];
total: number;
};
// Quante offerte sono state vendute (assegnate a un progetto), per offerta + tier.
export async function getOffersSoldBreakdown(): Promise<OffersSoldBreakdown> {
const rows = await db
.select({
offer_name: offer_macros.internal_name,
tier_letter: offer_micros.tier_letter,
count: sql<number>`count(*)::int`,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.groupBy(offer_macros.internal_name, offer_micros.tier_letter)
.orderBy(desc(sql`count(*)`));
const total = rows.reduce((sum, r) => sum + Number(r.count), 0);
return {
rows: rows.map((r) => ({
offer_name: r.offer_name,
tier_letter: r.tier_letter,
count: Number(r.count),
})),
total,
};
}
export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
// Load all project offers with micro duration info
// Active offers only (non-archived projects). offer_type drives the math:
// - retainer: accepted_total è il CANONE MENSILE → ogni mese coperto riceve l'intero importo
// - una_tantum: accepted_total è il prezzo TOTALE → spalmato su duration_months
const offers = await db
.select({
start_date: project_offers.start_date,
duration_months: offer_micros.duration_months,
accepted_total: project_offers.accepted_total,
offer_type: offer_macros.offer_type,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id));
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.innerJoin(projects, eq(project_offers.project_id, projects.id))
.where(eq(projects.archived, false));
// Build 12-month bucket array starting from current month
const now = new Date();
@@ -33,20 +75,31 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
});
for (const offer of offers) {
// Skip offers with no accepted_total — they contribute 0 to forecast
if (!offer.accepted_total) continue;
const total = parseFloat(String(offer.accepted_total));
if (isNaN(total) || total <= 0) continue;
const perMonth = total / offer.duration_months;
const start = new Date(offer.start_date);
const startMonthKey = start.getFullYear() * 12 + start.getMonth();
for (let m = 0; m < offer.duration_months; m++) {
const offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1);
const bucket = buckets.find(
(b) => b.year === offerMonth.getFullYear() && b.month === offerMonth.getMonth() + 1
);
if (bucket) bucket.total += perMonth;
if (offer.offer_type === "retainer") {
// Canone ricorrente: contribuisce ad OGNI mese dell'orizzonte da start in poi,
// senza limite di duration_months (un retainer è continuativo).
for (const b of buckets) {
const bucketMonthKey = b.year * 12 + (b.month - 1);
if (bucketMonthKey >= startMonthKey) b.total += total;
}
} else {
// Una tantum: prezzo totale ripartito su duration_months a partire da start.
const months = offer.duration_months > 0 ? offer.duration_months : 1;
const perMonth = total / months;
for (let m = 0; m < months; m++) {
const offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1);
const bucket = buckets.find(
(b) => b.year === offerMonth.getFullYear() && b.month === offerMonth.getMonth() + 1
);
if (bucket) bucket.total += perMonth;
}
}
}
+10
View File
@@ -219,3 +219,13 @@ export async function getTranscripts(leadId: string) {
.where(eq(clientTranscripts.lead_id, leadId))
.orderBy(desc(clientTranscripts.call_date));
}
// Get transcripts linked to a client (post lead→client conversion).
// Returns all fields — used by admin DocumentsTab and public TranscriptsSection.
export async function getTranscriptsByClientId(clientId: string) {
return await db
.select()
.from(clientTranscripts)
.where(eq(clientTranscripts.client_id, clientId))
.orderBy(desc(clientTranscripts.call_date));
}
+70 -25
View File
@@ -8,6 +8,7 @@ import {
services,
tags,
} from "@/db/schema";
import { getPool } from "@/lib/taxonomy";
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
import { eq, and, asc, inArray, sql } from "drizzle-orm";
@@ -104,16 +105,23 @@ export async function getMicroAssignedServiceIds(microId: string): Promise<strin
const OFFER_TIPO_ENTITY = "offer_macros.tipo";
const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo";
export type OfferListTier = {
tier_letter: string | null;
public_name: string;
public_price: string | null;
servicesTotal: string;
};
export type OfferListCard = Pick<
OfferMacro,
"id" | "internal_name" | "description" | "category" | "is_archived"
>;
> & { tiers: OfferListTier[] };
// List-page cards (Plan 04): one row per offer_macros, with category + archive
// status for client-side filtering. Archived offers ARE included — the
// "Mostra offerte archiviate" toggle filters client-side per UI-SPEC.
export async function getOfferListCards(): Promise<OfferListCard[]> {
const rows = await db
const macros = await db
.select({
id: offer_macros.id,
internal_name: offer_macros.internal_name,
@@ -123,7 +131,57 @@ export async function getOfferListCards(): Promise<OfferListCard[]> {
})
.from(offer_macros)
.orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at));
return rows;
if (macros.length === 0) return [];
const macroIds = macros.map((m) => m.id);
// Fetch all tiers for these macros, ordered A -> B -> C
const tierOrder = sql`CASE ${offer_micros.tier_letter} WHEN 'A' THEN 1 WHEN 'B' THEN 2 WHEN 'C' THEN 3 ELSE 4 END`;
const tierRows = await db
.select({
id: offer_micros.id,
macro_id: offer_micros.macro_id,
tier_letter: offer_micros.tier_letter,
public_name: offer_micros.public_name,
public_price: offer_micros.public_price,
})
.from(offer_micros)
.where(inArray(offer_micros.macro_id, macroIds))
.orderBy(asc(tierOrder));
const allTierIds = tierRows.map((t) => t.id);
// Compute servicesTotal per tier in batch
let totalsByTierId = new Map<string, string>();
if (allTierIds.length > 0) {
const totalRows = await db
.select({
tier_id: offer_tier_services.tier_id,
servicesTotal: sql<string>`coalesce(sum(${services.unit_price}::numeric), 0)`,
})
.from(offer_tier_services)
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
.where(inArray(offer_tier_services.tier_id, allTierIds))
.groupBy(offer_tier_services.tier_id);
totalsByTierId = new Map(totalRows.map((r) => [r.tier_id, r.servicesTotal]));
}
// Group tiers under each macro
const tiersByMacroId = new Map<string, OfferListTier[]>();
for (const t of tierRows) {
const list = tiersByMacroId.get(t.macro_id) ?? [];
list.push({
tier_letter: t.tier_letter,
public_name: t.public_name,
public_price: t.public_price,
servicesTotal: totalsByTierId.get(t.id) ?? "0",
});
tiersByMacroId.set(t.macro_id, list);
}
return macros.map((m) => ({ ...m, tiers: tiersByMacroId.get(m.id) ?? [] }));
}
export type OfferTierData = {
@@ -272,27 +330,14 @@ export type OfferFieldOptions = {
};
// Shared option pools for the offer editor's select fields (Notion-style
// dropdowns), mirroring getCatalogFieldOptions from admin-queries.ts.
// dropdowns). All four read from the centralized, persistent taxonomy pools
// (src/lib/taxonomy.ts), seeded from existing values on first access.
export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
const categoriaRows = await db.selectDistinct({ value: offer_macros.category }).from(offer_macros);
const ticketRows = await db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros);
const tagRows = await db
.selectDistinct({ name: tags.name, type: tags.entity_type })
.from(tags)
.where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY]));
const sortUnique = (arr: (string | null)[]) =>
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
(a, b) => a.localeCompare(b, "it")
);
return {
categoria: sortUnique(categoriaRows.map((r) => r.value)),
ticket: sortUnique(ticketRows.map((r) => r.value)),
tipo: sortUnique(tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name)),
obiettivo: sortUnique(
tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name)
),
};
const [categoria, ticket, tipo, obiettivo] = await Promise.all([
getPool("offer_categoria"),
getPool("offer_ticket"),
getPool("offer_tipo"),
getPool("offer_obiettivo"),
]);
return { categoria, ticket, tipo, obiettivo };
}
+2 -1
View File
@@ -11,6 +11,7 @@ type AgentInput = {
client?: Pick<Client, "id" | "name" | "brand_name" | "brief">;
transcripts: Pick<ClientTranscript, "title" | "content" | "call_date">[];
offer: OfferEditorData;
model?: string;
};
function buildSystemPrompt(): string {
@@ -128,7 +129,7 @@ export async function generateProposalContent(
const client = new Anthropic({ apiKey });
const message = await client.messages.create({
model: MODEL,
model: input.model ?? MODEL,
max_tokens: 8192,
system: buildSystemPrompt(),
messages: [{ role: "user", content: buildUserPrompt(input) }],
+2
View File
@@ -29,6 +29,7 @@ export type AssembledProposal = {
offer: {
macroId: string;
publicName: string;
offerType: string; // una_tantum | retainer
tiers: Array<{
id: string;
tierLetter: string;
@@ -99,6 +100,7 @@ export function assembleProposal(input: AssembleInput): AssembledProposal {
offer: {
macroId: input.offer.macro.id,
publicName: offerTitle,
offerType: input.offer.macro.offer_type ?? "una_tantum",
tiers,
},
};
+9 -2
View File
@@ -16,7 +16,10 @@ export async function getSetting(key: string): Promise<string | null> {
return rows[0]?.value ?? null;
}
export async function updateSetting(key: string, value: string): Promise<void> {
// Pure upsert — NO revalidatePath. Safe to call during a server-component render
// (e.g. lazy-seeding a taxonomy pool). Use updateSetting when revalidation is
// desired (form submissions).
export async function writeSetting(key: string, value: string): Promise<void> {
const existing = await getSetting(key);
if (existing !== null) {
await db
@@ -26,10 +29,14 @@ export async function updateSetting(key: string, value: string): Promise<void> {
} else {
await db.insert(settings).values({ key, value });
}
}
export async function updateSetting(key: string, value: string): Promise<void> {
await writeSetting(key, value);
revalidatePath("/admin/impostazioni");
}
export async function getTargetHourlyRate(): Promise<number> {
const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
return value ? parseFloat(value) : 50;
}
}
+199
View File
@@ -0,0 +1,199 @@
import { db } from "@/db";
import { tags, offer_macros, services } from "@/db/schema";
import { eq, sql } from "drizzle-orm";
import { getSetting, writeSetting } from "@/lib/settings";
// ── Centralized taxonomy (Notion-style select properties) ───────────────────
// Each taxonomy field has a PERSISTENT, independent option pool stored as a JSON
// array in the `settings` table (key `tax_pool_<fieldId>`). Pools survive
// unassignment: deselecting a value on a row never touches the pool — only an
// explicit delete from the settings panel does (cascade-strips it everywhere).
export type TaxonomyFieldId =
| "offer_categoria"
| "offer_ticket"
| "offer_tipo"
| "offer_obiettivo"
| "service_fase"
| "service_offerta"
| "service_pacchetto";
export type TaxonomyGroup = "offer" | "catalog";
// Where the field's REAL values live — used for seeding (distinct in-use),
// cascade-delete, and rename propagation.
type Storage =
| { kind: "column"; table: "offer_macros" | "services"; column: "category" | "ticket" | "fase" }
| { kind: "tag"; entityType: string };
type FieldConfig = {
label: string;
group: TaxonomyGroup;
settingsKey: string;
storage: Storage;
};
export const TAXONOMY_FIELDS: Record<TaxonomyFieldId, FieldConfig> = {
offer_categoria: {
label: "Categoria",
group: "offer",
settingsKey: "tax_pool_offer_categoria",
storage: { kind: "column", table: "offer_macros", column: "category" },
},
offer_ticket: {
label: "Ticket",
group: "offer",
settingsKey: "tax_pool_offer_ticket",
storage: { kind: "column", table: "offer_macros", column: "ticket" },
},
offer_tipo: {
label: "Tipo",
group: "offer",
settingsKey: "tax_pool_offer_tipo",
storage: { kind: "tag", entityType: "offer_macros.tipo" },
},
offer_obiettivo: {
label: "Obiettivo",
group: "offer",
settingsKey: "tax_pool_offer_obiettivo",
storage: { kind: "tag", entityType: "offer_macros.obiettivo" },
},
service_fase: {
label: "Fase",
group: "catalog",
settingsKey: "tax_pool_service_fase",
storage: { kind: "column", table: "services", column: "fase" },
},
service_offerta: {
label: "Offerta",
group: "catalog",
settingsKey: "tax_pool_service_offerta",
storage: { kind: "tag", entityType: "services" },
},
service_pacchetto: {
label: "Pacchetto",
group: "catalog",
settingsKey: "tax_pool_service_pacchetto",
storage: { kind: "tag", entityType: "services.pacchetto" },
},
};
export const TAXONOMY_FIELD_IDS = Object.keys(TAXONOMY_FIELDS) as TaxonomyFieldId[];
function sortUnique(values: string[]): string[] {
return Array.from(new Set(values.filter((v) => v && v.trim().length > 0))).sort((a, b) =>
a.localeCompare(b, "it")
);
}
// Resolves a column-storage descriptor to its drizzle column reference.
// Columns are distinct across the config (category/ticket/fase), so switching
// on `column` is unambiguous and keeps drizzle's typing intact.
function columnRef(storage: Extract<Storage, { kind: "column" }>) {
if (storage.column === "category") return offer_macros.category;
if (storage.column === "ticket") return offer_macros.ticket;
return services.fase;
}
// Distinct in-use values for the field — used to seed the pool on first read.
async function readInUseValues(storage: Storage): Promise<string[]> {
if (storage.kind === "tag") {
const rows = await db
.selectDistinct({ name: tags.name })
.from(tags)
.where(eq(tags.entity_type, storage.entityType));
return rows.map((r) => r.name);
}
const rows = await db.selectDistinct({ value: columnRef(storage) }).from(
storage.table === "offer_macros" ? offer_macros : services
);
return rows.map((r) => r.value).filter((v): v is string => v != null);
}
// Reads the pool. If the settings row is ABSENT (never seeded), lazy-seeds it
// from in-use values and persists — WITHOUT revalidatePath (safe in render).
// A persisted "[]" is respected (intentionally emptied → no reseed).
export async function getPool(fieldId: TaxonomyFieldId): Promise<string[]> {
const { settingsKey, storage } = TAXONOMY_FIELDS[fieldId];
const raw = await getSetting(settingsKey);
if (raw === null) {
const seeded = sortUnique(await readInUseValues(storage));
await writeSetting(settingsKey, JSON.stringify(seeded));
return seeded;
}
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? sortUnique(parsed as string[]) : [];
} catch {
return [];
}
}
export async function getAllPools(): Promise<Record<TaxonomyFieldId, string[]>> {
const entries = await Promise.all(
TAXONOMY_FIELD_IDS.map(async (id) => [id, await getPool(id)] as const)
);
return Object.fromEntries(entries) as Record<TaxonomyFieldId, string[]>;
}
// Registers a value into the pool (idempotent). Used by the settings "Aggiungi"
// and by inline-create sync across the offer editor / catalog. Never revalidates.
export async function addPoolValue(fieldId: TaxonomyFieldId, value: string): Promise<void> {
const trimmed = value.trim();
if (!trimmed) return;
const current = await getPool(fieldId);
if (current.includes(trimmed)) return;
await writeSetting(TAXONOMY_FIELDS[fieldId].settingsKey, JSON.stringify(sortUnique([...current, trimmed])));
}
// Removes a value from the pool AND cascade-strips it from every row using it
// (delete tag rows / null out the column). Settings-only (global delete).
export async function removePoolValue(fieldId: TaxonomyFieldId, value: string): Promise<void> {
const { settingsKey, storage } = TAXONOMY_FIELDS[fieldId];
const current = await getPool(fieldId);
await writeSetting(settingsKey, JSON.stringify(current.filter((v) => v !== value)));
if (storage.kind === "tag") {
await db
.delete(tags)
.where(sql`${tags.entity_type} = ${storage.entityType} and ${tags.name} = ${value}`);
} else if (storage.column === "category") {
await db.update(offer_macros).set({ category: null }).where(eq(offer_macros.category, value));
} else if (storage.column === "ticket") {
await db.update(offer_macros).set({ ticket: null }).where(eq(offer_macros.ticket, value));
} else {
await db.update(services).set({ fase: null }).where(eq(services.fase, value));
}
}
// Renames a value in the pool AND propagates to every row using it.
export async function renamePoolValue(
fieldId: TaxonomyFieldId,
oldValue: string,
newValue: string
): Promise<void> {
const next = newValue.trim();
if (!next || next === oldValue) return;
const { settingsKey, storage } = TAXONOMY_FIELDS[fieldId];
const current = await getPool(fieldId);
await writeSetting(
settingsKey,
JSON.stringify(sortUnique(current.map((v) => (v === oldValue ? next : v))))
);
if (storage.kind === "tag") {
await db
.update(tags)
.set({ name: next })
.where(sql`${tags.entity_type} = ${storage.entityType} and ${tags.name} = ${oldValue}`);
} else if (storage.column === "category") {
await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue));
} else if (storage.column === "ticket") {
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue));
} else {
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
}
}
+72
View File
@@ -0,0 +1,72 @@
'use client'
import { useEffect, useState } from 'react'
/**
* App theme preference. 'system' follows the OS (prefers-color-scheme) and is
* the default; 'light' / 'dark' are explicit overrides. The resolved theme is
* applied by toggling the `dark` class on <html> (Tailwind darkMode via
* @custom-variant in globals.css).
*
* The same localStorage key is read by the inline FOUC guard in layout.tsx so
* the correct class is set before React mounts keep THEME_STORAGE_KEY in sync there.
*/
export type ThemePref = 'system' | 'light' | 'dark'
export const THEME_STORAGE_KEY = 'iamcavalli:theme'
function systemPrefersDark(): boolean {
return (
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
)
}
export function readThemePref(): ThemePref {
if (typeof window === 'undefined') return 'system'
const v = window.localStorage.getItem(THEME_STORAGE_KEY)
return v === 'light' || v === 'dark' ? v : 'system'
}
export function resolveTheme(pref: ThemePref): 'light' | 'dark' {
return pref === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : pref
}
export function applyTheme(pref: ThemePref): void {
if (typeof document === 'undefined') return
document.documentElement.classList.toggle('dark', resolveTheme(pref) === 'dark')
}
/**
* Theme state hook. Initialises to 'system' on both server and first client
* render (avoids hydration mismatch the FOUC guard already set the <html>
* class), then reconciles from localStorage after mount. Applies the resolved
* theme on change and, while in 'system' mode, re-applies live when the OS
* theme flips. Returns the raw preference, the resolved light/dark value, and a
* setter that persists (clearing the key for 'system' so the OS stays
* authoritative).
*/
export function useTheme() {
const [pref, setPref] = useState<ThemePref>('system')
useEffect(() => {
setPref(readThemePref())
}, [])
useEffect(() => {
applyTheme(pref)
if (pref !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = () => applyTheme('system')
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [pref])
function setTheme(next: ThemePref) {
if (next === 'system') window.localStorage.removeItem(THEME_STORAGE_KEY)
else window.localStorage.setItem(THEME_STORAGE_KEY, next)
setPref(next)
}
return { pref, resolved: resolveTheme(pref), setTheme }
}