Compare commits

..

31 Commits

Author SHA1 Message Date
simone 9ce6f02ee4 fix(conversazioni): il menu dei tag mostra una riga per persona, non per alias
Con un cliente «Gian Luca Caruso / Caruso Speaker» il menu proponeva tre voci
per un destinatario solo. Non era un difetto di resa: al menu era stata passata
la stessa lista che il parser usa per RICONOSCERE un tag rileggendo il testo.

Sono due domande diverse, e il codice le aveva confuse:
  - come lo si puo' scrivere -> tutti gli alias, invisibili, solo in lettura
  - chi si puo' scegliere    -> una riga per destinatario

`MentionTarget` le separa: `label` per il menu, `aliases` per la rilettura.
Scrivere «@Gian» a mano continua a valere come tag e a far partire la mail --
cambia solo cosa viene offerto, non cosa viene accettato.

Il filtro del menu ora passa da `normalizeForSearch`, la stessa
normalizzazione del parser: con un toLowerCase() a parte, digitare «nicolo»
non avrebbe trovato «Nicolò» nel menu mentre nel messaggio sarebbe stato
riconosciuto -- due regole per la stessa domanda, e la seconda si scopre solo
quando la mail non parte.

Il difetto scalava peggio del valore: con tre contatti per cliente sarebbero
diventate nove righe. `mentionTargets()` ritorna gia' un array per questo, ma
il tag per singola persona resta impossibile finche' `client_emails` ha gli
indirizzi e non i nomi: serve una migration, ed e' un lavoro a se'.

Verificato: build e lint puliti, 23 test su parser e menu. Mai visto a schermo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:22:38 +02:00
simone 614eb00040 docs(state): il push e' passato, il 403 non c'e' piu'
Il bloccante sul push va tolto, non lasciato: una memoria sbagliata e' peggio
di una assente, e "push 403" avrebbe fatto ripartire la prossima sessione da
un problema gia' risolto.

Resta scritto COME si e' risolto -- l'accesso a Gitea si recupera dalla CLI
admin dentro il container, non dal web -- perche' quello sara' ancora vero la
prossima volta che il portachiavi scade.

In cima ai Next: rigenerare il token, che e' stato incollato in chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:36:45 +02:00
simone d71fede4e4 docs(state): conversazioni scritte e committate, push bloccato da un 403 di gitea
Il 403 va scritto come bloccante e non come nota di passaggio: finche' dura,
NESSUN commit arriva in produzione -- Coolify deploya sul push a `main`.
La credenziale nel portachiavi legge ma non scrive, e la chiave SSH locale non
e' registrata su Gitea, quindi non c'e' via alternativa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:53:21 +02:00
simone 41530b556a feat(conversazioni): scrivere per primo, taggare il cliente, notificarlo via mail
Tre cose che mancavano all'inbox admin, tutte senza migration.

1. Dall'inbox era impossibile aprire una conversazione: getConversations()
   costruiva la lista dai commenti, quindi un cliente compariva solo dopo aver
   scritto lui. Ora la lista parte da `clients` e i commenti la arricchiscono.
   Ordine: prima chi ha scritto (per recenza), in coda i clienti muti in
   alfabetico, cosi' l'inbox resta un inbox.

2. Menzioni «@Nome», rinviate dalla chat a canali. Modello senza schema: il tag
   si riconosce confrontando il testo con i nomi noti del cliente (nome intero,
   nome di battesimo, brand), insensibile ad accenti e maiuscole. Il body resta
   quello che l'admin ha scritto, quindi la menzione sopravvive alla modifica di
   un messaggio e resta leggibile ovunque finisca, mail compresa.
   Confini di parola su ENTRAMBI i lati: senza quello a sinistra,
   «scrivimi a mario@teckell.it» conteneva un tag «@Teckell».

3. Un tag manda una mail. E' l'unico messaggio che esce dal portale: per il
   resto il cliente entra quando gli pare, ma il tag e' la dichiarazione che
   quel messaggio non puo' aspettare il prossimo accesso. Nessuno scheduler --
   parte dalla stessa azione che scrive il messaggio, fuori transazione: se
   Resend e' giu' il messaggio in chat resta comunque scritto.
   Destinatari: whitelist OTP + email della scheda, deduplicati. Con zero
   indirizzi il compositore lo dice PRIMA, invece di lasciar credere che sia
   partita una mail che non partira'.

Il pulsante della mail punta a `?chat=<canale>`, validato lato server e passato
come prop: leggerlo nel browser vorrebbe dire renderizzare il pannello chiuso e
riaprirlo dopo l'idratazione.

La casella di risposta diventa controllata (ReplyComposer): il suggerimento del
tag deve leggere il testo mentre lo scrivi e reinserirlo al caret giusto.
Invio manda, Shift+Invio va a capo -- come nel pannello del cliente.

Verificato: `npm run build` e `eslint` puliti, parser delle menzioni provato su
9 casi. NON verificato a schermo ne' contro il DB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:51:58 +02:00
simone 817a8cd5d1 chore(claude): architettura base .claude — skill preventivo e audit, hook di guardia, piani nel repo
La cartella aveva dentro solo rules/ e i settings: nessun posto dove mettere una
skill, un hook o un piano. Ora ha lo scheletro completo e un .claude/CLAUDE.md che
spiega cosa va dove — non duplica CLAUDE.md di progetto, che resta quello che comanda.

Due skill locali (le altre restano globali in ~/.claude/skills/):
- /preventivo — la catena agent.ts → schema.ts → assemble.ts → ProposalDeck e i tre
  modi di romperla, di cui uno solo fa rumore. Nessun prompt di generazione qui
  dentro: quello vive in agent.ts ed e' l'unico. Porta check-profilo.sh.
- /audit — guida scripts/audit-fonti.ts, nuovo, che mette in moto le cinque fonti di
  src/lib/audit/sources/, in prod dal 2026-08-19 ma mai chiamate da nessuno. Provate
  su giojello.com: 5 su 5, 42,7 s, PageSpeed mobile 58 / desktop 93.

Due hook, provati a mano (6 casi il primo, 5 il secondo):
- guardia-migration.sh BLOCCA l'SQL distruttivo sulle entita' protette — il vincolo
  Data Safety (LOCKED) fatto rispettare dalla macchina invece che dalla memoria.
- guardia-token.sh AVVISA sulle classi Tailwind grezze. Non blocca: con ~450
  occorrenze di debito, bloccare lo renderebbe un ostacolo da disattivare.

I tre piani di v2.5 entrano nel repo: stavano solo in ~/.claude/plans/ e STATE.md
avvertiva che senza quelli la milestone non era ricostruibile. Passati al setaccio
per credenziali prima di committarli.

Corretta in rules/memory-discipline.md la chiave della memoria persistente: e'
…-Vault-IAMCAVALLI-hub, non quella del workspace. Sedici file stavano nella prima,
la regola indicava la seconda.

Impeccable resta abilitato solo a livello globale: fuori da settings.json locale.

Nessun tocco al prodotto. Build e lint verdi, lint identico al baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:09:14 +02:00
simone bec7e039d7 fix(portale): la legenda spiega "Cancellata" solo a chi ne ha una
Due sostituzioni del commit precedente non avevano agganciato — il testo
cercato aveva un a capo diverso — e la frase che spiega lo stato non è mai
finita nel file. Rifatte con verifica.

Nel rimetterla, condizionata: la voce «Cancellata» e la sua riga di spiegazione
compaiono solo se quel progetto ha davvero un task cancellato. Nella maggior
parte dei casi non ce n'è nessuno, e raccontare a tutti uno stato che non
vedranno mai è rumore in un riquadro che serve a togliere dubbi. La condizione
guarda i task veri, mai una preferenza: se una cancellata c'è, la legenda la
spiega.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:54:31 +02:00
simone 4403b39e63 docs: task cancellati e date dei pagamenti — STATUS, STATE, design system
STATUS.md: perché la pill sta su "Cancellata" e non su "Fatto" (sono entrambi
barrati, e scambiarli significa credere consegnato ciò che non esiste), perché
`due_date` è nullable, e le due verifiche che restano a mano — il riquadro
"Prossimo pagamento" non compare finché nessuna rata ha una scadenza, e due
`paid_at` storici valgono il primo del mese perché li ha scritti il vecchio
selettore a mese.

DESIGN-SYSTEM.md: la quinta forma, la regola della colonna Kanban che si
nasconde solo da vuota, e la spec del box pagamenti.

STATE.md: due decisioni nuove, e resta sotto le 100 righe accorpando le righe
di tabella della stessa settimana.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:51:51 +02:00
simone fe767899b9 feat(portale): task cancellati e le date dei pagamenti
Due cose che il cliente non poteva sapere guardando il portale.

**Task cancellati.** Fino a ieri un'attività tolta dal lavoro poteva solo
sparire (cancellata dal DB) o restare lì a far finta di essere ancora da
fare. Ora `cancelled` è il quinto stato: X dentro il cerchio, titolo barrato,
pill "Cancellata" — l'unico stato chiuso che la porta, perché "fatto" e
"cancellato" sono entrambi barrati e confonderli significa credere consegnato
qualcosa che non esiste.

Esce da tutti i denominatori — fase, progetto, board di consegna, riepilogo
admin — con un unico `countsTowardProgress()` in task-status.ts invece di
cinque `!== "cancelled"` sparsi. Contarlo terrebbe la fase sotto il 100% per
un lavoro che nessuno farà; contarlo come fatto racconterebbe una consegna
mai avvenuta. `recomputePhaseStatus` lo ignora allo stesso modo: senza questo,
cancellare l'ultima voce lasciava la fase "in corso" per sempre.

Nel kanban cliente la colonna compare solo se ha dentro qualcosa — le quattro
che raccontano il lavoro si tengono la larghezza — ma mai se è piena, quindi
nessun task sparisce dalla board. Nell'admin la colonna c'è sempre: è così che
si cancella un task, trascinandocelo.

**Date dei pagamenti** (migration 0023, già applicata in produzione).
`payments` sapeva solo quando una rata era stata incassata, mai quando era
attesa: il portale non poteva rispondere a "quando devo pagare?" e non c'era
niente su cui agganciare il promemoria email. Ora c'è `due_date`, nullable —
una rata senza data concordata è normale, e il portale la mostra solo se c'è.

Il cliente vede in cima al box la prossima scadenza col conto alla rovescia
("tra 12 giorni", "domani", "scaduto da 3 giorni" in rosso), e su ogni riga
la data: "Scade il…" se aperta, "Pagato il…" se saldata. Nessun importo per
riga — LOCKED #2 resta dov'era, le date non sono cifre.

I giorni si contano in `src/lib/payment-dates.ts`, sui giorni civili a Roma e
non sugli istanti: il container gira a UTC e "manca una settimana" non deve
cambiare risposta a seconda del fuso. È lo stesso modulo che userà il
promemoria email, così la mail e il portale non si contraddicono.

Lato admin ogni rata ha il campo Scadenza, e "Incassato nel mese" diventa
"Incassato il" — precisione al giorno, che le analytics (raggruppate per mese)
non notano. Il warning sul cambio schema ora conta anche le scadenze: una rata
"da saldare" con una data è già sotto gli occhi del cliente, e sparirebbe in
silenzio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:49:11 +02:00
simone 15b01e3e05 docs: stati dei task nel portale — STATUS, STATE, e la correzione su cosa significhi
Il punto che vale la pena scrivere non e' la UI ma la semantica: "in revisione" significa
"aspetta l'OK del cliente", non controllo qualita' interno come dice 5547e55. Quel commit
resta nel repo a raccontare la versione sbagliata, quindi la correzione deve stare dove
qualcuno la trova — STATUS.md, non solo nel messaggio di questo commit.

Annotato anche cosa NON e' stato verificato: il bundle in produzione contiene il codice
nuovo (controllato dentro il container), ma nessuno l'ha ancora visto a schermo, perche'
il portale sta dietro il gate OTP e l'anteprima vuole una sessione admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:29:18 +02:00
simone 8b54f48afd a11y(portale): la pill di stato non ripete quello che lo screen reader ha gia' sentito
L'icona porta gia' uno sr-only con lo stato esteso, su tutti e quattro gli stati. Senza
aria-hidden sulla pill, i due che ce l'hanno venivano annunciati due volte: "In corso —
ci stiamo lavorando noi", titolo, "In corso". La pill e' ridondanza *visiva*, che e'
esattamente cio' che chiede il design system; per l'assistive tech e' rumore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:27:39 +02:00
simone 2e9bd2ab60 feat(portale): gli stati dei task si distinguono, e "in revisione" dice di chi e' la palla
Nella Timeline — la vista di default — lo stato di un task era solo un cerchietto
colorato: ambra col puntino da 1.5px per "in corso", violetto col puntino da 2px per
"in revisione". A 20px sono lo stesso oggetto. Solo "in revisione" aveva un title; gli
altri tre stati non avevano ne' testo, ne' tooltip, ne' aria-label — l'opposto della
regola UX 3 del design system, che chiede colore + testo, mai colore da solo.

Il punto pero' non era la somiglianza fra i due colori. "In revisione" non significa
quello che dice 5547e55, che lo descriveva come controllo qualita' interno: nel flusso
reale vuol dire "consegnato, aspetta l'OK del cliente". E' uno stato che richiede
un'azione, e per questo un cerchietto muto era il problema vero.

- Le quattro icone si distinguono per forma, non solo per colore, quindi reggono anche
  in bianco e nero e con un daltonismo: vuoto -> punto -> spunta vuota -> spunta piena.
  La spunta compare a lavoro finito e si riempie a lavoro confermato.
- Pill di testo solo su "in corso" e "in revisione". "Da fare" e' un cerchio vuoto e
  "fatto" ha il titolo barrato: etichettarli aggiungeva rumore, non informazione.
- Il conteggio dei task in attesa sta nell'header della fase, quindi si legge anche a
  card chiusa. Serve davvero: l'admin puo' forzare una fase su "completata" dalla
  select, e in quel caso la card parte collassata con dentro la richiesta.
- Legenda + micro-copy sopra entrambe le viste: chi revisiona, e che ogni pagina include
  un giro di revisione — l'informazione commerciale che il cliente non aveva da nessuna
  parte. Rimanda alla chat della fase, che esiste gia'.
- Colonne del kanban cliente colorate con le stesse tinte dell'icona in Timeline: prima
  erano quattro colonne grigie identiche.
- Icona aria-hidden + sr-only, cosi' lo screen reader annuncia lo stato anche sui due
  che non portano la pill.

La barra di avanzamento non e' stata toccata: un task in revisione conta come uno in
corso, cioe' zero. Cambia il tipo di lavoro, non l'avanzamento — gonfiare la percentuale
le avrebbe fatto dire una cosa che il contatore "x di y task" smentiva una riga sotto.

Visual e copy in un solo file, e la legenda si genera da TASK_STATUSES: un quinto stato
non lascera' indietro una lista scritta a mano. Stessa disciplina di 5547e55, nata
proprio perche' due liste fisse avevano fatto sparire dei task senza un errore.

Nessuna migration: e' solo UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:24:35 +02:00
simone c3d2afa61f docs: modifica messaggi e firma in chat — STATUS, STATE, design system
La cosa da rileggere fra sei mesi: propagare una modifica richiede
insieme il filtro allargato a edited_at e il watermark del client sul
massimo dei due timestamp. Una sola delle due e o la modifica non
arriva, o arriva a ogni giro per sempre.

Più il perché di due scelte che sembrano sviste: il non-letto resta su
created_at, e la foto è un URL esterno perché l'upload su volume non
esiste (la deroga LOCKED #5 è scritta ma mai costruita).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:44:30 +02:00
simone 94f4a54248 feat(chat): modifica dei messaggi e firma di chi risponde
Due mancanze emerse provando la chat a canali in produzione.

**Modifica dei messaggi** (migration 0022, additiva, già applicata a
prod). Modello Slack/Discord: si corregge un proprio messaggio senza
limite di tempo, il testo precedente non si conserva, accanto all'ora
compare «modificato». Scelta deliberata, annotata in STATUS.md.

Il punto delicato non è la scrittura ma la propagazione: il poll chiede
`created_at > since` e una modifica non cambia `created_at`, quindi
l'altra parte vedrebbe il testo vecchio fino a un reload. Il filtro ora
guarda anche `edited_at`, e il watermark del client è il massimo fra i
due su tutti i messaggi — senza, il server rispedirebbe lo stesso
messaggio a ogni giro per sempre. Il merge per id già esistente fa il
resto, quindi niente duplicati.

Il non-letto resta ancorato a `created_at` di proposito: correggere un
refuso non deve riaccendere il pallino di un canale già letto.

Si modifica solo ciò di cui si è autori — il controllo è su `author`,
non solo sulla proprietà dell'entità, e lo rifà il server.

**Firma in chat.** Il nome era la stringa "iamcavalli" cablata nel
pannello: il cliente leggeva il marchio dove si aspetta una persona. Ora
arriva da `settings` (nessuna migration) con foto via URL esterno, che
rispetta il vincolo LOCKED #5 — l'upload su volume non esiste, la
deroga per l'audit è scritta in CLAUDE.md ma non è mai stata costruita.
Avatar rotto o assente ricade sul monogramma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:42:48 +02:00
simone 0d7186ba03 docs: chat a canali — design system, STATUS, STATE
Il pattern che vale la pena rileggere: la chiave del canale è derivata,
non è una colonna, e sta in un modulo condiviso perché le due sponde
devono concordare al carattere. Più il perché del letto/non-letto
asimmetrico (tabella per il cliente, timestamp per l'admin).

Chiude anche il caveat in STATUS.md sulla risposta admin che finiva
sempre sul thread generale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:53:33 +02:00
simone ac74a81a72 feat(chat): chat a canali nel portale cliente e inbox admin per canale
Il portale aveva una sola conversazione con un selettore di fase in un
dropdown: il cliente non vedeva dove c'era del non letto, e una risposta
admin poteva atterrare su un'entità diversa da quella della domanda.

Ora i messaggi si organizzano in canali — "Generale" più uno per fase —
derivati in un solo posto (src/lib/chat-channels.ts) così che le due
sponde concordino sulla stessa chiave. Task e deliverable non sono più
scrivibili ma lo storico non resta orfano: rientra nel canale della fase
proprietaria conservando il nome dell'entità come badge.

- migration 0021 (additiva, già applicata in prod): client_channel_reads
  per il letto/non-letto per canale lato cliente, più il primo indice mai
  esistito su comments (entity_id, created_at)
- GET/POST /api/client/chat: polling dei messaggi e ricevuta di lettura
- ChatPanel: tab per canale, pallino di non letto, modalità full-screen
- inbox admin: tab per canale con targeting dell'entità corretta in
  risposta e snapshot di adminLastReadAt sul thread

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:50:44 +02:00
simone 53f1758f52 docs: l'\override è già stato usato in produzione
Guardando il DB: Caruso Speaker / B ha offer_value_override = 7500 a
fronte di un calcolo di 20.250. La 0020 non fa backfill, quindi quel
numero è stato messo a mano dal tab Offerte — updateOfferValueOverride è
provata sul campo, non più solo buildata.

Resta scoperta Rossi Inc / B: venduta a 7.000, il portale mostra 20.250.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:30:23 +02:00
simone d14f95c80d docs: verifica del giro 0020, e .env.local non è più utilizzabile
Cosa è stato verificato davvero: build e lint verdi, migration 0020
applicata a prod prima del push con la colonna riletta a conferma,
deploy atterrato (immagine taggata 44be190 = HEAD).

Cosa no, e ora è scritto: le pagine non sono state rese a runtime.
.env.local è scaduto su tre fronti — ADMIN_PASSWORD, NEXTAUTH_SECRET e
la password del DB — e l'host che dichiara (…:5432) è chiuso dal
firewall; il DB vero sta su 127.0.0.1:54321. Estrarre la password viva
dal container è bloccato dal classifier e non è stato aggirato. La nota
tecnica che dava per buone quelle credenziali diceva il falso.

Aggiunto come vedere se un deploy è atterrato: il tag dell'immagine in
docker ps è lo SHA del commit.

REQUIREMENTS: HUB-14 e HUB-15 registrati come fatti, HUB-16 apre la
verifica a mano che resta da fare, e il riallineamento di .env.local
entra fra le cose aperte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:05:39 +02:00
simone 44be190631 feat(portale): barra full-width, card offerta senza accordion, valore override
Barra di avanzamento a tutta larghezza (via il max-w-[1200px]).

Card offerta: rimosso l'accordion "Cosa è compreso". La lista servizi non
arriva più al client — client-view.ts legge dai servizi i soli prezzi — e
OffersSection perde il suo unico useState, quindi anche il "use client".

"Valore incluso" diventa "Valore dell'offerta" e accetta un override manuale
(migration 0020, project_offers.offer_value_override). Prima era sempre la
somma dei prezzi di catalogo del tier: in produzione mostrava €20.250 su
offerte vendute a 7.000 e 5.500. NULL torna al calcolo, 0 nasconde la riga.
Si gestisce dal tab Offerte del progetto, che affianca la cifra calcolata
per far vedere cosa si sta sostituendo.

La somma calcolata vive ora in src/lib/offer-value.ts, letta sia dal portale
sia dall'admin: duplicarla avrebbe fatto divergere le due viste.

"Prezzo finale" diventa "Investimento finale" (il ricorrente resta "Canone
mensile").

Migration 0020 applicata a prod prima del push. Override tutti NULL, quindi
il comportamento resta invariato finché non se ne imposta uno.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:54:55 +02:00
simone 7e58031528 feat(portale): progress bar compatta, meno altezza
Segue il mock design-reference/Client-Portal-Progress-Bar: "Step N" e lo
stato passano sulla stessa riga, il titolo fase resta sotto — due righe di
testo invece di tre — e il padding del contenitore scende da py-8 a py-3.
Altezza della sezione da ~147px a ~90px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:08:00 +02:00
simone 4666fc4058 fix(task): key mancante sul ramo a task singolo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:44:34 +02:00
simone c9855d58ce docs: STATE.md rientra sotto le 100 righe
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:42:47 +02:00
simone 4e3907d382 docs: tab pagamenti e riordino task in produzione
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:42:23 +02:00
simone 1fa8e1ab5e feat(task): riordino dei task dentro la fase, trascinando
tasks.sort_order esisteva e veniva letto in ORDER BY, ma non era mai scritto se
non come max+1 all'inserimento: nel repo non c'era alcun riordino, per nessuna
entita'. @dnd-kit/sortable era gia' installato e mai importato.

Il nodo era PhasesTab: e' un Server Component con quattro closure "use server"
inline, che in un modulo client non compilano. Quindi niente conversione: le
righe restano renderizzate dal server e arrivano a SortableTaskList come
ReactNode opachi, che ci monta attorno solo la maniglia. E' la stessa forma di
PhasesViewToggle, che gia' passa un tab server-renderizzato come prop.

reorderTasks riscrive sort_order come 0..n-1 per tutta la fase invece di
scambiare due righe. Non e' pigrizia: in produzione una fase ha 14 task con
sort_order sparsi su 0..23 (buchi lasciati dai delete), le righe legacy stanno
sullo 0 di default e non esiste unique index su (phase_id, sort_order), quindi i
duplicati sono ammessi. La riscrittura completa normalizza tutto a ogni drop. Gli
id arrivano dal client, quindi vengono filtrati su quelli che appartengono
davvero alla fase.

Niente pacchetti nuovi: @dnd-kit/modifiers non c'e', e il vincolo verticale si
ottiene azzerando la X della transform.

Verificato a runtime contro il DB di produzione (build di produzione + tunnel
SSH, in sola lettura): la pagina progetto risponde 200 e rende 27 maniglie, che
sono esattamente i task delle fasi con piu' di un task. Il passaggio di nodi
server con "use server" inline attraverso il confine client regge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:41:46 +02:00
simone b49d4bfaaa feat(pagamenti): ordine stabile delle rate, label e importi modificabili
Mettere una rata su "saldato" la faceva saltare in fondo. Non era un'impressione:
payments non aveva NESSUNA colonna d'ordine (ne' sort_order ne' created_at) e
nessuna delle 13 query che la leggono aveva un ORDER BY. Postgres fa seq-scan e
restituisce l'ordine fisico; una UPDATE in MVCC riscrive la tupla in coda, quindi
la riga aggiornata tornava ultima.

In produzione 3 progetti su 5 mostravano gia' l'ordine sbagliato (uno 30/20/50,
uno del tutto rovesciato 20/30/50, e la coppia legacy con Saldo prima di Acconto).
Per questo la migration 0019 NON fa il backfill per ctid, che avrebbe fotografato
lo scombinamento: ordina per percent DESC con tie su label, che ricostruisce
l'intento di tutti gli schemi esistenti. Verificato: rimette a posto tutti e 5.

Aggiunto anche l'indice su (project_id, sort_order): una FK non crea indice sul
lato referenziante, e payments non ne aveva alcuno oltre alla PK.

Ora le rate si rinominano e gli importi si sovrascrivono a mano (EditableCell +
updatePaymentField, con la stessa normalizzazione it-IT di updateServiceField).
L'importo scritto a mano e' legge: amount_locked lo esclude dal ricalcolo. Quando
la somma delle rate non corrisponde al totale il tab lo dice, con la cifra esatta,
invece di aggiustare di nascosto.

Lo schema a 3 rate passa da 50/30/20 a 50/25/25 (le righe gia' esistenti non
cambiano: vale solo quando lo si riseleziona).

Due bug trovati per strada e chiusi:

- rescalePayments decideva con some(percent !== null): bastava UNA riga con
  percent per far entrare tutto il progetto nel ramo percentuale, che calcolava
  newTotal * 0 e azzerava ogni riga con percent NULL. splitPayment inserisce la
  Rata 2 proprio cosi', quindi splittare una rata e poi toccare il totale la
  portava a zero. Ora la regola e' per riga, non per progetto.
- Il selettore di schema fa DELETE+INSERT e cancellava in silenzio anche status e
  paid_at, con due rate gia' saldate in produzione. Ora chiede conferma, ma solo
  quando c'e' davvero storico da perdere.

Migration 0019 applicata a prod prima del push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:35:41 +02:00
simone df4671236a docs: distingue deployato da provato a mano
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:14:43 +02:00
simone 330749a883 docs: STATE.md rientra sotto le 100 righe
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:12:02 +02:00
simone 9a57e450fc docs: registra rinomina tassonomie e stato task "In revisione"
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:11:47 +02:00
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 97cc6460a0 docs: registra le modifiche hub e mette v2.5 in pausa dichiarata
STATE.md diceva "Phase 27, nessun bloccante" mentre v2.5 e' ferma per scelta e
in produzione e' andato altro. Ora dice cosa e' vero: v2.5 in pausa, modifiche
hub in corso, blocchi A/B/C1 in produzione, e due bloccanti scritti con cosa
manca e chi li sblocca — le credenziali API di TidyCal e LEAD_WEBHOOK_SECRET
su Coolify, senza la quale la route rifiuta tutti (fallimento chiuso voluto).

Sta di nuovo sotto le 100 righe: ci e' rientrato togliendo cio' che STATUS.md
gia' racconta per esteso, non accorciando i bloccanti.

REQUIREMENTS.md guadagna HUB-01..13, con lo stato reale: otto fatti, cinque no.
Fra quelli aperti c'e' anche la conferma del payload Elementor, che oggi e'
gestito in modo difensivo e non verificato sul campo — distinguere "scritto" da
"visto funzionare" e' il punto della regola.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:13:50 +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
81 changed files with 7225 additions and 929 deletions
+77
View File
@@ -0,0 +1,77 @@
# `.claude/` — la cartella di configurazione di ClientHub
Questo file si carica quando si lavora **dentro `.claude/`**. Spiega cosa va dove, e basta.
**Per il progetto comanda [`../CLAUDE.md`](../CLAUDE.md)**: stack, vincoli LOCKED, procedura
di deploy, accesso al DB, design system. Qui non si duplica niente di quello — una seconda
copia è il modo più veloce per averne due che si contraddicono.
## Cosa va in ogni cartella
| Cartella | Cosa ci va | Cosa **non** ci va |
|---|---|---|
| `rules/` | Regole caricate per contesto. Oggi: `memory-discipline.md`, importata da `../CLAUDE.md` | Documentazione di feature — quella sta in `STATUS.md` |
| `skills/` | **Solo** skill specifiche di ClientHub: `preventivo/`, `audit/` | Le skill globali (`/seo-audit`, `/copywriting`, `/docx`…) — stanno in `~/.claude/skills/` |
| `agents/` | **Solo** agenti specifici di ClientHub. Oggi vuota: quello dell'audit nasce col motore | I 18 agenti globali di `~/.claude/agents/` |
| `hooks/` | Script eseguibili richiamati da `settings.json` | Logica di prodotto |
| `plans/` | I piani delle milestone, versionati | Piani usa-e-getta di una singola sessione |
| `commands/`, `workflows/`, `projects/` | Vuote per ora, sono lo scheletro | — |
| `memory/` | Appunti di lavoro versionati nel repo | **La memoria persistente. Non è qui** → vedi sotto |
| `worktrees/` | Generata dagli strumenti | — |
### Agenti e skill globali non si copiano qui
Regola già fissata in [`../../CLAUDE.md`](../../CLAUDE.md): agenti e skill che valgono per
tutti i progetti vivono in `~/.claude/` e si invocano da qualunque cartella. Copiarne uno qui
crea due file destinati a divergere, e il primo a cambiare vince a caso.
In locale ci va solo ciò che **senza questo repo non ha senso**: le due skill qui sotto.
### `.claude/memory/` ≠ memoria persistente
Due posti diversi con lo stesso nome, e confonderli fa perdere il lavoro:
- **`.claude/memory/`** (questa cartella) — appunti versionati nel repo, li vede chiunque
faccia clone.
- **`~/.claude/projects/-Users-simonecavalli-Vault-IAMCAVALLI-hub/memory/`** — la memoria
persistente vera, un file per fatto più `MEMORY.md` come indice. Sta fuori dal repo, non
si committa, e viene iniettata in automatico a inizio sessione.
Ci va quello che **non si deduce dal repo**: perché una decisione è stata presa, un vincolo
operativo, una cosa provata che non funziona. Regole complete in
[`rules/memory-discipline.md`](rules/memory-discipline.md).
## Le skill del progetto
- **`/preventivo`** — l'attrezzo per lavorare sulla pipeline che genera i preventivi
(`src/lib/proposal/`) senza romperla. Non contiene un prompt di generazione: quello vive
in `agent.ts` ed è l'unico.
- **`/audit`** — fa girare le cinque fonti di `src/lib/audit/sources/` su un URL e dice cosa
è stato **misurato** e cosa no. Le fonti sono in produzione ma inerti: nessuna route le
chiama ancora.
## Gli hook attivi
Tutti e tre in [`settings.json`](settings.json). Si provano a mano prima di fidarsi.
| Hook | Quando | Cosa fa |
|---|---|---|
| Promemoria memoria | `Stop` | Se `src/` o `.planning/` hanno modifiche non committate, ricorda di aggiornare `STATE.md`. Non blocca |
| [`guardia-migration.sh`](hooks/guardia-migration.sh) | `PreToolUse` su Write/Edit in `src/db/migrations/` | **Blocca** l'SQL che cancella dati dalle entità protette (`clients`, `projects`, `payments`, `phases`). È il vincolo Data Safety LOCKED fatto rispettare dalla macchina |
| [`guardia-token.sh`](hooks/guardia-token.sh) | `PostToolUse` su `.tsx`/`.css` | **Avvisa** se compaiono classi Tailwind grezze o hex letterali. Non blocca: le eccezioni sanzionate esistono e stanno nella whitelist dello script |
Per provarli senza passare da Claude:
```bash
echo '{"tool_input":{"file_path":"src/db/migrations/9999_x.sql","content":"DROP TABLE payments;"}}' \
| .claude/hooks/guardia-migration.sh; echo "exit=$?" # atteso: 2
echo '{"tool_input":{"file_path":"src/x.tsx","content":"<div className=\"bg-slate-100\"/>"}}' \
| .claude/hooks/guardia-token.sh; echo "exit=$?" # atteso: 0 + avviso
```
## I piani
`plans/` contiene i piani della milestone v2.5, portati dentro il repo il 2026-08-26 perché
stavano solo in `~/.claude/plans/` e `STATE.md` avvertiva che senza quelli la milestone non
era ricostruibile. Dettaglio in [`plans/README.md`](plans/README.md).
View File
View File
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# PreToolUse su Write/Edit — vincolo Data Safety (LOCKED) di ../../CLAUDE.md.
#
# Blocca la scrittura di una migration che cancella dati dalle entita' protette.
# Non e' una prova: e' una rete. Un DROP scritto in modo esotico puo' passare —
# la revisione a occhio dell'SQL resta obbligatoria.
#
# exit 0 = passa · exit 2 = bloccato, il messaggio su stderr torna a Claude.
set -uo pipefail
INPUT=$(cat)
FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
# Fuori da src/db/migrations/ questo hook non ha voce in capitolo.
case "$FILE" in
*src/db/migrations/*) ;;
*) exit 0 ;;
esac
# Write porta `content`, Edit porta `new_string`. MultiEdit porta un array.
SQL=$(printf '%s' "$INPUT" | jq -r '
[ .tool_input.content?,
.tool_input.new_string?,
(.tool_input.edits? // [] | .[].new_string?)
] | map(select(. != null)) | join("\n")
')
[ -z "$SQL" ] && exit 0
PROTETTE='clients|projects|payments|phases'
# Via i commenti, tutto minuscolo, una riga per statement: cosi' "DROP" e il nome
# della tabella devono stare nella STESSA istruzione per far scattare il blocco.
PULITO=$(printf '%s' "$SQL" \
| sed -E 's/--.*$//' \
| tr '\n' ' ' \
| sed -E 's;/\*[^*]*\*+([^/*][^*]*\*+)*/; ;g' \
| tr '[:upper:]' '[:lower:]' \
| tr ';' '\n')
COLPEVOLI=$(printf '%s\n' "$PULITO" \
| grep -E 'drop[[:space:]]+table|drop[[:space:]]+column|truncate|delete[[:space:]]+from' \
| grep -E "\\b($PROTETTE)\\b" || true)
if [ -n "$COLPEVOLI" ]; then
{
echo "BLOCCATO — Data Safety (LOCKED, ../CLAUDE.md)."
echo
echo "Questa migration cancella dati da un'entita' protetta (clients, projects,"
echo "payments, phases). Le migration sono additive: si aggiungono colonne e"
echo "tabelle, non si tolgono righe."
echo
echo "Istruzioni incriminate:"
printf '%s\n' "$COLPEVOLI" | sed 's/^[[:space:]]*/ · /'
echo
echo "Se la rimozione serve davvero, e' una decisione da confermare a voce con"
echo "l'utente prima di scriverla — non da aggirare qui."
} >&2
exit 2
fi
exit 0
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# PostToolUse su .tsx/.css — regola cardinale del design system:
# solo token semantici (bg-card, text-muted-foreground, border-border).
#
# AVVISA, non blocca: le eccezioni sanzionate esistono (vedi WHITELIST) e il
# debito storico e' gia' di ~450 occorrenze. Bloccare renderebbe l'hook un
# ostacolo da disattivare invece di un promemoria da leggere.
#
# exit 0 sempre. Il messaggio su stderr arriva a Claude come contesto.
set -uo pipefail
INPUT=$(cat)
FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
case "$FILE" in
*.tsx|*.css) ;;
*) exit 0 ;;
esac
# Eccezioni sanzionate da ../CLAUDE.md § Design System:
# - StatusBadge: i colori di stato usano la palette con variante dark: esplicita
# - AdminShell: il verde brand della sidebar
# - mailer.ts / .html: l'HTML delle email non puo' usare variabili CSS
# - globals.css: e' il posto dove i token vengono *definiti*
# - design-reference/: i mock precedono la regola, si traducono non si copiano
case "$FILE" in
*StatusBadge*|*AdminShell*|*mailer*|*globals.css|*design-reference/*) exit 0 ;;
esac
TESTO=$(printf '%s' "$INPUT" | jq -r '
[ .tool_input.content?,
.tool_input.new_string?,
(.tool_input.edits? // [] | .[].new_string?)
] | map(select(. != null)) | join("\n")
')
[ -z "$TESTO" ] && exit 0
PALETTE='slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'
GREZZE=$(printf '%s\n' "$TESTO" \
| grep -oE "\\b(bg|text|border|ring|from|to|via|fill|stroke|divide|outline|shadow|accent|decoration|placeholder)-($PALETTE)-[0-9]{2,3}\\b" \
| sort -u | head -12 || true)
HEX=$(printf '%s\n' "$TESTO" \
| grep -oE '#[0-9a-fA-F]{3,8}\b' | sort -u | head -6 || true)
if [ -n "$GREZZE" ] || [ -n "$HEX" ]; then
{
echo "AVVISO design system — token semantici, non palette grezza."
echo "File: $FILE"
[ -n "$GREZZE" ] && { echo " classi grezze: $(printf '%s ' $GREZZE)"; }
[ -n "$HEX" ] && { echo " hex letterali: $(printf '%s ' $HEX)"; }
echo
echo "Usa bg-card / text-muted-foreground / border-border: e' quello che fa"
echo "funzionare chiaro e scuro sul solo toggle della classe .dark."
echo "Riferimento: design-reference/DESIGN-SYSTEM.md"
echo "Eccezione sanzionata? Aggiungi il file alla WHITELIST di questo hook."
} >&2
fi
exit 0
View File
+17
View File
@@ -0,0 +1,17 @@
# Piani
I piani delle milestone, **dentro il repo**. Ci sono arrivati il 2026-08-26: prima
stavano solo in `~/.claude/plans/`, con nomi generati a caso, e `STATE.md` avvertiva
che senza quei file v2.5 non era ricostruibile. Un piano che vive solo sul portatile
di chi l'ha scritto non e' documentazione, e' un ricordo.
| File | Cosa contiene | Origine |
|---|---|---|
| `v2.5-audit-documento.md` | Il documento di audit: tre livelli (Radiografia / Prima-Dopo / Rotta) come configurazioni di un unico documento su `/audit/[slug]` | `dovremmo-fare-una-cosa-woolly-puddle.md` |
| `v2.5-audit-motore.md` | Il motore: raccolta in parallelo, quattro sub-agent, sintetizzatore. Il vincolo che regge tutto — **un numero entra solo se misurato** | `vorrei-solo-farti-capire-radiant-valley.md` |
| `v2.5-modifiche-hub.md` | I blocchi A/B/C delle modifiche all'hub chieste il 2026-08-18 | `sei-arrivato-qua-search-recursive-kettle.md` |
Sono **piani, non stato**: dicono cosa era stato deciso di fare, non cosa e' fatto.
Per quello ci sono `STATUS.md` (narrativa) e `.planning/STATE.md` (digest).
Passati al setaccio per credenziali prima del commit: dentro compaiono nomi di
variabili d'ambiente, mai i loro valori.
+413
View File
@@ -0,0 +1,413 @@
# Audit — documento di restituzione
## Context
iamcavalli vende un servizio di analisi sito in tre livelli (La Radiografia / Il Prima-Dopo / La Rotta). Il deliverable è un documento di restituzione presentato in una call da 40 minuti. Oggi si fa a mano fuori dall'hub.
Il servizio si chiama **audit** → rotta `/audit/[slug]`. Il nome del livello acquistato compare **solo in copertina**.
```
acquisto Whop ──┐
├─► audit creato ─► intake dati ─► agent: analisi profonda
creazione manuale ┘ │
call (fissata a mano) ◄── consegna ◄── revisione + redesign (manuale)
```
Il redesign lo prepari tu mentre gli agent lavorano. **Deve essere possibile creare un cliente e far partire l'audit in manuale, senza acquisto.**
Volume: ~50 audit/anno. Overhead non-analitico sotto i 10 minuti per audit. Regola guida: *tu scrivi solo analisi e redesign, tutto il resto si popola*.
### Documento canonico
La **Spec V1** (blocchi, copy fisso, campi) è il documento canonico. L'Excel *Ecommerce Growthlist* è **rubrica interna del motore di analisi**, non struttura del documento — vedi §4.
### Verdetto di fattibilità
Fattibile. Tre pezzi da costruire da zero: **motore di analisi**, **PDF**, **hosting immagini**. Il resto ha precedenti diretti in casa.
### Conflitti risolti a favore del progetto
| Spec V1 | Qui | Perché |
|---|---|---|
| Supabase | Neon + Drizzle | Lo stack è quello |
| `uuid` | `text` + `nanoid()` | Convenzione di tutte le tabelle in `schema.ts` |
| PDF serverless headless | Print CSS | Deploy Docker su Coolify, non serverless. Stesso risultato, zero infra |
| `/r/[slug]?k=[token]`, slug = nome cliente | `/audit/[slug]`, slug nanoid nel path | **Sicurezza** (sotto) |
**Sullo slug.** `teckell-2026` è indovinabile: la segretezza si sposterebbe tutta sul token in query string, e i parametri di query finiscono nei log d'accesso e nei referrer molto più facilmente di un path. Il `Referrer-Policy: strict-origin-when-cross-origin` già presente mitiga in parte, ma la convenzione collaudata qui è **nanoid non indovinabile nel path, niente query** (`/preventivo/[slug]`, `/quote/[token]`). Un audit nomina un'azienda reale e ne elenca le debolezze: è il contenuto più sensibile che il portale pubblicherà.
**Due correzioni ad altre premesse:**
1. **I `gsd-audit-*` non c'entrano.** Verificato: `gsd-audit-milestone` verifica una milestone GSD, `gsd-audit-uat` raccoglie i test in sospeso, `gsd-audit-fix` sistema i finding nel *tuo* codebase. Auditano il progetto, non il sito del cliente. Motore custom; il precedente è `src/lib/proposal/agent.ts`.
2. **Il deck dei preventivi non è riusabile come layout.** `ProposalDeck.tsx:151` monta solo la slide corrente e `:133` imposta `body.overflow = "hidden"`: stamparlo produce una pagina sola. Il documento è **a scorrimento**. Si riusa schema e macchina a stati, non il guscio visivo.
### Decisioni prese
| Punto | Decisione |
|---|---|
| Motore | In-app, background, polling dall'admin (§5) |
| "La direzione" (blocco 8) | Manuale, foglio bianco, **con il materiale grezzo a fianco** |
| PDF | Print CSS — stesso DOM, vincolo "una sola fonte" per costruzione |
| Immagini | Volume persistente. **Modifica il vincolo LOCKED #5** (§9) |
| Redesign | Immagini caricate **e** link Figma, con ruoli distinti (§6) |
| Miglioramento nel tempo | Template versionato: tocca gli audit futuri, mai i consegnati (§3) |
| Copertina | Titolo = nome del livello acquistato |
| Scadenza | Nessuna. Depubblicazione manuale reversibile |
| Tracking | Prima apertura, ultima apertura, conteggio |
---
## 1 · Spike sul motore — si parte da qui
È la parte più incerta e quella su cui si regge tutto il resto. Se la qualità dell'analisi non regge, meglio scoprirlo prima di costruirci sopra schema, editor e documento.
**Non tocca il database, non tocca l'hub.** Uno script isolato, `scripts/spike-audit.ts`, sul sito di un cliente attuale, che stampa l'output grezzo.
Deve dimostrare che:
- Il fetch delle pagine chiave e l'estrazione del testo reggono su un sito reale
- La verifica delle voci di checklist è **affidabile e ripetibile** (§4)
- I problemi sono **concreti, non generici** — "il messaggio non è chiaro" non vale niente, "l'headline non nomina il destinatario" sì
- La conseguenza per il business è specifica
Da riusare subito: la protezione da prompt injection di `src/lib/proposal/agent.ts:32-35` (blocco SICUREZZA che dichiara il contenuto come dati, non istruzioni) e `:49` (neutralizzazione dei tag di chiusura). Con HTML scrapato da un sito esterno serve **più** che con le trascrizioni: è contenuto di terzi e può contenere istruzioni ostili.
> Il resto si esegue **dopo** esito positivo dello spike.
---
## 2 · Il documento
Un solo documento ben progettato; i tre livelli sono **configurazioni** di quello.
| # | Blocco | Radiografia | Prima/Dopo | Rotta |
|---|---|:--:|:--:|:--:|
| 1 | Copertina | ● | ● | ● |
| 2 | Sintesi | ● | ● | ● |
| 2b | **Cosa funziona già** | ● | ● | ● |
| 3 | Stato di fatto | ● | ● | ● |
| 4 | I problemi, per impatto | ● | ● | ● |
| 5 | Analisi per area | ● | ● | ● |
| 6 | Il redesign | — | ● | ● |
| 6b | **Cosa il redesign non risolve** | — | ● | ● |
| 7 | Le ottimizzazioni | — | — | ● |
| 8 | La direzione | ● | ● | ● |
| 9 | Come si prosegue | ● | ● | ● |
I blocchi non pertinenti **non esistono nel DOM**, non sono nascosti via CSS.
Il **copy fisso** di ogni blocco è quello della Spec V1, riportato integralmente in `template/v1.ts` (§3). Nel blocco 9 le *fasi già completate* si derivano dal livello acquistato, e il credito riconosciuto è `importo_pagato`.
**I due blocchi 2b e 6b sono aggiunte del prototipo Giojello, non della Spec V1**, e vanno tenute:
- *Cosa funziona già* costruisce credibilità prima di criticare, e dichiara cosa non va toccato
- *Cosa il redesign non risolve* è onesto e commercialmente più efficace della vendita: apre al progetto completo senza promettere
Campi: `punti_forza` (lista) e `redesign_limiti` (testo).
### Il prototipo Giojello è il riferimento del template v1
Il file HTML prodotto per Giojello **non è il documento: è la fonte di `template/v1.ts`** — copy, tipografia, gerarchia. Va spacchettato in componenti alimentati dal record. Quattro difetti da NON portarsi dietro:
1. **Contenuto cablato nel markup** (699€, 8,0s, 500€, le date). Viola il vincolo "un record → una pagina → un PDF": tutto viene dal DB
2. **Google Fonts via `<link>`** — la CSP del progetto è `font-src 'self' data:` e `style-src 'self' 'unsafe-inline'`: verrebbe **bloccato** e il documento cadrebbe sui font di sistema. **Font self-hostati** (Instrument Serif / Inter / IBM Plex Mono, se si conferma quel trio in luogo di Plus Jakarta Sans — è una deroga consapevole a DESIGN-SYSTEM.md, giustificabile perché è un documento pubblico, non la UI admin)
3. **Print CSS di sei righe.** Nasconde la legenda interna ma **non i colori che la legenda spiega**: i bordi e i testi rossi/blu dei blocchi di lavorazione finiscono stampati senza più nulla che li spieghi. I marcatori di lavorazione non devono esistere nel documento consegnato
4. **Colore come unico portatore di informazione nelle metriche**`3,4 s` rosso e `1,0 s` verde diventano identici in scala di grigi. Serve un secondo canale (glifo, peso, etichetta)
⚠️ **Tassonomia degli impatti.** Il prototipo usa cinque valori ("medio-alto", "basso-medio"…), lo schema ne prevede tre. Con cinque l'ordinamento automatico su tre non funziona. Decisione: si resta a **tre valori**, e la sfumatura sta nell'ordine dentro il gruppo (`sort_order`).
### Regola di collocazione: finding vs analisi
Il prototipo v2 ha aggiunto una sezione discorsiva "Interfaccia" con otto paragrafi che contengono almeno quattro **veri finding** — contrasto insufficiente sulla CTA della hero, riflesso che mangia il 40% dell'immagine prodotto, titoli troncati, e soprattutto il carrello (spedizione assicurata 39,90€ contro 6,13€ con la non assicurata preselezionata). Quest'ultimo è da primi cinque ed era in coda a una sezione discorsiva.
**Regola per il motore e per l'editor:** se una cosa ha un impatto e una conseguenza, è un finding — va nel blocco 4, prende un numero e viene ordinata. Il blocco 5 resta discorsivo e resta a **tre aree** (struttura / messaggio / conversione). Nessuna quarta area.
Motivo: il documento promette "non un elenco di quaranta punti", e una seconda lista non ordinata dopo quella ordinata annulla la promessa. La forza del documento è la **selezione**.
### Disciplina sui numeri — vincolo del motore
Confronto con un audit parallelo dello stesso sito prodotto da un altro modello: conteneva "~65% abbandono stimato" e "+60% velocità mobile immediata". **Nessuno dei due è misurato**: sono congetture presentate come rilevazioni.
Il prototipo Claude è disciplinato su questo — dice "una parte importante del traffico" proprio perché non può quantificarla. **Quella disciplina va imposta nel prompt di sistema del motore**: un numero compare nel documento solo se proviene da una rilevazione (PageSpeed, conteggio DOM, peso pagina, dati del sito). Mai stime di conversione o di guadagno percentuale. In un deliverable premium basta un cliente che verifichi per bruciare tutta la credibilità.
Voce di verifica: nessun numero nel documento consegnato è privo di fonte in `audit_runs.raw`.
### Contributi da assorbire dall'audit parallelo
- **La misura/taglia nella griglia prodotti** — su pezzi unici rigenerati è criterio di scelta primario e non compare da nessuna parte. Si aggancia al finding sull'unicità e apre a un servizio ("messa a misura disponibile"). Va nella checklist profilo `ecommerce` come voce
- **Conteggio dei nodi DOM** — quantifica il problema di peso meglio dei MB. Da aggiungere alle rilevazioni di `fetch.ts`
- **Stime in giornate** — alimentano la colonna *Impegno* del blocco 7
### Estensione opzionale — allegato tecnico (NON in fase 1)
Il blocco 9 promette *"il documento resta tuo e puoi darlo a chiunque lavorerà sul sito"*, ma consegna prosa da imprenditore a uno sviluppatore.
Un **allegato tecnico generato dallo stesso record** — stessi finding, registro da sviluppatore, con selettori, file coinvolti e stime — renderebbe quella promessa molto più preziosa. È una seconda vista sugli stessi dati, quindi non viola il vincolo della fonte unica e non richiede contenuto aggiuntivo. Da valutare dopo il primo audit consegnato.
---
## 3 · Versionamento del template
Requisito: migliorare il documento deve toccare gli audit **successivi**, mai quelli già consegnati.
Precedente in casa: `proposals.content` congela profilo e prezzi alla generazione, così modificarli dopo non retro-cambia un preventivo pubblicato.
Copy fisso e configurazione dei blocchi vivono in **moduli TS versionati**, non nel database:
```
src/lib/audit/template/
v1.ts copy fisso di ogni blocco + ordine + mappa livello→blocchi
index.ts registry versione→modulo + LATEST_TEMPLATE_VERSION
```
- `audits.template_version` impostata alla versione corrente **alla creazione**
- Un audit **consegnato resta congelato** sulla sua versione, per sempre
- Migliorare il documento = aggiungere `v2.ts` e alzare `LATEST_TEMPLATE_VERSION`. Nessuna migration, nessun backfill, storia completa in git
- Le **bozze** si portano all'ultima versione con un bottone esplicito, mai in automatico
---
## 4 · La checklist come rubrica del motore
L'Excel ha 264 voci su 7 step di funnel, con scoring ICE e peso di importanza per sezione. Serve al **motore**, non al documento.
**Perché è preziosa:** ogni voce è un'asserzione binaria e verificabile ("Il checkout consente l'acquisto come ospite"). Verificare 264 affermazioni falsificabili è molto più affidabile che chiedere a un modello "analizza questo sito": trasforma l'analisi da generativa a **verificativa**, controllabile voce per voce. E lo scoring ICE risolve gratis l'ordinamento del blocco 4 e le priorità del blocco 7.
**Tre vincoli emersi dalla lettura del file:**
1. **È al 73% ecommerce.** Generale (50) e Homepage (21) valgono per qualsiasi sito; Categoria, Scheda Prodotto, Carrello, Checkout, Ringraziamento — **193 voci su 264** — presuppongono un carrello. Servono **due profili**: `ecommerce` e `servizi`. Il secondo va scritto, non esiste nel file.
2. **Misura la conformità, non l'adeguatezza.** Un sito può fare 250/264 e continuare a descrivere un'azienda che non esiste più. Nessuna voce chiede se il posizionamento dichiarato corrisponde a quello che vendi oggi, o se le prove sono della fascia di cliente giusta. La checklist è il **pavimento** (guasti meccanici, li trova l'agent); lo scarto strategico è il **soffitto** e resta analisi tua. Se il documento diventa il rendering della checklist, torna a sembrare un audit automatico gratuito.
3. **Molte voci sono tattiche da ecommerce a volume** — scarsità, urgenza, countdown, popup di social proof. Su un brand premium **danneggiano**: abbassano il segnale di prezzo mentre tu vendi il contrario. Ogni voce porta quindi un campo `registro` (`volume` / `premium` / `neutro`) e l'audit di un brand premium non propone mai i trigger da discount.
⚠️ Note tecniche sul file: il foglio *Algoritmo* ha errori **`#REF!`**, e la colonna *Facilità* è **1.0 su tutte le righe** — non compilata, quindi lo Score attuale è di fatto solo Impatto × Confidenza. Da sistemare prima di seminarne i default.
Il foglio *Best Ecommerce List* (88 siti per settore) alimenta confronti concreti: "il tuo checkout chiede 11 campi, i riferimenti del settore ne chiedono 6".
**Nel documento** la checklist non compare come elenco. Il blocco 3 mostra al massimo il grado di conformità **per step di funnel** (una barra per step), accanto alle metriche di performance. I problemi del blocco 4 sono una **selezione curata** — voci non conformi ad alto impatto più lo scarto strategico — non il dump delle non conformità.
---
## 5 · Migration `0017_audits.sql`
Additiva e idempotente. Template di stile: `src/db/migrations/0016_retainer_lifecycle.sql` (header in italiano che spiega il *perché*, `ADD COLUMN IF NOT EXISTS`, `CHECK ... NOT VALID` dentro `DO $$ ... pg_constraint`, `CREATE INDEX IF NOT EXISTS`).
Convenzioni da `src/db/schema.ts`: id `text` con `$defaultFn(() => nanoid())`, **nessun `pgEnum`** (text + CHECK in SQL + tupla `as const` in TS + Zod nell'action), timestamp `withTimezone: true`, `updated_at` bumpato a mano nell'action.
**`audits`** — colonne scalari, non jsonb: non c'è snapshot da congelare (ci pensa `template_version`) e l'editor mappa 1:1.
- Identità: `id`, `slug` unique nanoid, `lead_id``leads` SET NULL, `client_id``clients` SET NULL (nullable entrambi, come `proposals`)
- Config: `livello` CHECK `('radiografia','prima_dopo','rotta')`, `template_version`, `profilo` CHECK `('ecommerce','servizi')`, `cliente_nome`, `cliente_referente`, `sito_url`, `importo_pagato numeric(10,2)`, `data_consegna date`
- Origine: `origin` CHECK `('manuale','whop')` default `'manuale'`, `external_ref` — predispone Whop senza costruirlo
- Stato: `state` CHECK `('draft','published')` default `'draft'` (etichettati *Bozza* / *Consegnata* nella UI), `published_at`
- Tracking: `first_viewed_at`, `last_viewed_at`, `view_count integer default 0`
- Rilevazioni (blocco 3): `perf_mobile`, `perf_desktop`, `lcp numeric(6,2)`, `cls numeric(5,3)`, `inp`, `pagine_indicizzate`, `screenshot_desktop_url`, `screenshot_mobile_url`, `measured_at`
- Contenuto (blocchi 2/2b/5/8): `sintesi`, `punti_forza jsonb` (lista, blocco 2b), `analisi_struttura`, `analisi_messaggio`, `analisi_conversione`, `direzione`
- Redesign (blocchi 6/6b): `redesign_sezione`, `redesign_prima_url`, `redesign_dopo_url`, `redesign_razionale`, `redesign_limiti`, `redesign_figma_url`
- Intake: `intake jsonb` — i dati che il cliente condivide, forma ancora da definire
**Tutti i campi di contenuto sono nullable.** È ciò che rende possibile "si salva sempre, anche a metà": la validazione di completezza scatta solo alla consegna.
**`audit_findings`** (blocco 4) — `audit_id` CASCADE, `titolo`, `impatto` CHECK `('alto','medio','basso')`, `area` CHECK `('struttura','messaggio','conversione','performance')`, `descrizione`, `conseguenza`, `screenshot_url`, `sort_order integer default 0`, `origin` CHECK `('agent','manuale')`
**`audit_optimizations`** (blocco 7) — `audit_id` CASCADE, `intervento`, `priorita` CHECK `('alta','media','bassa')`, `motivazione`, `impegno`, `sort_order integer default 0`
**`checklist_items`** (rubrica, versionata come il template) — `profilo`, `step`, `focus`, `testo`, `impatto_default`, `confidenza_default`, `registro` CHECK `('volume','premium','neutro')`, `sort_order`
**`audit_checklist_results`** — `audit_id` CASCADE, `item_id`, `esito` CHECK `('conforme','non_conforme','non_rilevante')`, `note`, `evidenza`, `origin` CHECK `('agent','manuale')`
**`audit_runs`** (§6) — `audit_id` CASCADE, `status` CHECK `('queued','running','done','error')`, `step`, `started_at`, `finished_at`, `heartbeat_at`, `error`, `raw jsonb` (output grezzo: materiale per il blocco 8)
Indici: unique su `slug`; index su `client_id`, `lead_id`, `(audit_id, sort_order)` per le figlie, `(audit_id, started_at desc)` per le run, `(audit_id, item_id)` unique per i risultati.
**Ordinamento automatico** (blocchi 4 e 7): la query ordina per rango di impatto/priorità con `sort_order` come spareggio *dentro* il gruppo. Tu non ordini niente.
> ⚠️ **Checkpoint bloccante.** Migration applicata in produzione **prima** di pushare il codice schema-dipendente (regola CLAUDE.md "Ordering"), via SSH/docker-exec. Aggiornare `src/db/schema.ts` a mano in parallelo — `drizzle-kit generate` è rotto.
---
## 6 · Motore di analisi
`src/lib/audit/` modellato su `src/lib/proposal/` ma multi-step:
```
src/lib/audit/
pipeline.ts funzione pura: (auditId) => risultato. Non conosce chi la chiama
agent.ts chiamate Anthropic, una per step di funnel
schema.ts validazione Zod dell'output
fetch.ts recupero pagine + estrazione testo + PageSpeed
```
**Passi:** recupero pagine chiave → metriche PageSpeed → verifica delle voci di checklist del profilo → sintesi dei problemi con impatto+area+descrizione+conseguenza.
Tutto l'output è **bozza**: finisce in `audit_checklist_results` e in `audit_findings` con `origin='agent'`. Tu rivedi e correggi prima di consegnare. Si automatizza il meccanico, **il giudizio resta tuo**.
**Il blocco 8 "La direzione" resta manuale, foglio bianco.** L'editor mostra a lato il materiale grezzo — problemi ad alto impatto e temi ricorrenti, da `audit_runs.raw`. Nessun testo proposto, nessun template. È il blocco che giustifica il prezzo: se diventa formula, il cliente lo sente.
**Esecuzione: in-app, background, con polling.**
- "Avvia analisi" → riga `audit_runs` con `status='queued'`, lancia la pipeline
- L'admin fa polling e mostra `step` corrente e avanzamento
- Progresso scritto a ogni passo: un'interruzione non perde il lavoro fatto
> ⚠️ **Rischio da mettere in conto.** Con `output: "standalone"` su singolo container Coolify, un redeploy **uccide un job in corso** e lascia una riga bloccata su `running`. Mitigazione: `heartbeat_at` aggiornato a ogni passo, le run senza heartbeat da N minuti vanno in `error`, e "Rilancia" riparte dall'ultimo passo completato. Non è un sistema a code: è deliberatamente il minimo che regge 50 audit/anno.
`pipeline.ts` è una **funzione pura riusabile**: oggi la chiama il bottone, domani il webhook Whop. Nessuna riscrittura.
---
## 7 · Storage immagini e redesign
Servono: 2 screenshot home, N screenshot dei problemi, 2 immagini redesign. Con URL esterni incollati a mano il budget dei 10 minuti non regge, e un URL morto uccide il documento del cliente.
**Volume persistente, non servizio esterno.** Nessuna dipendenza npm nuova, nessun account terzo, nessuna credenziale.
- Volume Coolify montato su `/app/uploads`
- Upload via **server action** che riceve `File` da `FormData`. Niente upload diretto dal browser: la CSP ha `connect-src 'self'`, un POST verso un host esterno sarebbe bloccato — passare dal nostro origin è l'unica strada e va bene così
- Validazione: whitelist MIME `image/png|jpeg|webp`, max 5MB, nome file `nanoid()` (mai l'originale)
- Lettura via `src/app/api/uploads/[...path]/route.ts` con guardia sul path traversal e `Cache-Control` lungo
- CSP invariata: serviamo dal nostro origin, coperto da `img-src 'self'`
> ⚠️ **Checkpoint bloccante.** Volume creato in Coolify prima del deploy, altrimenti gli upload si perdono a ogni redeploy.
Nuovo componente `ImageUploadField` — in tutto `src/` non c'è un solo `<input type="file">`.
**Il redesign usa entrambi i formati, con ruoli distinti:**
- **Immagini caricate** — rappresentazione canonica, quelle che si vedono nello slider e **le uniche che finiscono nel PDF**
- **Link Figma** — opzionale, "apri il redesign interattivo", nuova scheda
Perché non embeddare Figma: la CSP ha `default-src 'self'` senza `frame-src`, quindi l'iframe sarebbe bloccato; e un iframe **in stampa non produce nulla**, rompendo il vincolo "una sola fonte" proprio sul blocco che vale di più.
---
## 8 · Pagina pubblica e PDF
`/audit/[slug]`, modellata su `src/app/preventivo/[slug]/page.tsx`: `export const revalidate = 0`, fetch by slug, `notFound()` se assente.
**Differenze deliberate dal preventivo:**
- **Documento a scorrimento.** Nessun `h-screen`, nessun `overflow-hidden`, nessuna manipolazione di `body.style`
- **Rate limit**: aggiungere `/audit` al matcher di `src/proxy.ts` riusando `src/lib/rate-limit.ts`. Il preventivo non ce l'ha — non ereditare quell'omissione
- **noindex**: `X-Robots-Tag: noindex, nofollow` in `next.config.ts` più `metadata.robots`
- **Anteprima admin**: con `state === 'draft'` mostrare comunque il documento se `getServerSession` restituisce una sessione admin, altrimenti il placeholder. Non tocca LOCKED #4, che riguarda `/client/*`
- **Tracking**: alla prima render non-admin aggiornare `first_viewed_at` / `last_viewed_at` / `view_count`
Nuovi componenti in `src/components/public/audit/`, uno per blocco, server components salvo slider e bottone stampa. **Non riusare** le 20 sezioni di `public/proposal/sections/`: sono saldate a `ProposalContent` con copy hard-coded.
Da riusare: **`RichText.tsx`** per ogni testo libero (obbligatorio su output del modello — mai `dangerouslySetInnerHTML`), e le convenzioni visive esistenti (eyebrow `text-xs font-mono tracking-widest uppercase`, headline `text-5xl font-light`, card `border border-border rounded-xl p-8`).
⚠️ Il deck preventivi è light-only (`bg-white` hard-coded in `ProposalDeck.tsx:139,141,156`, `PricingSection.tsx:46`) e viola la regola dei token in due punti (`TimelineSection.tsx:5-9`, `ClosingSection.tsx:20,34`). **Non replicare quei difetti**: solo token semantici, dual light/dark.
**Leggibilità in B/N**: il livello di impatto non può dipendere dal colore. Etichetta + glifo — `●●● alto` / `●●○ medio` / `●○○ basso`.
### PDF via print CSS
Stessa pagina, stesso DOM, stesso record: il vincolo "una sola fonte" è soddisfatto per costruzione.
In `src/app/globals.css` — oggi **zero regole print** in 218 righe — un blocco `@media print`:
- Palette chiara forzata, `print-color-adjust: exact` dove serve
- `break-inside: avoid` su ogni card problema e riga della tabella; `break-before: page` sui blocchi maggiori
- Nascosti: bottone stampa, nav, controllo dello slider
- **Slider prima/dopo**: a schermo interattivo, in stampa due immagini impilate con etichetta. Tecnica: entrambe le `<img>` **sempre** nel DOM, clippate in overlay via CSS a schermo; in `@media print` si toglie il clip e si impila. Nessun ramo JS, nessuna divergenza possibile
- Bottone "Scarica PDF" sticky in alto a destra = client island con `window.print()`
---
## 9 · Admin
Mutazioni come Server Actions colocate in `actions.ts` (niente REST per l'admin).
- `src/app/admin/audit/page.tsx` — lista. Riusare `PageHeader`, `SearchInput`, badge di stato
- `src/app/admin/audit/nuovo/`**creazione manuale**: cliente o lead, livello, profilo, URL, importo → redirect all'editor
- `src/app/admin/audit/[id]/edit/page.tsx` — RSC sottile + client component
- `src/components/admin/audit/AuditEditor.tsx`
- `src/app/admin/audit/actions.ts`
- Voce in `AdminSidebar.tsx`
**Pattern editor: `saveOfferEditor` + `OfferEditorClient`** (`src/app/admin/offers/actions.ts:177-274`, `src/components/admin/offers/OfferEditorClient.tsx`) — unico precedente di salvataggio a payload intero con array di figli:
- Zod annidato con `z.array(findingSchema)` / `z.array(optimizationSchema)`, `id` opzionale sui figli
- Nell'action: update degli scalari, loop sui figli con **upsert per presenza di `id`**, delete delle righe sparite, `sort_order: index` assegnato server-side
- Client: array in `useState`, update immutabili via `.map`, `startTransition` + try/catch → `setSaveError`
- ⚠️ **Il re-sync degli id dopo il primo salvataggio** (`OfferEditorClient.tsx:93-98`): senza, i figli appena creati vengono re-inseriti invece che aggiornati al salvataggio successivo. È il bug più facile da introdurre qui
**Aggiungi / rimuovi / riordina righe**: non esiste nel codebase — `sort_order` oggi è write-once. `@dnd-kit/sortable` è già in `package.json` e usato in `KanbanBoard.tsx`: riusarlo, con l'indice dell'array che diventa `sort_order` al salvataggio.
**Campi condizionali**: redesign e ottimizzazioni non si renderizzano se il livello non li prevede.
**Bozza**: niente autosave (non esiste nel codebase, complessità sproporzionata). "Salva bozza" esplicito + avviso all'uscita con modifiche pendenti. Tutti i campi nullable. La **validazione di completezza scatta solo su "Consegna"**, sui soli blocchi pertinenti.
**Consegna / Ritira**: transizioni guardate `WHERE id = ? AND state = ?` come `publishProposal` (`src/app/admin/preventivi/actions.ts:117-127`). Entrambe reversibili.
**Automatismi** (budget 10 minuti): credito = `importo_pagato`, fasi già completate derivate dal livello, date da `published_at`/`measured_at`, ordinamento dei blocchi 4 e 7, analisi draftata dagli agent.
---
## 10 · Ingresso: manuale ora, Whop dopo
Fase 1 = **solo creazione manuale**, che resta comunque un requisito permanente.
Predisposto senza costruirlo: `origin` + `external_ref` sulla tabella, e `pipeline.ts` chiamabile da un webhook. L'intake del cliente è **ancora da definire** — la colonna `intake jsonb` accoglie qualunque forma prenderà, senza migration aggiuntive.
---
## 11 · Modifica al vincolo LOCKED #5 — richiede approvazione
`CLAUDE.md` oggi: `5. No file hosting v1 — documenti come URL esterni`
Nuova versione proposta:
```
5. No file hosting per i documenti — restano URL esterni.
Deroga (Phase 27, 2026-08-16): le immagini dell'audit (screenshot e
redesign) sono caricate su volume persistente e servite da
/api/uploads/[...path], con whitelist MIME e limite di dimensione.
Non estendere l'upload ad altre entità senza modificare questo vincolo.
```
---
## 12 · GSD e memoria di progetto
**Non c'è niente da costruire**: state file, roadmap e checkpoint automatico esistono già. `.planning/STATE.md` è il digest che comanda i comandi `/gsd-*`, `.planning/ROADMAP.md` traccia la posizione, `execute-plan.md` aggiorna STATE.md **dopo ogni piano**, e `.claude/rules/memory-discipline.md` è la regola scritta.
Due difetti reali:
1. **`.planning/config.json` non ha la chiave `hooks`.** Verificato: entrambi gli hook globali (`gsd-session-state.sh`, `gsd-phase-boundary.sh`) escono a vuoto senza `"hooks": { "community": true }`. Aggiungerla riattiva l'iniezione di STATE.md all'avvio sessione e il promemoria a ogni scrittura in `.planning/`. Una riga.
2. **`gsd-sdk` e `gsd-tools` non sono nel PATH** (verificato: `not found`). Le mutazioni automatiche di STATE.md in `execute-plan.md` non hanno backend e ricadono su edit manuali — è il motivo per cui le fasi 13 e 26 hanno SUMMARY ricostruiti a posteriori.
L'hook `Stop` in `.claude/settings.json` è solo advisory (`echo`) e guarda solo `src` e `.planning`: non nota modifiche a `STATUS.md` o `CLAUDE.md`.
**Inquadramento**: nuovo milestone **v2.5 — Audit** via `/gsd-new-milestone`. Numerazione progressiva e mai riusata (l'ultima è 26 ⇒ si parte da **27**), come impone `.planning/PROJECT.md`.
Da aggiornare a fine lavoro: `.planning/STATE.md`, `.planning/ROADMAP.md`, `STATUS.md`, `CLAUDE.md`.
---
## 13 · File toccati
**Nuovi**`scripts/spike-audit.ts` · `src/db/migrations/0017_audits.sql` · `src/lib/audit/{pipeline,agent,schema,fetch}.ts` · `src/lib/audit/template/{v1,index}.ts` · `src/lib/audit-queries.ts` · `src/lib/audit-view.ts` · `src/app/audit/[slug]/page.tsx` · `src/components/public/audit/*` · `src/app/admin/audit/**` · `src/components/admin/audit/AuditEditor.tsx` · `src/components/ui/ImageUploadField.tsx` · `src/app/api/uploads/[...path]/route.ts`
**Modificati**`src/db/schema.ts` · `src/proxy.ts` · `next.config.ts` · `src/app/globals.css` · `src/components/admin/AdminSidebar.tsx` · `CLAUDE.md` · `.planning/config.json` · `Dockerfile` + volume Coolify
---
## 14 · Verifica
Non esiste test suite (nessun vitest/jest/playwright, nessuno script `test`). **`npm run build` è la verifica di riferimento** — fa il typecheck. `npm run lint` è `eslint` nudo.
**Fase 1 (spike)** — gira su un sito reale, i problemi sono concreti e verificabili, la valutazione delle voci di checklist è ripetibile. È un giudizio tuo, non un test automatico.
**Dopo:**
1. `npm run build` — typecheck e compilazione
2. Migration applicata in prod **prima** del push; verificare che le sei tabelle esistano
3. Un audit di prova per ciascuno dei tre livelli, su entrambi i profili: i blocchi condizionali compaiono e spariscono correttamente
4. Salvare a metà, ricaricare, nulla si perde. Salvare due volte di fila: i figli si aggiornano e **non si duplicano** (test del re-sync degli id)
5. Riordinare i problemi, salvare, ricaricare: l'ordine regge
6. Avviare un'analisi e **riavviare il container a metà**: la run va in `error` e "Rilancia" riparte senza perdere i passi completati
7. Upload di un'immagine → redeploy → l'immagine è ancora lì (prova del volume)
8. Slug consegnato in incognito: si apre. Bozza in incognito: placeholder. Bozza da admin loggato: si vede
9. `X-Robots-Tag: noindex, nofollow` presente nella risposta
10. **Stampa**: Cmd+P sul documento consegnato → interruzioni di pagina corrette, slider impilato in due immagini, nessun marcatore di lavorazione (bordi/testi colorati) sopravvissuto, e in **scala di grigi** sia i livelli di impatto sia i valori delle metriche restano distinguibili
10b. **Rete disattivata dopo il primo caricamento**: i font restano quelli giusti (prova che sono self-hostati e che nessuna risorsa esterna è rimasta)
11. Il tracking apertura si incrementa da visitatore e **non** dalla preview admin
12. Consegnato un audit su `v1`, alzare `LATEST_TEMPLATE_VERSION` a `v2`: il consegnato continua a rendere `v1`, uno nuovo nasce `v2`
13. Un audit su brand premium **non** propone mai voci con `registro='volume'`
⚠️ Playwright non funziona contro `npm run dev` (la CSP blocca `eval`, i client component non si idratano). Per E2E usare il build di produzione.
+200
View File
@@ -0,0 +1,200 @@
# Audit — motore multi-agente, rilevazioni e tracciamento
## Context
Il piano approvato il 2026-08-16 (`dovremmo-fare-una-cosa-woolly-puddle.md`) resta in vigore per struttura del documento, versionamento del template, rubrica checklist, editor admin, pagina pubblica e print CSS. **Questo file ne sostituisce tre parti** — §6 (motore), §7 (immagini) e il tracciamento in §5/§8 — e non tocca il resto.
Cosa è successo da allora:
- **Lo spike è girato su giojello.com** e ha prodotto un audit di buona qualità. Ma `psi: {}` — le rilevazioni PageSpeed sono fallite tutte per quota anonima esaurita. Quell'audit è stato scritto **con zero misurazioni**, solo HTML statico.
- **Il 52% della checklist non è verificabile da HTML statico** (204 voci → 107 non verificabili; lo step `generale` 40 su 50). Barra di ricerca, widget carrello, hover, animazioni, banner cookie esistono solo a runtime.
- **Il VPS non può ospitare Chromium**: 1.492 MB di RAM disponibili su 3.819, 736 MB già in swap, 2 vCPU, con Coolify/n8n/Gitea/Vaultwarden sopra. Il disco invece è libero (21 GB su 38). Immagine `node:20-alpine`, non supportata da Playwright.
- **Il processo bersaglio è: incolli un URL, il motore fa ricerche incrociate con le API gratuite, esce un documento pronto da inoltrare.** Tu carichi a mano solo due immagini: hero prima e hero dopo, JPG ≤ 0,5 MB.
### Decisioni
| Punto | Decisione | Data |
|---|---|---|
| Renderer headless | **Nessuno, da nessuna parte.** Motore 100% in-app, self-service | 2026-08-18 |
| Buco a runtime | Coperto da audit Lighthouse completi + screenshot renderizzato letto in visione + CrUX + Wayback | 2026-08-18 |
| Immagini manuali | Solo le due del redesign, JPG ≤ 0,5 MB | 2026-08-18 |
| Screenshot stato di fatto | Automatici, da PageSpeed | 2026-08-18 |
| Tracciamento | Registro delle singole visite + evento stampa | 2026-08-18 |
**Due decisioni del 2026-08-17 sono superate da questa revisione** e vanno corrette prima di eseguire (§8): il renderer locale sul Mac e la frase "la copia master è la cartella di cattura locale" scritta in `CLAUDE.md` — non esiste più nessuna cattura locale.
---
## 1 · Prerequisito bloccante — chiave PageSpeed e verifica dell'ipotesi
Tutto il resto poggia su un'ipotesi **non ancora verificata**: la quota anonima risponde `429` e senza chiave non si prova. Primo passo, prima di scrivere codice:
1. Creare un progetto Google Cloud, abilitare *PageSpeed Insights API* e *Chrome UX Report API*, generare una chiave (gratuita, nessuna carta). In `.env.local` e in Coolify come `PAGESPEED_API_KEY`.
2. Una chiamata a mano, e **verificare che esistano davvero**:
- `lighthouseResult.fullPageScreenshot.screenshot.data``data:image/jpeg;base64,…` a pagina intera
- `lighthouseResult.audits` → l'insieme completo (~150 voci), non i 10 scalari che lo spike estrae oggi
**Se lo screenshot a pagina intera non c'è**, ripiego su `lighthouseResult.audits["final-screenshot"]` (solo viewport, quindi di fatto l'hero — che è comunque il blocco più importante). **Se non c'è nemmeno quello**, l'analisi visiva esce dalla fase 1 e gli screenshot dello stato di fatto tornano manuali: cambia §5 di questo piano, non il resto.
> ⚠️ Non costruire il sub-agent visivo prima di aver visto quel campo con i tuoi occhi.
---
## 2 · Le fonti — "tutte le api gratuite che servono"
| Fonte | Cosa dà | Chiave | Alimenta |
|---|---|---|---|
| **PageSpeed Insights v5** (mobile + desktop) | ~150 audit Lighthouse, punteggi, screenshot renderizzato a pagina intera | sì, gratuita | blocco 3, finding tecnici, sub-agent visivo |
| **CrUX API** | LCP/INP/CLS **p75 di utenti reali**, per origin e per URL | stessa chiave | blocco 3 — la distinzione lab/campo |
| **Wayback CDX** | storico degli snapshot, diff del testo hero rispetto a 1/3/5 anni fa | no | la tesi "l'azienda è cresciuta, il sito no" |
| **RDAP** (`rdap.org`) | età del dominio | no | contesto in blocco 3 |
| **Mozilla HTTP Observatory** | voto sugli header di sicurezza | no | segnali di fiducia |
| **Fetch diretto** | `robots.txt`, `sitemap.xml` (→ `pagine_indicizzate`), JSON-LD, `hreflang`, impronta della piattaforma | no | blocco 3, finding tecnici |
| **Le pagine del sito** | già nello spike: home + fino a 3 pagine per profilo | no | verifica checklist |
**Perché questo elenco e non altro:** ognuna di queste è una **rilevazione**, non una stima. È esattamente ciò che impone la disciplina sui numeri del piano approvato — un numero entra nel documento solo se misurato. CrUX in particolare porta dati di utenti veri, la cosa più difendibile che si possa scrivere in un audit.
**Il guadagno più grande è già in casa e viene buttato via:** lo spike chiama PageSpeed e ne estrae 10 numeri. Gli audit Lighthouse coprono da soli una fetta consistente del 52% non verificabile — `color-contrast`, `tap-targets`, `font-size`, `image-alt`, `link-text`, `crawlable-anchors`, `structured-data`, `unsized-images`, `errors-in-console`, `viewport`, `canonical`. Sono verifiche fatte **sul DOM renderizzato**, cioè proprio quello che l'HTML statico non vede.
---
## 3 · Il motore
`src/lib/audit/`, modellato su `src/lib/proposal/` ma multi-step e con fan-out:
```
src/lib/audit/
pipeline.ts orchestratore: (auditId) => risultato. Non sa chi lo chiama
sources/ raccolta dati, nessun LLM
fetch.ts pagine + estrazione testo (dallo spike, quasi invariato)
pagespeed.ts PSI: audit completi + screenshot + estrazione scalari
crux.ts dati di campo
history.ts Wayback CDX + diff
signals.ts RDAP, header, robots/sitemap/JSON-LD
agents/ un file per sub-agent, ognuno con prompt e schema Zod propri
checklist.ts verificatore, Sonnet, batch da 12 per step di funnel
visual.ts analisi visiva, Opus con input immagine
history.ts scarto fra il sito di allora e l'azienda di oggi, Sonnet
technical.ts audit Lighthouse + header + dati strutturati, Sonnet
synthesis.ts sintetizzatore, Opus
schema.ts Zod per ogni output di sub-agent + per la sintesi
```
**Sequenza.** Raccolta in parallelo (I/O di rete puro) → quattro sub-agent in parallelo, ognuno sul proprio materiale → sintetizzatore che riceve i quattro output e **incrocia**.
**Cosa vuol dire "incrociate", in concreto.** I sub-agent producono osservazioni, non finding. È il sintetizzatore che le fonde:
> checklist: "nessun segnale di fiducia sulla scheda prodotto" + tecnico: "nessun dato strutturato `Product`/`Review`" + storico: "il testo dell'hero non cambia dal 2021" + visivo: "le recensioni sono sotto tre schermate di scroll"
> → **un solo finding**, con quattro evidenze indipendenti, invece di quattro finding deboli.
Questo serve la regola di selezione del piano approvato: il documento promette "non un elenco di quaranta punti", e la forza sta nella selezione. Massimo 10 problemi, ordinati per impatto.
**Tre cose che lo spike non fa e vanno fatte:**
1. **Validazione Zod di ogni output di modello.** Lo spike fa `as Record<string, unknown>` e stampa. `proposal/schema.ts` è il precedente: `safeParse`, fallimento duro, nessun loop di riparazione.
2. **Concorrenza limitata e retry.** Fan-out sì, ma con un tetto (4 chiamate contemporanee) e un retry con backoff sulle 429/529 di Anthropic. Lo spike non ha né l'uno né l'altro.
3. **Heartbeat a ogni passo.** `audit_runs.heartbeat_at`; una run senza battito da N minuti va in `error`, "Rilancia" riparte dall'ultimo passo completato. È la mitigazione già prevista per il redeploy che uccide il job.
**Da riusare invariati dallo spike:** i blocchi `SICUREZZA` e `NUMERI`, la funzione `fence()` che neutralizza i tag di chiusura, e l'estrazione del primo blocco `text` con guardia su `stop_reason === "max_tokens"` (più robusta di `content[0]` in `proposal/agent.ts`).
**Impronta sul VPS:** I/O di rete e JSON. Nessun processo pesante, nessun Chromium. Il picco è la dimensione dello screenshot in memoria prima dell'invio ad Anthropic — vedi §7.
---
## 4 · Schema — differenze rispetto a §5 del piano approvato
La migration resta `0017_audits.sql`, additiva e idempotente, applicata in prod **prima** del push. Rispetto all'elenco già approvato:
**Nuova tabella `audit_visits`** — il registro delle singole visite:
`id`, `audit_id` CASCADE, `occurred_at` default now, `event` CHECK `('view','print')`, `referrer`, `user_agent`, `ip_hash`. Indice su `(audit_id, occurred_at desc)`.
L'IP **non si salva in chiaro**: SHA-256 di `ip + NEXTAUTH_SECRET` via Web Crypto, come già si fa per il digest del gate admin in `src/lib/admin-gate.ts`. Serve a distinguere due aperture dello stesso lettore da due lettori diversi, non a identificare qualcuno.
**Campi aggiunti su `audits`** — separare laboratorio e campo, perché è la distinzione che rende credibile il blocco 3:
`lcp_field`, `inp_field`, `cls_field` (numeric, da CrUX, nullable — un sito senza traffico sufficiente non ha dati di campo, ed è un'informazione anch'essa).
**Restano come da piano approvato** le colonne di roll-up `first_viewed_at` / `last_viewed_at` / `view_count`: sono la lettura veloce per la lista admin, aggiornate insieme alla riga di `audit_visits`.
**`screenshot_desktop_url` / `screenshot_mobile_url`** ora li scrive la pipeline, non tu.
---
## 5 · Immagini
**Due upload manuali per audit**, non di più: `redesign_prima_url` e `redesign_dopo_url`. JPG, limite **512 KB**. Il componente `ImageUploadField` accetta `image/jpeg|png|webp` — non ha senso rifiutare un PNG per principio — ma il limite di dimensione è quello.
**Due file scritti dalla pipeline**: gli screenshot PageSpeed mobile e desktop, decodificati da base64 e salvati come JPG sullo stesso volume.
Quattro file per audit, circa 1,5 MB. Il volume Coolify su `/app/uploads` e la lettura via `src/app/api/uploads/[...path]/route.ts` con guardia sul path traversal restano come da §7 del piano approvato — con un margine molto più comodo di quanto si era dimensionato.
> ⚠️ **Checkpoint bloccante.** Volume creato in Coolify **prima** del deploy, altrimenti gli upload si perdono a ogni redeploy.
---
## 6 · Tracciamento
Un'isola client `<AuditVisitTracker>` montata nella pagina pubblica chiama una server action fire-and-forget:
- **all'mount** → evento `view`
- **su `window.print()`** e sull'evento `beforeprint` → evento `print`
La server action, lato server, chiama `getServerSession`: **se c'è una sessione admin non scrive niente.** È così che l'anteprima admin non inquina i numeri — requisito già scritto nella verifica del piano approvato (test 11).
Perché un'isola client e non una scrittura nel render RSC: la pagina ha `revalidate = 0` e quindi ri-esegue a ogni richiesta, ma scrivere sul DB dentro un render è un anti-pattern Next e conterebbe anche i prefetch e i bot. La convenzione del progetto è comunque "le mutazioni sono Server Actions".
Lato admin: il registro visite compare nell'editor dell'audit, sotto forma di elenco cronologico.
---
## 7 · Rischi da mettere in conto
- **Lo screenshot a pagina intera può essere altissimo** (una home lunga arriva a 10-15.000 px). Va ridimensionato prima di mandarlo a Opus, o mandato a fette. Non risolto: da misurare sul primo audit vero.
- **Latenza PageSpeed**: 30-60 s per strategia, due strategie. Il job in background lo regge, ma incide sul tempo totale — stimare il totale della pipeline dopo il primo giro completo, non prima.
- **Wayback e Observatory sono lenti e ballerini.** Ogni fonte deve fallire in modo non fatale, come già fa PageSpeed nello spike (`psi[s] = null` e si prosegue).
- **CrUX non risponde per i siti a basso traffico.** Non è un errore: è un dato. Il documento deve saperlo dire ("non ci sono abbastanza visitatori perché Google raccolga dati di campo") invece di lasciare un buco.
- **La ripetibilità è già stata vista traballare**: lo step `generale` ha dato 0 non conformi nel giro completo e 2 in quello isolato, a parità di sito e voci. Con il fan-out il rischio non diminuisce. Da tenere sotto osservazione al primo audit di prova.
---
## 8 · Da correggere prima di eseguire
Due cose scritte ieri sono ora sbagliate:
1. **`CLAUDE.md`, deroga a LOCKED #5** — dice "la copia master è la cartella di cattura locale". Non esiste più nessuna cattura locale. Sostituire con la formulazione di §11 del piano approvato, che è già corretta e non parla di copie locali.
2. **`.planning/STATE.md`** — la decisione "il renderer headless gira in LOCALE sul Mac" va riscritta come "nessun renderer headless: il buco a runtime è coperto dagli audit Lighthouse e dallo screenshot PageSpeed". Il *perché* il VPS non regge Chromium resta valido e va tenuto: è la ragione per cui l'opzione non tornerà.
Stessa correzione nel file di memoria persistente `project_clienthub_vps_no_headless.md`.
---
## 9 · File toccati
**Nuovi**`src/db/migrations/0017_audits.sql` · `src/lib/audit/pipeline.ts` · `src/lib/audit/sources/{fetch,pagespeed,crux,history,signals}.ts` · `src/lib/audit/agents/{checklist,visual,history,technical,synthesis}.ts` · `src/lib/audit/schema.ts` · `src/lib/audit/template/{v1,index}.ts` · `src/lib/audit-queries.ts` · `src/app/audit/[slug]/page.tsx` · `src/components/public/audit/*` (uno per blocco, più `AuditVisitTracker`) · `src/app/admin/audit/**` · `src/components/admin/audit/AuditEditor.tsx` · `src/components/ui/ImageUploadField.tsx` · `src/app/api/uploads/[...path]/route.ts`
**Modificati**`src/db/schema.ts` · `src/proxy.ts` (aggiungere `/audit` al matcher, con `rateLimit`) · `next.config.ts` (`X-Robots-Tag: noindex, nofollow`) · `src/app/globals.css` (blocco `@media print`) · `src/components/admin/AdminSidebar.tsx` · `CLAUDE.md` · `.planning/STATE.md` · Dockerfile + volume Coolify
**Riusati senza modifiche**`src/lib/rate-limit.ts` · `src/components/public/proposal/RichText.tsx` · `src/lib/admin-gate.ts` (per l'hash) · `src/app/admin/offers/actions.ts` come modello di editor a payload intero · `@dnd-kit/sortable` per il riordino
---
## 10 · Verifica
Non c'è test suite: **`npm run build` è la verifica di riferimento** (fa il typecheck).
**Prima di tutto** — la chiave PageSpeed funziona e `fullPageScreenshot` esiste davvero (§1). Se non esiste, fermarsi e rivedere §5.
Poi, nell'ordine:
1. `npm run build` pulito
2. Migration applicata in prod **prima** del push; le sette tabelle esistono
3. Un audit completo su giojello.com: **confrontare la quota di non verificabili con il 52% dello spike**. È la misura che dice se l'approccio senza Chromium ha funzionato
4. Ogni numero nel documento consegnato ha una fonte rintracciabile in `audit_runs.raw`. Nessuna percentuale inventata
5. Un sito senza dati CrUX: il documento lo dice, non lascia un buco
6. Riavviare il container a metà analisi: la run va in `error`, "Rilancia" riparte senza rifare i passi completati
7. Upload di un'immagine → redeploy → l'immagine è ancora lì (prova del volume)
8. Aprire il documento consegnato in incognito: compare una riga in `audit_visits`. Aprirlo da admin loggato: **non** compare. Premere "Scarica PDF": compare una riga `print`
9. Cmd+P sul documento: interruzioni di pagina corrette, slider impilato in due immagini, nessun marcatore di lavorazione sopravvissuto, e in **scala di grigi** impatti e metriche restano distinguibili
10. Rete disattivata dopo il primo caricamento: i font restano quelli giusti (prova che sono self-hostati)
11. I test 3, 4, 5, 8, 12 e 13 del piano approvato — blocchi condizionali per livello, salvataggio a metà, re-sync degli id dei figli, riordino, congelamento del template, e nessuna voce `registro='volume'` su un brand premium
⚠️ Playwright non funziona contro `npm run dev` (la CSP blocca `eval`). Vale solo se un giorno servirà un E2E sul portale: usare il build di produzione.
+319
View File
@@ -0,0 +1,319 @@
# Modifiche all'hub — dashboard, progetti, pipeline
## Contesto
Il 2026-08-18 hai elencato una serie di modifiche all'hub divise per sezione
(dashboard, progetti, pipeline). La sessione si è fermata sul limite settimanale
subito dopo aver lanciato l'esplorazione, e quel lavoro non è mai stato ripreso.
Questo piano lo raccoglie.
Il punto di partenza è che **quasi tutto quello che chiedi è calcolabile con i
dati che l'hub già ha**: le categorie di offerta esistono, le fasi e i task hanno
uno stato, i pagamenti hanno una data di incasso, le conversazioni sono già
aggregate. Serve poco schema nuovo — una sola migration additiva — e molta
query + UI.
L'eccezione è l'ingresso da Whop per gli audit: dipende dal motore di analisi
della v2.5, che è fermo alle sole fonti. Hai scelto di fare **prima le modifiche
hub e poi il motore**, quindi quel blocco resta documentato ma non costruito.
### Decisioni prese
| Domanda | Scelta |
|---|---|
| Sequenza | Hub prima, motore audit dopo |
| Fonte "Audit" negli analytics | È la tua **Entry Offer** — si legge da `offer_macros.category`, come Signature e Retainer |
| Data di consegna attesa | **Derivata** da offerta + durata, con **override manuale** opzionale |
| Ingresso lead | TidyCal + form sito — vedi il blocco C, dove c'è un vincolo che cambia le carte |
---
## Blocco A — Progetti
Il blocco a rischio più basso e a resa più immediata. Da fare per primo.
### A1 · Togliere il tab Commenti e il timer dalla lista
Due rimozioni chieste esplicitamente.
- **Tab "Commenti"** in [page.tsx:75](src/app/admin/projects/[id]/page.tsx#L75) e
[page.tsx:118-120](src/app/admin/projects/[id]/page.tsx#L118-L120), più l'import a riga 8.
**Non si perde niente**: ho verificato che `buildEntityMap()` in
[conversations-queries.ts:57](src/lib/conversations-queries.ts#L57) cammina
clienti → progetti → fasi → task → deliverable e raccoglie *tutti* i commenti
con la loro etichetta d'entità. `/admin/conversazioni` è un sovrainsieme
stretto di quel tab. Il campo `comments` in `ProjectFullDetail` diventa morto:
va tolto anche da [admin-queries.ts:567](src/lib/admin-queries.ts#L567) e dalla query.
- **Colonna "Timer"** dalla lista progetti: `<TimerCell>` in
[ProjectRow.tsx:85](src/components/admin/ProjectRow.tsx#L85) e l'intestazione in
[projects/page.tsx:47](src/app/admin/projects/page.tsx#L47). Il timer resta dove
ha senso, dentro il progetto.
### A2 · Mini-dashboard nel singolo progetto
Una striscia sopra i tab, prima di `<Tabs>` in
[projects/[id]/page.tsx:69](src/app/admin/projects/[id]/page.tsx#L69). **Nessuna
query nuova**: `getProjectFullDetail` restituisce già tutto.
**Finance** — incassato / contrattualizzato / residuo, da `payments` +
`offersAcceptedTotal`; ore tracciate e €/h reale da `totalTrackedSeconds` e
`targetHourlyRate` (già passato alla pagina a riga 24).
**Avanzamento** — task `done` su totale, con barra. Stessa aritmetica del
`progress_pct` già usato per le fasi.
Riusa il pattern `MetricCard` di [admin/page.tsx:18](src/app/admin/page.tsx#L18):
va estratto in `src/components/admin/MetricCard.tsx` e importato da entrambe.
Scrivere la striscia **a token semantici** (`bg-card`, `text-muted-foreground`) —
questa route è il cluster peggiore di DEBT-01 (~182 occorrenze di palette raw) e
non va peggiorata.
### A3 · Timer per task e fase
Oggi `time_entries` ha solo `project_id`
([schema.ts:245](src/db/schema.ts#L245)). Servono due colonne nullable.
- **Migration `0018`** (dettagli in fondo): `phase_id`, `task_id` su
`time_entries`, entrambe `ON DELETE SET NULL`. Cancellare un task **non deve**
cancellare il tempo tracciato: è storico fatturabile, l'entry ricade a livello
progetto. `project_id` resta obbligatoria, quindi ogni entry è sempre
attribuita.
- **Server actions** in [timer-actions.ts](src/app/admin/timer-actions.ts):
`startTimer` prende `phaseId`/`taskId` opzionali; stessa cosa per
`addManualTimeEntry`. La regola "un solo timer attivo alla volta" (righe 19-38)
resta com'è — vale globalmente, non per task.
- **UI**: `<TimerCell>` su ogni riga task dentro
[PhasesTab.tsx](src/components/admin/tabs/PhasesTab.tsx), con il totale della
fase come somma dei suoi task. `TimerTab` continua a mostrare il totale progetto.
---
## Blocco B — Dashboard
### B1 · Analytics per linea di prodotto
Tre righe — **Entry (Audit), Signature, Retainer** — lette da
`offer_macros.category`, che è già una taxonomy editabile da
`/admin/impostazioni` ([taxonomy.ts:37](src/lib/taxonomy.ts#L37)). In produzione
oggi ci sono esattamente quelle tre categorie.
Per categoria:
- **Iniziati questo mese** — `count(project_offers)` con `start_date` nel mese corrente.
- **Totale anno** — `count` + somma `accepted_total` con `start_date` nell'anno.
- **Incassato** — `payments` in stato `saldato` con `paid_at` nell'anno.
L'incasso ha un problema di attribuzione da risolvere esplicitamente: i pagamenti
stanno sul **progetto**, non sull'offerta. La regola:
1. progetto con **una** offerta → tutto l'incasso va a quella categoria;
2. progetto con **più** offerte → ripartito in proporzione ai rispettivi `accepted_total`;
3. progetto **senza** offerta → riga separata **"Senza offerta"**, mostrata a video.
Il terzo caso non va nascosto. Oggi in prod ci sono 5 progetti e 2 righe in
`project_offers`: la maggior parte dell'incassato finirebbe lì, e vederlo è il
modo per accorgersene.
Nuovo modulo `src/lib/product-analytics.ts` (non gonfiare `analytics-queries.ts`),
nuovo componente `src/components/admin/dashboard/ProductBreakdown.tsx`.
> **Nota da verificare al primo giro:** l'unica Entry Offer in produzione si
> chiama *"Sblocca Business"*. Se l'audit è un prodotto diverso, va creata la sua
> `offer_macro` con categoria `Entry Offer` — è configurazione, non codice.
### B2 · Timeline delle consegne
Riguarda i progetti che **si consegnano** — Entry e Signature. I retainer sono
continuativi e non hanno una consegna: restano fuori, filtrando su
`offer_macros.offer_type = 'una_tantum'`.
Per ogni progetto non archiviato:
- **Consegna attesa** = `projects.due_date` se valorizzata, altrimenti
`project_offers.start_date + offer_micros.duration_months`. La colonna
`due_date` arriva con la migration 0018 ed è l'override manuale che hai chiesto.
- **% completamento** = task `done` / task totali.
- **% tempo trascorso** = `(oggi inizio) / (consegna inizio)`.
- **Semaforo** — soglia ±10 punti: `in linea` dentro la banda, `in ritardo` se il
completamento è sotto, `in anticipo` se è sopra. Un progetto oltre la data di
consegna e non completo è `in ritardo` a prescindere.
I progetti senza scadenza calcolabile vanno elencati sotto come **"senza
scadenza"** invece di sparire — altrimenti un progetto senza offerta assegnata
diventa invisibile proprio nella vista che dovrebbe segnalarlo.
Query in `src/lib/delivery-queries.ts`, componente
`src/components/admin/dashboard/DeliveryTimeline.tsx`. `StatusBadge` per il
semaforo, Geist Mono per date e percentuali.
### B3 · Inbox
**Questo esiste già.** [MessagesWidget.tsx](src/components/admin/dashboard/MessagesWidget.tsx)
mostra i clienti con messaggi non letti, l'anteprima dell'ultimo messaggio e il
link "Rispondi →" verso `/admin/conversazioni`. È il widget stretto in colonna
1/3, in fondo: probabilmente non l'hai visto perché sta sotto la piega.
Quindi non si costruisce, si promuove:
- fascia **a piena larghezza in cima** alla dashboard, sopra i KPI, e solo quando
c'è almeno un non letto;
- 6 righe invece di 4, con **data relativa** e **etichetta dell'entità**
(`lastMessageAt` e `lastEntityLabel` sono già in `ConversationSummary`,
[conversations-queries.ts:8](src/lib/conversations-queries.ts#L8) — oggi non
vengono usati);
- anteprima più lunga del messaggio.
Nuovo layout in [admin/page.tsx](src/app/admin/page.tsx): Inbox → KPI →
Timeline consegne → Prodotti → il resto.
---
## Blocco C — Pipeline: ingresso lead
### Il vincolo che cambia le carte
**TidyCal non ha webhook.** È scritto nella loro FAQ: nessun supporto nativo, e
la strada suggerita è passare da Zapier / Make / Pabbly. Quello che TidyCal *ha*
è una REST API con OAuth 2.0 e **Personal Access Token disponibile su tutti i
piani** (si crea da `tidycal.com/integrations/oauth`), con endpoint sulle
prenotazioni.
Elementor Pro, invece, ha un'azione **Webhook** nativa in "Actions After Submit":
si incolla un URL e lui fa POST dei campi del form. Se invece andate su Astro,
il POST lo scrivete voi. In entrambi i casi è una chiamata in ingresso.
Da qui la forma della soluzione: **un endpoint solo, sia per il form che per
TidyCal**, e per TidyCal un pezzo in più che va a prendersi le prenotazioni.
### C1 · Endpoint di ingresso — `POST /api/webhooks/lead`
Un solo endpoint, indipendente da chi chiama.
- **Guardia**: header `x-webhook-secret` confrontato con una env nuova
(`LEAD_WEBHOOK_SECRET`), stesso pattern di
[validate-slug/route.ts:7](src/app/api/internal/validate-slug/route.ts#L7) —
ma **senza** la scorciatoia "se non è configurato passa": questa route è
esposta a internet, secret assente deve voler dire 403.
- **Rate limit** con `rateLimit()` da [rate-limit.ts](src/lib/rate-limit.ts),
bucket per IP. `src/proxy.ts` **non intercetta** `/api/*` (matcher a riga 120),
quindi la difesa è tutta dentro la route.
- **Payload** validato con Zod: `name` obbligatorio, `email`/`phone`/`company`/
`notes` opzionali, più un `source` libero. Mappatura campi in un modulo a
parte così aggiungere una sorgente non tocca la route.
- **Effetto**: crea un `lead` in stato `contacted` riusando le funzioni di
[lead-service.ts](src/lib/lead-service.ts). Se l'email esiste già, aggiorna
`last_contact_date` invece di duplicare.
Questo è tutto ciò che serve per Elementor, ed è pronto in anticipo per Astro:
la decisione Elementor-vs-Astro **non blocca niente**, perché il contratto è
un POST JSON in entrambi i casi.
### C2 · TidyCal — sincronizzazione a polling
Senza webhook, l'unico modo pulito senza terze parti è andare a leggere.
- `src/lib/tidycal.ts` — client con Personal Access Token in
`TIDYCAL_ACCESS_TOKEN`, che chiede le prenotazioni create dall'ultimo giro.
- `GET /api/internal/tidycal-sync` — protetta da `INTERNAL_SECRET`, come le due
internal esistenti. Per ogni prenotazione nuova crea un lead con nome, email e
la data della call in `next_action_date`, e registra un'`activity` di tipo
`meeting`. Idempotente sull'id TidyCal, così rilanciarla non duplica.
- **Schedulazione**: Coolify ha gli Scheduled Tasks. Un `curl` ogni 15 minuti
verso quella route. Non serve infrastruttura cron nell'app — e infatti nel
repo non ce n'è.
**Prima di scrivere il client va guardata la documentazione vera dell'API**, che
è dietro login (`tidycal.com/integrations` → API Keys) e non è indicizzata: forma
esatta dell'endpoint prenotazioni, filtro per data, paginazione. È il primo passo
del blocco, non un dettaglio: vale la regola già scritta in memoria — *ogni
estrazione da API di terzi va vista funzionare, non dedotta dai docs*.
Se l'API risultasse inadatta, il ripiego è Zapier/Make che POSTa su C1, che a
quel punto esiste già.
### C3 · "Alleggerire l'hub"
Questa richiesta non ha ancora un perimetro. Non la pianifico al buio: quando i
blocchi A e B sono in produzione ti porto l'elenco di cosa è davvero poco usato
(`/admin/quotes` e `/admin/preventivi` convivono, `service_catalog` e
`offer_services` sono legacy da DEBT-02) e decidi tu cosa togliere. Cancellare
route è irreversibile e va fatto guardando, non indovinando.
---
## Blocco D — Whop → audit *(rinviato, dipende dal motore)*
L'ingresso Whop è già predisposto nello schema: `audits.origin` accetta
`'whop'` e `audits.external_ref` tiene il riferimento esterno
([schema.ts:688-690](src/db/schema.ts#L688-L690)).
Ma "partono gli agent e fanno il lavoro" oggi **non è possibile**: in
`src/lib/audit/` ci sono solo le cinque fonti; agent, sintetizzatore e pipeline
(AUD-06 → AUD-11) non sono scritti. Costruire il webhook adesso significa
costruire un innesco che non innesca niente.
Quando il motore c'è, il blocco è: `POST /api/webhooks/whop` con verifica firma →
crea cliente + progetto con la Entry Offer assegnata (così l'audit compare
automaticamente in B1 e B2) → crea l'`audit` con `origin='whop'` → accoda la run.
---
## Migration 0018 — additiva
`src/db/migrations/0018_timer_scope_and_due_date.sql`, in parallelo alle stesse
modifiche in [schema.ts](src/db/schema.ts) (drizzle-kit generate è rotto, i due
file si tengono in pari a mano).
```
ALTER TABLE time_entries ADD COLUMN phase_id text REFERENCES phases(id) ON DELETE SET NULL;
ALTER TABLE time_entries ADD COLUMN task_id text REFERENCES tasks(id) ON DELETE SET NULL;
CREATE INDEX time_entries_phase_idx ON time_entries(phase_id);
CREATE INDEX time_entries_task_idx ON time_entries(task_id);
ALTER TABLE projects ADD COLUMN due_date timestamptz;
```
Solo `ADD COLUMN` e `CREATE INDEX`: nessun DROP, nessun TRUNCATE, nessuna colonna
rimossa — conforme a Data Safety (LOCKED). Le righe esistenti di `time_entries`
restano valide con le due colonne a NULL, cioè "tempo di progetto".
**Ordine di applicazione**, come da CLAUDE.md: prima a produzione via
`ssh root@178.104.27.55` + `docker exec`, **poi** il push del codice che le usa.
---
## Ordine di esecuzione
1. **A1** — le due rimozioni (nessuna dipendenza, effetto immediato)
2. **Migration 0018** in produzione
3. **A3** timer su task/fase · **A2** mini-dashboard progetto
4. **B3** inbox (piccola) → **B1** analytics prodotto → **B2** timeline consegne
5. **C1** endpoint lead → **C2** TidyCal, dopo aver letto l'API vera
6. **C3** e **D** dopo, con le informazioni che oggi non abbiamo
Commit per unità logica, come negli ultimi quattro.
## Verifica
Non c'è suite di test in questo progetto: **`npm run build` è la verifica di
riferimento** (fa il typecheck), più `npm run lint`.
Controlli manuali, in ordine:
- **A1** — il progetto non mostra più il tab Commenti; gli stessi commenti si
vedono ancora in `/admin/conversazioni` con la loro etichetta d'entità; la
lista progetti non ha più la colonna Timer.
- **A3** — timer avviato su un task: `time_entries` ha `task_id` valorizzato; il
totale della fase è la somma dei suoi task; il totale progetto non cambia.
Poi cancellare quel task e verificare che **l'entry sopravviva** con
`task_id` a NULL.
- **A2 / B1 / B2** — confrontare ogni numero a video con la stessa query lanciata
a mano su prod via `psql`. Su un dataset di 5 progetti si controllano tutti a
occhio; è l'unico momento in cui questo è ancora possibile.
- **B2** — mettere una `due_date` manuale su un progetto e verificare che vinca
sulla data derivata dall'offerta.
- **C1** — `curl` con secret giusto (lead creato), con secret sbagliato (403),
senza secret (403), e ripetuto per verificare che non duplichi.
- Dual light/dark su ogni schermata nuova, e nessuna classe palette raw.
Il portale cliente non va toccato da nessuna di queste modifiche: `client-view.ts`
non si tocca, quindi il vincolo LOCKED #2 non è in gioco.
View File
+3 -1
View File
@@ -16,7 +16,9 @@ Dopo **ogni** unità di lavoro conclusa — una fase, una migration applicata, u
## Cosa scrivere nella memoria persistente
`~/.claude/projects/-Users-simonecavalli-Vault-IAMCAVALLI/memory/` — un file per fatto, più la riga di indice in `MEMORY.md`.
`~/.claude/projects/-Users-simonecavalli-Vault-IAMCAVALLI-hub/memory/` — un file per fatto, più la riga di indice in `MEMORY.md`.
⚠️ **La chiave finisce in `-hub`.** Quella senza suffisso (`…-Vault-IAMCAVALLI/memory/`) è la memoria del *workspace*, un altro posto con altri file: scriverci un fatto di ClientHub significa non ritrovarlo più, perché a inizio sessione qui viene iniettata solo quella con `-hub`. Da non confondere nemmeno con `.claude/memory/`, che è versionata nel repo — la distinzione sta in [`../CLAUDE.md`](../CLAUDE.md).
Ci va quello che **non si deduce dal repo**: decisioni e il loro perché, vincoli operativi, cose che sono state provate e non funzionano. Non ci va quello che il codice già dice: struttura, cronologia dei fix, contenuto di `CLAUDE.md`.
+22 -11
View File
@@ -18,6 +18,28 @@
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guardia-migration.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guardia-token.sh"
}
]
}
],
"Stop": [
{
"hooks": [
@@ -28,16 +50,5 @@
]
}
]
},
"enabledPlugins": {
"impeccable@impeccable": true
},
"extraKnownMarketplaces": {
"impeccable": {
"source": {
"source": "github",
"repo": "pbakaus/impeccable"
}
}
}
}
+77
View File
@@ -0,0 +1,77 @@
---
name: audit
description: Far girare le fonti di rilevazione dell'audit sito di ClientHub su un URL e capire cosa e' stato misurato e cosa no. Da usare quando si lavora al motore audit v2.5, quando serve una rilevazione su un sito reale, quando si vuole sapere se una fonte risponde, o prima di scrivere qualsiasi pezzo del documento di audit.
---
# Audit — le rilevazioni, e cosa vale come misura
Il motore v2.5 **non e' scritto**. Quello che esiste, e che questa skill mette in moto,
sono le fonti di rilevazione: nessun LLM, nessun database, nessun browser headless.
## Due attrezzi, due domande diverse
| Comando | Cosa fa | Quando |
|---|---|---|
| `npx tsx scripts/audit-fonti.ts <url>` | Le **cinque fonti** di `src/lib/audit/sources/` — pagine, CrUX, PageSpeed, storico Wayback, segnali. Puramente meccanico | «Cosa si riesce a misurare su questo sito?» |
| `npx tsx scripts/spike-audit.ts <url>` | Verifica le **264 voci di checklist** con Sonnet 5, poi sintetizza con Opus 5. Volutamente **isolato** da `src/`: non importa nulla, non tocca il DB | «Cosa c'e' che non va, in parole?» |
Opzioni comuni: `--profilo=servizi|ecommerce`, `--no-psi` (salta PageSpeed, che da solo
vale meta' del tempo). Riferimento misurato il 2026-08-26 su `giojello.com`: cinque fonti
su cinque, **42,7 s** in tutto, PageSpeed 34,2 s. Se una fonte tace, **quella e' la notizia**
va riportata, non aggirata.
`PAGESPEED_API_KEY` viene letta dall'ambiente o da `.env.local`. Senza, PageSpeed e CrUX
rispondono a vuoto **senza errore**: lo script lo dice in testa, leggerlo.
Il grezzo finisce in `audit-fonti-<dominio>.json` (gitignorato). E' li' che ogni numero del
documento dovra' essere rintracciabile.
## Le tre regole che il motore erediterà
Non sono preferenze. Sono gia' costate, e stanno per esteso in `STATUS.md` § Lezioni operative.
1. **Un numero entra solo se e' stato misurato.** Rintracciabile nel grezzo, e nel motore in
`audit_runs.raw`. Nessun numero dedotto, arrotondato o ricordato.
2. **Laboratorio e campo non si fondono — la differenza *e'* il risultato.** Su giojello.com
Lighthouse dava `server-response-time` **7 ms** e CrUX TTFB p75 **3.553 ms con l'1% nel
verde**: il server risponde in fretta al datacenter Google e lento a tutti gli altri. Due
numeri con lo stesso nome verrebbero fusi in uno solo — per questo il campo si chiama
`risposta_server_ms` e non `ttfb_ms`.
3. **Un punteggio va sempre con la sua data.** Performance mobile 52, poi 64 sullo stesso sito
mezz'ora dopo. Mai presentato come una costante del sito.
E una regola di metodo che vale per ogni fonte nuova: **ogni estrazione da un'API di terzi va
vista funzionare, non dedotta dai docs.** Scrivendo `sources/`, due estrazioni prese dalla
documentazione hanno restituito valori vuoti *senza errore*`largest-contentful-paint-element`
non esiste piu' e `configSettings.screenEmulation` non esiste affatto nelle risposte pubbliche.
Trovate solo perche' le fonti sono state fatte girare su un sito vero.
## Perche' niente browser headless
Misurato il 2026-08-17 sul VPS: **1,5 GB di RAM liberi su 3,8**, Chromium ne prende 500 MB1 GB
→ OOM kill sui servizi con dati reali. Verificato il 2026-08-18 che non serve comunque: 153 voci
si verificano sul DOM renderizzato che PageSpeed restituisce, e lo screenshot buono e'
`final-screenshot`, non `fullPageScreenshot`.
## Lo stato di v2.5, per non ripartire dal posto sbagliato
**In pausa dal 2026-08-19** per scelta: prima le modifiche all'hub, poi il motore.
| Pezzo | Dove | Stato |
|---|---|---|
| Schema, 7 tabelle + rubrica 264 voci | `0017_audits.sql`, `checklist_items` | in produzione |
| Le cinque fonti | `src/lib/audit/sources/` | **in prod ma inerti**: nessuna route le chiama |
| Agent, sintetizzatore, pipeline, editor, pagina | `src/lib/audit/`, `src/app/{admin/audit,audit}` | **da scrivere** |
I piani stanno in [`../../plans/`](../../plans/): `v2.5-audit-documento.md` (i tre livelli
Radiografia / Prima-Dopo / Rotta come configurazioni di **un unico** documento) e
`v2.5-audit-motore.md` (raccolta in parallelo → quattro sub-agent → sintetizzatore che
**incrocia** le osservazioni in massimo 10 finding).
## L'agente, quando lo progetteremo
Va in `.claude/agents/audit-*.md`, non qui. Non esiste ancora e non va abbozzato: il design
dei quattro sub-agent cambiera' scrivendo il motore. Quando nascera' eredita le tre regole
qui sopra — sono il perimetro, non il contorno.
+89
View File
@@ -0,0 +1,89 @@
---
name: preventivo
description: Lavorare sulla pipeline che genera i preventivi di ClientHub (src/lib/proposal/) senza romperla. Da usare quando si modifica il prompt, lo schema Zod, il montaggio o la resa di un preventivo, quando una generazione fallisce con "Contenuto AI non valido", quando una sezione del documento esce vuota, o prima di pubblicare una proposta a un cliente.
---
# Preventivo — la catena e come non spezzarla
Il preventivo lo genera **l'app**, da `/admin/preventivi/genera`. Questa skill non contiene
un prompt di generazione e non ne va aggiunto uno: il prompt vive in `agent.ts` ed e' l'unico.
Un secondo prompt qui resterebbe indietro rispetto a quello vero senza che nessuno se ne accorga.
## La catena: quattro file che devono dire la stessa cosa
```
src/lib/proposal/agent.ts il prompt chiede N campi ─┐
src/lib/proposal/schema.ts lo Zod pretende quegli N campi │ se non
src/lib/proposal/assemble.ts li impacchetta con prezzi+profilo │ coincidono,
src/components/public/proposal/ ProposalDeck + sections/ li resa ─┘ si rompe
```
**Si tocca il prompt, si riapre lo schema. Sempre.** I due fallimenti hanno forma diversa e
solo uno si vede subito:
| Cosa hai fatto | Cosa succede |
|---|---|
| Campo aggiunto allo **schema**, non al prompt | `ProposalContentSchema.safeParse` fallisce → **ogni** generazione muore con «Contenuto AI non valido». Rumoroso, si scopre subito |
| Campo aggiunto al **prompt**, non allo schema | Zod lo scarta in silenzio. Il documento esce senza quella parte, e te ne accorgi davanti al cliente |
| Campo nello schema, nessuna `<Section>` che lo legge | Generazione verde, sezione assente. Il piu' subdolo: niente segnala l'errore |
Quando aggiungi un campo, il giro completo e' **quattro file**: prompt in `buildUserPrompt`,
schema Zod, eventuale passaggio in `assembleProposal`, e la sezione in
`src/components/public/proposal/sections/` piu' la riga in `ProposalDeck.tsx` che la monta.
I vincoli di cardinalita' stanno nello schema (`.min(3).max(5)` sui problemi, `.length(5)` sui
nodi del diagramma, `.min(4).max(10)` sulla matrice): se li cambi li' e non nel prompt, il
modello continua a produrre il numero vecchio e Zod lo rifiuta.
## Prezzi: cosa puo' vedere il cliente
Vincolo **LOCKED #2** (`../../CLAUDE.md`): al cliente vanno i totali, mai le righe di prezzo.
`PricingSection.tsx` renderizza `tier.publicPrice ?? tier.servicesTotal` e il **nome** dei
servizi — corretto. Ma `assembleProposal` mette in `content.offer.tiers[].services[].unitPrice`
anche il prezzo unitario, e `ProposalDeck` e' `"use client"`: riceve l'intero `proposal` come
prop da un server component, quindi **tutto** l'oggetto finisce serializzato nel payload RSC
della pagina, renderizzato o no.
E' la stessa trappola del gate OTP annotata in `STATUS.md`*sparire a schermo non e' sparire*.
Prima di toccare la pagina pubblica, e prima di mandare un preventivo a un cliente che potrebbe
aprire il sorgente:
```bash
curl -s https://<host>/preventivo/<slug> > /tmp/p.html
LC_ALL=C grep -c unitPrice /tmp/p.html # atteso a regime: 0
```
Se e' > 0, la correzione non e' nascondere la sezione: e' una proiezione client-safe in
`assemble.ts` o al confine del componente, come gia' fa `src/lib/client-view.ts` per il portale.
## Prima di pubblicare
1. **Preflight sui dati finti**`./check-profilo.sh` da questa cartella. `profile.ts` e' uno
**snapshot**: quello che c'e' dentro al momento della generazione finisce in
`proposals.content` e ci resta anche se poi correggi il file.
2. **Le citazioni sono verbatim o non sono.** Il prompt lo impone; verificarlo a campione contro
il transcript e' il controllo che nessuna macchina fa al posto tuo. Una citazione inventata
in un preventivo e' peggio di un preventivo senza citazioni.
3. **Copy** — italiano, registro di `../../../../brand/voce.md`, e **nessun numero che non stia
in `../../../../brand/prove.md` marcato divulgabile** (`../../../../.claude/rules/lingua-e-tono.md`).
⚠️ `brand/` sta **fuori dal repo hub**: su un clone senza il workspace questo passo si salta
dichiarandolo, non si finge di averlo fatto.
4. **Stato**`draft` non e' visibile (la pagina risponde «non ancora disponibile»).
Pubblicare = `publishProposal` in `src/app/admin/preventivi/actions.ts`.
## Come si prova davvero
- `npm run build` e' **la verifica di riferimento**: non esiste test suite, e il build fa il
typecheck. Verde qui significa «i tipi tornano», non «il documento e' giusto».
- Per vedere l'output serve una **generazione vera** dall'admin, con un'offerta e almeno un
transcript. In locale oggi **non si puo'**: `.env.local` non autentica piu' contro il DB
(dal 2026-08-21, vedi `STATUS.md`). Si guarda in produzione.
- Un preventivo si legge a schermo prima di mandarlo. «Buildato» non e' «funziona», e
«generato» non e' «verificato».
## Note sulla chiamata al modello
`agent.ts` usa `max_tokens: 8192` per uno schema che chiede fino a 5 problemi + 5 soluzioni +
matrice. Se la risposta viene troncata, il fallimento **non** dice «troncata»: dice
«L'AI ha prodotto JSON non valido», e manda a cercare nel posto sbagliato. Se capita,
controllare `message.stop_reason === "max_tokens"` prima di dare la colpa al parsing.
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Preflight: src/lib/proposal/profile.ts contiene dati placeholder?
#
# profile.ts e' uno SNAPSHOT: finisce dentro proposals.content al momento della
# generazione e ci resta. Un placeholder spedito una volta non si corregge piu'
# modificando il file — va rigenerato il preventivo.
#
# exit 0 = pulito · exit 1 = placeholder trovati
set -uo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)
F="$ROOT/src/lib/proposal/profile.ts"
[ -f "$F" ] || { echo "profile.ts non trovato in $F" >&2; exit 1; }
# Ogni riga: etichetta|regex. Sono i segni concreti di "da riempire".
PATTERNS=(
'testimonianza segnaposto|→ Aggiungere'
'nome cliente finto|"Cliente [0-9]"'
'ruolo generico|role: "Professione"'
'campo da compilare|// → '
'dominio sbagliato (il brand e iamcavalli.net)|iamcavalli\.com'
)
TROVATI=0
for p in "${PATTERNS[@]}"; do
ETICHETTA="${p%%|*}"; RE="${p#*|}"
N=$(grep -cE "$RE" "$F" || true)
if [ "$N" -gt 0 ]; then
printf ' ✗ %-48s %s occorrenz%s\n' "$ETICHETTA" "$N" "$([ "$N" = 1 ] && echo a || echo e)"
TROVATI=$((TROVATI + N))
fi
done
if [ "$TROVATI" -gt 0 ]; then
cat >&2 <<'MSG'
BLOCCO — profile.ts spedisce dati placeholder in ogni preventivo generato.
Non si riempiono a occhio: i numeri divulgabili stanno in brand/prove.md, che oggi
e' ancora `stato: scheletro-intervista`. Finche' non sono verificati, la mossa giusta
non e' inventare — e' non renderizzare il blocco.
Regola: ../../../.claude/rules/lingua-e-tono.md — «un numero affermato con sicurezza e
mai verificato e' il modo piu' veloce per bruciare la credibilita' che il tono costruisce».
MSG
exit 1
fi
echo " ✓ profile.ts pulito"
View File
+12
View File
@@ -15,3 +15,15 @@ INTERNAL_SECRET=generate-with-openssl-rand-base64-32
# RESEND_FROM deve usare un dominio verificato su Resend
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
RESEND_FROM=Nome Mittente <no-reply@iamcavalli.net>
# Base pubblica dei link nelle email (pulsante "Apri la conversazione" della
# notifica di tag). Opzionale: se manca si usa NEXTAUTH_URL, che in ogni
# ambiente e' gia' l'origine giusta. Serve solo se le due devono divergere.
# APP_BASE_URL=https://hub.iamcavalli.net
# Ingresso lead da fuori (form del sito, bridge Zapier/Make) su
# POST /api/webhooks/lead, header x-webhook-secret.
# A differenza di INTERNAL_SECRET questa route e' esposta a internet: se la
# variabile manca, la route risponde 403 invece di lasciar passare.
# Generate with: openssl rand -base64 32
LEAD_WEBHOOK_SECRET=generate-with-openssl-rand-base64-32
+3
View File
@@ -49,3 +49,6 @@ next-env.d.ts
# Output degli spike audit (dati di siti di clienti, non vanno committati)
spike-audit-*.json
# Grezzo delle rilevazioni audit (npx tsx scripts/audit-fonti.ts)
audit-fonti-*.json
+30
View File
@@ -67,6 +67,28 @@ I blocchi non pertinenti **non esistono nel DOM**, non sono nascosti via CSS.
- **LOCKED #5 (no file hosting)** — emendato limitatamente agli asset di audit, deroga già annotata in `CLAUDE.md`. Non estendere ad altre entità.
- **Nessun renderer headless, da nessuna parte.** Il VPS non regge Chromium (RAM), e non serve: gli audit Lighthouse arrivano già fatti sul DOM renderizzato.
## Modifiche hub (richieste 2026-08-18, in corso)
Fuori dalla milestone v2.5, che è in pausa. Piano in
`~/.claude/plans/sei-arrivato-qua-search-recursive-kettle.md`.
- [x] **HUB-01**: Via il tab Commenti dal progetto — `/admin/conversazioni` li aggrega già tutti con l'etichetta dell'entità. *Perde solo la risposta sulla singola entità, che era già confluita sul thread generale.*
- [x] **HUB-02**: Via il timer dalla lista progetti — si avvia dove c'è il contesto
- [x] **HUB-03**: Riepilogo soldi + avanzamento in testa al progetto, senza query nuove
- [x] **HUB-04**: Timer per fase e task — migration `0018`, `ON DELETE SET NULL` perché le ore sopravvivono al task
- [x] **HUB-05**: Inbox in cima alla dashboard, con da-quanto-aspetta e contesto del messaggio
- [x] **HUB-06**: Analytics per linea di prodotto (Entry/Signature/Retainer) dalla tassonomia, con l'incassato non attribuibile mostrato a parte
- [x] **HUB-07**: Timeline delle consegne con semaforo ritardo/anticipo; scadenza dedotta da offerta + durata, `projects.due_date` come override
- [x] **HUB-08**: `POST /api/webhooks/lead` — un endpoint per form del sito e bridge, con dedup sull'email
- [ ] **HUB-09**: Confermare la forma del payload Elementor con un invio **vero** — oggi è gestita in modo difensivo
- [ ] **HUB-10**: `LEAD_WEBHOOK_SECRET` su Coolify — finché manca, la route risponde 403 a tutti
- [ ] **HUB-11**: TidyCal. **[BLOCCANTE]** Niente webhook (loro FAQ): serve polling della REST API. Path e filtri stanno dietro il login → servono token o documentazione dall'utente
- [ ] **HUB-12**: Alleggerire l'hub. Senza perimetro: si definisce guardando cosa è poco usato
- [ ] **HUB-13**: Whop → progetto + audit automatico. Dipende dal motore v2.5 (AUD-06→11)
- [x] **HUB-14**: Rifiniture dall'uso reale del pannello — rinomina di un valore di tassonomia con propagazione alle fasi dei progetti, stato task "In revisione", tab pagamenti con ordine stabile e importi a mano, riordino task per trascinamento. *In prod 2026-08-20, migration `0019`.*
- [x] **HUB-15**: Portale cliente — barra di avanzamento compatta e a tutta larghezza, card offerta senza accordion, "Valore dell'offerta" con override admin (`project_offers.offer_value_override`). *In prod 2026-08-21, migration `0020`. Serviva perché la somma dei prezzi di catalogo mostrava €20.250 su offerte vendute a 7.000 e 5.500.*
- [ ] **HUB-16**: Guardare a mano HUB-14 e HUB-15 in produzione. `updateOfferValueOverride` **è provata** (Caruso Speaker ha un override a 7.500 messo dal pannello); restano da cliccare `reorderTasks`, `updatePaymentField`, `clearPaymentOverride` e il drag, e dal 2026-08-21 la verifica locale contro i dati veri non è più possibile
## Backlog (ereditato, nessuno in corso)
- [ ] **SEND-01 / SEND-02** — Invio del link `/preventivo/[slug]` via email. Il mailer è già in produzione dalla v2.3: manca il pulsante e l'action. *Rinviati il 2026-07-28.*
@@ -86,6 +108,14 @@ I blocchi non pertinenti **non esistono nel DOM**, non sono nascosti via CSS.
## Aperto, non un requisito
**`.env.local` da riallineare a Coolify.** `ADMIN_PASSWORD`, `NEXTAUTH_SECRET` **e la
password del DB** sono stale, e l'host che il file dichiara (`178.104.27.55:5432`) è chiuso
dal firewall — il DB vero è su `127.0.0.1:54321` dietro tunnel. Finché resta così **una
modifica al portale si verifica solo in produzione**: build, migration e query di controllo,
poi occhio umano sul sito. Recuperare la password viva dal container è bloccato dal
classifier dei permessi e non va aggirato: la sblocca l'utente copiando le variabili da
Coolify. Le migration non ne soffrono (`docker exec` non usa quelle credenziali).
**Whitelist del portale vuota per 3 clienti su 4.** La migration 0015 ha seedato solo
`mario@test.it`. Protocollo Estetico, Caruso Speaker e Teckell hanno whitelist vuota e
finché lo è **il loro portale non è accessibile**. Si popola da `/admin/clients/<id>`
+54 -50
View File
@@ -3,9 +3,9 @@ gsd_state_version: 1.0
milestone: v2.5
milestone_name: Audit
status: executing
stopped_at: "Phase 27 a metà: schema in prod, fonti scritte e provate sul campo. Prossimo: src/lib/audit/schema.ts + agents/."
last_updated: "2026-08-19T13:20:00.000Z"
last_activity: 2026-08-19 -- v2.5 pushata su main e deployata (8000d56); motore ancora non richiamato da nessuna route
stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1, rifiniture, chat a canali (0021), modifica messaggi + firma (0022), stati task e date pagamenti (0023) in prod; C2 (TidyCal) bloccato sulle credenziali API. Conversazioni (scrittura per primo, menzioni, mail sul tag) pushata il 2026-09-01, nessuna migration, MAI provata a mano. Nulla del portale e' stato visto a schermo: .env.local non autentica piu', serve ?preview=1 in prod. Il riquadro 'Prossimo pagamento' non compare finche' nessuna rata ha una due_date."
last_updated: "2026-09-01T00:15:00.000Z"
last_activity: 2026-09-01 -- conversazioni: scrivere per primo, menzioni, mail sul tag. Pushata dopo aver recuperato l'accesso a gitea dalla CLI admin
progress:
total_phases: 4
completed_phases: 0
@@ -23,44 +23,46 @@ progress:
## Project Reference
See: .planning/PROJECT.md · **Core value:** il cliente apre il link e vede a che punto è
il suo progetto, senza scrivere email. · **Current focus:** milestone **v2.5 "Audit"**
il servizio di analisi sito diventa un documento privato su `/audit/[slug]`.
il suo progetto, senza scrivere email. · **Current focus:** modifiche hub (v2.5 in pausa).
## Current Position
Phase: 27 of 30 — Motore di analisi
Plan: schema e fonti fatti; agent, sintetizzatore e pipeline da scrivere
Status: nessun bloccante — prossimo passo `src/lib/audit/schema.ts` + `agents/`
Last activity: 2026-08-18 — `src/lib/audit/sources/` scritto e provato su giojello.com
**v2.5 è in pausa per scelta** (2026-08-19): prima le modifiche all'hub, poi il motore.
Phase 27 resta a metà — schema e fonti in prod, resto da scrivere.
Progress: [███░░░░░░░] 25%
| Blocco (modifiche hub) | Stato |
|---|---|
| A — Progetti (via commenti/timer, riepilogo, timer per task) | ✅ in produzione 2026-08-19 |
| B — Dashboard (inbox, linee di prodotto, timeline consegne) | ✅ in produzione 2026-08-19 |
| C1 — `POST /api/webhooks/lead` | ✅ in produzione, provato contro il DB vero |
| C2 — TidyCal | ⛔ **[BLOCCANTE]** vedi sotto |
| C3 — Alleggerire l'hub | ⏸️ senza perimetro |
| Rifiniture — tassonomie, tab pagamenti, riordino task | ✅ in prod 2026-08-20 (`0019`) |
| Portale cliente — stepper compatto/full-width, card offerta | ✅ in prod 2026-08-21 (`0020`); override provato su Caruso Speaker |
| Chat — canali, modifica messaggi, firma admin | ✅ in prod 2026-08-21 (`0021`, `0022`); **mai provata a mano**; manca l'attribuzione |
| Conversazioni — scrivere per primo, menzioni `@Nome`, mail sul tag | ✅ pushata 2026-09-01 (`41530b5`), nessuna migration. Menu dei tag corretto lo stesso giorno: mostrava **una riga per alias** invece che per persona. Build + lint puliti, 23 test sul parser e sul menu; **nessuna mail di tag mai partita davvero** |
| Portale — stati task (forma, pill, legenda, «Cancellata») + date dei pagamenti | ✅ in prod 2026-08-22 (`2e9bd2a`, `8b54f48`, `fe76789`, migration `0023`); **mai visto a schermo**, nessuna `due_date` ancora inserita |
| D — Whop → audit | ⏸️ dipende dal motore v2.5 |
Progress: [███░░░░░░░] 25% (v2.5)
## Dove sta cosa
| Cosa | Dove | Stato |
I piani della milestone sono **nel repo dal 2026-08-26**: `.claude/plans/v2.5-*.md`.
| Cosa (audit) | Dove | Stato |
|---|---|---|
| Schema audit (7 tabelle) | `src/db/migrations/0017_audits.sql` + `src/db/schema.ts` | **in produzione** |
| Rubrica del motore, 264 voci | tabella `checklist_items`, sorgente `scripts/data/checklist.json` | **in produzione** |
| Fonti del motore (5 moduli) | `src/lib/audit/sources/` | **in produzione ma inerte**: deployato, nessuna route lo chiama |
| Agent, sintetizzatore, pipeline | `src/lib/audit/{schema,agents,pipeline}.ts` | **da scrivere** |
| Editor admin e pagina pubblica | `src/app/admin/audit/`, `src/app/audit/[slug]/` | **da scrivere** |
| L'unico audit prodotto finora | `spike-audit-giojello.com.json` (root, **gitignorato**) | spike del 2026-08-16, **zero rilevazioni** |
| I due piani della milestone | `~/.claude/plans/``…woolly-puddle.md` (documento) + `…radiant-valley.md` (motore) | **fuori dal repo** |
| Schema, 7 tabelle + rubrica 264 voci | `0017_audits.sql`, `checklist_items` | **in produzione** |
| Fonti del motore (5 moduli) | `src/lib/audit/sources/` | **in prod ma inerte**: nessuna route lo chiama |
| Agent, sintetizzatore, pipeline, editor, pagina | `src/lib/audit/`, `src/app/{admin/audit,audit}` | **da scrivere** |
| L'unico audit prodotto finora | `spike-audit-giojello.com.json` (gitignorato) | spike 2026-08-16, **zero rilevazioni** |
## Come funziona il motore
Si incolla un URL. Nessun browser headless da nessuna parte.
1. **Raccolta in parallelo** (`sources/`, nessun LLM): PageSpeed, CrUX, Wayback, RDAP, robots/sitemap/JSON-LD, header. Ogni fonte fallisce in modo non fatale e dice *perché*.
2. **Quattro sub-agent in parallelo** (`agents/`): checklist, visivo, storico, tecnico. Producono osservazioni, non finding.
3. **Sintetizzatore** che le **incrocia**: quattro osservazioni deboli su temi diversi diventano un finding solo con quattro evidenze indipendenti. Massimo 10, per impatto.
4. **Editor admin** per rifinire, poi consegna su `/audit/[slug]`.
Vincolo che regge tutto: **un numero entra solo se misurato**, e dev'essere rintracciabile in `audit_runs.raw`.
## Performance Metrics
**Velocity:** 21 plans completati (v2.1v2.4). Phase 27: spike ~1h, schema ~1h, fonti ~2h.
Raccolta in parallelo (nessun LLM, nessun browser headless) → quattro sub-agent →
sintetizzatore che **incrocia** le osservazioni in massimo 10 finding. Vincolo che
regge tutto: **un numero entra solo se misurato**, rintracciabile in `audit_runs.raw`.
Passo per passo in `STATUS.md` e in `.claude/plans/v2.5-audit-motore.md`.
## Accumulated Context
@@ -68,32 +70,34 @@ Vincolo che regge tutto: **un numero entra solo se misurato**, e dev'essere rint
Log completo in `PROJECT.md`. Vive per il lavoro corrente:
- **[2026-08-18] Il documento usa il design system dell'area admin** — token semantici, Plus Jakarta Sans, Geist Mono per metriche e date, `StatusBadge` per gli impatti. Sostituisce la deroga tipografica del piano; i font sono già self-hostati da `next/font/google`, quindi la CSP è soddisfatta senza lavoro.
- **[2026-08-18] Nessun renderer headless** — PageSpeed dà 153 audit sul DOM renderizzato, cioè le osservazioni visive che prima richiedevano screenshot a mano.
- **[2026-08-18] Laboratorio ≠ campo, e la differenza è il risultato** — Lighthouse dà 7 ms di risposta server, CrUX dà TTFB p75 3.553 ms. Da qui il nome `risposta_server_ms`: con lo stesso nome il sintetizzatore li tratterebbe come un numero solo.
- **[2026-08-17] Il VPS non regge Chromium** — RAM, non disco. È la ragione per cui l'opzione headless non torna.
- **[2026-08-16] La checklist alimenta il MOTORE, non il documento** — se diventa il rendering della checklist, torna a sembrare un audit automatico gratuito.
- **[2026-08-16] Immagini** — le due del redesign le carica l'utente, le due dello stato di fatto le scrive la pipeline. Emenda LOCKED #5 solo per gli asset di audit.
### Pending Todos — nessuno (`.planning/todos/` non esiste)
- **[2026-09-01] Una lista che serve a leggere non è una lista da offrire in scrittura** — il menu dei tag mostrava tre voci (nome, brand, nome di battesimo) per una persona sola, perché gli era stata passata la stessa lista che il parser usa per **riconoscere** un tag nel testo. Sono due domande diverse: *come lo si può scrivere* (tutti gli alias, invisibili) e *chi si può scegliere* (una riga per destinatario). Ora `MentionTarget` le tiene separate — `label` per il menu, `aliases` per la rilettura — e il filtro passa da `normalizeForSearch`, la stessa normalizzazione del parser: con un `toLowerCase()` a parte, digitare «nicolo» non troverebbe «Nicolò» nel menu mentre nel messaggio verrebbe riconosciuto. Il difetto scalava peggio del valore: con tre contatti sarebbero state nove righe.
- **[2026-09-01] L'accesso a Gitea si recupera dalla sua CLI, non dal web** — il push rispondeva 403 (la credenziale nel portachiavi leggeva ma non scriveva) e l'account web non era piu' accessibile, quindi la via del browser era chiusa in partenza. Si rientra da dentro il container: `docker exec -u git gitea-tgw04ws48sogkso84oogwckk gitea admin user change-password` per la password e `... generate-access-token --scopes write:repository --raw` per il token del push. Il token va messo nel portachiavi con `git credential approve`, **non** nell'URL del remote: li' finirebbe in chiaro dentro `.git/config`. Da ricordare perche' senza push non esiste deploy — Coolify parte da `main` e basta.
- **[2026-08-22] Un task cancellato esce dai denominatori, non dalla lista** — resta visibile barrato (il cliente ha letto quella voce e deve capire che fine ha fatto) ma non conta, via un solo `countsTowardProgress()` in `task-status.ts`: contarlo terrebbe la fase sotto il 100% per sempre, contarlo come fatto racconterebbe una consegna mai avvenuta. Se in una fase restano solo cancellati torna «Da iniziare»: degenere, ma «Completata» mentirebbe.
- **[2026-08-22] Le date dei pagamenti sì, gli importi per riga no** — LOCKED #2 parla di cifre, non di date. Salvate a **mezzogiorno UTC** (a mezzanotte il giorno civile a Roma è già quello dopo) e contate sui giorni civili a Roma in `src/lib/payment-dates.ts`, lo stesso modulo del futuro promemoria email: mail e portale non devono contraddirsi su quanti giorni mancano.
- **[2026-08-20] Gli importi scritti a mano non si ricalcolano** — `amount_locked` esclude la riga da `rescalePayments`, e lo scarto fra somma rate e totale si dichiara invece di aggiustarlo. Il backfill dell'ordine rate va per `percent DESC`, non per `ctid`: 3 progetti su 5 erano già scombinati e l'ordine fisico avrebbe fissato l'errore.
- **[2026-08-20] Rinominare una fase rinomina anche le fasi dei progetti** — non c'è FK fra tassonomia e `phases`: `importOfferIntoProject` riconosce una fase solo dal titolo (`offer_phase_id` non viene mai popolata). Senza propagazione, il re-import di un'offerta crea una fase duplicata accanto a quella vecchia. È l'unico rename che scrive fuori dal catalogo, quindi l'unico con conferma.
- **[2026-08-19] Prima l'hub, poi il motore** — le modifiche all'hub sono indipendenti e a basso rischio, il motore no. Il Whop → audit resta ultimo perché dipende dal motore.
- **[2026-08-19] L'incassato non attribuibile si mostra, non si spalma** — i pagamenti stanno sul progetto, non sull'offerta. Un progetto senza offerta finisce in una riga "Senza offerta" separata: spalmarlo darebbe un totale che quadra e righe che mentono.
- **[2026-08-19] Il tempo lavorato sopravvive alla cancellazione del task** — `ON DELETE SET NULL`, mai cascade: con cascade, ripulire una fase abbasserebbe in silenzio il fatturato tracciato.
- **[2026-08-18] Audit:** design system dell'area admin; nessun renderer headless (il VPS non regge Chromium); laboratorio ≠ campo, quindi nomi distinti per Lighthouse e CrUX; la checklist alimenta il **motore**, non il documento. Per esteso in `STATUS.md`.
### Blockers/Concerns
- **Il copy fisso del template v1 non ha una fonte nel repo** — il prototipo Giojello non c'è. Lo *stile* ora viene dal design system, ma testi e gerarchia dei blocchi vanno recuperati prima di Phase 30.
- **Il caso "zero dati CrUX" non è ancora stato visto** su un sito vero (test 5 del piano).
- **Il 52% della checklist non è verificabile da HTML statico** — quanto ne recuperino gli audit Lighthouse non è ancora misurato (test 3 del piano).
- **Whitelist portale vuota per 3 clienti su 4** — si popola da `/admin/clients/<id>`.
- **`.env.local` punta al DB di PRODUZIONE**, non allineato a Coolify per `ADMIN_PASSWORD` / `NEXTAUTH_SECRET`.
- **Ogni fase con schema**: migration applicata a prod **prima** del push del codice.
- **[BLOCCANTE] TidyCal non ha webhook** (verificato 2026-08-19 sulla loro FAQ; la via suggerita è Zapier/Make). La REST API c'è, con Personal Access Token su tutti i piani, ma path, filtri e paginazione **stanno dietro il login**. Sblocca: l'utente apre `tidycal.com/integrations` → API Keys e passa token o documentazione. Non dedurre la forma dell'API dai docs.
- **[BLOCCANTE] `LEAD_WEBHOOK_SECRET` non è su Coolify**: finché manca, `/api/webhooks/lead` risponde 403 a tutti (fallimento chiuso voluto). Sblocca: l'utente la imposta.
- **Il 100% dell'incassato è "Senza offerta"** — Caruso Speaker e Protocollo Estetico: 5.300 € senza offerte assegnate. Si sistema assegnandole dai rispettivi progetti. Il payload Elementor, intanto, non è ancora verificato sul campo: gestito in modo difensivo, serve un invio vero.
- **Il copy del template v1 non ha fonte nel repo** — il prototipo Giojello non c'è: testi e gerarchia dei blocchi da recuperare prima di Phase 30.
- **Audit, da vedere sul campo:** il caso "zero dati CrUX" (test 5) e quanto del 52% non verificabile da HTML statico recuperi Lighthouse (test 3). **Whitelist portale vuota per 3 clienti su 4** — si popola da `/admin/clients/<id>`.
- **`.env.local` NON è allineato a Coolify**: `ADMIN_PASSWORD`, `NEXTAUTH_SECRET` **e la password del DB** sono stale, e l'host che scrive (`178.104.27.55:5432`) è chiuso — il DB vero è su `127.0.0.1:54321` dietro tunnel SSH. Estrarre la password viva dal container è **bloccato dal classifier** e non va aggirato. Rendere in locale contro i dati veri **oggi non si può** (2026-08-21); sblocca: l'utente riallinea il file alle variabili di Coolify. Le migration non ne soffrono, e resta valido il resto della procedura: **ogni fase con schema applica la migration a prod prima del push del codice**.
- **Debito design (DEBT-01)** — ~40 file, ~450 occorrenze. Dettaglio in `STATUS.md`.
## Deferred Items
Vedi `REQUIREMENTS.md` § Backlog e § Rinviati esplicitamente da v2.5.
## Deferred Items — vedi `REQUIREMENTS.md` § Backlog e § Rinviati da v2.5.
## Session Continuity
Last session: 2026-08-18
Stopped at: `src/lib/audit/sources/` (5 moduli) scritto e **provato sul campo su giojello.com** — giro completo 73 s, tutte le fonti hanno risposto. Build e lint puliti.
Next: (1) `src/lib/audit/schema.ts` + `agents/` con validazione Zod dura; (2) `pipeline.ts` con heartbeat su `audit_runs`; (3) volume Coolify prima del deploy; (4) editor e pagina.
Last session: 2026-08-26
Stopped at: **stato task «Cancellata» + date dei pagamenti nel portale** (`fe76789`, migration `0023` applicata a prod prima del push). Un task tolto dal lavoro ora ha dove stare: **X nel cerchio, titolo barrato**, pill «Cancellata» — l'unico stato chiuso con la pill, perché «Fatto» e «Cancellata» sono entrambi barrati e scambiarli significa credere consegnato ciò che non esiste. Esce da **tutti** i denominatori via `countsTowardProgress()`. Nel Kanban cliente la colonna compare solo se piena (mai nascosta se ha dentro qualcosa); nell'admin c'è sempre, ed è così che si cancella un task. Lato pagamenti: `payments.due_date` (nullable, più indice parziale per il futuro promemoria email), riquadro «Prossimo pagamento» con conto alla rovescia in parole e rosso se scaduto, «Scade il…» / «Pagato il…» su ogni riga, **zero importi**. Admin: campo Scadenza per rata, «Incassato nel mese» → «Incassato il» (giorno; le analytics raggruppano per mese e non se ne accorgono). Build, typecheck e lint verdi.
**2026-08-26 — architettura `.claude/`**: skill `/preventivo` e `/audit`, due hook di guardia, piani nel repo. Nessun tocco al prodotto. Le due cose trovate e non risolte sul preventivo → `STATUS.md`.
Next: (0) **[SICUREZZA] rigenerare il token Gitea creato il 2026-09-01**: e' stato incollato in chat, quindi va considerato esposto. Si revoca da Settings -> Applications e si rifa'; (1) verificare in prod con `?preview=1` **entrambe** le cose: stati task su **Caruso Speaker, fase «3 - Esecuzione»** (l'unica con «In corso» e «In revisione» insieme, 4 + 2 su 11) e box pagamenti — ma prima **inserire una scadenza** dal tab Pagamenti, altrimenti il riquadro non compare per definizione; (2) due `paid_at` storici valgono il primo del mese (2026-03-01, 2026-01-01, scritti dal vecchio selettore a mese) e il cliente ora li legge come «Pagato il 1 mar 2026»: correggerli se il giorno vero era un altro; (3) provare la chat in prod: modificare un messaggio admin e vederlo cambiare da solo entro ~20s senza duplicarsi; (4) sbloccare TidyCal con token o documentazione; (5) `LEAD_WEBHOOK_SECRET` su Coolify, senza cui `/api/webhooks/lead` risponde 403 a tutti; (6) poi v2.5 da `src/lib/audit/schema.ts` + `agents/`.
Resume file: None
+12 -1
View File
@@ -4,6 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
# ClientHub
> Progetto del workspace [IAMCAVALLI](../CLAUDE.md). Questa e' la cartella del **codice**: sito, social e Meta Ads sono progetti separati accanto a questo. Per il codice comanda questo file, non quello del workspace.
>
> **Per i testi che legge un cliente** — etichette di stato, pulsanti, messaggi di errore, email transazionali — vale [`../brand/voce.md`](../brand/voce.md) e la regola in `../.claude/rules/lingua-e-tono.md`. Italiano, registro premium e assertivo, e nessun numero affermato che non stia in `../brand/prove.md`.
Portale clienti per consulente di personal branding. Admin area + dashboard cliente via link segreto.
## Stack
@@ -50,7 +54,14 @@ Single source of truth: **`design-reference/DESIGN-SYSTEM.md`** ("Quiet Luxury"
- Reuse the existing primitives before building new ones: `StatusBadge`, `SearchInput`, `SegmentedToggle`, `editable-cell`, `option-select`/`option-multi-select` in `src/components/ui/`, and the shell in `src/components/admin/AdminShell.tsx`.
- Status/semantic colours (lead stages in `StatusBadge`) are the one sanctioned exception to the token rule — they use the Tailwind palette directly, each with an explicit `dark:` variant. So is the sidebar's brand green, and `src/lib/mailer.ts` (email HTML can't use CSS vars).
Other docs: `STATUS.md`**the single narrative document**: current status, backlog, and the operational lessons worth re-reading · `.planning/STATE.md` (GSD digest, kept under 100 lines, milestone v2.4) · `.planning/REQUIREMENTS.md` (current backlog) · `.planning/security/` (2026-07 audit, closed) · `.planning/milestones/` (closed-milestone archives).
Other docs: `STATUS.md`**the single narrative document**: current status, backlog, and the operational lessons worth re-reading · `.planning/STATE.md` (GSD digest, kept under 100 lines, milestone v2.4) · `.planning/REQUIREMENTS.md` (current backlog) · `.planning/security/` (2026-07 audit, closed) · `.planning/milestones/` (closed-milestone archives) · `.claude/CLAUDE.md` (guida della cartella di configurazione: skill, hook, dove sta la memoria) · `.claude/plans/` (i piani delle milestone).
## Project Skills
Due skill locali, in `.claude/skills/` (le altre sono globali in `~/.claude/skills/`):
- **`/preventivo`** — la catena `agent.ts → schema.ts → assemble.ts → ProposalDeck` e come non spezzarla; preflight sui dati placeholder di `profile.ts`.
- **`/audit`** — `npx tsx scripts/audit-fonti.ts <url>` mette in moto le cinque fonti di `src/lib/audit/sources/` (oggi inerti in prod) e dice cosa e' stato misurato.
Due hook di guardia in `.claude/hooks/`: `guardia-migration.sh` **blocca** l'SQL distruttivo sulle entita' protette (Data Safety LOCKED), `guardia-token.sh` **avvisa** sulle classi Tailwind grezze. Dettaglio e comandi di prova in `.claude/CLAUDE.md`.
## GSD Workflow
Planning in `.planning/`. Use `/gsd-plan-phase N``/gsd-execute-phase N`. State in `.planning/STATE.md`.
+476 -17
View File
@@ -1,6 +1,6 @@
# ClientHub (IAMCAVALLI) — Status
_Ultimo aggiornamento: 2026-08-18_
_Ultimo aggiornamento: 2026-08-22_
Questo è **l'unico documento narrativo** del progetto: a che punto siamo, cosa manca,
cosa abbiamo imparato. `.planning/STATE.md` è il digest che leggono i comandi
@@ -11,12 +11,14 @@ cosa abbiamo imparato. `.planning/STATE.md` è il digest che leggono i comandi
In produzione su `hub.iamcavalli.net` (Coolify/Hetzner, deploy automatico su push a
`main` via Gitea). Build verde, `npm audit` pulito.
Milestone **v2.5 "Audit"** in corso — Phase 27 a metà. In produzione c'è **solo lo
schema** dell'audit: nessuna pagina, né admin né pubblica.
Milestone **v2.5 "Audit"** in **pausa** a Phase 27 a metà: in produzione c'è solo lo
schema dell'audit, nessuna pagina. La pausa è una scelta del 2026-08-19 — prima le
**modifiche all'hub** chieste il 2026-08-18, poi il motore.
| Milestone | Fasi | Stato |
|---|---|---|
| v2.5 Audit | 2730 | 🔨 in corso — Phase 27 ~50% |
| Modifiche hub | — | 🔨 in corso — dashboard e progetti in produzione, pipeline a metà |
| v2.5 Audit | 2730 | ⏸️ in pausa — Phase 27 ~50% |
| v2.4 Post-vendita | 13, 26 | ✅ chiusa 2026-08-08, entrambe in produzione |
| v2.3 Email & Accesso | 2325 | ✅ shipped 2026-07-29, verificata E2E |
| v2.2 Sales Loop | 1822 | ✅ shipped 2026-06-20 |
@@ -25,7 +27,436 @@ schema** dell'audit: nessuna pagina, né admin né pubblica.
## In corso
### v2.5 — Audit (Phases 2730)
### Modifiche hub (richieste 2026-08-18)
Tre aree: dashboard, progetti, pipeline. Piano in
[`.claude/plans/v2.5-modifiche-hub.md`](.claude/plans/v2.5-modifiche-hub.md).
**In produzione dal 2026-08-19** (commit `4b135ce``19ed377`):
- **Progetti.** Via il tab Commenti (duplicava `/admin/conversazioni`, che è un
sovrainsieme stretto) e il timer dalla lista. Riepilogo soldi + avanzamento in testa
al progetto. **Timer per fase e task** — migration `0018`, `ON DELETE SET NULL` perché
cancellare un task non deve cancellare le ore lavorate.
- **Dashboard.** Inbox promossa in cima a piena larghezza, con da-quanto-aspetta e su
cosa è stato scritto. **Analytics per linea di prodotto** (Entry/Signature/Retainer,
letti dalla tassonomia). **Timeline delle consegne** con semaforo ritardo/anticipo,
scadenza dedotta da offerta + durata con override manuale `projects.due_date`.
- **Pipeline.** `POST /api/webhooks/lead`, un endpoint solo per form del sito e bridge;
regge payload piatto, `fields` annidati di Elementor e urlencoded, e non duplica chi
compila due volte. Provato contro il DB di produzione via tunnel, poi ripulito.
**In produzione dal 2026-08-20** (`3fcb10d`, `5547e55`) — due modifiche uscite dall'uso
reale del pannello, nessuna migration (verificato: `tasks.status` è `text` senza `CHECK`):
- **Rinomina di un valore di tassonomia** da `/admin/impostazioni`. Prima si poteva solo
aggiungere o eliminare: per cambiare nome a una fase bisognava cancellarla — il che la
strappava via da ogni servizio — e ricrearla a mano. `renamePoolValue` c'era già e
propagava ovunque tranne in un punto: `importOfferIntoProject` copia `services.fase`
dentro `phases.title` e poi ritrova la fase **confrontando i titoli**, perché
`phases.offer_phase_id` esiste in schema ma non viene mai popolata. Un rename fermo al
catalogo lasciava le fasi dei progetti col vecchio nome e al re-import ne nasceva una
duplicata. Ora propaga anche lì, con lo stesso match `trim`+`lowercase` del merge, ed
è l'unico rename che chiede conferma — dicendo quante fasi e quanti progetti tocca.
- **Stato task "In revisione"**, fra "In corso" e "Fatto", visibile anche al cliente. Il
lavoro vero non era il nuovo stato ma i tre letterali ricopiati a mano in otto file:
ora tutto deriva da `TASK_STATUSES` in `src/lib/task-status.ts`.
**In produzione dal 2026-08-20**, secondo giro (`b49d4bf`, `1fa8e1a`, migration `0019`):
- **Tab pagamenti.** Mettere una rata su "saldato" la faceva saltare in fondo: `payments`
non aveva **nessuna** colonna d'ordine e nessuna delle 13 query che la leggono aveva un
`ORDER BY`, quindi Postgres restituiva l'ordine fisico e una `UPDATE` in MVCC riscrive la
tupla in coda. In produzione **3 progetti su 5 erano già scombinati**. Per questo il
backfill della `0019` **non** ordina per `ctid` (avrebbe fotografato lo scombinamento) ma
per `percent DESC` con tie su `label`. Ora le rate si rinominano, gli importi si
sovrascrivono a mano (`amount_locked` li esclude dal ricalcolo) e lo scarto fra somma
rate e totale viene **dichiarato**, non aggiustato di nascosto. Schema a 3 rate: 50/25/25.
- **Riordino dei task** dentro la fase, trascinando. `PhasesTab` è un Server Component con
closure `"use server"` inline, quindi non è stato convertito: le righe restano
server-renderizzate e `SortableTaskList` ci monta attorno solo la maniglia.
Due bug chiusi per strada, entrambi vivi in produzione: `rescalePayments` azzerava le righe
con `percent` NULL appena una riga del progetto ne aveva uno (ed è proprio quello che
produce `splitPayment`), e il selettore di schema cancellava `status`/`paid_at` senza
chiedere, con due rate già saldate a rischio.
⚠️ **Provato a runtime, non ancora cliccato.** Build di produzione contro il DB vero via
tunnel SSH, in sola lettura: pagina progetto 200, 27 maniglie di trascinamento (esattamente
i task delle fasi con più di un task), rate nell'ordine giusto. Restano da esercitare a mano
le tre scritture nuove — `reorderTasks`, `updatePaymentField`, `clearPaymentOverride` — e il
drag vero e proprio.
⚠️ **Il giro precedente resta deployato ma non provato a mano.** Build e lint verdi, immagine
`9a57e45` viva, `/admin/login` risponde 200. Restano da esercitare in UI le due
interazioni: la matita di rinomina con il dialog di conferma, e il drag di un task in
"In revisione" con il controllo che la fase risulti *attiva*. Non sono state automatizzate
di proposito: **l'unico DB raggiungibile in locale è la produzione**, e una prova di
rinomina riscriverebbe titoli di fasi veri.
**In produzione dal 2026-08-21** (migration `0020`) — portale cliente:
- **Barra di avanzamento compatta e a tutta larghezza.** Rubava ~147px di altezza sopra
la piega per dire una cosa sola. Ora "Step N" e lo stato stanno sulla stessa riga, il
titolo della fase sotto — due righe invece di tre — e il padding scende da `py-8` a
`py-3`: ~88px. Il mock sta in `design-reference/Client-Portal-Progress-Bar`, scritto in
`slate-*` grezzi come tutti i mock: tradotto a token, non copiato.
- **Card offerta senza accordion.** Via "Cosa è compreso": la lista dei servizi non serve
al cliente e non viene più nemmeno **proiettata**`client-view.ts` ora legge dai
servizi i soli prezzi. `OffersSection` ha perso il suo unico `useState` e con esso il
`"use client"`.
- **"Valore incluso" → "Valore dell'offerta", con override.** Era sempre la somma dei
prezzi di catalogo dei servizi del tier: un artefatto che si muove quando si muove il
listino. In produzione produceva **€20.250 su offerte vendute a 7.000 e 5.500** — al
cliente sembrava uno sconto del 70%. Ora `project_offers.offer_value_override` vince
sulla somma; NULL torna al calcolo, `0` nasconde la riga. Si gestisce dal tab Offerte
del progetto, che mostra accanto la cifra calcolata per far vedere cosa si sta
sostituendo. La somma vive in `src/lib/offer-value.ts`, letta **sia** dal portale sia
dall'admin: duplicarla avrebbe fatto divergere le due viste.
- **"Prezzo finale" → "Investimento finale"** (solo una tantum; il ricorrente resta
"Canone mensile").
**Verificato quel che si poteva verificare.** Build verde, lint senza nuovi warning,
migration `0020` applicata a prod **prima** del push e colonna riletta dal DB a conferma;
deploy atterrato (l'immagine in esecuzione è taggata `44be190`, cioè HEAD). **La scrittura
dell'override è provata sul campo**: il 2026-08-21 in prod `Caruso Speaker / B` ha
`offer_value_override = 7500.00` a fronte di un calcolo di 20.250 — la `0020` aggiunge la
colonna e basta, non fa backfill, quindi quel numero è stato messo a mano dal tab Offerte.
⚠️ **Non reso a runtime, e va detto.** Le pagine non sono state aperte contro i dati veri:
`.env.local` è scaduto **su tre fronti**`ADMIN_PASSWORD`, `NEXTAUTH_SECRET` **e la
password del DB** — e l'host che dichiara (`178.104.27.55:5432`) è chiuso dal firewall.
Estrarre la password viva dal container è stato **bloccato dal classifier dei permessi**, e
non è stato aggirato. Quindi: *scritto e buildato*, non *provato*. **Da fare a mano:**
aprire il portale di un cliente e guardare barra e card offerta. **Resta un override da
mettere**: `Rossi Inc / B` è venduta a 7.000 e il portale le mostra ancora i 20.250 del
calcolo. (`Teckell / A`: calcolo e venduto coincidono a 200, non serve toccarla.) Perché la verifica locale torni possibile serve
riallineare `.env.local` alle variabili di Coolify (vedi § Note tecniche).
Cosa manca, e perché:
- **[BLOCCANTE] TidyCal.** Non ha webhook — è scritto nella loro FAQ, la strada
suggerita è Zapier/Make. Ha una REST API con Personal Access Token su tutti i piani,
ma **path, filtri per data e paginazione stanno dietro il login** e non sono
indicizzati. Serve che l'utente apra `tidycal.com/integrations` → API Keys e passi
token o documentazione: dedurre la forma dell'API violerebbe la regola che il
progetto si è già dato dopo il caso PageSpeed.
- **Alleggerire l'hub.** Senza perimetro. Da affrontare guardando cosa è davvero poco
usato, ora che dashboard e progetti sono in produzione.
- **Whop → audit.** Dipende dal motore: oggi sarebbe un innesco che non innesca nulla.
~~Il tab Commenti permetteva di rispondere sulla singola entità;
`replyToConversation` salva sempre sul thread generale.~~ **Chiuso dalla chat a canali**
(sotto): la risposta va sull'entità del canale attivo.
### Chat a canali (2026-08-21, in produzione)
Il portale aveva **una** conversazione con un selettore di fase in un dropdown. Due
problemi veri: il cliente non aveva modo di vedere *dove* c'era del non letto, e una
risposta dell'admin poteva atterrare su un'entità diversa da quella della domanda.
Ora i messaggi si organizzano in **canali** — "Generale" più uno per fase. Il canale non
è una colonna: `comments` resta polimorfica (`entity_type` + `entity_id`) e la chiave si
*deriva*. La derivazione sta in un posto solo, `src/lib/chat-channels.ts`, funzioni pure
importate da entrambe le sponde — **perché se cliente e admin la calcolassero ognuno per
conto suo, una risposta finirebbe in un tab diverso da quello della domanda, che è
esattamente il bug che questo giro chiude.** Task e deliverable non sono più scrivibili,
ma lo storico non resta orfano: rientra nel canale della fase proprietaria, conservando
il nome dell'entità come badge sul messaggio.
**Il letto/non-letto è asimmetrico fra le due sponde, di proposito.** Il cliente ha una
tabella nuova, `client_channel_reads`, una riga per `(client_id, channel_key)`: un
singolo `client_last_read_at` segnerebbe letti *tutti* i canali all'apertura della chat,
e il pallino sul singolo tab — l'unica cosa che dice al cliente dove guardare — perderebbe
senso. L'admin invece resta su `clients.admin_last_read_at` (già esistente dalla `0014`):
lì la lettura è per conversazione e il pallino per canale si deriva da quel timestamp,
quindi nessuna tabella nuova. Il thread admin porta `adminLastReadAt` come **snapshot**
congelato al caricamento: senza, l'auto-mark-read all'apertura cancellerebbe i pallini
sotto gli occhi di chi sta leggendo.
Migration `0021`, additiva, applicata a prod **prima** del push e tabella riletta a
conferma. Dentro anche un indice su `comments (entity_id, created_at)`: la tabella non ne
ha mai avuto uno, **nemmeno su `entity_id`**, dal giorno 0 — e il feed si legge esattamente
così. Deploy atterrato, immagine taggata `ac74a81` = HEAD.
⚠️ **Buildata, non provata a mano** — stesso motivo di sopra (`.env.local` scaduto).
**Da fare:** aprire il portale di un cliente, scrivere su una fase, rispondere da
`/admin/conversazioni` e verificare che la risposta torni **in quel tab**.
### Modifica dei messaggi + firma in chat (2026-08-21, in produzione)
Due mancanze emerse **provando la chat in produzione** — che è esattamente il tipo di
cosa che né il build né il lint possono dire.
**Modifica dei messaggi**, modello Slack/Discord: si corregge un proprio messaggio
**senza limite di tempo**, il testo precedente **non si conserva**, accanto all'ora
compare «modificato».
> ⚠️ **Scelta deliberata, non una svista.** Illimitato + nessuno storico significa che
> un messaggio scritto mesi fa resta riscrivibile, e l'unica difesa dell'altra parte è
> quell'etichetta. Il rischio è stato posto e accettato il 2026-08-21. Se un domani
> servisse dimostrare cosa era stato scritto, la strada è una tabella `comment_edits`:
> additiva, si aggiunge senza toccare niente di quanto c'è ora.
**Il punto difficile non era scrivere la modifica, era propagarla.** Il poll chiede i
messaggi con `created_at > since`, e una modifica **non cambia `created_at`** — quindi
l'altra parte avrebbe continuato a vedere il testo vecchio fino a un ricaricamento
completo. Il filtro ora guarda **anche `edited_at`**, e il watermark del client è il
**massimo fra i due su tutti i messaggi**: se restasse il `created_at` dell'ultimo, il
server rispedirebbe lo stesso messaggio modificato a ogni giro, per sempre. Le due cose
vanno insieme — una sola delle due e o la modifica non arriva, o arriva all'infinito.
Il merge per id era già in piedi, quindi nessun duplicato da temere.
**Il non-letto resta ancorato a `created_at`, di proposito**: correggere un refuso non
deve riaccendere il pallino di un canale già letto. Annotato nel codice, altrimenti al
prossimo refactor sembra una svista da "sistemare".
Autorizzazione: **si modifica solo ciò di cui si è autori** — il controllo è su
`author`, non solo sulla proprietà dell'entità, e lo rifà il server (il pulsante
nascosto non è una difesa). Migration `0022`, additiva, applicata a prod prima del push
e riletta a conferma: 11 messaggi, 0 marcati come modificati.
**Firma in chat.** Il nome era la stringa `iamcavalli` cablata nel pannello: il cliente
leggeva il marchio dove si aspetta una persona, e il monogramma diceva `IA`. Ora nome e
foto arrivano da `settings`**nessuna migration**, la tabella è già un key/value con i
suoi helper — e si impostano da `/admin/impostazioni`.
La foto è un **URL esterno**, e non è un ripiego: `/api/uploads/[...path]` **non esiste**.
La deroga al vincolo LOCKED #5 per le immagini dell'audit è scritta in CLAUDE.md ma il
codice non è mai stato costruito (Phase 27 è ferma allo schema). Un upload vero avrebbe
richiesto di modificare un vincolo LOCKED *e* costruire da zero volume persistente,
Server Action, rotta di lettura, whitelist MIME e limite di dimensione. Avatar assente o
rotto ricade sul monogramma, così un link che muore non lascia un buco.
Nell'inbox admin il nome **non** cambia: lì i propri messaggi dicono «Tu (Admin)», che è
già corretto. Il problema era solo come il cliente vede l'altra parte.
⚠️ **Buildata, non provata a mano** (`.env.local` scaduto). **Da fare, in quest'ordine:**
(1) con la chat del cliente **aperta**, modificare da `/admin/conversazioni` un messaggio
dell'admin in quel canale — entro ~20s il testo deve cambiare da solo, senza duplicarsi;
(2) verificare che il pallino **non** si riaccenda; (3) impostare nome e foto in
`/admin/impostazioni` e ricontrollare il portale; (4) da `?preview=1` il pulsante
«Modifica» non deve esserci.
**Rimandato, con la ragione scritta:** le **menzioni** con più persone invitate in un
portale. L'ostacolo non sono le notifiche ma l'attribuzione — `comments.author` contiene
solo `client`/`admin`, non *quale* persona, quindi oggi tre invitati sono tre «Tu»
identici. L'identità però esiste già: la sessione OTP porta l'email
(`ClientSession = { clientId, email, iat }`) e `client_emails` è la lista degli invitati.
Manca che le scritture della chat leggano la sessione invece del solo token — il lavoro
già annotato in `client-chat.ts` come «un lavoro a sé». Ordine giusto: **attribuzione**,
poi notifiche (Resend c'è già), poi eventualmente menzioni. Molto probabilmente vedere
*chi* ha scritto risolve gran parte del problema da solo.
### Stati dei task, resi leggibili nel portale (2026-08-22)
Nella Timeline — la vista di default — lo stato di un task era **solo un cerchietto
colorato**: ambra col puntino da 1.5px per «In corso», violetto col puntino da 2px per
«In revisione». A 20px sono lo stesso oggetto. Solo «In revisione» aveva un `title`; gli
altri tre stati non avevano né testo, né tooltip, né `aria-label`. Il design system chiede
l'opposto, ed è la sua regola UX 3: **colore + testo, mai colore da solo**.
**«In revisione» non significa quello che dice il commit che l'ha introdotto.** `5547e55`
lo descriveva come controllo qualità interno («finito, ma da controllare prima di
consegnarlo»). Nel flusso reale è **«consegnato, aspetta l'OK del cliente»**: la palla è al
cliente. Un cerchietto muto per uno stato che *richiede un'azione* era il problema vero,
più della somiglianza fra i due colori.
Cosa è cambiato:
- **Le quattro icone si distinguono per forma**, non solo per colore, quindi reggono anche
in bianco e nero e con un daltonismo: vuoto → punto → **spunta vuota** → spunta piena. La
spunta compare quando il lavoro è finito e si riempie quando è confermato.
- **Pill di testo solo su «In corso» e «In revisione».** «Da fare» è un cerchio vuoto e
«Fatto» ha il titolo barrato: etichettarli aggiungeva rumore senza aggiungere informazione.
- **Il conteggio dei task in attesa sta nell'header della fase**, accanto a «4 di 8 task»,
quindi si legge anche a card chiusa. Serve davvero: l'admin può forzare una fase su
«Completata» dalla select, e in quel caso la card parte collassata con dentro la richiesta.
- **Legenda + micro-copy** sopra entrambe le viste, che dice chi revisiona e che **ogni
pagina include un giro di revisione** — l'informazione commerciale che il cliente non
aveva da nessuna parte. Rimanda alla chat della fase, che esiste già.
- **Colonne del Kanban cliente colorate**, con la stessa tinta dell'icona in Timeline:
prima erano quattro colonne grigie identiche.
- **Lo screen reader legge lo stato su tutti e quattro** (icona `aria-hidden` + `sr-only`),
non solo sui due che portano la pill.
**La barra di avanzamento non è stata toccata.** Un task in revisione conta come uno in
corso — cioè zero. Cambia il *tipo* di lavoro, non l'avanzamento: gonfiare la percentuale
avrebbe fatto dire alla barra una cosa che il contatore «x di y task» smentiva una riga
sotto.
Visual e copy stanno in un solo file, `src/components/client/TaskStatusIndicator.tsx`, e la
legenda si genera da `TASK_STATUSES`: aggiungere un quinto stato non lascia indietro una
lista scritta a mano. È la stessa disciplina di `5547e55`, nata proprio perché due liste
fisse avevano fatto sparire dei task dal portale **senza un errore**.
**In produzione dal 2026-08-22** (`2e9bd2a`, più `8b54f48` che toglie la doppia lettura
della pill allo screen reader). Nessuna migration: è solo UI. Build e typecheck verdi, lint
invariato — 12 errori, tutti preesistenti e nessuno nei file toccati. Verificato che il
bundle servito in produzione contenga davvero il codice nuovo: legenda, micro-copy e il
conteggio «in attesa di riscontro» sono nei chunk statici del container vivo.
⚠️ **Non ancora visto a schermo, e non posso vederlo io.** Il portale sta dietro il gate OTP
e l'anteprima admin vuole una sessione. La verifica che conta va fatta a mano: `?preview=1`
da admin loggato su **Caruso Speaker**, fase «3 - Esecuzione» — l'unica dove «In corso» (4)
e «In revisione» (2) convivono, su 11 task, quindi l'unica dove si vede se ora si
distinguono davvero. Da guardare anche col tema scuro: è lì che l'icona «In corso» prima
non aveva varianti.
### Task cancellati e date dei pagamenti (2026-08-22)
Due buchi che si vedevano solo aprendo il portale dalla parte del cliente.
**Un task tolto dal lavoro non aveva dove stare.** L'admin poteva solo cancellarlo dal DB
— e allora il cliente non capiva perché una voce che aveva letto la settimana prima non
c'era più — oppure lasciarlo lì a fingersi «Da fare» per sempre. Ora `cancelled` è il
quinto stato: **X dentro il cerchio, titolo barrato**, e la pill «Cancellata».
È l'unico stato *chiuso* che porta la pill, e non è un'incoerenza: «Fatto» e «Cancellata»
sono **entrambi barrati**, e scambiarli significa credere consegnato qualcosa che non
esiste. La forma da sola non basta a coprire quel rischio.
Il lavoro vero, di nuovo, non era il nuovo stato ma i denominatori. Un task cancellato esce
da **tutti**: percentuale di fase, percentuale globale, board delle consegne, riepilogo
admin — attraverso un solo `countsTowardProgress()` in `src/lib/task-status.ts`, non cinque
`!== "cancelled"` sparsi in giro. Contarlo terrebbe la fase sotto il 100% per un lavoro che
nessuno farà mai; contarlo come fatto racconterebbe una consegna mai avvenuta.
`recomputePhaseStatus` lo ignora allo stesso modo: senza, cancellare l'ultima voce di una
fase la lasciava «In corso» per sempre. Se restano **solo** cancellati la fase torna «Da
iniziare» — è degenere, ma «Completata» al cliente racconterebbe una consegna che non c'è
stata.
Nel Kanban cliente la colonna «Cancellate» compare **solo se ha dentro qualcosa**, così le
quattro che raccontano il lavoro si tengono la larghezza — ma mai se è piena, quindi nessun
task sparisce dalla board: era esattamente il bug che aveva fatto nascere `TASK_STATUSES`.
Nell'admin la colonna c'è sempre, ed è così che si cancella un task: trascinandocelo.
**Il box pagamenti non sapeva dire quando si paga.** `payments` aveva `paid_at` — quando una
rata è stata incassata — e nient'altro: nessun campo per la scadenza. Il portale non poteva
rispondere alla domanda più ovvia del cliente, e non c'era niente su cui agganciare il
promemoria via email. **Migration `0023`**, additiva: `payments.due_date`, nullable senza
default, più un indice parziale su cui girerà la query del promemoria («le non saldate in
scadenza entro N giorni», che l'indice `(project_id, sort_order)` non copre).
Nullable è una scelta: una rata senza data concordata è normale, e un default inventerebbe
una scadenza che nessuno ha pattuito. **Il portale mostra la data solo se c'è.**
Cosa vede il cliente:
- In cima al box, **«Prossimo pagamento»**: data lunga e conto alla rovescia in parole —
«tra 12 giorni», «domani», «oggi». Se è passata, il riquadro diventa rosso e dice
«Pagamento scaduto — scaduto da 3 giorni». Un arretrato batte sempre la rata del mese
prossimo: se c'è, *quello* è il prossimo pagamento.
- Su ogni riga, la sua data: **«Scade il…»** se aperta, **«Pagato il…»** se saldata.
- **Nessun importo per riga.** LOCKED #2 resta dov'era: le date non sono cifre.
I giorni si contano in `src/lib/payment-dates.ts`, sui **giorni civili a Roma** e non sugli
istanti: il container gira a UTC, e «manca una settimana» non deve cambiare risposta a
seconda del fuso di chi renderizza. È lo stesso modulo che userà il promemoria email —
scritto ora proprio perché la mail e il portale non si contraddicano su quanti giorni
mancano. Le date si salvano a **mezzogiorno UTC**, non a mezzanotte: a mezzanotte UTC il
giorno civile a Roma è già quello dopo, e la data tornerebbe indietro di un giorno appena
riletta.
Lato admin ogni rata ha ora il campo **Scadenza**, e «Incassato nel mese» è diventato
**«Incassato il»** — precisione al giorno, che le analytics non notano perché raggruppano
con `extract(month from paid_at)`. Il warning sul cambio schema rate conta anche le
scadenze: una rata «da saldare» con una data è già sotto gli occhi del cliente, e
sparirebbe in silenzio.
**In produzione dal 2026-08-22** (`fe76789`). Migration `0023` applicata a prod **prima**
del push, come da procedura: `ALTER TABLE` + `CREATE INDEX`, 12 righe in tabella, nessuna
toccata. Build, typecheck e lint verdi — lint invariato, nessun problema nuovo nei file
toccati.
⚠️ **Due cose da fare a mano.** (1) Il portale non l'ho visto a schermo: vale la stessa
verifica `?preview=1` della sezione precedente, e finché nessuna rata ha una `due_date` il
riquadro «Prossimo pagamento» **non compare per definizione** — va messa una scadenza dal
tab Pagamenti per vederlo. (2) Due `paid_at` storici valgono il **primo del mese**
(2026-03-01 e 2026-01-01): li ha scritti così il vecchio selettore a mese, e ora il cliente
li legge come «Pagato il 1 mar 2026». Se il giorno vero era un altro, si corregge dal nuovo
campo.
### Architettura `.claude/` (2026-08-26)
Configurazione, non prodotto: l'unico file di codice che nasce qui è `scripts/audit-fonti.ts`.
`hub/.claude/` aveva tre cose dentro — `rules/`, `settings.json`, `settings.local.json` — e
nessun posto dove mettere una skill, un hook o un piano. Ora ha lo scheletro completo e un
**`.claude/CLAUDE.md`** che spiega cosa va dove. Non è «il CLAUDE.md che mancava»: quello di
progetto sta alla radice ed è lui a comandare. Questo è la guida della cartella di
configurazione, e si carica quando ci lavori dentro.
**Due skill locali** (le altre restano globali in `~/.claude/skills/`, e copiarle qui
significherebbe averne due destinate a divergere):
- **`/preventivo`** — non contiene un prompt di generazione, e non deve averlo: quello vive
in `agent.ts` ed è l'unico. Contiene la mappa della catena `agent.ts → schema.ts →
assemble.ts → ProposalDeck + sections/`, con la tabella dei tre modi di romperla — e solo
uno fa rumore. Campo nello schema e non nel prompt: **ogni** generazione muore con
«Contenuto AI non valido». Campo nel prompt e non nello schema: Zod lo scarta in silenzio.
Campo nello schema senza sezione che lo legge: generazione verde, sezione assente.
Porta anche `check-profilo.sh`, il preflight sui dati finti.
- **`/audit`** — guida `scripts/audit-fonti.ts`, nuovo, che mette in moto le cinque fonti di
`src/lib/audit/sources/`, in produzione dal 2026-08-19 ma **mai chiamate da nessuno**.
Provate su giojello.com il 2026-08-26: cinque su cinque, **42,7 s** (PageSpeed 34,2), tre
pagine raccolte, CrUX disponibile a livello URL, PageSpeed mobile 58 / desktop 93. Il
grezzo finisce in `audit-fonti-<dominio>.json`, gitignorato: è lì che ogni numero del
documento dovrà essere rintracciabile. Distinto da `spike-audit.ts`, che è un altro
attrezzo e risponde a un'altra domanda.
**Due hook di guardia**, provati a mano prima di fidarsene (6 casi il primo, 5 il secondo):
- `guardia-migration.sh` **blocca** — è il vincolo Data Safety (LOCKED) fatto rispettare dalla
macchina invece che dalla memoria. Toglie i commenti e spezza per `;` prima di guardare, così
«non fare mai DROP TABLE payments» dentro un commento non fa scattare nulla, e
`ALTER TABLE clients DROP COLUMN token` sì. Fuori da `src/db/migrations/` non ha voce.
- `guardia-token.sh` **avvisa e basta**. Bloccare, con ~450 occorrenze di debito già in casa,
lo renderebbe un ostacolo da disattivare invece di un promemoria da leggere. Whitelist per
le eccezioni sanzionate: `StatusBadge`, `AdminShell`, `mailer`, `globals.css`,
`design-reference/`.
**I tre piani della milestone sono entrati nel repo.** Stavano solo in `~/.claude/plans/` con
nomi generati a caso, e STATE.md avvertiva che senza quelli v2.5 non era ricostruibile: un
piano che vive sul portatile di chi l'ha scritto non è documentazione, è un ricordo. Passati
al setaccio per credenziali prima di committarli — dentro ci sono nomi di variabili
d'ambiente, mai i loro valori.
Corretta anche una riga di `rules/memory-discipline.md` che mandava nel posto sbagliato: la
memoria persistente di hub sta sotto la chiave `…-Vault-IAMCAVALLI-hub`, non quella del
workspace senza suffisso. Sedici file erano nella prima, la regola indicava la seconda.
#### Due cose trovate sul preventivo, e non risolte
Nessuna delle due è stata toccata: sono decisioni, non refusi.
**`profile.ts` spedisce dati placeholder in ogni preventivo generato.** Tre testimonianze sono
letteralmente `{ name: "Cliente 1", quote: "→ Aggiungere testimonianza reale." }`; accanto,
email `simone@iamcavalli.com` e sito `iamcavalli.com` quando il dominio è **iamcavalli.net**, e
titolo «Strategist · Brand & Business» quando il posizionamento è **Full Stack Designer**. Il
file è uno **snapshot**: finisce in `proposals.content` al momento della generazione e ci resta
anche correggendo il file dopo. Non è riempibile a occhio — `brand/prove.md` è ancora
`stato: scheletro-intervista`, con i numeri marcati «da verificare». Quindi la mossa non è
inventare: è **non renderizzare il blocco** finché il dato non c'è, allineare i campi già
verificati, e portare la domanda a chi ha le testimonianze vere. `check-profilo.sh` oggi
fallisce con 19 occorrenze, ed è il comportamento giusto.
**`unitPrice` viaggia nel payload RSC di `/preventivo/[slug]`.** `PricingSection` renderizza
solo `publicPrice ?? servicesTotal` e il nome dei servizi — corretto. Ma `assembleProposal`
mette in `content.offer.tiers[].services[].unitPrice` anche il prezzo unitario, e
`ProposalDeck` è `"use client"`: riceve l'intero `proposal` come prop da un server component,
quindi tutto l'oggetto viene serializzato nella pagina, renderizzato o no. È la stessa trappola
del gate OTP: **sparire a schermo non è sparire**. Verificato leggendo il codice, **non ancora
a schermo** — serve `curl` su un preventivo pubblicato e `LC_ALL=C grep -c unitPrice`. Se
conferma, la correzione è una proiezione client-safe come già fa `client-view.ts` per il
portale, non nascondere una sezione.
Terza, minore: `agent.ts` chiama il modello con `max_tokens: 8192` per uno schema che chiede
fino a 5 problemi + 5 soluzioni + matrice. Se la risposta viene troncata l'errore non dice
«troncata», dice «L'AI ha prodotto JSON non valido» — e manda a cercare nel posto sbagliato.
Il controllo di `stop_reason` costa tre righe.
### v2.5 — Audit (Phases 2730), in pausa
Il servizio di analisi sito (tre livelli: **Radiografia / Prima-Dopo / Rotta**) diventa
un documento privato su `/audit/[slug]`, generato da un motore multi-agente e rifinito a
@@ -36,17 +467,20 @@ Fatto finora (Phase 27, ~50%):
- **[prod 2026-08-18] Schema.** Migration `0017_audits.sql`, 7 tabelle additive, più
`checklist_items` seminata con 264 voci. Nessuna UI le legge ancora.
- **[non pushato] Le fonti del motore.** `src/lib/audit/sources/` PageSpeed, CrUX,
Wayback, RDAP, robots/sitemap/JSON-LD, header. Provate sul campo su giojello.com:
giro completo in 73 s, tutte e cinque hanno risposto.
- **[prod 2026-08-19, inerte] Le fonti del motore.** `src/lib/audit/sources/`
PageSpeed, CrUX, Wayback, RDAP, robots/sitemap/JSON-LD, header. Provate sul campo su
giojello.com: giro completo in 73 s, tutte e cinque hanno risposto. Deployate, ma
**nessuna route le chiama**.
Manca: agent + sintetizzatore + pipeline, storage immagini, editor admin, pagina
pubblica. Dettaglio in [`.planning/ROADMAP.md`](.planning/ROADMAP.md) e
[`.planning/REQUIREMENTS.md`](.planning/REQUIREMENTS.md).
⚠️ **I due piani della milestone stanno in `~/.claude/plans/`, fuori dal repo**
(`…woolly-puddle.md` per il documento, `…radiant-valley.md` per il motore). Senza quei
file la milestone non è ricostruibile.
I due piani della milestone sono **nel repo dal 2026-08-26**:
[`.claude/plans/v2.5-audit-documento.md`](.claude/plans/v2.5-audit-documento.md) per il
documento, [`.claude/plans/v2.5-audit-motore.md`](.claude/plans/v2.5-audit-motore.md) per il
motore. Prima stavano solo in `~/.claude/plans/`, e senza quei file la milestone non era
ricostruibile: un piano che vive sul portatile di chi l'ha scritto non è documentazione.
## Fatto
@@ -124,11 +558,29 @@ cui si ricasca.
`failed`, non fidarsi di valori DKIM annotati in passato: rileggerli da
`GET /domains` e confrontarli con
`dig +short TXT resend._domainkey.iamcavalli.net @8.8.8.8`.
- **`.env.local` non è allineato a produzione.** `ADMIN_PASSWORD` e
`NEXTAUTH_SECRET` sono stati ruotati il 2026-07-28 **solo su Coolify**. Per
costruire firme o hash validi in prod vanno letti da Coolify, non da `.env.local`.
- **`.env.local` non è allineato a produzione, e la deriva è peggiorata.** Il 2026-07-28
erano stati ruotati **solo su Coolify** `ADMIN_PASSWORD` e `NEXTAUTH_SECRET`; il
2026-08-21 si è scoperto stale anche **la password del DB** (Postgres risponde
`password authentication failed`) e chiuso dal firewall l'host che il file dichiara,
`178.104.27.55:5432` — il DB vero è su `127.0.0.1:54321` dietro tunnel, quindi vanno
riscritti host **e** porta. Recuperare la password viva dal container è **bloccato dal
classifier**: non si aggira, si chiede all'utente di riallineare il file. Conseguenza
operativa: finché resta così, **una modifica al portale si verifica solo guardandola in
produzione** — e va detto, invece di far passare "buildato" per "funziona". Le migration
non ne soffrono (`docker exec` non usa quelle credenziali).
- **L'API Coolify rifiuta `is_build_time`** con 422 sul POST a
`/api/v1/applications/<uuid>/envs`: mandare solo `key`, `value`, `is_preview`.
- **Una lista di valori validi dimentica in silenzio, una negazione no.**
`recomputePhaseStatus` decideva "fase iniziata" con `status === "in_progress" ||
status === "done"`. Aggiungendo "In revisione", una fase con tutti i task in revisione
non rientrava né in `allDone` né in `anyActive` e **retrocedeva a "upcoming"**: si
leggeva "non iniziata" quando era quasi finita. Stesso schema in `ClientKanban`, dove
i task erano ripartiti da un oggetto a tre chiavi fisse invece che 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**. La causa a monte di entrambi era
lo stesso `as` al confine del portale, che TypeScript non verifica. Quando un insieme
di stati può crescere: derivare le colonne dalla costante, scrivere il predicato come
negazione, e normalizzare al confine invece di castare.
- **Playwright non funziona contro `npm run dev`**: la CSP blocca `eval` e i client
component non si idratano. Serve il build di produzione.
- **Le API di Google cambiano forma sotto i piedi, e in silenzio.** Scrivendo
@@ -149,9 +601,16 @@ cui si ricasca.
## Note tecniche
- **Il DB di `.env.local` È la produzione** (178.104.27.55). Non esiste un database
di sviluppo separato: qualunque cosa si esegua in locale scrive su dati reali.
Verificare i conteggi delle tabelle protette prima e dopo ogni prova.
- **Non esiste un database di sviluppo.** Quello puntato da `.env.local` **è** la
produzione: qualunque cosa si esegua in locale scrive su dati reali — verificare i
conteggi delle tabelle protette prima e dopo ogni prova. ⚠️ Dal 2026-08-21 quelle
credenziali **non autenticano più** e l'host che il file dichiara è chiuso: per
raggiungere il DB servono il tunnel su `127.0.0.1:54321` **e** una password viva presa
da Coolify. Fino ad allora `docker exec` via SSH è l'unica via.
- **Controllare che un deploy sia atterrato**: `docker ps` sul server mostra l'immagine, e
**il tag È lo SHA del commit** — si confronta con `git rev-parse --short HEAD`. Coolify
ci mette ~2 minuti e cambia il suffisso del container a ogni giro: un controllo immediato
fa credere a torto che il webhook sia rotto.
- **Migrazioni**: SQL scritto a mano in `src/db/migrations/` (`drizzle-kit generate`
è rotto — vanno tenuti in sync `schema.ts` e l'SQL). Si applicano **da locale via
SSH + docker exec**, senza tunnel — procedura completa in `CLAUDE.md`. Il tunnel
+170
View File
@@ -0,0 +1,170 @@
<div class="stepper-original-style">
<!-- Linea di avanzamento continua (come nello screenshot) -->
<div class="line-track">
<div class="line-progress" style="width: 20%;"></div>
</div>
<!-- STEPS -->
<div class="steps-wrapper">
<!-- Step 1: Completato -->
<div class="step-node completed">
<div class="circle">
<svg width="14" height="11" viewBox="0 0 14 11" fill="none">
<path d="M1.5 5.5L5 9L12.5 1.5" stroke="white" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="step-text">
<div class="title-row">
<span class="step-title">Step 1</span>
<span class="status completed">COMPLETATA</span>
</div>
<div class="step-desc">Fase 1 → Onboarding / Setup</div>
</div>
</div>
<!-- Step 2 -->
<div class="step-node">
<div class="circle">2</div>
<div class="step-text">
<div class="title-row">
<span class="step-title">Step 2</span>
<span class="status">DA INIZIARE</span>
</div>
<div class="step-desc">Fase 2 → Analisi / Strategia</div>
</div>
</div>
<!-- Step 3 -->
<div class="step-node">
<div class="circle">3</div>
<div class="step-text">
<div class="title-row">
<span class="step-title">Step 3</span>
<span class="status">DA INIZIARE</span>
</div>
<div class="step-desc">Fase 3 → Esecuzione / Core</div>
</div>
</div>
<!-- Step 4 -->
<div class="step-node">
<div class="circle">4</div>
<div class="step-text">
<div class="title-row">
<span class="step-title">Step 4</span>
<span class="status">DA INIZIARE</span>
</div>
<div class="step-desc">Fase 4 → Raffinamento / Extra</div>
</div>
</div>
</div>
</div>
<style>
.stepper-original-style {
position: relative;
width: 100%;
max-width: 1000px;
padding: 10px 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
box-sizing: border-box;
}
/* La linea continua dello screenshot originale */
.line-track {
position: absolute;
top: 28px; /* Centrata perfettamente dietro i cerchi */
left: 60px;
right: 60px;
height: 4px;
background-color: #edf2f4; /* Grigio chiaro dello screen */
border-radius: 2px;
z-index: 1;
}
.line-progress {
height: 100%;
background-color: #0d4a3a; /* Verde scuro originale */
border-radius: 2px;
transition: width 0.3s ease;
}
/* Griglia dei nodi */
.steps-wrapper {
position: relative;
z-index: 2;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.step-node {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
flex: 1;
}
/* Cerchi identici allo screen originale */
.circle {
width: 38px;
height: 38px;
border-radius: 50%;
background-color: #ffffff;
color: #334155;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
/* Ombra morbida originale */
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
margin-bottom: 8px;
}
/* Cerchio completato (Verde) */
.step-node.completed .circle {
background-color: #0d4a3a;
color: #ffffff;
box-shadow: 0 4px 10px rgba(13, 74, 58, 0.2);
}
/* Testi compatti a 2 righe */
.step-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.title-row {
display: flex;
align-items: center;
gap: 6px;
}
.step-title {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.status {
font-size: 10px;
font-weight: 700;
color: #94a3b8;
letter-spacing: 0.2px;
}
.status.completed {
color: #059669; /* Verde stato completato */
}
.step-desc {
font-size: 11px;
color: #64748b;
font-weight: 400;
white-space: nowrap;
}
</style>
+90 -11
View File
@@ -146,11 +146,15 @@ across all future admin pages.
| `LeadTable` | `src/components/admin/leads/LeadTable.tsx` | Luxury table restyle: `bg-card rounded-xl shadow-card`, uppercase muted headers, `hover:bg-muted/40` rows, uses `StatusBadge`. |
| `LeadsKanbanBoard` | `src/components/admin/leads/LeadsKanbanBoard.tsx` | Kanban restyle: `bg-muted/60` columns with count pills, `bg-card` cards with `hover:border-primary/30`. Uses `@dnd-kit`, keeps all 6 lead stages. |
| `LeadsViewToggle` | `src/components/admin/leads/LeadsViewToggle.tsx` | Wires `SearchInput` + `SegmentedToggle` together with the table/kanban views. |
| `MilestoneStepper` | `src/components/client/MilestoneStepper.tsx` | Client-portal horizontal milestone stepper: one node per real phase, connecting line filled by `global_progress_pct`. Done = filled `bg-primary` ✓, active = `border-primary` outline, upcoming = `bg-muted`. |
| `MilestoneStepper` | `src/components/client/MilestoneStepper.tsx` | Client-portal horizontal milestone stepper: one node per real phase, connecting line filled by `global_progress_pct`. Done = filled `bg-primary` ✓, active = `border-primary` outline, upcoming = `bg-muted`. Layout **compatto** (`py-3`, due sole righe di testo: `Step N` + stato in linea, titolo fase sotto) — mock: `design-reference/Client-Portal-Progress-Bar`. |
| `LeadDetail` | `src/components/admin/leads/LeadDetail.tsx` | Lead detail page (`/admin/pipeline/[id]`): bespoke header (name + inline `StatusBadge` + action bar), asymmetric `lg:grid-cols-3` layout (1/3 profile card w/ icon-chip rows + tags, 2/3 Note/Follow-up + unified timeline). |
| `ClientProfitability` | `src/components/admin/dashboard/ClientProfitability.tsx` | Dashboard widget: valore orario reale per cliente (`getClientProfitability` → contrattualizzato ÷ ore tracciate) con badge margine (Ottimo `bg-primary/10 text-primary` / In Linea `bg-muted`). |
| `YearSelector` | `src/components/admin/YearSelector.tsx` | Selettore anno restilizzato a pill (`rounded-full border-border bg-card shadow-card`), frecce ←/→, valore `font-mono`. Guida i dati year-scoped della dashboard via `?year=`. |
| `ConversationsView` | `src/components/admin/conversazioni/ConversationsView.tsx` | Inbox messaggi clienti (`/admin/conversazioni`) a due pannelli: lista conversazioni filtrabile (search su nome/brand/ultimo messaggio, pallino unread) + thread attivo con bolle messaggio (admin a destra `bg-primary`, cliente a sinistra), badge entità per messaggio (Generale/Fase/Task/Deliverable) e form di risposta. Auto-mark-read all'apertura. |
| `ConversationsView` | `src/components/admin/conversazioni/ConversationsView.tsx` | Inbox messaggi clienti (`/admin/conversazioni`) a due pannelli: lista conversazioni filtrabile (search su nome/brand/ultimo messaggio, pallino unread) + thread attivo con **tab per canale** (Generale + una per fase, pallino accent sul tab non letto) e bolle messaggio (admin a destra `bg-primary`, cliente a sinistra). Badge entità solo sui messaggi storici di task/deliverable. La risposta va sull'entità del canale attivo. Auto-mark-read all'apertura, su snapshot di `adminLastReadAt`. |
| `ChatPanel` | `src/components/client/ChatPanel.tsx` | Chat del portale cliente: FAB `h-14 w-14` fisso in basso a destra con pallino unread, drawer laterale `fixed inset-y-0 right-0 bg-card shadow-2xl` che in `expanded` diventa full-screen (`Maximize2`/`Minimize2`, `Esc` chiude prima l'espansione poi il pannello). Tab canale scrollabili orizzontalmente (`no-scrollbar`), invio **e modifica** ottimistici, polling `GET /api/client/chat` ogni 20s. La firma di chi risponde arriva da `settings` via `ChatData`, non è più cablata. |
| `MessageRow` | `src/components/client/ChatPanel.tsx` | Una riga del feed, componente a sé perché la modifica ha bisogno di stato per messaggio (bozza, salvataggio, errore). Divider di giornata, raggruppamento dei messaggi consecutivi dello stesso autore entro `GROUP_WINDOW_MS`, badge entità per lo storico di task/deliverable, «(modificato)» inline dopo il corpo. |
| `Avatar` (chat) | `src/components/client/ChatPanel.tsx` | Cerchio `h-9 w-9` con immagine da URL esterno e **fallback al monogramma** se `src` manca o il caricamento fallisce (`onError`). `<img>` nudo e non `next/image`: l'URL lo incolla l'utente in Impostazioni e non è noto in fase di build, quindi non si può dichiarare l'host in `next.config`. |
| `ChatProvider` | `src/components/client/ChatProvider.tsx` | Context del solo stato UI (aperto/espanso/canale attivo) — i messaggi restano dentro `ChatPanel`, unico a fare polling e a tenere la copia optimistic. Il canale sta nel context perché lo decide anche chi è fuori dal pannello: `openChat(phaseId)` dalla bolla di una `PhaseCard` apre la chat già sulla fase giusta. |
| `MessagesWidget` | `src/components/admin/dashboard/MessagesWidget.tsx` | Widget dashboard "Messaggi Clienti": chat clienti in attesa di risposta (`getConversations()` filtrato su `unread`), pill emerald "N Nuovi" + pallino pulsante, righe con anteprima e link `Rispondi →` deep-link a `/admin/conversazioni?c=<clientId>`. Empty state "Nessun messaggio in attesa ✓". |
| `CopyLinkButton` | `src/components/admin/CopyLinkButton.tsx` | Icon button che copia un URL assoluto (`window.location.origin + path`) negli appunti; swap icona → check emerald per 1.5s con tooltip "Copiato!". Usato nella lista Clienti per il link profilo pubblico. |
| `ClientRow` | `src/components/admin/ClientRow.tsx` | Riga tabella Clienti restilizzata Quiet Luxury: nome link → dettaglio + sottotitolo brand/progetti, colonne numeriche `font-mono` right-aligned (LTV, incassato come pill emerald, da saldare), cella "Link profilo" con pill mono troncata (apre portale in new tab) + `CopyLinkButton`. |
@@ -183,25 +187,70 @@ layout: no in-`<main>` `PageHeader` (sidebar + shell header carry the chrome).
- Widget rimossi dal legacy: chart incassi mensili, card "Da incassare"/"Clienti
acquisiti", barre "Ore per cliente" (sostituite dalla redditività oraria).
### Conversazioni (inbox) notes
### Chat a canali (portale + inbox) notes
`/admin/conversazioni` è l'inbox unificata dei messaggi clienti (replica
`design-reference/pagina-conversazioni.html`). Ogni conversazione è **per cliente**:
raccoglie tutti i suoi commenti — `general` + quelli su fasi/task/deliverable —
in un unico thread ordinato, con badge entità per messaggio.
I messaggi vivono in **canali**: "Generale" più uno per fase. Il canale non è una
colonna — `comments` resta polimorfica (`entity_type` + `entity_id`) — ma una
chiave *derivata*, e la derivazione sta in un posto solo:
`src/lib/chat-channels.ts`, funzioni pure senza accesso al DB, importate da
entrambe le sponde.
"Generale" -> clients.id
una fase -> phases.id
task -> la chiave della fase proprietaria (tasks.phase_id)
deliverable -> la chiave della fase del task proprietario
**Perché in un modulo condiviso**: se cliente e admin calcolassero il canale
ognuno per conto suo, una risposta atterrerebbe in un tab diverso da quello in
cui è stata scritta la domanda. Task e deliverable non sono più scrivibili da
nessuna UI, ma lo storico esiste e rientra nel canale della fase, conservando il
nome dell'entità come badge sul messaggio.
**Il letto/non-letto è asimmetrico fra le due sponde, di proposito:**
- **Cliente** → tabella `client_channel_reads` (migration 0021), una riga per
`(client_id, channel_key)` con upsert `ON CONFLICT … DO UPDATE`. Serve una
riga per canale perché un singolo `client_last_read_at` segnerebbe letti
*tutti* i canali all'apertura della chat, e il pallino sul singolo tab —
l'unica cosa che dice al cliente dove guardare — perderebbe senso.
- **Admin** → resta su `clients.admin_last_read_at` (0014): lì la lettura è già
a livello di conversazione, e il pallino per canale si deriva da quel
timestamp. Nessuna tabella nuova.
`/admin/conversazioni` (replica `design-reference/pagina-conversazioni.html`)
resta a due pannelli, con i canali come tab dentro il thread attivo.
- **Fonte unread**: `clients.admin_last_read_at` confrontato con l'ultimo
messaggio autore `client`. Una conversazione è non letta se esiste un messaggio
cliente dopo `admin_last_read_at` (o se è `NULL`). Query in
`src/lib/conversations-queries.ts` (`getConversations`, `getConversationThread`,
`getUnreadConversationsCount`).
`getUnreadConversationsCount`). Il thread porta con sé `adminLastReadAt` come
**snapshot**: congelato al caricamento, così l'auto-mark-read all'apertura non
cancella i pallini per canale sotto gli occhi di chi sta leggendo.
- **Badge sidebar**: voce "Conversazioni" (`AdminSidebar`) con pill emerald del
numero di clienti con non letti (pallino verde se collapsed), alimentato da
`getUnreadConversationsCount()` in `src/app/admin/layout.tsx`. Stessa fonte di
verità del `MessagesWidget` in dashboard.
- **Reply**: salvata come commento `general` sul cliente e aggiorna
`admin_last_read_at` (mark-read). L'apertura di una conversazione fa
auto-mark-read. Deep-link a un thread specifico via `?c=<clientId>`.
- **Reply**: scritta sull'entità del canale attivo (cliente per "Generale",
fase per gli altri) — non più sempre `general` — e aggiorna
`admin_last_read_at`. Deep-link a un thread via `?c=<clientId>`.
**Indice**: `comments` non ha mai avuto un indice, nemmeno su `entity_id`, dal
giorno 0. La 0021 aggiunge `(entity_id, created_at)`, che è esattamente il modo
in cui il feed si legge.
**Pattern «Modifica» sui messaggi** (portale e inbox admin, stessa forma): pulsante
testuale piccolo, non un'icona, coerente con i già presenti `Riprova`/`Elimina`. Si
rivela al passaggio del mouse ma **deve restare raggiungibile senza puntatore**:
opacity-0 transition-opacity group-hover:opacity-100
focus-visible:opacity-100 [@media(hover:none)]:opacity-100
L'ultima clausola non è un dettaglio — senza, il pulsante è irraggiungibile da telefono,
ed è da telefono che il cliente apre il portale. In modifica la bolla diventa una
textarea: `Invio` salva, `Esc` annulla, più i due pulsanti espliciti. Nell'inbox admin i
controlli stanno **fuori** dalla bolla: sopra `bg-primary` un testo tenue non si legge.
Il pulsante nascosto non è una difesa — chi può modificare lo ridecide il server.
### Clienti (lista) notes
@@ -340,9 +389,39 @@ The `/client/[token]` portal was migrated to the same "Quiet Luxury" tokens
`bg-muted`/`text-muted-foreground` (upcoming) — each with a `dark:` variant.
- **Phase cards** (`PhaseCard`): `rounded-xl border-border-light bg-card
shadow-card`, progress bar colored per status (emerald/amber/`bg-border`).
- **Stato dei task** (`TaskStatusIndicator`): le cinque forme si distinguono
**senza colore** — `todo` cerchio vuoto `border-border`, `in_progress` cerchio +
punto ambra, `in_review` cerchio + **spunta vuota** violetta, `done` cerchio pieno
+ spunta emerald, `cancelled` cerchio spento + **X**. La spunta compare a lavoro
finito e si riempie a lavoro confermato; la X è l'unica forma che non è un passo
avanti. La pill di testo compare su `in_progress`, `in_review` e `cancelled`;
`todo` e `done` si leggono dalla forma e dal titolo. **`done` e `cancelled` sono
entrambi barrati**, ed è proprio per questo che `cancelled` porta la pill: la
differenza fra consegnato e tolto dal lavoro è troppo importante per affidarla al
solo disegno dentro il cerchio. Colori come le altre pill del portale (soft tint
`-50/700` + variante `dark:`, tinta `muted` per la cancellata), tutti raccolti in
`TASK_STATUS_VISUALS` e mai riscritti nei componenti: le stesse tinte colorano le
colonne del Kanban cliente e la legenda, che si genera da `TASK_STATUSES`. L'icona
è `aria-hidden` e lo stato viaggia in uno `sr-only`, così lo screen reader lo
annuncia anche su quelli senza pill.
La colonna «Cancellate» del Kanban cliente è l'unica che **si nasconde da vuota**
(le altre quattro si tengono la larghezza) — mai se è piena: nessun task deve
sparire dalla board.
- **Box pagamenti** (`PaymentStatus`): sopra le righe, un riquadro «Prossimo
pagamento» con data lunga e conto alla rovescia in parole («tra 12 giorni»,
«domani», «oggi»); se la scadenza è passata diventa rosso e dice «Pagamento
scaduto — scaduto da N giorni». Ogni riga porta la sua data sotto l'etichetta:
«Scade il…» se aperta, «Pagato il…» se saldata, **nulla** se in gestionale la data
non c'è — il portale non inventa scadenze. Nessun importo per riga (LOCKED #2):
le date non sono cifre. I giorni si contano in `src/lib/payment-dates.ts` sui
giorni civili a Roma, non sugli istanti.
- Sidebar cards (`OffersSection`, `PaymentStatus`, `DocumentsSection`,
`NotesSection`, `TranscriptsSection`) all follow `rounded-xl border-border-light
bg-card shadow-card`.
- **Offer card** (`OffersSection`): due sole righe di importo — «Valore
dell'offerta» (override admin o somma dei servizi; nascosta a 0) e «Canone
mensile» / «Investimento finale». Niente accordion «Cosa è compreso»: la lista
servizi non arriva più al client, e il componente non è più `"use client"`.
- `ClientDashboard` takes an `embedded` prop: when rendered inside the
multi-project tabs wrapper (`page.tsx`), it drops its own header/footer/canvas
so the page-level chrome isn't duplicated.
+108
View File
@@ -0,0 +1,108 @@
/**
* Mette in moto le cinque fonti di `src/lib/audit/sources/` su un URL.
*
* Le fonti sono in produzione dal 2026-08-19 ma INERTI: nessuna route le chiama.
* Questo script e' l'unico modo di vederle funzionare, e serve a rispondere a una
* domanda sola: **cosa e' stato misurato davvero, e cosa no.** Un buco qui e' il
* risultato, non un errore da nascondere — il motore dovra' saperlo dire al cliente.
*
* Nessun LLM, nessun database, nessun browser headless (il VPS non lo regge).
*
* npx tsx scripts/audit-fonti.ts https://esempio.it
* npx tsx scripts/audit-fonti.ts https://esempio.it --profilo=ecommerce
* npx tsx scripts/audit-fonti.ts https://esempio.it --no-psi
*
* PAGESPEED_API_KEY (per PageSpeed e CrUX) viene letta dall'ambiente o da .env.local.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { raccogliPagine } from "../src/lib/audit/sources/fetch";
import { rilevaCrux } from "../src/lib/audit/sources/crux";
import { rilevaPagespeedCompleto } from "../src/lib/audit/sources/pagespeed";
import { rilevaStorico } from "../src/lib/audit/sources/history";
import { rilevaSegnali } from "../src/lib/audit/sources/signals";
function caricaEnv() {
if (process.env.PAGESPEED_API_KEY) return;
const f = resolve(process.cwd(), ".env.local");
if (!existsSync(f)) return;
for (const riga of readFileSync(f, "utf8").split("\n")) {
const m = riga.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
}
}
/** Esegue una fonte senza far cadere le altre: un buco e' un dato, non un crash. */
async function fonte<T>(nome: string, f: () => Promise<T>) {
const t0 = Date.now();
try {
const dati = await f();
return { nome, ok: true as const, ms: Date.now() - t0, dati };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { nome, ok: false as const, ms: Date.now() - t0, errore: msg };
}
}
async function main() {
caricaEnv();
const argv = process.argv.slice(2);
const url = argv.find((a) => !a.startsWith("--"));
if (!url) {
console.error("uso: npx tsx scripts/audit-fonti.ts <url> [--profilo=servizi] [--no-psi]");
process.exit(1);
}
const profilo = argv.find((a) => a.startsWith("--profilo="))?.split("=")[1] ?? "servizi";
const psi = !argv.includes("--no-psi");
const inizio = Date.now();
console.log(`\n ${url} · profilo ${profilo}${psi ? "" : " · PageSpeed saltato"}`);
if (!process.env.PAGESPEED_API_KEY) {
console.log(" ⚠ PAGESPEED_API_KEY assente: PageSpeed e CrUX risponderanno a vuoto.");
}
console.log("");
// La home serve alle altre due fonti: si scarica una volta sola.
const raccolta = await fonte("pagine", () => raccogliPagine(url, profilo));
if (!raccolta.ok) {
console.error(` ✗ pagine — ${raccolta.errore}\n\n Senza la home non parte nient'altro.`);
process.exit(1);
}
const { home, homeHtml, homeHeaders, pagine, errori } = raccolta.dati;
const risultati = [
raccolta,
...(await Promise.all([
fonte("crux", () => rilevaCrux(home)),
...(psi ? [fonte("pagespeed", () => rilevaPagespeedCompleto(home))] : []),
fonte("storico", () => rilevaStorico(home, homeHtml)),
fonte("segnali", () => rilevaSegnali(home, homeHtml, homeHeaders)),
])),
];
console.log(" FONTE ESITO TEMPO");
for (const r of risultati) {
const esito = r.ok ? "ok " : "MUTA ";
console.log(` ${r.nome.padEnd(11)} ${esito} ${(r.ms / 1000).toFixed(1)}s${r.ok ? "" : ` ${r.errore}`}`);
}
console.log(`\n Pagine raccolte: ${pagine.length} (${pagine.map((p) => p.ruolo).join(", ")})`);
if (errori.length) console.log(` Non scaricate: ${errori.join(", ")}`);
if (home !== url) console.log(` Canonico: ${home} (redirect dal valore passato)`);
// Il grezzo e' il posto dove ogni numero del documento dovra' essere rintracciabile.
const dominio = new URL(home).hostname.replace(/^www\./, "");
const file = resolve(process.cwd(), `audit-fonti-${dominio}.json`);
writeFileSync(file, JSON.stringify({ url: home, profilo, quando: new Date().toISOString(), risultati }, null, 2));
console.log(`\n Grezzo: ${file}`);
console.log(` Totale: ${((Date.now() - inizio) / 1000).toFixed(1)}s\n`);
console.log(" Un numero entra in un documento solo se lo ritrovi qui dentro.");
console.log(" Un punteggio PageSpeed va sempre con la sua data: ballano di 12 punti in mezz'ora.\n");
}
main().catch((e) => {
console.error("\nERRORE:", e instanceof Error ? e.message : e);
process.exit(1);
});
+3 -5
View File
@@ -187,16 +187,14 @@ export async function renameServiceOption(
if (next.length === 0) throw new Error("Nuovo nome richiesto");
if (next === oldValue) return;
// renamePoolValue owns the propagation (tags / services.fase / project phases);
// duplicating the UPDATE here would just run it twice.
if (field === "tag" || field === "pacchetto") {
await db
.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") {
// No taxonomy pool backs services.category — this one propagates by hand.
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}`);
+186 -31
View File
@@ -23,6 +23,7 @@ import {
} from "@/db/schema";
import { eq, asc, and, isNull } from "drizzle-orm";
import { z } from "zod";
import { countsTowardProgress, isTaskStatus } from "@/lib/task-status";
// ── ENTITY RESOLUTION ────────────────────────────────────────────────────────
// Both clientId and projectId are passed as "clientId" by tab components.
@@ -179,21 +180,31 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
// ── 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
// all done → done
// any task moved off "todo" → active
// all todo (or no tasks) → upcoming
// "any moved off todo" is deliberately a negation, not a list of the statuses
// that count as started: as a list it silently forgot new statuses, and a phase
// whose tasks were all in review fell back to "upcoming" — reading as
// not-started when it was nearly finished.
//
// I task cancellati sono invisibili a questo calcolo: non tengono la fase aperta
// (senza questo, cancellare l'ultima voce la lascerebbe "in corso" per sempre) e
// da soli non la fanno partire. Se restano SOLO cancellati la fase torna
// "Da iniziare": è degenere, ma è meglio di "Completata", che al cliente
// racconterebbe una consegna che non c'è stata.
export async function recomputePhaseStatus(phaseId: string): Promise<void> {
const phaseTasks = await db
.select({ status: tasks.status })
.from(tasks)
.where(eq(tasks.phase_id, phaseId));
const countedTasks = phaseTasks.filter((t) => countsTowardProgress(t.status));
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 (countedTasks.length > 0) {
const allDone = countedTasks.every((t) => t.status === "done");
const anyActive = countedTasks.some((t) => t.status !== "todo");
if (allDone) newStatus = "done";
else if (anyActive) newStatus = "active";
}
@@ -202,8 +213,7 @@ export async function recomputePhaseStatus(phaseId: string): Promise<void> {
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");
if (!isTaskStatus(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
@@ -225,6 +235,40 @@ export async function deleteTask(taskId: string, id: string) {
revalidatePath(path);
}
// Persists a new task order inside one phase.
//
// Rewrites sort_order for EVERY task of the phase as 0..n-1 rather than swapping
// two rows. That is deliberate: sort_order is not a reliable index today —
// deleteTask leaves gaps (0,1,3), rows created outside addTask/importOfferIntoProject
// sit at the 0 default, and there is no unique index on (phase_id, sort_order) so
// duplicates are legal. A full rewrite normalizes all of that on every drop.
export async function reorderTasks(
phaseId: string,
id: string,
orderedTaskIds: string[]
): Promise<void> {
await requireAdmin();
// The array comes from the client: only trust ids that really belong to this phase.
const phaseTasks = await db
.select({ id: tasks.id })
.from(tasks)
.where(eq(tasks.phase_id, phaseId));
const belongs = new Set(phaseTasks.map((t) => t.id));
const ordered = orderedTaskIds.filter((taskId) => belongs.has(taskId));
if (ordered.length !== phaseTasks.length) {
throw new Error("Ordine non valido: elenco task incompleto");
}
for (let i = 0; i < ordered.length; i++) {
await db.update(tasks).set({ sort_order: i }).where(eq(tasks.id, ordered[i]));
}
const { path } = await resolveEntity(id);
revalidatePath(path);
}
// ── DELIVERABLES ──────────────────────────────────────────────────────────────
export async function addDeliverable(taskId: string, id: string, formData: FormData) {
@@ -290,17 +334,42 @@ 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) {
// Parsing di una data inserita dall'admin, in due formati:
// "YYYY-MM-DD" → quel giorno (quello che scrivono i campi di oggi)
// "YYYY-MM" → primo del mese (formato storico di paid_at, campo <input type="month">)
//
// Mezzogiorno UTC, non mezzanotte: a mezzanotte UTC il giorno civile a Roma è già
// quello dopo per due ore in estate, e la data tornerebbe indietro di un giorno
// una volta riletta. A mezzogiorno nessun fuso reale sposta il giorno.
function parseAdminDate(value: string): Date {
const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (day) {
const [, y, m, d] = day;
const year = parseInt(y, 10);
const month = parseInt(m, 10);
const date = parseInt(d, 10);
if (month < 1 || month > 12 || date < 1 || date > 31) throw new Error("Data non valida");
return new Date(Date.UTC(year, month - 1, date, 12, 0, 0));
}
const month = /^(\d{4})-(\d{2})$/.exec(value);
if (month) {
const [, y, m] = month;
const monthNum = parseInt(m, 10);
if (monthNum < 1 || monthNum > 12) throw new Error("Data non valida");
return new Date(Date.UTC(parseInt(y, 10), monthNum - 1, 1, 12, 0, 0));
}
throw new Error("Data non valida");
}
// Imposta il giorno in cui un pagamento è stato incassato e porta lo stato a
// "saldato", così l'incasso viene attribuito a quel mese nelle analytics (che
// raggruppano con `extract(month from paid_at)`: la precisione al giorno non le
// tocca). Accetta anche il vecchio "YYYY-MM" delle righe storiche.
export async function setPaymentPaidAt(paymentId: string, id: string, dateStr: 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));
const paid_at = parseAdminDate(dateStr);
await db
.update(payments)
.set({ paid_at, status: "saldato" })
@@ -309,35 +378,121 @@ export async function setPaymentPaidAt(paymentId: string, id: string, monthStr:
revalidatePath(path);
}
// Scadenza concordata della rata. Stringa vuota = la scadenza si toglie: era
// stata messa e non vale più, e cancellarla deve essere possibile senza dover
// cancellare la rata.
export async function setPaymentDueDate(paymentId: string, id: string, dateStr: string) {
await requireAdmin();
const due_date = dateStr.trim() === "" ? null : parseAdminDate(dateStr);
await db.update(payments).set({ due_date }).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.
//
// The rule is PER ROW, not per project. The previous version decided with
// `some((p) => p.percent !== null)`: a single row carrying a percent dragged the
// whole project into the percentage branch, where every percent-NULL row was
// computed as `newTotal * 0` and silently written to 0.00. That was a live bug —
// splitPayment inserts its second instalment with percent NULL, so splitting a
// payment and then touching the total zeroed the remainder.
//
// Now: a row is rescaled only if it still carries a percent AND has not been
// manually overridden. Everything else is left exactly as it is. The equal-split
// fallback survives only for projects where NO row has a percent (legacy stubs).
async function rescalePayments(projectId: string, newTotal: number): Promise<void> {
const projectPayments = await db
.select({ id: payments.id, percent: payments.percent })
.select({ id: payments.id, percent: payments.percent, amount_locked: payments.amount_locked })
.from(payments)
.where(eq(payments.project_id, projectId));
if (projectPayments.length === 0) return;
const hasPercent = projectPayments.some((p) => p.percent !== null);
const anyPercent = projectPayments.some((p) => p.percent !== null);
if (hasPercent) {
// New plan: rescale each row by its stored percent
if (anyPercent) {
for (const p of projectPayments) {
const pct = p.percent !== null ? parseFloat(String(p.percent)) : 0;
const newAmount = ((newTotal * pct) / 100).toFixed(2);
if (p.percent === null || p.amount_locked) continue;
const newAmount = ((newTotal * parseFloat(String(p.percent))) / 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) {
// Legacy: equal split across the rows that are still automatic.
const auto = projectPayments.filter((p) => !p.amount_locked);
if (auto.length === 0) return;
const share = (newTotal / auto.length).toFixed(2);
for (const p of auto) {
await db.update(payments).set({ amount: share }).where(eq(payments.id, p.id));
}
}
}
// Per-row edit of a payment's label or amount, mirroring updateServiceField in
// src/app/admin/catalog/actions.ts. Writing an amount marks the row as manually
// overridden so rescalePayments stops touching it.
const EDITABLE_PAYMENT_FIELDS = ["label", "amount"] as const;
type EditablePaymentField = (typeof EDITABLE_PAYMENT_FIELDS)[number];
export async function updatePaymentField(
paymentId: string,
id: string,
fieldName: string,
value: string
): Promise<void> {
await requireAdmin();
if (!(EDITABLE_PAYMENT_FIELDS as readonly string[]).includes(fieldName)) {
throw new Error(`Campo non editabile: ${fieldName}`);
}
const field = fieldName as EditablePaymentField;
if (field === "label") {
const label = value.trim();
if (!label) throw new Error("Nome rata richiesto");
await db.update(payments).set({ label }).where(eq(payments.id, paymentId));
} else {
// The cell renders it-IT (€ 1.234,50), so an admin may well type "1.234,50".
// When a comma is present treat "." as thousands separators and "," as the
// decimal mark; otherwise "." is the decimal mark. Number() (not parseFloat)
// rejects trailing garbage like "12abc".
const raw = value.trim();
const normalized = raw.includes(",") ? raw.replace(/\./g, "").replace(",", ".") : raw;
const num = Number(normalized);
if (!Number.isFinite(num) || num < 0) throw new Error("Importo non valido");
await db
.update(payments)
.set({ amount: num.toFixed(2), amount_locked: true })
.where(eq(payments.id, paymentId));
}
const { path } = await resolveEntity(id);
revalidatePath(path);
}
// Releases a manual override: the row goes back under automatic rescaling and is
// immediately recomputed from its percent, so the effect is visible at once.
export async function clearPaymentOverride(paymentId: string, id: string): Promise<void> {
await requireAdmin();
const rows = await db
.select({ project_id: payments.project_id })
.from(payments)
.where(eq(payments.id, paymentId))
.limit(1);
if (!rows[0]) throw new Error("Pagamento non trovato");
await db.update(payments).set({ amount_locked: false }).where(eq(payments.id, paymentId));
const proj = await db
.select({ accepted_total: projects.accepted_total })
.from(projects)
.where(eq(projects.id, rows[0].project_id))
.limit(1);
if (proj[0]) await rescalePayments(rows[0].project_id, parseFloat(proj[0].accepted_total ?? "0"));
const { path } = await resolveEntity(id);
revalidatePath(path);
}
export async function updateAcceptedTotal(id: string, formData: FormData) {
await requireAdmin();
const raw = (formData.get("accepted_total") as string)?.trim();
+5 -2
View File
@@ -86,9 +86,12 @@ export async function createClientCore(input: {
})
.returning({ id: projects.id });
// percent is seeded so these stubs rescale properly once a total is set —
// previously they were NULL, which put the project in the legacy equal-split
// branch and made them indistinguishable from a manually overridden amount.
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", percent: "50.00", status: "da_saldare", sort_order: 0 },
{ project_id: newProject.id, label: "Saldo 50%", amount: "0", percent: "50.00", status: "da_saldare", sort_order: 1 },
]);
return { clientId: newClient.id, projectId: newProject.id };
+102 -14
View File
@@ -3,40 +3,63 @@
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { authOptions } from "@/lib/auth";
import { db } from "@/db";
import { clients, comments } from "@/db/schema";
import { assertClientOwnsEntity } from "@/lib/client-chat";
import { hasMention, mentionCandidates } from "@/lib/mentions";
import { notifyClientMention } from "@/lib/client-notifications";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
const replySchema = z.object({
// "general" ancora il messaggio al cliente, "phase" a una sua fase. Task e
// deliverable restano fuori: nessuna UI li scrive più, da nessuna delle due parti.
entity_type: z.enum(["general", "phase"]),
entity_id: z.string().min(1),
body: z.string().trim().min(1, "Il messaggio non può essere vuoto").max(2000),
});
/**
* Admin reply from the Conversazioni inbox. Per project decision, replies are
* saved as a "general" comment on the client (entity_id = clientId), so they
* surface in the client's general chat.
* Risposta dell'admin dall'inbox Conversazioni, sul canale da cui si sta
* scrivendo.
*
* Questa è l'UNICA via di risposta dell'admin da quando il tab Commenti del
* progetto è stato rimosso: i messaggi su fase/task/deliverable si leggono qui
* con la loro etichetta, ma la risposta torna sempre sul thread generale.
* Fino alla chat a canali l'admin poteva rispondere SOLO sul thread generale:
* i messaggi su fase/task/deliverable si leggevano con la loro etichetta ma la
* risposta tornava sempre in Generale. Con i tab quella asimmetria diventava un
* bug visibile — domanda in "Fase 2", risposta in un altro tab — quindi ora il
* canale viaggia insieme al messaggio.
*/
export async function replyToConversation(clientId: string, formData: FormData) {
await requireAdmin();
const body = (formData.get("body") as string)?.trim();
if (!clientId || !body) throw new Error("Dati mancanti");
// Validate the client exists (entity_id integrity).
const rows = await db
.select({ id: clients.id })
const parsed = replySchema.safeParse({
entity_type: formData.get("entity_type"),
entity_id: formData.get("entity_id"),
body: formData.get("body"),
});
if (!clientId || !parsed.success) throw new Error("Dati mancanti");
const { entity_type, entity_id, body } = parsed.data;
const [client] = await db
.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (rows.length === 0) throw new Error("Cliente non trovato");
if (!client) throw new Error("Cliente non trovato");
// Stessa verifica di appartenenza usata dalle route del portale: un entity_id
// arbitrario nel form non deve poter scrivere nella chat di un altro cliente.
const owns = await assertClientOwnsEntity(clientId, entity_type, entity_id);
if (!owns) throw new Error("Canale non valido per questo cliente");
await db.insert(comments).values({
entity_type: "general",
entity_id: clientId,
entity_type,
entity_id,
author: "admin",
body,
});
@@ -47,6 +70,71 @@ export async function replyToConversation(clientId: string, formData: FormData)
.set({ admin_last_read_at: new Date() })
.where(eq(clients.id, clientId));
// Il tag è l'unico messaggio che esce dal portale. Si valuta sul testo appena
// scritto, non su un campo nascosto del form: quello resterebbe indietro se
// l'admin cancellasse la menzione prima di inviare.
//
// L'invio non è dentro una transazione con l'insert e non deve esserlo: se la
// mail fallisce, il messaggio in chat resta comunque scritto — il contrario
// sarebbe perdere il messaggio per colpa del provider di posta.
if (hasMention(body, mentionCandidates(client))) {
await notifyClientMention({ clientId, channelKey: entity_id, body });
}
revalidatePath("/admin/conversazioni");
revalidatePath(`/admin/clients/${clientId}`);
}
const editSchema = z.object({
comment_id: z.string().min(1),
body: z.string().trim().min(1, "Il messaggio non può essere vuoto").max(2000),
});
/**
* Modifica di un messaggio già inviato dall'admin.
*
* Nessun limite di tempo (modello Slack/Discord), ma si modifica solo ciò di cui
* si è autori: l'admin non riscrive i messaggi del cliente. L'admin è un utente
* unico e fidato, ma il controllo di appartenenza resta comunque — un commentId
* arbitrario nel form non deve poter scrivere nel thread di un altro cliente.
*
* A differenza di replyToConversation, NON tocca admin_last_read_at: correggere
* un refuso non è leggere la conversazione.
*/
export async function editConversationMessage(
clientId: string,
formData: FormData
) {
await requireAdmin();
const parsed = editSchema.safeParse({
comment_id: formData.get("comment_id"),
body: formData.get("body"),
});
if (!clientId || !parsed.success) throw new Error("Dati mancanti");
const { comment_id, body } = parsed.data;
const [existing] = await db
.select()
.from(comments)
.where(eq(comments.id, comment_id))
.limit(1);
if (!existing || existing.author !== "admin") {
throw new Error("Messaggio non modificabile");
}
const owns = await assertClientOwnsEntity(
clientId,
existing.entity_type as "general" | "phase" | "task" | "deliverable",
existing.entity_id
);
if (!owns) throw new Error("Messaggio non appartenente a questo cliente");
await db
.update(comments)
.set({ body, edited_at: new Date() })
.where(eq(comments.id, comment_id));
revalidatePath("/admin/conversazioni");
revalidatePath(`/admin/clients/${clientId}`);
}
+33
View File
@@ -5,7 +5,9 @@ import { authOptions } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import {
addPoolValue,
countProjectPhasesByTitle,
removePoolValue,
renamePoolValue,
TAXONOMY_FIELDS,
type TaxonomyFieldId,
} from "@/lib/taxonomy";
@@ -23,6 +25,10 @@ function revalidateAll() {
revalidatePath("/admin/impostazioni");
revalidatePath("/admin/offers");
revalidatePath("/admin/catalog");
// A `service_fase` rename rewrites phase titles inside projects too, so the
// project/client trees have to drop their cached copies of the old name.
revalidatePath("/admin/projects", "layout");
revalidatePath("/admin/clients", "layout");
}
export async function addTaxonomyValue(fieldId: string, value: string): Promise<void> {
@@ -40,3 +46,30 @@ export async function removeTaxonomyValue(fieldId: string, value: string): Promi
await removePoolValue(fieldId, value);
revalidateAll();
}
// Global rename: renames the value in the pool AND propagates it to every row
// using it — including, for `service_fase`, the phases already materialized in
// projects (see renamePoolValue).
export async function renameTaxonomyValue(
fieldId: string,
oldValue: string,
newValue: string
): Promise<void> {
await requireAdmin();
assertField(fieldId);
const next = newValue.trim();
if (!next) throw new Error("Nuovo nome richiesto");
if (next === oldValue) return;
await renamePoolValue(fieldId, oldValue, next);
revalidateAll();
}
// Read-only: how many project phases a `service_fase` rename would rewrite.
// Feeds the confirmation dialog — this is the one taxonomy rename that writes
// outside the catalog/offer domain.
export async function getFaseRenameImpact(
value: string
): Promise<{ phases: number; projects: number }> {
await requireAdmin();
return countProjectPhasesByTitle(value);
}
+103 -2
View File
@@ -1,4 +1,10 @@
import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
import {
getTargetHourlyRate,
getAdminIdentity,
updateSetting,
SETTINGS_KEYS,
ADMIN_NAME_FALLBACK,
} from "@/lib/settings";
import { getAllPools } from "@/lib/taxonomy";
import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager";
import { PageHeader } from "@/components/admin/PageHeader";
@@ -6,7 +12,11 @@ import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0;
export default async function ImpostazioniPage() {
const [targetRate, pools] = await Promise.all([getTargetHourlyRate(), getAllPools()]);
const [targetRate, pools, admin] = await Promise.all([
getTargetHourlyRate(),
getAllPools(),
getAdminIdentity(),
]);
async function handleSave(fd: FormData) {
"use server";
@@ -16,6 +26,19 @@ export default async function ImpostazioniPage() {
await updateSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE, val.toFixed(2));
}
async function handleSaveIdentity(fd: FormData) {
"use server";
const name = String(fd.get("admin_display_name") ?? "").trim();
const avatar = String(fd.get("admin_avatar_url") ?? "").trim();
// Un URL che non è http(s) non verrebbe mai caricato dal browser: meglio non
// scriverlo affatto che salvarlo e lasciare l'avatar rotto senza spiegazione.
const validAvatar = /^https?:\/\//i.test(avatar) ? avatar : "";
await updateSetting(SETTINGS_KEYS.ADMIN_DISPLAY_NAME, name);
await updateSetting(SETTINGS_KEYS.ADMIN_AVATAR_URL, validAvatar);
}
return (
<div className="space-y-8">
<PageHeader
@@ -63,6 +86,84 @@ export default async function ImpostazioniPage() {
</div>
</section>
<section className="rounded-xl border border-border-light bg-card p-6 shadow-card">
<div className="max-w-2xl">
<h2 className="mb-1 text-xs font-bold uppercase tracking-wider text-foreground">
Firma nella chat
</h2>
<p className="mb-6 text-xs text-tertiary">
Come ti vede il cliente quando rispondi nel portale. Senza nome resta
«{ADMIN_NAME_FALLBACK}», che è il marchio e non una persona. Senza foto
resta il monogramma delle iniziali.
</p>
<form action={handleSaveIdentity} className="space-y-4">
<div className="flex items-center gap-4">
{/* Anteprima: se il link è rotto te ne accorgi qui, non dal cliente. */}
{admin.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={admin.avatarUrl}
alt="Anteprima della foto profilo"
className="h-12 w-12 shrink-0 rounded-full border border-border object-cover"
/>
) : (
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">
{admin.name.slice(0, 2).toUpperCase()}
</span>
)}
<div className="flex-1 space-y-3">
<div>
<label
htmlFor="admin_display_name"
className="mb-1 block text-[11px] font-medium text-tertiary"
>
Nome visualizzato
</label>
<input
id="admin_display_name"
name="admin_display_name"
type="text"
maxLength={60}
defaultValue={
admin.name === ADMIN_NAME_FALLBACK ? "" : admin.name
}
placeholder={ADMIN_NAME_FALLBACK}
className="w-full rounded-lg border border-border bg-transparent px-3 py-2.5 text-xs text-foreground transition-all duration-150 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label
htmlFor="admin_avatar_url"
className="mb-1 block text-[11px] font-medium text-tertiary"
>
Foto profilo indirizzo di un&apos;immagine già online
</label>
<input
id="admin_avatar_url"
name="admin_avatar_url"
type="url"
inputMode="url"
defaultValue={admin.avatarUrl ?? ""}
placeholder="https://…"
className="w-full rounded-lg border border-border bg-transparent px-3 py-2.5 font-mono text-xs text-foreground transition-all duration-150 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
</div>
<button
type="submit"
className="rounded-lg bg-primary px-5 py-2.5 text-xs font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Salva
</button>
</form>
</div>
</section>
<TaxonomyManager pools={pools} />
</div>
);
+1 -6
View File
@@ -342,17 +342,12 @@ export async function renameOfferOption(
if (next.length === 0) throw new Error("Nuovo nome richiesto");
if (next === oldValue) return;
// renamePoolValue already propagates to offer_macros / tags — no manual UPDATE.
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}`);
+59 -6
View File
@@ -15,7 +15,7 @@ import {
tasks,
PROJECT_OFFER_STATUSES,
} from "@/db/schema";
import { eq, asc, and } from "drizzle-orm";
import { eq, asc, and, gt, sql } from "drizzle-orm";
import { z } from "zod";
import { nanoid } from "nanoid";
@@ -83,7 +83,7 @@ export async function initProjectPayments(projectId: string): Promise<void> {
// 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%
// three → 3 rows 50%/25%/25%
export async function setPaymentPlan(
projectId: string,
mode: "single" | "two" | "three",
@@ -100,8 +100,8 @@ export async function setPaymentPlan(
],
three: [
{ label: "Acconto 50% (inizio lavori)", percent: 50 },
{ label: "30% (post revisioni)", percent: 30 },
{ label: "Saldo 20% (alla consegna)", percent: 20 },
{ label: "25% (in corso d'opera)", percent: 25 },
{ label: "Saldo 25% (alla consegna)", percent: 25 },
],
};
@@ -109,14 +109,17 @@ export async function setPaymentPlan(
if (!plan) throw new Error("Modalità pagamento non valida");
// Delete existing payments for this project, then insert new ones.
// Destructive by design (the UI says so), and the tab asks for confirmation
// when a row has already been marked inviata/saldato.
await db.delete(payments).where(eq(payments.project_id, projectId));
await db.insert(payments).values(
plan.map((p) => ({
plan.map((p, i) => ({
project_id: projectId,
label: p.label,
percent: p.percent.toFixed(2),
amount: ((total * p.percent) / 100).toFixed(2),
status: "da_saldare" as const,
sort_order: i,
}))
);
@@ -145,16 +148,29 @@ export async function splitPayment(
// Strip any existing " Rata N" suffix so re-splits stay clean.
const baseLabel = original.label.replace(/ Rata \d+$/, "");
// Both halves are manual amounts by definition, so they are locked: without
// this, the next change to the project total would recompute Rata 1 from its
// (now meaningless) percent and zero out Rata 2, which carries none.
await db
.update(payments)
.set({ amount: first.toFixed(2), label: `${baseLabel} Rata 1` })
.set({ amount: first.toFixed(2), label: `${baseLabel} Rata 1`, amount_locked: true })
.where(eq(payments.id, paymentId));
// Rata 2 slots in right after Rata 1; everything below shifts down one.
await db
.update(payments)
.set({ sort_order: sql`${payments.sort_order} + 1` })
.where(
and(eq(payments.project_id, projectId), gt(payments.sort_order, original.sort_order))
);
await db.insert(payments).values({
project_id: projectId,
label: `${baseLabel} Rata 2`,
amount: second.toFixed(2),
status: original.status,
sort_order: original.sort_order + 1,
amount_locked: true,
});
revalidatePath(`/admin/projects/${projectId}`);
@@ -295,6 +311,43 @@ export async function updateProjectOfferTotal(
revalidatePath(`/admin/projects/${projectId}`);
}
/**
* Override manuale del "Valore dell'offerta" mostrato nel portale cliente
* (migration 0020).
*
* Campo vuoto NULL il portale torna alla somma calcolata dai prezzi dei
* servizi del tier. Zero è un valore legittimo, non un "vuoto": nasconde la
* riga al cliente, esattamente come fa già una somma calcolata pari a 0.
*/
export async function updateOfferValueOverride(
projectOfferId: string,
projectId: string,
rawValue: string
): Promise<{ ok: true } | { ok: false; error: string }> {
await requireAdmin();
const trimmed = rawValue.trim();
let override: string | null = null;
if (trimmed !== "") {
// L'input admin è un <input type="number">, quindi punto decimale; la
// virgola è comunque tollerata per chi incolla un importo in formato it-IT.
const amount = parseFloat(trimmed.replace(",", "."));
if (!Number.isFinite(amount) || amount < 0) {
return { ok: false, error: "Importo non valido" };
}
override = amount.toFixed(2);
}
await db
.update(project_offers)
.set({ offer_value_override: override })
.where(eq(project_offers.id, projectOfferId));
revalidatePath(`/admin/projects/${projectId}`);
return { ok: true };
}
// ── CICLO DI VITA DELL'OFFERTA ASSEGNATA (v2.4 Phase 13) ─────────────────────
const lifecycleSchema = z.object({
+147
View File
@@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from "next/server";
import { and, eq, gt, inArray, asc, or } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db";
import { comments, client_channel_reads } from "@/db/schema";
import { rateLimit } from "@/lib/rate-limit";
import {
resolveClientByToken,
assertClientOwnsEntity,
getProjectChatScope,
} from "@/lib/client-chat";
/**
* Chat del portale cliente: poll dei messaggi nuovi (GET) e ricevuta di lettura
* per canale (POST).
*
* Perché un endpoint dedicato invece di router.refresh(): il refresh RSC rifà
* l'intera getProjectView pagamenti, offerte, documenti, trascrizioni per
* portare a casa due righe di chat. A pannello aperto, ogni 20 secondi, sarebbe
* sproporzionato. Qui si legge solo `comments`, filtrata su `since`.
*/
const readSchema = z.object({
token: z.string().min(1),
channel_key: z.string().min(1),
});
// ── GET: messaggi del progetto creati dopo `since` + stato di lettura ──────────
export async function GET(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
// Una richiesta ogni 20s per pannello aperto: 30/min lascia margine a più
// schede aperte dietro lo stesso IP senza aprire la porta a un abuso.
if (!rateLimit(`chat-poll:${ip}`, 30, 60_000)) {
return NextResponse.json({ error: "Troppe richieste" }, { status: 429 });
}
try {
const { searchParams } = new URL(request.url);
const token = searchParams.get("token");
const projectId = searchParams.get("project_id");
const since = searchParams.get("since");
if (!token || !projectId) {
return NextResponse.json({ error: "Parametri mancanti" }, { status: 400 });
}
const clientId = await resolveClientByToken(token);
if (!clientId) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const entityIds = await getProjectChatScope(clientId, projectId);
if (!entityIds) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
// Un `since` illeggibile deve degradare a "dammi tutto", non a una data del
// 1970 né a un crash: nel peggiore dei casi il pannello rilegge lo storico.
const sinceDate = since ? new Date(since) : null;
const validSince =
sinceDate && !Number.isNaN(sinceDate.getTime()) ? sinceDate : null;
// Un messaggio è "nuovo per il pannello" se è stato scritto DOPPURE modificato
// dopo `since`: una modifica non tocca `created_at`, quindi filtrando solo su
// quello il testo corretto non arriverebbe mai all'altra parte. Con `edited_at`
// a NULL il confronto è NULL — i messaggi mai modificati non vengono ripescati,
// quindi il caso normale non paga nulla.
const scope = inArray(comments.entity_id, entityIds);
const rows = await db
.select()
.from(comments)
.where(
validSince
? and(
scope,
or(
gt(comments.created_at, validSince),
gt(comments.edited_at, validSince)
)
)
: scope
)
.orderBy(asc(comments.created_at));
const readRows = await db
.select()
.from(client_channel_reads)
.where(eq(client_channel_reads.client_id, clientId));
return NextResponse.json({
comments: rows,
reads: Object.fromEntries(
readRows.map((r) => [r.channel_key, r.read_at.toISOString()])
),
});
} catch (err) {
console.error("/api/client/chat GET error:", err);
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
}
}
// ── POST: il cliente ha letto un canale fino ad adesso ────────────────────────
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (!rateLimit(`chat-read:${ip}`, 60, 60_000)) {
return NextResponse.json({ error: "Troppe richieste" }, { status: 429 });
}
try {
const parsed = readSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: "Dati non validi" }, { status: 400 });
}
const { token, channel_key } = parsed.data;
const clientId = await resolveClientByToken(token);
if (!clientId) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
// channel_key è clients.id per "Generale", altrimenti dev'essere una fase
// di questo cliente: senza il controllo si potrebbero seminare righe di
// lettura su id arbitrari.
const owns =
channel_key === clientId
? true
: await assertClientOwnsEntity(clientId, "phase", channel_key);
if (!owns) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
await db
.insert(client_channel_reads)
.values({ client_id: clientId, channel_key, read_at: new Date() })
.onConflictDoUpdate({
target: [client_channel_reads.client_id, client_channel_reads.channel_key],
set: { read_at: new Date() },
});
return NextResponse.json({ success: true });
} catch (err) {
console.error("/api/client/chat POST error:", err);
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
}
}
+91 -75
View File
@@ -1,9 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { eq, inArray } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db";
import { clients, comments, tasks, phases, deliverables, projects } from "@/db/schema";
import { comments } from "@/db/schema";
import { rateLimit } from "@/lib/rate-limit";
import { resolveClientByToken, assertClientOwnsEntity } from "@/lib/client-chat";
const commentSchema = z.object({
token: z.string().min(1),
@@ -12,6 +13,12 @@ const commentSchema = z.object({
body: z.string().min(1, "Il commento non può essere vuoto").max(2000),
});
const editSchema = z.object({
token: z.string().min(1),
comment_id: z.string().min(1),
body: z.string().trim().min(1, "Il commento non può essere vuoto").max(2000),
});
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (!rateLimit(`comment:${ip}`, 10, 60_000)) {
@@ -31,86 +38,95 @@ export async function POST(request: NextRequest) {
const { token, entity_type, entity_id, body: commentBody } = parsed.data;
// Validate token
const clientRows = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.token, token))
.limit(1);
if (clientRows.length === 0) {
const clientId = await resolveClientByToken(token);
if (!clientId) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const clientId = clientRows[0].id;
if (entity_type === "general") {
// General messages: entity_id must be the client's own id
if (entity_id !== clientId) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
} else {
// Scope phases through projects → client
const clientProjects = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.client_id, clientId));
const projectIds = clientProjects.map((p) => p.id);
if (projectIds.length === 0) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const phasesForClient = await db
.select({ id: phases.id })
.from(phases)
.where(inArray(phases.project_id, projectIds));
const phaseIds = phasesForClient.map((p) => p.id);
if (phaseIds.length === 0) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const taskRows = await db
.select({ id: tasks.id })
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds));
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 });
}
} else {
// deliverable
const taskIds = taskRows.map((r) => r.id);
if (taskIds.length === 0) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const delivRows = await db
.select({ id: deliverables.id })
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
if (!delivRows.find((r) => r.id === entity_id)) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
}
const owns = await assertClientOwnsEntity(clientId, entity_type, entity_id);
if (!owns) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
await db.insert(comments).values({
entity_type,
entity_id,
author: "client",
body: commentBody,
});
// Il messaggio torna indietro: il pannello lo usa per sostituire la copia
// optimistic con la riga vera (stesso id, stesso created_at del server).
const [created] = await db
.insert(comments)
.values({
entity_type,
entity_id,
author: "client",
body: commentBody,
})
.returning();
return NextResponse.json({ success: true }, { status: 201 });
return NextResponse.json({ success: true, comment: created }, { status: 201 });
} catch (err) {
console.error("/api/client/comment error:", err);
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
}
}
}
/**
* Modifica di un messaggio già inviato dal cliente.
*
* Nessun limite di tempo (modello Slack/Discord), ma un limite netto su CHI:
* si modifica solo un messaggio di cui si è autori. Il controllo su `author`
* non è ridondante rispetto a quello sull'entità senza, il cliente potrebbe
* riscrivere le risposte dell'admin nella propria chat, che è peggio del non
* poter modificare affatto.
*/
export async function PATCH(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (!rateLimit(`comment-edit:${ip}`, 10, 60_000)) {
return NextResponse.json({ error: "Troppe richieste" }, { status: 429 });
}
try {
const parsed = editSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0].message },
{ status: 400 }
);
}
const { token, comment_id, body: newBody } = parsed.data;
const clientId = await resolveClientByToken(token);
if (!clientId) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const [existing] = await db
.select()
.from(comments)
.where(eq(comments.id, comment_id))
.limit(1);
// Stesso 403 per "non esiste" e "non è tuo": distinguerli direbbe a chi prova
// id a caso quali esistono.
if (!existing || existing.author !== "client") {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const owns = await assertClientOwnsEntity(
clientId,
existing.entity_type as "general" | "phase" | "task" | "deliverable",
existing.entity_id
);
if (!owns) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
}
const [updated] = await db
.update(comments)
.set({ body: newBody, edited_at: new Date() })
.where(eq(comments.id, comment_id))
.returning();
return NextResponse.json({ success: true, comment: updated });
} catch (err) {
console.error("/api/client/comment PATCH error:", err);
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
}
}
+92
View File
@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import { rateLimit } from "@/lib/rate-limit";
import { safeEqual } from "@/lib/admin-gate";
import { ingestLead, leadIntakeSchema, normalizeLeadPayload } from "@/lib/lead-intake";
/**
* Ingresso lead dall'esterno form del sito, bridge Zapier/Make, qualunque
* cosa sappia fare un POST JSON.
*
* curl -X POST https://…/api/webhooks/lead \
* -H 'content-type: application/json' \
* -H "x-webhook-secret: $LEAD_WEBHOOK_SECRET" \
* -d '{"name":"Mario Rossi","email":"mario@example.com","source":"form-home"}'
*
* Due differenze rispetto alle route in /api/internal, e sono volute:
*
* 1. `src/proxy.ts` NON intercetta /api/* (vedi il matcher in fondo a quel
* file), quindi qui non arriva nessuna protezione da monte: rate limit e
* controllo del segreto stanno tutti dentro la route.
* 2. Segreto assente in ambiente significa 403, non "passa". Le internal
* possono permetterselo perché sono raggiungibili solo da localhost; questa
* è esposta a internet, e un deploy con la variabile dimenticata deve
* smettere di accettare lead, non accettarli da chiunque.
*/
const MAX_BODY_BYTES = 16 * 1024;
function clientIp(request: NextRequest): string {
return (
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
request.headers.get("x-real-ip") ||
"unknown"
);
}
export async function POST(request: NextRequest) {
const ip = clientIp(request);
// Il rate limit viene PRIMA del confronto sul segreto: altrimenti tentare
// segreti a raffica costerebbe zero.
if (!rateLimit(`webhook-lead:${ip}`, 20, 60 * 1000)) {
return NextResponse.json({ error: "Troppe richieste" }, { status: 429 });
}
const secret = process.env.LEAD_WEBHOOK_SECRET;
if (!secret) {
console.error("[webhook/lead] LEAD_WEBHOOK_SECRET non configurato — richiesta rifiutata");
return NextResponse.json({ error: "Non autorizzato" }, { status: 403 });
}
if (!safeEqual(request.headers.get("x-webhook-secret"), secret)) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 403 });
}
const raw = await request.text();
if (raw.length > MAX_BODY_BYTES) {
return NextResponse.json({ error: "Payload troppo grande" }, { status: 413 });
}
let body: unknown;
try {
body = JSON.parse(raw);
} catch {
// I form che mandano application/x-www-form-urlencoded sono la norma:
// vale la pena leggerli invece di rispondere 400 e lasciare perdere il lead.
try {
body = Object.fromEntries(new URLSearchParams(raw));
} catch {
return NextResponse.json({ error: "Corpo non leggibile" }, { status: 400 });
}
}
const parsed = leadIntakeSchema.safeParse(normalizeLeadPayload(body));
if (!parsed.success) {
return NextResponse.json(
{ error: "Dati non validi", detail: parsed.error.issues[0].message },
{ status: 422 }
);
}
try {
const result = await ingestLead(parsed.data);
return NextResponse.json(
{ ok: true, outcome: result.outcome, leadId: result.leadId },
{ status: result.outcome === "created" ? 201 : 200 }
);
} catch (error) {
// Il dettaglio resta nei log del server: al chiamante non si dice mai
// perché il database si è lamentato.
console.error("[webhook/lead] ingest fallito:", error);
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
}
}
+43 -6
View File
@@ -12,7 +12,8 @@ import { ClientDashboard } from "@/components/client-dashboard";
import { OtpGate } from "@/components/client/OtpGate";
import { PreviewBanner } from "@/components/client/PreviewBanner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Comment } from "@/db/schema";
import { normalizeTaskStatus } from "@/lib/task-status";
import { getAdminIdentity } from "@/lib/settings";
export const revalidate = 0;
@@ -40,7 +41,7 @@ function projectViewToClientView(
id: task.id,
title: task.title,
description: task.description,
status: task.status as "todo" | "in_progress" | "done",
status: normalizeTaskStatus(task.status),
sort_order: task.sort_order,
deliverables: task.deliverables.map((d) => ({
id: d.id,
@@ -57,6 +58,8 @@ function projectViewToClientView(
id: p.id,
label: p.label,
status: p.status as "da_saldare" | "inviata" | "saldato",
due_date: p.due_date instanceof Date ? p.due_date.toISOString() : null,
paid_at: p.paid_at instanceof Date ? p.paid_at.toISOString() : null,
})),
documents: view.documents.map((d) => ({
id: d.id,
@@ -95,15 +98,31 @@ export async function generateMetadata({
};
}
/**
* Il canale chiesto con `?chat=<key>`, se appartiene a QUESTO progetto.
*
* La validazione non e' cosmetica: senza, un id qualsiasi nella query aprirebbe
* la chat su un canale vuoto e con piu' progetti a tab, la fase di un progetto
* spalancherebbe il pannello anche negli altri.
*/
function resolveChatChannel(
requested: string | undefined,
view: ProjectView
): string | null {
if (!requested) return null;
if (requested === view.project.client_id) return requested;
return view.phases.some((p) => p.id === requested) ? requested : null;
}
export default async function ClientPage({
params,
searchParams,
}: {
params: Promise<{ token: string }>;
searchParams: Promise<{ preview?: string }>;
searchParams: Promise<{ preview?: string; chat?: string }>;
}) {
const { token } = await params;
const { preview: previewParam } = await searchParams;
const { preview: previewParam, chat: chatParam } = await searchParams;
// ⚠️ Il gate va PRIMA di ogni query sui dati del progetto: se si interroga il
// DB e poi si decide di mostrare il form, i dati sono già nel payload RSC
@@ -136,6 +155,10 @@ export default async function ClientPage({
);
}
// Come si firma chi risponde in chat. Una lettura sola per pagina, condivisa
// da tutti i progetti: è la stessa persona in ogni tab.
const admin = await getAdminIdentity();
if (projects.length === 1) {
// D-09: single project → direct view without selector
const view = await getProjectView(projects[0].id);
@@ -146,8 +169,15 @@ export default async function ClientPage({
<ClientDashboard
view={projectViewToClientView(client, view)}
token={client.token}
comments={view.comments as unknown as Comment[]}
chat={{
projectId: view.project.id,
messages: view.comments,
reads: view.channel_reads,
adminName: admin.name,
adminAvatarUrl: admin.avatarUrl,
}}
preview={preview}
initialChatChannel={resolveChatChannel(chatParam, view)}
/>
</>
);
@@ -192,9 +222,16 @@ export default async function ClientPage({
<ClientDashboard
view={projectViewToClientView(client, view)}
token={client.token}
comments={view.comments as unknown as Comment[]}
chat={{
projectId: view.project.id,
messages: view.comments,
reads: view.channel_reads,
adminName: admin.name,
adminAvatarUrl: admin.avatarUrl,
}}
embedded
preview={preview}
initialChatChannel={resolveChatChannel(chatParam, view)}
/>
) : (
<p className="text-sm text-muted-foreground">Progetto non disponibile.</p>
+4 -1
View File
@@ -1,5 +1,6 @@
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
import type { ProjectFullDetail } from "@/lib/admin-queries";
import { countsTowardProgress } from "@/lib/task-status";
type Props = {
acceptedTotal: string;
@@ -38,7 +39,9 @@ export function ProjectSummary({
const collectedPct = contracted > 0 ? Math.round((collected / contracted) * 100) : 0;
const allTasks = phases.flatMap((p) => p.tasks);
// Le cancellate escono dal denominatore, come nel portale cliente: le due
// percentuali devono raccontare la stessa cosa.
const allTasks = phases.flatMap((p) => p.tasks).filter((t) => countsTowardProgress(t.status));
const doneTasks = allTasks.filter((t) => t.status === "done").length;
const progressPct =
allTasks.length > 0 ? Math.round((doneTasks / allTasks.length) * 100) : 0;
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { type ReactNode, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { reorderTasks } from "@/app/admin/clients/[id]/actions";
export type SortableTaskItem = { id: string; node: ReactNode };
// PhasesTab is a Server Component holding inline "use server" form closures, which
// cannot live in a client module. So the task rows stay server-rendered and arrive
// here as opaque ReactNodes; this component only adds the handle and the ordering.
// Same shape as PhasesViewToggle, which already passes a server-rendered tab as a prop.
function SortableRow({ id, children }: { id: string; children: ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
return (
<div
ref={setNodeRef}
// Vertical-only: zero the X component instead of pulling in
// @dnd-kit/modifiers just for restrictToVerticalAxis.
style={{
transform: CSS.Transform.toString(transform ? { ...transform, x: 0 } : null),
transition,
}}
className={`flex items-center gap-1 ${isDragging ? "relative z-10 opacity-80" : ""}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label="Trascina per riordinare"
className="shrink-0 cursor-grab touch-none rounded p-1 text-tertiary opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 active:cursor-grabbing group-hover/tasks:opacity-100"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
export function SortableTaskList({
phaseId,
entityId,
items,
}: {
phaseId: string;
entityId: string;
items: SortableTaskItem[];
}) {
const router = useRouter();
const [, startTransition] = useTransition();
const incomingIds = items.map((i) => i.id);
const [order, setOrder] = useState<string[]>(incomingIds);
// Re-sync when the server sends a different set or order. The two existing
// kanban boards seed state once and never resync; for a status that barely
// shows, for an ORDER it means a stale local array fights router.refresh().
// Adjusted during render (React's documented pattern), not in an effect.
const [prevIds, setPrevIds] = useState(incomingIds.join(","));
if (prevIds !== incomingIds.join(",")) {
setPrevIds(incomingIds.join(","));
setOrder(incomingIds);
}
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor)
);
const byId = new Map(items.map((i) => [i.id, i.node]));
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = order.indexOf(active.id as string);
const to = order.indexOf(over.id as string);
if (from === -1 || to === -1) return;
const next = arrayMove(order, from, to);
setOrder(next);
startTransition(async () => {
await reorderTasks(phaseId, entityId, next);
router.refresh();
});
}
// One task can't be reordered — skip the DnD machinery entirely.
if (items.length < 2) {
return (
<div className="space-y-2">
{items.map((i) => (
<div key={i.id}>{i.node}</div>
))}
</div>
);
}
return (
<DndContext
sensors={sensors}
onDragEnd={handleDragEnd}
>
<SortableContext items={order} strategy={verticalListSortingStrategy}>
<div className="group/tasks space-y-2">
{order.map((id) => (
<SortableRow key={id} id={id}>
{byId.get(id)}
</SortableRow>
))}
</div>
</SortableContext>
</DndContext>
);
}
@@ -1,16 +1,17 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
import { SearchInput } from "@/components/ui/SearchInput";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MentionText } from "@/components/ui/MentionText";
import { cn } from "@/lib/utils";
import { ReplyComposer } from "./ReplyComposer";
import {
editConversationMessage,
markConversationRead,
replyToConversation,
} from "@/app/admin/conversazioni/actions";
import type {
ConversationSummary,
@@ -85,11 +86,14 @@ export function ConversationsView({
{/* ── Right: active thread ────────────────────────────────── */}
<section className="flex-1 min-w-0 flex flex-col bg-card border border-border rounded-xl shadow-card overflow-hidden">
{activeThread ? (
<ActiveThread thread={activeThread} />
// key sul cliente: al refresh (che segna letta la conversazione) il
// componente NON rimonta e tiene i pallini calcolati all'apertura;
// cambiando cliente rimonta e li ricalcola sui dati freschi.
<ActiveThread key={activeThread.clientId} thread={activeThread} />
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 text-muted-foreground">
<MessageSquare className="w-10 h-10 opacity-40" strokeWidth={1.5} />
<p className="text-sm">Nessun messaggio dai clienti ancora.</p>
<p className="text-sm">Scegli un cliente per aprire la conversazione.</p>
</div>
)}
</section>
@@ -122,18 +126,28 @@ function ConversationListItem({
>
{conv.name}
</span>
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
{formatListTime(conv.lastMessageAt)}
</span>
{conv.lastMessageAt && (
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
{formatListTime(conv.lastMessageAt)}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground truncate">
{conv.lastMessageAuthor === "admin" && (
<span className="text-muted-foreground/70">Tu: </span>
{conv.lastMessageAt ? (
<>
{conv.lastMessageAuthor === "admin" && (
<span className="text-muted-foreground/70">Tu: </span>
)}
{conv.lastMessage}
</>
) : (
<span className="italic text-muted-foreground/70">
Nessun messaggio scrivi tu per primo
</span>
)}
{conv.lastMessage}
</p>
<div className="mt-2 flex items-center justify-between gap-2">
<EntityBadge label={conv.lastEntityLabel} />
{conv.lastMessageAt ? <EntityBadge label={conv.lastEntityLabel} /> : <span />}
{conv.unread && (
<span className="flex items-center gap-1.5 shrink-0">
{conv.unreadCount > 1 && (
@@ -153,11 +167,53 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
const scrollRef = useRef<HTMLDivElement>(null);
const portalHref = `/client/${thread.slug ?? thread.token}`;
// Canali con messaggi del cliente non ancora letti, fotografati al montaggio.
// Non è un useMemo: dopo markConversationRead + refresh il calcolo darebbe
// sempre insieme vuoto e i pallini sparirebbero prima di essere visti.
const [unreadAtOpen] = useState(() => {
const lastReadAt = thread.adminLastReadAt?.getTime() ?? 0;
const set = new Set<string>();
for (const m of thread.messages) {
if (m.author === "client" && new Date(m.created_at).getTime() > lastReadAt) {
set.add(m.channelKey);
}
}
return set;
});
const [visited, setVisited] = useState<Set<string>>(new Set());
// Si apre dove c'è qualcosa da leggere; in mancanza, sul canale dell'ultimo
// messaggio. Aprire sempre su "Generale" costringerebbe a cercare a mano il
// tab da cui è arrivata la domanda.
const [activeChannel, setActiveChannel] = useState(() => {
const firstUnread = thread.messages.find(
(m) => m.author === "client" && unreadAtOpen.has(m.channelKey)
);
if (firstUnread) return firstUnread.channelKey;
const last = thread.messages[thread.messages.length - 1];
return last?.channelKey ?? thread.clientId;
});
function selectChannel(key: string) {
setActiveChannel(key);
setVisited((prev) => new Set(prev).add(key));
}
const visibleMessages = useMemo(
() => thread.messages.filter((m) => m.channelKey === activeChannel),
[thread.messages, activeChannel]
);
const activeLabel =
thread.channels.find((c) => c.key === activeChannel)?.label ?? "Generale";
const isGeneral = activeChannel === thread.clientId;
const showTabs = thread.channels.length > 1;
// Keep the thread pinned to the latest message.
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [thread.clientId, thread.messages.length]);
}, [activeChannel, visibleMessages.length]);
return (
<>
@@ -184,74 +240,226 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
</div>
</div>
{/* Canali — stessi tab che vede il cliente nel portale */}
{showTabs && (
<div
className="no-scrollbar flex gap-1 overflow-x-auto border-b border-border px-4 py-2"
role="tablist"
aria-label="Canali della conversazione"
>
{thread.channels.map((channel) => {
const active = channel.key === activeChannel;
const unread = unreadAtOpen.has(channel.key) && !visited.has(channel.key);
return (
<button
key={channel.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => selectChannel(channel.key)}
title={channel.label}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors",
active
? "bg-muted font-semibold text-foreground"
: "font-medium text-muted-foreground hover:text-foreground"
)}
>
<span className="max-w-[120px] truncate">{channel.label}</span>
{unread && !active && (
<>
<span className="w-1.5 h-1.5 shrink-0 rounded-full bg-emerald-500" />
<span className="sr-only">non letti</span>
</>
)}
</button>
);
})}
</div>
)}
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
{thread.messages.length === 0 ? (
{visibleMessages.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
Nessun messaggio in questa conversazione.
Nessun messaggio in questo canale. Scrivi tu per primo.
</p>
) : (
thread.messages.map((m) => <MessageBubble key={m.id} message={m} />)
visibleMessages.map((m) => (
<MessageBubble
key={m.id}
message={m}
clientId={thread.clientId}
mentionCandidates={thread.mentionAliases}
/>
))
)}
</div>
{/* Reply box */}
<form
action={replyToConversation.bind(null, thread.clientId)}
className="p-4 border-t border-border bg-card flex items-end gap-3"
>
<Textarea
name="body"
rows={2}
required
placeholder="Scrivi una risposta..."
className="flex-1 resize-none"
/>
<Button type="submit" size="sm" className="shrink-0">
Invia
</Button>
</form>
{/* Reply box — il canale viaggia col messaggio */}
<ReplyComposer
clientId={thread.clientId}
channelKey={activeChannel}
channelLabel={activeLabel}
isGeneral={isGeneral}
mentionTargets={thread.mentionTargets}
notifyEmailCount={thread.notifyEmailCount}
/>
</>
);
}
function MessageBubble({
message,
clientId,
mentionCandidates,
}: {
message: ConversationThread["messages"][number];
clientId: string;
mentionCandidates: string[];
}) {
const isAdmin = message.author === "admin";
const showEntity = message.entityType !== "general";
// Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo.
const showEntity =
message.entityType === "task" || message.entityType === "deliverable";
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(message.body);
const [failed, setFailed] = useState(false);
const [saving, startSaving] = useTransition();
function startEditing() {
setDraft(message.body);
setFailed(false);
setEditing(true);
}
function save() {
const next = draft.trim();
if (!next || next === message.body) {
setEditing(false);
return;
}
const fd = new FormData();
fd.set("comment_id", message.id);
fd.set("body", next);
startSaving(async () => {
try {
await editConversationMessage(clientId, fd);
setEditing(false);
} catch {
setFailed(true);
}
});
}
return (
<div className={cn("flex", isAdmin && "justify-end")}>
<div
className={cn(
"max-w-[75%] p-3.5 rounded-2xl shadow-sm space-y-1",
isAdmin
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-background border border-border text-foreground rounded-tl-sm"
)}
>
<div className="flex items-center gap-2">
<span
className={cn(
"text-[10px] font-bold",
isAdmin ? "text-primary-foreground/70" : "text-muted-foreground"
)}
>
{isAdmin ? "Tu (Admin)" : "Cliente"}
</span>
{showEntity && <EntityBadge label={message.entityLabel} />}
</div>
<p className="text-xs leading-relaxed whitespace-pre-wrap">{message.body}</p>
<p
<div className={cn("group flex", isAdmin && "justify-end")}>
<div className="max-w-[75%]">
<div
className={cn(
"text-[9px] font-mono",
isAdmin ? "text-primary-foreground/50" : "text-muted-foreground/60"
"p-3.5 rounded-2xl shadow-sm space-y-1",
isAdmin
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-background border border-border text-foreground rounded-tl-sm"
)}
>
{formatMessageTime(message.created_at)}
</p>
<div className="flex items-center gap-2">
<span
className={cn(
"text-[10px] font-bold",
isAdmin ? "text-primary-foreground/70" : "text-muted-foreground"
)}
>
{isAdmin ? "Tu (Admin)" : "Cliente"}
</span>
{showEntity && <EntityBadge label={message.entityLabel} />}
</div>
{editing ? (
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
if (e.key === "Escape") {
e.preventDefault();
setEditing(false);
}
}}
rows={3}
autoFocus
maxLength={2000}
aria-label="Modifica il messaggio"
className="resize-none text-xs text-foreground bg-background"
/>
) : (
<p className="text-xs leading-relaxed whitespace-pre-wrap">
<MentionText
body={message.body}
candidates={mentionCandidates}
// Sulla bolla admin il fondo è `bg-primary`: un chip tenue ci
// sparirebbe sopra, quindi si scava invece di colorare.
mentionClassName={
isAdmin
? "rounded bg-primary-foreground/20 px-1 font-semibold"
: undefined
}
/>
</p>
)}
<p
className={cn(
"text-[9px] font-mono",
isAdmin ? "text-primary-foreground/50" : "text-muted-foreground/60"
)}
>
{formatMessageTime(message.created_at)}
{message.edited_at && " · modificato"}
</p>
</div>
{/* Fuori dalla bolla: sopra `bg-primary` un testo tenue non si leggerebbe. */}
{isAdmin && (
<div className={cn("mt-1 flex items-center gap-3", "justify-end")}>
{editing ? (
<>
<button
type="button"
onClick={save}
disabled={saving}
className="text-[11px] font-semibold text-primary hover:underline disabled:opacity-50"
>
{saving ? "Salvo…" : "Salva"}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="text-[11px] font-medium text-muted-foreground hover:underline"
>
Annulla
</button>
{failed && (
<span className="text-[10px] text-destructive">
Non salvato, riprova
</span>
)}
</>
) : (
<button
type="button"
onClick={startEditing}
className="text-[11px] font-medium text-muted-foreground opacity-0 transition-opacity hover:underline focus-visible:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
>
Modifica
</button>
)}
</div>
)}
</div>
</div>
);
@@ -0,0 +1,219 @@
"use client";
import { useMemo, useRef, useState, useTransition } from "react";
import { AtSign } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { normalizeForSearch, type MentionTarget } from "@/lib/mentions";
import { replyToConversation } from "@/app/admin/conversazioni/actions";
/**
* Casella di risposta dell'inbox, con il tag del cliente.
*
* È controllata e non più un `<form action>` con una textarea libera perché
* il suggerimento del tag deve leggere il testo mentre lo scrivi e riscriverlo
* al punto giusto. Il canale continua a viaggiare col messaggio: `entity_id` è
* il canale aperto, `entity_type` si deriva da esso.
*/
export function ReplyComposer({
clientId,
channelKey,
channelLabel,
isGeneral,
mentionTargets,
notifyEmailCount,
}: {
clientId: string;
channelKey: string;
channelLabel: string;
isGeneral: boolean;
/**
* Chi si può taggare: **una voce per destinatario**, non una per modo di
* scriverlo. Prima qui arrivava la lista piatta degli alias e il menu mostrava
* tre righe nome, brand, nome di battesimo per una persona sola.
*/
mentionTargets: MentionTarget[];
/** Quante mail partirebbero davvero con un tag. Zero = si avvisa, non si tace. */
notifyEmailCount: number;
}) {
const [body, setBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [sending, startSending] = useTransition();
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Posizione dell'ultima "@" ancora aperta e testo digitato dopo: null quando
// non c'è nessun tag in corso di scrittura.
const [trigger, setTrigger] = useState<{ at: number; query: string } | null>(null);
const suggestions = useMemo(() => {
if (!trigger) return [];
const q = normalizeForSearch(trigger.query);
// Si filtra sugli alias ma si tiene il TARGET: digitando «car» il cliente si
// trova passando dal brand, e resta comunque una riga sola col suo nome.
// La normalizzazione è quella del parser, così «nicolo» trova «Nicolò».
return mentionTargets
.filter((t) => t.aliases.some((a) => normalizeForSearch(a).startsWith(q)))
.slice(0, 6);
}, [trigger, mentionTargets]);
const open = suggestions.length > 0;
function syncTrigger(value: string, caret: number) {
const before = value.slice(0, caret);
const at = before.lastIndexOf("@");
if (at === -1) return setTrigger(null);
// Una "@" vale solo a inizio parola: "email@dominio.it" non apre il menu.
const prev = at > 0 ? before[at - 1] : " ";
if (!/\s/.test(prev)) return setTrigger(null);
const query = before.slice(at + 1);
// Un tag non attraversa un a capo, e oltre il nome più lungo non c'è più
// niente da suggerire.
if (query.includes("\n")) return setTrigger(null);
setTrigger({ at, query });
}
function insert(candidate: string) {
if (!trigger) return;
const el = textareaRef.current;
const caret = el?.selectionStart ?? body.length;
const next = `${body.slice(0, trigger.at)}@${candidate} ${body.slice(caret)}`;
const nextCaret = trigger.at + candidate.length + 2;
setBody(next);
setTrigger(null);
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(nextCaret, nextCaret);
});
}
/** Il pulsante @: inserisce la chiocciola e apre subito il menu. */
function startMention() {
const el = textareaRef.current;
const caret = el?.selectionStart ?? body.length;
const needsSpace = caret > 0 && !/\s/.test(body[caret - 1]);
const prefix = needsSpace ? " @" : "@";
const next = `${body.slice(0, caret)}${prefix}${body.slice(caret)}`;
const nextCaret = caret + prefix.length;
setBody(next);
setTrigger({ at: nextCaret - 1, query: "" });
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(nextCaret, nextCaret);
});
}
function send() {
const text = body.trim();
if (!text || sending) return;
const fd = new FormData();
fd.set("entity_type", isGeneral ? "general" : "phase");
fd.set("entity_id", channelKey);
fd.set("body", text);
setError(null);
startSending(async () => {
try {
await replyToConversation(clientId, fd);
setBody("");
setTrigger(null);
} catch {
setError("Messaggio non inviato, riprova");
}
});
}
return (
<div className="border-t border-border bg-card p-4">
<div className="relative flex items-end gap-3">
{open && (
<ul
role="listbox"
aria-label="Tagga il cliente"
className="absolute bottom-full left-0 z-20 mb-2 w-64 overflow-hidden rounded-lg border border-border bg-card shadow-lg"
>
{suggestions.map((target, i) => (
<li key={target.label}>
<button
type="button"
role="option"
aria-selected={i === 0}
// onMouseDown e non onClick: il click toglierebbe il fuoco alla
// textarea prima di leggerne il caret, e l'inserimento finirebbe
// nel posto sbagliato.
onMouseDown={(e) => {
e.preventDefault();
insert(target.label);
}}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted",
i === 0 && "bg-muted/50"
)}
>
<AtSign className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-foreground">{target.label}</span>
</button>
</li>
))}
</ul>
)}
<Textarea
ref={textareaRef}
value={body}
onChange={(e) => {
setBody(e.target.value);
syncTrigger(e.target.value, e.target.selectionStart ?? e.target.value.length);
}}
onClick={(e) => {
const el = e.currentTarget;
syncTrigger(el.value, el.selectionStart ?? el.value.length);
}}
onKeyDown={(e) => {
if (open && (e.key === "Enter" || e.key === "Tab")) {
e.preventDefault();
insert(suggestions[0].label);
return;
}
if (e.key === "Escape" && open) {
e.preventDefault();
setTrigger(null);
return;
}
// Invio manda, Shift+Invio va a capo: il comportamento che il
// pannello del cliente ha già.
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}}
rows={2}
placeholder={`Rispondi in ${channelLabel}...`}
aria-label={`Rispondi nel canale ${channelLabel}`}
className="flex-1 resize-none"
/>
<div className="flex shrink-0 flex-col items-end gap-2">
<Button type="button" size="sm" onClick={send} disabled={sending || !body.trim()}>
{sending ? "Invio…" : "Invia"}
</Button>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={startMention}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<AtSign className="h-3 w-3" /> Tagga
</button>
<span className="text-[11px] text-muted-foreground">
{notifyEmailCount > 0
? `Un tag gli manda una mail (${notifyEmailCount} ${notifyEmailCount === 1 ? "indirizzo" : "indirizzi"}).`
: "Nessuna email a registro: il tag resta solo in chat."}
</span>
{error && <span className="text-[11px] text-destructive">{error}</span>}
</div>
</div>
);
}
+4 -1
View File
@@ -58,7 +58,10 @@ export async function InboxBand() {
{conv.brand_name}
</h3>
<span className="text-[10px] text-muted-foreground font-mono tabular-nums shrink-0">
{relativeTime(conv.lastMessageAt)}
{/* `unread` implica almeno un messaggio: il null non capita,
ma il tipo lo ammette da quando la lista include anche i
clienti con cui non si è ancora scritto. */}
{conv.lastMessageAt ? relativeTime(conv.lastMessageAt) : ""}
</span>
{conv.unreadCount > 1 && (
<span className="text-[10px] font-bold text-emerald-700 dark:text-emerald-400 shrink-0">
@@ -1,22 +1,31 @@
"use client";
import { useState, useTransition } from "react";
import { X } from "lucide-react";
import { Pencil, X } from "lucide-react";
export function PoolManager({
label,
pool,
onAdd,
onRemove,
onRename,
getRenameWarning,
}: {
label: string;
pool: string[];
onAdd: (value: string) => Promise<void>;
onRemove: (value: string) => Promise<void>;
onRename?: (oldValue: string, newValue: string) => Promise<void>;
// Returns a message to confirm before the rename lands, or null to go straight
// through. Only wired for the one field whose rename writes outside the
// catalog (Fase → phases.title).
getRenameWarning?: (oldValue: string, newValue: string) => Promise<string | null>;
}) {
const [input, setInput] = useState("");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [renaming, setRenaming] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
function handleAdd() {
const trimmed = input.trim();
@@ -40,6 +49,22 @@ export function PoolManager({
startTransition(() => onRemove(value));
}
function commitRename(oldValue: string) {
const next = renameValue.trim();
setRenaming(null);
if (!next || next === oldValue) return;
if (pool.includes(next)) {
setError("Valore già presente");
return;
}
setError(null);
startTransition(async () => {
const warning = await getRenameWarning?.(oldValue, next);
if (warning && !window.confirm(warning)) return;
await onRename?.(oldValue, next);
});
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
@@ -69,24 +94,63 @@ export function PoolManager({
Nessun valore inserito
</div>
) : (
pool.map((v) => (
<span
key={v}
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-card py-1 pl-2.5 pr-1.5 text-[11px] font-semibold text-foreground shadow-sm"
>
{v}
<button
type="button"
onClick={() => handleRemove(v)}
disabled={isPending}
className="rounded p-0.5 text-tertiary transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
aria-label={`Elimina ${v}`}
title={`Elimina "${v}" ovunque`}
pool.map((v) =>
renaming === v ? (
<input
key={v}
autoFocus
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => commitRename(v)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitRename(v);
}
if (e.key === "Escape") {
e.preventDefault();
setRenaming(null);
}
}}
aria-label={`Nuovo nome per ${v}`}
className="w-28 rounded-md border border-primary bg-card px-2 py-1 text-[11px] font-semibold text-foreground focus:outline-none"
/>
) : (
<span
key={v}
className="group inline-flex items-center gap-1 rounded-md border border-border/60 bg-card py-1 pl-2.5 pr-1.5 text-[11px] font-semibold text-foreground shadow-sm"
>
<X className="h-3 w-3" />
</button>
</span>
))
{v}
{onRename && (
<button
type="button"
onClick={() => {
setRenameValue(v);
setRenaming(v);
setError(null);
}}
disabled={isPending}
className="rounded p-0.5 text-tertiary opacity-0 transition-all hover:bg-muted hover:text-foreground focus:opacity-100 group-hover:opacity-100 disabled:opacity-50"
aria-label={`Rinomina ${v}`}
title={`Rinomina "${v}" ovunque`}
>
<Pencil className="h-3 w-3" />
</button>
)}
<button
type="button"
onClick={() => handleRemove(v)}
disabled={isPending}
className="rounded p-0.5 text-tertiary transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
aria-label={`Elimina ${v}`}
title={`Elimina "${v}" ovunque`}
>
<X className="h-3 w-3" />
</button>
</span>
)
)
)}
</div>
</div>
@@ -2,7 +2,12 @@
import { Tag, BookOpen } from "lucide-react";
import { PoolManager } from "./PoolManager";
import { addTaxonomyValue, removeTaxonomyValue } from "@/app/admin/impostazioni/actions";
import {
addTaxonomyValue,
getFaseRenameImpact,
removeTaxonomyValue,
renameTaxonomyValue,
} from "@/app/admin/impostazioni/actions";
type FieldDef = { id: string; label: string };
@@ -19,6 +24,17 @@ const CATALOG_FIELDS: FieldDef[] = [
{ id: "service_pacchetto", label: "Pacchetto" },
];
// Renaming a Fase also rewrites `phases.title` in projects that already imported
// it — the only taxonomy rename that touches delivery data, so it asks first and
// says exactly how much it will touch.
async function faseRenameWarning(oldValue: string, newValue: string): Promise<string | null> {
const { phases, projects } = await getFaseRenameImpact(oldValue);
if (phases === 0) return null;
const fasi = phases === 1 ? "1 fase" : `${phases} fasi`;
const prog = projects === 1 ? "1 progetto" : `${projects} progetti`;
return `Rinominare "${oldValue}" in "${newValue}"?\n\nVerrà aggiornato anche il titolo di ${fasi} in ${prog} già avviati.`;
}
function Section({
icon,
title,
@@ -54,6 +70,8 @@ function Section({
pool={pools[f.id] ?? []}
onAdd={(v) => addTaxonomyValue(f.id, v)}
onRemove={(v) => removeTaxonomyValue(f.id, v)}
onRename={(o, n) => renameTaxonomyValue(f.id, o, n)}
getRenameWarning={f.id === "service_fase" ? faseRenameWarning : undefined}
/>
))}
</div>
@@ -82,7 +100,8 @@ export function TaxonomyManager({ pools }: { pools: Record<string, string[]> })
/>
<p className="max-w-4xl px-2 text-xs leading-relaxed text-tertiary">
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.
comparirà qui automaticamente. Rinominando un valore da qui il nuovo nome sostituisce il
vecchio ovunque sia usato; eliminandolo viene rimosso ovunque.
</p>
</div>
);
+42 -25
View File
@@ -15,32 +15,41 @@ import {
} from "@dnd-kit/core";
import { updateTaskStatus } from "@/app/admin/clients/[id]/actions";
import type { ClientFullDetail } from "@/lib/admin-queries";
import {
normalizeTaskStatus,
TASK_STATUS_LABELS,
TASK_STATUSES,
type TaskStatus,
} from "@/lib/task-status";
type Task = ClientFullDetail["phases"][number]["tasks"][number] & {
phaseTitle: string;
};
type Status = "todo" | "in_progress" | "done";
type Status = TaskStatus;
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] = [
{
id: "todo",
label: "Da fare",
headerClass: "text-[#71717a]",
dotClass: "bg-[#d4d4d8]",
// Colours are status semantics — the sanctioned exception to the token rule.
// The hex values are pre-existing design debt (DEBT-01); "in revisione" uses
// violet rather than amber, which PhaseCard already spends on "in corso".
const COLUMN_STYLES: Record<Status, { headerClass: string; dotClass: string }> = {
todo: { headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
in_progress: { headerClass: "text-[#1A463C]", dotClass: "bg-[#DEF168]" },
in_review: {
headerClass: "text-violet-600 dark:text-violet-400",
dotClass: "bg-violet-500",
},
{
id: "in_progress",
label: "In corso",
headerClass: "text-[#1A463C]",
dotClass: "bg-[#DEF168]",
},
{
id: "done",
label: "Fatto",
headerClass: "text-[#1A463C]",
dotClass: "bg-[#1A463C]",
},
];
done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" },
cancelled: { headerClass: "text-[#a1a1aa]", dotClass: "bg-[#a1a1aa]" },
};
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] =
TASK_STATUSES.map((id) => ({
id,
label: TASK_STATUS_LABELS[id],
...COLUMN_STYLES[id],
}));
// Derived from the columns, never a second hand-written list.
const VALID_STATUSES: string[] = COLUMNS.map((c) => c.id);
function DroppableColumn({
id,
@@ -84,6 +93,7 @@ function DroppableColumn({
key={task.id}
task={task}
isActive={activeId === task.id}
status={id}
/>
))}
{tasks.length === 0 && (
@@ -99,9 +109,11 @@ function DroppableColumn({
function DraggableCard({
task,
isActive,
status,
}: {
task: Task;
isActive: boolean;
status: Status;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({ id: task.id });
@@ -123,7 +135,13 @@ function DraggableCard({
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
{task.phaseTitle}
</p>
<p className="text-sm font-medium text-[#1a1a1a] leading-snug">{task.title}</p>
<p
className={`text-sm font-medium leading-snug ${
status === "cancelled" ? "text-[#a1a1aa] line-through" : "text-[#1a1a1a]"
}`}
>
{task.title}
</p>
{task.description && (
<p className="text-xs text-[#71717a] mt-1 leading-snug line-clamp-2">
{task.description}
@@ -149,7 +167,7 @@ export function KanbanBoard({
const map: Record<string, Status> = {};
for (const phase of phases) {
for (const task of phase.tasks) {
map[task.id] = task.status as Status;
map[task.id] = normalizeTaskStatus(task.status);
}
}
return map;
@@ -187,8 +205,7 @@ export function KanbanBoard({
const currentStatus = taskStatuses[taskId];
if (newStatus === currentStatus) return;
if (!(["todo", "in_progress", "done"] as string[]).includes(newStatus))
return;
if (!VALID_STATUSES.includes(newStatus)) return;
setTaskStatuses((prev) => ({ ...prev, [taskId]: newStatus }));
@@ -204,7 +221,7 @@ export function KanbanBoard({
onDragStart={(e) => setActiveId(e.active.id as string)}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-2 gap-4 xl:grid-cols-5">
{COLUMNS.map((col) => (
<DroppableColumn
key={col.id}
+44
View File
@@ -7,6 +7,7 @@ import {
assignOfferToProject,
removeProjectOffer,
updateProjectOfferTotal,
updateOfferValueOverride,
setProjectOfferLifecycle,
} from "@/app/admin/projects/project-actions";
import type { ProjectOfferWithMicro } from "@/lib/admin-queries";
@@ -111,6 +112,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
// l'eliminazione in ClientActions).
const [ceaseArmed, setCeaseArmed] = useState<string | null>(null);
const [lifecycleError, setLifecycleError] = useState<string | null>(null);
const [overrideError, setOverrideError] = useState<string | null>(null);
// Group tiers by offer (macro), preserving query order.
const offers = useMemo(() => {
@@ -174,6 +176,18 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
});
}
function handleValueOverride(offerId: string, value: string) {
setOverrideError(null);
startTransition(async () => {
const res = await updateOfferValueOverride(offerId, projectId, value);
if (!res.ok) {
setOverrideError(res.error);
return;
}
router.refresh();
});
}
function handleLifecycle(
offerId: string,
input: { status: string; end_date?: string | null }
@@ -198,6 +212,9 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
{lifecycleError && (
<p className="mb-3 text-xs text-destructive">{lifecycleError}</p>
)}
{overrideError && (
<p className="mb-3 text-xs text-destructive">{overrideError}</p>
)}
{projectOffers.length === 0 ? (
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
) : (
@@ -243,6 +260,33 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
/>
</div>
{/* Override del "Valore dell'offerta" mostrato nel portale
(0020). Vuoto = il portale calcola la somma dei prezzi
dei servizi del tier; 0 = riga nascosta al cliente. */}
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1">
<label className="text-xs text-[#71717a]">Valore dell&apos;offerta </label>
<input
type="number"
step="0.01"
min="0"
defaultValue={offer.offer_value_override ?? ""}
placeholder={offer.computed_offer_value}
disabled={isPending}
onBlur={(e) => {
const val = e.currentTarget.value.trim();
if (val !== (offer.offer_value_override ?? "")) {
handleValueOverride(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 disabled:opacity-50"
/>
<span className="text-[11px] text-[#71717a]">
{offer.offer_value_override == null
? `calcolato dai servizi (€${offer.computed_offer_value})`
: `sostituisce €${offer.computed_offer_value} · svuota per tornare al calcolo`}
</span>
</div>
{/* Ciclo di vita solo per i ricorrenti: per una una tantum
"sospendere" o "cessare" non significa nulla, la durata è
già data da duration_months. */}
+175 -44
View File
@@ -1,16 +1,21 @@
"use client";
import { useState } from "react";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { RotateCcw } from "lucide-react";
import {
updatePaymentStatus,
updateAcceptedTotal,
setPaymentPaidAt,
updatePaymentField,
clearPaymentOverride,
setPaymentDueDate,
} from "@/app/admin/clients/[id]/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";
import { EditableCell } from "@/components/ui/editable-cell";
import { SplitPaymentForm } from "@/components/admin/SplitPaymentForm";
import type { Payment } from "@/db/schema";
@@ -28,14 +33,23 @@ const statusLabels: Record<string, string> = {
saldato: "Saldato",
};
// 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")}`;
function formatEuro(value: number): string {
return value.toLocaleString("it-IT", { minimumFractionDigits: 2 });
}
// Date | string | null (serializzato sul confine RSC) → "YYYY-MM-DD" per <input type="date">.
// Le date sono salvate a mezzogiorno UTC apposta, quindi i getter locali leggono
// il giorno giusto in qualunque fuso senza scivolare di uno.
function toDateValue(value: Date | string | null | undefined): string {
if (!value) return "";
const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return "";
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function todayValue(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
type PlanMode = "single" | "two" | "three";
@@ -43,7 +57,7 @@ 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%" },
{ mode: "three", label: "3 step", description: "50% + 25% + 25%" },
];
export function PaymentsTab({
@@ -57,35 +71,84 @@ export function PaymentsTab({
const [overrideValue, setOverrideValue] = useState(acceptedTotal);
const [planLoading, setPlanLoading] = useState<PlanMode | null>(null);
const [statusLoading, setStatusLoading] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
// The input is seeded from a prop, so it has to follow the prop after a
// router.refresh() — otherwise it keeps showing the pre-save value. Adjusted
// during render rather than in an effect (React's documented pattern for
// "resetting state when a prop changes"): no extra pass, no flash of stale value.
const [prevAcceptedTotal, setPrevAcceptedTotal] = useState(acceptedTotal);
if (prevAcceptedTotal !== acceptedTotal) {
setPrevAcceptedTotal(acceptedTotal);
setOverrideValue(acceptedTotal);
}
const currentTotal = parseFloat(acceptedTotal) || 0;
const offersTotal = offersAcceptedTotal;
const hasOffers = offersTotal > 0;
const rowsTotal = payments.reduce((sum, p) => sum + (parseFloat(p.amount) || 0), 0);
const drift = rowsTotal - currentTotal;
const hasDrift = payments.length > 0 && Math.abs(drift) >= 0.01;
// Shared runner: every mutation goes through here so a failure is visible
// instead of vanishing into an unhandled rejection.
function run(fn: () => Promise<unknown>) {
setError(null);
startTransition(async () => {
try {
await fn();
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
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();
run(() => updateAcceptedTotal(projectId, fd));
}
async function handleSaveTotal(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("accepted_total", overrideValue);
await updateAcceptedTotal(clientId, fd);
router.refresh();
run(() => updateAcceptedTotal(clientId, fd));
}
async function handleSetPlan(mode: PlanMode) {
if (!projectId) return;
// Picking a plan deletes every existing row — including status, paid_at and
// due_date. Only worth interrupting when there is actually something to lose.
// La scadenza conta quanto lo stato: una rata "da saldare" con una data
// concordata è informazione che il cliente sta già leggendo nel portale, e
// sparirebbe senza che nessuno se ne accorga.
const tracked = payments.filter(
(p) => p.status === "saldato" || p.status === "inviata" || p.due_date !== null
).length;
if (tracked > 0) {
const what =
tracked === 1 ? "1 rata con dati inseriti" : `${tracked} rate con dati inseriti`;
const ok = window.confirm(
`Cambiare schema cancella tutte le rate di questo progetto.\n\nCi sono ${what} (stato, scadenza o data di incasso): andranno persi.\n\nProcedere?`
);
if (!ok) return;
}
setPlanLoading(mode);
setError(null);
try {
const total = parseFloat(overrideValue) || currentTotal;
await setPaymentPlan(projectId, mode, total);
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
} finally {
setPlanLoading(null);
}
@@ -101,11 +164,22 @@ export function PaymentsTab({
}
}
async function handlePaidMonthUpdate(paymentId: string, monthStr: string) {
if (!monthStr) return;
async function handlePaidDateUpdate(paymentId: string, dateStr: string) {
if (!dateStr) return;
setStatusLoading(paymentId);
try {
await setPaymentPaidAt(paymentId, clientId, monthStr);
await setPaymentPaidAt(paymentId, clientId, dateStr);
router.refresh();
} finally {
setStatusLoading(null);
}
}
// La scadenza si può anche togliere: la stringa vuota è un valore, non un no-op.
async function handleDueDateUpdate(paymentId: string, dateStr: string) {
setStatusLoading(paymentId);
try {
await setPaymentDueDate(paymentId, clientId, dateStr);
router.refresh();
} finally {
setStatusLoading(null);
@@ -114,6 +188,12 @@ export function PaymentsTab({
return (
<div className="space-y-6 max-w-md">
{error && (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
{/* 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>
@@ -124,8 +204,7 @@ export function PaymentsTab({
<span className="text-sm text-[#71717a]">
Ereditato dalle offerte attive:{" "}
<span className="font-semibold text-[#1a1a1a]">
{" "}
{offersTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
{formatEuro(offersTotal)}
</span>
</span>
{projectId && (
@@ -211,28 +290,48 @@ export function PaymentsTab({
{/* Payment rows */}
{payments.map((p) => {
const amount = parseFloat(p.amount);
const pct = p.percent !== null && p.percent !== undefined
? parseFloat(String(p.percent))
: null;
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">
<div>
<h3 className="font-medium text-gray-900">{p.label}</h3>
{pct !== null && (
<p className="text-xs text-[#71717a]">{pct}% del totale</p>
<div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-gray-900">
<EditableCell
value={p.label}
type="text"
required
onSave={(v) => run(() => updatePaymentField(p.id, clientId, "label", v))}
/>
</div>
{pct !== null && !p.amount_locked && (
<p className="text-xs text-[#71717a] px-2">{pct}% del totale</p>
)}
{p.amount_locked && (
<p className="flex items-center gap-1 px-2 text-xs text-amber-700 dark:text-amber-500">
Importo scritto a mano
<button
type="button"
onClick={() => run(() => clearPaymentOverride(p.id, clientId))}
className="rounded p-0.5 hover:bg-amber-500/10"
aria-label="Rimuovi l'override e torna al calcolo automatico"
title="Torna al calcolo automatico dalla percentuale"
>
<RotateCcw className="h-3 w-3" />
</button>
</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">
{" "}
{amount.toLocaleString("it-IT", {
minimumFractionDigits: 2,
})}
</span>
<div className="flex items-center gap-2 shrink-0">
<div className="text-sm text-gray-600">
<EditableCell
value={p.amount}
type="number"
required
formatDisplay={(raw) => `${formatEuro(parseFloat(raw) || 0)}`}
onSave={(v) => run(() => updatePaymentField(p.id, clientId, "amount", v))}
/>
</div>
{projectId && amount > 0 && (
<SplitPaymentForm
paymentId={p.id}
@@ -258,21 +357,38 @@ export function PaymentsTab({
</option>
))}
</select>
{statusLoading === p.id && (
<span className="text-xs text-[#71717a]">...</span>
{statusLoading === p.id && <span className="text-xs text-[#71717a]">...</span>}
</div>
{/* Scadenza: è questa che il cliente vede nel portale, col conto alla
rovescia, ed è l'aggancio del futuro promemoria via email.
Sempre modificabile, anche a rata saldata: resta lo storico. */}
<div className="flex items-center gap-2 mt-2">
<Label htmlFor={`due-${p.id}`} className="text-xs text-[#71717a] shrink-0">
Scadenza
</Label>
<input
id={`due-${p.id}`}
type="date"
defaultValue={toDateValue(p.due_date)}
disabled={statusLoading === p.id}
onChange={(e) => handleDueDateUpdate(p.id, e.target.value)}
className="text-sm border border-gray-200 rounded px-2 py-1 bg-white"
/>
{!p.due_date && (
<span className="text-xs text-[#a1a1aa]">non mostrata al cliente</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
Incassato il
</Label>
<input
id={`paid-${p.id}`}
type="month"
defaultValue={toMonthValue(p.paid_at)}
type="date"
defaultValue={toDateValue(p.paid_at) || todayValue()}
disabled={statusLoading === p.id}
onChange={(e) => handlePaidMonthUpdate(p.id, e.target.value)}
onChange={(e) => handlePaidDateUpdate(p.id, e.target.value)}
className="text-sm border border-gray-200 rounded px-2 py-1 bg-white"
/>
</div>
@@ -280,6 +396,21 @@ export function PaymentsTab({
</div>
);
})}
{/* Scarto fra la somma delle rate e il totale del progetto. Si dice, non si
sistema di nascosto: un importo scritto a mano è una scelta, non un errore. */}
{hasDrift && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-500/40 dark:bg-amber-500/10">
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
{drift > 0
? `Le rate superano il totale di € ${formatEuro(drift)}`
: `Le rate coprono € ${formatEuro(-drift)} in meno del totale`}
</p>
<p className="mt-1 font-mono text-xs tabular-nums text-amber-700 dark:text-amber-400">
Somma rate {formatEuro(rowsTotal)} · Totale {formatEuro(currentTotal)}
</p>
</div>
)}
</div>
);
}
+93 -67
View File
@@ -9,6 +9,8 @@ import { Input } from "@/components/ui/input";
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
import { TimerCell } from "@/components/admin/TimerCell";
import type { ClientFullDetail } from "@/lib/admin-queries";
import { TASK_STATUS_LABELS, TASK_STATUSES } from "@/lib/task-status";
import { SortableTaskList } from "@/components/admin/SortableTaskList";
type Props = {
phases: ClientFullDetail["phases"];
@@ -22,11 +24,10 @@ type Props = {
phaseSeconds?: Record<string, number>;
};
const taskStatusOptions = [
{ value: "todo", label: "Da fare" },
{ value: "in_progress", label: "In corso" },
{ value: "done", label: "Fatto" },
];
const taskStatusOptions = TASK_STATUSES.map((value) => ({
value,
label: TASK_STATUS_LABELS[value],
}));
const phaseStatusOptions = [
{ value: "upcoming", label: "Da iniziare" },
@@ -102,7 +103,7 @@ export async function PhasesTab({
await updatePhaseStatus(
phase.id,
clientId,
fd.get("status") as string
fd.get("status") as string,
);
}}
className="flex items-center gap-2"
@@ -118,73 +119,98 @@ export async function PhasesTab({
</option>
))}
</select>
<Button type="submit" variant="ghost" size="sm" className="text-xs">
<Button
type="submit"
variant="ghost"
size="sm"
className="text-xs"
>
Salva
</Button>
</form>
<DeletePhaseTaskButton type="phase" phaseId={phase.id} clientId={clientId} />
<DeletePhaseTaskButton
type="phase"
phaseId={phase.id}
clientId={clientId}
/>
</div>
</div>
{/* Tasks */}
<div className="space-y-2 mb-3">
{phase.tasks.map((task) => (
<div
key={task.id}
className="flex items-center justify-between gap-3 pl-3 border-l-2 border-border"
>
<span className="text-sm text-foreground">{task.title}</span>
<div className="flex items-center gap-1">
{projectId && (
<TimerCell
projectId={projectId}
phaseId={phase.id}
taskId={task.id}
activeEntryId={
activeTimerTaskId === task.id ? activeTimerEntryId : null
}
activeStartedAt={
activeTimerTaskId === task.id ? activeTimerStartedAt : null
}
totalTrackedSeconds={taskSeconds[task.id] ?? 0}
compact
/>
)}
<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-border rounded px-2 py-1 bg-background text-foreground"
>
{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>
))}
{/* Tasks server-rendered rows handed to a client wrapper that adds the
drag handle. The rows keep their inline "use server" forms, which is
only legal inside a Server Component. */}
<div className="mb-3">
<SortableTaskList
phaseId={phase.id}
entityId={clientId}
items={phase.tasks.map((task) => ({
id: task.id,
node: (
<div className="flex items-center justify-between gap-3 pl-3 border-l-2 border-border">
<span className="text-sm text-foreground">
{task.title}
</span>
<div className="flex items-center gap-1">
{projectId && (
<TimerCell
projectId={projectId}
phaseId={phase.id}
taskId={task.id}
activeEntryId={
activeTimerTaskId === task.id
? activeTimerEntryId
: null
}
activeStartedAt={
activeTimerTaskId === task.id
? activeTimerStartedAt
: null
}
totalTrackedSeconds={taskSeconds[task.id] ?? 0}
compact
/>
)}
<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-border rounded px-2 py-1 bg-background text-foreground"
>
{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>
{/* Add task form */}
+24 -5
View File
@@ -1,5 +1,6 @@
import type { ClientView } from '@/lib/client-view';
import type { Comment } from '@/db/schema';
import { mentionCandidates } from '@/lib/mentions';
import type { ChatData } from '@/lib/chat-channels';
import { RefreshCw } from 'lucide-react';
import { PhaseTimeline } from './phase-timeline';
import { PaymentStatus } from './payment-status';
@@ -16,15 +17,25 @@ import { PreviewProvider } from './client/PreviewProvider';
interface ClientDashboardProps {
view: ClientView;
token: string;
comments: Comment[];
/** Messaggi + ricevute di lettura della chat, già scoped a questo progetto. */
chat: ChatData;
/** When rendered inside the multi-project tabs wrapper, the page already
* provides the portal header + footer skip them here to avoid duplicates. */
embedded?: boolean;
/** Anteprima admin: sola lettura, approvazioni e chat disattivate. */
preview?: boolean;
/** Canale da aprire all'arrivo (`?chat=`), gia' validato dalla pagina. */
initialChatChannel?: string | null;
}
export function ClientDashboard({ view, token, comments, embedded = false, preview = false }: ClientDashboardProps) {
export function ClientDashboard({
view,
token,
chat,
embedded = false,
preview = false,
initialChatChannel = null,
}: ClientDashboardProps) {
// Determine payment display mode based on active offers.
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
@@ -42,7 +53,11 @@ export function ClientDashboard({ view, token, comments, embedded = false, previ
return (
<PreviewProvider preview={preview}>
<ChatProvider phases={view.phases} clientId={view.client.id}>
<ChatProvider
phases={view.phases}
clientId={view.client.id}
initialChannel={initialChatChannel}
>
<div className={embedded ? "" : "min-h-screen bg-background"}>
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
{!embedded && (
@@ -160,7 +175,11 @@ export function ClientDashboard({ view, token, comments, embedded = false, previ
</footer>
{/* Floating chat panel — FAB + slide-in panel */}
<ChatPanel token={token} comments={comments} />
<ChatPanel
token={token}
chat={chat}
mentionCandidates={mentionCandidates(view.client)}
/>
</div>
</ChatProvider>
</PreviewProvider>
File diff suppressed because it is too large Load Diff
+61 -18
View File
@@ -1,15 +1,27 @@
"use client";
import { createContext, useContext, useState, useCallback } from "react";
import { createContext, useContext, useState, useCallback, useMemo } from "react";
import type { ClientView } from "@/lib/client-view";
/**
* Stato di sola UI della chat: quale canale è aperto, se il pannello è visibile,
* se è a tutto schermo. I messaggi NON stanno qui vivono in ChatPanel, che è
* l'unico a leggerli, a fare polling e a tenerne la copia optimistic.
*
* Il canale sta invece nel context perché lo decide anche chi è fuori dal
* pannello: la bolla su una PhaseCard apre la chat già sulla fase giusta.
*/
interface ChatContextValue {
isOpen: boolean;
selectedPhaseId: string | null; // null = "Generale"
expanded: boolean;
/** Chiave del canale attivo: clientId per "Generale", phases.id per una fase. */
activeChannel: string;
phases: ClientView["phases"];
clientId: string;
openChat: (phaseId?: string) => void;
closeChat: () => void;
toggleExpanded: () => void;
setActiveChannel: (key: string) => void;
}
const ChatContext = createContext<ChatContextValue | null>(null);
@@ -24,26 +36,57 @@ export function ChatProvider({
children,
phases,
clientId,
initialChannel,
}: {
children: React.ReactNode;
phases: ClientView["phases"];
clientId: string;
/**
* Canale richiesto da fuori con `?chat=<key>` è così che il pulsante della
* mail di tag porta il cliente dentro la conversazione giusta invece che sulla
* dashboard, dove la chat resterebbe chiusa e il messaggio non letto.
*
* Arriva dal server, già validato: leggerlo qui da `window.location` vorrebbe
* dire aprire il pannello in un effetto, cioè renderizzarlo chiuso e riaprirlo
* subito dopo l'idratazione.
*/
initialChannel?: string | null;
}) {
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>
// Un canale richiesto vale anche come "apri il pannello": chi arriva da quel
// link sta venendo a leggere un messaggio, non a guardare la timeline.
const [isOpen, setIsOpen] = useState(!!initialChannel);
const [expanded, setExpanded] = useState(false);
// Il canale "Generale" è identificato dall'id del cliente: stessa convenzione
// di comments.entity_id, così non serve tradurre nulla in scrittura.
const [activeChannel, setActiveChannel] = useState<string>(
initialChannel ?? clientId
);
const openChat = useCallback(
(phaseId?: string) => {
setActiveChannel(phaseId ?? clientId);
setIsOpen(true);
},
[clientId]
);
const closeChat = useCallback(() => setIsOpen(false), []);
const toggleExpanded = useCallback(() => setExpanded((v) => !v), []);
const value = useMemo(
() => ({
isOpen,
expanded,
activeChannel,
phases,
clientId,
openChat,
closeChat,
toggleExpanded,
setActiveChannel,
}),
[isOpen, expanded, activeChannel, phases, clientId, openChat, closeChat, toggleExpanded]
);
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
}
+28 -18
View File
@@ -18,6 +18,10 @@ const statusWordClass: Record<"upcoming" | "active" | "done", string> = {
* Horizontal milestone stepper replica del "STEP PROGRESS TIMELINE" del mock.
* Un nodo per fase reale (non 5 fissi); la linea di fondo si riempie in base a
* global_progress_pct.
*
* Layout compatto (design-reference/Client-Portal-Progress-Bar): il nodo occupa
* due righe di testo "Step N" e lo stato sulla stessa riga, il titolo fase
* sotto con padding ridotto, così la barra ruba poca altezza alla dashboard.
*/
export function MilestoneStepper({
phases,
@@ -29,10 +33,12 @@ export function MilestoneStepper({
if (phases.length === 0) return null;
return (
<div className="bg-card border-b border-border-light py-8 px-6">
<div className="relative mx-auto max-w-[1200px]">
{/* Linea di connessione + riempimento dinamico */}
<div className="absolute left-[5%] right-[5%] top-[15px] z-0 h-1 rounded-full bg-muted">
<div className="bg-card border-b border-border-light px-6 py-3">
{/* Nessun max-width: la barra occupa tutta la larghezza della viewport,
a differenza del resto della dashboard che è incolonnato a 1400px. */}
<div className="relative w-full">
{/* Linea di connessione + riempimento dinamico (centrata sui cerchi) */}
<div className="absolute left-[5%] right-[5%] top-[14px] z-0 h-1 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)]"
style={{ width: `${globalProgress}%` }}
@@ -41,13 +47,13 @@ export function MilestoneStepper({
{/* Nodi milestone */}
<div
className="relative z-10 grid gap-4"
className="relative z-10 grid gap-3"
style={{ gridTemplateColumns: `repeat(${phases.length}, minmax(0, 1fr))` }}
>
{phases.map((phase, i) => {
const s = phase.status;
return (
<div key={phase.id} className="flex flex-col items-center text-center">
<div key={phase.id} className="flex min-w-0 flex-col items-center text-center">
<div
className={
"flex h-8 w-8 items-center justify-center rounded-full border-4 border-card text-xs font-bold shadow-md transition-all " +
@@ -60,20 +66,24 @@ export function MilestoneStepper({
>
{s === "done" ? "✓" : i + 1}
</div>
<span
className={
"mt-2 text-[11px] font-bold " +
(s === "upcoming" ? "text-muted-foreground" : "text-foreground")
}
>
Step {i + 1}
</span>
<span className="text-[10px] font-medium text-muted-foreground line-clamp-1">
<div className="mt-1.5 flex max-w-full items-baseline gap-1.5">
<span
className={
"text-[11px] font-bold leading-tight " +
(s === "upcoming" ? "text-muted-foreground" : "text-foreground")
}
>
Step {i + 1}
</span>
<span
className={`truncate text-[9px] font-bold uppercase leading-tight tracking-wide ${statusWordClass[s]}`}
>
{statusWord[s]}
</span>
</div>
<span className="max-w-full truncate text-[10px] font-medium leading-tight text-muted-foreground">
{phase.title}
</span>
<span className={`mt-1 text-[9px] font-bold uppercase ${statusWordClass[s]}`}>
{statusWord[s]}
</span>
</div>
);
})}
+9 -51
View File
@@ -1,24 +1,13 @@
"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 — 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
offer_value: string; // override admin, o somma calcolata dei servizi
accepted_total: string | null;
status: string; // attivo | sospeso — le cessate non arrivano qui
start_date: string; // ISO
end_date: string | null; // ISO — null = continuativo
services: IncludedService[];
}
/** "15 giugno 2026" — le date nel portale sono in italiano esteso. */
@@ -35,10 +24,8 @@ interface OffersSectionProps {
}
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;
const value = parseFloat(offer.offer_value);
const hasValue = value > 0;
const isRetainer = offer.offer_type === "retainer";
const isSuspended = offer.status === "sospeso";
@@ -67,11 +54,12 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
)}
<div className="mt-2 space-y-1.5">
{/* "Valore incluso" — hidden when 0 */}
{hasPrice && (
{/* "Valore dell'offerta" override admin o somma dei servizi.
Zero significa "non mostrarlo". */}
{hasValue && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Valore incluso</span>
<span className="font-mono text-foreground">{price.toFixed(2)}</span>
<span className="text-muted-foreground">Valore dell&apos;offerta</span>
<span className="font-mono text-foreground">{value.toFixed(2)}</span>
</div>
)}
@@ -79,7 +67,7 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
{offer.accepted_total && (
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-foreground">
{isRetainer ? "Canone mensile" : "Prezzo finale"}
{isRetainer ? "Canone mensile" : "Investimento finale"}
</span>
<span className="text-sm font-bold text-primary">
{parseFloat(offer.accepted_total).toFixed(2)}
@@ -89,36 +77,6 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
</div>
</div>
</div>
{/* Accordion "Cosa è compreso" — only when there are services */}
{hasServices && (
<div className="border-t border-border-light">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-5 py-2.5 text-xs font-semibold text-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
aria-expanded={open}
>
<span>Cosa è compreso</span>
<ChevronDown
className={`w-3.5 h-3.5 text-muted-foreground transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<ul className="px-5 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-foreground">{svc.name}</span>
{svc.description && (
<span className="text-[11px] text-muted-foreground leading-snug">{svc.description}</span>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
+49 -33
View File
@@ -3,6 +3,12 @@
import { useState } from "react";
import { ApproveButton } from "@/components/client/ApproveButton";
import { useChatContext } from "@/components/client/ChatProvider";
import {
isClosedTaskStatus,
TaskStatusIcon,
TaskStatusPill,
} from "@/components/client/TaskStatusIndicator";
import { countsTowardProgress } from "@/lib/task-status";
import type { ClientView } from "@/lib/client-view";
type Phase = ClientView["phases"][number];
@@ -28,26 +34,6 @@ const phaseBarColor: Record<"upcoming" | "active" | "done", string> = {
done: "bg-emerald-600",
};
function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) {
if (status === "done") {
return (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-50 text-[10px] font-bold text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">
</span>
);
}
if (status === "in_progress") {
return (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-amber-400 bg-card">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
</span>
);
}
return (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-border bg-card" />
);
}
export function PhaseCard({
phase,
token,
@@ -60,6 +46,14 @@ export function PhaseCard({
const [open, setOpen] = useState(defaultOpen);
const { openChat } = useChatContext();
const doneCount = phase.tasks.filter((t) => t.status === "done").length;
// Il denominatore esclude le cancellate: "3 di 4" con una quarta voce annullata
// resterebbe fermo lì per sempre, e la barra non toccherebbe mai il 100%.
const countedTotal = phase.tasks.filter((t) => countsTowardProgress(t.status)).length;
const cancelledCount = phase.tasks.length - countedTotal;
// I task in revisione aspettano il cliente, non noi. Il conteggio sta nell'header,
// che resta visibile anche a card chiusa: altrimenti la richiesta si nasconde
// dentro una fase collassata e il cliente non sa che tocca a lui.
const reviewCount = phase.tasks.filter((t) => t.status === "in_review").length;
return (
<div className="rounded-xl border border-border-light bg-card p-6 shadow-card">
@@ -107,9 +101,23 @@ export function PhaseCard({
{/* Progress bar — always visible, colore per stato */}
<div className="mb-1">
<div className="flex justify-between items-center mb-1.5">
<p className="text-xs text-muted-foreground font-medium">{doneCount} di {phase.tasks.length} task</p>
<p className="text-xs font-semibold text-foreground">{phase.progress_pct}%</p>
<div className="flex justify-between items-center gap-3 mb-1.5">
<p className="text-xs text-muted-foreground font-medium min-w-0">
{doneCount} di {countedTotal} task
{reviewCount > 0 && (
<span className="text-violet-600 dark:text-violet-400">
{" · "}
{reviewCount} in attesa di riscontro
</span>
)}
{cancelledCount > 0 && (
<span>
{" · "}
{cancelledCount} {cancelledCount === 1 ? "cancellata" : "cancellate"}
</span>
)}
</p>
<p className="text-xs font-semibold text-foreground shrink-0">{phase.progress_pct}%</p>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
@@ -128,18 +136,26 @@ export function PhaseCard({
) : (
<ul className="space-y-3">
{phase.tasks.map((task) => (
<li key={task.id} className="flex items-start gap-3">
<li
key={task.id}
className={`flex items-start gap-3 ${task.status === "cancelled" ? "opacity-70" : ""}`}
>
<TaskStatusIcon status={task.status} />
<div className="flex-1 min-w-0">
<p
className={`text-xs leading-snug ${
task.status === "done"
? "line-through text-muted-foreground"
: "text-foreground"
}`}
>
{task.title}
</p>
{/* flex-wrap: su un titolo lungo la pill va a capo invece di
schiacciare il testo */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<p
className={`text-xs leading-snug ${
isClosedTaskStatus(task.status)
? "line-through text-muted-foreground"
: "text-foreground"
}`}
>
{task.title}
</p>
<TaskStatusPill status={task.status} />
</div>
{task.description && (
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
{task.description}
@@ -0,0 +1,210 @@
// ── Come lo stato di un task appare al cliente ───────────────────────────────
// Le etichette stanno in src/lib/task-status.ts e sono condivise con l'admin.
// Qui c'è solo il livello client-facing: forma, colore e la frase che spiega lo
// stato a chi non lavora al progetto.
//
// Prima di questo file lo stato era un cerchietto senza testo: ambra col puntino
// da 1.5px per "in corso", violetto col puntino da 2px per "in revisione". A 20px
// sono lo stesso oggetto, e il design system chiede il contrario — colore + testo,
// mai colore da solo. Ora le quattro forme si distinguono anche in bianco e nero:
// vuoto → punto → spunta vuota → spunta piena. La spunta compare quando il lavoro
// è finito e si riempie quando è confermato. La cancellata è l'unica che non è
// un passo avanti: X e testo barrato, tinta spenta, fuori da ogni conteggio.
import { TASK_STATUSES, TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
type StatusVisual = {
/** Classi del cerchio nella lista task. */
ring: string;
/** Classi della pill di testo. `null` = stato che non ne ha bisogno: "da fare"
* è un cerchio vuoto e "fatto" è barrato, si leggono senza etichetta. Tenerlo
* come dato della mappa evita che "quali stati hanno la pill" finisca in un if. */
pill: string | null;
/** Pallino per legenda e intestazioni del kanban. */
dot: string;
/** Colore dell'etichetta nelle intestazioni del kanban. */
label: string;
/** Tinta del contatore in cima alla colonna kanban. */
count: string;
/** Frase client-facing: legge lo screen reader, e sta nella legenda. */
hint: string;
};
// Colori di stato: l'eccezione sanzionata alla regola dei soli token semantici
// (DESIGN-SYSTEM.md). Soft tint come le altre pill del portale, ogni valore con
// la sua variante dark — che l'icona "in corso" non aveva.
export const TASK_STATUS_VISUALS: Record<TaskStatus, StatusVisual> = {
todo: {
ring: "border-2 border-border bg-card",
pill: null,
dot: "bg-border",
label: "text-muted-foreground",
count: "bg-muted text-muted-foreground",
hint: "Da fare — non ancora iniziato.",
},
in_progress: {
ring: "border-2 border-amber-400 bg-card dark:border-amber-500",
pill: "bg-amber-50 text-amber-700 border border-amber-100 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20",
dot: "bg-amber-500 dark:bg-amber-400",
label: "text-amber-700 dark:text-amber-400",
count: "bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400",
hint: "In corso — ci stiamo lavorando noi.",
},
in_review: {
ring: "border-2 border-violet-400 bg-card dark:border-violet-500",
pill: "bg-violet-50 text-violet-700 border border-violet-100 dark:bg-violet-500/10 dark:text-violet-400 dark:border-violet-500/20",
dot: "bg-violet-500 dark:bg-violet-400",
label: "text-violet-700 dark:text-violet-400",
count: "bg-violet-50 text-violet-700 dark:bg-violet-500/10 dark:text-violet-400",
hint: "In revisione — pronto, aspetta il tuo riscontro.",
},
done: {
ring: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400",
pill: null,
dot: "bg-emerald-500 dark:bg-emerald-400",
label: "text-emerald-700 dark:text-emerald-400",
count: "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400",
hint: "Fatto — completato.",
},
cancelled: {
ring: "border-2 border-border bg-muted text-muted-foreground",
// Unica pill fra gli stati chiusi: "fatto" e "cancellata" sono entrambi
// barrati, e la differenza fra i due — consegnato o tolto dal lavoro — è
// troppo importante per affidarla al solo disegno dentro il cerchio.
pill: "bg-muted text-muted-foreground border border-border",
dot: "bg-muted-foreground/40",
label: "text-muted-foreground",
count: "bg-muted text-muted-foreground",
hint: "Cancellata — non verrà realizzata.",
},
};
/** Stati chiusi: il titolo si legge barrato, il lavoro non è più in corso. */
export function isClosedTaskStatus(status: TaskStatus): boolean {
return status === "done" || status === "cancelled";
}
function CheckGlyph({ className }: { className: string }) {
return (
<svg
className={className}
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
}
function CrossGlyph({ className }: { className: string }) {
return (
<svg
className={className}
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6L6 18" />
</svg>
);
}
/**
* Il cerchio di stato accanto al titolo del task.
*
* L'icona è decorativa (`aria-hidden`) e lo stato viaggia in uno `sr-only`: così
* lo screen reader lo annuncia su tutti gli stati, mentre a schermo solo quelli
* ambigui portano la pill.
*/
export function TaskStatusIcon({ status }: { status: TaskStatus }) {
const v = TASK_STATUS_VISUALS[status];
return (
<>
<span
aria-hidden="true"
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${v.ring}`}
>
{status === "done" && <CheckGlyph className="h-3 w-3" />}
{status === "cancelled" && <CrossGlyph className="h-2.5 w-2.5" />}
{status === "in_review" && (
<CheckGlyph className="h-2.5 w-2.5 text-violet-600 dark:text-violet-400" />
)}
{status === "in_progress" && (
<span className="h-1.5 w-1.5 rounded-full bg-amber-500 dark:bg-amber-400" />
)}
</span>
<span className="sr-only">{v.hint}</span>
</>
);
}
/**
* Etichetta di testo. Nulla per "da fare" e "fatto", che si leggono da soli.
*
* `aria-hidden` perché è ridondanza *visiva*: allo screen reader lo stato lo dice già
* lo `sr-only` dell'icona, su tutti gli stati. Senza questo, quelli che hanno la pill
* verrebbero annunciati due volte.
*/
export function TaskStatusPill({ status }: { status: TaskStatus }) {
const v = TASK_STATUS_VISUALS[status];
if (!v.pill) return null;
return (
<span
aria-hidden="true"
className={`shrink-0 rounded px-1.5 py-0.5 text-[9px] font-bold uppercase leading-none tracking-wide ${v.pill}`}
>
{TASK_STATUS_LABELS[status]}
</span>
);
}
/**
* Legenda degli stati + la riga che spiega "in revisione".
*
* Le voci si generano da TASK_STATUSES, mai da una lista riscritta a mano: è la
* disciplina introdotta insieme allo stato "in revisione", dopo che due liste
* fisse avevano già fatto sparire task dal portale senza un errore.
*/
export function TaskStatusLegend({ showCancelled = false }: { showCancelled?: boolean }) {
// "Cancellata" è l'unica voce condizionata: nella maggior parte dei progetti non
// ce n'è nessuna, e spiegare a tutti uno stato che non vedranno mai è rumore.
// La condizione guarda i task veri: se una cancellata c'è, la legenda la spiega.
const statuses = TASK_STATUSES.filter((s) => s !== "cancelled" || showCancelled);
return (
<div className="mb-6 rounded-lg bg-muted/50 px-4 py-3">
<ul className="flex flex-wrap items-center gap-x-5 gap-y-2">
{statuses.map((status) => (
<li key={status} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className={`h-1.5 w-1.5 shrink-0 rounded-full ${TASK_STATUS_VISUALS[status].dot}`}
/>
<span className="text-[11px] font-medium text-muted-foreground">
{TASK_STATUS_LABELS[status]}
</span>
</li>
))}
</ul>
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">
<span className="font-semibold text-foreground">In revisione</span> il lavoro è pronto e
aspetta il tuo riscontro: scrivicelo dalla chat della fase. Ogni pagina include un giro di
revisione.
</p>
{showCancelled && (
<p className="mt-1 text-[11px] leading-snug text-muted-foreground">
<span className="font-semibold text-foreground">Cancellata</span> l&apos;attività è
stata tolta dal lavoro, d&apos;accordo con te: resta scritta ma non verrà realizzata, e
non pesa sull&apos;avanzamento.
</p>
)}
</div>
);
}
+63 -31
View File
@@ -2,24 +2,29 @@
import type { ClientView } from "@/lib/client-view";
import { ApproveButton } from "@/components/client/ApproveButton";
import { isClosedTaskStatus, TASK_STATUS_VISUALS } from "@/components/client/TaskStatusIndicator";
import { TASK_STATUS_LABELS, TASK_STATUSES, type TaskStatus } from "@/lib/task-status";
type Task = ClientView["phases"][number]["tasks"][number] & {
phaseTitle: string;
};
const COLUMNS: { id: "todo" | "in_progress" | "done"; label: string }[] = [
{ id: "todo", label: "Da fare" },
{ id: "in_progress", label: "In corso" },
{ id: "done", label: "Fatto" },
];
const COLUMNS: { id: TaskStatus; label: string }[] = TASK_STATUSES.map((id) => ({
id,
label: TASK_STATUS_LABELS[id],
}));
function TaskCard({ task, token }: { task: Task; token: string }) {
return (
<div className="bg-card rounded-lg border border-border-light px-4 py-4 shadow-sm hover:border-primary/30 hover:shadow-card-hover transition-all">
<div
className={`bg-card rounded-lg border border-border-light px-4 py-4 shadow-sm hover:border-primary/30 hover:shadow-card-hover transition-all ${
task.status === "cancelled" ? "opacity-70" : ""
}`}
>
<p className="text-[9px] font-semibold text-muted-foreground uppercase tracking-wide mb-1 truncate">
{task.phaseTitle}
</p>
<p className={`text-xs font-medium leading-snug ${task.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}>
<p className={`text-xs font-medium leading-snug ${isClosedTaskStatus(task.status) ? "line-through text-muted-foreground" : "text-foreground"}`}>
{task.title}
</p>
{task.description && (
@@ -46,32 +51,59 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
);
const tasksByStatus = {
todo: allTasks.filter((t) => t.status === "todo"),
in_progress: allTasks.filter((t) => t.status === "in_progress"),
done: allTasks.filter((t) => t.status === "done"),
};
// Derived from the statuses, not a hand-written object: as three fixed keys it
// dropped any task outside them from every column AND every counter, so the
// client silently saw fewer tasks than the project had.
const tasksByStatus = COLUMNS.reduce(
(acc, col) => {
acc[col.id] = allTasks.filter((t) => t.status === col.id);
return acc;
},
{} as Record<TaskStatus, Task[]>
);
// "Cancellate" è l'unica colonna che si nasconde da vuota: nella maggior parte
// dei progetti non ce n'è nessuna, e una quinta colonna vuota ruberebbe spazio
// alle quattro che raccontano il lavoro. Si nasconde solo se è vuota, quindi
// nessun task sparisce mai dalla board — era proprio quello il bug di prima.
const visibleColumns = COLUMNS.filter(
(col) => col.id !== "cancelled" || tasksByStatus.cancelled.length > 0
);
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{COLUMNS.map((col) => (
<div key={col.id} className="flex flex-col gap-4 rounded-xl border border-border-light bg-muted/60 p-4 min-h-[300px]">
<div className="flex items-center justify-between border-b border-border-light pb-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">{col.label}</span>
<span className="text-[10px] font-bold tabular-nums bg-secondary text-secondary-foreground rounded-full px-2 py-0.5">
{tasksByStatus[col.id].length}
</span>
<div
className={`grid grid-cols-1 gap-6 sm:grid-cols-2 ${
visibleColumns.length > 4 ? "xl:grid-cols-5" : "xl:grid-cols-4"
}`}
>
{visibleColumns.map((col) => {
// Stessa tinta dell'icona in Timeline: passando da una vista all'altra
// "in corso" e "in revisione" restano riconoscibili senza rileggere.
const v = TASK_STATUS_VISUALS[col.id];
return (
<div key={col.id} className="flex flex-col gap-4 rounded-xl border border-border-light bg-muted/60 p-4 min-h-[300px]">
<div className="flex items-center justify-between gap-2 border-b border-border-light pb-2">
<div className="flex min-w-0 items-center gap-2">
<span aria-hidden="true" className={`h-1.5 w-1.5 shrink-0 rounded-full ${v.dot}`} />
<span className={`truncate text-xs font-bold uppercase tracking-wider ${v.label}`}>
{col.label}
</span>
</div>
<span className={`shrink-0 text-[10px] font-bold tabular-nums rounded-full px-2 py-0.5 ${v.count}`}>
{tasksByStatus[col.id].length}
</span>
</div>
<div className="flex-1 flex flex-col gap-3">
{tasksByStatus[col.id].map((task) => (
<TaskCard key={task.id} task={task} token={token} />
))}
{tasksByStatus[col.id].length === 0 && (
<p className="text-xs text-muted-foreground italic text-center py-10">Nessun task</p>
)}
</div>
</div>
<div className="flex-1 flex flex-col gap-3">
{tasksByStatus[col.id].map((task) => (
<TaskCard key={task.id} task={task} token={token} />
))}
{tasksByStatus[col.id].length === 0 && (
<p className="text-xs text-muted-foreground italic text-center py-10">Nessun task</p>
)}
</div>
</div>
))}
);
})}
</div>
);
}
}
@@ -2,6 +2,7 @@
import { useState, type ReactNode } from "react";
import { ClientKanban } from "./ClientKanban";
import { TaskStatusLegend } from "@/components/client/TaskStatusIndicator";
import type { ClientView } from "@/lib/client-view";
export function PhaseViewToggle({
@@ -14,6 +15,7 @@ export function PhaseViewToggle({
token: string;
}) {
const [view, setView] = useState<"timeline" | "kanban">("timeline");
const hasCancelled = phases.some((p) => p.tasks.some((t) => t.status === "cancelled"));
return (
<div>
@@ -39,7 +41,10 @@ export function PhaseViewToggle({
</div>
</div>
{/* Fuori dallo switch: gli stati sono gli stessi in entrambe le viste */}
<TaskStatusLegend showCancelled={hasCancelled} />
{view === "timeline" ? timelineView : <ClientKanban phases={phases} token={token} />}
</div>
);
}
}
+77 -5
View File
@@ -1,4 +1,11 @@
import type { ClientView } from '@/lib/client-view';
import {
daysUntil,
findNextDue,
formatCountdown,
formatLongDate,
formatShortDate,
} from '@/lib/payment-dates';
interface PaymentStatusProps {
accepted_total: string;
@@ -49,6 +56,16 @@ export function PaymentStatus({
maximumFractionDigits: 2,
});
// Il conto alla rovescia si calcola qui, sul server: la pagina è dinamica
// (`revalidate = 0`), quindi il numero è fresco a ogni apertura del portale.
//
// Il riquadro compare anche quando le righe sono nascoste (retainer): non
// contiene importi, e una scadenza esiste solo se l'admin l'ha scritta — se
// l'ha scritta, è perché il cliente la deve sapere.
const nextDue = findNextDue(payments);
const nextDueDays = nextDue?.due_date ? daysUntil(nextDue.due_date) : null;
const isLate = nextDueDays !== null && nextDueDays < 0;
return (
<div className="rounded-xl border border-border-light bg-card shadow-card overflow-hidden">
{/* Totale — unico importo visibile al cliente (LOCKED) */}
@@ -61,7 +78,34 @@ export function PaymentStatus({
</p>
</div>
{/* Righe pagamento: solo etichetta + stato, MAI importo singolo — omesse se hideRows */}
{/* Prossima scadenza — la domanda più ovvia del cliente, in cima e senza cifre */}
{nextDue && nextDue.due_date && nextDueDays !== null && (
<div
className={`px-5 py-4 border-b border-border-light ${
isLate
? 'bg-red-50 dark:bg-red-500/10'
: 'bg-amber-50/60 dark:bg-amber-500/10'
}`}
>
<p
className={`text-[10px] font-bold uppercase tracking-wider mb-1 ${
isLate
? 'text-red-700 dark:text-red-400'
: 'text-amber-700 dark:text-amber-400'
}`}
>
{isLate ? 'Pagamento scaduto' : 'Prossimo pagamento'}
</p>
<p className="text-sm font-bold text-foreground leading-snug">
{formatLongDate(nextDue.due_date)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{nextDue.label} · {formatCountdown(nextDueDays)}
</p>
</div>
)}
{/* Righe pagamento: solo etichetta, stato e date — MAI importo singolo */}
{!hideRows && (
<div className="px-5 py-4 space-y-2">
{payments.length === 0 ? (
@@ -73,14 +117,42 @@ export function PaymentStatus({
const status = payment.status as PaymentStatusValue;
const config = statusConfig[status] ?? statusConfig.da_saldare;
// Una riga saldata mostra quando è stata incassata; una ancora
// aperta mostra quando scade. Nessuna delle due inventa la data:
// se in gestionale non c'è, sotto l'etichetta non compare nulla.
const paidOn =
status === 'saldato' && payment.paid_at
? `Pagato il ${formatShortDate(payment.paid_at)}`
: null;
const dueOn =
status !== 'saldato' && payment.due_date
? `Scade il ${formatShortDate(payment.due_date)} · ${formatCountdown(
daysUntil(payment.due_date)
)}`
: null;
const overdue = dueOn !== null && daysUntil(payment.due_date!) < 0;
return (
<div
key={payment.id}
className="flex items-center justify-between gap-4 rounded-lg border border-border-light p-3"
className="flex items-start justify-between gap-4 rounded-lg border border-border-light p-3"
>
<p className="text-xs font-semibold text-foreground">
{payment.label}
</p>
<div className="min-w-0">
<p className="text-xs font-semibold text-foreground">
{payment.label}
</p>
{(paidOn || dueOn) && (
<p
className={`text-[11px] mt-0.5 leading-snug ${
overdue
? 'text-red-600 dark:text-red-400'
: 'text-muted-foreground'
}`}
>
{paidOn ?? dueOn}
</p>
)}
</div>
{/* Pill stato — nessun importo */}
<span
+44
View File
@@ -0,0 +1,44 @@
import { Fragment } from "react";
import { cn } from "@/lib/utils";
import { splitMentions } from "@/lib/mentions";
/**
* Corpo di un messaggio di chat con le menzioni evidenziate.
*
* Sostituisce `{m.body}` dentro il paragrafo: mantiene `whitespace-pre-wrap` sul
* genitore, quindi gli a capo continuano a funzionare senza dover spezzare il
* testo in righe qui dentro.
*/
export function MentionText({
body,
candidates,
mentionClassName,
}: {
body: string;
candidates: string[];
/** Il contrasto cambia col fondo della bolla: lo decide chi la disegna. */
mentionClassName?: string;
}) {
const parts = splitMentions(body, candidates);
return (
<>
{parts.map((part, i) =>
part.type === "mention" ? (
<span
key={i}
className={cn(
"rounded px-1 font-semibold",
mentionClassName ??
"bg-primary/10 text-primary dark:bg-primary/20"
)}
>
{part.value}
</span>
) : (
<Fragment key={i}>{part.value}</Fragment>
)
)}
</>
);
}
@@ -0,0 +1,32 @@
-- Additive: give `payments` a stable display order and a manual-override flag.
--
-- Why: the table had NO ordering column at all (no sort_order, no created_at) and
-- none of the 13 queries reading it had an ORDER BY. Postgres seq-scans and returns
-- physical order; an MVCC UPDATE rewrites the tuple at the end, so marking a payment
-- "saldato" pushed it to the bottom of the list.
--
-- No drops, no truncates, no deletes — `payments` is LOCKED in CLAUDE.md.
ALTER TABLE payments ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_locked boolean NOT NULL DEFAULT false;
-- Backfill. Deliberately NOT ordered by ctid: 3 of the 5 projects in production are
-- already displaying scrambled (one shows 30/20/50, one is fully reversed 20/30/50,
-- and the legacy pair shows Saldo before Acconto), so physical order would freeze the
-- bug in place instead of fixing it.
--
-- percent DESC reconstructs the intent of every plan shape that exists (100 / 50-50 /
-- 50-30-20). The label tiebreaker puts "Acconto…" before "Saldo…" in the legacy pairs
-- that carry percent NULL.
UPDATE payments p SET sort_order = s.rn - 1
FROM (
SELECT id, row_number() OVER (
PARTITION BY project_id ORDER BY percent DESC NULLS LAST, label ASC
) AS rn
FROM payments
) s
WHERE p.id = s.id;
-- A foreign key creates no index on the referencing side, so payments had no index
-- at all beyond its PK. This one serves both the FK lookups and the new ORDER BY.
CREATE INDEX IF NOT EXISTS payments_project_sort_idx ON payments(project_id, sort_order);
@@ -0,0 +1,16 @@
-- Additive: let the admin override the "Valore dell'offerta" shown in the client portal.
--
-- Why: the figure on the portal's offer card was always computed — the sum of the
-- unit_price of every service attached to the tier (or the legacy offer_services
-- fallback). That sum is a catalog artifact: it moves whenever a price in the
-- catalog moves, and for offers assembled out of odd service mixes it lands on a
-- number that means nothing to the client. It had no manual escape hatch.
--
-- NULL keeps the computed sum (current behaviour for every existing row).
-- A value wins over it. Zero is meaningful: it hides the row entirely, the same
-- way a computed sum of 0 already does.
--
-- No drops, no truncates, no deletes.
ALTER TABLE project_offers
ADD COLUMN IF NOT EXISTS offer_value_override numeric(10, 2);
+36
View File
@@ -0,0 +1,36 @@
-- Additive: chat a canali nel portale cliente (Generale + una per fase).
--
-- Il modello resta quello polimorfico di `comments` (entity_type + entity_id):
-- nessuna colonna nuova lì, il "canale" è derivato — general => clients.id,
-- phase => phases.id, mentre task e deliverable rientrano nel canale della fase
-- proprietaria. Qui si aggiunge solo ciò che il modello non sa esprimere: fino a
-- dove il CLIENTE ha letto ciascun canale.
--
-- Perché una tabella e non una colonna su clients: un singolo
-- client_last_read_at segnerebbe letti TUTTI i canali all'apertura della chat,
-- e il pallino sul singolo tab — l'unica cosa che dice al cliente dove
-- guardare — perderebbe senso. L'admin resta invece su clients.admin_last_read_at
-- (vedi 0014): lì la lettura è già a livello di conversazione e il pallino per
-- canale si deriva da quel timestamp, quindi non serve nulla di nuovo.
--
-- channel_key non ha FK: vale clients.id per "Generale" e phases.id per le fasi.
-- Una fase cancellata lascia una riga orfana, innocua e ignorata in lettura.
--
-- Apply to prod via SSH+docker exec BEFORE pushing schema-dependent code.
-- No drops, no truncates, no data loss.
CREATE TABLE IF NOT EXISTS client_channel_reads (
id text PRIMARY KEY,
client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
channel_key text NOT NULL,
read_at timestamptz NOT NULL DEFAULT now()
);
-- Upsert target: ON CONFLICT (client_id, channel_key) DO UPDATE SET read_at = now()
CREATE UNIQUE INDEX IF NOT EXISTS client_channel_reads_client_channel_idx
ON client_channel_reads (client_id, channel_key);
-- Il feed ora si legge filtrato per entità e ordinato nel tempo: comments non ha
-- mai avuto un indice (nemmeno su entity_id) dal giorno 0.
CREATE INDEX IF NOT EXISTS comments_entity_created_idx
ON comments (entity_id, created_at);
+21
View File
@@ -0,0 +1,21 @@
-- Additive: modifica di un messaggio già inviato, in chat (portale + inbox admin).
--
-- Modello Slack/Discord: si corregge un proprio messaggio senza limite di tempo,
-- il testo precedente NON viene conservato, e accanto all'ora compare «modificato».
-- È una scelta deliberata e non una svista — vedi STATUS.md: un messaggio scritto
-- mesi fa resta riscrivibile, e l'unica difesa dell'altra parte è quell'etichetta.
-- Se un domani servisse dimostrare cosa era stato scritto, la strada è una tabella
-- `comment_edits` con lo storico: additiva, si aggiunge senza toccare questa colonna.
--
-- NULL = mai modificato. Nessun backfill: i messaggi esistenti non sono stati
-- modificati, e scriverci dentro un timestamp finto li marcherebbe tutti come tali.
--
-- Perché serve una colonna e non basta l'update del body: il poll della chat chiede
-- i messaggi con `created_at > since`, e una modifica NON cambia `created_at`. Senza
-- un secondo timestamp su cui filtrare, l'altra parte continuerebbe a vedere il testo
-- vecchio fino a un ricaricamento completo della pagina.
--
-- Apply to prod via SSH+docker exec BEFORE pushing schema-dependent code.
-- No drops, no truncates, no data loss.
ALTER TABLE comments ADD COLUMN IF NOT EXISTS edited_at timestamptz;
@@ -0,0 +1,19 @@
-- Additive: la data in cui una rata è attesa.
--
-- Perché: `payments` sapeva solo QUANDO una rata è stata incassata (paid_at) e mai
-- quando ci si aspetta che lo sia. Il portale non poteva quindi rispondere alla
-- domanda più ovvia del cliente — "quando devo pagare?" — e non c'era nessun campo
-- su cui agganciare il promemoria via email prima della scadenza.
--
-- Nullable senza default: una rata senza data concordata è normale, e un default
-- (per esempio "oggi") inventerebbe una scadenza che nessuno ha pattuito. Il
-- portale mostra la scadenza solo se c'è.
--
-- Nessun DROP, nessun TRUNCATE, nessuna DELETE — `payments` è LOCKED in CLAUDE.md.
ALTER TABLE payments ADD COLUMN IF NOT EXISTS due_date timestamptz;
-- Il promemoria email cercherà "le rate non saldate in scadenza entro N giorni":
-- è una scansione su tutta la tabella filtrata per data, non per progetto, quindi
-- l'indice esistente (project_id, sort_order) non la copre.
CREATE INDEX IF NOT EXISTS payments_due_date_idx ON payments(due_date) WHERE due_date IS NOT NULL;
+59 -2
View File
@@ -141,7 +141,7 @@ export const tasks = pgTable("tasks", {
.references(() => phases.id, { onDelete: "cascade" }),
title: text("title").notNull(),
description: text("description"),
status: text("status").notNull().default("todo"), // todo | in_progress | done
status: text("status").notNull().default("todo"), // TASK_STATUSES in src/lib/task-status.ts
sort_order: integer("sort_order").notNull().default(0),
});
@@ -165,15 +165,50 @@ export const comments = pgTable("comments", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
entity_type: text("entity_type").notNull(), // task | deliverable
// general | phase | task | deliverable. Il canale della chat si deriva da qui:
// general => entity_id è clients.id, phase => phases.id. Task e deliverable non
// sono più scrivibili da nessuna UI, ma lo storico si legge ancora e rientra nel
// canale della fase proprietaria — vedi src/lib/chat-channels.ts.
entity_type: text("entity_type").notNull(),
entity_id: text("entity_id").notNull(),
author: text("author").notNull(), // client | admin
body: text("body").notNull(),
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
// NULL = mai modificato. Il body viene sovrascritto e il testo precedente non
// si conserva (modello Slack/Discord): qui resta solo l'istante, che serve a
// due cose — l'etichetta «modificato» e il filtro del poll, che senza questo
// non avrebbe modo di accorgersi di una modifica (created_at non cambia).
edited_at: timestamp("edited_at", { withTimezone: true }),
});
// Fin dove il CLIENTE ha letto ciascun canale della chat. Una riga per
// (client_id, channel_key); channel_key è clients.id per "Generale" e phases.id
// per le fasi — nessuna FK, è polimorfico come comments.
//
// L'admin NON usa questa tabella: la sua lettura resta clients.admin_last_read_at
// (a livello di conversazione), e il pallino per canale si deriva da lì.
export const client_channel_reads = pgTable(
"client_channel_reads",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
client_id: text("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
channel_key: text("channel_key").notNull(),
read_at: timestamp("read_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex("client_channel_reads_client_channel_idx").on(
table.client_id,
table.channel_key
),
]
);
// ============ TAGS (polymorphic — services now, leads in Phase 14) ============
// entity_type scopes the tag pool (D-06): "services" tags and "leads" tags are
// separate pools even though they share this table. No `color` column — badge
@@ -214,6 +249,12 @@ export const payments = pgTable("payments", {
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 }),
// Scadenza concordata. Nullable: non tutte le rate ne hanno una, e il portale
// la mostra al cliente solo quando c'è. È anche l'aggancio del promemoria email.
due_date: timestamp("due_date", { withTimezone: true }),
sort_order: integer("sort_order").notNull().default(0),
// true = l'importo è stato scritto a mano: rescalePayments non lo tocca più.
amount_locked: boolean("amount_locked").notNull().default(false),
});
// ============ DOCUMENTS ============
@@ -458,6 +499,10 @@ export const project_offers = pgTable("project_offers", {
status: text("status").notNull().default("attivo"),
// NULL = continuativo (semantica implicita prima della 0016).
end_date: timestamp("end_date", { withTimezone: true }),
// Override manuale del "Valore dell'offerta" mostrato nel portale cliente
// (migration 0020). NULL = si usa la somma calcolata dai prezzi dei servizi;
// 0 = riga nascosta al cliente.
offer_value_override: numeric("offer_value_override", { precision: 10, scale: 2 }),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
@@ -994,6 +1039,16 @@ export const commentsRelations = relations(comments, (_) => ({
// Polymorphic: no direct FK relation — entity_type + entity_id used at query time
}));
export const clientChannelReadsRelations = relations(
client_channel_reads,
({ one }) => ({
client: one(clients, {
fields: [client_channel_reads.client_id],
references: [clients.id],
}),
})
);
export const tagsRelations = relations(tags, (_) => ({
// Polymorphic: no direct FK relation — entity_type + entity_id used at query time
}));
@@ -1144,6 +1199,8 @@ export type Deliverable = typeof deliverables.$inferSelect;
export type NewDeliverable = typeof deliverables.$inferInsert;
export type Comment = typeof comments.$inferSelect;
export type NewComment = typeof comments.$inferInsert;
export type ClientChannelRead = typeof client_channel_reads.$inferSelect;
export type NewClientChannelRead = typeof client_channel_reads.$inferInsert;
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;
export type Payment = typeof payments.$inferSelect;
+29 -6
View File
@@ -28,6 +28,7 @@ import {
import { eq, ne, inArray, asc, desc, isNull, isNotNull, sql, and } from "drizzle-orm";
import { getPool } from "@/lib/taxonomy";
import { LEAD_STAGES } from "@/lib/lead-validators";
import { getComputedOfferValues } from "@/lib/offer-value";
import type {
Client,
Project,
@@ -120,7 +121,11 @@ export async function getAllClientsWithPayments(
}
const [allPayments, activeEntries, totals, offerTotals] = await Promise.all([
db.select().from(payments).where(inArray(payments.project_id, projectIds)),
db
.select()
.from(payments)
.where(inArray(payments.project_id, projectIds))
.orderBy(asc(payments.sort_order)),
db
.select({
@@ -241,6 +246,10 @@ export type ProjectOfferWithMicro = {
// Ciclo di vita (v2.4 Phase 13): attivo | sospeso | cessato.
status: string;
end_date: Date | null;
/** Override del "Valore dell'offerta" nel portale (0020). null = somma calcolata. */
offer_value_override: string | null;
/** Somma dei prezzi dei servizi del tier — la cifra che l'override sostituisce. */
computed_offer_value: string;
created_at: Date;
};
@@ -326,7 +335,8 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
const paymentsRows = await db
.select()
.from(payments)
.where(inArray(payments.project_id, projectIds));
.where(inArray(payments.project_id, projectIds))
.orderBy(asc(payments.sort_order));
const documentsRows = await db
.select()
@@ -510,7 +520,8 @@ export async function getAllProjectsWithPayments(
db
.select()
.from(payments)
.where(inArray(payments.project_id, projectIds)),
.where(inArray(payments.project_id, projectIds))
.orderBy(asc(payments.sort_order)),
db
.select({
@@ -641,7 +652,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, taskSecondsRows, phaseSecondsRows, projectOffersRows, availableMicrosRows, transcriptsRows] =
await Promise.all([
db.select().from(payments).where(eq(payments.project_id, id)),
db.select().from(payments).where(eq(payments.project_id, id)).orderBy(asc(payments.sort_order)),
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
db.select().from(notes).where(eq(notes.project_id, id)).orderBy(asc(notes.created_at)),
db
@@ -709,6 +720,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
accepted_total: project_offers.accepted_total,
status: project_offers.status,
end_date: project_offers.end_date,
offer_value_override: project_offers.offer_value_override,
created_at: project_offers.created_at,
})
.from(project_offers)
@@ -783,9 +795,20 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
return true;
});
// "Valore dell'offerta" calcolato per ogni tier assegnato: è la cifra che il
// portale mostra quando offer_value_override è NULL, quindi l'admin deve
// vederla per sapere cosa sta sostituendo scrivendo nel campo override.
const computedOfferValues = await getComputedOfferValues(
projectOffersRows.map((o) => o.micro_id)
);
const projectOffers: ProjectOfferWithMicro[] = projectOffersRows.map((o) => ({
...o,
computed_offer_value: computedOfferValues.get(o.micro_id) ?? "0",
}));
// Le offerte cessate non entrano nel totale: serve come default del piano
// pagamenti, e un abbonamento chiuso non è denaro da incassare.
const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce(
const offersAcceptedTotal = projectOffers.reduce(
(sum, o) =>
o.status === "cessato"
? sum
@@ -808,7 +831,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
taskSeconds,
phaseSeconds,
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
projectOffers,
offersAcceptedTotal,
availableMicros: dedupedMicros,
transcripts: transcriptsRows,
+141
View File
@@ -0,0 +1,141 @@
/**
* Canali della chat logica condivisa tra portale cliente e inbox admin.
*
* `comments` è polimorfica (entity_type + entity_id) e non conosce il concetto di
* "canale": lo deriviamo qui, in un solo posto, perché le due sponde devono
* concordare al carattere. Se cliente e admin calcolassero il canale ognuno per
* conto suo, una risposta finirebbe in un tab diverso da quello in cui è stata
* scritta la domanda che è esattamente il bug che questa riorganizzazione chiude.
*
* Convenzione delle chiavi:
* "Generale" -> clients.id
* una fase -> phases.id
* task -> la chiave della fase proprietaria (tasks.phase_id)
* deliverable -> la chiave della fase del task proprietario
*
* Task e deliverable non sono più scrivibili da nessuna UI, ma lo storico esiste:
* rientra nel canale della fase invece di restare orfano, conservando il nome
* dell'entità come badge sul messaggio.
*
* Funzioni pure: nessun accesso al DB, nessun import di server-only.
*/
export const GENERAL_LABEL = "Generale";
export type ChatChannel = {
/** clients.id per "Generale", phases.id per le fasi. */
key: string;
type: "general" | "phase";
label: string;
};
type PhaseLike = {
id: string;
title: string;
tasks?: ReadonlyArray<TaskLike>;
};
type TaskLike = {
id: string;
title?: string;
deliverables?: ReadonlyArray<{ id: string; title?: string }>;
};
export type ChannelIndex = {
/** entity_id -> chiave del canale. */
channelOf: ReadonlyMap<string, string>;
/**
* entity_id -> nome dell'entità, popolato SOLO per task e deliverable: dentro
* un canale-fase serve a distinguere "su cosa" era il messaggio storico.
* Vuoto per general e phase, dove l'etichetta è già il nome del tab.
*/
entityLabel: ReadonlyMap<string, string>;
};
/** L'elenco dei tab: Generale in testa, poi le fasi nell'ordine ricevuto. */
export function buildChannels(
clientId: string,
phases: ReadonlyArray<{ id: string; title: string }>
): ChatChannel[] {
return [
{ key: clientId, type: "general", label: GENERAL_LABEL },
...phases.map((p) => ({
key: p.id,
type: "phase" as const,
// Una fase senza titolo romperebbe il tab: meglio un'etichetta muta che vuota.
label: p.title?.trim() || "Fase senza titolo",
})),
];
}
/** Mappa ogni entità del progetto al canale in cui i suoi messaggi vanno letti. */
export function buildChannelIndex(
clientId: string,
phases: ReadonlyArray<PhaseLike>
): ChannelIndex {
const channelOf = new Map<string, string>();
const entityLabel = new Map<string, string>();
channelOf.set(clientId, clientId);
for (const phase of phases) {
channelOf.set(phase.id, phase.id);
for (const task of phase.tasks ?? []) {
channelOf.set(task.id, phase.id);
if (task.title) entityLabel.set(task.id, task.title);
for (const deliverable of task.deliverables ?? []) {
channelOf.set(deliverable.id, phase.id);
if (deliverable.title) entityLabel.set(deliverable.id, deliverable.title);
}
}
}
return { channelOf, entityLabel };
}
/**
* Canale di un messaggio. Il fallback su "Generale" non è cosmetico: se una fase
* viene cancellata, i suoi messaggi resterebbero senza tab e sparirebbero dalla
* vista senza alcun errore. Meglio farli riemergere in Generale.
*/
export function channelOfComment(
comment: { entity_id: string },
index: ChannelIndex,
clientId: string
): string {
return index.channelOf.get(comment.entity_id) ?? clientId;
}
/**
* Forma strutturale di un messaggio, compatibile sia con la riga Drizzle
* (`created_at: Date`) sia con la stessa riga passata via JSON dal poll
* (`created_at: string`). Evita il cast `as unknown as Comment[]` che serviva
* per far entrare i comment nel tipo legacy ClientView.
*/
export type ChatMessage = {
id: string;
entity_type: string;
entity_id: string;
author: string;
body: string;
created_at: Date | string;
/** Valorizzato solo se il messaggio è stato modificato dopo l'invio. */
edited_at?: Date | string | null;
};
/** Tutto ciò che il pannello riceve dal server al primo render. */
export type ChatData = {
/** Serve al poll: la chat è per progetto, come getProjectView. */
projectId: string;
messages: ChatMessage[];
/** channel_key -> ISO dell'ultima lettura del cliente. */
reads: Record<string, string>;
/**
* Come si firma chi risponde al cliente. Prima era la stringa "iamcavalli"
* cablata nel pannello: il cliente leggeva il marchio dove si aspetta una
* persona. Arriva da `settings`, con default in getAdminIdentity().
*/
adminName: string;
/** URL esterno, o null: in quel caso resta il monogramma del nome. */
adminAvatarUrl: string | null;
};
+127
View File
@@ -0,0 +1,127 @@
import { eq, and, inArray } from "drizzle-orm";
import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables } from "@/db/schema";
/**
* Autorizzazione della chat lato cliente, in un solo posto.
*
* Prima queste stesse regole vivevano inline dentro POST /api/client/comment;
* ora le route sono tre (scrittura, poll, ricevuta di lettura) e duplicarle
* sarebbe il modo più rapido per farle divergere.
*
* Nota sul modello di autenticazione: qui si valida SOLO il token del portale,
* non il cookie di sessione OTP. È il comportamento pre-esistente, condiviso con
* /api/client/approve chi ha il link scrive. Allineare tutte le route client al
* gate OTP è un lavoro a : queste funzioni non lo peggiorano lo risolvono.
*/
/** clients.id dal token del portale, o null se il token non esiste. */
export async function resolveClientByToken(token: string): Promise<string | null> {
const rows = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.token, token))
.limit(1);
return rows[0]?.id ?? null;
}
/**
* L'entità appartiene davvero a questo cliente?
*
* Una join mirata per tipo invece delle quattro scansioni "prendi tutti i
* progetti, tutte le fasi, tutti i task" che faceva la route: stesso esito, una
* query sola, e non cresce col numero di task del cliente.
*/
export async function assertClientOwnsEntity(
clientId: string,
entityType: "general" | "phase" | "task" | "deliverable",
entityId: string
): Promise<boolean> {
if (entityType === "general") {
// I messaggi generali sono ancorati al cliente stesso.
return entityId === clientId;
}
if (entityType === "phase") {
const rows = await db
.select({ id: phases.id })
.from(phases)
.innerJoin(projects, eq(phases.project_id, projects.id))
.where(and(eq(phases.id, entityId), eq(projects.client_id, clientId)))
.limit(1);
return rows.length > 0;
}
if (entityType === "task") {
const rows = await db
.select({ id: tasks.id })
.from(tasks)
.innerJoin(phases, eq(tasks.phase_id, phases.id))
.innerJoin(projects, eq(phases.project_id, projects.id))
.where(and(eq(tasks.id, entityId), eq(projects.client_id, clientId)))
.limit(1);
return rows.length > 0;
}
const rows = await db
.select({ id: deliverables.id })
.from(deliverables)
.innerJoin(tasks, eq(deliverables.task_id, tasks.id))
.innerJoin(phases, eq(tasks.phase_id, phases.id))
.innerJoin(projects, eq(phases.project_id, projects.id))
.where(and(eq(deliverables.id, entityId), eq(projects.client_id, clientId)))
.limit(1);
return rows.length > 0;
}
/**
* Gli entity_id di cui la chat di UN progetto deve leggere i messaggi:
* il cliente stesso (canale Generale) più fasi, task e deliverable del progetto.
*
* Deve restare allineato allo scope di getProjectView (client-view.ts): se il
* poll leggesse più entità del primo render, comparirebbero messaggi che nessun
* tab sa dove mettere; se ne leggesse meno, le risposte non arriverebbero mai.
*
* Ritorna null se il progetto non è di questo cliente.
*/
export async function getProjectChatScope(
clientId: string,
projectId: string
): Promise<string[] | null> {
const projectRows = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.client_id, clientId)))
.limit(1);
if (projectRows.length === 0) return null;
const phaseRows = await db
.select({ id: phases.id })
.from(phases)
.where(eq(phases.project_id, projectId));
const phaseIds = phaseRows.map((p) => p.id);
const taskRows =
phaseIds.length === 0
? []
: await db
.select({ id: tasks.id })
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds));
const taskIds = taskRows.map((t) => t.id);
const deliverableRows =
taskIds.length === 0
? []
: await db
.select({ id: deliverables.id })
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
return [
clientId,
...phaseIds,
...taskIds,
...deliverableRows.map((d) => d.id),
];
}
+160
View File
@@ -0,0 +1,160 @@
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { clients, client_emails, phases } from "@/db/schema";
import { mentionEmailTemplate, sendEmail } from "@/lib/mailer";
import { getAdminIdentity } from "@/lib/settings";
import { GENERAL_LABEL } from "@/lib/chat-channels";
/**
* Notifica al cliente che è stato taggato in chat.
*
* Il portale non manda una notifica per ogni messaggio, e non deve: il cliente
* lo apre quando gli pare. Il tag è l'eccezione dichiarata — l'admin decide che
* quel messaggio non può aspettare il prossimo accesso, e quella decisione vale
* una mail. Nessuno scheduler, nessuna coda: l'invio parte dalla stessa azione
* che scrive il messaggio.
*/
/**
* Gli indirizzi che ricevono la notifica: la whitelist OTP (le persone che
* possono davvero entrare nel portale) più l'indirizzo di contatto sulla scheda.
* Deduplicati senza distinzione di maiuscole, perché lo stesso indirizzo scritto
* in due modi è una persona sola.
*/
export async function getMentionRecipients(clientId: string): Promise<string[]> {
const [whitelisted, clientRows] = await Promise.all([
db
.select({ email: client_emails.email })
.from(client_emails)
.where(eq(client_emails.client_id, clientId)),
db
.select({ email: clients.email })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1),
]);
const seen = new Set<string>();
const out: string[] = [];
for (const value of [
...whitelisted.map((r) => r.email),
clientRows[0]?.email ?? null,
]) {
const email = value?.trim();
if (!email) continue;
const key = email.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(email);
}
return out;
}
/**
* Base pubblica del portale. `NEXTAUTH_URL` è già l'origine giusta in ogni
* ambiente; `APP_BASE_URL` esiste solo per il caso in cui le due debbano
* divergere. Se manca tutto si torna null: meglio una mail senza link che una
* mail con un link a localhost.
*/
function portalBaseUrl(): string | null {
const raw = process.env.APP_BASE_URL ?? process.env.NEXTAUTH_URL;
const trimmed = raw?.trim().replace(/\/+$/, "");
return trimmed || null;
}
type MentionInput = {
clientId: string;
/** clients.id per "Generale", phases.id per una fase. */
channelKey: string;
body: string;
};
export type MentionNotifyResult = {
/** Quante mail sono partite davvero. */
sent: number;
recipients: number;
errors: string[];
};
/**
* Non lancia mai: il messaggio in chat è già stato scritto e resta valido anche
* se Resend è giù. Un errore d'invio si logga e si restituisce, non si propaga
* fino a far sembrare fallita la risposta al cliente.
*/
export async function notifyClientMention({
clientId,
channelKey,
body,
}: MentionInput): Promise<MentionNotifyResult> {
const result: MentionNotifyResult = { sent: 0, recipients: 0, errors: [] };
try {
const [clientRow] = await db
.select({
name: clients.name,
brand_name: clients.brand_name,
token: clients.token,
slug: clients.slug,
})
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (!clientRow) return result;
const recipients = await getMentionRecipients(clientId);
result.recipients = recipients.length;
if (recipients.length === 0) return result;
const [admin, channelLabel] = await Promise.all([
getAdminIdentity(),
resolveChannelLabel(clientId, channelKey),
]);
const base = portalBaseUrl();
// Lo slug è il link "bello" quando c'è; il token è sempre valido. Entrambi
// sono credenziali al portatore: finiscono solo nella mail del cliente.
const url = base
? `${base}/client/${clientRow.slug ?? clientRow.token}?chat=${encodeURIComponent(channelKey)}`
: null;
const { subject, html } = mentionEmailTemplate({
clientName: clientRow.name,
adminName: admin.name,
channelLabel,
body,
url,
});
// In serie e non in parallelo: sono uno o due indirizzi, e un 429 di Resend
// su un burst costerebbe più di quanto valga la concorrenza.
for (const to of recipients) {
const outcome = await sendEmail({ to, subject, html });
if (outcome.ok) result.sent++;
else result.errors.push(`${to}: ${outcome.error}`);
}
if (result.errors.length > 0) {
console.error("[notifyClientMention] invii falliti:", result.errors.join(" | "));
}
} catch (err) {
const message = err instanceof Error ? err.message : "errore sconosciuto";
result.errors.push(message);
console.error("[notifyClientMention] errore:", message);
}
return result;
}
/** "Generale" o il titolo della fase: è la stessa etichetta che il cliente vede sul tab. */
async function resolveChannelLabel(
clientId: string,
channelKey: string
): Promise<string> {
if (channelKey === clientId) return GENERAL_LABEL;
const [row] = await db
.select({ title: phases.title })
.from(phases)
.where(eq(phases.id, channelKey))
.limit(1);
return row?.title?.trim() || GENERAL_LABEL;
}
+59 -77
View File
@@ -1,6 +1,8 @@
import { eq, ne, and, inArray, asc, desc } 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, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, client_channel_reads, project_offers, offer_micros, offer_macros, clientTranscripts } from "@/db/schema";
import { countsTowardProgress, type TaskStatus } from "@/lib/task-status";
import { getComputedOfferValues } from "@/lib/offer-value";
/**
* ClientView: Legacy shape used by ClientDashboard component.
@@ -24,7 +26,7 @@ export interface ClientView {
id: string;
title: string;
description: string | null;
status: "todo" | "in_progress" | "done";
status: TaskStatus;
sort_order: number;
deliverables: Array<{
id: string;
@@ -40,6 +42,10 @@ export interface ClientView {
id: string;
label: string;
status: "da_saldare" | "inviata" | "saldato";
// Le date sì, gli importi no: il cliente deve sapere quando paga e quando ha
// pagato — quanto, per singola rata, resta fuori dall'API cliente (LOCKED #2).
due_date: string | null; // ISO
paid_at: string | null; // ISO
}>;
documents: Array<{
id: string;
@@ -57,12 +63,11 @@ export interface ClientView {
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;
offer_value: string; // override admin, o somma calcolata dei servizi
accepted_total: string | null;
status: string; // attivo | sospeso — le cessate non arrivano al cliente
start_date: string; // ISO
end_date: string | null; // ISO — null = continuativo
services: Array<{ name: string; description: string | null }>;
}>;
transcripts: Array<{
id: string;
@@ -107,6 +112,8 @@ export interface ProjectView {
label: string;
// amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md + DASH-07)
status: string;
due_date: Date | null;
paid_at: Date | null;
}>;
documents: Array<{
id: string;
@@ -126,19 +133,29 @@ export interface ProjectView {
author: string;
body: string;
created_at: Date;
/** Valorizzato solo se il messaggio è stato modificato dopo l'invio. */
edited_at: Date | null;
}>;
/**
* Fin dove il cliente ha letto ciascun canale della chat: channel_key -> ISO.
* Serve al primo paint senza, i pallini di "non letto" comparirebbero solo
* dopo il primo poll, cioè fino a 20 secondi dopo l'apertura della pagina.
*/
channel_reads: Record<string, string>;
global_progress_pct: number;
activeOffers?: Array<{
id: string;
public_name: string; // offer_micros.public_name — NEVER internal_name
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)
// "Valore dell'offerta": project_offers.offer_value_override quando è
// valorizzato, altrimenti la somma di services.unit_price (nuovo catalogo)
// o offer_services.price (legacy). "0" = da non mostrare.
offer_value: string;
accepted_total: string | null;
status: string; // attivo | sospeso — le cessate non arrivano al cliente
start_date: string; // ISO
end_date: string | null; // ISO — null = continuativo
services: Array<{ name: string; description: string | null }>; // included services list
}>;
transcripts: Array<{
id: string;
@@ -315,10 +332,15 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
id: payments.id,
label: payments.label,
status: payments.status,
// Date sì, importo no: la scadenza e l'incasso servono al cliente per
// sapere quando paga; l'importo della singola rata resta escluso.
due_date: payments.due_date,
paid_at: payments.paid_at,
// amount intentionally excluded — client API never exposes payment amounts
})
.from(payments)
.where(eq(payments.project_id, projectId));
.where(eq(payments.project_id, projectId))
.orderBy(asc(payments.sort_order));
const documentsRows = await db
.select({
@@ -351,6 +373,7 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
status: project_offers.status, // attivo | sospeso
start_date: project_offers.start_date,
end_date: project_offers.end_date,
offer_value_override: project_offers.offer_value_override,
micro_id: project_offers.micro_id,
})
.from(project_offers)
@@ -363,84 +386,31 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
)
);
// 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);
// 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,
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, microsNeedingLegacy));
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);
}
// Somma dei prezzi dei servizi per micro — è il fallback del "Valore
// dell'offerta" quando l'admin non ha messo un override. Stesso helper che
// usa l'admin per mostrare quale cifra sta sostituendo.
const computedValues = await getComputedOfferValues(
projectOfferRows.map((o) => o.micro_id)
);
const activeOffers = projectOfferRows.map((o) => {
const newStyleServices = tierServicesMap.get(o.micro_id);
const legacyServices = legacyServicesMap.get(o.micro_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 = [];
}
// L'override dell'admin vince sulla somma calcolata. Lo zero è un valore
// vero — nasconde la riga — quindi si testa la presenza, non la verità.
const offer_value =
o.offer_value_override != null
? parseFloat(String(o.offer_value_override)).toFixed(2)
: computedValues.get(o.micro_id) ?? "0";
return {
id: o.id,
public_name: o.public_name,
offer_name: o.offer_name,
offer_type: o.offer_type,
cumulative_price,
offer_value,
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
status: o.status,
start_date: o.start_date.toISOString(),
end_date: o.end_date ? o.end_date.toISOString() : null,
services: servicesList,
};
});
@@ -473,6 +443,11 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
.where(inArray(comments.entity_id, allEntityIds))
.orderBy(asc(comments.created_at));
const channelReadRows = await db
.select()
.from(client_channel_reads)
.where(eq(client_channel_reads.client_id, project.client_id));
const phasesWithTasks = phasesRows.map((phase) => {
const phaseTasks = tasksRows
.filter((t) => t.phase_id === phase.id)
@@ -481,16 +456,20 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
}));
const doneCount = phaseTasks.filter((t) => t.status === "done").length;
// I task cancellati escono dal denominatore: restano in lista, barrati, ma
// non tengono la fase sotto il 100% per un lavoro che nessuno farà più.
const countedTasks = phaseTasks.filter((t) => countsTowardProgress(t.status));
const doneCount = countedTasks.filter((t) => t.status === "done").length;
const progress_pct =
phaseTasks.length > 0 ? Math.round((doneCount / phaseTasks.length) * 100) : 0;
countedTasks.length > 0 ? Math.round((doneCount / countedTasks.length) * 100) : 0;
return { ...phase, tasks: phaseTasks, progress_pct };
});
const doneTasks = tasksRows.filter((t) => t.status === "done").length;
const countedTasks = tasksRows.filter((t) => countsTowardProgress(t.status));
const doneTasks = countedTasks.filter((t) => t.status === "done").length;
const global_progress_pct =
tasksRows.length > 0 ? Math.round((doneTasks / tasksRows.length) * 100) : 0;
countedTasks.length > 0 ? Math.round((doneTasks / countedTasks.length) * 100) : 0;
return {
project: {
@@ -504,6 +483,9 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
documents: documentsRows,
notes: notesRows,
comments: commentsRows,
channel_reads: Object.fromEntries(
channelReadRows.map((r) => [r.channel_key, r.read_at.toISOString()])
),
global_progress_pct,
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
transcripts: transcriptsRows,
+126 -23
View File
@@ -1,7 +1,11 @@
import { cache } from "react";
import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, comments } from "@/db/schema";
import { eq, inArray, asc } from "drizzle-orm";
import type { Comment } from "@/db/schema";
import { buildChannels, type ChatChannel } from "@/lib/chat-channels";
import { mentionTargets, mentionAliases, type MentionTarget } from "@/lib/mentions";
import { getMentionRecipients } from "@/lib/client-notifications";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -11,9 +15,11 @@ export type ConversationSummary = {
brand_name: string;
token: string;
slug: string | null;
/** Stringa vuota per un cliente con cui non si e' ancora scambiato nulla. */
lastMessage: string;
lastMessageAuthor: "client" | "admin";
lastMessageAt: Date;
lastMessageAuthor: "client" | "admin" | null;
/** null = nessun messaggio: la conversazione esiste ma e' ancora da aprire. */
lastMessageAt: Date | null;
lastEntityLabel: string;
unread: boolean;
unreadCount: number;
@@ -24,8 +30,17 @@ export type ThreadMessage = {
author: "client" | "admin";
body: string;
created_at: Date;
/** Valorizzato solo se il messaggio è stato modificato dopo l'invio. */
edited_at: Date | null;
entityLabel: string;
entityType: string;
/**
* Canale in cui il messaggio va letto: clientId per "Generale", phases.id per
* una fase. Task e deliverable rientrano nella fase proprietaria la regola
* sta in chat-channels.ts, condivisa col portale, perché le due sponde devono
* mettere lo stesso messaggio nello stesso tab.
*/
channelKey: string;
};
export type ConversationThread = {
@@ -35,6 +50,31 @@ export type ConversationThread = {
token: string;
slug: string | null;
messages: ThreadMessage[];
/** Generale + tutte le fasi del cliente (di tutti i suoi progetti). */
channels: ChatChannel[];
/**
* Serve alla vista per capire QUALI canali avevano novità al momento
* dell'apertura: aprire la conversazione la segna letta subito, quindi dopo il
* refresh il dato non sarebbe più ricostruibile.
*/
adminLastReadAt: Date | null;
/**
* **Chi** si può taggare: una voce per destinatario, per il menu del
* compositore. Oggi ne esce sempre una la chat è 1:1.
*/
mentionTargets: MentionTarget[];
/**
* **Come** un tag viene riconosciuto rileggendo il testo: tutti gli alias di
* tutti i destinatari. Serve solo a evidenziare, mai a offrire una scelta
* confondere le due liste è ciò che faceva comparire tre voci per una persona.
*/
mentionAliases: string[];
/**
* Quanti indirizzi riceverebbero la notifica di un tag. Zero significa che il
* tag resta un'evidenziazione e basta: il compositore lo dice prima, invece di
* lasciar credere che sia partita una mail che non partirà.
*/
notifyEmailCount: number;
};
type ClientMeta = {
@@ -46,7 +86,7 @@ type ClientMeta = {
admin_last_read_at: Date | null;
};
type EntityInfo = { clientId: string; label: string; type: string };
type EntityInfo = { clientId: string; label: string; type: string; channelKey: string };
// ── Shared aggregation ──────────────────────────────────────────────────────────
// Builds the entity_id → owning client map by walking
@@ -54,13 +94,18 @@ type EntityInfo = { clientId: string; label: string; type: string };
// under its owning client. Same pattern as admin-queries.getClientFullDetail,
// but across ALL non-archived clients at once (cross-client inbox).
async function buildEntityMap(): Promise<{
// cache(): senza, questa funzione gira quattro volte per render della pagina
// admin — getConversations, getConversationThread, il badge nel layout e
// InboxBand — e ogni giro sono cinque scansioni su tutto il DB.
const buildEntityMap = cache(async function buildEntityMap(): Promise<{
clientMeta: Map<string, ClientMeta>;
entityMap: Map<string, EntityInfo>;
allEntityIds: string[];
phasesByClient: Map<string, { id: string; title: string }[]>;
}> {
const clientMeta = new Map<string, ClientMeta>();
const entityMap = new Map<string, EntityInfo>();
const phasesByClient = new Map<string, { id: string; title: string }[]>();
const clientRows = await db
.select()
@@ -77,12 +122,17 @@ async function buildEntityMap(): Promise<{
admin_last_read_at: c.admin_last_read_at,
});
// A client's own id is the entity_id for "general" messages.
entityMap.set(c.id, { clientId: c.id, label: "Generale", type: "general" });
entityMap.set(c.id, {
clientId: c.id,
label: "Generale",
type: "general",
channelKey: c.id,
});
}
const clientIds = clientRows.map((c) => c.id);
if (clientIds.length === 0) {
return { clientMeta, entityMap, allEntityIds: [] };
return { clientMeta, entityMap, allEntityIds: [], phasesByClient };
}
const projectRows = await db
@@ -96,13 +146,24 @@ async function buildEntityMap(): Promise<{
const phaseRows = await db
.select({ id: phases.id, project_id: phases.project_id, title: phases.title })
.from(phases)
.where(inArray(phases.project_id, projectIds));
.where(inArray(phases.project_id, projectIds))
// I tab dei canali devono uscire nello stesso ordine in cui il cliente
// vede le fasi nella sua timeline.
.orderBy(asc(phases.project_id), asc(phases.sort_order));
const phaseToClient = new Map<string, string>();
for (const ph of phaseRows) {
const clientId = projectToClient.get(ph.project_id);
if (!clientId) continue;
phaseToClient.set(ph.id, clientId);
entityMap.set(ph.id, { clientId, label: `Fase: ${ph.title}`, type: "phase" });
entityMap.set(ph.id, {
clientId,
label: `Fase: ${ph.title}`,
type: "phase",
channelKey: ph.id,
});
const list = phasesByClient.get(clientId) ?? [];
list.push({ id: ph.id, title: ph.title });
phasesByClient.set(clientId, list);
}
const phaseIds = phaseRows.map((p) => p.id);
@@ -116,7 +177,13 @@ async function buildEntityMap(): Promise<{
const clientId = phaseToClient.get(t.phase_id);
if (!clientId) continue;
taskToClient.set(t.id, clientId);
entityMap.set(t.id, { clientId, label: `Task: ${t.title}`, type: "task" });
entityMap.set(t.id, {
clientId,
label: `Task: ${t.title}`,
type: "task",
// Un task non è un canale: i suoi messaggi si leggono nella sua fase.
channelKey: t.phase_id,
});
}
const taskIds = taskRows.map((t) => t.id);
@@ -132,14 +199,21 @@ async function buildEntityMap(): Promise<{
clientId,
label: `Deliverable: ${d.title}`,
type: "deliverable",
// Come per i task: il canale è la fase del task proprietario.
channelKey: entityMap.get(d.task_id)?.channelKey ?? clientId,
});
}
}
}
}
return { clientMeta, entityMap, allEntityIds: [...entityMap.keys()] };
}
return {
clientMeta,
entityMap,
allEntityIds: [...entityMap.keys()],
phasesByClient,
};
});
// Groups comments (already sorted asc) by owning client id.
function groupCommentsByClient(
@@ -168,17 +242,26 @@ async function fetchComments(allEntityIds: string[]): Promise<Comment[]> {
// ── Public API ──────────────────────────────────────────────────────────────────
/** One entry per non-archived client that has at least one comment, newest first. */
/**
* Una voce per OGNI cliente non archiviato, anche senza un solo messaggio.
*
* Prima l'elenco nasceva dai commenti, quindi un cliente compariva solo dopo
* aver scritto per primo: dall'inbox era letteralmente impossibile aprire una
* conversazione. Il centro messaggi è la rubrica dei clienti, non l'archivio di
* chi ha già parlato la lista parte da `clients` e i commenti la arricchiscono.
*
* Ordine: prima chi ha scritto, dal più recente; in coda chi non ha ancora
* niente, in ordine alfabetico. Così l'inbox resta un inbox e i clienti muti
* non spingono giù le conversazioni vive.
*/
export async function getConversations(): Promise<ConversationSummary[]> {
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
const commentRows = await fetchComments(allEntityIds);
const byClient = groupCommentsByClient(commentRows, entityMap);
const summaries: ConversationSummary[] = [];
for (const [clientId, msgs] of byClient) {
const meta = clientMeta.get(clientId);
if (!meta || msgs.length === 0) continue;
for (const meta of clientMeta.values()) {
const msgs = byClient.get(meta.id) ?? [];
const last = msgs[msgs.length - 1];
const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0;
const clientMsgsAfterRead = msgs.filter(
@@ -186,21 +269,28 @@ export async function getConversations(): Promise<ConversationSummary[]> {
);
summaries.push({
clientId,
clientId: meta.id,
name: meta.name,
brand_name: meta.brand_name,
token: meta.token,
slug: meta.slug,
lastMessage: last.body,
lastMessageAuthor: last.author as "client" | "admin",
lastMessageAt: last.created_at,
lastEntityLabel: entityMap.get(last.entity_id)?.label ?? "Generale",
lastMessage: last?.body ?? "",
lastMessageAuthor: (last?.author as "client" | "admin") ?? null,
lastMessageAt: last?.created_at ?? null,
lastEntityLabel: last ? entityMap.get(last.entity_id)?.label ?? "Generale" : "Generale",
unread: clientMsgsAfterRead.length > 0,
unreadCount: clientMsgsAfterRead.length,
});
}
summaries.sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime());
summaries.sort((a, b) => {
if (a.lastMessageAt && b.lastMessageAt) {
return b.lastMessageAt.getTime() - a.lastMessageAt.getTime();
}
if (a.lastMessageAt) return -1;
if (b.lastMessageAt) return 1;
return a.name.localeCompare(b.name, "it");
});
return summaries;
}
@@ -208,7 +298,7 @@ export async function getConversations(): Promise<ConversationSummary[]> {
export async function getConversationThread(
clientId: string
): Promise<ConversationThread | null> {
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
const { clientMeta, entityMap, allEntityIds, phasesByClient } = await buildEntityMap();
const meta = clientMeta.get(clientId);
if (!meta) return null;
@@ -218,6 +308,10 @@ export async function getConversationThread(
);
const commentRows = await fetchComments(ownEntityIds);
// Calcolati una volta: gli alias derivano dai target, e ricavarli due volte
// aprirebbe la porta a un menu e a un'evidenziazione che non concordano.
const targets = mentionTargets(meta);
const messages: ThreadMessage[] = commentRows.map((c) => {
const info = entityMap.get(c.entity_id);
return {
@@ -225,8 +319,12 @@ export async function getConversationThread(
author: c.author as "client" | "admin",
body: c.body,
created_at: c.created_at,
edited_at: c.edited_at,
entityLabel: info?.label ?? "Generale",
entityType: info?.type ?? "general",
// Entità sparita (fase cancellata): il messaggio riemerge in Generale
// invece di restare senza tab e sparire dalla vista senza errore.
channelKey: info?.channelKey ?? clientId,
};
});
@@ -237,6 +335,11 @@ export async function getConversationThread(
token: meta.token,
slug: meta.slug,
messages,
channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []),
adminLastReadAt: meta.admin_last_read_at,
mentionTargets: targets,
mentionAliases: mentionAliases(targets),
notifyEmailCount: (await getMentionRecipients(clientId)).length,
};
}
+4
View File
@@ -9,6 +9,7 @@ import {
offer_macros,
} from "@/db/schema";
import { eq, inArray } from "drizzle-orm";
import { countsTowardProgress } from "@/lib/task-status";
export type DeliveryStatus = "consegnato" | "in_anticipo" | "in_linea" | "in_ritardo";
@@ -131,6 +132,9 @@ export async function getDeliveryBoard(): Promise<DeliveryBoard> {
for (const task of taskRows) {
const projectId = phaseToProject.get(task.phase_id);
if (!projectId) continue;
// Le cancellate non entrano nel totale: il board di consegna misura quanto
// manca, e quello che è stato tolto dal lavoro non manca più.
if (!countsTowardProgress(task.status)) continue;
const tally = taskTally.get(projectId) ?? { done: 0, total: 0 };
tally.total += 1;
if (task.status === "done") tally.done += 1;
+183
View File
@@ -0,0 +1,183 @@
import { db } from "@/db";
import { leads, activities } from "@/db/schema";
import { and, eq, sql } from "drizzle-orm";
import { z } from "zod";
/**
* Ingresso lead dall'esterno: form del sito, TidyCal, o qualunque cosa sappia
* fare un POST.
*
* La normalizzazione sta qui e non nella route perché è la parte che cambia
* quando si aggiunge una sorgente. La route resta la guardia: segreto, rate
* limit, forma della risposta.
*/
/** Forma canonica, dopo la normalizzazione. */
export const leadIntakeSchema = z.object({
name: z.string().trim().min(1, "Nome richiesto").max(100),
email: z.string().trim().email("Email non valida").optional(),
phone: z.string().trim().max(50).optional(),
company: z.string().trim().max(120).optional(),
notes: z.string().trim().max(2000).optional(),
/** Da dove arriva — finisce nelle note, per sapere cosa sta portando lead. */
source: z.string().trim().max(60).optional(),
/** Data della call, quando la sorgente è un calendario. */
meetingAt: z.coerce.date().optional(),
/** Id lato sorgente, per non creare due volte lo stesso lead. */
externalRef: z.string().trim().max(120).optional(),
});
export type LeadIntake = z.infer<typeof leadIntakeSchema>;
// Sinonimi accettati per ciascun campo canonico. Le sorgenti chiamano le stesse
// cose in modi diversi — un form Elementor in italiano manda "nome" e
// "messaggio" — e riscrivere la route per ognuna sarebbe il modo sbagliato.
const ALIASES: Record<keyof LeadIntake, string[]> = {
name: ["name", "nome", "full_name", "fullname", "nome_completo", "your-name"],
email: ["email", "mail", "e-mail", "email_address", "your-email"],
phone: ["phone", "telefono", "tel", "cellulare", "phone_number"],
company: ["company", "azienda", "società", "societa", "business", "brand"],
notes: ["notes", "note", "message", "messaggio", "note_aggiuntive", "comments"],
source: ["source", "sorgente", "form_name", "origine", "utm_source"],
meetingAt: ["meetingat", "meeting_at", "booking_date", "starts_at", "start_time", "data"],
externalRef: ["externalref", "external_ref", "id", "booking_id"],
};
/** Estrae il valore utile da una voce che può essere scalare o {value: …}. */
function unwrap(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === "string" || typeof value === "number") return String(value);
if (typeof value === "object") {
const obj = value as Record<string, unknown>;
// Elementor manda ogni campo come { id, title, value, raw_value }
for (const key of ["value", "raw_value", "text"]) {
if (typeof obj[key] === "string" || typeof obj[key] === "number") {
return String(obj[key]);
}
}
}
return undefined;
}
/**
* Riduce un payload qualunque alla forma canonica.
*
* Regge tre casi senza saperli distinguere: piatto (`{name, email}`), annidato
* in `fields` (Elementor Pro), e con i campi come oggetti `{value: …}`.
*
* NOTA: la forma esatta del payload di Elementor Pro va confermata con un invio
* vero prima di considerarla verificata. Qui è gestita in modo difensivo, non
* su documentazione letta.
*/
export function normalizeLeadPayload(body: unknown): Record<string, unknown> {
if (typeof body !== "object" || body === null) return {};
const raw = body as Record<string, unknown>;
// Appiattisce: prima il livello alto, poi `fields`/`data` che vincono perché
// più specifici.
const flat: Record<string, unknown> = {};
const merge = (source: unknown) => {
if (typeof source !== "object" || source === null) return;
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
const unwrapped = unwrap(value);
if (unwrapped !== undefined && unwrapped !== "") {
flat[key.toLowerCase().trim()] = unwrapped;
}
}
};
merge(raw);
merge(raw.fields);
merge(raw.data);
const out: Record<string, unknown> = {};
for (const [canonical, aliases] of Object.entries(ALIASES)) {
for (const alias of aliases) {
if (flat[alias] !== undefined) {
out[canonical] = flat[alias];
break;
}
}
}
return out;
}
export type IngestResult =
| { outcome: "created"; leadId: string }
| { outcome: "updated"; leadId: string };
/**
* Crea il lead, oppure aggiorna quello che c'è già.
*
* Chi compila due volte il form non deve diventare due lead: la pipeline si
* riempirebbe di doppioni proprio per i contatti più interessati. Il secondo
* invio aggiorna `last_contact_date` e lascia un'attività con quello che ha
* scritto, così il messaggio non si perde ma la scheda resta una.
*
* Il riconoscimento è sull'email. Senza email non si può dedurre, e si crea.
*/
export async function ingestLead(input: LeadIntake): Promise<IngestResult> {
const now = new Date();
const contextLines = [
input.source ? `Origine: ${input.source}` : null,
input.meetingAt ? `Call fissata: ${input.meetingAt.toLocaleString("it-IT")}` : null,
input.notes ?? null,
].filter(Boolean);
const body = contextLines.join("\n");
if (input.email) {
const existing = await db
.select({ id: leads.id })
.from(leads)
.where(and(eq(leads.archived, false), sql`lower(${leads.email}) = lower(${input.email})`))
.limit(1);
if (existing[0]) {
const leadId = existing[0].id;
await db
.update(leads)
.set({
last_contact_date: now,
updated_at: now,
...(input.meetingAt ? { next_action_date: input.meetingAt } : {}),
...(input.phone ? { phone: input.phone } : {}),
...(input.company ? { company: input.company } : {}),
})
.where(eq(leads.id, leadId));
await db.insert(activities).values({
lead_id: leadId,
type: input.meetingAt ? "meeting" : "note",
activity_date: input.meetingAt ?? now,
notes: body || "Nuovo invio dal form, senza messaggio.",
});
return { outcome: "updated", leadId };
}
}
const [lead] = await db
.insert(leads)
.values({
name: input.name,
email: input.email ?? null,
phone: input.phone ?? null,
company: input.company ?? null,
status: "contacted",
notes: body || null,
last_contact_date: now,
next_action: input.meetingAt ? "Call fissata" : null,
next_action_date: input.meetingAt ?? null,
})
.returning({ id: leads.id });
if (input.meetingAt) {
await db.insert(activities).values({
lead_id: lead.id,
type: "meeting",
activity_date: input.meetingAt,
notes: body || "Call prenotata.",
});
}
return { outcome: "created", leadId: lead.id };
}
+61
View File
@@ -73,6 +73,67 @@ export function otpEmailTemplate(code: string, brandName: string): { subject: st
};
}
/**
* Notifica «ti hanno taggato in chat».
*
* Il cliente apre il portale quando gli pare: senza questa mail un messaggio
* urgente resta finché non gli viene in mente di guardare. Il testo dice una
* cosa sola c'è un messaggio per te, qui e ci porta con un pulsante.
*
* L'estratto è volutamente corto: serve a far capire di cosa si parla, non a
* sostituire la chat. La conversazione si legge nel portale, dove si risponde.
*/
export function mentionEmailTemplate({
clientName,
adminName,
channelLabel,
body,
url,
}: {
clientName: string;
adminName: string;
channelLabel: string;
body: string;
/** null se la base pubblica non è configurata: la mail parte comunque, senza pulsante. */
url: string | null;
}): { subject: string; html: string } {
const firstName = clientName.trim().split(/\s+/)[0] || clientName;
const excerpt = truncate(body, 280);
const button = url
? `<p style="margin:0 0 24px;"><a href="${escapeHtml(url)}" style="display:inline-block;background:#1a1a1a;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;padding:14px 28px;border-radius:8px;">Apri la conversazione</a></p>`
: "";
return {
subject: `${adminName} ti ha taggato in chat`,
html: `<!doctype html>
<html lang="it">
<body style="margin:0;padding:32px 16px;background:#f6f6f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#1a1a1a;">
<table role="presentation" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;background:#ffffff;border-radius:12px;padding:32px;">
<tr><td>
<p style="margin:0 0 8px;font-size:14px;color:#71717a;">${escapeHtml(channelLabel)}</p>
<h1 style="margin:0 0 24px;font-size:20px;font-weight:600;">${escapeHtml(firstName)}, ${escapeHtml(adminName)} ti ha taggato in chat</h1>
<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;margin:0 0 24px;background:#f6f6f4;border-radius:8px;">
<tr><td style="padding:16px 18px;font-size:15px;line-height:1.6;color:#1a1a1a;">${escapeHtml(excerpt).replace(/\n/g, "<br>")}</td></tr>
</table>
${button}
<p style="margin:0;font-size:14px;color:#71717a;line-height:1.6;">Rispondi dal portale: la conversazione resta , insieme al progetto.</p>
</td></tr>
</table>
</body>
</html>`,
};
}
/** Taglia sull'ultimo spazio prima del limite, per non spezzare una parola a metà. */
function truncate(text: string, max: number): string {
const clean = text.trim();
if (clean.length <= max) return clean;
const cut = clean.slice(0, max);
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}`;
}
/** Il brand name arriva dal DB ed è admin-controlled, ma finisce in HTML: si sanifica comunque. */
function escapeHtml(s: string): string {
return s
+206
View File
@@ -0,0 +1,206 @@
/**
* Menzioni nella chat «@Nome» dentro il corpo di un messaggio.
*
* Il modello è deliberatamente senza schema: nessuna tabella `mentions`, nessun
* marcatore nel testo. Il body resta esattamente quello che l'admin ha scritto,
* e la menzione si riconosce a posteriori confrontando il testo con i nomi noti
* del cliente di quella conversazione.
*
* Perché così e non con un token strutturato tipo `@[Nome](client:id)`:
* 1. il testo resta leggibile ovunque finisca email di notifica, anteprima
* nella lista, log senza bisogno di un renderer che lo traduca;
* 2. sopravvive alla modifica di un messaggio, che qui riscrive il body e non
* saprebbe rigenerare un id nascosto;
* 3. non introduce una migration per una funzione che riguarda una sola entità
* (la chat è 1:1, admin cliente: non esiste "chi" da disambiguare).
*
* Il prezzo: se il cliente cambia nome, le vecchie menzioni smettono di essere
* evidenziate. È un difetto cosmetico e si auto-ripara scrivendo il nome nuovo.
*
* Funzioni pure: nessun accesso al DB, usabili sia sul server sia nel browser.
*/
/** Diacritici Unicode combinanti — via da qui, così «Nicolò» matcha «nicolo». */
const COMBINING = /[\u0300-\u036f]/g;
/**
* Una menzione è delimitata da parola su entrambi i lati:
* - a destra, «@Mario» non deve matchare dentro «@Mariotti»;
* - a sinistra, la chiocciola deve aprire una parola. Senza questo controllo
* «scrivimi a mario@teckell.it» conterrebbe un tag «@Teckell» e farebbe
* partire una mail di notifica che nessuno ha chiesto.
*/
const WORD_CHAR = /[\p{L}\p{N}_]/u;
function foldChar(ch: string): string {
const folded = ch.normalize("NFD").replace(COMBINING, "").toLowerCase();
// Un carattere può decomporsi in più code point: si tiene il primo, così la
// stringa ripiegata resta allineata all'originale posizione per posizione —
// che è quello che permette di ritagliare il testo originale dagli indici.
return folded.length > 0 ? folded[0] : ch.toLowerCase();
}
/**
* La stessa normalizzazione che usa il parser, esposta per chi deve confrontare
* testo digitato con un nome oggi il filtro del menu nel compositore.
*
* Va condivisa, non riscritta: con un `toLowerCase()` fatto in casa, digitare
* «@nicolo» non troverebbe «Nicolò» nel menu mentre il parser lo riconoscerebbe
* benissimo nel messaggio. Due regole diverse per la stessa domanda, e la
* seconda si scopre solo quando la mail non parte.
*/
export function normalizeForSearch(value: string): string {
return Array.from(value).map(foldChar).join("");
}
/**
* Chi si può taggare, e in quanti modi lo si può scrivere.
*
* La distinzione è il punto di tutto il modulo, ed è quella che il menu del
* compositore aveva sbagliato mostrando tre voci per una persona sola:
*
* - `label` = **chi**. Una riga per destinatario, ed è il testo che finisce nel
* messaggio scegliendolo dal menu.
* - `aliases` = **come**. Tutti i modi in cui quel destinatario viene
* riconosciuto rileggendo il testo, perché «@Gian» scritto a mano deve valere
* quanto «@Gian Luca Caruso» scelto dall'elenco.
*
* Tenendole separate, il giorno in cui i contatti per cliente saranno tre il
* menu avrà tre righe e non nove.
*/
export type MentionTarget = {
/** Il nome che si scrive scegliendolo dal menu. Uno per destinatario. */
label: string;
/** Ogni forma accettata in lettura, dalla più lunga alla più corta. */
aliases: string[];
};
/**
* I destinatari taggabili in questa conversazione.
*
* Oggi ne esce sempre uno: la chat è 1:1 e `clients` conosce una persona sola.
* La forma è già al plurale perché il giorno in cui i contatti diventano righe
* a (oggi `client_emails` ha gli indirizzi ma non i nomi) qui cambia solo la
* query che li carica, non il menu il parser.
*/
export function mentionTargets(client: {
name: string;
brand_name?: string | null;
}): MentionTarget[] {
const label = client.name.trim();
if (!label) return [];
return [
{
label,
// Il brand è un alias della persona, non un secondo destinatario: taggare
// «@Caruso Speaker» scrive comunque a chi legge quella conversazione.
aliases: dedupe([label, client.brand_name ?? "", label.split(/\s+/)[0] ?? ""]),
},
];
}
/**
* Tutti gli alias di tutti i destinatari, dal più lungo al più corto: è la lista
* che serve al parser. L'ordine conta «@Mario Rossi» deve vincere su «@Mario»,
* altrimenti il chip si fermerebbe a metà del nome.
*/
export function mentionAliases(targets: ReadonlyArray<MentionTarget>): string[] {
return dedupe(targets.flatMap((t) => t.aliases)).sort((a, b) => b.length - a.length);
}
/** Scorciatoia per chi deve solo rileggere del testo, senza offrire un menu. */
export function mentionCandidates(client: {
name: string;
brand_name?: string | null;
}): string[] {
return mentionAliases(mentionTargets(client));
}
function dedupe(values: ReadonlyArray<string>): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const value of values) {
const trimmed = value.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(trimmed);
}
return out;
}
export type MentionPart =
| { type: "text"; value: string }
| { type: "mention"; value: string };
/**
* Spezza il corpo del messaggio in testo semplice e menzioni, pronte per essere
* rese con uno stile diverso. Il valore di una menzione include la chiocciola:
* chi lo disegna non deve rimetterla e non deve sapere come è stata trovata.
*/
export function splitMentions(body: string, candidates: string[]): MentionPart[] {
if (!body) return [];
if (candidates.length === 0) return [{ type: "text", value: body }];
// Si lavora su array di code point, non sulla stringa: gli indici restano
// validi anche con emoji o caratteri fuori dal piano base.
const chars = Array.from(body);
const folded = chars.map(foldChar);
const foldedCandidates = candidates.map((c) => Array.from(c).map(foldChar));
const parts: MentionPart[] = [];
let plainFrom = 0;
let i = 0;
while (i < chars.length) {
if (chars[i] !== "@") {
i++;
continue;
}
const before = i > 0 ? chars[i - 1] : undefined;
if (before !== undefined && WORD_CHAR.test(before)) {
i++;
continue;
}
const matched = foldedCandidates.find((cand) =>
matchesAt(folded, i + 1, cand)
);
if (!matched) {
i++;
continue;
}
const end = i + 1 + matched.length;
if (plainFrom < i) {
parts.push({ type: "text", value: chars.slice(plainFrom, i).join("") });
}
parts.push({ type: "mention", value: chars.slice(i, end).join("") });
plainFrom = end;
i = end;
}
if (plainFrom < chars.length) {
parts.push({ type: "text", value: chars.slice(plainFrom).join("") });
}
return parts;
}
function matchesAt(
folded: string[],
start: number,
candidate: string[]
): boolean {
if (start + candidate.length > folded.length) return false;
for (let k = 0; k < candidate.length; k++) {
if (folded[start + k] !== candidate[k]) return false;
}
const after = folded[start + candidate.length];
return after === undefined || !WORD_CHAR.test(after);
}
/** Questo messaggio tagga il cliente? È il solo interruttore della notifica. */
export function hasMention(body: string, candidates: string[]): boolean {
return splitMentions(body, candidates).some((p) => p.type === "mention");
}
+78
View File
@@ -0,0 +1,78 @@
import { inArray, eq } from "drizzle-orm";
import { db } from "@/db";
import {
offer_tier_services,
services,
offer_micro_services,
offer_services,
} from "@/db/schema";
/**
* "Valore dell'offerta" calcolato: la somma dei prezzi dei servizi collegati a
* un tier (micro).
*
* Due sorgenti, nell'ordine: `offer_tier_services` `services` (catalogo
* Phase 12) e, per i tier che non hanno righe , il legacy
* `offer_micro_services` `offer_services`.
*
* Vive in un modulo neutro perché lo leggono **due** lati: il portale cliente
* (`client-view.ts`, dove è il fallback quando l'admin non ha messo un
* override) e l'area admin (`admin-queries.ts`, dove serve a mostrare quale
* cifra si sta sostituendo). Duplicarlo significherebbe farli divergere.
*
* Legge solo prezzi: nomi e descrizioni dei servizi non servono a nessuno dei
* due chiamanti, e client-view non deve proiettare più di quanto renderizza.
*
* @returns Map micro_id totale con 2 decimali. I micro senza servizi non
* compaiono nella mappa (il chiamante decide se è "0" o "assente").
*/
export async function getComputedOfferValues(
microIds: string[]
): Promise<Map<string, string>> {
const out = new Map<string, string>();
if (microIds.length === 0) return out;
const sum = (prices: Array<string | null>) =>
prices.reduce((acc, p) => acc + (parseFloat(p ?? "0") || 0), 0).toFixed(2);
// Sorgente primaria: catalogo unificato.
const tierRows = await db
.select({
tier_id: offer_tier_services.tier_id,
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));
const byTier = new Map<string, Array<string | null>>();
for (const row of tierRows) {
const existing = byTier.get(row.tier_id) ?? [];
existing.push(row.unit_price);
byTier.set(row.tier_id, existing);
}
for (const [tierId, prices] of byTier) out.set(tierId, sum(prices));
// Fallback legacy solo per i micro rimasti scoperti.
const needLegacy = microIds.filter((id) => !out.has(id));
if (needLegacy.length === 0) return out;
const legacyRows = await db
.select({
micro_id: offer_micro_services.micro_id,
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, needLegacy));
const byMicro = new Map<string, Array<string | null>>();
for (const row of legacyRows) {
const existing = byMicro.get(row.micro_id) ?? [];
existing.push(row.price);
byMicro.set(row.micro_id, existing);
}
for (const [microId, prices] of byMicro) out.set(microId, sum(prices));
return out;
}
+100
View File
@@ -0,0 +1,100 @@
// ── Date dei pagamenti, lette come le legge il cliente ───────────────────────
// Un pagamento ha due date: quella attesa (`due_date`) e quella incassata
// (`paid_at`). Entrambe sono timestamptz, ma al cliente interessa il giorno sul
// calendario, non l'istante: "manca una settimana" non deve cambiare risposta
// perché il container gira a UTC e chi legge sta a Roma.
//
// Da qui passeranno anche i promemoria email ("la scadenza si avvicina"): il
// conteggio dei giorni che decide se mandare la mail deve essere lo stesso
// numero che il cliente vede nel portale, altrimenti la mail arriva a dire
// "mancano 3 giorni" mentre il portale ne mostra 2.
/** Il fuso in cui il cliente guarda il calendario. */
const TZ = "Europe/Rome";
const dayKeyFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const longDateFormatter = new Intl.DateTimeFormat("it-IT", {
timeZone: TZ,
day: "numeric",
month: "long",
year: "numeric",
});
const shortDateFormatter = new Intl.DateTimeFormat("it-IT", {
timeZone: TZ,
day: "numeric",
month: "short",
year: "numeric",
});
function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
/** "2026-03-15" — il giorno civile a Roma, qualunque sia il fuso del server. */
function dayKey(value: Date | string): string {
return dayKeyFormatter.format(toDate(value));
}
/**
* Giorni interi che separano oggi dalla scadenza: 0 = oggi, 3 = fra tre giorni,
* -2 = scaduta da due giorni.
*
* Si confrontano i giorni civili, non gli istanti: una scadenza fissata a
* mezzogiorno non deve leggersi "fra 0 giorni" solo perché mancano 20 ore.
*/
export function daysUntil(due: Date | string, now: Date = new Date()): number {
const a = Date.parse(`${dayKey(now)}T00:00:00Z`);
const b = Date.parse(`${dayKey(due)}T00:00:00Z`);
return Math.round((b - a) / 86_400_000);
}
/** "15 marzo 2026" */
export function formatLongDate(value: Date | string): string {
return longDateFormatter.format(toDate(value));
}
/** "15 mar 2026" — per le righe strette della sidebar. */
export function formatShortDate(value: Date | string): string {
return shortDateFormatter.format(toDate(value));
}
/**
* Il conto alla rovescia in parole. Il ritardo si dice, non si nasconde: una
* rata scaduta che continua a mostrare la sua data senza commento sembra a posto.
*/
export function formatCountdown(days: number): string {
if (days === 0) return "oggi";
if (days === 1) return "domani";
if (days === -1) return "scaduto da 1 giorno";
if (days > 1) return `tra ${days} giorni`;
return `scaduto da ${Math.abs(days)} giorni`;
}
/** Una rata è ancora aperta finché non è saldata — "inviata" non è incassata. */
export function isOutstanding(status: string): boolean {
return status !== "saldato";
}
type Schedulable = { status: string; due_date: Date | string | null };
/**
* La prossima rata da pagare: la più vicina fra quelle aperte che hanno una data.
*
* Le scadute restano candidate e vincono sulle future se c'è un arretrato è
* quello "il prossimo pagamento", non la rata del mese prossimo.
*/
export function findNextDue<T extends Schedulable>(payments: T[]): T | null {
const withDate = payments.filter((p) => isOutstanding(p.status) && p.due_date !== null);
if (withDate.length === 0) return null;
return withDate.reduce((earliest, p) =>
toDate(p.due_date!).getTime() < toDate(earliest.due_date!).getTime() ? p : earliest
);
}
+24
View File
@@ -5,8 +5,32 @@ import { revalidatePath } from "next/cache";
export const SETTINGS_KEYS = {
TARGET_HOURLY_RATE: "target_hourly_rate",
ADMIN_DISPLAY_NAME: "admin_display_name",
ADMIN_AVATAR_URL: "admin_avatar_url",
} as const;
/**
* Il nome con cui il consulente si firma nella chat del portale, non il marchio.
* Il default è "iamcavalli" perché è il comportamento che c'era prima: un DB
* senza queste righe deve continuare a funzionare come oggi, non mostrare vuoto.
*/
export const ADMIN_NAME_FALLBACK = "iamcavalli";
export async function getAdminIdentity(): Promise<{
name: string;
avatarUrl: string | null;
}> {
const [name, avatarUrl] = await Promise.all([
getSetting(SETTINGS_KEYS.ADMIN_DISPLAY_NAME),
getSetting(SETTINGS_KEYS.ADMIN_AVATAR_URL),
]);
return {
name: name?.trim() || ADMIN_NAME_FALLBACK,
// Stringa vuota = campo svuotato dal pannello: vale quanto "mai impostata".
avatarUrl: avatarUrl?.trim() || null,
};
}
export async function getSetting(key: string): Promise<string | null> {
const rows = await db
.select({ value: settings.value })
+40
View File
@@ -0,0 +1,40 @@
// ── Task statuses, in one place ───────────────────────────────────────────────
// `tasks.status` is a plain text column with no CHECK constraint, and until now
// the three values were retyped by hand in eight files — as a runtime allow-list,
// as a TS union, as kanban columns, as <select> options. Two of those copies
// dropped unknown values silently. Everything derives from here instead.
//
// No db import on purpose: client components import this file.
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done", "cancelled"] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
todo: "Da fare",
in_progress: "In corso",
in_review: "In revisione",
done: "Fatto",
cancelled: "Cancellata",
};
// Un task cancellato è chiuso, non fatto: resta visibile (barrato) perché il
// cliente ha il diritto di sapere che quella voce esisteva ed è stata tolta, ma
// esce dai conteggi. Contarlo nel denominatore bloccherebbe la fase sotto il
// 100% per sempre; contarlo come fatto racconterebbe una consegna mai avvenuta.
// Vive qui e non come `!== "cancelled"` sparso nei file: è la stessa disciplina
// che ha fatto nascere questo modulo.
export function countsTowardProgress(status: string): boolean {
return status !== "cancelled";
}
export function isTaskStatus(value: string): value is TaskStatus {
return (TASK_STATUSES as readonly string[]).includes(value);
}
// Guards the boundary where a raw DB string enters a typed union. Previously an
// `as` cast, which TypeScript does not check: a row holding an unknown status
// slipped through and then vanished from whichever board failed to match it.
export function normalizeTaskStatus(value: string): TaskStatus {
return isTaskStatus(value) ? value : "todo";
}
+28 -1
View File
@@ -1,5 +1,5 @@
import { db } from "@/db";
import { tags, offer_macros, services } from "@/db/schema";
import { tags, offer_macros, services, phases } from "@/db/schema";
import { eq, sql } from "drizzle-orm";
import { getSetting, writeSetting } from "@/lib/settings";
@@ -195,5 +195,32 @@ export async function renamePoolValue(
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));
await renameProjectPhasesByTitle(oldValue, next);
}
}
// `importOfferIntoProject` materializes project phases by COPYING the service's
// `fase` into `phases.title`, and later re-imports match an existing phase by that
// title alone (there is no FK: `phases.offer_phase_id` is never populated). So a
// fase rename that stops at the catalog leaves every project phase stranded under
// the old name, and the next re-import creates a duplicate phase beside it.
// The match mirrors the import's merge key exactly (trim + lowercase) — an exact
// match would miss the casing variants the import already treats as one phase.
async function renameProjectPhasesByTitle(oldValue: string, next: string): Promise<void> {
await db
.update(phases)
.set({ title: next })
.where(sql`lower(trim(${phases.title})) = ${oldValue.trim().toLowerCase()}`);
}
// How many project phases a `service_fase` rename would rewrite — powers the
// confirmation dialog in the settings panel. Read-only.
export async function countProjectPhasesByTitle(
value: string
): Promise<{ phases: number; projects: number }> {
const rows = await db
.select({ project_id: phases.project_id })
.from(phases)
.where(sql`lower(trim(${phases.title})) = ${value.trim().toLowerCase()}`);
return { phases: rows.length, projects: new Set(rows.map((r) => r.project_id)).size };
}