feat(chat): chat a canali nel portale cliente e inbox admin per canale

Il portale aveva una sola conversazione con un selettore di fase in un
dropdown: il cliente non vedeva dove c'era del non letto, e una risposta
admin poteva atterrare su un'entità diversa da quella della domanda.

Ora i messaggi si organizzano in canali — "Generale" più uno per fase —
derivati in un solo posto (src/lib/chat-channels.ts) così che le due
sponde concordino sulla stessa chiave. Task e deliverable non sono più
scrivibili ma lo storico non resta orfano: rientra nel canale della fase
proprietaria conservando il nome dell'entità come badge.

- migration 0021 (additiva, già applicata in prod): client_channel_reads
  per il letto/non-letto per canale lato cliente, più il primo indice mai
  esistito su comments (entity_id, created_at)
- GET/POST /api/client/chat: polling dei messaggi e ricevuta di lettura
- ChatPanel: tab per canale, pallino di non letto, modalità full-screen
- inbox admin: tab per canale con targeting dell'entità corretta in
  risposta e snapshot di adminLastReadAt sul thread

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 17:50:44 +02:00
parent 53f1758f52
commit ac74a81a72
14 changed files with 1320 additions and 332 deletions
+44 -16
View File
@@ -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);
@@ -30,20 +42,36 @@ export function ChatProvider({
clientId: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
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>(clientId);
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>
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>;
}