docs: chat a canali — design system, STATUS, STATE
Il pattern che vale la pena rileggere: la chiave del canale è derivata, non è una colonna, e sta in un modulo condiviso perché le due sponde devono concordare al carattere. Più il perché del letto/non-letto asimmetrico (tabella per il cliente, timestamp per l'admin). Chiude anche il caveat in STATUS.md sulla risposta admin che finiva sempre sul thread generale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -150,7 +150,9 @@ across all future admin pages.
|
||||
| `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 ottimistico, polling `GET /api/client/chat` ogni 20s. |
|
||||
| `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 +185,57 @@ 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.
|
||||
|
||||
### Clienti (lista) notes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user