# 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: `` 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 `` 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**: `` 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.