817a8cd5d1
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>
320 lines
15 KiB
Markdown
320 lines
15 KiB
Markdown
# 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.
|