Commit Graph

119 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 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 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 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 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 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 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 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 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 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 d6d3be00f4 feat(dashboard): timeline delle consegne con semaforo ritardo/anticipo
Quali progetti vanno consegnati, a che punto sono e se il ritmo regge.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Due scelte che vale la pena spiegare:

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

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

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

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

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

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

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

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

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

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

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

Build e lint puliti.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:08:28 +02:00