Inbox identity/grouping (fixes mislabeled threads): - customerName = real From display name, then a customer matched BY EMAIL, then the raw address — never the AI's content-guessed name (a gilles@ relay showed as "Sylvie Juteau"). - never group a thread by one of our own domains (support@targo.ca, *@targointernet.com): re-ingested outbound was collapsing unrelated threads into one mislabeled "Guylaine Gagnon" thread. Refactor: queues/TYPES/QUEUE_OF/CAT centralized in lib/categories.js (were drifting across conversation.js + inbox-triage.js; telephonie/television had no matching triage type). Removed dead export findConversationByEmail. Feat: addMessage stamps msg.agent on outbound replies -> per-agent stats. Historique (/historique, HistoriquePage.vue): tournées par technicien (ALL statuses incl. Completed/Cancelled — the board hid them), tech leaderboard, inbox leaderboard. Hub: /dispatch/history, /dispatch/leaderboard, /conversations/leaderboard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
492 lines
29 KiB
JavaScript
492 lines
29 KiB
JavaScript
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,
|
|
}
|
|
}
|