feat(inbox+historique): sender-identity fix, single-source taxonomy, dispatch history & leaderboards
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>
This commit is contained in:
parent
e66b328efb
commit
bd92558fa6
File diff suppressed because it is too large
Load Diff
323
apps/ops/src/components/shared/MessageList.vue
Normal file
323
apps/ops/src/components/shared/MessageList.vue
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
<template>
|
||||
<div class="mlist" :class="{ 'mlist-compact': compact }">
|
||||
<div class="mlist-head">
|
||||
<q-input v-model="search" dense outlined clearable debounce="200" placeholder="Rechercher (nom, courriel, sujet…)" class="mlist-search">
|
||||
<template #prepend><q-icon name="search" size="18px" /></template>
|
||||
</q-input>
|
||||
<q-btn flat dense round :icon="compact ? 'density_small' : 'density_medium'" size="sm" @click="toggleDensity"><q-tooltip>{{ compact ? 'Affichage confortable' : 'Affichage compact' }}</q-tooltip></q-btn>
|
||||
<q-btn flat dense round :icon="notifyEnabled ? 'notifications_active' : 'notifications_none'" :color="notifyEnabled ? 'indigo-6' : 'grey-6'" size="sm" @click="toggleNotify"><q-tooltip>{{ notifyEnabled ? 'Notifications navigateur activées' : 'Activer les notifications de nouveaux courriels' }}</q-tooltip></q-btn>
|
||||
<q-btn flat dense round icon="refresh" size="sm" :loading="loading" @click="fetchList" />
|
||||
</div>
|
||||
|
||||
<!-- Case « tout cocher » + filtres OU actions de lot DANS LA MÊME LIGNE (hauteur constante → pas de décalage au clic) -->
|
||||
<div class="mlist-tabs">
|
||||
<q-checkbox dense size="xs" class="mlist-allcb" :model-value="headState" @update:model-value="toggleAll"><q-tooltip>{{ checked.size ? 'Tout décocher' : 'Tout cocher' }}</q-tooltip></q-checkbox>
|
||||
<template v-if="checked.size">
|
||||
<span class="text-weight-medium text-grey-8">{{ checked.size }} sélectionné(s)</span>
|
||||
<q-space />
|
||||
<q-btn dense flat no-caps size="sm" color="teal-7" icon="confirmation_number" label="→ Ticket" :loading="batchBusy">
|
||||
<q-menu auto-close>
|
||||
<q-list dense style="min-width:190px">
|
||||
<q-item-label header class="q-py-xs">Créer un ticket dans…</q-item-label>
|
||||
<q-item v-for="dep in TICKET_DEPTS" :key="dep.cat" clickable @click="batchTicket(dep)">
|
||||
<q-item-section avatar><q-icon :name="dep.icon" :color="dep.color" size="18px" /></q-item-section>
|
||||
<q-item-section>{{ dep.label }}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<q-btn dense flat no-caps size="sm" color="grey-7" icon="archive" label="Archiver" :loading="batchBusy" @click="batchArchive" />
|
||||
<q-btn dense flat no-caps size="sm" color="red-6" icon="delete" label="Supprimer" :loading="batchBusy" @click="batchDelete" />
|
||||
<q-btn dense flat round size="sm" icon="close" @click="checked = new Set()"><q-tooltip>Annuler la sélection</q-tooltip></q-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<q-chip dense clickable size="sm" :color="sel.size === 0 ? 'indigo-6' : 'grey-3'" :text-color="sel.size === 0 ? 'white' : 'grey-8'" @click="toggle('all')">
|
||||
Tous<span class="mlist-cnt">{{ visible.length }}</span>
|
||||
</q-chip>
|
||||
<q-chip v-for="f in chips" :key="f.key" dense clickable size="sm"
|
||||
:color="sel.has(f.key) ? 'indigo-6' : 'grey-3'" :text-color="sel.has(f.key) ? 'white' : 'grey-8'"
|
||||
@click="toggle(f.key)">
|
||||
{{ f.label }}<span v-if="f.count" class="mlist-cnt">{{ f.count }}</span>
|
||||
</q-chip>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mlist-body">
|
||||
<div v-for="d in shown" :key="d.id" class="mrow" :class="{ 'mrow-unread': isUnread(d), 'mrow-checked': checked.has(d.id) }" @click="open(d)">
|
||||
<q-checkbox :model-value="checked.has(d.id)" dense size="xs" class="mrow-cb" @click.stop @update:model-value="toggleCheck(d.id)" />
|
||||
<!-- icône de canal seulement pour SMS/chat/Facebook ; pour le courriel (cas par défaut) elle est redondante -->
|
||||
<q-icon v-if="d.channel && d.channel !== 'email'" :name="chanIcon(d.channel)" :color="chanColor(d.channel)" size="17px" class="mrow-chan" />
|
||||
<!-- Ligne unique pleine largeur (style Gmail) : Nom · file · Sujet — aperçu · indicateurs · heure -->
|
||||
<span class="mrow-name">{{ d.customerName || d.email || d.phone || 'Inconnu' }}</span>
|
||||
<span v-if="replyInfo(d).count > 1" class="mrow-count">{{ replyInfo(d).count }}</span>
|
||||
<q-badge v-if="d.queue" class="mrow-tag" color="blue-grey-1" text-color="blue-grey-8">{{ qLabel(d.queue) }}</q-badge>
|
||||
<div class="mrow-subject"><span v-if="subj(d)" class="mrow-subj-strong">{{ subj(d) }}</span><span class="mrow-snip">{{ subj(d) && snip(d) ? ' — ' : '' }}{{ snip(d) }}</span></div>
|
||||
<div class="mrow-right">
|
||||
<q-icon v-if="replyInfo(d).client" name="reply" size="14px" color="teal-6" class="mrow-rep"><q-tooltip>Le client a répondu</q-tooltip></q-icon>
|
||||
<q-icon v-if="replyInfo(d).team" name="forum" size="13px" color="indigo-5" class="mrow-rep"><q-tooltip>Un collègue a répondu</q-tooltip></q-icon>
|
||||
<span v-if="typingLabel(d)" class="mrow-typing"><q-icon name="edit" size="11px" /> {{ typingLabel(d) }}</span>
|
||||
<ReaderStack :readers="readersOf(d)" :me="meEmail" :max="3" :size="17" />
|
||||
<span class="mrow-time">{{ relTime(lastTs(d)) }}</span>
|
||||
</div>
|
||||
<!-- Actions au survol (style Gmail) : répondre / classer en ticket par dépt / archiver / supprimer -->
|
||||
<div class="mrow-actions" @click.stop>
|
||||
<q-spinner v-if="rowBusy === d.id" size="18px" color="indigo-6" />
|
||||
<template v-else>
|
||||
<q-btn flat dense round size="sm" icon="reply" color="grey-7" @click.stop="open(d)"><q-tooltip>Répondre (ouvrir le fil)</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="confirmation_number" color="teal-7">
|
||||
<q-tooltip>Classer en ticket…</q-tooltip>
|
||||
<q-menu auto-close anchor="bottom right" self="top right">
|
||||
<q-list dense style="min-width:190px">
|
||||
<q-item-label header class="q-py-xs">Créer un ticket dans…</q-item-label>
|
||||
<q-item v-for="dep in TICKET_DEPTS" :key="dep.cat" clickable @click="ticketTo(d, dep)">
|
||||
<q-item-section avatar><q-icon :name="dep.icon" :color="dep.color" size="18px" /></q-item-section>
|
||||
<q-item-section>{{ dep.label }}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<q-btn flat dense round size="sm" icon="filter_alt" color="blue-grey-6" @click.stop="openFilterLike(d)"><q-tooltip>Filtrer les messages comme celui-ci (masquer / afficher)…</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="archive" color="grey-7" @click.stop="quickArchive(d)"><q-tooltip>Archiver (retirer de la boîte)</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="delete" color="red-4" @click.stop="quickDelete(d)"><q-tooltip>Supprimer</q-tooltip></q-btn>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!shown.length" class="mlist-empty">Aucune conversation{{ sel.size || search ? ' (filtre actif)' : '' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- « Filtrer les messages comme celui-ci » : crée une règle masquer/afficher (style Gmail) -->
|
||||
<q-dialog v-model="filterDialog.open">
|
||||
<q-card style="min-width:340px;max-width:92vw">
|
||||
<q-card-section class="row items-center q-pb-none"><q-icon name="filter_alt" color="blue-grey-6" size="20px" class="q-mr-sm" /><span class="text-subtitle1 text-weight-bold">Filtrer les messages comme celui-ci</span></q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Les messages correspondants (futurs <b>et déjà reçus</b>) seront <b>{{ filterDialog.action === 'mask' ? 'masqués de la boîte' : 'toujours affichés' }}</b>.</div>
|
||||
<q-option-group :model-value="filterDialog.field" @update:model-value="onFilterField" inline dense color="indigo-6" :options="[{ label: 'Expéditeur', value: 'from' }, { label: 'Sujet', value: 'subject' }]" />
|
||||
<q-input dense outlined v-model="filterDialog.contains" class="q-mt-sm" :label="filterDialog.field === 'from' ? 'Expéditeur contient' : 'Sujet contient'" hint="Courriel complet, domaine (ex. targo.ca) ou mot-clé" />
|
||||
<q-option-group v-model="filterDialog.action" inline dense color="indigo-6" class="q-mt-md" :options="[{ label: 'Masquer', value: 'mask' }, { label: 'Toujours afficher', value: 'allow' }]" />
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat no-caps label="Annuler" v-close-popup />
|
||||
<q-btn unelevated no-caps color="indigo-6" icon="check" label="Créer la règle" @click="doSaveFilter" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useConversations } from 'src/composables/useConversations'
|
||||
import { useAuthStore } from 'src/stores/auth'
|
||||
import { relTime } from 'src/composables/useFormatters'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ReaderStack from './ReaderStack.vue'
|
||||
|
||||
const $q = useQuasar()
|
||||
const router = useRouter()
|
||||
const { discussions, QUEUES, fetchList, loading, openDiscussion, panelOpen, agentTyping, bulkDelete, bulkArchive, createTicket, archiveDiscussion, notifyEnabled, requestNotifyPermission, fetchInboxRules, saveInboxRules } = useConversations()
|
||||
// « Filtrer comme ceci » (style Gmail) : crée une règle masquer/afficher par expéditeur ou sujet
|
||||
const filterDialog = ref({ open: false, field: 'from', contains: '', action: 'mask', srcEmail: '', srcSubject: '' })
|
||||
function openFilterLike (d) { filterDialog.value = { open: true, field: 'from', contains: d.email || d.customerName || '', action: 'mask', srcEmail: d.email || '', srcSubject: subj(d) || '' } }
|
||||
function onFilterField (v) { filterDialog.value.field = v; filterDialog.value.contains = v === 'from' ? filterDialog.value.srcEmail : filterDialog.value.srcSubject }
|
||||
async function doSaveFilter () {
|
||||
const f = filterDialog.value
|
||||
if (!String(f.contains || '').trim()) { $q.notify({ type: 'warning', message: 'Précisez un texte à filtrer.' }); return }
|
||||
try {
|
||||
const rules = await fetchInboxRules()
|
||||
rules.push({ id: 'r' + Date.now(), field: f.field, contains: String(f.contains).trim(), action: f.action })
|
||||
const r = await saveInboxRules(rules)
|
||||
$q.notify({ type: 'positive', message: (f.action === 'mask' ? 'Règle : masquer' : 'Règle : toujours afficher') + ` « ${f.contains} »` + (r.retro ? ` · ${r.retro} conv. mise(s) à jour` : '') })
|
||||
filterDialog.value.open = false
|
||||
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
||||
}
|
||||
async function toggleNotify () {
|
||||
if (notifyEnabled.value) { $q.notify({ type: 'info', message: 'Pour les désactiver, utilisez les réglages de notification du navigateur.' }); return }
|
||||
const ok = await requestNotifyPermission()
|
||||
$q.notify({ type: ok ? 'positive' : 'warning', message: ok ? 'Notifications activées — alerte à chaque nouveau courriel.' : 'Refusé — autorisez les notifications dans le navigateur.' })
|
||||
}
|
||||
const meEmail = computed(() => { try { return useAuthStore().user } catch { return null } })
|
||||
|
||||
const sel = ref(new Set()) // filtres cumulables (départements OU entre eux ; unread/mine = ET)
|
||||
const search = ref('')
|
||||
const compact = ref(false); try { compact.value = localStorage.getItem('mlist-density') === 'compact' } catch (e) { /* */ }
|
||||
function toggleDensity () { compact.value = !compact.value; try { localStorage.setItem('mlist-density', compact.value ? 'compact' : '') } catch (e) { /* */ } }
|
||||
const checked = ref(new Set()) // ids de discussions cochées (batch)
|
||||
const batchBusy = ref(false)
|
||||
|
||||
const QLABELS = { Facturations: 'Facturation', 'Service à la clientèle': 'Service client', Supports: 'Support', Technicien: 'Technicien' }
|
||||
function qLabel (q) { return QLABELS[q] || q }
|
||||
function chanIcon (c) { return c === 'email' ? 'mail' : c === 'webchat' ? 'forum' : c === 'facebook' ? 'chat' : 'sms' }
|
||||
function chanColor (c) { return c === 'email' ? 'red-5' : c === 'webchat' ? 'purple-5' : c === 'facebook' ? 'blue-6' : 'teal-6' }
|
||||
function lastTs (d) { return d.lastMessage && d.lastMessage.ts }
|
||||
function subjectOf (d) { return d.subject || (d.lastMessage && (d.lastMessage.text || '')) || '—' }
|
||||
function subj (d) { const s = d.subject; return (s && s !== '(courriel)') ? s : '' } // ligne de sujet (vide pour SMS/chat)
|
||||
function snip (d) { return String((d.lastMessage && d.lastMessage.text) || '').replace(/^✉️[^\n]*\n+/, '').replace(/\s+/g, ' ').trim() } // aperçu du dernier message
|
||||
function discToken (d) { const a = (d.conversations || []).find(c => c.status === 'active'); return (a && a.token) || d.token || ((d.conversations || [])[0] || {}).token || null }
|
||||
|
||||
function isUnread (d) {
|
||||
if (!d.lastMessage || d.lastMessage.from === 'agent') return false
|
||||
const mine = (d.readBy || {})[meEmail.value]
|
||||
if (!mine) return true
|
||||
return !mine.lastTs || String(mine.lastTs) < String(d.lastMessage.ts)
|
||||
}
|
||||
function readersOf (d) {
|
||||
const rb = d.readBy || {}
|
||||
return Object.keys(rb).map(e => ({ email: e, readAt: rb[e] && rb[e].readAt })).sort((a, b) => String(b.readAt || '').localeCompare(String(a.readAt || '')))
|
||||
}
|
||||
function typingLabel (d) {
|
||||
for (const c of (d.conversations || [])) { const a = agentTyping.value[c.token]; if (a) return a.includes('@') ? a.split('@')[0] : a }
|
||||
return ''
|
||||
}
|
||||
// Indicateurs « il y a eu des réponses » : nb total de messages + un collègue (agent) a répondu + le client a répondu (≥2 msgs client)
|
||||
function replyInfo (d) {
|
||||
const ms = d.messages || []
|
||||
return { count: d.messageCount || ms.length, team: ms.some(m => m.from === 'agent'), client: ms.filter(m => m.from === 'customer').length > 1 }
|
||||
}
|
||||
function matchSearch (d, q) {
|
||||
return [d.customerName, d.email, d.phone, d.subject, d.lastMessage && d.lastMessage.text].some(x => String(x || '').toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
const CH_LABELS = { email: 'Courriel', sms: 'SMS', webchat: 'Chat web', facebook: 'Messenger' }
|
||||
// Vue « Masqués » : affiche les conversations masquées (noise) au lieu de l'inbox normale
|
||||
const maskedMode = computed(() => sel.value.has('masked'))
|
||||
const visible = computed(() => (discussions.value || []).filter(d => d.status === 'active' && (maskedMode.value ? !!d.noise : !d.noise)))
|
||||
const maskedCount = computed(() => (discussions.value || []).filter(d => d.status === 'active' && d.noise).length)
|
||||
const chips = computed(() => {
|
||||
const base = [
|
||||
{ key: 'unread', label: 'Non lus', count: visible.value.filter(isUnread).length },
|
||||
{ key: 'mine', label: 'À moi', count: visible.value.filter(d => d.assignee === meEmail.value).length },
|
||||
]
|
||||
for (const q of QUEUES) base.push({ key: q, label: qLabel(q), count: visible.value.filter(d => (d.queue || '') === q).length })
|
||||
// Filtre par CANAL — seulement si plusieurs canaux présents (sinon inutile dans une boîte tout-courriel)
|
||||
const chans = [...new Set(visible.value.map(d => d.channel || 'email'))]
|
||||
if (chans.length > 1) for (const ch of chans) base.push({ key: 'ch:' + ch, label: CH_LABELS[ch] || ch, count: visible.value.filter(d => (d.channel || 'email') === ch).length })
|
||||
if (maskedCount.value) base.push({ key: 'masked', label: '🔇 Masqués', count: maskedCount.value }) // voir ce qui est filtré/masqué
|
||||
return base
|
||||
})
|
||||
function toggle (k) {
|
||||
const s = new Set(sel.value)
|
||||
if (k === 'all') s.clear()
|
||||
else if (s.has(k)) s.delete(k); else s.add(k)
|
||||
sel.value = s
|
||||
}
|
||||
const shown = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
const depts = [...sel.value].filter(k => QUEUES.includes(k))
|
||||
const chans = [...sel.value].filter(k => k.startsWith('ch:')).map(k => k.slice(3))
|
||||
const needU = sel.value.has('unread'); const needM = sel.value.has('mine')
|
||||
return visible.value.filter(d => {
|
||||
if (depts.length && !depts.includes(d.queue || '')) return false
|
||||
if (chans.length && !chans.includes(d.channel || 'email')) return false
|
||||
if (needU && !isUnread(d)) return false
|
||||
if (needM && d.assignee !== meEmail.value) return false
|
||||
if (q && !matchSearch(d, q)) return false
|
||||
return true
|
||||
}).sort((a, b) => String(lastTs(b) || '').localeCompare(String(lastTs(a) || '')))
|
||||
})
|
||||
|
||||
function toggleCheck (id) { const s = new Set(checked.value); s.has(id) ? s.delete(id) : s.add(id); checked.value = s }
|
||||
const checkedDiscs = computed(() => shown.value.filter(d => checked.value.has(d.id)))
|
||||
// Case maîtresse « tout cocher » : true=tout, null=indéterminé (partiel), false=rien
|
||||
const allChecked = computed(() => shown.value.length > 0 && shown.value.every(d => checked.value.has(d.id)))
|
||||
const headState = computed(() => checked.value.size ? (allChecked.value ? true : null) : false)
|
||||
function toggleAll () { if (allChecked.value) { checked.value = new Set() } else { const s = new Set(); shown.value.forEach(d => s.add(d.id)); checked.value = s } }
|
||||
async function batchDelete () {
|
||||
if (!checkedDiscs.value.length) return
|
||||
$q.dialog({ title: 'Supprimer', message: `Supprimer ${checkedDiscs.value.length} conversation(s) ? (courriels liés → corbeille Gmail)`, cancel: true, ok: { label: 'Supprimer', color: 'negative' } }).onOk(async () => {
|
||||
batchBusy.value = true
|
||||
try { await bulkDelete(checkedDiscs.value); $q.notify({ type: 'positive', message: 'Supprimé.' }); checked.value = new Set() } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { batchBusy.value = false }
|
||||
})
|
||||
}
|
||||
async function batchTicket (dep) {
|
||||
const list = checkedDiscs.value.slice()
|
||||
if (!list.length) return
|
||||
batchBusy.value = true
|
||||
let ok = 0
|
||||
for (const d of list) {
|
||||
const tok = discToken(d); if (!tok) continue
|
||||
try { await createTicket(tok, { title: subjectOf(d).slice(0, 90), category: dep.cat, priority: 'Medium' }); ok++ } catch (e) { /* continue */ }
|
||||
}
|
||||
try { await bulkArchive(list) } catch (e) { /* */ } // retire de la boîte ; les tickets suivent le travail
|
||||
batchBusy.value = false
|
||||
checked.value = new Set()
|
||||
$q.notify({ type: ok ? 'positive' : 'negative', message: `${ok} ticket(s) « ${dep.label} » créé(s) · conversations archivées.` })
|
||||
}
|
||||
async function batchArchive () {
|
||||
const list = checkedDiscs.value.slice()
|
||||
if (!list.length) return
|
||||
batchBusy.value = true
|
||||
try { await bulkArchive(list); $q.notify({ type: 'positive', message: `${list.length} archivé(s).` }); checked.value = new Set() } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { batchBusy.value = false }
|
||||
}
|
||||
|
||||
// Actions au survol (style Gmail) : répondre / classer en ticket par département / archiver / supprimer
|
||||
const TICKET_DEPTS = [
|
||||
{ label: 'Support technique', cat: 'Support', icon: 'build', color: 'indigo-6' },
|
||||
{ label: 'Facturation', cat: 'Facturation', icon: 'receipt_long', color: 'green-7' },
|
||||
{ label: 'Service client', cat: 'Service client', icon: 'support_agent', color: 'blue-6' },
|
||||
{ label: 'Installation', cat: 'Installation', icon: 'cable', color: 'deep-orange-6' },
|
||||
]
|
||||
const rowBusy = ref(null)
|
||||
async function ticketTo (d, dep) {
|
||||
const tok = discToken(d); if (!tok) return
|
||||
rowBusy.value = d.id
|
||||
try { await createTicket(tok, { title: subjectOf(d).slice(0, 90), category: dep.cat, priority: 'Medium' }); await archiveDiscussion(d); $q.notify({ type: 'positive', message: 'Ticket « ' + dep.label + ' » créé — conversation archivée' }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { rowBusy.value = null }
|
||||
}
|
||||
async function quickArchive (d) {
|
||||
rowBusy.value = d.id
|
||||
try { await archiveDiscussion(d); $q.notify({ type: 'positive', message: 'Archivé' }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { rowBusy.value = null }
|
||||
}
|
||||
function quickDelete (d) {
|
||||
$q.dialog({ title: 'Supprimer', message: 'Supprimer cette conversation ? (courriels liés → corbeille Gmail)', cancel: true, ok: { label: 'Supprimer', color: 'negative' } }).onOk(async () => {
|
||||
try { await bulkDelete([d]); $q.notify({ type: 'positive', message: 'Supprimé' }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
||||
})
|
||||
}
|
||||
function open (d) { const tok = discToken(d); if (tok) router.push('/communications/c/' + tok); else { panelOpen.value = true; openDiscussion(d) } }
|
||||
onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Pleine hauteur ; largeur PLAFONNÉE à une taille confortable (ni étriquée, ni étirée à l'infini sur grand écran), ancrée à gauche */
|
||||
.mlist { display: flex; flex-direction: column; height: calc(100vh - 50px); max-width: 1280px; }
|
||||
.mlist-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px 4px; }
|
||||
.mlist-search { flex: 1; max-width: 520px; }
|
||||
/* Une SEULE ligne (les puces défilent horizontalement si trop nombreuses) → hauteur constante, donc la sélection ne décale jamais la liste */
|
||||
.mlist-tabs { display: flex; flex-wrap: nowrap; align-items: center; gap: 4px; padding: 6px 12px; border-bottom: 1px solid var(--ops-border, #e2e8f0); overflow-x: auto; min-height: 40px; }
|
||||
.mlist-tabs::-webkit-scrollbar { height: 0; }
|
||||
.mlist-tabs .q-chip { flex: 0 0 auto; }
|
||||
.mlist-allcb { flex: 0 0 auto; margin-right: 4px; }
|
||||
.mlist-cnt { margin-left: 5px; font-size: 0.66rem; opacity: 0.8; }
|
||||
.mlist-batch { display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: #eef2ff; border-bottom: 1px solid #c7d2fe; font-size: 0.82rem; }
|
||||
.mlist-body { flex: 1; overflow-y: auto; overflow-x: hidden; }
|
||||
/* garantit que chaque ligne tient dans la largeur (pas de débordement horizontal ; le sujet/aperçu s'ellipse) */
|
||||
.mrow { width: 100%; box-sizing: border-box; }
|
||||
.mrow { display: flex; gap: 8px; padding: 8px 16px; border-bottom: 1px solid #f1f5f9; cursor: pointer; align-items: center; position: relative; }
|
||||
.mrow:hover { background: var(--ops-bg-hover, #eef2ff); }
|
||||
/* Actions au survol : apparaissent à droite (par-dessus heure/lecteurs), comme Gmail */
|
||||
.mrow-actions { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: none; align-items: center; gap: 1px; background: var(--ops-bg-hover, #eef2ff); padding: 2px 5px; border-radius: 8px; box-shadow: 0 1px 5px rgba(15, 23, 42, .12); }
|
||||
.mrow:hover .mrow-actions { display: flex; }
|
||||
.mrow-checked { background: #eef2ff; }
|
||||
.mrow-cb { margin-top: 1px; flex: 0 0 auto; }
|
||||
.mrow-chan { margin-top: 2px; flex: 0 0 auto; }
|
||||
.mrow-main { flex: 1; min-width: 0; }
|
||||
.mrow-line1 { display: flex; align-items: center; gap: 6px; }
|
||||
.mlist-compact .mrow { padding-top: 5px; padding-bottom: 5px; }
|
||||
.mlist-compact .mrow-subject, .mlist-compact .mrow-name { font-size: 0.76rem; }
|
||||
.mrow-name { flex: 0 0 auto; width: 200px; max-width: 26%; min-width: 110px; font-size: 0.86rem; color: var(--ops-text, #1e293b); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
/* Sujet — aperçu : occupe toute la largeur restante, sur une seule ligne */
|
||||
.mrow-subject { flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 0.82rem; }
|
||||
.mrow-subj-strong { color: var(--ops-text, #1e293b); }
|
||||
.mrow-snip { color: var(--ops-text-muted, #64748b); }
|
||||
.mrow-right { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; }
|
||||
.mrow-tag { font-size: 0.62rem; }
|
||||
.mrow-count { font-size: 0.66rem; color: #64748b; background: #eef2f7; border-radius: 9px; padding: 0 6px; line-height: 1.5; flex: 0 0 auto; }
|
||||
.mrow-rep { margin-left: 4px; flex: 0 0 auto; }
|
||||
.mrow-time { font-size: 0.7rem; color: var(--ops-text-muted, #64748b); flex: 0 0 auto; }
|
||||
.mrow-sub { font-size: 0.78rem; color: var(--ops-text-muted, #64748b); margin-top: 1px; }
|
||||
.mrow-foot { display: flex; align-items: center; min-height: 20px; margin-top: 5px; }
|
||||
.mrow-typing { font-size: 0.7rem; color: #0d9488; font-weight: 500; display: flex; align-items: center; gap: 3px; }
|
||||
.mrow-unread .mrow-name, .mrow-unread .mrow-subj-strong { font-weight: 700; }
|
||||
.mrow-unread .mrow-snip { color: var(--ops-text, #334155); }
|
||||
.mrow-unread { background: #fafbff; }
|
||||
.mlist-empty { text-align: center; color: #94a3b8; padding: 32px; font-size: 0.85rem; }
|
||||
</style>
|
||||
|
|
@ -16,12 +16,78 @@ 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
|
||||
|
|
@ -37,16 +103,45 @@ export function useConversations () {
|
|||
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')
|
||||
if (active) activeToken.value = active.token
|
||||
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
|
||||
try { const res = await fetch(`${HUB_URL}/conversations/${token}`); if (res.ok) activeConv.value = await res.json() } catch {}
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -63,11 +158,16 @@ export function useConversations () {
|
|||
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
|
||||
|
|
@ -97,29 +197,65 @@ export function useConversations () {
|
|||
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) {
|
||||
const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ text }) })
|
||||
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()
|
||||
}
|
||||
|
||||
async function startConversation ({ phone, customer, customerName, subject }) {
|
||||
const res = await fetch(`${HUB_URL}/conversations`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ phone, customer, customerName, subject }) })
|
||||
// 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'
|
||||
|
|
@ -172,6 +308,138 @@ export function useConversations () {
|
|||
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)
|
||||
|
|
@ -191,14 +459,33 @@ export function useConversations () {
|
|||
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,
|
||||
loading, panelOpen, showAll, selectedIds, selectedDiscussions,
|
||||
fetchList, openDiscussion, openConversation, sendMessage, startConversation,
|
||||
closeConversation, deleteDiscussion, bulkDelete, archiveDiscussion, bulkArchive,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,25 @@
|
|||
// Ops sidebar navigation + search filter options
|
||||
// `requires` = capability needed to see this nav item (null = always visible)
|
||||
// `group: 'admin'` → regroupé sous une section repliée « Administration » (épuration nav)
|
||||
export const navItems = [
|
||||
{ path: '/', icon: 'LayoutDashboard', label: 'Tableau de bord', requires: 'view_dashboard_kpi' },
|
||||
{ path: '/communications', icon: 'MessagesSquare', label: 'Communications', requires: 'view_clients' },
|
||||
{ path: '/clients', icon: 'Users', label: 'Clients', requires: 'view_clients' },
|
||||
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' },
|
||||
{ path: '/dispatch', icon: 'Truck', label: 'Dispatch', requires: 'view_all_jobs' },
|
||||
{ path: '/planification', icon: 'CalendarRange', label: 'Planification', requires: 'view_all_jobs' },
|
||||
{ path: '/rdv', icon: 'CalendarClock', label: 'Rendez-vous', requires: 'view_all_jobs' },
|
||||
{ path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs' },
|
||||
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' },
|
||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
|
||||
{ path: '/historique', icon: 'History', label: 'Historique', requires: 'view_all_jobs' },
|
||||
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
||||
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' },
|
||||
{ path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings' },
|
||||
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings' },
|
||||
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
|
||||
// ── Administration (replié) ──
|
||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', group: 'admin' },
|
||||
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users', group: 'admin' },
|
||||
{ path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings', group: 'admin' },
|
||||
{ path: '/sync-legacy', icon: 'RefreshCw', label: 'Sync F↔ERPNext', requires: 'view_settings', group: 'admin' },
|
||||
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings', group: 'admin' },
|
||||
{ path: '/factures-fournisseurs', icon: 'ReceiptText', label: 'Factures fournisseurs', requires: 'view_settings', group: 'admin' },
|
||||
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings', group: 'admin' },
|
||||
]
|
||||
|
||||
export const territoryOptions = [
|
||||
|
|
|
|||
|
|
@ -9,14 +9,18 @@
|
|||
<q-item-section v-if="!collapsed"><q-item-label style="color:#fff;font-size:1.1rem;font-weight:700">Targo Ops</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item v-for="nav in navItems" :key="nav.path" clickable :to="nav.path"
|
||||
:class="{ 'active-link': isActive(nav.path), 'justify-center': collapsed }"
|
||||
:title="collapsed ? nav.label : undefined">
|
||||
<q-item-section avatar><component :is="icons[nav.icon]" :size="20" /></q-item-section>
|
||||
<q-item-section v-if="!collapsed"><q-item-label>{{ nav.label }}</q-item-label></q-item-section>
|
||||
<q-item-section side v-if="nav.badge && !collapsed"><q-badge color="red" :label="nav.badge" rounded /></q-item-section>
|
||||
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">{{ nav.label }}</q-tooltip>
|
||||
</q-item>
|
||||
<template v-for="(nav, i) in navItems" :key="nav.path">
|
||||
<q-item-label v-if="!collapsed && nav.group === 'admin' && (i === 0 || navItems[i - 1].group !== 'admin')" header
|
||||
style="color:rgba(255,255,255,.45);font-size:.68rem;letter-spacing:.05em;text-transform:uppercase;padding:14px 16px 4px">Administration</q-item-label>
|
||||
<q-item clickable :to="nav.path"
|
||||
:class="{ 'active-link': isActive(nav.path), 'justify-center': collapsed }"
|
||||
:title="collapsed ? nav.label : undefined">
|
||||
<q-item-section avatar><component :is="icons[nav.icon]" :size="20" /></q-item-section>
|
||||
<q-item-section v-if="!collapsed"><q-item-label>{{ nav.label }}</q-item-label></q-item-section>
|
||||
<q-item-section side v-if="nav.badge && !collapsed"><q-badge color="red" :label="nav.badge" rounded /></q-item-section>
|
||||
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">{{ nav.label }}</q-tooltip>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-list>
|
||||
|
||||
<div class="ops-sidebar-bottom">
|
||||
|
|
@ -97,17 +101,42 @@
|
|||
<router-view />
|
||||
</q-page-container>
|
||||
|
||||
<!-- Conversation panel (right drawer) -->
|
||||
<ConversationPanel v-if="can('view_clients')" />
|
||||
<!-- Conversation panel (right drawer) — masqué sur la route plein écran (sinon double affichage du fil) -->
|
||||
<ConversationPanel v-if="can('view_clients') && !route.path.startsWith('/communications/c/')" />
|
||||
|
||||
<!-- Floating conversation button -->
|
||||
<!-- FAB messagerie : clic « forum » = ouvrir les communications (action directe et fiable) ; bouton « + » (clic) = menu créer rapide. -->
|
||||
<q-page-sticky v-if="can('view_clients')" position="bottom-right" :offset="[18, 18]">
|
||||
<q-btn fab icon="chat" color="indigo-6" @click="toggleConvPanel">
|
||||
<q-badge v-if="convCount > 0" color="red" floating rounded :label="convCount" />
|
||||
<q-tooltip>Conversations</q-tooltip>
|
||||
</q-btn>
|
||||
<div class="column items-end q-gutter-sm">
|
||||
<q-slide-transition>
|
||||
<div v-show="quickOpen" class="column items-end q-gutter-xs">
|
||||
<q-btn rounded unelevated color="deep-purple-6" icon="bolt" label="Dicter une commande" no-caps size="sm" @click="quickOpen = false; orchestratorOpen = true" />
|
||||
<q-btn rounded unelevated color="cyan-8" icon="wifi_find" label="Statut du service" no-caps size="sm" @click="quickOpen = false; serviceStatusOpen = true" />
|
||||
<q-btn rounded unelevated color="teal-7" icon="confirmation_number" label="Créer un ticket" no-caps size="sm" @click="quickOpen = false; newTicketOpen = true" />
|
||||
<q-btn rounded unelevated color="green-6" icon="sms" label="Nouveau texto" no-caps size="sm" @click="quickOpen = false; openNew('sms')" />
|
||||
<q-btn rounded unelevated color="red-5" icon="mail" label="Nouveau courriel" no-caps size="sm" @click="quickOpen = false; openNew('email')" />
|
||||
</div>
|
||||
</q-slide-transition>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-btn round unelevated :color="quickOpen ? 'blue-grey-7' : 'blue-grey-4'" :icon="quickOpen ? 'close' : 'add'" size="sm" @click="quickOpen = !quickOpen">
|
||||
<q-tooltip>Créer rapidement (ticket · texto · courriel · dicter)</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn fab icon="forum" color="indigo-6" @click="toggleConvPanel">
|
||||
<q-badge v-if="convCount > 0" color="red" floating rounded :label="convCount" />
|
||||
<q-tooltip>Voir les communications</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-page-sticky>
|
||||
|
||||
<!-- Création rapide d'un ticket depuis le FAB -->
|
||||
<NewTicketDialog v-if="can('view_clients')" v-model="newTicketOpen" />
|
||||
<!-- Commande dictée / langage naturel → orchestre plusieurs actions -->
|
||||
<OrchestratorDialog v-if="can('view_clients')" v-model="orchestratorOpen" />
|
||||
<!-- Statut service+modem par identifiant libre (adresse / nom / téléphone / courriel) — lecture -->
|
||||
<ServiceStatusDialog v-if="can('view_clients')" v-model="serviceStatusOpen" />
|
||||
<!-- Tampon d'envoi : courriels multi-destinataires retenus + compte à rebours + annuler (anti-spam) -->
|
||||
<OutboxPanel v-if="can('view_clients')" />
|
||||
|
||||
<!-- Global Flow Editor dialog (any page can open it via useFlowEditor) -->
|
||||
<FlowEditorDialog v-if="can('manage_settings')" />
|
||||
|
||||
|
|
@ -124,16 +153,26 @@ import { navItems as allNavItems } from 'src/config/nav'
|
|||
import {
|
||||
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
||||
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
|
||||
CalendarRange, CalendarClock, Sparkles, MapPinned,
|
||||
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History,
|
||||
} from 'lucide-vue-next'
|
||||
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
||||
import { useConversations } from 'src/composables/useConversations'
|
||||
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
||||
import NewTicketDialog from 'src/components/shared/NewTicketDialog.vue'
|
||||
import OrchestratorDialog from 'src/components/shared/OrchestratorDialog.vue'
|
||||
import ServiceStatusDialog from 'src/components/shared/ServiceStatusDialog.vue'
|
||||
import OutboxPanel from 'src/components/shared/OutboxPanel.vue'
|
||||
|
||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned }
|
||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History }
|
||||
|
||||
const { panelOpen, activeCount: convCount } = useConversations()
|
||||
const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount } = useConversations()
|
||||
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
|
||||
// FAB messagerie : clic « forum » = ouvrir les communications ; bouton « + » (clic, pas survol) = menu créer rapide.
|
||||
const quickOpen = ref(false)
|
||||
const newTicketOpen = ref(false)
|
||||
const orchestratorOpen = ref(false)
|
||||
const serviceStatusOpen = ref(false)
|
||||
function openNew (channel) { newDialogChannel.value = channel; newDialogOpen.value = true }
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { can, isLoaded, userName } = usePermissions()
|
||||
|
|
|
|||
269
apps/ops/src/pages/HistoriquePage.vue
Normal file
269
apps/ops/src/pages/HistoriquePage.vue
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
<template>
|
||||
<q-page class="hist-page">
|
||||
<div class="hist-head">
|
||||
<div class="text-h6">Historique & classements</div>
|
||||
<div class="hist-sub">Retracer la journée de chaque technicien (tous statuts) et reconnaître l'effort de l'équipe.</div>
|
||||
</div>
|
||||
|
||||
<q-tabs v-model="tab" no-caps dense align="left" class="hist-tabs" active-color="primary" indicator-color="primary">
|
||||
<q-tab name="tournees" icon="route" label="Tournées par technicien" />
|
||||
<q-tab name="techs" icon="emoji_events" label="Classement techniciens" />
|
||||
<q-tab name="inbox" icon="forum" label="Classement Inbox" />
|
||||
</q-tabs>
|
||||
<q-separator />
|
||||
|
||||
<q-tab-panels v-model="tab" animated class="hist-panels">
|
||||
<!-- ─────────── Tournées par technicien (tous statuts) ─────────── -->
|
||||
<q-tab-panel name="tournees" class="q-pa-none">
|
||||
<div class="hist-toolbar">
|
||||
<q-btn flat dense round icon="chevron_left" @click="shiftDay(-1)" />
|
||||
<q-btn flat dense no-caps class="hist-datebtn" :label="prettyDate(histDate)" icon-right="event">
|
||||
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
|
||||
<q-date v-model="histDate" mask="YYYY-MM-DD" today-btn minimal @update:model-value="() => { loadHistory() }" />
|
||||
</q-popup-proxy>
|
||||
</q-btn>
|
||||
<q-btn flat dense round icon="chevron_right" :disable="histDate >= today" @click="shiftDay(1)" />
|
||||
<q-btn flat dense no-caps label="Aujourd'hui" class="q-ml-sm" :disable="histDate === today" @click="histDate = today; loadHistory()" />
|
||||
<q-space />
|
||||
<div v-if="!histLoading && hist" class="hist-summary">
|
||||
<b>{{ hist.count }}</b> tâche{{ hist.count > 1 ? 's' : '' }} · <b>{{ (hist.techs || []).length }}</b> technicien{{ (hist.techs || []).length > 1 ? 's' : '' }}
|
||||
<span v-for="s in histStatusSummary" :key="s.key" class="hist-sumchip" :style="{ color: s.color }">● {{ s.n }} {{ s.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="histLoading" class="hist-loading"><q-spinner-dots size="32px" color="primary" /></div>
|
||||
<div v-else-if="!hist || !(hist.techs || []).length" class="hist-empty">
|
||||
<q-icon name="event_busy" size="32px" class="q-mb-sm" />
|
||||
<div>Aucune tâche planifiée le {{ prettyDate(histDate) }}.</div>
|
||||
</div>
|
||||
<div v-else class="hist-techs">
|
||||
<q-card v-for="t in hist.techs" :key="t.tech" flat bordered class="hist-techcard">
|
||||
<div class="hist-techhead">
|
||||
<q-avatar size="28px" color="blue-grey-1" text-color="blue-grey-8" class="q-mr-sm">{{ initials(t.tech_name) }}</q-avatar>
|
||||
<div class="hist-techname">{{ t.tech_name }}</div>
|
||||
<q-space />
|
||||
<span class="hist-techcount">{{ t.jobs.length }} tâche{{ t.jobs.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<q-separator />
|
||||
<div v-for="j in t.jobs" :key="j.name" class="hist-job" :class="{ 'hist-job--cancel': j.status === 'Cancelled' }">
|
||||
<q-chip dense square :style="{ background: statusMeta(j.status).bg, color: statusMeta(j.status).fg }" class="hist-statuschip">{{ statusMeta(j.status).label }}</q-chip>
|
||||
<span class="hist-jobtime">{{ jobTime(j) }}</span>
|
||||
<span class="hist-jobsubj">{{ j.subject || j.job_type || j.name }}</span>
|
||||
<q-space />
|
||||
<span v-if="j.duration_h" class="hist-jobdur">{{ j.duration_h }} h</span>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- ─────────── Classement techniciens (jobs complétés) ─────────── -->
|
||||
<q-tab-panel name="techs" class="q-pa-none">
|
||||
<div class="hist-toolbar">
|
||||
<q-btn-toggle v-model="rangeDays" no-caps dense unelevated toggle-color="primary" color="grey-2" text-color="grey-8"
|
||||
:options="[{ label: '7 jours', value: 7 }, { label: '30 jours', value: 30 }, { label: '90 jours', value: 90 }]"
|
||||
@update:model-value="loadTechBoard" />
|
||||
<q-space />
|
||||
<div class="hist-summary">Jobs <b>complétés</b> · {{ prettyDate(rangeFrom) }} → {{ prettyDate(today) }}</div>
|
||||
</div>
|
||||
<div v-if="techLoading" class="hist-loading"><q-spinner-dots size="32px" color="primary" /></div>
|
||||
<div v-else-if="!techBoard.length" class="hist-empty"><q-icon name="emoji_events" size="32px" class="q-mb-sm" /><div>Aucun job complété sur la période.</div></div>
|
||||
<div v-else class="hist-board">
|
||||
<div v-for="(b, i) in techBoard" :key="b.tech" class="hist-rank">
|
||||
<div class="hist-medal" :class="'hist-medal--' + (i < 3 ? i + 1 : 0)">{{ i < 3 ? ['🥇', '🥈', '🥉'][i] : (i + 1) }}</div>
|
||||
<q-avatar size="30px" color="blue-grey-1" text-color="blue-grey-8" class="q-mr-sm">{{ initials(b.tech_name) }}</q-avatar>
|
||||
<div class="hist-rankname">{{ b.tech_name }}</div>
|
||||
<div class="hist-bar"><div class="hist-bar-fill" :style="{ width: barPct(b.completed, techMax) + '%' }"></div></div>
|
||||
<div class="hist-rankval">{{ b.completed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- ─────────── Classement Inbox (réponses par agent) ─────────── -->
|
||||
<q-tab-panel name="inbox" class="q-pa-none">
|
||||
<div class="hist-toolbar">
|
||||
<q-btn-toggle v-model="rangeDays" no-caps dense unelevated toggle-color="primary" color="grey-2" text-color="grey-8"
|
||||
:options="[{ label: '7 jours', value: 7 }, { label: '30 jours', value: 30 }, { label: '90 jours', value: 90 }]"
|
||||
@update:model-value="loadInboxBoard" />
|
||||
<q-space />
|
||||
<div class="hist-summary">Réponses & conversations prises · {{ prettyDate(rangeFrom) }} → {{ prettyDate(today) }}</div>
|
||||
</div>
|
||||
<div v-if="inboxLoading" class="hist-loading"><q-spinner-dots size="32px" color="primary" /></div>
|
||||
<template v-else>
|
||||
<div v-if="!inboxBoard.length" class="hist-empty">
|
||||
<q-icon name="forum" size="32px" class="q-mb-sm" />
|
||||
<div>Les statistiques par agent s'accumulent à partir de maintenant.</div>
|
||||
<div class="hist-empty-sub">Chaque réponse envoyée est désormais attribuée à son auteur·rice.<br>{{ inboxUnattributed }} réponse{{ inboxUnattributed > 1 ? 's' : '' }} antérieure{{ inboxUnattributed > 1 ? 's' : '' }} (avant suivi) ne sont pas attribuées.</div>
|
||||
</div>
|
||||
<div v-else class="hist-board">
|
||||
<div v-for="(b, i) in inboxBoard" :key="b.agent" class="hist-rank">
|
||||
<div class="hist-medal" :class="'hist-medal--' + (i < 3 ? i + 1 : 0)">{{ i < 3 ? ['🥇', '🥈', '🥉'][i] : (i + 1) }}</div>
|
||||
<q-avatar size="30px" color="teal-1" text-color="teal-8" class="q-mr-sm">{{ initials(agentName(b.agent)) }}</q-avatar>
|
||||
<div class="hist-rankname">{{ agentName(b.agent) }}</div>
|
||||
<div class="hist-bar"><div class="hist-bar-fill hist-bar-fill--teal" :style="{ width: barPct(b.replies, inboxMax) + '%' }"></div></div>
|
||||
<div class="hist-rankval">{{ b.replies }}<span class="hist-rankval-sub">rép.</span></div>
|
||||
<div class="hist-rankval2" v-if="b.handled">{{ b.handled }}<span class="hist-rankval-sub">pris</span></div>
|
||||
</div>
|
||||
<div v-if="inboxUnattributed" class="hist-note">+ {{ inboxUnattributed }} réponse{{ inboxUnattributed > 1 ? 's' : '' }} antérieure{{ inboxUnattributed > 1 ? 's' : '' }} non attribuée{{ inboxUnattributed > 1 ? 's' : '' }} (avant le suivi par auteur).</div>
|
||||
</div>
|
||||
</template>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
|
||||
const tab = ref('tournees')
|
||||
|
||||
function localDateStr (d = new Date()) {
|
||||
// Date locale (QC = ET) en YYYY-MM-DD, sans décalage UTC
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
const today = localDateStr()
|
||||
|
||||
async function hubGet (path) {
|
||||
const res = await fetch(HUB_URL + path, { headers: { 'Content-Type': 'application/json' } })
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Affichage commun ──
|
||||
function initials (name) {
|
||||
const parts = String(name || '?').trim().split(/\s+/).filter(Boolean)
|
||||
return ((parts[0]?.[0] || '') + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase() || '?'
|
||||
}
|
||||
function prettyDate (iso) {
|
||||
if (!iso) return ''
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
return new Date(y, m - 1, d).toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
function barPct (v, max) { return max > 0 ? Math.max(4, Math.round((v / max) * 100)) : 0 }
|
||||
|
||||
const STATUS_META = {
|
||||
Completed: { label: 'Complété', bg: '#dcfce7', fg: '#15803d' },
|
||||
assigned: { label: 'Assigné', bg: '#dbeafe', fg: '#1d4ed8' },
|
||||
in_progress: { label: 'En cours', bg: '#fef3c7', fg: '#b45309' },
|
||||
open: { label: 'Ouvert', bg: '#e2e8f0', fg: '#475569' },
|
||||
'On Hold': { label: 'En attente', bg: '#ffedd5', fg: '#c2410c' },
|
||||
Cancelled: { label: 'Annulé', bg: '#f1f5f9', fg: '#94a3b8' },
|
||||
}
|
||||
function statusMeta (s) { return STATUS_META[s] || { label: s || '—', bg: '#e2e8f0', fg: '#475569' } }
|
||||
function jobTime (j) {
|
||||
const t = j.start_time ? String(j.start_time).slice(11, 16) : ''
|
||||
return t || '—'
|
||||
}
|
||||
|
||||
// ── Tab 1 : tournées par technicien ──
|
||||
const histDate = ref(today)
|
||||
const hist = ref(null)
|
||||
const histLoading = ref(false)
|
||||
function shiftDay (delta) {
|
||||
const [y, m, d] = histDate.value.split('-').map(Number)
|
||||
const nd = new Date(y, m - 1, d + delta)
|
||||
const next = localDateStr(nd)
|
||||
if (next > today) return
|
||||
histDate.value = next
|
||||
loadHistory()
|
||||
}
|
||||
async function loadHistory () {
|
||||
histLoading.value = true
|
||||
try { hist.value = await hubGet(`/dispatch/history?date=${histDate.value}`) }
|
||||
catch (e) { hist.value = null; Notify.create({ type: 'negative', message: 'Chargement de l\'historique : ' + e.message }) }
|
||||
finally { histLoading.value = false }
|
||||
}
|
||||
const histStatusSummary = computed(() => {
|
||||
if (!hist.value) return []
|
||||
const counts = {}
|
||||
for (const t of (hist.value.techs || [])) for (const j of t.jobs) counts[j.status] = (counts[j.status] || 0) + 1
|
||||
return Object.keys(counts).sort((a, b) => counts[b] - counts[a]).map(k => ({ key: k, n: counts[k], label: statusMeta(k).label.toLowerCase(), color: statusMeta(k).fg }))
|
||||
})
|
||||
|
||||
// ── Périodes (tabs 2 & 3) ──
|
||||
const rangeDays = ref(30)
|
||||
const rangeFrom = computed(() => {
|
||||
const d = new Date(); d.setDate(d.getDate() - (rangeDays.value - 1))
|
||||
return localDateStr(d)
|
||||
})
|
||||
|
||||
// ── Tab 2 : classement techniciens ──
|
||||
const techBoard = ref([])
|
||||
const techLoading = ref(false)
|
||||
const techMax = computed(() => techBoard.value.reduce((m, b) => Math.max(m, b.completed), 0))
|
||||
async function loadTechBoard () {
|
||||
techLoading.value = true
|
||||
try { const r = await hubGet(`/dispatch/leaderboard?from=${rangeFrom.value}&to=${today}`); techBoard.value = r.board || [] }
|
||||
catch (e) { techBoard.value = []; Notify.create({ type: 'negative', message: 'Classement techniciens : ' + e.message }) }
|
||||
finally { techLoading.value = false }
|
||||
}
|
||||
|
||||
// ── Tab 3 : classement Inbox ──
|
||||
const inboxBoard = ref([])
|
||||
const inboxUnattributed = ref(0)
|
||||
const inboxLoading = ref(false)
|
||||
const inboxMax = computed(() => inboxBoard.value.reduce((m, b) => Math.max(m, b.replies), 0))
|
||||
function agentName (email) {
|
||||
if (!email) return '—'
|
||||
const local = String(email).split('@')[0]
|
||||
return local.split(/[.\-_]/).filter(Boolean).map(p => p[0].toUpperCase() + p.slice(1)).join(' ') || email
|
||||
}
|
||||
async function loadInboxBoard () {
|
||||
inboxLoading.value = true
|
||||
try {
|
||||
const r = await hubGet(`/conversations/leaderboard?from=${rangeFrom.value}&to=${today}`)
|
||||
inboxBoard.value = (r.board || []).filter(b => b.replies > 0 || b.handled > 0)
|
||||
inboxUnattributed.value = r.unattributed || 0
|
||||
} catch (e) { inboxBoard.value = []; Notify.create({ type: 'negative', message: 'Classement Inbox : ' + e.message }) }
|
||||
finally { inboxLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { loadHistory(); loadTechBoard(); loadInboxBoard() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hist-page { padding: 16px 20px; background: var(--ops-bg, #f8fafc); min-height: 100%; }
|
||||
.hist-head { margin-bottom: 10px; }
|
||||
.hist-sub { color: var(--ops-text-muted, #64748b); font-size: 13px; margin-top: 2px; }
|
||||
.hist-tabs { color: var(--ops-text, #1e293b); }
|
||||
.hist-panels { background: transparent; }
|
||||
|
||||
.hist-toolbar { display: flex; align-items: center; gap: 2px; padding: 12px 2px; flex-wrap: wrap; }
|
||||
.hist-datebtn { font-weight: 600; text-transform: capitalize; }
|
||||
.hist-summary { font-size: 13px; color: var(--ops-text-muted, #64748b); }
|
||||
.hist-summary b { color: var(--ops-text, #1e293b); }
|
||||
.hist-sumchip { margin-left: 10px; font-size: 12px; white-space: nowrap; }
|
||||
|
||||
.hist-loading, .hist-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 16px; color: var(--ops-text-muted, #94a3b8); text-align: center; }
|
||||
.hist-empty-sub { font-size: 12px; margin-top: 6px; line-height: 1.5; }
|
||||
|
||||
/* Tournées */
|
||||
.hist-techs { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
|
||||
.hist-techcard { border-radius: 10px; background: var(--ops-surface, #fff); overflow: hidden; }
|
||||
.hist-techhead { display: flex; align-items: center; padding: 10px 12px; }
|
||||
.hist-techname { font-weight: 600; color: var(--ops-text, #1e293b); }
|
||||
.hist-techcount { font-size: 12px; color: var(--ops-text-muted, #64748b); }
|
||||
.hist-job { display: flex; align-items: center; gap: 8px; padding: 7px 12px; border-top: 1px solid var(--ops-border, #f1f5f9); font-size: 13px; }
|
||||
.hist-job:first-of-type { border-top: none; }
|
||||
.hist-job--cancel { opacity: 0.55; }
|
||||
.hist-job--cancel .hist-jobsubj { text-decoration: line-through; }
|
||||
.hist-statuschip { font-size: 11px; font-weight: 600; height: 20px; }
|
||||
.hist-jobtime { font-variant-numeric: tabular-nums; color: var(--ops-text-muted, #64748b); width: 42px; flex-shrink: 0; }
|
||||
.hist-jobsubj { color: var(--ops-text, #1e293b); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hist-jobdur { font-size: 12px; color: var(--ops-text-muted, #94a3b8); flex-shrink: 0; }
|
||||
|
||||
/* Classements */
|
||||
.hist-board { max-width: 760px; }
|
||||
.hist-rank { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--ops-border, #eef2f6); }
|
||||
.hist-medal { width: 30px; text-align: center; font-weight: 700; font-size: 16px; color: var(--ops-text-muted, #94a3b8); flex-shrink: 0; }
|
||||
.hist-medal--1, .hist-medal--2, .hist-medal--3 { font-size: 18px; }
|
||||
.hist-rankname { font-weight: 600; color: var(--ops-text, #1e293b); width: 200px; max-width: 32%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hist-bar { flex: 1; height: 10px; background: var(--ops-border, #eef2f6); border-radius: 6px; overflow: hidden; margin: 0 4px; }
|
||||
.hist-bar-fill { height: 100%; background: #3b82f6; border-radius: 6px; transition: width .3s; }
|
||||
.hist-bar-fill--teal { background: #14b8a6; }
|
||||
.hist-rankval { font-weight: 700; color: var(--ops-text, #1e293b); min-width: 38px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.hist-rankval2 { min-width: 42px; text-align: right; color: var(--ops-text-muted, #64748b); font-variant-numeric: tabular-nums; }
|
||||
.hist-rankval-sub { font-size: 10px; font-weight: 400; color: var(--ops-text-muted, #94a3b8); margin-left: 2px; }
|
||||
.hist-note { font-size: 12px; color: var(--ops-text-muted, #94a3b8); padding: 10px; }
|
||||
</style>
|
||||
|
|
@ -26,6 +26,9 @@ const routes = [
|
|||
{ path: 'clients', component: () => import('src/pages/ClientsPage.vue') },
|
||||
{ path: 'clients/:id', component: () => import('src/pages/ClientDetailPage.vue'), props: true },
|
||||
{ path: 'tickets', component: () => import('src/pages/TicketsPage.vue') },
|
||||
{ path: 'communications', component: () => import('src/pages/CommunicationsPage.vue') },
|
||||
{ path: 'communications/c/:token', component: () => import('src/pages/ConversationFullPage.vue'), props: true },
|
||||
{ path: 'pipeline', component: () => import('src/pages/PipelinePage.vue') },
|
||||
{ path: 'equipe', component: () => import('src/pages/EquipePage.vue') },
|
||||
{ path: 'rapports', component: () => import('src/pages/RapportsPage.vue') },
|
||||
{ path: 'rapports/revenus', component: () => import('src/pages/ReportRevenuPage.vue') },
|
||||
|
|
@ -33,17 +36,22 @@ const routes = [
|
|||
{ path: 'rapports/taxes', component: () => import('src/pages/ReportTaxesPage.vue') },
|
||||
{ path: 'rapports/ar', component: () => import('src/pages/ReportARPage.vue') },
|
||||
{ path: 'rapports/internet-cher', component: () => import('src/pages/ReportInternetCherPage.vue') },
|
||||
{ path: 'rapports/factures-negatives', component: () => import('src/pages/ReportNegativeBillingPage.vue') },
|
||||
{ path: 'rapports/resilies-actifs', component: () => import('src/pages/ReportTerminatedActivePage.vue') },
|
||||
{ path: 'ocr', component: () => import('src/pages/OcrPage.vue') },
|
||||
{ path: 'settings', component: () => import('src/pages/SettingsPage.vue') },
|
||||
{ path: 'email-queue', component: () => import('src/pages/EmailQueuePage.vue') },
|
||||
{ path: 'factures-fournisseurs', component: () => import('src/pages/SupplierInvoicesPage.vue') },
|
||||
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
|
||||
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
||||
{ path: 'historique', component: () => import('src/pages/HistoriquePage.vue') },
|
||||
{ path: 'planification', component: () => import('src/pages/PlanificationPage.vue') },
|
||||
{ path: 'conformite-adresses', component: () => import('src/pages/AddressConformityPage.vue') },
|
||||
{ path: 'rdv', component: () => import('src/pages/RendezVousPage.vue') },
|
||||
{ path: 'copilote', component: () => import('src/pages/CopilotePage.vue') },
|
||||
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
||||
{ path: 'network', component: () => import('src/pages/NetworkPage.vue') },
|
||||
{ path: 'sync-legacy', component: () => import('src/pages/LegacySyncPage.vue') },
|
||||
// Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates
|
||||
{ path: 'campaigns', component: () => import('src/modules/campaigns/pages/CampaignsListPage.vue') },
|
||||
{ path: 'campaigns/new', component: () => import('src/modules/campaigns/pages/CampaignNewPage.vue') },
|
||||
|
|
|
|||
33
services/targo-hub/lib/categories.js
Normal file
33
services/targo-hub/lib/categories.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// ── Taxonomie des conversations — SOURCE UNIQUE ───────────────────────────────
|
||||
// Avant : le « type » d'une conversation vivait dans 4 endroits désynchronisables
|
||||
// - TYPES + l'énumération du prompt IA → inbox-triage.js
|
||||
// - QUEUE_OF (type→file) + QUEUES → conversation.js
|
||||
// Conséquence réelle : `telephonie`/`television` existaient dans QUEUE_OF mais PAS
|
||||
// dans TYPES → jamais produits par le triage (clés inertes). On centralise ici pour
|
||||
// que toute évolution de catégorie se fasse à UN seul endroit.
|
||||
//
|
||||
// ⚠️ CONTRAT : le prompt système de `classifyEmail` (inbox-triage.js) énumère les
|
||||
// valeurs de TYPES À LA MAIN (contrainte du modèle). Si tu ajoutes un type ici,
|
||||
// ajoute-le AUSSI dans cette énumération du prompt, sinon le triage ne le produira pas.
|
||||
|
||||
// Les 4 FILES de l'inbox partagé (= labels F fiables, vs 30 départements).
|
||||
const QUEUES = ['Facturations', 'Service à la clientèle', 'Supports', 'Technicien']
|
||||
|
||||
// Types de demande que le triage peut produire (doivent matcher l'énumération du prompt IA).
|
||||
const TYPES = ['support', 'facturation', 'vente', 'installation', 'autre']
|
||||
|
||||
// type → FILE de routage. (telephonie/television : réservés — routent vers Technicien si un jour produits.)
|
||||
const QUEUE_OF = {
|
||||
facturation: 'Facturations',
|
||||
vente: 'Service à la clientèle',
|
||||
support: 'Supports',
|
||||
installation: 'Technicien',
|
||||
telephonie: 'Technicien',
|
||||
television: 'Technicien',
|
||||
autre: '',
|
||||
}
|
||||
|
||||
// type → catégorie de TICKET (ERPNext Issue).
|
||||
const CAT = { facturation: 'Facturation', vente: 'Commercial', installation: 'Installation', support: 'Support', autre: 'Autre' }
|
||||
|
||||
module.exports = { QUEUES, TYPES, QUEUE_OF, CAT }
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -623,6 +623,44 @@ async function handle (req, res, method, path) {
|
|||
}
|
||||
}
|
||||
|
||||
// GET /dispatch/history?date=YYYY-MM-DD[&tech=TECH-x] — TOUS les jobs d'une journée, statuts INCLUS (Completed/Cancelled), groupés par technicien.
|
||||
// Corrige la perte de visibilité : le board n'affiche que open/assigned/in_progress → une fois un job terminé/annulé (ou son ticket fermé/réassigné) on ne voyait plus où le tech était allé.
|
||||
if (sub.startsWith('history') && method === 'GET') {
|
||||
try {
|
||||
const params = require('url').parse(req.url, true).query
|
||||
const date = params.date || todayET()
|
||||
const filters = [['scheduled_date', '=', date]]
|
||||
if (params.tech) filters.push(['assigned_tech', '=', params.tech])
|
||||
const fields = ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'job_type', 'source_issue', 'customer']
|
||||
const qs = new URLSearchParams({ filters: JSON.stringify(filters), fields: JSON.stringify(fields), limit_page_length: '500', order_by: 'assigned_tech asc, start_time asc, modified asc' })
|
||||
const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`)
|
||||
if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status })
|
||||
const jobs = r.data?.data || []
|
||||
const tr = await erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['technician_id', 'full_name']))}&limit_page_length=200`)
|
||||
const names = {}; for (const t of (tr.data?.data || [])) names[t.technician_id] = t.full_name || t.technician_id
|
||||
const groups = {}
|
||||
for (const j of jobs) { const k = j.assigned_tech || '__none__'; (groups[k] = groups[k] || { tech: k, tech_name: k === '__none__' ? 'Non assigné' : (names[k] || k), jobs: [] }).jobs.push(j) }
|
||||
return json(res, 200, { date, count: jobs.length, techs: Object.values(groups).sort((a, b) => String(a.tech_name).localeCompare(String(b.tech_name))) })
|
||||
} catch (e) { log('dispatch history error: ' + e.message); return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
||||
// GET /dispatch/leaderboard?from=YYYY-MM-DD&to=YYYY-MM-DD — classement des techs par jobs COMPLÉTÉS (reconnaissance / gamification).
|
||||
if (sub.startsWith('leaderboard') && method === 'GET') {
|
||||
try {
|
||||
const params = require('url').parse(req.url, true).query
|
||||
const to = params.to || todayET(); const from = params.from || dateAddDays(to, -30)
|
||||
const filters = [['status', '=', 'Completed'], ['scheduled_date', '>=', from], ['scheduled_date', '<=', to]]
|
||||
const qs = new URLSearchParams({ filters: JSON.stringify(filters), fields: JSON.stringify(['assigned_tech']), limit_page_length: '5000' })
|
||||
const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`)
|
||||
if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status })
|
||||
const counts = {}; for (const j of (r.data?.data || [])) { if (j.assigned_tech) counts[j.assigned_tech] = (counts[j.assigned_tech] || 0) + 1 }
|
||||
const tr = await erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['technician_id', 'full_name']))}&limit_page_length=200`)
|
||||
const names = {}; for (const t of (tr.data?.data || [])) names[t.technician_id] = t.full_name || t.technician_id
|
||||
const board = Object.keys(counts).map(k => ({ tech: k, tech_name: names[k] || k, completed: counts[k] })).sort((a, b) => b.completed - a.completed)
|
||||
return json(res, 200, { from, to, board })
|
||||
} catch (e) { log('dispatch leaderboard error: ' + e.message); return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
||||
// POST /dispatch/best-tech — find optimal tech for a job location
|
||||
if (sub === 'best-tech' && method === 'POST') {
|
||||
try {
|
||||
|
|
|
|||
76
services/targo-hub/lib/inbox-triage.js
Normal file
76
services/targo-hub/lib/inbox-triage.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// ── Triage IA d'un courriel entrant (Phase 3 — Inbox→Ticket→Task) ──
|
||||
// classifyEmail({from,subject,body}) : client connu ? type ? faut-il suggérer un ticket ?
|
||||
// SANS dépendance à la boîte mail (testable seul + réutilisable par le poller une fois la boîte branchée).
|
||||
const cfg = require('./config')
|
||||
const { log } = require('./helpers')
|
||||
const erp = require('./erp')
|
||||
|
||||
function extractEmail (from) {
|
||||
const m = /<([^>]+)>/.exec(from || '')
|
||||
const raw = (m ? m[1] : String(from || '')).trim().toLowerCase()
|
||||
return /.+@.+\..+/.test(raw) ? raw : ''
|
||||
}
|
||||
|
||||
// Match client DÉTERMINISTE par courriel (email_id, puis email_billing qui peut contenir plusieurs adresses).
|
||||
async function matchCustomer (email) {
|
||||
if (!email) return null
|
||||
try {
|
||||
let r = await erp.list('Customer', { filters: [['email_id', '=', email]], fields: ['name', 'customer_name'], limit: 1 })
|
||||
if (r && r[0]) return r[0]
|
||||
r = await erp.list('Customer', { filters: [['email_billing', 'like', '%' + email + '%']], fields: ['name', 'customer_name'], limit: 1 })
|
||||
if (r && r[0]) return r[0]
|
||||
} catch (e) { log('triage matchCustomer error:', e.message) }
|
||||
return null
|
||||
}
|
||||
|
||||
// Triage courriel = PII (contenu client) + gros volume → routé par tâche 'triage' (bascule possible vers le modèle LOCAL).
|
||||
// reasoningEffort:'none' : sans ça, gemini-2.5-flash « pense » et ses jetons mangent maxTokens → sur un long courriel, le JSON est tronqué, JSON.parse échoue et on retombe SILENCIEUSEMENT sur les heuristiques regex (triage IA inopérant). Le triage n'a pas besoin de raisonnement.
|
||||
const aiChat = async (messages) => require('./ai').chat({ task: 'triage', maxTokens: 300, temperature: 0, reasoningEffort: 'none', messages })
|
||||
|
||||
const { TYPES, CAT } = require('./categories') // taxonomie = SOURCE UNIQUE (voir lib/categories.js)
|
||||
// Heuristiques de repli (regex → type) si l'IA est indisponible/illisible. Propre au triage (pas dans la taxonomie partagée).
|
||||
const HEUR = [
|
||||
{ t: 'facturation', re: /factur|paiement|invoice|solde|rembours|prélèv|carte de cr/i },
|
||||
{ t: 'vente', re: /nouveau client|abonn|forfait|devis|soumission|s'abonner|disponib|couvertur|d[ée]m[ée]nage|prix/i },
|
||||
{ t: 'installation', re: /installation|rendez-?vous|technicien|raccord|branch/i },
|
||||
{ t: 'support', re: /lent|panne|coup[ée]|ne fonctionne|probl[èe]me|pixel|wifi|internet|connexion|signal|bug|hors service/i },
|
||||
]
|
||||
function heuristicType (text) { for (const h of HEUR) if (h.re.test(text)) return h.t; return '' }
|
||||
|
||||
async function classifyEmail ({ from, subject, body } = {}) {
|
||||
const email = extractEmail(from)
|
||||
const customer = await matchCustomer(email)
|
||||
const text = `${subject || ''}\n${String(body || '').slice(0, 1500)}`
|
||||
let type = '', suggestTicket = true, suggestedTitle = String(subject || '').slice(0, 120), serviceAffected = '', reason = ''
|
||||
if (cfg.AI_API_KEY) {
|
||||
try {
|
||||
const sys = 'Tu tries les courriels entrants d\'un fournisseur Internet/télécom (TARGO / Gigafibre). Réponds UNIQUEMENT en JSON, sans texte autour : {"type":"support|facturation|vente|installation|autre","service_affected":"internet|tv|telephone|aucun","suggest_ticket":true|false,"title":"titre court actionnable en français","reason":"une phrase"}. Mets suggest_ticket=false si c\'est un pourriel, une pub, une réponse automatique, une notification no-reply, ou tout courriel SANS action requise.'
|
||||
const out = await aiChat([{ role: 'system', content: sys }, { role: 'user', content: text }])
|
||||
const j = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0])
|
||||
if (TYPES.includes(j.type)) type = j.type
|
||||
if (typeof j.suggest_ticket === 'boolean') suggestTicket = j.suggest_ticket
|
||||
if (j.title) suggestedTitle = String(j.title).slice(0, 120)
|
||||
serviceAffected = j.service_affected || ''
|
||||
reason = j.reason || ''
|
||||
} catch (e) { log('classifyEmail AI error:', e.message) }
|
||||
}
|
||||
if (!type) type = heuristicType(text) || 'autre'
|
||||
// BRUIT : no-reply / pourriel / notifications automatiques / infolettres → pas de ticket, pas de notif, masqué par défaut.
|
||||
const noise = /no-?reply|noreply|do-?not-?reply|mailer-daemon|postmaster|unsubscribe|d[ée]sabonn|notification@|newsletter|infolettre|bounce|via google|automated|calendar-notification|@docs\.google|@drive\.google/i.test(`${from} ${subject}`)
|
||||
if (noise) suggestTicket = false
|
||||
return {
|
||||
email,
|
||||
is_customer: !!customer,
|
||||
customer: customer ? customer.name : null,
|
||||
customer_name: customer ? (customer.customer_name || customer.name) : null,
|
||||
type,
|
||||
category: CAT[type] || 'Autre',
|
||||
service_affected: serviceAffected,
|
||||
suggest_ticket: suggestTicket,
|
||||
suggested_title: suggestedTitle,
|
||||
reason,
|
||||
noise,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { classifyEmail, matchCustomer, extractEmail }
|
||||
Loading…
Reference in New Issue
Block a user