Compare commits
21 Commits
4e3907d382
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ce6f02ee4 | |||
| 614eb00040 | |||
| d71fede4e4 | |||
| 41530b556a | |||
| 817a8cd5d1 | |||
| bec7e039d7 | |||
| 4403b39e63 | |||
| fe767899b9 | |||
| 15b01e3e05 | |||
| 8b54f48afd | |||
| 2e9bd2ab60 | |||
| c3d2afa61f | |||
| 94f4a54248 | |||
| 0d7186ba03 | |||
| ac74a81a72 | |||
| 53f1758f52 | |||
| d14f95c80d | |||
| 44be190631 | |||
| 7e58031528 | |||
| 4666fc4058 | |||
| c9855d58ce |
@@ -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).
|
||||
Executable
+61
@@ -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
|
||||
Executable
+59
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 MB–1 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.
|
||||
@@ -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.
|
||||
Executable
+49
@@ -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"
|
||||
@@ -16,6 +16,11 @@ INTERNAL_SECRET=generate-with-openssl-rand-base64-32
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,6 +85,9 @@ Fuori dalla milestone v2.5, che è in pausa. Piano in
|
||||
- [ ] **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)
|
||||
|
||||
@@ -105,6 +108,14 @@ Fuori dalla milestone v2.5, che è in pausa. Piano in
|
||||
|
||||
## 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>` →
|
||||
|
||||
+21
-19
@@ -3,9 +3,9 @@ gsd_state_version: 1.0
|
||||
milestone: v2.5
|
||||
milestone_name: Audit
|
||||
status: executing
|
||||
stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1 e le due rifiniture del 2026-08-20 in prod; C2 (TidyCal) bloccato sulle credenziali API."
|
||||
last_updated: "2026-08-20T22:45:00.000Z"
|
||||
last_activity: 2026-08-20 -- tab pagamenti (ordine, rinomina, override) e riordino task (1fa8e1a)
|
||||
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
|
||||
@@ -37,17 +37,18 @@ Phase 27 resta a metà — schema e fonti in prod, resto da scrivere.
|
||||
| C1 — `POST /api/webhooks/lead` | ✅ in produzione, provato contro il DB vero |
|
||||
| C2 — TidyCal | ⛔ **[BLOCCANTE]** vedi sotto |
|
||||
| C3 — Alleggerire l'hub | ⏸️ senza perimetro |
|
||||
| Rifiniture — rinomina tassonomie, stato task "In revisione" | ✅ in prod 2026-08-20 |
|
||||
| Tab pagamenti (ordine stabile, rinomina, override) + riordino task | ✅ in prod 2026-08-20, migration `0019` |
|
||||
| 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
|
||||
|
||||
**Fuori dal repo, e senza questi niente è ricostruibile:** i piani in `~/.claude/plans/`
|
||||
— `…woolly-puddle.md` (documento audit), `…radiant-valley.md` (motore),
|
||||
`sei-arrivato-qua-search-recursive-kettle.md` (modifiche hub).
|
||||
I piani della milestone sono **nel repo dal 2026-08-26**: `.claude/plans/v2.5-*.md`.
|
||||
|
||||
| Cosa (audit) | Dove | Stato |
|
||||
|---|---|---|
|
||||
@@ -61,11 +62,7 @@ Progress: [███░░░░░░░] 25% (v2.5)
|
||||
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 `…radiant-valley.md`.
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:** 21 plans (v2.1–v2.4). Phase 27: spike ~1h, schema ~1h, fonti ~2h. Modifiche hub: A+B+C1 in una sessione.
|
||||
Passo per passo in `STATUS.md` e in `.claude/plans/v2.5-audit-motore.md`.
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -73,6 +70,10 @@ Passo per passo in `STATUS.md` e in `…radiant-valley.md`.
|
||||
|
||||
Log completo in `PROJECT.md`. Vive per il lavoro corrente:
|
||||
|
||||
- **[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.
|
||||
@@ -85,17 +86,18 @@ Log completo in `PROJECT.md`. Vive per il lavoro corrente:
|
||||
- **[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 fisso del template v1 non ha una fonte nel repo** — il prototipo Giojello non c'è: testi e gerarchia dei blocchi vanno recuperati prima di Phase 30.
|
||||
- **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` punta al DB di PRODUZIONE** (non allineato a Coolify per `ADMIN_PASSWORD`/`NEXTAUTH_SECRET`), e la porta 54321 non è pubblica: per provare in locale serve il tunnel SSH.
|
||||
- **Ogni fase con schema**: migration applicata a prod **prima** del push del codice.
|
||||
- **`.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 da v2.5.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-08-19
|
||||
Stopped at: modifiche hub, blocchi A + B + C1 in produzione (commit `19ed377`, migration `0018` applicata prima del push). C2 fermo sulle credenziali TidyCal.
|
||||
Next: (1) sbloccare TidyCal con token o documentazione; (2) confermare il payload Elementor con un invio vero; (3) impostare `LEAD_WEBHOOK_SECRET` su Coolify — finché manca la route risponde 403 a tutti; (4) poi riprendere v2.5 da `src/lib/audit/schema.ts` + `agents/`.
|
||||
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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ClientHub (IAMCAVALLI) — Status
|
||||
|
||||
_Ultimo aggiornamento: 2026-08-20_
|
||||
_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
|
||||
@@ -30,7 +30,7 @@ schema dell'audit, nessuna pagina. La pausa è una scelta del 2026-08-19 — pri
|
||||
### Modifiche hub (richieste 2026-08-18)
|
||||
|
||||
Tre aree: dashboard, progetti, pipeline. Piano in
|
||||
`~/.claude/plans/sei-arrivato-qua-search-recursive-kettle.md`, **fuori dal repo**.
|
||||
[`.claude/plans/v2.5-modifiche-hub.md`](.claude/plans/v2.5-modifiche-hub.md).
|
||||
|
||||
**In produzione dal 2026-08-19** (commit `4b135ce` → `19ed377`):
|
||||
|
||||
@@ -94,6 +94,45 @@ interazioni: la matita di rinomina con il dialog di conferma, e il drag di un ta
|
||||
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
|
||||
@@ -106,9 +145,316 @@ Cosa manca, e perché:
|
||||
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. Non è una regressione introdotta
|
||||
ora — era già una scelta di prodotto — ma da oggi è l'unica via.
|
||||
✅ ~~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 27–30), in pausa
|
||||
|
||||
@@ -130,9 +476,11 @@ 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
|
||||
|
||||
@@ -210,9 +558,16 @@ 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.**
|
||||
@@ -246,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
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "@/db/schema";
|
||||
import { eq, asc, and, isNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { isTaskStatus } from "@/lib/task-status";
|
||||
import { countsTowardProgress, isTaskStatus } from "@/lib/task-status";
|
||||
|
||||
// ── ENTITY RESOLUTION ────────────────────────────────────────────────────────
|
||||
// Both clientId and projectId are passed as "clientId" by tab components.
|
||||
@@ -187,16 +187,24 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
|
||||
// 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 !== "todo");
|
||||
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";
|
||||
}
|
||||
@@ -326,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" })
|
||||
@@ -345,6 +378,17 @@ 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.
|
||||
//
|
||||
// The rule is PER ROW, not per project. The previous version decided with
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -311,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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +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;
|
||||
|
||||
@@ -58,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,
|
||||
@@ -96,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
|
||||
@@ -137,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);
|
||||
@@ -147,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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -193,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -106,7 +106,13 @@ export function SortableTaskList({
|
||||
|
||||
// One task can't be reordered — skip the DnD machinery entirely.
|
||||
if (items.length < 2) {
|
||||
return <div className="space-y-2">{items.map((i) => i.node)}</div>;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((i) => (
|
||||
<div key={i.id}>{i.node}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -38,6 +38,7 @@ const COLUMN_STYLES: Record<Status, { headerClass: string; dotClass: string }> =
|
||||
dotClass: "bg-violet-500",
|
||||
},
|
||||
done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" },
|
||||
cancelled: { headerClass: "text-[#a1a1aa]", dotClass: "bg-[#a1a1aa]" },
|
||||
};
|
||||
|
||||
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] =
|
||||
@@ -92,6 +93,7 @@ function DroppableColumn({
|
||||
key={task.id}
|
||||
task={task}
|
||||
isActive={activeId === task.id}
|
||||
status={id}
|
||||
/>
|
||||
))}
|
||||
{tasks.length === 0 && (
|
||||
@@ -107,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 });
|
||||
@@ -131,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}
|
||||
@@ -211,7 +221,7 @@ export function KanbanBoard({
|
||||
onDragStart={(e) => setActiveId(e.active.id as string)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-5">
|
||||
{COLUMNS.map((col) => (
|
||||
<DroppableColumn
|
||||
key={col.id}
|
||||
|
||||
@@ -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'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. */}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
setPaymentPaidAt,
|
||||
updatePaymentField,
|
||||
clearPaymentOverride,
|
||||
setPaymentDueDate,
|
||||
} from "@/app/admin/clients/[id]/actions";
|
||||
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,14 +37,19 @@ function formatEuro(value: number): string {
|
||||
return value.toLocaleString("it-IT", { minimumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
// 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")}`;
|
||||
// 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";
|
||||
@@ -118,16 +124,19 @@ export function PaymentsTab({
|
||||
async function handleSetPlan(mode: PlanMode) {
|
||||
if (!projectId) return;
|
||||
|
||||
// Picking a plan deletes every existing row — including status and paid_at.
|
||||
// Only worth interrupting when there is actually payment history to lose.
|
||||
// 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) => p.status === "saldato" || p.status === "inviata" || p.due_date !== null
|
||||
).length;
|
||||
if (tracked > 0) {
|
||||
const what =
|
||||
tracked === 1 ? "1 rata già segnata" : `${tracked} rate già segnate`;
|
||||
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} come inviate o saldate: lo stato e il mese di incasso andranno persi.\n\nProcedere?`
|
||||
`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;
|
||||
}
|
||||
@@ -155,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);
|
||||
@@ -339,17 +359,36 @@ export function PaymentsTab({
|
||||
</select>
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+814
-203
File diff suppressed because it is too large
Load Diff
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
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";
|
||||
import { TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
|
||||
|
||||
type Phase = ClientView["phases"][number];
|
||||
|
||||
@@ -29,36 +34,6 @@ const phaseBarColor: Record<"upcoming" | "active" | "done", string> = {
|
||||
done: "bg-emerald-600",
|
||||
};
|
||||
|
||||
function TaskStatusIcon({ status }: { status: TaskStatus }) {
|
||||
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_review") {
|
||||
return (
|
||||
<span
|
||||
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-violet-400 bg-card dark:border-violet-500"
|
||||
title={TASK_STATUS_LABELS.in_review}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-violet-500 dark:bg-violet-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,
|
||||
@@ -71,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">
|
||||
@@ -118,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
|
||||
@@ -139,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'attività è
|
||||
stata tolta dal lavoro, d'accordo con te: resta scritta ma non verrà realizzata, e
|
||||
non pesa sull'avanzamento.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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] & {
|
||||
@@ -15,11 +16,15 @@ const COLUMNS: { id: TaskStatus; label: string }[] = TASK_STATUSES.map((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 && (
|
||||
@@ -57,26 +62,48 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
|
||||
{} 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 gap-6 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{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,6 +41,9 @@ 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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
+55
-1
@@ -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,9 @@ 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),
|
||||
@@ -461,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(),
|
||||
});
|
||||
|
||||
@@ -997,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
|
||||
}));
|
||||
@@ -1147,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;
|
||||
|
||||
@@ -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,
|
||||
@@ -245,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;
|
||||
};
|
||||
|
||||
@@ -715,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)
|
||||
@@ -789,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
|
||||
@@ -814,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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 sé: queste funzioni non lo peggiorano né 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),
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+56
-76
@@ -1,7 +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 type { TaskStatus } from "@/lib/task-status";
|
||||
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.
|
||||
@@ -41,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;
|
||||
@@ -58,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;
|
||||
@@ -108,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;
|
||||
@@ -127,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;
|
||||
@@ -316,6 +332,10 @@ 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)
|
||||
@@ -353,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)
|
||||
@@ -365,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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -475,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)
|
||||
@@ -483,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: {
|
||||
@@ -506,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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 lì 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 lì, 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
|
||||
|
||||
@@ -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 sé (oggi `client_emails` ha gli indirizzi ma non i nomi) qui cambia solo la
|
||||
* query che li carica, non il menu né 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");
|
||||
}
|
||||
@@ -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 lì, 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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
+12
-1
@@ -6,7 +6,7 @@
|
||||
//
|
||||
// No db import on purpose: client components import this file.
|
||||
|
||||
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const;
|
||||
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done", "cancelled"] as const;
|
||||
|
||||
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
||||
|
||||
@@ -15,8 +15,19 @@ export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user