Commit Graph

174 Commits

Author SHA1 Message Date
simone 5547e555bd feat(tasks): stato "In revisione", con gli stati finalmente in un posto solo
Mancava il modo di dire "finito, ma da controllare prima di consegnarlo".
Il nuovo stato sta fra "In corso" e "Fatto" ed e' visibile anche al cliente:
il lavoro c'e' ed e' in controllo qualita', non e' fermo.

Il costo non era la logica ma la dispersione: tre letterali ricopiati a mano in
otto file, in tre forme diverse (allow-list a runtime, union TS, colonne kanban,
opzioni della select) e nessun CHECK in DB a tenerli insieme. Invece di
modificarne quattordici occorrenze, tutto deriva da TASK_STATUSES in
src/lib/task-status.ts: la prossima aggiunta costa una riga.

Due punti perdevano dati in silenzio, ed erano il vero motivo per centralizzare:

- recomputePhaseStatus considerava "iniziato" solo in_progress|done, come lista.
  Una fase con tutti i task in revisione non rientrava ne' in allDone ne' in
  anyActive e retrocedeva a "upcoming": si leggeva "non iniziata" quando era
  quasi finita. Ora e' la negazione di "todo", e regge anche il prossimo stato.
- ClientKanban ripartiva i task con un oggetto a tre chiavi fisse, non derivato
  dalle colonne: un task fuori da quelle spariva da ogni colonna e da ogni
  contatore, e il cliente ne vedeva meno di quanti ce n'erano, senza errore.

Chiuso anche il cast non verificato al confine del portale (page.tsx), che era
la causa a monte di entrambi: ora ci passa normalizeTaskStatus.

Nessuna migration: tasks.status e' text senza CHECK, le righe esistenti valgono.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:10:52 +02:00
simone 3fcb10dac6 feat(impostazioni): rinomina di un valore di tassonomia, fasi dei progetti incluse
Dalle impostazioni si poteva solo aggiungere o eliminare un valore: per
cambiargli nome bisognava cancellarlo — strappandolo via da ogni servizio che
lo usava — e ricrearlo a mano. La matita nel chip fa il rename in un passo.

renamePoolValue esisteva gia' e propagava ovunque, tranne in un punto:
importOfferIntoProject copia services.fase dentro phases.title e poi ritrova
la fase confrontando i titoli (phases.offer_phase_id non viene mai popolata,
quindi il titolo e' l'unico legame). Un rename fermo al catalogo lasciava le
fasi dei progetti col vecchio nome e al re-import ne nasceva una duplicata.
Ora propaga anche li', con lo stesso match trim+lowercase del merge.

E' l'unico rename di tassonomia che scrive fuori dal dominio catalogo/offerte,
quindi e' l'unico che chiede conferma, dicendo quante fasi e quanti progetti
sta per toccare.

Tolte anche le UPDATE manuali in renameServiceOption/renameOfferOption: erano
la stessa scrittura che renamePoolValue faceva subito dopo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:08:20 +02:00
simone 19ed377214 feat(pipeline): endpoint di ingresso lead, indipendente dalla sorgente
POST /api/webhooks/lead con header x-webhook-secret. Un endpoint solo per il
form del sito e per qualunque bridge: il contratto e' un POST, e chi lo manda
non cambia la route.

La normalizzazione dei campi sta in lead-intake.ts, non nella route, perche' e'
la parte che cambia quando si aggiunge una sorgente. Regge tre forme senza
doverle distinguere: payload piatto, `fields` annidati con {value} (Elementor
Pro), e urlencoded per i form che non mandano JSON. Riconosce i nomi italiani
(nome, telefono, azienda, messaggio), che e' come li chiama un form Elementor
scritto in italiano.

Chi compila due volte non diventa due lead. Il riconoscimento e' sull'email: il
secondo invio aggiorna last_contact_date e lascia un'attivita' con quello che
ha scritto, cosi' il messaggio non si perde ma la scheda resta una. Senza email
non si puo' dedurre nulla e si crea.

Due scelte di sicurezza, entrambe diverse dalle route /api/internal:

- Segreto assente in ambiente = 403, non "passa". Le internal possono
  permetterselo perche' sono raggiungibili solo da localhost; questa e' esposta
  a internet, e un deploy con la variabile dimenticata deve smettere di
  accettare lead, non accettarli da chiunque.
- Il rate limit viene PRIMA del confronto sul segreto, altrimenti tentare
  segreti a raffica costerebbe zero. Confronto a tempo costante con safeEqual,
  lo stesso del gate admin.

src/proxy.ts non intercetta /api/*, quindi da monte non arriva nessuna
protezione: sta tutto dentro la route.

Provato contro il DB di produzione via tunnel SSH, poi ripulito (2 lead e 2
attivita' prima, 2 e 2 dopo): senza segreto 403, segreto sbagliato 403, nome
mancante 422, payload piatto 201, ripetuto 200 "updated" senza duplicare,
forma Elementor 201 con nome/telefono/messaggio mappati, urlencoded 201, e con
starts_at valorizzato il lead nasce con la data della call e un'attivita'
"meeting".

Resta da confermare con un invio VERO da Elementor la forma esatta del suo
payload: qui e' gestita in modo difensivo, non verificata sul campo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:57:42 +02:00
simone d6d3be00f4 feat(dashboard): timeline delle consegne con semaforo ritardo/anticipo
Quali progetti vanno consegnati, a che punto sono e se il ritmo regge.

La data attesa non esisteva nello schema. Si deduce da start_date +
duration_months dell'offerta, e projects.due_date (migration 0018) la
sovrascrive quando la durata a catalogo non descrive quel progetto li'.

Entrano solo le offerte una_tantum. Un retainer e' continuativo e una consegna
non ce l'ha per costruzione: in questa lista risulterebbe in ritardo per
sempre. E' lo stesso discrimine che gia' regge il forecast. Teckell, che ha
solo un "Mantenimento", sparisce dalla vista — e sparisce del tutto, non
finisce fra i "senza scadenza" dove sembrerebbe una dimenticanza.

Il semaforo confronta due percentuali, task chiusi e tempo trascorso, con dieci
punti di tolleranza: sotto, la differenza e' rumore, e un semaforo che vira al
rosso ogni settimana storta smette di essere guardato. Oltre la data di
consegna e' rosso e basta.

La barra le mostra entrambe: pieno = fatto, tacca = tempo passato. La distanza
fra le due E' il ritardo, e si legge senza doversi fidare del semaforo.

I progetti senza scadenza calcolabile restano elencati sotto invece di
sparire: sono quelli a cui non e' assegnata un'offerta, cioe' esattamente il
problema che la vista dovrebbe far notare.

StatusBadge guadagna i toni. Serviva perche' i quattro stati di consegna non
sono stadi di lead e cadevano tutti nel grigio di fallback: quattro pillole
grigie non sono un semaforo. I lead continuano a usarlo come prima.

Atteso e verificato sul DB: una sola riga, Rossi Inc in ritardo (22 giu + 1
mese = 22 lug, oggi 19 ago, 3 task su 28), piu' tre progetti senza scadenza.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:48:42 +02:00
simone 8f2b3255ab feat(dashboard): analytics per linea di prodotto, con il residuo in chiaro
Entry (l'Audit), Signature e Retainer a confronto: quante offerte sono partite
questo mese, quante nell'anno, quanto valgono e quanto e' stato incassato. Le
categorie si leggono da offer_macros.category, cioe' dalla stessa tassonomia
che si edita da /admin/impostazioni: aggiungerne una la fa comparire, senza
toccare il codice. Le categorie configurate compaiono sempre, anche a zero —
"questo mese non e' partito nessun audit" e' una risposta, una riga mancante no.

Il punto delicato e' l'attribuzione dell'incassato. I pagamenti stanno sul
PROGETTO, non sull'offerta: per dire quanto ha incassato una linea di prodotto
bisogna ridiscendere dal progetto alle sue offerte. Un'offerta sola prende
tutto; piu' offerte si spartiscono in proporzione all'accepted_total; nessuna
offerta finisce in una riga "Senza offerta", separata e visibile.

Quella riga separata non e' prudenza teorica. Sui dati di oggi vale il 100%
dell'incassato: 5.300 EUR su 5.300. I due progetti che hanno incassato (Caruso
Speaker, Protocollo Estetico) non hanno nessuna offerta assegnata; i due che
l'hanno (Rossi Inc, Teckell) non hanno ancora incassato. Spalmando quei soldi
sulle tre categorie la dashboard avrebbe mostrato numeri inventati con un
totale che quadra. Cosi' invece si vede che c'e' da assegnare le offerte.

Lo stato dell'offerta non filtra niente: un retainer disdetto oggi ha comunque
incassato quello che ha incassato. Stessa ragione gia' scritta in
getOffersSoldBreakdown — il ciclo di vita riguarda il forecast, non lo storico.

Attesi per il 2026, verificati a mano sul DB: Entry 0, Retainer 1 offerta /
200 EUR, Signature 1 / 7.000 EUR, Senza offerta 5.300 EUR incassati. Nessuna
partenza ad agosto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:45:23 +02:00
simone 1115bb2265 feat(dashboard): l'inbox sale in cima e dice da quanto aspettano
Il widget dei messaggi esisteva gia', ma era il terzo riquadro della colonna
stretta: sotto la piega, cioe' invisibile. Un messaggio non letto e' la cosa
piu' urgente della giornata e ora sta in testa alla dashboard, a piena
larghezza, due colonne.

Due dati erano gia' in ConversationSummary e non venivano mostrati: da quanto
aspetta il messaggio e su cosa e' stato scritto (fase, task, deliverable o
generale). Sono esattamente i due che dicono con che fretta rispondere. Il
tempo relativo si ferma alla settimana e poi passa alla data: oltre, "23 giorni
fa" non aiuta piu' nessuno.

La fascia sparisce del tutto quando non c'e' niente da leggere, invece di
lasciare a video un riquadro vuoto che si impara a saltare — e con lui si
imparerebbe a saltare anche quello pieno.

relativeTime va in src/lib/dates.ts perche' serve anche altrove. FollowUpWidget
ha ancora la sua copia locale con il prefisso "Contattato": la si unifica
quando la si tocca, non oggi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:42:52 +02:00
simone d6e95ef66a feat(progetti): riepilogo soldi e avanzamento in testa al progetto
Per sapere a che punto era un progetto bisognava aprire tre tab e sommare a
mente. Ora la risposta e' sopra i tab: contrattualizzato, incassato, da
incassare, redditivita' oraria, e una barra con i task fatti sul totale.

Nessuna query nuova: sono tutti dati che getProjectFullDetail restituisce gia'
per i tab sottostanti. E' aritmetica su quello che c'e'.

Una scelta: l'incassato si legge dai `payments`, non da `accepted_total`. Il
contratto dice quanto vale il progetto, le rate dicono quanto e' entrato
davvero, e quando i due non tornano e' un'informazione — non un errore di
calcolo da nascondere pareggiando i conti.

MetricCard esce da admin/page.tsx e diventa un componente condiviso: due copie
della stessa card avrebbero iniziato a divergere alla prima modifica.

Numeri verificati contro il DB di produzione, progetto per progetto. Rossi Inc:
7.000 EUR su 30h tracciate = 233 EUR/h sopra il target di 100, 3 task su 28,
1 fase su 4. Teckell, che ha ore ma nessun contratto, mostra "—" e non uno
zero travestito da dato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:40:48 +02:00
simone a9358da96f feat(timer): il tempo si imputa a fase e task, non solo al progetto
"Quanto e' costata questa fase" non era una domanda che si potesse fare:
time_entries aveva la sola project_id.

Migration 0018 (gia' applicata a prod): phase_id e task_id su time_entries,
piu' due indici, piu' projects.due_date che serve al blocco successivo. Solo
ADD COLUMN e CREATE INDEX.

Due scelte che vale la pena spiegare:

- ON DELETE SET NULL, non CASCADE. Cancellare un task NON deve cancellare le
  ore lavorate su di esso: sono storico fatturabile. L'entry ricade a livello
  progetto e il totale del progetto non cambia mai. Con CASCADE, ripulire una
  fase avrebbe silenziosamente abbassato il fatturato tracciato. Verificato
  sul DB di produzione dentro una transazione con ROLLBACK: cancellato il
  task, l'entry sopravvive con task_id NULL, phase_id intatto e i secondi
  invariati.
- Il timer su un task scrive ENTRAMBE le colonne. Cosi' il totale di una fase
  e' un group-by diretto su phase_id, senza risalire dai task, e comprende
  anche il tempo imputato alla fase ma a nessun task in particolare.

Resta un solo timer attivo in tutto l'hub. Da qui una conseguenza in UI: se
sta girando su un task, il timer del tab "Timer" NON si mostra acceso —
mostrarlo acceso farebbe credere che siano due cronometri diversi. Il tab lo
dice a parole e avvisa che avviarlo fermerebbe l'altro.

Le 8 entry esistenti restano valide con entrambe le colonne a NULL, cioe'
"tempo di progetto": e' esattamente cio' che sono.

PhasesTab passa ai token semantici mentre lo si tocca. Non e' zelo: ci si
infila dentro una TimerCell che i token li usa gia', e in dark mode un badge a
token dentro una card bg-white si vede. Un pezzo di DEBT-01 in meno.

Cade startTimerForClient, senza chiamanti da quando la lista progetti non ha
piu' il timer.

Build pulito. L'avvio/arresto dal browser non e' ancora stato provato: si
verifica in produzione, che e' l'unico posto dove esiste il DB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:38:06 +02:00
simone 4b135ce67f refactor(progetti): via il tab Commenti e il timer dalla lista
Due rimozioni chieste esplicitamente, che tolgono due doppioni.

Il tab "Commenti" del progetto duplicava /admin/conversazioni. Non era una
scorciatoia: era la seconda copia. `buildEntityMap()` in conversations-queries
cammina clienti -> progetti -> fasi -> task -> deliverable e raccoglie TUTTI i
commenti con l'etichetta dell'entita' di origine, quindi l'inbox e' un
sovrainsieme stretto di quel tab. La lettura non perde niente.

Una cosa la perde, e va detta: dal tab si poteva rispondere sulla singola
entita', mentre `replyToConversation` salva sempre sul thread generale. Non e'
una regressione introdotta qui — e' una scelta di prodotto gia' presa e gia'
annotata in conversazioni/actions.ts — ma da oggi e' l'unica via, e il commento
la' sopra ora lo dice.

Il timer nella lista progetti era l'altro doppione: si avvia e si ferma dentro
il progetto, dove c'e' il contesto per sapere su cosa stai lavorando. Toglierlo
elimina anche una query per pagina (la scansione delle entry aperte).

Cade di conseguenza il codice rimasto senza chiamanti: CommentsTab.tsx,
`postAdminComment`, il campo `comments` di ProjectFullDetail con la sua query, e
i due campi activeTimer* di ProjectWithPayments. `totalTrackedSeconds` resta:
serve al calcolo del EUR/h, che in lista ci sta ancora.

Build e lint puliti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:30:58 +02:00
simone 08b0a60bae feat(audit): le fonti del motore di analisi
src/lib/audit/sources/ — raccolta dati, nessun LLM. Cinque moduli:

- fetch.ts      home + fino a 3 pagine interne per profilo, piu' gli helper di
                rete condivisi dalle altre fonti (ritentativi su 429/5xx e
                timeout, tetto di concorrenza, navigazione JSON difensiva)
- pagespeed.ts  153 audit Lighthouse fatti sul DOM renderizzato, falliti
                ordinati per gravita' con elementi concreti, per_id per la
                checklist, fasi LCP, screenshot
- crux.ts       dati di utenti reali, con scala di ripiego a quattro gradini
- history.ts    Wayback CDX, istantanee a 1/3/5 anni, confronto con la home
- signals.ts    RDAP, robots/sitemap, JSON-LD, hreflang, piattaforma, header

Regola comune: nessuna fonte puo' uccidere la pipeline. Chi fallisce restituisce
un risultato con `errore` valorizzato — e "non ha risposto" resta distinto da
"ha risposto che non ci sono dati", perche' il documento deve poterlo dire.

Provate sul campo su giojello.com prima di costruirci sopra, e il giro ha
trovato quattro cose che il typecheck non poteva vedere:

- fasi_lcp usciva vuoto: largest-contentful-paint-element non esiste piu'
  nell'API pubblica, ora e' lcp-breakdown-insight con subpart/duration e senza
  percentuali (si calcolano). Dice che il 91% dell'LCP e' ritardo nel *trovare*
  la risorsa, non peso dell'immagine: comprimere le foto non toccherebbe nulla.
- ttfb_ms era un nome pericoloso. Lighthouse da' 7 ms, CrUX da' 3.553 ms di p75:
  il server risponde in fretta al datacenter Google e lento a tutti gli altri.
  Con lo stesso nome il sintetizzatore li tratterebbe come un numero solo, da
  qui risposta_server_ms.
- le dimensioni dello screenshot erano sempre null: configSettings.screenEmulation
  non esiste. Ora si leggono dai byte dell'immagine — 250x498, leggibile.
- Wayback andava in timeout a 30 s e la fonte usciva vuota.

Nessun renderer headless, da nessuna parte: il VPS non regge Chromium e non
serve, gli audit Lighthouse arrivano gia' fatti sul DOM renderizzato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:48:27 +02:00
simone 31237da11c feat(audit): schema del documento di restituzione (migration 0017)
Sette tabelle additive per la milestone v2.5 "Audit": audits, audit_findings,
audit_optimizations, checklist_items, audit_checklist_results, audit_runs e
audit_visits. Nessun DROP, nessun TRUNCATE, nessuna colonna rimossa.

Tre scelte che vale la pena spiegare:

- Colonne scalari su audits, non un jsonb unico. A differenza di proposals non
  c'e' snapshot da congelare: il copy fisso sta in moduli TS versionati e
  l'editor mappa 1:1 sui campi. Tutti i campi di contenuto sono NULLABLE — e'
  cio' che rende possibile "si salva sempre, anche a meta'".
- checklist_items.profili e' jsonb: 71 voci su 264 valgono per entrambi i
  profili, una colonna singola costringerebbe a duplicarle.
- audit_checklist_results.esito ammette 'non_verificabile'. E' l'esito piu'
  frequente misurato sullo spike (107 su 204) e serve a sapere quanto il motore
  NON riesce a vedere: buttarlo via renderebbe impossibile misurare se le
  rilevazioni Lighthouse stanno recuperando terreno.

Migration gia' applicata in produzione il 2026-08-18, dati esistenti intatti.
CLAUDE.md annota la deroga al vincolo LOCKED #5, limitata agli asset di audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:48:11 +02:00
simone 187550fedf feat(client): anteprima admin in sola lettura del portale cliente
Quando un cliente segnalava "non trovo una cosa" non c'era modo di
guardare il portale con i suoi occhi: il gate OTP lascia entrare solo
lui. Dall'elenco clienti ora un'icona apre /client/<slug>?preview=1.

getClientGate() accetta { previewRequested } e salta il gate solo se il
query param c'è E getServerSession(authOptions) è valida. Senza param
anche un admin vede il gate OTP, così il gate resta testabile dal vivo.
Ritorna preview: true senza sintetizzare una ClientSession: un admin in
anteprima non è un cliente autenticato, e confondere i due stati li
renderebbe indistinguibili proprio dove serve distinguerli.

Sola lettura perché il portale scrive davvero: /api/client/approve e
/api/client/comment autenticano sul token nel body, non sulla sessione,
e deliverables.approved_at è immutabile una volta impostato (LOCKED #3).
La protezione è a livello di UI, non di API — impedisce l'incidente, non
difende da sé stessi. Il flag passa da PreviewProvider e non per prop
drilling: ApproveButton sta quattro livelli sotto la dashboard.

Deviazione consapevole dal vincolo LOCKED #4: una route client ora legge
anche la sessione Auth.js. CLAUDE.md non è aggiornato, la sezione LOCKED
richiede approvazione esplicita.

Verificato col build di produzione contro il DB reale (sole letture):
gate OTP senza sessione admin, con cookie contraffatto e con preview=0/
abc/vuoto; portale con banner e composer disattivato con sessione valida,
sia a progetto singolo sia a due progetti. Il ramo ApproveButton non è
esercitabile dal vivo: in produzione deliverables è vuota.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 14:19:22 +02:00
simone 09a5b1ff4f feat(auth): toggle mostra/nascondi password sul login admin
Il campo password non offriva modo di rileggere quanto digitato, quindi
un accesso fallito era indistinguibile da un errore di battitura.

Toggle inline e non nuovo primitivo in ui/: `type="password"` compare una
sola volta in tutto il codebase. type="button" perché dentro un <form> il
default è submit, e tabIndex -1 per tenere il Tab sulla sequenza campo →
Accedi. Classi a token semantici; gli hex literal preesistenti di questa
pagina restano da migrare a parte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 14:19:09 +02:00
simone 5177a3700a feat(offers): ciclo di vita dei servizi ricorrenti (v2.4 Phase 13)
Un retainer, una volta assegnato, non si poteva fermare: project_offers
aveva solo start_date e il forecast sommava il canone a ogni mese
dell'orizzonte da li in poi, per sempre. Un cliente che disdiceva
continuava a gonfiare il forecast a 12 mesi e a vedersi l'abbonamento
attivo nel portale.

- migration 0016 (gia applicata a prod): project_offers.status
  (attivo|sospeso|cessato, CHECK) + end_date. Additiva pura, default
  'attivo' cosi le righe esistenti conservano il comportamento di prima
- forecast: i retainer si fermano a end_date, sospesi e cessati escono.
  getOffersSoldBreakdown NON filtra per stato: e uno storico di vendita,
  escludere le cessate riscriverebbe il passato
- offersAcceptedTotal esclude le cessate (default del piano pagamenti)
- admin: comandi Sospendi/Riattiva/Cessa + data fine nella tab Offerte,
  solo per i ricorrenti. setProjectOfferLifecycle valida con Zod e filtra
  anche per project_id, cosi un id arbitrario non tocca altri progetti
- portale: "Attivo dal", "fino al", badge In pausa, "Canone mensile"
  invece di "Prezzo finale"; le cessate non arrivano al client
- fix: un retainer sospeso continuava a intestare i pagamenti "Totale
  Pagamento Mensile" e a sovrascrivere l'importo

Igiene nello stesso giro:
- STATUS.md riscritto: era fermo al 22 giugno e diceva che node/docker non
  sono disponibili sul server e che le migrazioni si applicano da locale
  con uno script postgres.js — il contrario della procedura reale
- rimossi ChatSection/CommentList/CommentForm, senza importatori (308 righe)
- overrides postcss>=8.5.18 e sharp>=0.35.0: 3 CVE high transitive di Next
  senza fix upstream. npm audit ora pulito, build verde

Verifica: forecast controllato sui dati veri in 5 scenari (baseline
invariata, end_date, sospeso, cessato, ripristino); portale verificato nei
4 stati; tab admin verificata con Playwright sul build di produzione
(i comandi non compaiono sulle una tantum). Dati di test ripuliti,
tabelle protette invariate 4/5/11/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:18:28 +02:00
simone 8158038145 feat(auth): gate OTP email sul portale cliente (v2.3 Phases 23-25)
Il portale /client/<slug> era protetto dal solo token in URL: chiunque
ricevesse o intercettasse il link entrava, per sempre, senza identificarsi.
Ora l'admin registra le email autorizzate per cliente e il cliente si
identifica con un codice usa-e-getta prima di vedere qualsiasi dato.

- Resend 6.18.1 + src/lib/mailer.ts (Result tipizzato, mai catch silenzioso)
- migration 0015 (gia applicata a prod): client_emails, otp_codes,
  clients.sessions_valid_from. Additiva pura, conteggi verificati pre/post
- admin: sezione "Accessi al portale" in /admin/clients/[id] con whitelist
  e revoca sessioni in blocco
- gate: codice 6 cifre CSPRNG, hash SHA-256 (mai il codice in chiaro),
  TTL 15 min, max 5 tentativi, rate limit su entrambi gli endpoint,
  risposta identica per email in whitelist e non (no enumeration)
- sessione: cookie HMAC per-cliente, 90 giorni, httpOnly/secure/lax

Il gate sta in cima alla page, NON nel layout: nell'App Router il segmento
page viene renderizzato in parallelo al layout, quindi gattare nel layout
nascondeva la dashboard a schermo ma lasciava fasi, task e pagamenti nel
payload RSC dell'HTML (46907 byte -> 17594 dopo il fix). Verificato.

Verifica: build OK, 9/9 test E2E in locale contro il DB di produzione.

NON DEPLOYARE prima di: RESEND_API_KEY+RESEND_FROM su Coolify e whitelist
popolata per i 3 clienti reali (oggi vuota) - altrimenti il gate li chiude
fuori dal loro portale. Checklist in .planning/STATE.md.

SEND-01/02 (invio preventivo via email) rinviati a v2.4 su richiesta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:10:33 +02:00
simone e2bd1d95ed fix(security): audit completo — secondo gate admin, hardening slug, XSS, CSP/HSTS, update CVE
Audit di sicurezza su tutta l'app. Report in .planning/SECURITY-SCAN.md (codice),
.planning/SECURITY-AUDIT-INFRA.md (dipendenze/segreti/deploy) e piano in
.planning/SECURITY-REMEDIATION-PLAN.md.

CRITICO — l'autorizzazione admin era un unico punto di rottura: nessuna delle 21
pagine /admin controllava la sessione e admin/layout.tsx renderizzava comunque i
figli quando mancava. L'unico guard era proxy.ts, su un Next.js affetto da
GHSA-6gpp-xcg3-4w24 (proxy bypass). Ora il layout è un secondo gate indipendente;
proxy.ts marca il path con un token derivato da NEXTAUTH_SECRET, così il gate non
è aggirabile forgiando header e fallisce chiuso se il proxy non gira.

ALTO — gli slug cliente avevano 4 caratteri casuali da Math.random() (~20 bit,
1.7M tentativi) e risolvono prima del token: ora 12 caratteri via nanoid
(CSPRNG, ~62 bit). Aggiunto rate limit al ramo /client/, che ne era privo.

ALTO — src/lib/quote-actions.ts esponeva due server action pubbliche senza
autenticazione, una delle quali scriveva su DB. Codice morto, zero chiamanti:
rimosso.

MEDIO — i quattro dangerouslySetInnerHTML nelle sezioni proposta rendevano output
AI come HTML grezzo su pagina pubblica, alimentato da transcript di terzi. Sostituiti
con RichText (whitelist di emphasis, nessun HTML al DOM). I transcript ora sono
recintati in tag che il system prompt dichiara essere dati, non istruzioni.

Inoltre: next 16.2.6 -> 16.2.12 e next-auth 4.24.14 -> 4.24.15 (chiude 9 CVE Next
piu GHSA-xmf8-cvqr-rfgj su getToken, raggiungibile dal proxy); HSTS e CSP;
potatura della Map di rate-limit.ts, che cresceva senza limite; espunta la password
Postgres di produzione dai due 07-01-SUMMARY.md.

Verificato: tsc pulito, build OK, smoke test su login/redirect/header forgiati.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:35:46 +02:00
simone dd2d148457 feat: pagina Impostazioni redesign Quiet Luxury — sezioni tokenizzate + PoolManager tassonomie dual-mode
- impostazioni/page.tsx: sezione Analytics con input tariffa bordato (€/h, font-mono) + header uppercase
- TaxonomyManager: card sezione tokenizzata, griglie Offerte (2col) / Catalogo (3col)
- PoolManager: box bg-muted, badge conteggio mono, chip valori, add su Enter, delete cascade confermata
- DESIGN-SYSTEM.md: note pagina Impostazioni

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 12:14:52 +02:00
simone d444bd6064 feat: pagina Progetti redesign Quiet Luxury — tabella tokenizzata + badge pagamento/timer dual-mode
- projects/page.tsx: tabella in contenitore bg-card/shadow-card, header uppercase muted
- ProjectRow: badge pagamento pill rounded-full dual light/dark (saldato/da_saldare/inviata)
- TimerCell: pill rounded-full mono, idle neutro + running emerald con contatore live
- ConversationsView: bordo item attivo 3px per coerenza
- DESIGN-SYSTEM.md: note pagina Progetti

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 12:14:31 +02:00
simone dd4ae42542 feat: pagina Preventivi redesign Quiet Luxury — tabella tokenizzata + badge stato dual-mode
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 11:53:41 +02:00
simone c29fab8975 feat: pagina Catalogo redesign Quiet Luxury — griglia servizi tokenizzata + footer conteggio
- ServiceTable: contenitore bg-card/shadow-card, header uppercase muted, righe hover:bg-muted/40, footer "Visualizzazione di N servizi"
- CatalogSearch: usa il primitivo SearchInput
- page: sottotitolo sezione
- tokenizza OptionSelect ed EditableCell (accent-primary) — erano rimasti con hex hardcoded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 11:37:09 +02:00
simone 1fc0650dcf feat: pagina Offerte redesign Quiet Luxury — grid card tokenizzata + badge categoria
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:36:34 +02:00
simone c1ff5a7e67 feat: pagina Clienti redesign Quiet Luxury — tabella tokenizzata + copia link
- /admin/clients replica design-reference/pagina-clienti (dual light/dark via token)
- ClientRow: colonne numeriche font-mono right-aligned, incassato come pill emerald,
  cella "Link profilo" con pill mono troncata + CopyLinkButton
- nuovo primitivo CopyLinkButton (copia URL assoluto, feedback check emerald)
- PageHeader con sottotitolo + footer conteggio clienti
- DESIGN-SYSTEM.md: inventory (CopyLinkButton, ClientRow) + note pagina Clienti

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:32:34 +02:00
simone a1d8d18902 feat: dashboard box "Messaggi Clienti" — chat clienti in attesa di risposta
Nuovo MessagesWidget nella colonna 1/3 della dashboard: riusa
getConversations() filtrando le conversazioni unread, mostra le prime 4
con anteprima e deep-link "Rispondi" a /admin/conversazioni?c=<clientId>.
Pill emerald "N Nuovi" + pallino pulsante, empty state quando non c'è nulla
in attesa. Stessa fonte di verità (clients.admin_last_read_at) del badge
sidebar. Aggiornato DESIGN-SYSTEM.md (inventory + note Conversazioni/dashboard)
e il mock design-reference/pagina-dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:23:43 +02:00
simone c1cc13a99a feat: pagina Conversazioni — inbox unificata messaggi clienti
Nuova pagina admin /admin/conversazioni: vista WhatsApp-style (lista
conversazioni a sinistra, thread + risposta a destra) che aggrega i
messaggi di tutti i clienti dalla tabella comments, cross-cliente.

- Migration additiva 0014: clients.admin_last_read_at per tracciare
  letto/non-letto (pallini + badge in sidebar). Applicata a prod.
- conversations-queries.ts: aggregazione entity_id → cliente
  (general/phase/task/deliverable), getConversations /
  getConversationThread / getUnreadConversationsCount.
- Risposte admin salvate come commento "general" (visibili anche nella
  chat cliente e nel CommentsTab della scheda).
- Voce di menu + badge non-letti (AdminSidebar/AdminShell/layout).
- Token semantici per dual light/dark; refresh manuale (no polling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:10:14 +02:00
simone a20a9de2d7 style: rifinitura Quiet Luxury dashboard — bordi soft, badge, colori
Allinea la dashboard alla rifinitura del mock (finezza bordi/colori/badge):

- Card su border-border-light (slate-100) per il bordo sottile
- KPI: valore verde su Incassato Reale, delta verde "+N questo mese" su
  Clienti Attivi (nuova metrica getDashboardStats.clientiNuoviMese)
- Redditività: badge "Ottimo" con tint emerald soft (non brand primary)
- Follow-up: righe come box bordati (no avatar), link "Apri →" primary
- Forecast: selettore anno (pill interattiva) spostato nell'header della card,
  barre non-picco grigie (bg-border), rimosso il numero "mese prossimo"
- Offerte Più Richieste: rimossa la pill totale nell'header

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:18:10 +02:00
simone 08aadc1d97 feat: Dashboard admin redesign to Quiet Luxury + client profitability widget
Replica il mock design-reference/pagina-dashboard: layout condensato a
schermata singola (4 KPI + griglia 2/3-1/3), token semantici per dual-theme.

- Nuova query getClientProfitability(year): valore orario reale per cliente
  (contrattualizzato / ore tracciate) con badge margine, target 100 €/h
- Nuovo widget ClientProfitability "Analisi Oraria & Redditività Clienti"
- Rewrite /admin: KPI strip + Cashflow + Redditività | Follow-up + Offerte
  Più Richieste. Rimossi chart mensile, card extra, barre ore/cliente
- Tokenizzati ForecastChart, OffersSoldChart, FollowUpWidget, YearSelector
  (ora pill interattiva); rimosso MonthlyChart e prop availableYears inutile
- DESIGN-SYSTEM.md: inventario + note dashboard (applied 2026-07-11)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:57:10 +02:00
simone e5fa07bba3 feat: Lead Details redesign to Quiet Luxury + unified interaction timeline
Rewrite /admin/pipeline/[id] LeadDetail with token-based dual-theme layout:
bespoke header with inline StatusBadge, asymmetric 1/3 profile + 2/3 content
columns, and a unified reverse-chronological timeline merging activities and
transcripts (transcript node highlighted + expandable + deletable).

Restyle modal triggers (Registra Attività, Aggiungi Transcript, Modifica-as-icon)
to the mock outline style. Remove the confusing "Invia Preventivo" button (it only
linked a token + advanced stage, no email). Keep Converti in cliente for won leads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:33:18 +02:00
simone c110689b6a fix: chat drawer above header + vertical "Chiudi" tab on left edge
Panel z-index raised to z-[60] so it sits above the sticky page header
(z-50) that was covering the close control. Replaced the header X with a
notebook-divider-style vertical "Chiudi" tab protruding from the left edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 08:34:25 +02:00
simone 11870e15d3 feat: Client Portal redesign to Quiet Luxury + milestone stepper
- New MilestoneStepper primitive (horizontal per-phase progress)
- Token-migrate portal shell, phase cards, sidebar cards, kanban to dual-theme
- Soft-tint status pills (emerald/amber/muted) replacing solid badges
- embedded prop on ClientDashboard fixes double-header in multi-project tabs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:54:58 +02:00
simone 6d5e04bf9c style: refine Pipeline fidelity to Quiet Luxury reference
- Sidebar: revert to white palette (drop emerald pass), remove right border
- Table: softer slate-100 borders, remove min-height gap, "Mostrando N di M
  lead" footer, empty Tag cell = dashed-circle add button
- SegmentedToggle: more visible track (slate-200/60), neutral active text
- Kanban: roomy fixed-width columns (280px, horizontal scroll), sober column
  headers (no colored dots), airy cards matching mock (name+badge/email/
  company+"Tag +"); 6 stages + @dnd-kit intact

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:33:26 +02:00
simone 26d752892a style: align light palette to Quiet Luxury reference
- Page background now #F8F9FA (was white); tokens matched to reference:
  foreground slate-900, muted slate-50/slate-500, border slate-200,
  card pure white, tertiary slate-400, border-light slate-100
- Sidebar colors matched to mock: emerald-200/70 inactive, white/8 active
  with emerald-400 active icon, emerald-50 logo, red-300 logout, right border
- Content wrapped in max-w-[1400px] centered container (px-8 lg:px-10)

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 14:37:05 +02:00