import { ref, computed } from 'vue' import { useAuthStore } from 'src/stores/auth' import { HUB_URL } from 'src/config/hub' function agentHeaders (extra = {}) { try { const email = useAuthStore().user if (email && email !== 'authenticated') return { 'Content-Type': 'application/json', 'X-Authentik-Email': email, ...extra } } catch {} return { 'Content-Type': 'application/json', ...extra } } const conversations = ref([]) const discussions = ref([]) const activeToken = ref(null) const activeDiscussion = ref(null) const activeConv = ref(null) const tickets = ref([]) // tickets (Issues ERPNext) ouverts — inbox unifié const showAll = ref(true) const loading = ref(false) const panelOpen = ref(false) const newDialogOpen = ref(false) // dialogue « Nouveau texto » partagé (ouvrable depuis le FAB sticky ou le panneau) const newDialogChannel = ref('sms') // canal présélectionné quand on ouvre le dialogue (sms | email) — posé par le FAB const selectedIds = ref(new Set()) let sseSource = null // Présence agent : quel autre agent OPS est en train d'écrire dans une conversation. { [convToken]: agentEmail } const agentTyping = ref({}) const _typingTimers = {} // Identifiant d'onglet (par session navigateur) : on ne filtre QUE sa propre frappe (même onglet), // pas celle des autres onglets/users → deux users se voient, et 2 onglets du même compte aussi (testable seul). const _tabSid = Math.random().toString(36).slice(2) + Date.now().toString(36) // ── Notifications navigateur sur NOUVEAU courriel (Notifications API ; fonctionne quand l'app est ouverte, même onglet en arrière-plan) ── const notifyEnabled = ref(false) try { notifyEnabled.value = (typeof Notification !== 'undefined' && Notification.permission === 'granted') } catch (e) { /* */ } async function requestNotifyPermission () { try { if (typeof Notification === 'undefined') return false; const p = await Notification.requestPermission(); notifyEnabled.value = (p === 'granted'); return notifyEnabled.value } catch (e) { return false } } function notifyNewEmail (d) { try { if (!notifyEnabled.value || typeof Notification === 'undefined' || Notification.permission !== 'granted') return if (d && d.triage && d.triage.noise) return // jamais de notif sur le bruit (infra / no-reply / interne) const who = (d && d.triage && d.triage.customer_name) || (d && d.email) || 'Nouveau courriel' const subj = (d && d.triage && d.triage.suggested_title) || 'Nouveau message dans l\'inbox' const n = new Notification('📨 ' + who, { body: subj, tag: 'conv:' + (d && d.token) }) n.onclick = () => { try { window.focus(); if (d && d.token) window.location.hash = '#/communications/c/' + d.token } catch (e) { /* */ } } } catch (e) { /* */ } } function clearTyping (token) { if (!token) return clearTimeout(_typingTimers[token]) if (agentTyping.value[token]) { const n = { ...agentTyping.value }; delete n[token]; agentTyping.value = n } } function handleAgentTyping (data) { if (!data || !data.token || !data.agent) return if (data.sid && data.sid === _tabSid) return // mon propre onglet → ne pas m'afficher à moi-même agentTyping.value = { ...agentTyping.value, [data.token]: data.agent } clearTimeout(_typingTimers[data.token]) _typingTimers[data.token] = setTimeout(() => clearTyping(data.token), 6000) } let _lastTypingSent = 0 async function notifyTyping (token) { if (!token) return const now = Date.now() if (now - _lastTypingSent < 2500) return // throttle réseau _lastTypingSent = now try { await fetch(`${HUB_URL}/conversations/${token}/typing?sid=${_tabSid}`, { method: 'POST', headers: agentHeaders() }) } catch {} } // Brouillon partagé reçu d'un AUTRE agent (miroir temps réel) : { [token]: { agent, sid, html, text, ts } | null } const sharedDraft = ref({}) let _lastDraftSent = 0 function handleConvDraft (data) { if (!data || !data.token) return if (data.sid && data.sid === _tabSid) return // mon propre brouillon → ne pas me le renvoyer à moi-même sharedDraft.value = { ...sharedDraft.value, [data.token]: data.draft || null } } function handleConvRead (data) { if (!data || !data.token || !data.agent) return const rd = { readAt: data.readAt, lastTs: data.lastTs } if (activeConv.value && activeToken.value === data.token) { activeConv.value.readBy = { ...(activeConv.value.readBy || {}), [data.agent]: rd } } // patch la LISTE (gras non-lu + avatars « vu par » en direct) const disc = discussions.value.find(d => d.token === data.token || (d.conversations || []).some(c => c.token === data.token)) if (disc) disc.readBy = { ...(disc.readBy || {}), [data.agent]: rd } } export function useConversations () { async function fetchList () { loading.value = true try { const url = showAll.value ? `${HUB_URL}/conversations?all=1` : `${HUB_URL}/conversations` const res = await fetch(url) if (res.ok) { const data = await res.json() conversations.value = data.conversations || [] discussions.value = data.discussions || [] } } catch {} loading.value = false } // Tickets ouverts (Issues ERPNext) pour l'inbox unifié. async function fetchTickets () { try { const res = await fetch(`${HUB_URL}/conversations/inbox-tickets`, { headers: agentHeaders() }) if (res.ok) { const d = await res.json(); tickets.value = d.tickets || [] } } catch {} } function openDiscussion (disc) { activeDiscussion.value = disc; activeToken.value = null; activeConv.value = null const active = disc.conversations.find(c => c.status === 'active') || disc.conversations[disc.conversations.length - 1] const tok = (active && active.token) || disc.token if (tok) { activeToken.value = tok markRead(tok) // charge le DÉTAIL (notes, file, assigné, gmailReplyUrl, suggestion) pour la barre de coordination fetch(`${HUB_URL}/conversations/${tok}`).then(r => r.ok ? r.json() : null).then(d => { if (d && activeToken.value === tok) activeConv.value = d }).catch(() => {}) } connectDiscussionSSE(disc.conversations.map(c => c.token)) } async function openConversation (token) { activeToken.value = token markRead(token) try { const res = await fetch(`${HUB_URL}/conversations/${token}`) if (res.ok) { const d = await res.json() activeConv.value = d // IMPORTANT : la vue (surtout plein écran) s'affiche d'après activeDiscussion → on en construit une depuis le détail, // sinon un fil hors-liste (URL directe, conv non listée) reste coincé sur « Chargement… ». activeDiscussion.value = { token, customerName: d.customerName, email: d.email, phone: d.phone, channel: d.channel || 'email', status: d.status, subject: d.subject, messages: d.messages || [], conversations: [{ token, status: d.status }], messageCount: (d.messages || []).length, date: String(d.createdAt || '').slice(0, 10), readBy: d.readBy || {}, } } } catch {} connectSSE(token) } function connectDiscussionSSE (tokens) { if (sseSource) sseSource.close() sseSource = new EventSource(`${HUB_URL}/sse?topics=${tokens.map(t => 'conv:' + t).concat(['conversations']).join(',')}`) sseSource.addEventListener('conv-message', e => { try { const data = JSON.parse(e.data) if (activeDiscussion.value && data.message) { const msg = { ...data.message, convToken: data.token } if (!activeDiscussion.value.messages.find(m => m.id === msg.id)) activeDiscussion.value.messages.push(msg) } updateListFromMessage(data) } catch {} }) sseSource.addEventListener('conv-agent-typing', e => { try { handleAgentTyping(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-read', e => { try { handleConvRead(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-draft', e => { try { handleConvDraft(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-closed', handleConvClosed) sseSource.addEventListener('conv-deleted', () => fetchList()) sseSource.addEventListener('conv-email', e => { try { const d = JSON.parse(e.data); fetchList(); notifyNewEmail(d) } catch (er) { /* */ } }) // NOUVEAU courriel ingéré → rafraîchit la liste EN DIRECT + notif navigateur } function updateListFromMessage (data) { clearTyping(data.token) // un message est arrivé → l'agent a fini d'écrire const idx = conversations.value.findIndex(c => c.token === data.token) if (idx >= 0) { conversations.value[idx].lastMessage = data.message conversations.value[idx].lastActivity = data.message.ts conversations.value[idx].messageCount++ } else fetchList() } function handleConvClosed (e) { try { const data = JSON.parse(e.data) const idx = conversations.value.findIndex(c => c.token === data.token) if (idx >= 0) conversations.value[idx].status = 'closed' if (activeConv.value && data.token === activeToken.value) activeConv.value.status = 'closed' } catch {} } function connectSSE (token) { if (sseSource) sseSource.close() sseSource = new EventSource(`${HUB_URL}/sse?topics=conv:${token},conversations`) sseSource.addEventListener('conv-message', e => { try { const data = JSON.parse(e.data) if (activeConv.value && data.token === activeToken.value && data.message) { if (!activeConv.value.messages.find(m => m.id === data.message.id)) activeConv.value.messages.push(data.message) } updateListFromMessage(data) } catch {} }) sseSource.addEventListener('conv-agent-typing', e => { try { handleAgentTyping(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-read', e => { try { handleConvRead(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-draft', e => { try { handleConvDraft(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-closed', handleConvClosed) sseSource.addEventListener('conv-deleted', () => fetchList()) sseSource.addEventListener('conv-email', e => { try { const d = JSON.parse(e.data); fetchList(); notifyNewEmail(d) } catch (er) { /* */ } }) // NOUVEAU courriel ingéré → rafraîchit la liste EN DIRECT + notif navigateur } function connectGlobalSSE () { if (sseSource) sseSource.close() sseSource = new EventSource(`${HUB_URL}/sse?topics=conversations`) sseSource.addEventListener('conv-message', e => { try { updateListFromMessage(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-agent-typing', e => { try { handleAgentTyping(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-read', e => { try { handleConvRead(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-draft', e => { try { handleConvDraft(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-deleted', () => fetchList()) sseSource.addEventListener('conv-email', e => { try { const d = JSON.parse(e.data); fetchList(); notifyNewEmail(d) } catch (er) { /* */ } }) // NOUVEAU courriel ingéré → rafraîchit la liste EN DIRECT + notif navigateur } async function sendMessage (token, text, media, html) { const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ text, media, html }) }) if (!res.ok) throw new Error('Failed to send') return res.json() } // Cherche les fiches client par téléphone → pour rattacher / choisir avant de démarrer le texto. async function resolvePhone (phone) { if (!phone || phone.replace(/\D/g, '').length < 7) return [] try { const res = await fetch(`${HUB_URL}/conversations/resolve?phone=${encodeURIComponent(phone)}`, { headers: agentHeaders() }); if (!res.ok) return []; const d = await res.json(); return d.matches || [] } catch { return [] } } // Idem par COURRIEL (canal email) — alimente le même sélecteur de fiche que le SMS. async function resolveEmail (email) { if (!email || !/.+@.+\..+/.test(email)) return [] try { const res = await fetch(`${HUB_URL}/conversations/resolve?email=${encodeURIComponent(email)}`, { headers: agentHeaders() }); if (!res.ok) return []; const d = await res.json(); return d.matches || [] } catch { return [] } } // Rattache la conversation à une fiche client choisie (résout les multi-fiches, comme pour le SMS). async function linkCustomer (token, customer, customerName) { const res = await fetch(`${HUB_URL}/conversations/${token}/link`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ customer, customerName }) }) if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || 'Liaison échouée') } const d = await res.json() if (activeConv.value && activeToken.value === token) { activeConv.value.customer = d.customer; activeConv.value.customerName = d.customerName } if (activeDiscussion.value) { activeDiscussion.value.customer = d.customer; activeDiscussion.value.customerName = d.customerName } await fetchList() return d } async function startConversation ({ phone, customer, customerName, subject, message, media }) { const res = await fetch(`${HUB_URL}/conversations`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ phone, customer, customerName, subject, message, media }) }) if (!res.ok) throw new Error('Failed to create conversation') const data = await res.json(); await fetchList(); return data } // Nouveau courriel SORTANT (part de cc@/support@ via Gmail) → conversation email suivie async function startEmail ({ to, subject, body, customer, customerName }) { const res = await fetch(`${HUB_URL}/conversations/email-new`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ to, subject, body, customer, customerName }) }) if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || 'Envoi du courriel échoué') } const data = await res.json(); await fetchList(); return data } async function closeConversation (token) { await fetch(`${HUB_URL}/conversations/${token}/close`, { method: 'POST', headers: agentHeaders() }) if (activeConv.value && activeToken.value === token) activeConv.value.status = 'closed' const idx = conversations.value.findIndex(c => c.token === token) if (idx >= 0) conversations.value[idx].status = 'closed' } async function deleteDiscussion (disc) { const res = await fetch(`${HUB_URL}/conversations/discussion`, { method: 'DELETE', headers: agentHeaders(), body: JSON.stringify({ phone: disc.phone, date: disc.id.includes(':active') ? 'active' : disc.date }), }) if (!res.ok) throw new Error('Failed to delete') clearActiveIfMatch(disc); await fetchList() } async function bulkDelete (discs) { const res = await fetch(`${HUB_URL}/conversations/bulk`, { method: 'DELETE', headers: agentHeaders(), body: JSON.stringify({ discussions: discs.map(d => ({ tokens: d.conversations.map(c => c.token) })) }), }) if (!res.ok) throw new Error('Failed to bulk delete') const data = await res.json() for (const d of discs) clearActiveIfMatch(d) selectedIds.value.clear(); await fetchList(); return data } async function archiveDiscussion (disc) { const res = await fetch(`${HUB_URL}/conversations/archive`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ tokens: disc.conversations.map(c => c.token) }), }) if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Archive failed') } const data = await res.json(); clearActiveIfMatch(disc); await fetchList(); return data } async function bulkArchive (discs) { const results = [] for (const d of discs) { const res = await fetch(`${HUB_URL}/conversations/archive`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ tokens: d.conversations.map(c => c.token) }), }) if (res.ok) results.push(await res.json()) } selectedIds.value.clear(); await fetchList(); return results } function clearActiveIfMatch (disc) { if (activeDiscussion.value?.id === disc.id) { activeDiscussion.value = null; activeToken.value = null; activeConv.value = null } } // ── Coordination shared-inbox : file (label) + claim + notes ── const QUEUES = ['Facturations', 'Service à la clientèle', 'Supports', 'Technicien'] async function assignQueue (token, queue) { await fetch(`${HUB_URL}/conversations/${token}/queue`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ queue }) }) if (activeConv.value && activeToken.value === token) activeConv.value.queue = queue if (activeDiscussion.value) activeDiscussion.value.queue = queue await fetchList() } async function claimConv (token) { const r = await fetch(`${HUB_URL}/conversations/${token}/claim`, { method: 'POST', headers: agentHeaders() }) const d = await r.json().catch(() => ({})) if (activeConv.value && activeToken.value === token) activeConv.value.assignee = d.assignee if (activeDiscussion.value) activeDiscussion.value.assignee = d.assignee await fetchList(); return d } async function releaseConv (token) { await fetch(`${HUB_URL}/conversations/${token}/release`, { method: 'POST', headers: agentHeaders() }) if (activeConv.value && activeToken.value === token) activeConv.value.assignee = null if (activeDiscussion.value) activeDiscussion.value.assignee = null await fetchList() } // Assignation directe (agent précis) + assistants (chips). assignee=null pour désassigner. async function assignConv (token, { assignee, assistants } = {}) { const r = await fetch(`${HUB_URL}/conversations/${token}/assign`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ assignee, assistants }) }) const d = await r.json().catch(() => ({})) for (const c of [activeConv.value, activeDiscussion.value]) { if (c && (c.token === token || activeToken.value === token)) { c.assignee = d.assignee; c.assistants = d.assistants || [] } } await fetchList(); return d } // État ticket : 'open' | 'pending' (+ pendingUntil YYYY-MM-DD) | 'closed'. async function setConvState (token, state, pendingUntil) { const r = await fetch(`${HUB_URL}/conversations/${token}/state`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ state, pendingUntil }) }) const d = await r.json().catch(() => ({})) for (const c of [activeConv.value, activeDiscussion.value]) { if (c && (c.token === token || activeToken.value === token)) { c.ticketState = d.ticketState; c.pendingUntil = d.pendingUntil; if (d.status) c.status = d.status } } await fetchList(); return d } let _agents = null async function fetchAgents () { if (_agents) return _agents; try { const r = await fetch(`${HUB_URL}/conversations/agents`, { headers: agentHeaders() }); if (r.ok) { const d = await r.json(); _agents = d.agents || [] } } catch {} return _agents || [] } // Spam → marque le(s) courriel(s) SPAM côté Gmail + retire la conversation. async function spamConv (token) { const r = await fetch(`${HUB_URL}/conversations/${token}/spam`, { method: 'POST', headers: agentHeaders() }); const d = await r.json().catch(() => ({})); await fetchList(); return d } // Suppression par TOKEN (≠ deleteDiscussion par phone/date) → côté hub, met aussi les courriels liés à la corbeille Gmail. async function deleteConvByToken (token) { const r = await fetch(`${HUB_URL}/conversations/${token}`, { method: 'DELETE', headers: agentHeaders() }); if (!r.ok) throw new Error('Échec suppression'); await fetchList(); return r.json().catch(() => ({})) } // Pipeline de leads : board (colonnes par étape) + déplacement async function pipelineBoard () { try { const r = await fetch(`${HUB_URL}/conversations/pipeline-board`, { headers: agentHeaders() }); if (r.ok) return await r.json() } catch {} return { stages: [], columns: {} } } async function setPipeline (token, stage, value) { await fetch(`${HUB_URL}/conversations/${token}/pipeline`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ stage, value }) }) } async function nlCommand (token, command) { const res = await fetch(`${HUB_URL}/conversations/${token}/nl`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ command }) }) if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || 'Commande échouée') } const d = await res.json() try { if (activeToken.value === token) { const r = await fetch(`${HUB_URL}/conversations/${token}`); if (r.ok) activeConv.value = await r.json() } } catch {} await fetchList() return d } async function addNote (token, text) { const r = await fetch(`${HUB_URL}/conversations/${token}/note`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ text }) }) const d = await r.json().catch(() => ({})) if (d.note) { if (!activeConv.value) activeConv.value = {}; activeConv.value.notes = activeConv.value.notes || []; activeConv.value.notes.push(d.note) } return d } // Crée un ticket (ERPNext Issue OUVERT) depuis une conversation — la conversation RESTE (≠ archive). async function createTicket (token, payload) { const res = await fetch(`${HUB_URL}/conversations/${token}/ticket`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify(payload || {}), }) if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || 'Création du ticket échouée') } const data = await res.json() try { if (activeToken.value === token) { const r = await fetch(`${HUB_URL}/conversations/${token}`); if (r.ok) activeConv.value = await r.json() } } catch {} await fetchList() return data } // SPLIT — détecte les sujets distincts (aperçu) puis crée 1 ticket par sujet sur confirmation. async function splitPreview (token) { const r = await fetch(`${HUB_URL}/conversations/${token}/split-preview`, { headers: agentHeaders() }) if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || 'Analyse échouée') } return await r.json() } async function splitConversation (token, topics) { const r = await fetch(`${HUB_URL}/conversations/${token}/split`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ topics }) }) if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || 'Scission échouée') } const d = await r.json() try { if (activeToken.value === token) { const x = await fetch(`${HUB_URL}/conversations/${token}`); if (x.ok) activeConv.value = await x.json() } } catch {} await fetchList() return d } // MERGE — fusionne la conversation SOURCE dans la CIBLE (target garde tout). async function mergeConversation (targetToken, fromToken) { const r = await fetch(`${HUB_URL}/conversations/${targetToken}/merge`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ from: fromToken }) }) if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || 'Fusion échouée') } const d = await r.json() await fetchList() return d } // Preuve de paiement : OCR d'une image du fil → {is_payment, amount, date, reference, method…}. Affiche pour décider (pas d'écriture facturation). async function analyzePayment (token, image, messageId) { const r = await fetch(`${HUB_URL}/conversations/${token}/payment-proof`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ image, messageId }) }) if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || 'Analyse échouée') } const d = await r.json() if (activeConv.value && activeToken.value === token) activeConv.value.paymentProof = d.payment return d } // Résumé IA du fil (style « Summarise this email » de Gmail) → puces pour l'agent. Ne modifie rien. async function summarize (token) { const r = await fetch(`${HUB_URL}/conversations/${token}/summarize`, { method: 'POST', headers: agentHeaders() }) if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || 'Résumé indisponible') } return await r.json() } // Règles d'inbox (« Filtrer comme ceci ») : l'agent choisit ce qui est masqué/affiché (par expéditeur ou sujet). async function fetchInboxRules () { try { const r = await fetch(`${HUB_URL}/conversations/inbox-rules`, { headers: agentHeaders() }); if (r.ok) return (await r.json()).rules || [] } catch {} return [] } async function saveInboxRules (rules) { const r = await fetch(`${HUB_URL}/conversations/inbox-rules`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ rules }) }) if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || 'Échec enregistrement règle') } const d = await r.json(); await fetchList(); return d } // Disponibilité du service : adresse (IA) → fibre (RQA) → brouillon de réponse. N'ENVOIE RIEN (l'agent valide). async function serviceability (token, address) { const r = await fetch(`${HUB_URL}/conversations/${token}/serviceability`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify(address ? { address } : {}) }) if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || 'Vérification indisponible') } return await r.json() } // Crée un ticket (Issue) AUTONOME — depuis le FAB « Créer un ticket » (hors conversation). async function createStandaloneTicket (payload) { const r = await fetch(`${HUB_URL}/collab/ticket`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify(payload || {}) }) if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || 'Création échouée') } const d = await r.json() fetchTickets().catch(() => {}) return d } // Réponses pré-écrites (canned) async function fetchCanned () { try { const r = await fetch(`${HUB_URL}/conversations/canned`, { headers: agentHeaders() }); if (r.ok) { const d = await r.json(); return d.responses || [] } } catch {} return [] } async function saveCanned (responses) { const r = await fetch(`${HUB_URL}/conversations/canned`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ responses }) }); return r.ok } function toggleSelect (discId) { const s = new Set(selectedIds.value) s.has(discId) ? s.delete(discId) : s.add(discId) selectedIds.value = s } function selectAll () { selectedIds.value = new Set(discussions.value.map(d => d.id)) } function clearSelection () { selectedIds.value = new Set() } const selectedDiscussions = computed(() => discussions.value.filter(d => selectedIds.value.has(d.id))) function disconnectSSE () { if (sseSource) { sseSource.close(); sseSource = null } } function goBack () { activeToken.value = null; activeConv.value = null; activeDiscussion.value = null if (sseSource) sseSource.close() connectGlobalSSE() } // « Vu par » : signale que cet agent a ouvert la conversation (avatars chez les autres). async function markRead (token) { if (!token) return try { await fetch(`${HUB_URL}/conversations/${token}/read`, { method: 'POST', headers: agentHeaders() }) } catch {} } // Pousse le brouillon courant aux autres agents (throttle frappe ; l'envoi VIDE passe toujours pour effacer le miroir). function pushDraft (token, html, text) { if (!token) return const empty = !String(html || '').replace(/<[^>]+>/g, '').trim() && !String(text || '').trim() const now = Date.now() if (!empty && now - _lastDraftSent < 700) return _lastDraftSent = now try { fetch(`${HUB_URL}/conversations/${token}/draft?sid=${_tabSid}`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ html: html || '', text: text || '' }) }) } catch {} } const activeCount = computed(() => discussions.value.filter(d => d.status === 'active').length) return { conversations, discussions, activeToken, activeDiscussion, activeConv, tickets, loading, panelOpen, newDialogOpen, newDialogChannel, createStandaloneTicket, showAll, selectedIds, selectedDiscussions, fetchList, fetchTickets, openDiscussion, openConversation, sendMessage, startConversation, startEmail, resolvePhone, resolveEmail, linkCustomer, closeConversation, deleteDiscussion, bulkDelete, archiveDiscussion, bulkArchive, createTicket, QUEUES, assignQueue, claimConv, releaseConv, assignConv, setConvState, fetchAgents, spamConv, deleteConvByToken, addNote, nlCommand, pipelineBoard, setPipeline, splitPreview, splitConversation, mergeConversation, fetchCanned, saveCanned, analyzePayment, summarize, serviceability, fetchInboxRules, saveInboxRules, toggleSelect, selectAll, clearSelection, goBack, disconnectSSE, connectGlobalSSE, activeCount, HUB_URL, agentTyping, notifyTyping, sharedDraft, markRead, pushDraft, notifyEnabled, requestNotifyPermission, } }