gigafibre-fsm/apps/ops/src/components/shared/MessageList.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

540 lines
40 KiB
Vue

<template>
<div class="mlist" :class="{ 'mlist-compact': compact }">
<div class="mlist-head">
<q-btn unelevated no-caps rounded icon="edit" label="Composer" class="mlist-compose" @click="openCompose">
<q-tooltip>Nouveau message (courriel par défaut, ou texto)</q-tooltip>
</q-btn>
<q-btn-toggle v-model="chanView" :options="chanOptions" dense unelevated no-caps toggle-color="primary" color="grey-2" text-color="grey-7" class="mlist-chanseg" />
<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 ? 'primary' : '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="refreshing || loading" @click="refreshNow"><q-tooltip>Rafraîchir — chercher les nouveaux courriels maintenant</q-tooltip></q-btn>
<!-- Admin : voir la Boîte (vue/filtres) comme un autre utilisateur, en lecture seule -->
<q-btn v-if="isAdmin" flat dense round icon="visibility" :color="impersonating ? 'deep-orange-6' : 'grey-6'" size="sm" @click="openViewAs">
<q-tooltip>Voir la Boîte comme un autre utilisateur (admin · lecture seule)</q-tooltip>
<q-menu>
<q-list dense style="min-width:230px">
<q-item-label header class="q-py-xs">Voir comme… (lecture seule)</q-item-label>
<q-item v-for="a in agentList" :key="a" clickable v-close-popup @click="viewAs(a)" :active="a === impersonating" active-class="bg-deep-orange-1">
<q-item-section avatar><q-avatar size="22px" color="blue-grey-5" text-color="white" style="font-size:10px">{{ agentInitials(a) }}</q-avatar></q-item-section>
<q-item-section>{{ agentName(a) }}</q-item-section>
</q-item>
<q-item v-if="!agentList.length"><q-item-section class="text-grey-5 text-caption">Chargement…</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<!-- Bandeau lecture seule pendant l'impersonation admin -->
<div v-if="impersonating" class="mlist-impersonate row items-center no-wrap">
<q-icon name="visibility" size="15px" class="q-mr-xs" />
<span>Vue de <b>{{ agentName(impersonating) }}</b> — lecture seule (vos préférences ne changent pas)</span>
<q-space />
<q-btn flat dense size="sm" no-caps icon="logout" label="Quitter" color="white" @click="exitViewAs" />
</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">
<!-- Mode TICKETS : critères de filtre obligatoires (statut + assignés à moi) -->
<template v-if="ticketMode">
<q-icon name="confirmation_number" color="positive" size="16px" class="q-ml-xs q-mr-sm" />
<q-btn-toggle v-model="ticketStatus" :options="TStatusOpts" dense unelevated no-caps toggle-color="primary" color="grey-2" text-color="grey-7" size="sm" />
<q-chip dense clickable size="sm" class="q-ml-sm" :color="ticketMine ? 'primary' : 'grey-3'" :text-color="ticketMine ? 'white' : 'grey-8'" @click="ticketMine = !ticketMine">À moi<span v-if="ticketMineCount" class="mlist-cnt">{{ ticketMineCount }}</span><q-tooltip>Tickets qui me sont assignés (ERPNext)</q-tooltip></q-chip>
<q-space />
<span class="text-caption text-grey-6">{{ shownTickets.length }} ticket(s)</span>
</template>
<template v-else>
<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="positive" 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="negative" 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 ? 'primary' : '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) ? 'primary' : '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>
</template>
</div>
<div class="mlist-body">
<!-- Mode TICKETS : liste des tickets ERPNext (Issues) filtrés -->
<template v-if="ticketMode">
<div v-for="t in shownTickets" :key="t.name" class="mrow mrow-ticket" @click="openTicket(t)">
<q-icon name="confirmation_number" color="positive" size="17px" class="mrow-chan" />
<span class="mrow-name">{{ t.customer || '—' }}</span>
<q-badge class="mrow-tag" :color="t.status === 'Open' ? 'orange-1' : 'blue-1'" :text-color="t.status === 'Open' ? 'orange-9' : 'blue-9'">{{ t.status === 'Open' ? 'Ouvert' : t.status === 'Replied' ? 'Répondu' : t.status }}</q-badge>
<div class="mrow-subject"><span class="mrow-subj-strong">{{ t.subject || t.name }}</span><span class="mrow-snip"> — {{ t.name }}</span></div>
<div class="mrow-right">
<q-icon v-if="t.convToken" name="forum" size="13px" color="green-5" class="mrow-rep"><q-tooltip>Fil de conversation lié — clic pour l'ouvrir</q-tooltip></q-icon>
<q-badge v-if="t.priority && t.priority !== 'Medium'" class="mrow-tag" :color="['High', 'Urgent'].includes(t.priority) ? 'red-2' : 'grey-3'" :text-color="['High', 'Urgent'].includes(t.priority) ? 'red-9' : 'grey-8'">{{ t.priority }}</q-badge>
<span class="mrow-time">{{ relTime(t.modified) }}</span>
</div>
</div>
<div v-if="!shownTickets.length" class="mlist-empty">Aucun ticket{{ ticketStatus !== 'all' || ticketMine || search ? ' (filtre actif)' : '' }}</div>
</template>
<!-- Mode CONVERSATIONS (défaut) -->
<template v-else>
<!-- RÉSULTATS DE RECHERCHE FLOUE (SQL pg_trgm) : nom/courriel/sujet + CORPS DES MESSAGES, typo-tolérant, sur TOUTES les convs (même archivées) -->
<template v-if="searchResults !== null">
<div v-for="r in searchResults" :key="'s:' + r.token" class="mrow" @click="openSearchResult(r)">
<q-icon v-if="r.channel" :name="channelMeta(r.channel).icon" :color="channelMeta(r.channel).color" size="16px" class="mrow-chan" />
<span class="mrow-name">{{ r.customerName || r.email || 'Inconnu' }}</span>
<q-badge v-if="r.queue" class="mrow-tag" color="blue-grey-1" text-color="blue-grey-8">{{ qLabel(r.queue) }}</q-badge>
<q-badge v-if="r.status && r.status !== 'active'" class="mrow-tag" color="grey-3" text-color="grey-7">archivé</q-badge>
<div class="mrow-subject"><span v-if="r.subject" class="mrow-subj-strong">{{ r.subject }}</span><span class="mrow-snip">{{ r.subject && r.snippet ? ' — ' : '' }}{{ r.snippet }}</span></div>
<div class="mrow-right"><span class="mrow-time">{{ relTime(r.lastActivity) }}</span></div>
</div>
<div v-if="searchBusy" class="mlist-empty">Recherche…</div>
<div v-else-if="!searchResults.length" class="mlist-empty">Aucun résultat pour « {{ search }} »</div>
</template>
<template v-else>
<div v-for="(d, idx) 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="onRowCheck($event, idx, d.id)" />
<!-- Drapeau de PRIORITÉ (style ClickUp) : coloré si défini, discret sinon ; clic = menu rapide (triage depuis la boîte) -->
<q-btn flat dense round size="xs" class="mrow-flag" :class="{ 'mrow-flag-none': !d.priority }" :icon="priorityMeta(d.priority).icon" :color="priorityMeta(d.priority).color" @click.stop>
<q-tooltip v-if="d.priority">Priorité : {{ priorityMeta(d.priority).label }}</q-tooltip>
<q-menu auto-close anchor="bottom left" self="top left">
<q-list dense style="min-width:168px">
<q-item-label header class="q-py-xs">Priorité</q-item-label>
<q-item v-for="lvl in PRIORITY_LEVELS" :key="lvl" clickable @click="setRowPriority(d, lvl)" :active="d.priority === lvl" active-class="bg-green-1">
<q-item-section avatar><q-icon name="flag" :color="priorityMeta(lvl).color" size="18px" /></q-item-section>
<q-item-section>{{ priorityMeta(lvl).label }}</q-item-section>
</q-item>
<q-separator />
<q-item clickable @click="setRowPriority(d, '')"><q-item-section avatar><q-icon name="outlined_flag" color="grey-5" size="18px" /></q-item-section><q-item-section class="text-grey-7">Aucune</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<!-- 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" :name="channelMeta(d.channel).icon" :color="channelMeta(d.channel).color" size="16px" class="mrow-chan"><q-tooltip>{{ channelMeta(d.channel).label }}</q-tooltip></q-icon>
<!-- 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="positive" 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="green-5" class="mrow-rep"><q-tooltip>Un collègue a répondu</q-tooltip></q-icon>
<q-avatar v-if="draftAgentOf(d)" size="19px" color="green-5" text-color="white" class="q-mr-xs mrow-draft">
<span style="font-size:9px;font-weight:700">{{ agentInitials(draftAgentOf(d)) }}</span>
<q-tooltip>✎ Brouillon en cours — {{ agentName(draftAgentOf(d)) }}</q-tooltip>
</q-avatar>
<ReaderStack :readers="readersOf(d)" :me="meEmail" :max="3" :size="17" />
<span class="mrow-time">{{ relTime(lastTs(d)) }}</span>
</div>
<!-- Barre d'actions au survol RETIRÉE : elle cachait l'avatar de présence/lecteurs à droite. Répondre/ticket/archiver/supprimer
se font dans le fil ouvert (clic sur la ligne) + la barre de lot (cases à cocher). Le filtre « masquer comme ceci » est dans le détail du fil. -->
</div>
<div v-if="!shown.length" class="mlist-empty">Aucune conversation{{ sel.size || search || chanView !== 'all' ? ' (filtre actif)' : '' }}</div>
</template>
</template>
</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="primary" :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="primary" 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="primary" 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, watch } from 'vue'
import { useQuasar } from 'quasar'
import { useConversations } from 'src/composables/useConversations'
import { useUserPrefs } from 'src/composables/useUserPrefs'
import { usePermissions } from 'src/composables/usePermissions'
import { HUB_URL } from 'src/config/hub'
import { useAuthStore } from 'src/stores/auth'
import { relTime } from 'src/composables/useFormatters'
import { channelMeta, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay'
import { useRouter } from 'vue-router'
import ReaderStack from './ReaderStack.vue'
const $q = useQuasar()
const router = useRouter()
const { discussions, tickets, fetchTickets, QUEUES, fetchList, pollNow, loading, openDiscussion, panelOpen, newDialogOpen, newDialogChannel, agentTyping, sharedDraft, bulkDelete, bulkArchive, createTicket, archiveDiscussion, notifyEnabled, requestNotifyPermission, fetchInboxRules, saveInboxRules, searchConversations, setPriority } = useConversations()
async function setRowPriority (d, p) { try { await setPriority(d.token, p); d.priority = p } catch (e) { /* */ } }
// Rafraîchir à la demande = forcer un pull Gmail immédiat puis recharger la liste (≠ simple relecture).
const refreshing = ref(false)
async function refreshNow () { refreshing.value = true; try { await pollNow() } finally { await fetchList(); refreshing.value = false } }
// « 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 } })
// ── Brouillons : qui rédige une réponse pour ce client (live SSE OU brouillon persisté) → pastille avatar ──
function draftAgentOf (d) {
for (const c of (d.conversations || [])) { const a = agentTyping.value[c.token]; if (a) return a } // tape en ce moment
for (const c of (d.conversations || [])) { const sd = sharedDraft.value[c.token]; if (sd && sd.agent) return sd.agent } // brouillon mirroré (live)
return d.draftAgent || null // brouillon persisté (au chargement)
}
function agentInitials (a) {
const parts = String(a || '').split('@')[0].replace(/[._-]+/g, ' ').trim().split(/\s+/).filter(Boolean)
return ((parts[0] || '?')[0] + (parts[1] ? parts[1][0] : (parts[0] || '?').slice(1, 2))).toUpperCase()
}
function agentName (a) { return String(a || '').split('@')[0].replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }
const sel = ref(new Set()) // filtres cumulables (départements OU entre eux ; unread/mine = ET)
const search = ref('')
// Recherche FLOUE omnichannel (SQL pg_trgm côté hub) : nom/courriel/sujet + corps des messages, sur TOUTES les convs.
const searchResults = ref(null) // null = pas de recherche active → liste normale ; tableau = résultats SQL
const searchBusy = ref(false)
let _searchSeq = 0
watch(search, async (v) => { // l'input est déjà debounce 200ms
const q = String(v || '').trim()
if (q.length < 2) { searchResults.value = null; searchBusy.value = false; return }
const seq = ++_searchSeq
searchBusy.value = true
const r = await searchConversations(q)
if (seq !== _searchSeq) return // frappe plus récente → résultat périmé ignoré
searchResults.value = r; searchBusy.value = false
})
function openSearchResult (r) { if (r && r.token) router.push('/communications/c/' + r.token) } // peut ouvrir une conv archivée non chargée
// ── Vue par canal (style Gmail « Mail / Chat ») : Tous (défaut) · Courriel · Clavardage (SMS + chat web temps réel) ──
const chanView = ref('all')
const CHAT_CH = ['sms', 'webchat', '3cx', 'facebook']
const chanOptions = [
{ value: 'all', label: 'Tous', icon: 'all_inbox' },
{ value: 'email', label: 'Courriel', icon: 'mail' },
{ value: 'chat', label: 'Clavardage', icon: 'forum' },
{ value: 'tickets', label: 'Tickets', icon: 'confirmation_number' },
]
const ticketMode = computed(() => chanView.value === 'tickets')
// Composer (style Gmail) : ouvre la fenêtre de composition ; défaut = canal de la vue courante (Clavardage → texto).
function openCompose () { newDialogChannel.value = 'email'; newDialogOpen.value = true } // Compose = courriel par défaut ; SMS = choix secondaire dans la fenêtre
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)
// ── Vue TICKETS dans la Boîte (ERPNext Issues) — affichée seulement en mode « Tickets », avec critères de filtre OBLIGATOIRES (statut + assignés à moi + recherche) pour ne pas noyer la file ──
const ticketStatus = ref('open') // 'open' | 'replied' | 'all'
const ticketMine = ref(false)
const TStatusOpts = [{ value: 'open', label: 'Ouverts' }, { value: 'replied', label: 'Répondus' }, { value: 'all', label: 'Tous' }]
const ticketMineCount = computed(() => { const me = String(meEmail.value || '').toLowerCase(); return (tickets.value || []).filter(t => (t.assignees || []).map(x => String(x).toLowerCase()).includes(me)).length })
const shownTickets = computed(() => {
const q = search.value.trim().toLowerCase()
const me = String(meEmail.value || '').toLowerCase()
return (tickets.value || []).filter(t => {
if (ticketStatus.value === 'open' && t.status !== 'Open') return false
if (ticketStatus.value === 'replied' && t.status !== 'Replied') return false
if (ticketMine.value && !(t.assignees || []).map(x => String(x).toLowerCase()).includes(me)) return false
if (q && ![t.name, t.subject, t.customer].some(x => String(x || '').toLowerCase().includes(q))) return false
return true
}) // déjà trié « modified desc » par le hub
})
function openTicket (t) {
if (t.convToken) { const d = (discussions.value || []).find(x => (x.conversations || []).some(c => c.token === t.convToken) || x.token === t.convToken); if (d) { open(d); return } router.push('/communications/c/' + t.convToken); return }
if (t.customer) { router.push('/clients/' + encodeURIComponent(t.customer)); return }
$q.notify({ type: 'info', message: t.name + (t.subject ? ' — ' + t.subject : '') })
}
watch(() => chanView.value === 'tickets', (on) => { if (on) fetchTickets() }) // (re)charge les tickets en entrant dans la vue
// ── MOTEUR DE PRÉFÉRENCES par utilisateur (serveur, suit l'usager entre appareils + lisible par l'admin) : vue/filtres/densité de la Boîte persistés ──
const impersonating = ref('') // PHASE 3 — courriel de l'utilisateur dont on regarde la Boîte ('' = soi-même). Déclaré AVANT les watch (le watch immédiat le lit → éviter le TDZ).
const { prefs: inboxPrefs, save: saveInbox } = useUserPrefs('inbox', { chanView: 'all', filters: [], compact: false, ticketStatus: 'open', ticketMine: false })
let _lastSaved = ''
function _prefSig () { return JSON.stringify({ chanView: chanView.value, filters: [...sel.value].sort(), compact: compact.value, ticketStatus: ticketStatus.value, ticketMine: ticketMine.value }) }
function applyInboxPrefs (p) { // applique un jeu de préférences à l'état local (les miennes OU celles d'un autre en mode « voir comme »)
if (!p) return
chanView.value = p.chanView || 'all'
sel.value = new Set(Array.isArray(p.filters) ? p.filters : [])
compact.value = !!p.compact
ticketStatus.value = p.ticketStatus || 'open'
ticketMine.value = !!p.ticketMine
}
watch(inboxPrefs, (p) => { // localStorage (instantané) puis serveur → applique à l'état local
if (impersonating.value) return // en mode « voir comme » : ne PAS écraser la vue avec mes propres prefs
applyInboxPrefs(p)
_lastSaved = _prefSig() // évite de re-sauver ce qu'on vient d'appliquer (anti-écho)
}, { immediate: true, deep: true })
watch([chanView, sel, compact, ticketStatus, ticketMine], () => {
if (impersonating.value) return // LECTURE SEULE pendant l'impersonation : on n'enregistre rien
const sig = _prefSig(); if (sig === _lastSaved) return
_lastSaved = sig
saveInbox({ chanView: chanView.value, filters: [...sel.value], compact: compact.value, ticketStatus: ticketStatus.value, ticketMine: ticketMine.value })
}, { deep: true })
// ── PHASE 3 — ADMIN : « Voir comme… » un autre utilisateur (lecture seule). Le serveur (/prefs?as=) n'autorise QUE les admins (x-authentik-groups) ; sinon il renvoie nos propres prefs (repli sûr). ──
const { isAdmin } = usePermissions()
const agentList = ref([])
async function openViewAs () { if (!agentList.value.length) agentList.value = await fetchAgents() }
async function viewAs (email) {
try {
const r = await fetch(`${HUB_URL}/prefs?as=${encodeURIComponent(email)}`).then(x => x.ok ? x.json() : null)
if (!r) throw new Error('chargement échoué')
impersonating.value = email // active la lecture seule AVANT d'appliquer (bloque la sauvegarde)
applyInboxPrefs((r.prefs && r.prefs.inbox) || {})
if (chanView.value === 'tickets') fetchTickets()
$q.notify({ type: 'info', icon: 'visibility', message: 'Vue de ' + agentName(email) + ' — lecture seule' })
} catch (e) { impersonating.value = ''; $q.notify({ type: 'negative', message: 'Voir comme : ' + e.message }) }
}
async function exitViewAs () {
impersonating.value = ''
try { const r = await fetch(`${HUB_URL}/prefs`).then(x => x.ok ? x.json() : null); applyInboxPrefs((r && r.prefs && r.prefs.inbox) || {}); _lastSaved = _prefSig() } catch (e) { /* on garde l'état courant */ }
$q.notify({ type: 'positive', message: 'Retour à vos préférences' })
}
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 },
]
const prioN = visible.value.filter(d => d.priority === 'urgent' || d.priority === 'high').length
if (prioN) base.push({ key: 'prio', label: '🚩 Prioritaires', count: prioN })
const draftN = visible.value.filter(draftAgentOf).length
if (draftN) base.push({ key: 'draft', label: '✎ Brouillons', count: draftN })
for (const q of QUEUES) base.push({ key: q, label: qLabel(q), count: visible.value.filter(d => (d.queue || '') === q).length })
// (Filtre par canal = segment « Tous / Courriel / Clavardage » de l'en-tête, style Gmail.)
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 needU = sel.value.has('unread'); const needM = sel.value.has('mine'); const needD = sel.value.has('draft'); const needPrio = sel.value.has('prio')
return visible.value.filter(d => {
if (depts.length && !depts.includes(d.queue || '')) return false
const isChat = CHAT_CH.includes(d.channel || '')
if (chanView.value === 'email' && isChat) return false
if (chanView.value === 'chat' && !isChat) return false
if (needD && !draftAgentOf(d)) return false
if (needU && !isUnread(d)) return false
if (needM && d.assignee !== meEmail.value) return false
if (needPrio && !(d.priority === 'urgent' || d.priority === 'high')) return false
if (q && !matchSearch(d, q)) return false
return true
// Tri : priorité d'abord (Urgent → Haute → Normale → Basse → aucune), puis récence dans chaque bande.
}).sort((a, b) => (priorityMeta(b.priority).rank - priorityMeta(a.priority).rank) || String(lastTs(b) || '').localeCompare(String(lastTs(a) || '')))
})
// Sélection par plage (style Gmail) : cocher une case, puis SHIFT+clic sur une
// autre case sélectionne TOUTES les lignes entre les deux. Sinon, bascule simple.
// `lastIdx` = ancre (dernière case cliquée sans shift).
const lastIdx = ref(null)
function onRowCheck (e, idx, id) {
const s = new Set(checked.value)
if (e && e.shiftKey && lastIdx.value !== null && lastIdx.value !== idx) {
const a = Math.min(lastIdx.value, idx)
const b = Math.max(lastIdx.value, idx)
for (let i = a; i <= b; i++) { const row = shown.value[i]; if (row) s.add(row.id) }
if (window.getSelection) { try { window.getSelection().removeAllRanges() } catch (_) { /* évite la sélection de texte au shift-clic */ } }
} else {
s.has(id) ? s.delete(id) : s.add(id)
}
checked.value = s
lastIdx.value = idx
}
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: 'primary' },
{ 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) } }
function openFiche (d) { if (d && d.customer) router.push('/clients/' + encodeURIComponent(d.customer)) } // lien direct liste → fiche client OPS 360
onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList() })
</script>
<style scoped>
/* Pleine hauteur et PLEINE LARGEUR : le panneau de messages est maintenant une
fenêtre flottante (plus de volet ancré à droite), donc la boîte occupe tout l'espace. */
.mlist { display: flex; flex-direction: column; height: calc(100vh - 50px); width: 100%; }
.mlist-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px 4px; }
.mlist-impersonate { background: #f4511e; color: #fff; font-size: 0.8rem; padding: 5px 12px; gap: 4px; }
.mlist-impersonate b { font-weight: 700; }
.mlist-search { flex: 1; max-width: 520px; }
.mlist-compose { font-weight: 600; padding: 5px 18px; flex: 0 0 auto; background: #00C853 !important; color: #fff !important; }
.mlist-chanseg { border: 1px solid #e2e8f0; border-radius: 999px; overflow: hidden; flex: 0 0 auto; }
/* 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; }
/* Mobile : la barre d'outils s'empile — recherche pleine largeur sous Composer + canal (les chips défilent déjà). */
@media (max-width: 599px) {
.mlist-head { flex-wrap: wrap; padding: 8px 8px 4px; }
.mlist-search { flex: 1 1 100%; order: 10; max-width: none; }
}
.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-flag { flex: 0 0 auto; transition: opacity .15s; }
.mrow-flag-none { opacity: .2; } /* aucune priorité → drapeau discret (ne pollue pas la liste) */
.mrow:hover .mrow-flag-none { opacity: .55; } /* au survol → visible pour classer en 1 clic */
.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>