gigafibre-fsm/apps/ops/src/components/shared/ConversationPanel.vue
louispaulb 453bef006c fix(ops): sécurité + robustesse dispatch/comms
- Sécurité : sanitize DOMPurify sur tout HTML entrant courriel/osTicket (v-html) →
  ferme le vecteur XSS (ConversationPanel + IssueDetail). Nouveau src/utils/sanitize.js,
  dompurify ajouté en dépendance directe.
- Fix : piège TDZ (watches de la carte des tournées placés avant les const suggestDlg/
  suggestMapDay/suggestGroups) qui cassait le montage de PlanificationPage → déplacés
  après les définitions.
- Finition : étiquette de fenêtre du dispatch auto « Aujourd'hui + Demain » (au lieu de l'ISO).

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

2644 lines
202 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="inline || panelOpen" :class="['conv-panel', { 'conv-fullpage': inline, 'conv-float': !inline }]" :style="!inline ? floatStyle : undefined">
<!-- List view (discussions) — masquée en mode pleine page (on n'y montre que le fil) -->
<template v-if="!activeDiscussion && !inline">
<q-toolbar class="conv-toolbar" @pointerdown="startDrag">
<q-toolbar-title class="text-weight-bold" style="font-size:0.95rem">
Inbox
<q-badge v-if="activeCount" color="primary" class="q-ml-xs">{{ activeCount }}</q-badge>
</q-toolbar-title>
<q-btn flat round dense :icon="showAll ? 'filter_alt_off' : 'filter_alt'" size="sm"
:color="showAll ? 'primary' : 'grey-6'" @click="showAll = !showAll; fetchList()">
<q-tooltip>{{ showAll ? 'Actives seulement' : 'Voir tout' }}</q-tooltip>
</q-btn>
<q-btn flat round dense icon="add" size="sm" @click="newDialogOpen = true">
<q-tooltip>Nouveau texto</q-tooltip>
</q-btn>
<q-btn flat round dense icon="refresh" size="sm" :loading="loading" @click="refreshAll" />
<q-btn flat round dense icon="close" size="sm" @click="panelOpen = false" />
</q-toolbar>
<!-- Filtre inbox (ce qu'on VOIT) — couleur NEUTRE (blue-grey) : distincte du compose (indigo « Répondre » / teal « canal ») pour ne pas confondre filtrer vs composer. -->
<q-btn-toggle v-model="inboxFilter" spread no-caps unelevated dense class="inbox-filter"
toggle-color="blue-grey-7" color="grey-1" text-color="grey-8"
:options="[
{ value: 'all', label: 'Tout' },
{ value: 'conv', slot: 'conv' },
{ value: 'tickets', slot: 'tickets' },
]">
<template #conv><q-icon name="chat" size="16px" /> <span class="q-ml-xs">{{ discussions.length }}</span></template>
<template #tickets><q-icon name="confirmation_number" size="16px" /> <span class="q-ml-xs">{{ tickets.length }}</span></template>
</q-btn-toggle>
<div v-if="noiseCount" class="noise-toggle">
<q-btn flat dense size="sm" no-caps :icon="hideNoise ? 'visibility_off' : 'visibility'" :color="hideNoise ? 'grey-6' : 'orange-7'" :label="hideNoise ? (noiseCount + ' masqué' + (noiseCount > 1 ? 's' : '') + ' (bruit)') : 'Masquer le bruit'" @click="hideNoise = !hideNoise" />
</div>
<!-- Bulk action bar -->
<div v-if="selectedIds.size > 0" class="bulk-bar">
<q-checkbox :model-value="selectedIds.size === discussions.length" @update:model-value="v => v ? selectAll() : clearSelection()"
dense size="sm" class="q-mr-xs" :indeterminate-value="true"
:true-value="true" :false-value="false" />
<span class="text-caption text-weight-medium">{{ selectedIds.size }} selectionne{{ selectedIds.size > 1 ? 's' : '' }}</span>
<q-space />
<q-btn flat dense size="sm" icon="archive" color="primary" :loading="archiving" @click="doBulkArchive">
<q-tooltip>Archiver en tickets</q-tooltip>
</q-btn>
<q-btn flat dense size="sm" icon="delete_outline" color="negative" :loading="bulkDeleting" @click="doBulkDelete">
<q-tooltip>Supprimer</q-tooltip>
</q-btn>
<q-btn flat dense size="sm" icon="close" color="grey-6" @click="clearSelection" />
</div>
<q-list v-if="discussions.length && inboxFilter !== 'tickets'" separator class="disc-list">
<q-item v-for="d in shownDiscussions" :key="d.id" clickable
:class="{ 'bg-blue-1': d.lastMessage?.from === 'customer' && d.status === 'active', 'bg-green-1': selectedIds.has(d.id) }">
<q-item-section avatar style="min-width:auto;padding-right:4px">
<q-checkbox :model-value="selectedIds.has(d.id)" @update:model-value="toggleSelect(d.id)"
dense size="sm" @click.stop />
</q-item-section>
<q-item-section @click="openDiscussion(d)">
<q-item-label class="text-weight-medium" style="font-size:0.85rem">
{{ d.customerName || d.phone }}
<q-badge v-if="d.status === 'active'" color="green" class="q-ml-xs" style="font-size:0.55rem;padding:1px 4px;">actif</q-badge>
</q-item-label>
<q-item-label caption lines="1">
<span v-if="typingLabelFor(d)" class="text-positive text-weight-medium"><q-icon name="edit" size="12px" /> {{ typingLabelFor(d) }} écrit…</span>
<span v-else-if="d.lastMessage" :class="{ 'text-weight-medium': d.lastMessage.from === 'customer' }">
<template v-if="d.lastMessage.from === 'customer'">Client: </template>
<template v-else-if="d.lastMessage.from === 'agent' && d.lastMessage.via === 'ai'">AI: </template>
<q-icon v-if="d.lastMessage.media" name="image" size="13px" class="q-mr-xs" />{{ d.lastMessage.text || (d.lastMessage.media ? 'Image' : '') }}
</span>
<span v-else>Aucun message</span>
</q-item-label>
</q-item-section>
<q-item-section side top @click="openDiscussion(d)">
<q-item-label caption>{{ formatDate(d.date) }}</q-item-label>
<q-badge :color="d.status === 'active' ? 'green' : 'grey-4'" :text-color="d.status === 'active' ? 'white' : 'grey-6'"
style="font-size:0.6rem;padding:1px 4px;">
{{ d.messageCount }} msg
</q-badge>
</q-item-section>
</q-item>
</q-list>
<div v-else-if="!loading && inboxFilter !== 'tickets'" class="q-pa-lg text-center text-grey-5">
<q-icon name="chat_bubble_outline" size="40px" class="q-mb-sm" /><br>
Aucune discussion
</div>
<!-- TICKETS ouverts (Issues ERPNext) — partie « ticket » de l'inbox unifié -->
<q-list v-if="inboxFilter !== 'conv' && tickets.length" separator class="disc-list">
<q-item-label header class="text-grey-7" style="font-size:0.7rem;min-height:auto;padding:6px 12px 2px">🎫 Tickets ouverts ({{ tickets.length }})</q-item-label>
<q-item v-for="t in tickets" :key="t.name" clickable @click="openTicket(t.name)">
<q-item-section avatar style="min-width:auto;padding-right:6px">
<q-icon name="confirmation_number" :color="ticketColor(t)" size="20px" />
</q-item-section>
<q-item-section>
<q-item-label class="text-weight-medium" style="font-size:0.85rem" lines="2">{{ ticketTitle(t) }}</q-item-label>
<q-item-label caption lines="1">
<q-badge v-if="ticketCategory(t)" dense color="green-1" text-color="green-9" class="q-mr-xs" style="font-size:0.6rem">{{ ticketCategory(t) }}</q-badge>
<span class="text-grey-6">{{ t.customer || '—' }}</span>
</q-item-label>
</q-item-section>
<q-item-section side top>
<q-item-label caption>{{ formatDate(t.modified) }}</q-item-label>
<q-badge :color="ticketColor(t)" style="font-size:0.6rem;padding:1px 4px;">{{ t.status }}</q-badge>
</q-item-section>
</q-item>
</q-list>
<div v-else-if="!loading && inboxFilter === 'tickets' && !tickets.length" class="q-pa-lg text-center text-grey-5">
<q-icon name="confirmation_number" size="40px" class="q-mb-sm" /><br>
Aucun ticket ouvert
</div>
</template>
<!-- Discussion detail: merged message thread -->
<template v-else-if="activeDiscussion">
<q-toolbar class="conv-toolbar" @pointerdown="startDrag">
<q-btn v-if="!inline" flat round dense icon="arrow_back" size="sm" @click="goBack" />
<!-- Sujet DÉPLACÉ dans le scroll (toute la discussion défile, style Gmail) : la barre sticky ne garde QUE les icônes d'options du fil. -->
<q-space />
<!-- PRIORITÉ (style ClickUp) : drapeau coloré + menu rapide -->
<q-btn flat dense round size="sm" :icon="priorityMeta(convPriority).icon" :color="priorityMeta(convPriority).color">
<q-tooltip>Priorité : {{ priorityMeta(convPriority).label }}</q-tooltip>
<q-menu auto-close anchor="bottom right" self="top right">
<q-list dense style="min-width:186px">
<q-item-label header class="q-py-xs">Priorité</q-item-label>
<q-item v-for="lvl in PRIORITY_LEVELS" :key="lvl" clickable @click="setConvPriority(lvl)" :active="convPriority === 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-section side v-if="convPriority === lvl"><q-icon name="check" color="primary" size="16px" /></q-item-section>
</q-item>
<q-separator />
<q-item clickable @click="setConvPriority('')" :active="!convPriority"><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>
<q-btn v-if="(activeDiscussion.messages || []).length > 1" flat dense size="sm" :icon="allExpanded ? 'unfold_less' : 'unfold_more'" color="grey-7" @click="toggleExpandAll">
<q-tooltip>{{ allExpanded ? 'Tout replier' : 'Tout déplier' }}</q-tooltip>
</q-btn>
<q-btn flat dense size="sm" icon="archive" color="primary" :loading="archiving" @click="doArchive(activeDiscussion)">
<q-tooltip>Archiver (ferme + retire la discussion)</q-tooltip>
</q-btn>
<q-btn v-if="convPhone" flat dense size="sm" icon="call" color="green-7" @click="callConv">
<q-tooltip>Appeler {{ convPhone }} (softphone Twilio)</q-tooltip>
</q-btn>
<q-btn flat dense size="sm" icon="add_task" color="positive" @click="openTask()">
<q-tooltip>Ajouter ce fil à une tâche (existante ou nouvelle)</q-tooltip>
</q-btn>
<q-btn v-if="isEmailConv && activeDiscussion.email" flat dense size="sm" icon="sync" color="info" :loading="resyncing" @click="doResync">
<q-tooltip>Re-synchroniser depuis Gmail — récupère les réponses envoyées hors hub + les courriels manqués (panne / sortis de la boîte)</q-tooltip>
</q-btn>
<q-btn v-if="isEmailConv" flat dense size="sm" icon="filter_alt" color="blue-grey-6" @click="openFilterLike">
<q-tooltip>Filtrer comme ceci — créer une règle (masquer / toujours afficher) par expéditeur ou sujet</q-tooltip>
</q-btn>
<q-btn v-if="isEmailConv" flat dense size="sm" icon="report" color="warning" @click="doSpam">
<q-tooltip>Spam → marquer SPAM dans Gmail + retirer</q-tooltip>
</q-btn>
<q-btn flat dense size="sm" icon="delete_outline" color="negative" @click="doDelete">
<q-tooltip>{{ isEmailConv ? 'Supprimer (+ courriels → corbeille Gmail)' : 'Supprimer' }}</q-tooltip>
</q-btn>
<q-btn v-if="activeToken && activeDiscussion.status === 'active'" flat dense size="sm" icon="stop_circle" color="warning"
@click="confirmClose">
<q-tooltip>Fermer la session</q-tooltip>
</q-btn>
<q-btn v-if="!inline" flat round dense size="sm" icon="close" color="grey-7" @click="panelOpen = false">
<q-tooltip>Fermer la fenêtre</q-tooltip>
</q-btn>
</q-toolbar>
<!-- UN SEUL SCROLL : contexte + barres + fil + composeur défilent ensemble sous la toolbar sticky -->
<div class="conv-scroll">
<!-- Contexte du fil (défile) : SUJET, expéditeur, date, destinataire, fiche liée, tickets -->
<div class="conv-context">
<div v-if="convSubject" class="conv-subject-h" :title="convSubject"><q-icon v-if="activeDiscussion.channel" :name="channelMeta(activeDiscussion.channel).icon" size="16px" :color="channelMeta(activeDiscussion.channel).color" class="q-mr-xs" style="vertical-align:-2px" />{{ convSubject }}</div>
<div class="text-weight-bold">
<router-link v-if="coordCustomer" :to="'/clients/' + encodeURIComponent(coordCustomer)" style="color:#3730a3;text-decoration:underline;text-underline-offset:2px">{{ activeDiscussion.customerName || activeDiscussion.email || activeDiscussion.phone }}<q-icon name="open_in_new" size="12px" class="q-ml-xs" style="vertical-align:-1px;opacity:.6" /></router-link>
<span v-else>{{ activeDiscussion.customerName || activeDiscussion.email || activeDiscussion.phone }}</span>
</div>
<div class="text-caption text-grey-6">{{ formatDate(activeDiscussion.date) }} &middot; {{ activeDiscussion.messageCount }} messages<span v-if="activeDiscussion.email"> &middot; {{ activeDiscussion.email }}</span></div>
<div v-if="convTo" class="text-caption text-grey-6"><q-icon name="subdirectory_arrow_right" size="11px" style="vertical-align:-1px" /> à <span class="text-grey-8">{{ convToLabel }}</span><q-tooltip class="bg-grey-9" style="font-size:11px">Boîte qui a reçu : {{ convTo }}</q-tooltip></div>
<div class="q-mt-xs">
<q-chip v-if="activeDiscussion.customer" dense size="sm" color="blue-1" text-color="blue-9" icon="account_circle" clickable removable @click="openLink" @remove="unlinkFiche">{{ coordAccountName || activeDiscussion.customerName || activeDiscussion.customer }}<q-tooltip>Compte lié — clic : changer de fiche · ✕ : délier</q-tooltip></q-chip>
<q-btn v-else dense flat size="sm" color="warning" icon="person_add" label="Lier une fiche" no-caps @click="openLink"><q-tooltip>Associer cette conversation à une fiche client</q-tooltip></q-btn>
</div>
<div v-if="linkedTickets.length" class="q-mt-xs">
<q-chip v-for="t in linkedTickets" :key="t.name" dense size="sm" color="green-1" text-color="green-9" icon="confirmation_number" clickable @click="openTicket(t.name)">{{ t.name }}<q-tooltip>{{ t.subject }} — ouvrir le ticket ↗</q-tooltip></q-chip>
</div>
<!-- LIENS / interdépendances (style ClickUp) : autres fils, fiches, tickets reliés + bouton Lier -->
<div class="q-mt-xs row items-center q-gutter-xs">
<q-chip v-for="(l, i) in convLinks" :key="'lnk' + i" dense size="sm" :color="linkChip(l.type).color" text-color="white" :icon="linkChip(l.type).icon" clickable removable @click="openConvLink(l)" @remove="removeConvLink(l)">{{ l.label || l.id }}<q-tooltip>{{ linkChip(l.type).label }} lié — clic pour ouvrir · ✕ délier</q-tooltip></q-chip>
<q-btn dense flat size="sm" no-caps color="primary" icon="link" label="Lier" @click="openRelate"><q-tooltip>Lier ce fil à un autre fil / une fiche / un ticket (interdépendances)</q-tooltip></q-btn>
</div>
<!-- Fenêtre LIER : conversation / fiche client / ticket (sélecteur réutilisant la recherche) -->
<q-dialog v-model="relateDialog">
<q-card style="min-width:480px;max-width:94vw">
<q-card-section class="row items-center q-pb-none"><q-icon name="link" color="primary" size="20px" class="q-mr-sm" /><span class="text-subtitle1 text-weight-bold">Lier ce fil</span></q-card-section>
<q-card-section>
<q-btn-toggle :model-value="relateType" @update:model-value="onRelateType" spread no-caps dense unelevated toggle-color="primary" color="grey-2" text-color="grey-8" class="q-mb-sm"
:options="[{ label: 'Conversation', value: 'conversation', icon: 'forum' }, { label: 'Fiche client', value: 'customer', icon: 'account_circle' }, { label: 'Ticket', value: 'ticket', icon: 'confirmation_number' }]" />
<q-input v-model="relateQuery" dense outlined autofocus debounce="250" :placeholder="relateType === 'customer' ? 'Rechercher un client (nom)…' : relateType === 'ticket' ? 'Rechercher un ticket (sujet, n°)…' : 'Rechercher un fil (nom, courriel, sujet, message…)'" @update:model-value="onRelateSearch"><template #prepend><q-icon name="search" size="18px" /></template></q-input>
<q-list dense class="q-mt-sm" style="max-height:340px;overflow:auto">
<q-item v-for="r in relateResults" :key="r.id" clickable @click="doRelate(r)">
<q-item-section avatar><q-icon :name="r.icon" :color="r.color" size="18px" /></q-item-section>
<q-item-section><q-item-label>{{ r.label }}</q-item-label><q-item-label caption lines="1">{{ r.sub }}</q-item-label></q-item-section>
</q-item>
<q-item v-if="relateBusy"><q-item-section class="text-grey-5 text-caption">Recherche…</q-item-section></q-item>
<q-item v-else-if="relateQuery && relateQuery.length >= 2 && !relateResults.length"><q-item-section class="text-grey-5 text-caption">Aucun résultat</q-item-section></q-item>
</q-list>
</q-card-section>
<q-card-actions align="right"><q-btn flat no-caps label="Fermer" v-close-popup /></q-card-actions>
</q-card>
</q-dialog>
</div>
<!-- Prendre / Assigner unifié (responsable + assistants) + Note interne — regroupés en tête -->
<div v-if="activeToken" class="coord-own row items-center">
<q-btn-dropdown split dense unelevated size="sm" no-caps :color="coordAssignee ? 'primary' : 'green-7'" :icon="coordAssignee ? 'manage_accounts' : 'pan_tool'" :label="coordAssignee ? 'Assigner' : 'Je m\'en occupe'" @click="doClaim" @show="assignSearch = ''; ensureAgents()">
<q-tooltip>{{ coordAssignee ? 'Me l\'assigner (1 clic) · ▾ pour choisir un agent' : 'M\'assigner ce fil en 1 clic · ▾ pour choisir un agent' }}</q-tooltip>
<q-list dense style="min-width:250px">
<q-item-label header class="q-py-xs">Assigner — 1<sup>er</sup> = responsable, puis assistants</q-item-label>
<q-item class="q-pb-none">
<q-input dense outlined v-model="assignSearch" autofocus placeholder="Rechercher un agent…" class="full-width" @keydown.stop><template #prepend><q-icon name="search" size="16px" /></template></q-input>
</q-item>
<q-item v-for="a in filteredAgents" :key="a" clickable @click="pickAgent(a)" :active="a === coordAssignee" active-class="bg-green-1">
<q-item-section avatar><q-avatar size="22px" :style="{ background: staffColor(a), color: '#fff', fontSize: '10px' }">{{ staffInitials(noteAuthorName(a)) }}</q-avatar></q-item-section>
<q-item-section><q-item-label>{{ noteAuthorName(a) }}</q-item-label><q-item-label caption>{{ a }}</q-item-label></q-item-section>
<q-item-section side><q-icon v-if="a === coordAssignee" name="star" color="warning" size="16px"><q-tooltip>Responsable</q-tooltip></q-icon><q-icon v-else-if="coordAssistants.includes(a)" name="group" color="blue-grey-5" size="16px"><q-tooltip>Assistant</q-tooltip></q-icon></q-item-section>
</q-item>
<q-item v-if="!filteredAgents.length"><q-item-section class="text-grey-5 text-caption">Aucun agent</q-item-section></q-item>
<q-separator />
<q-item v-if="coordAssignee" clickable v-close-popup @click="assignTo(null)"><q-item-section class="text-negative"><q-icon name="person_off" size="16px" class="q-mr-xs" /> Désassigner</q-item-section></q-item>
<q-item clickable v-close-popup @click="openTicketDialog"><q-item-section avatar><q-icon name="confirmation_number" color="positive" /></q-item-section><q-item-section>Créer un ticket…</q-item-section></q-item>
</q-list>
</q-btn-dropdown>
<q-chip v-if="coordAssignee" dense size="sm" color="amber-1" text-color="amber-9" icon="star" removable @remove="doRelease"><b>{{ shortAgent(coordAssignee) }}</b><q-tooltip>Responsable — ✕ pour libérer</q-tooltip></q-chip>
<q-chip v-for="a in coordAssistants" :key="a" dense size="sm" color="blue-grey-2" text-color="blue-grey-9" icon="group" removable @remove="removeAssistant(a)">{{ shortAgent(a) }}<q-tooltip>Assistant</q-tooltip></q-chip>
</div>
<!-- Saisie de note déplacée dans le composeur unifié en bas (bascule Répondre / Note interne). -->
<!-- File (département) + bascule vers les actions avancées (repliées) -->
<div class="coord-bar">
<span class="coord-lbl">File</span>
<q-chip v-for="q in QUEUES" :key="q" dense clickable size="sm" :color="coordQueue === q ? 'primary' : 'grey-3'" :text-color="coordQueue === q ? 'white' : 'grey-8'" @click="setQueue(q)">{{ qLabel(q) }}</q-chip>
<q-space />
<q-btn dense flat round size="sm" class="q-ml-xs" :icon="coordOpen ? 'expand_less' : 'tune'" :color="coordOpen ? 'primary' : 'grey-6'" @click="coordOpen = !coordOpen"><q-tooltip>{{ coordOpen ? 'Masquer les actions' : 'Coordination avancée : état, scinder, fusionner, IA…' }}</q-tooltip></q-btn>
</div>
<!-- #3 Drapeau « mauvais aiguillage » : le triage IA classe ce fil dans une autre file que l'actuelle -->
<div v-if="misrouted" class="coord-misroute">
<q-icon name="alt_route" size="16px" color="warning" class="q-mr-xs" />
<span>L'IA classe ceci en <b>{{ TYPE_LBL[triageType] || triageType }}</b> → file suggérée <b>{{ qLabel(aiSuggestedQueue) }}</b> (actuellement {{ qLabel(coordQueue) }}).</span>
<q-space />
<q-btn dense unelevated size="sm" color="warning" no-caps icon="alt_route" :label="'Réorienter → ' + qLabel(aiSuggestedQueue)" @click="rerouteToAI" />
<q-btn dense flat size="sm" color="blue-grey-7" no-caps icon="groups" label="Groupes" class="q-ml-xs" @click="openGroups"><q-tooltip>Gérer les membres des files (Paramètres)</q-tooltip></q-btn>
</div>
<div v-if="coordOpen && activeToken && (gmailReplyUrl || coordSuggested || true)" class="coord-bar2">
<q-btn v-if="gmailReplyUrl" dense flat size="sm" color="negative" icon="mail" label="Répondre dans Gmail" no-caps @click="openGmailReply"><q-tooltip>Ouvre le fil dans Gmail (éditeur + Gemini + Drive) — la réponse est recapturée ici</q-tooltip></q-btn>
<q-btn v-if="coordSuggested" dense flat size="sm" color="positive" icon="confirmation_number" :label="'🤖 ' + (coordSuggested.title || 'Ticket suggéré')" no-caps @click="openTicketDialog"><q-tooltip>Ticket suggéré par l'IA ({{ coordSuggested.category }}) — clic = créer (pré-rempli)</q-tooltip></q-btn>
<q-space />
<q-btn dense flat size="sm" color="warning" icon="call_split" label="Scinder" no-caps :loading="splitBusy" @click="openSplit"><q-tooltip>Détecte les problèmes distincts (ex. facturation + télé) et crée un ticket par sujet</q-tooltip></q-btn>
<q-btn dense flat size="sm" color="blue-grey-6" icon="merge" label="Fusionner" no-caps @click="openMerge"><q-tooltip>Fusionner une autre conversation (doublon) dans celle-ci</q-tooltip></q-btn>
</div>
<!-- Présence (style Google Docs) : qui a VU (avatar) + qui RÉPOND (anneau clignotant + « rédige ») -->
<div v-if="activeToken && presence.length" class="coord-readers row items-center no-wrap">
<span class="text-caption text-grey-6 q-mr-xs">Vu par</span>
<q-avatar v-for="p in presence" :key="p.email" size="22px" class="coord-reader-av" :class="{ 'presence-typing': p.typing }"
:style="{ background: staffColor(p.email), color: '#fff', fontSize: '10px' }">
{{ staffInitials(noteAuthorName(p.email)) }}
<q-tooltip>{{ shortAgent(p.email) }} · {{ p.typing ? 'rédige une réponse…' : ('vu ' + relTime(p.readAt)) }}</q-tooltip>
</q-avatar>
</div>
<!-- État (ticket) + assignation directe + assistants -->
<div v-if="coordOpen && activeToken" class="coord-bar3 row items-center no-wrap">
<q-btn-toggle :model-value="coordState" @update:model-value="setState" dense unelevated spread no-caps size="sm"
toggle-color="primary" color="grey-2" text-color="grey-8"
:options="[{ label: 'Ouvert', value: 'open' }, { label: 'En attente', value: 'pending' }, { label: 'Fermé', value: 'closed' }]" />
<q-input v-if="coordState === 'pending'" v-model="pendingDateInput" type="date" dense outlined stack-label
label="Attendre jusqu'au" class="q-ml-xs" style="max-width:160px" @update:model-value="v => setState('pending')">
<template #prepend><q-icon name="event" size="15px" color="warning" /></template>
</q-input>
<q-btn-group v-if="coordState === 'pending'" flat class="q-ml-xs">
<q-btn dense no-caps size="sm" color="grey-7" label="Demain" @click="snoozePreset('tomorrow')"><q-tooltip>Reporter à demain</q-tooltip></q-btn>
<q-btn dense no-caps size="sm" color="grey-7" label="Vendredi" @click="snoozePreset('friday')"><q-tooltip>Reporter à vendredi</q-tooltip></q-btn>
<q-btn dense no-caps size="sm" color="grey-7" label="Lun. proch." @click="snoozePreset('nextweek')"><q-tooltip>Reporter à lundi prochain</q-tooltip></q-btn>
</q-btn-group>
</div>
<!-- DIAGNOSTIC SERVICE/MODEM du client lié — visible depuis le courriel ; image de l'état attachable à la réponse -->
<div v-if="activeToken && coordCustomer" class="svc-bar">
<div class="row items-center no-wrap">
<q-icon name="wifi_find" size="16px" color="info" class="q-mr-xs" />
<span class="text-caption text-weight-medium">Statut du service</span>
<q-space />
<q-btn v-if="!svcStatus && !svcLoading" dense flat size="sm" no-caps color="info" icon="search" label="Vérifier" @click="loadServiceStatus" />
<q-spinner v-if="svcLoading" size="16px" color="info" />
<q-btn v-if="svcStatus" dense flat round size="sm" icon="refresh" color="grey-6" @click="loadServiceStatus"><q-tooltip>Réinterroger</q-tooltip></q-btn>
<q-btn dense flat size="sm" no-caps color="info" icon="send" label="Diag. au client" :loading="diagSending" @click="sendDiagLink"><q-tooltip>Insère un lien de diagnostic en libre-service dans la réponse au client</q-tooltip></q-btn>
</div>
<template v-if="svcStatus && svcStatus.found">
<div class="text-caption text-grey-7 ellipsis q-mt-xs"><q-icon name="place" size="13px" color="warning" /> {{ svcStatus.location ? (svcStatus.location.address || svcStatus.location.name) : '—' }}</div>
<div class="row items-center q-gutter-xs q-mt-xs">
<q-chip dense :color="svcColor(svcStatus.summary.online)" text-color="white" :icon="svcIcon(svcStatus.summary.online)" class="q-ma-none">{{ svcStatus.summary.label }}</q-chip>
<q-chip v-for="d in svcStatus.devices" :key="d.serial" dense square size="sm" :color="svcColor(d.online)" text-color="white" class="q-ma-none">{{ d.serial }}<span v-if="d.signal" class="q-ml-xs">· {{ d.signal }}</span></q-chip>
</div>
<div v-if="svcStatus.subscriptions && svcStatus.subscriptions.length" class="text-caption text-grey-6 q-mt-xs">{{ svcStatus.subscriptions.map(s => s.plan).filter(Boolean).join(' · ') }}</div>
<q-btn dense flat size="sm" no-caps color="primary" icon="attach_file" class="q-mt-xs" :label="isEmailConv ? 'Joindre l\'état au courriel' : 'Joindre l\'état (image)'" :loading="svcAttaching" @click="attachStatusImage" />
</template>
<div v-else-if="svcStatus && !svcStatus.found" class="text-caption text-grey-6 q-mt-xs">{{ svcStatus.ambiguous ? 'Plusieurs comptes — ouvrir la fiche' : 'Aucun équipement lié' }}</div>
</div>
<!-- Paiement détecté par OCR (capture client) → AFFICHE pour décider. Pas d'écriture facturation (F autoritaire) ; réactivation à venir (GenieACS). -->
<div v-if="coordPayment" class="pay-card" :class="{ 'pay-card-no': coordPayment.is_payment === false }">
<div class="row items-center no-wrap">
<q-icon :name="coordPayment.is_payment ? 'paid' : 'help_outline'" :color="coordPayment.is_payment ? 'green-7' : 'grey-6'" size="20px" class="q-mr-sm" />
<div class="col">
<div class="text-weight-bold">
<template v-if="coordPayment.is_payment">💳 Paiement détecté{{ coordPayment.amount != null ? ' — ' + coordPayment.amount + ' ' + (coordPayment.currency || 'CAD') : '' }}</template>
<template v-else>Aucun paiement clair dans l'image</template>
</div>
<div v-if="coordPayment.is_payment" class="text-caption text-grey-7">
<span v-if="coordPayment.date">📅 {{ coordPayment.date }}</span>
<span v-if="coordPayment.method"> · {{ coordPayment.method }}</span>
<span v-if="coordPayment.reference"> · réf {{ coordPayment.reference }}</span>
<span v-if="coordPayment.payer_name"> · {{ coordPayment.payer_name }}</span>
· confiance {{ Math.round((coordPayment.confidence || 0) * 100) }}%
</div>
</div>
</div>
<div v-if="coordPayment.is_payment" class="row items-center q-gutter-sm q-mt-xs">
<q-btn dense unelevated size="sm" color="primary" icon="forward_to_inbox" label="Router vers Facturation" no-caps @click="routePaymentToBilling" />
<q-btn dense outline size="sm" color="grey-6" icon="power_settings_new" label="Réactiver (bientôt)" no-caps disable><q-tooltip>Réactivation automatique via GenieACS — en préparation</q-tooltip></q-btn>
<q-space />
<span class="text-caption text-grey-5">Vérifier dans F avant d'enregistrer — aucune écriture automatique.</span>
</div>
</div>
<!-- Copilote : commande en langage naturel → file/claim/note/ticket + brouillon de réponse -->
<div v-if="coordOpen" class="coord-nl">
<q-input dense outlined v-model="nlText" :loading="nlBusy" placeholder="✨ « assigne à Facturation et réponds qu'on traite sous 24 h »" @keyup.enter="runNl">
<template #prepend><q-icon name="auto_awesome" color="deep-purple-5" /></template>
<template #append><q-btn flat dense round icon="send" color="deep-purple-5" :disable="!nlText.trim() || nlBusy" @click="runNl" /></template>
</q-input>
</div>
<!-- Résumé IA du fil (style Gmail « Summarise this email ») — à la demande -->
<div v-if="activeToken" class="conv-summary-zone">
<div class="row items-center q-gutter-xs">
<q-btn v-if="!summaryText && !summaryBusy && !summaryErr" flat dense no-caps size="sm" color="deep-purple-6" icon="auto_awesome" label="Résumer ce fil" @click="summarizeThread" />
<q-btn v-if="isEmailConv" flat dense no-caps size="sm" color="green-7" icon="travel_explore" label="Disponibilité" :loading="svcBusy" @click="checkServiceability"><q-tooltip>Vérifier la fibre à l'adresse du client (RQA) + préparer un brouillon de réponse</q-tooltip></q-btn>
</div>
<div v-if="summaryBusy" class="conv-summary-card row items-center"><q-spinner-dots size="22px" color="deep-purple-5" class="q-mr-sm" /> Résumé en cours…</div>
<div v-else-if="summaryErr" class="row items-center text-caption text-negative"><q-icon name="error_outline" class="q-mr-xs" />{{ summaryErr }} <q-btn flat dense size="sm" no-caps label="Réessayer" color="deep-purple-6" class="q-ml-sm" @click="summarizeThread" /></div>
<div v-else-if="summaryText" class="conv-summary-card">
<div class="row items-center q-mb-xs"><q-icon name="auto_awesome" color="deep-purple-5" size="16px" class="q-mr-xs" /><b class="text-deep-purple-7">Résumé IA</b><q-space /><q-btn flat dense round size="xs" icon="refresh" color="grey-6" @click="summarizeThread"><q-tooltip>Régénérer</q-tooltip></q-btn><q-btn flat dense round size="xs" icon="close" color="grey-6" @click="summaryText = ''" /></div>
<div class="conv-summary-body" v-html="sanitizeHtml(summaryHtml)"></div>
<div class="row q-mt-sm">
<q-btn dense unelevated no-caps size="sm" color="positive" icon="confirmation_number" :label="'Créer un ticket — ' + ticketTypeLabel" :loading="ticketBusy" @click="createTicketFromSummary" />
</div>
</div>
<!-- #2 Résultat disponibilité (adresse → fibre RQA) + insertion du brouillon (gated : l'agent relit puis envoie, jamais auto-envoyé) -->
<div v-if="svcResult" class="conv-svc-card" :class="svcResult.fiber_available ? 'svc-yes' : (svcResult.needs_address ? 'svc-warn' : 'svc-no')">
<div class="row items-center q-mb-xs">
<q-icon :name="svcResult.needs_address ? 'help_outline' : (svcResult.fiber_available ? 'check_circle' : 'cancel')" :color="svcResult.needs_address ? 'amber-8' : (svcResult.fiber_available ? 'green-7' : 'red-6')" size="18px" class="q-mr-xs" />
<b>{{ svcResult.needs_address ? 'Adresse à préciser' : (svcResult.fiber_available ? 'Fibre disponible' : 'Fibre non disponible') }}</b>
<q-space />
<q-btn flat dense round size="xs" icon="close" color="grey-6" @click="svcResult = null" />
</div>
<div v-if="svcResult.address" class="text-caption q-mb-xs">{{ svcResult.address }}<span v-if="svcResult.max_speed"> · {{ svcResult.max_speed }}</span></div>
<div v-if="svcResult.needs_address" class="text-caption text-grey-7">{{ svcResult.message }}</div>
<q-btn v-else dense unelevated no-caps size="sm" color="green-7" icon="edit_note" label="Insérer le brouillon dans la réponse" @click="insertServiceabilityDraft" />
</div>
</div>
<!-- Édition EN PLACE d'une réponse / signature (depuis le menu « Réponses types ») -->
<q-dialog v-model="cannedEdit.open">
<q-card style="min-width:380px;max-width:94vw">
<q-card-section class="row items-center q-pb-none"><q-icon name="quickreply" color="primary" class="q-mr-sm" /><span class="text-subtitle1 text-weight-bold">{{ cannedEdit.isNew ? 'Nouvelle réponse / signature' : 'Modifier la réponse / signature' }}</span></q-card-section>
<q-card-section class="q-gutter-sm">
<q-input dense outlined v-model="cannedEdit.title" label="Titre (ex. Signature Support)" />
<q-select dense outlined v-model="cannedEdit.mailbox" :options="cannedMailboxes" emit-value map-options label="Mailbox / file (signature suggérée)" />
<q-editor v-model="cannedEdit.html" min-height="7rem" :toolbar="[['bold', 'italic', 'underline'], ['unordered', 'ordered'], ['link', 'removeFormat'], ['undo', 'redo']]" placeholder="Texte / signature… (image via le bouton, ou collez depuis Gmail)" />
<q-btn flat dense size="sm" icon="image" color="primary" no-caps label="Image / logo" :loading="cannedUploading" @click="pickCannedImage" />
<div class="text-caption text-grey-5">Variables : <code v-pre>{{ client }}</code>, <code v-pre>{{ agent }}</code> (remplacées à l'insertion).</div>
</q-card-section>
<q-card-actions align="right">
<q-btn v-if="!cannedEdit.isNew" flat no-caps color="negative" icon="delete" label="Supprimer" :loading="cannedSaving" @click="deleteCannedEdit" />
<q-space />
<q-btn flat no-caps label="Annuler" v-close-popup />
<q-btn unelevated no-caps color="primary" icon="save" label="Enregistrer" :loading="cannedSaving" @click="saveCannedEdit" />
</q-card-actions>
</q-card>
</q-dialog>
<input ref="cannedFileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" style="display:none" @change="onCannedFile" />
<!-- « Filtrer comme ceci » (style Gmail) : crée une règle masquer/afficher par expéditeur ou sujet (icône dans la barre du message détaillé) -->
<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>
<!-- TRANSFÉRER un message du fil vers d'autres destinataires (nouvel envoi via email-new) -->
<q-dialog v-model="fwdDialog.open">
<q-card style="min-width:520px;max-width:94vw">
<q-card-section class="row items-center q-pb-none"><q-icon name="forward" color="primary" size="20px" class="q-mr-sm" /><span class="text-subtitle1 text-weight-bold">Transférer le message</span></q-card-section>
<q-card-section class="q-gutter-sm">
<RecipientSelect v-model="fwdDialog.to" label="À — tapez un nom ou un courriel" icon="person" />
<RecipientSelect v-model="fwdDialog.cc" label="Cc — tapez un nom ou un courriel" icon="group" />
<q-input v-model="fwdDialog.subject" dense outlined label="Objet" />
<q-input v-model="fwdDialog.note" type="textarea" autogrow dense outlined label="Note d'accompagnement (optionnelle, en tête)" />
<q-checkbox v-if="fwdAttCount" v-model="fwdDialog.includeAtts" dense color="primary" :label="'Inclure les pièces jointes (' + fwdAttCount + ')'" />
<div class="fwd-preview">
<div class="text-caption text-grey-6 q-mb-xs"><q-icon name="subdirectory_arrow_right" size="13px" style="vertical-align:-2px" /> Contenu transféré</div>
<div class="fwd-preview-body" v-html="sanitizeHtml(fwdPreviewHtml)"></div>
</div>
</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="forward" label="Transférer" :disable="!fwdDialog.to.length || fwdDialog.sending" :loading="fwdDialog.sending" @click="doForward" />
</q-card-actions>
</q-card>
</q-dialog>
<div ref="messagesEl" class="conv-messages">
<template v-for="msg in threadTimeline" :key="(msg._kind === 'note' ? 'n:' : msg._kind === 'draft' ? 'd:' : msg._kind === 'compose' ? 'c:' : 'm:') + msg.id">
<!-- NOTE INTERNE en bulle ambre (style Missive) — entrelacée dans le fil par date -->
<div v-if="msg._kind === 'note'" class="conv-note-bubble">
<div class="row items-center no-wrap conv-note-head">
<q-icon name="sticky_note_2" size="13px" color="warning" class="q-mr-xs" />
<b class="text-warning">{{ titleCase(shortAgent(msg.agent)) }}</b>
<span class="text-caption text-grey-6 q-ml-xs">· note interne · {{ relTime(msg.ts) }}</span>
<q-space />
<template v-if="editNoteId !== msg.id">
<q-btn flat dense round size="xs" icon="edit" color="grey-5" @click="startEditNote(msg)"><q-tooltip>Modifier</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="close" color="grey-5" @click="removeNote(msg)"><q-tooltip>Supprimer</q-tooltip></q-btn>
</template>
</div>
<q-input v-if="editNoteId === msg.id" dense outlined v-model="editNoteText" autofocus class="q-mt-xs" @keyup.enter="saveEditNote(msg)" @keyup.esc="editNoteId = ''">
<template #append><q-btn flat dense round size="xs" icon="check" color="green-7" @click="saveEditNote(msg)" /><q-btn flat dense round size="xs" icon="close" color="grey-6" @click="editNoteId = ''" /></template>
</q-input>
<div v-else class="conv-note-text">{{ msg.text }}<span v-if="msg.edited" class="text-grey-5 text-caption"> · modifié</span></div>
</div>
<!-- ÉDITEUR DE RÉPONSE INLINE (style Missive/Gmail) : rendu DIRECTEMENT dans le fil, ancré juste après le message référencé -->
<div v-else-if="msg._kind === 'compose'" class="conv-compose conv-compose-inline" :class="{ 'conv-compose-typing': typingAgentLabel }">
<!-- En-tête du brouillon : fermer (garder) ou supprimer -->
<div class="row items-center no-wrap q-mb-xs">
<q-icon name="edit_note" size="16px" color="green-5" class="q-mr-xs" />
<span class="text-caption text-weight-medium text-grey-7">Brouillon de réponse<span v-if="replyTarget && (activeDiscussion.messages || []).length > 1" class="text-primary"> · en réponse à {{ msgWho(replyTarget) }}</span></span>
<q-space />
<q-btn flat dense no-caps size="sm" icon="delete_outline" color="negative" label="Supprimer" @click="discardDraft"><q-tooltip>Supprimer ce brouillon (vide la réponse)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="close" color="grey-6" class="q-ml-xs" @click="closeComposer"><q-tooltip>Fermer — garde le brouillon (visible dans la liste des brouillons)</q-tooltip></q-btn>
</div>
<div v-if="replyMedia" class="row items-center q-gutter-xs q-mb-xs">
<img :src="replyMedia" style="height:42px;border-radius:6px;border:1px solid #ddd" />
<q-btn flat dense round size="xs" icon="close" color="grey-6" @click="replyMedia = ''" />
</div>
<!-- COURRIEL : éditeur WYSIWYG (type Gmail) → envoie du HTML dans le même fil -->
<template v-if="isEmailConv">
<div class="row items-center q-mb-xs">
<span class="text-caption text-grey-6 q-mr-sm">De :</span>
<q-select v-model="replySendAs" :options="senderOpts" dense options-dense outlined emit-value map-options hide-bottom-space style="min-width:230px;max-width:340px">
<template #prepend><q-icon name="alternate_email" size="15px" color="grey-6" /></template>
</q-select>
<q-space />
<q-btn flat dense size="sm" no-caps :color="(showCcTo || replyCcList.length) ? 'primary' : 'grey-6'" :icon="showCcTo ? 'expand_less' : 'group_add'" :label="replyCcList.length ? ('Cc · ' + replyCcList.length) : 'Cc / À'" @click="showCcTo = !showCcTo"><q-tooltip>Ajouter des destinataires en Cc ou modifier le « À » (répondre à tous)</q-tooltip></q-btn>
</div>
<!-- À / Cc éditables (repliés par défaut) : répondre à tous / boucler un collègue -->
<div v-if="showCcTo" class="conv-ccto q-mb-xs">
<RecipientSelect v-model="replyToList" label="À — tapez un nom ou un courriel" icon="person" class="q-mb-xs" />
<RecipientSelect v-model="replyCcList" label="Cc — tapez un nom ou un courriel" icon="group" />
</div>
<q-editor v-model="draftHtml" min-height="8rem" class="email-editor" :class="{ 'quote-folded': quoteFolded }"
:toolbar="[['bold','italic','underline','strike'],['unordered','ordered'],['link','removeFormat'],['undo','redo']]"
@keydown.ctrl.enter.prevent="sendEmailReply" @keydown.meta.enter.prevent="sendEmailReply"
placeholder="Rédiger la réponse… (mise en forme, listes, liens — part dans le fil Gmail) · Ctrl/⌘+Entrée pour envoyer" />
<!-- Citation repliée par défaut (style Gmail) : on écrit au propre ; « ··· » déplie le message cité (toujours envoyé). -->
<div v-if="hasQuote" class="q-mt-xs">
<q-btn flat dense size="sm" no-caps :icon="quoteFolded ? 'more_horiz' : 'unfold_less'" :color="quoteFolded ? 'grey-6' : 'primary'" :label="quoteFolded ? 'Afficher la citation' : 'Masquer la citation'" @click="quoteFolded = !quoteFolded"><q-tooltip>Replier / déplier le message cité (il reste inclus dans l'envoi)</q-tooltip></q-btn>
</div>
<div v-if="replyAttachments.length" class="row items-center q-gutter-xs q-mt-xs">
<q-chip v-for="(a, i) in replyAttachments" :key="a.asset + i" dense removable size="sm" color="blue-grey-1" text-color="blue-grey-9" @remove="replyAttachments.splice(i, 1)">
<q-avatar v-if="/^image\//.test(a.mime)"><img :src="a.url"></q-avatar><span class="q-ml-xs ellipsis" style="max-width:130px">{{ a.filename }}</span>
</q-chip>
</div>
<input ref="replyAttInput" type="file" accept="image/*" multiple style="display:none" @change="e => onAttachFiles(e, 'reply')" />
<div v-if="advHtml" class="adv-ready row items-center q-mt-xs">
<q-icon name="auto_awesome" size="16px" class="q-mr-xs" style="color:#00C853" />
<span class="text-caption text-weight-medium" style="color:#1B2E24">Mise en page avancée prête — c'est elle qui sera envoyée</span>
<q-space />
<q-btn flat dense size="sm" no-caps icon="visibility" label="Aperçu" color="primary" @click="previewAdvHtml(advHtml)" />
<q-btn flat dense size="sm" no-caps label="Modifier" style="color:#00C853" @click="openAdvanced('reply')" />
<q-btn flat dense size="sm" no-caps color="negative" label="Retirer" @click="advHtml = ''" />
</div>
<div class="row items-center q-mt-xs">
<q-btn flat dense size="sm" icon="quickreply" color="grey-7" no-caps label="Réponses types" @click="loadCanned">
<q-menu anchor="top left" self="bottom left">
<q-list dense style="min-width:260px;max-width:340px">
<q-item-label header class="q-py-xs">Réponses pré-écrites</q-item-label>
<q-item v-for="c in cannedSorted" :key="c.id" clickable v-close-popup @click="insertCanned(c)">
<q-item-section><q-item-label>{{ c.title }}</q-item-label><q-item-label caption lines="2">{{ c.body }}</q-item-label></q-item-section>
<q-item-section side v-if="suggestedSignature && c.id === suggestedSignature.id"><q-badge color="green-5" label="Suggérée" /></q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="edit" color="grey-6" @click.stop="editCanned(c)" v-close-popup><q-tooltip>Modifier en place</q-tooltip></q-btn></q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="newCanned"><q-item-section avatar><q-icon name="add" color="primary" size="20px" /></q-item-section><q-item-section class="text-primary">Nouvelle réponse / signature…</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<!-- (Bouton « Signature » retiré : doublon exact — la signature suggérée est déjà dans le menu « Réponses types » avec le badge « Suggérée ». -->
<q-btn flat dense size="sm" icon="attach_file" color="grey-7" no-caps label="Joindre" :loading="attUploading" class="q-ml-xs" @click="pickReplyAtt" />
<q-btn flat dense size="sm" icon="collections" color="grey-7" no-caps label="Bibliothèque" class="q-ml-xs" @click="openPresetGallery('reply')" />
<q-btn flat dense size="sm" icon="auto_awesome" color="green-7" no-caps label="Éditeur avancé" class="q-ml-xs" @click="openAdvanced('reply')"><q-tooltip>Composer un courriel mis en page (Unlayer)</q-tooltip></q-btn>
<span class="text-caption text-grey-5 q-ml-sm">↳ {{ activeDiscussion.email }}</span>
<q-space />
<q-btn unelevated dense icon="send" label="Envoyer le courriel" no-caps class="giga-btn" :disable="(!hasRealReply && !replyAttachments.length && !advHtml) || sending" :loading="sending" @click="sendEmailReply"><q-tooltip>Envoyer (Ctrl/⌘ + Entrée)</q-tooltip></q-btn>
</div>
</template>
<!-- SMS / chat : champ simple -->
<q-input v-else v-model="draft" dense outlined placeholder="Repondre..." autogrow
:input-style="{ fontSize: '0.85rem' }"
@keydown.enter.exact.prevent="send">
<template #prepend>
<q-btn flat round dense icon="quickreply" color="grey-6" size="sm" @click="loadCanned"><q-tooltip>Réponses types</q-tooltip>
<q-menu anchor="top left" self="bottom left">
<q-list dense style="min-width:240px;max-width:320px">
<q-item-label header class="q-py-xs">Réponses pré-écrites</q-item-label>
<q-item v-for="c in cannedSorted" :key="c.id" clickable v-close-popup @click="insertCanned(c)">
<q-item-section><q-item-label>{{ c.title }}</q-item-label><q-item-label caption lines="1">{{ c.body }}</q-item-label></q-item-section>
</q-item>
<q-item v-if="!cannedSorted.length"><q-item-section class="text-grey-5 text-caption">Aucune réponse type</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<q-btn flat round dense icon="image" color="grey-6" size="sm" :loading="replyUploading" @click="pickReplyImage">
<q-tooltip>Joindre une image</q-tooltip>
</q-btn>
</template>
<template #append>
<q-btn flat round dense icon="send" color="primary" size="sm"
:disable="(!draft.trim() && !replyMedia) || sending" :loading="sending" @click="send" />
</template>
</q-input>
<input ref="replyImgInput" type="file" accept="image/*" style="display:none" @change="onReplyImage" />
</div>
<!-- BROUILLON MIROIR (lecture seule) : collègue qui rédige, ou le mien si le composeur est replié -->
<div v-else-if="msg._kind === 'draft'" class="conv-draft-inline" :class="{ 'conv-draft-inline-mine': msg.mine }">
<div class="row items-center no-wrap conv-draft-inline-head">
<q-avatar size="20px" class="q-mr-xs" :class="{ 'presence-pulse': !msg.mine }" :style="{ background: staffColor(msg.agent), color: '#fff', fontSize: '9px' }">{{ staffInitials(noteAuthorName(msg.agent)) }}</q-avatar>
<q-icon name="edit_note" size="15px" color="primary" class="q-mr-xs" />
<b class="text-green-8">{{ msg.mine ? 'Vous' : shortAgent(msg.agent) }}</b>
<span class="text-caption text-grey-6 q-ml-xs">· brouillon<template v-if="msg.replyTo && msgById(msg.replyTo)"> · en réponse à {{ msgWho(msgById(msg.replyTo)) }}</template><template v-else-if="!msg.mine"> · rédige en direct…</template></span>
<q-space />
<template v-if="msg.mine">
<q-btn flat dense no-caps size="sm" icon="edit" color="primary" label="Continuer" @click="focusComposer"><q-tooltip>Reprendre la rédaction dans le composeur</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="delete_outline" color="negative" class="q-ml-xs" @click="discardDraft"><q-tooltip>Supprimer ce brouillon</q-tooltip></q-btn>
</template>
<q-btn v-else flat dense round size="sm" icon="close" color="green-4" @click="closeOtherDraft"><q-tooltip>Masquer cet aperçu</q-tooltip></q-btn>
</div>
<div class="conv-draft-inline-body" v-html="sanitizeHtml(msg.html || (msg.text || '').replace(/\n/g, '<br>'))"></div>
</div>
<div v-else class="conv-msg" :class="'conv-msg-' + msg.from">
<!-- En-tête repliable (accordéon, style Gmail) : qui · heure · aperçu — clic pour déplier/replier -->
<div class="msg-head" :class="{ 'msg-head-open': isExpanded(msg) }" @click="toggleMsg(msg)">
<div class="msg-head-line1 row items-center no-wrap">
<!-- Avatar coloré + initiales par message (style Gmail/CRM) → on voit d'un coup QUI a écrit dans un fil -->
<q-avatar v-if="(activeDiscussion.messages || []).length > 1" size="22px" class="q-mr-xs msg-av" :style="{ background: msgAvatarColor(msg), color: '#fff', fontSize: '9px', fontWeight: 700 }">{{ msgAvatarInitials(msg) }}</q-avatar>
<q-icon :name="msgIcon(msg)" :color="msg.from === 'agent' ? (msg.via === 'ai' ? 'deep-purple-4' : 'green-5') : 'teal-6'" size="13px" class="q-mr-xs" />
<router-link v-if="msg.from === 'customer' && coordCustomer && msgWho(msg) && (activeDiscussion.messages || []).length > 1" :to="'/clients/' + encodeURIComponent(coordCustomer)" class="msg-who" style="color:#3730a3;text-decoration:underline;text-underline-offset:2px" @click.stop>{{ msgWho(msg) }}<q-tooltip class="bg-grey-9" style="font-size:11px">Ouvrir la fiche client (OPS 360) ↗</q-tooltip></router-link>
<span v-else-if="msgWho(msg) && (activeDiscussion.messages || []).length > 1" class="msg-who">{{ msgWho(msg) }}</span>
<q-space />
<!-- Actions du message SUR la ligne d'en-tête (expéditeur) : répondre + agrandir le courriel -->
<q-btn v-if="msg.from !== 'system' && activeDiscussion.status === 'active'" flat dense round size="xs" icon="reply" color="green-5" class="q-mr-xs" @click.stop="openReply(msg)"><q-tooltip>Répondre en citant CE message</q-tooltip></q-btn>
<q-btn v-if="msg.from !== 'system'" flat dense round size="xs" icon="forward" color="blue-grey-6" class="q-mr-xs" @click.stop="openForward(msg)"><q-tooltip>Transférer ce message à d'autres destinataires</q-tooltip></q-btn>
<q-btn v-if="activeDiscussion.status === 'active'" flat dense round size="xs" icon="add_task" color="positive" class="q-mr-xs" @click.stop="openTask(msg)"><q-tooltip>Créer une tâche depuis ce message</q-tooltip></q-btn>
<q-btn v-if="msg.html" flat dense round size="xs" icon="open_in_full" color="grey-6" class="q-mr-xs" @click.stop="expandEmail(msg)"><q-tooltip>Agrandir le courriel (plein écran)</q-tooltip></q-btn>
<span class="msg-time q-ml-xs">{{ relTime(msg.emailDate || msg.ts) }}<q-tooltip v-if="msgFullDate(msg)" class="bg-grey-9" style="font-size:11px">{{ msgFullDate(msg) }}</q-tooltip></span>
<q-icon :name="isExpanded(msg) ? 'expand_less' : 'expand_more'" size="16px" color="grey-5" class="q-ml-xs" />
</div>
<!-- Destinataire de CE message (style Gmail « à … ») — visible une fois déplié : on comprend à qui chaque message s'adresse -->
<div v-if="isExpanded(msg) && msgTo(msg)" class="msg-to"><q-icon name="subdirectory_arrow_right" size="11px" style="vertical-align:-1px" /> à <span class="msg-to-rcpt">{{ msgTo(msg) }}<q-tooltip class="bg-grey-9" anchor="bottom left" self="top left" :delay="150" style="font-size:11px;white-space:pre-line;max-width:340px">{{ msgToFull(msg) }}</q-tooltip></span></div>
<!-- Aperçu DIRECTEMENT sous le nom (style Gmail), pas repoussé à droite -->
<div v-if="!isExpanded(msg)" class="msg-snippet">{{ msgSnippet(msg) }}</div>
</div>
<!-- Pièces jointes (PDF/JPG…) — trombone cliquable (ouvre le document) -->
<div v-if="attByMsg[msg.id] && attByMsg[msg.id].length" class="msg-att row items-center q-gutter-xs">
<q-icon name="attach_file" size="14px" color="grey-6" />
<q-chip v-for="a in attByMsg[msg.id]" :key="a.attachmentId" dense clickable size="sm" color="green-1" text-color="green-9" :icon="attIcon(a.mimeType)" @click.stop="openAtt(a)">{{ a.filename }}<q-tooltip>Ouvrir {{ a.filename }} ({{ a.mimeType }})</q-tooltip></q-chip>
</div>
<div v-if="isExpanded(msg)" class="msg-body">
<!-- Courriel complet (HTML) → carte large + iframe sandbox (rendu WYSIWYG fidèle, scripts désactivés) -->
<div v-if="msg.html" class="email-card" :class="{ 'email-card-agent': msg.from === 'agent' }">
<iframe class="email-frame" :srcdoc="emailSrcdoc(msg.html)" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" loading="lazy" @load="fitEmailFrame"></iframe>
</div>
<!-- Bulle standard (SMS / chat / texte) -->
<div v-else class="conv-bubble">
<div class="conv-sender" v-if="msg.from === 'agent' && msg.via === 'ai'">AI Assistant</div>
<img v-if="msg.media" :src="msg.media" class="conv-img" @click="openImg(msg.media)" />
<q-btn v-if="msg.media && msg.from === 'customer'" dense flat size="sm" color="green-8" icon="receipt_long" label="Lire le paiement" no-caps class="q-mt-xs" :loading="paymentBusy === msg.id" @click="runPayment(msg)"><q-tooltip>OCR : détecter un paiement (montant, date, référence)</q-tooltip></q-btn>
<div class="conv-text" v-if="msg.text">{{ cleanBody(msg.text) }}</div>
<div class="conv-meta">
<span v-if="msg.via === 'sms'" class="conv-via">SMS</span>
<span v-else-if="msg.via === 'ai'" class="conv-via ai">AI</span>
<span v-else-if="msg.via === 'email'" class="conv-via">✉</span>
{{ relTime(msg.ts) }}
</div>
</div>
</div>
</div>
</template>
</div>
<div v-if="typingAgentLabel" class="conv-typing-line row items-center no-wrap">
<q-avatar size="22px" class="q-mr-xs presence-pulse" :style="{ background: staffColor(typingAgentRaw), color: '#fff', fontSize: '10px' }">{{ staffInitials(noteAuthorName(typingAgentRaw)) }}</q-avatar>
<q-spinner-dots size="18px" color="positive" class="q-mr-xs" /> <b>{{ typingAgentLabel }}</b>&nbsp;est en train d'écrire…
</div>
<!-- (Le brouillon — le mien ou celui d'un collègue — s'affiche désormais INLINE dans le fil, ancré au message auquel il répond. Voir _kind === 'draft' ci-dessus.) -->
<!-- COMPOSEUR UNIFIÉ (style Missive) : Répondre (client) / Note interne — boutons d'ACTION (« Répondre » OUVRE l'éditeur ; plus de doublon avec un CTA séparé) -->
<div v-if="activeToken && activeDiscussion.status === 'active'" class="conv-compose-switch row no-wrap">
<q-btn class="col" no-caps unelevated icon="reply" label="Répondre"
:color="composeMode === 'reply' ? 'primary' : 'grey-3'" :text-color="composeMode === 'reply' ? 'white' : 'grey-8'"
@click="startReplyMode" />
<q-btn class="col q-ml-xs" no-caps unelevated icon="sticky_note_2" label="Note interne"
:color="composeMode === 'note' ? 'amber-7' : 'grey-3'" :text-color="composeMode === 'note' ? 'white' : 'grey-8'"
@click="composeMode = 'note'" />
</div>
<!-- Mode NOTE INTERNE (ambre — jamais envoyé au client) -->
<div v-if="activeToken && activeDiscussion.status === 'active' && composeMode === 'note'" class="conv-note-compose">
<div class="row items-center q-gutter-xs">
<q-input ref="noteInputEl" dense outlined v-model="noteText" class="col" placeholder="Note interne… tapez @ pour taguer un collègue (jamais envoyé au client)" autofocus
@update:model-value="onNoteInput" @keydown.down="onMentionDown" @keydown.up="onMentionUp" @keydown.tab="onMentionTab" @keydown.space="onMentionSpace" @keydown.enter="onMentionEnter" @keydown.esc="mention.open = false">
<q-menu v-model="mention.open" no-parent-event no-focus fit anchor="bottom left" self="top left" style="min-width:250px">
<q-list dense>
<q-item-label header class="q-py-xs">Taguer un collègue · <span class="text-grey-6">↑↓ · <b>Tab</b>/<b>Espace</b> pour insérer</span></q-item-label>
<q-item v-for="(a, i) in mentionMatches" :key="a" clickable @click="pickMention(a)" :active="i === (mention.active || 0)" active-class="mention-active" @mouseenter="mention.active = i">
<q-item-section avatar><q-avatar size="24px" :style="{ background: staffColor(a), color: '#fff', fontSize: '10px' }">{{ staffInitials(noteAuthorName(a)) }}</q-avatar></q-item-section>
<q-item-section><q-item-label>{{ noteAuthorName(a) }}</q-item-label><q-item-label caption>@{{ shortAgent(a) }}</q-item-label></q-item-section>
<q-item-section side v-if="i === (mention.active || 0)"><q-icon name="keyboard_return" size="15px" color="warning" /></q-item-section>
</q-item>
<q-item v-if="!mentionMatches.length"><q-item-section class="text-grey-5 text-caption">Aucun collègue</q-item-section></q-item>
</q-list>
</q-menu>
</q-input>
<q-btn dense unelevated color="warning" icon="send" @click="submitNote"><q-tooltip>Enregistrer la note interne</q-tooltip></q-btn>
</div>
</div>
<!-- Mode RÉPONDRE : l'ÉDITEUR est rendu INLINE dans le fil (ancré au message — voir _kind==='compose'), ouvert par le bouton « Répondre » ci-dessus (plus de CTA dupliqué). -->
<div v-if="activeToken && activeDiscussion.status !== 'active'" class="q-pa-sm text-center text-grey-5 text-caption" style="border-top:1px solid #e2e8f0">
Discussion terminée
</div>
</div><!-- /.conv-scroll -->
</template>
<!-- Mode pleine page : conversation pas encore chargée -->
<template v-else>
<div class="conv-loading">
<div class="row items-center justify-center" style="gap:8px"><q-spinner-dots size="28px" color="green-5" /> Chargement de la conversation…</div>
<q-btn v-if="inline" flat dense no-caps color="primary" icon="arrow_back" label="Retour à la boîte" class="q-mt-md" @click="router.push('/communications')" />
</div>
</template>
</div>
<!-- Nouveau texto : rendu HORS du drawer (ouvrable depuis le FAB même panneau fermé). Résout la fiche par téléphone. -->
<q-dialog v-if="!inline" v-model="newDialogOpen" @hide="resetNew">
<q-card :style="newChannel === 'email' ? 'min-width:560px;max-width:96vw' : 'min-width:380px'">
<q-card-section class="text-weight-bold row items-center">
<q-icon :name="newChannel === 'email' ? 'mail' : 'sms'" style="color:#00C853" class="q-mr-sm" />{{ newChannel === 'email' ? 'Nouveau courriel' : 'Nouveau texto' }}
<q-space />
<q-btn-toggle v-model="newChannel" dense unelevated no-caps size="sm" toggle-color="positive" color="grey-3" text-color="grey-8" :options="[{ value: 'email', icon: 'mail', label: 'Courriel' }, { value: 'sms', icon: 'sms', label: 'SMS' }]" />
</q-card-section>
<q-card-section class="q-pt-none">
<!-- Recherche client (nom / adresse / téléphone) → résumé contacts → voir si SMS possible -->
<div style="position:relative" class="q-mb-sm">
<q-input v-model="custSearch" dense outlined label="Trouver un client (nom, adresse, téléphone)…" autocomplete="off" name="cust-nope" @update:model-value="onCustSearch">
<template #prepend><q-icon name="person_search" color="positive" /></template>
<template #append><q-spinner v-if="custSearching" size="16px" color="positive" /><q-icon v-else-if="custSearch" name="close" class="cursor-pointer" @click="custSearch = ''; custResults = []" /></template>
</q-input>
<div v-if="custResults.length" class="qm-dropdown">
<div v-for="m in custResults" :key="m.name" class="qm-item" @click="pickCust(m)">
<q-icon :name="m.matched === 'adresse' ? 'place' : m.matched === 'téléphone' ? 'call' : 'person'" size="16px" color="positive" class="q-mr-sm" />
<div class="col ellipsis"><div class="text-body2 ellipsis">{{ m.customer_name }}<q-badge v-if="m.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></div><div class="text-caption text-grey-6 ellipsis">{{ m.address || m.email || m.phone || m.territory }}</div></div>
<q-icon v-if="m.can_sms" name="sms" size="15px" color="green-6" class="q-ml-xs"><q-tooltip>Mobile — SMS possible</q-tooltip></q-icon>
</div>
</div>
</div>
<div v-if="custPicked" class="cust-summary q-mb-sm">
<div class="row items-center"><q-icon name="badge" color="info" size="16px" class="q-mr-xs" /><b>{{ custPicked.customer_name }}</b><q-badge v-if="custPicked.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /><q-space /><q-btn flat round dense size="xs" icon="close" color="grey-6" @click="clearCust" /></div>
<div class="text-caption q-mt-xs column q-gutter-xs">
<div><q-icon name="mail" size="13px" :color="custPicked.email ? 'green-5' : 'grey-4'" /> {{ custPicked.email || '— aucun courriel' }}</div>
<div><q-icon name="call" size="13px" :color="custPicked.phone ? 'teal-6' : 'grey-4'" /> {{ custPicked.phone || '— aucun numéro' }}
<q-badge v-if="custPicked.can_sms" color="green-2" text-color="green-9" label="SMS ✓" class="q-ml-xs" />
<q-badge v-else color="grey-3" text-color="grey-7" label="pas de mobile" class="q-ml-xs" />
</div>
<div v-if="custPicked.address"><q-icon name="place" size="13px" color="warning" /> {{ custPicked.address }}</div>
</div>
</div>
<template v-if="newChannel === 'sms'">
<q-input v-model="newPhone" dense outlined label="Téléphone du client" mask="(###) ###-####" unmasked-value autofocus :loading="newResolving" class="q-mb-sm" />
<div v-if="String(newPhone).replace(/\D/g,'').length >= 10" class="q-mb-sm">
<div v-if="newResolving" class="text-caption text-grey-6">Recherche de la fiche…</div>
<template v-else>
<div v-if="newMatches.length === 1" class="text-caption text-green-8"><q-icon name="link" /> Lié à <b>{{ newMatches[0].customer_name || newMatches[0].name }}</b></div>
<div v-else-if="newMatches.length > 1">
<div class="text-caption text-grey-7 q-mb-xs">{{ newMatches.length }} fiches partagent ce numéro — choisir :</div>
<q-option-group v-model="newLinkChoice" :options="matchOptions" type="radio" dense color="positive" />
</div>
<div v-else class="text-caption text-warning"><q-icon name="info" /> Aucune fiche — le texto partira non lié.</div>
</template>
</div>
<q-input v-model="newMessage" type="textarea" autogrow :rows="3" dense outlined label="Message" placeholder="Écrire le texto… (liens, etc.)" class="q-mb-xs" />
<div class="row items-center q-gutter-sm">
<q-btn flat dense no-caps size="sm" :icon="newMedia ? 'check_circle' : 'image'" :label="newMedia ? 'Image jointe' : 'Ajouter une image'" :color="newMedia ? 'green-7' : 'grey-7'" :loading="newUploading" @click="pickImage" />
<img v-if="newMedia" :src="newMedia" style="height:38px;border-radius:6px;border:1px solid #ddd" />
<q-btn v-if="newMedia" flat dense round size="xs" icon="close" color="grey-6" @click="newMedia = ''" />
<input ref="imgInput" type="file" accept="image/*" style="display:none" @change="onImage" />
</div>
</template>
<template v-else>
<div style="position:relative" class="q-mb-sm">
<q-input v-model="newEmail" dense outlined type="email" label="Destinataire (À) — courriel ou nom du client" autocomplete="off" name="to-nope" @update:model-value="onToSearch">
<template #prepend><q-icon name="alternate_email" color="green-5" /></template>
<template #append><q-spinner v-if="toSearching" size="14px" color="primary" /></template>
</q-input>
<div v-if="toResults.length" class="qm-dropdown">
<div v-for="m in toResults" :key="m.name" class="qm-item" @click="pickTo(m)">
<q-icon :name="m.kind === 'équipe' ? 'groups' : 'person'" size="15px" :color="m.kind === 'équipe' ? 'teal-6' : 'green-5'" class="q-mr-sm" />
<div class="col ellipsis"><div class="text-body2 ellipsis">{{ m.customer_name }}<q-badge v-if="m.kind === 'équipe'" color="green-1" text-color="green-9" class="q-ml-xs" label="Équipe" /></div><div class="text-caption text-grey-6 ellipsis">{{ m.email }}</div></div>
</div>
</div>
</div>
<!-- Cc / Cci : chips supprimables + autosuggestion (client → courriel, ou saisie libre) -->
<div class="q-mb-sm">
<div v-if="newCc.length || newBcc.length" class="row items-center q-gutter-xs q-mb-xs">
<q-chip v-for="(e, i) in newCc" :key="'cc' + i" dense removable size="sm" color="green-1" text-color="green-9" @remove="newCc.splice(i, 1)"><b class="q-mr-xs">Cc</b>{{ e }}</q-chip>
<q-chip v-for="(e, i) in newBcc" :key="'bcc' + i" dense removable size="sm" color="grey-3" text-color="grey-8" @remove="newBcc.splice(i, 1)"><b class="q-mr-xs">Cci</b>{{ e }}</q-chip>
</div>
<div style="position:relative">
<q-input v-model="ccSearch" dense outlined :placeholder="ccMode === 'cc' ? 'Ajouter Cc (courriel ou nom)…' : 'Ajouter Cci — copie cachée (courriel ou nom)…'" autocomplete="off" name="cc-nope" @update:model-value="onCcSearch" @keydown.enter.prevent="addCcTyped">
<template #prepend>
<q-btn-toggle v-model="ccMode" dense flat unelevated size="xs" toggle-color="primary" color="grey-5" :options="[{ label: 'Cc', value: 'cc' }, { label: 'Cci', value: 'bcc' }]" />
</template>
<template #append><q-spinner v-if="ccSearching" size="14px" color="primary" /><q-btn v-else-if="ccSearch.trim()" flat dense round size="sm" icon="add" color="primary" @click="addCcTyped"><q-tooltip>Ajouter</q-tooltip></q-btn></template>
</q-input>
<div v-if="ccResults.length" class="qm-dropdown">
<div v-for="m in ccResults" :key="m.name" class="qm-item" @click="addCcPicked(m)">
<q-icon :name="m.kind === 'équipe' ? 'groups' : 'person'" size="15px" :color="m.kind === 'équipe' ? 'teal-6' : 'green-5'" class="q-mr-sm" />
<div class="col ellipsis"><div class="text-body2 ellipsis">{{ m.customer_name }}<q-badge v-if="m.kind === 'équipe'" color="green-1" text-color="green-9" class="q-ml-xs" label="Équipe" /></div><div class="text-caption text-grey-6 ellipsis">{{ m.email }}</div></div>
</div>
</div>
</div>
</div>
<q-input v-model="newSubject" dense outlined label="Sujet" class="q-mb-sm" />
<div class="row items-center q-mb-sm">
<span class="text-caption text-grey-6 q-mr-sm">De :</span>
<q-select v-model="composeSendAs" :options="senderOpts" dense options-dense outlined emit-value map-options hide-bottom-space style="min-width:230px;max-width:340px">
<template #prepend><q-icon name="alternate_email" size="15px" color="grey-6" /></template>
</q-select>
</div>
<!-- Éditeur riche (même composeur que les réponses) + réponses types / signatures -->
<q-editor v-model="newHtml" min-height="9rem" max-height="48vh" class="email-editor"
:toolbar="[['bold', 'italic', 'underline', 'strike'], ['unordered', 'ordered'], ['link', 'removeFormat'], ['undo', 'redo']]"
placeholder="Rédiger le courriel… (mise en forme, listes, liens — part de support@, fil suivi dans l'Inbox)" />
<div v-if="composeAttachments.length" class="row items-center q-gutter-xs q-mt-xs">
<q-chip v-for="(a, i) in composeAttachments" :key="a.asset + i" dense removable size="sm" color="blue-grey-1" text-color="blue-grey-9" @remove="composeAttachments.splice(i, 1)">
<q-avatar v-if="/^image\//.test(a.mime)"><img :src="a.url"></q-avatar><span class="q-ml-xs ellipsis" style="max-width:130px">{{ a.filename }}</span>
</q-chip>
</div>
<input ref="composeAttInput" type="file" accept="image/*" multiple style="display:none" @change="e => onAttachFiles(e, 'compose')" />
<div v-if="composeAdvHtml" class="adv-ready row items-center q-mt-xs">
<q-icon name="auto_awesome" size="16px" class="q-mr-xs" style="color:#00C853" />
<span class="text-caption text-weight-medium" style="color:#1B2E24">Mise en page avancée prête — c'est elle qui sera envoyée</span>
<q-space />
<q-btn flat dense size="sm" no-caps icon="visibility" label="Aperçu" color="primary" @click="previewAdvHtml(composeAdvHtml)" />
<q-btn flat dense size="sm" no-caps label="Modifier" style="color:#00C853" @click="openAdvanced('compose')" />
<q-btn flat dense size="sm" no-caps color="negative" label="Retirer" @click="composeAdvHtml = ''" />
</div>
<div class="row items-center q-mt-xs">
<q-btn flat dense size="sm" icon="quickreply" color="grey-7" no-caps label="Réponses types / signatures" @click="loadCanned">
<q-menu anchor="top left" self="bottom left">
<q-list dense style="min-width:260px;max-width:340px">
<q-item-label header class="q-py-xs">Réponses pré-écrites / signatures</q-item-label>
<q-item v-for="c in cannedSorted" :key="c.id" clickable v-close-popup @click="insertCannedCompose(c)">
<q-item-section><q-item-label>{{ c.title }}</q-item-label><q-item-label caption lines="2">{{ c.body }}</q-item-label></q-item-section>
<q-item-section side v-if="c.html"><q-badge color="green-2" text-color="green-9" label="signature" /></q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
<q-btn flat dense size="sm" icon="attach_file" color="grey-7" no-caps label="Joindre" :loading="attUploading" class="q-ml-xs" @click="pickComposeAtt" />
<q-btn flat dense size="sm" icon="collections" color="grey-7" no-caps label="Bibliothèque" class="q-ml-xs" @click="openPresetGallery('compose')" />
<q-btn flat dense size="sm" icon="auto_awesome" color="green-7" no-caps label="Éditeur avancé" class="q-ml-xs" @click="openAdvanced('compose')"><q-tooltip>Composer un courriel mis en page (Unlayer)</q-tooltip></q-btn>
<q-space />
<span class="text-caption text-grey-5">↳ part de support@ / cc@</span>
</div>
</template>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn v-if="newChannel === 'sms'" unelevated icon="send" label="Envoyer le texto" class="giga-btn" :disable="String(newPhone).replace(/\D/g,'').length < 10 || (!newMessage.trim() && !newMedia) || creating" :loading="creating" @click="createNew" />
<q-btn v-else unelevated icon="send" label="Envoyer le courriel" class="giga-btn" :disable="!/.+@.+\..+/.test(newEmail) || (!composeHasBody && !composeAttachments.length && !composeAdvHtml) || creating" :loading="creating" @click="createNewEmail" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Créer un TICKET (ERPNext Issue OUVERT) depuis la conversation — la conversation RESTE dans l'historique -->
<!-- Éditeur avancé (Unlayer) : composer un courriel mis en page (blocs glisser-déposer) -->
<q-dialog v-model="advDlg.open" maximized>
<q-card class="column no-wrap">
<q-card-section class="row items-center q-py-sm" style="border-bottom:1px solid #e2e8f0">
<q-icon name="auto_awesome" class="q-mr-sm" style="color:#00C853" /><span class="text-weight-bold">Éditeur avancé — courriel mis en page</span>
<q-space />
<q-btn flat no-caps icon="close" label="Annuler" v-close-popup />
<q-btn unelevated no-caps icon="check" label="Insérer dans le courriel" class="giga-btn q-ml-sm" :disable="!advReady" @click="insertAdvanced" />
</q-card-section>
<div class="col" style="position:relative;min-height:0">
<EmailEditor ref="advEditor" :options="advEditorOptions" :min-height="'100%'" style="height:100%;width:100%" @ready="onAdvReady" />
</div>
</q-card>
</q-dialog>
<!-- Bibliothèque d'images : pièces jointes d'équipements fréquents, réutilisables (mêmes images qu'une FAQ) -->
<q-dialog v-model="presetDlg.open">
<q-card style="min-width:440px;max-width:96vw">
<q-card-section class="row items-center text-weight-bold">
<q-icon name="collections" color="primary" class="q-mr-sm" />Bibliothèque d'images
<q-space />
<q-btn flat dense no-caps icon="add_photo_alternate" color="primary" label="Ajouter" :loading="presetDlg.loading" @click="pickPresetUpload" />
</q-card-section>
<q-card-section class="q-pt-none">
<div v-if="presetDlg.loading && !presetDlg.list.length" class="flex flex-center q-pa-lg"><q-spinner color="primary" size="28px" /></div>
<div v-else-if="!presetDlg.list.length" class="text-grey-6 text-center q-pa-md">
<q-icon name="image" size="34px" color="grey-4" /><br>Aucune image. Ajoutez des photos d'équipements fréquents (modem, ONT, branchements…) pour les joindre en un clic.
</div>
<div v-else class="row q-col-gutter-sm">
<div v-for="p in presetDlg.list" :key="p.id" class="col-4">
<q-card flat bordered>
<q-img :src="p.url" fit="contain" class="cursor-pointer" style="height:104px;background:#f8fafc" @click="pickPreset(p); presetDlg.open = false"><q-tooltip>Joindre cette image</q-tooltip></q-img>
<div class="row items-center no-wrap q-px-xs">
<div class="text-caption ellipsis col">{{ p.name }}</div>
<q-btn flat dense round size="xs" icon="delete" color="grey-5" @click="removePreset(p)"><q-tooltip>Retirer de la bibliothèque</q-tooltip></q-btn>
</div>
</q-card>
</div>
</div>
</q-card-section>
<input ref="presetAddInput" type="file" accept="image/*" style="display:none" @change="addPresetFromFile" />
<q-card-actions align="right"><q-btn flat label="Fermer" v-close-popup /></q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="ticketDialog.open">
<q-card style="min-width:380px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="confirmation_number" color="positive" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Créer un ticket</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-sm">
<div class="text-caption text-grey-7">À partir de la conversation avec <b>{{ activeDiscussion && (activeDiscussion.customerName || activeDiscussion.phone) }}</b>. La conversation reste dans l'historique ; le ticket suit le travail.</div>
<q-input dense outlined v-model="ticketDialog.title" label="Titre" autofocus @keyup.enter="ticketDialog.title.trim() && submitTicket()" />
<div class="row q-col-gutter-sm">
<q-select class="col" dense outlined v-model="ticketDialog.category" :options="ticketCategories" label="Catégorie" />
<q-select class="col" dense outlined v-model="ticketDialog.priority" :options="['Low','Medium','High','Urgent']" label="Priorité" />
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="positive" icon="confirmation_number" label="Créer le ticket" :disable="!ticketDialog.title.trim()" :loading="creatingTicket" @click="submitTicket" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- SCINDER : l'IA détecte les problèmes distincts → 1 ticket par sujet (chaque équipe notifiée) -->
<q-dialog v-model="splitDialog.open">
<q-card style="min-width:420px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="call_split" color="warning" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Scinder la conversation</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div v-if="splitDialog.loading" class="text-center q-pa-md"><q-spinner color="warning" size="28px" /><div class="text-caption text-grey-6 q-mt-sm">Analyse des sujets…</div></div>
<template v-else>
<div class="text-caption text-grey-7 q-mb-sm">{{ splitDialog.topics.length > 1 ? 'L\'IA a détecté plusieurs sujets. Cochez ceux à transformer en tickets distincts :' : 'Un seul sujet détecté. Vous pouvez tout de même créer le ticket, ou ajuster manuellement.' }}</div>
<div v-for="(t, i) in splitDialog.topics" :key="i" class="row items-center q-gutter-sm q-mb-xs">
<q-checkbox v-model="t._sel" dense />
<q-input dense outlined class="col" v-model="t.title" :label="'Sujet ' + (i + 1)" />
<q-select dense outlined style="min-width:140px" v-model="t.queue" :options="QUEUES" label="File" />
</div>
<div v-if="!splitDialog.topics.length" class="text-grey-6 text-caption">Aucun sujet détecté.</div>
</template>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="warning" icon="call_split" :label="`Créer ${splitDialog.topics.filter(t => t._sel).length} ticket(s)`" :disable="!splitDialog.topics.some(t => t._sel && t.title.trim())" :loading="splitBusy" @click="confirmSplit" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- FUSIONNER : rapatrier une autre conversation (doublon) dans celle-ci -->
<q-dialog v-model="mergeDialog.open">
<q-card style="min-width:420px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="merge" color="blue-grey-7" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Fusionner une conversation</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div class="text-caption text-grey-7 q-mb-sm">La conversation choisie sera <b>rapatriée dans celle-ci</b> (messages, notes, tickets) puis masquée. Utile pour les doublons (même client, 2 fils).</div>
<q-input dense outlined v-model="mergeSearch" label="Filtrer (nom, courriel, téléphone)…" clearable class="q-mb-sm" />
<q-list bordered separator style="max-height:300px;overflow-y:auto;border-radius:8px">
<q-item v-for="d in mergeCandidates" :key="d.id" clickable :active="mergeDialog.from === d.token" active-class="bg-blue-grey-1" @click="mergeDialog.from = d.token">
<q-item-section avatar><q-icon :name="d.channel === 'email' ? 'mail' : 'sms'" :color="d.channel === 'email' ? 'red-5' : 'green-6'" /></q-item-section>
<q-item-section>
<q-item-label>{{ d.customerName || d.email || d.phone || d.token }}</q-item-label>
<q-item-label caption>{{ d.lastMessage ? (d.lastMessage.text || '').slice(0, 50) : '' }}</q-item-label>
</q-item-section>
<q-item-section side v-if="mergeDialog.from === d.token"><q-icon name="check_circle" color="blue-grey-7" /></q-item-section>
</q-item>
<q-item v-if="!mergeCandidates.length"><q-item-section class="text-grey-6 text-caption">Aucune autre conversation.</q-item-section></q-item>
</q-list>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="blue-grey-7" icon="merge" label="Fusionner ici" :disable="!mergeDialog.from" :loading="mergeBusy" @click="confirmMerge" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- LIER UNE FICHE CLIENT — résout par courriel (canal email) ou téléphone (SMS), comme à la création d'un texto -->
<q-dialog v-model="linkDialog.open">
<q-card style="min-width:440px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="person_add" color="info" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Lier une fiche client</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<!-- Références détectées par l'IA dans le message (n° de compte/réf, nom, adresse, tél.) → cliquer une puce lance la recherche → lien rapide d'un SMS d'inconnu. -->
<div v-if="linkRefChips.length" class="q-mb-sm">
<div class="text-caption text-weight-medium text-positive q-mb-xs"><q-icon name="auto_awesome" size="14px" /> Références détectées dans le message — cliquez pour rechercher :</div>
<div class="row q-gutter-xs">
<q-chip v-for="(c, i) in linkRefChips" :key="'ref' + i" dense clickable color="green-1" text-color="green-9" :icon="c.icon" @click="useRefChip(c)">{{ c.label }}<q-tooltip>Rechercher « {{ c.query }} » → lier</q-tooltip></q-chip>
</div>
</div>
<div v-if="linkDialog.suggestions && linkDialog.suggestions.length" class="q-mb-sm">
<div class="text-caption text-weight-medium text-primary q-mb-xs"><q-icon name="auto_awesome" size="14px" /> Suggestions recoupées du message (titulaire ≠ expéditeur possible)</div>
<q-list bordered separator style="border-radius:8px;overflow:hidden">
<q-item v-for="m in linkDialog.suggestions" :key="'sg-' + m.name" clickable @click="doLink(m)" class="bg-green-1">
<q-item-section avatar><q-avatar size="30px" color="green-3" text-color="green-10">{{ (m.customer_name || m.name).charAt(0) }}</q-avatar></q-item-section>
<q-item-section>
<q-item-label class="text-weight-medium">{{ m.customer_name || m.name }}<q-badge v-if="m.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></q-item-label>
<q-item-label caption>{{ m.address || m.email || m.phone || m.name }}</q-item-label>
<div class="q-mt-xs row q-gutter-xs"><q-badge v-for="s in (m.signals || [])" :key="s" color="green-2" text-color="green-9" :label="s" /></div>
</q-item-section>
<q-item-section side><q-icon name="link" color="primary" /></q-item-section>
</q-item>
</q-list>
<div class="text-caption text-grey-6 q-mt-sm">ou rechercher manuellement :</div>
</div>
<q-input dense outlined v-model="linkDialog.query" debounce="300" label="Rechercher (nom, courriel, téléphone, adresse, n° de réf.)…" autofocus @update:model-value="runLinkSearch" @keyup.enter="runLinkSearch" class="q-mb-sm">
<template #prepend><q-icon name="person_search" color="info" /></template>
<template #append><q-spinner v-if="linkDialog.loading" size="16px" color="info" /><q-icon v-else-if="linkDialog.query" name="close" class="cursor-pointer" @click="linkDialog.query = ''; linkDialog.matches = []" /></template>
</q-input>
<div v-if="linkDialog.query && !linkDialog.loading && linkDialog.matches.length" class="text-caption text-grey-6 q-mb-xs"><q-icon name="lightbulb" size="13px" color="warning" /> Suggestions d'après {{ linkIsEmail ? 'le courriel' : 'le numéro' }} de la conversation — ou tapez autre chose.</div>
<div v-if="linkDialog.loading" class="text-center q-pa-sm"><q-spinner color="info" size="24px" /></div>
<q-list v-else bordered separator style="max-height:300px;overflow-y:auto;border-radius:8px">
<q-item v-for="m in linkDialog.matches" :key="m.name" clickable @click="doLink(m)" :class="m.via ? 'bg-teal-1' : ''">
<q-item-section avatar>
<q-avatar size="30px" :color="m.via ? 'teal-2' : 'blue-1'" :text-color="m.via ? 'teal-9' : 'blue-9'">
<q-icon v-if="m.via" name="confirmation_number" size="18px" /><template v-else>{{ (m.customer_name || m.name).charAt(0) }}</template>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ m.customer_name || m.name }}</q-item-label>
<q-item-label caption><span v-if="m.via" class="text-positive text-weight-medium">{{ m.via }}<span v-if="m.subject"> · {{ m.subject }}</span></span><template v-else>{{ m.email || m.phone || m.address || m.name }}<span v-if="m.matched"> · par {{ m.matched }}</span><span v-if="m.inactive" class="text-grey-5"> · inactif</span></template></q-item-label>
</q-item-section>
<q-item-section side><q-icon name="link" color="info" /></q-item-section>
</q-item>
<q-item v-if="!linkDialog.matches.length && !linkDialog.ticket"><q-item-section class="text-grey-6 text-caption">Aucune fiche — ajustez la recherche. La conversation reste non liée.</q-item-section></q-item>
</q-list>
<!-- N° de réf. trouvé MAIS aucune fiche ERPNext (client non encore synchronisé) → on montre au moins l'identité du ticket -->
<div v-if="linkDialog.ticket" class="q-mt-sm q-pa-sm bg-amber-1 rounded-borders" style="font-size:0.8rem">
<div class="text-weight-medium text-warning"><q-icon name="confirmation_number" size="15px" /> Ticket #{{ linkDialog.ticket.ref }}<span v-if="linkDialog.ticket.status"> · {{ linkDialog.ticket.status }}</span> — aucune fiche ERPNext liée</div>
<div class="text-grey-8 q-mt-xs">{{ linkDialog.ticket.name }}<span v-if="linkDialog.ticket.email"> · {{ linkDialog.ticket.email }}</span><span v-if="linkDialog.ticket.phone"> · {{ linkDialog.ticket.phone }}</span></div>
<div v-if="linkDialog.ticket.address" class="text-grey-7">{{ linkDialog.ticket.address }}</div>
<div class="text-grey-6 q-mt-xs" style="font-size:0.72rem">Ce client n'est pas (encore) une fiche ERPNext — vérifiez la synchro, ou cherchez par nom/adresse.</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
<!-- COURRIEL AGRANDI — iframe sandbox plein écran (composant extrait) -->
<EmailExpandDialog v-model="emailExpand.open" :html="emailExpand.html" :subject="emailExpand.subject" />
<!-- Ajouter le fil courant à une tâche (réutilisable — conversation/ticket/projet) -->
<AddToTaskDialog v-model="addTaskOpen" :source="activeTaskSrc || taskSource" />
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted, defineAsyncComponent } from 'vue'
// Chargé à la demande (lourd) : l'éditeur Unlayer ne se télécharge qu'à l'ouverture de « Éditeur avancé », pas au montage du panneau.
const EmailEditor = defineAsyncComponent(() => import('vue-email-editor').then(m => m.EmailEditor))
import { useQuasar } from 'quasar'
import { useConversations } from 'src/composables/useConversations'
import { sanitizeHtml } from 'src/utils/sanitize'
import { getDoc, listDocs } from 'src/api/erp'
import RecipientSelect from 'src/components/shared/RecipientSelect.vue'
import { useSoftphone } from 'src/composables/useSoftphone'
import { shortAgent, staffColor, staffInitials, noteAuthorName, relTime } from 'src/composables/useFormatters'
import { recipientLabel, messageIdentity, channelMeta, titleCase, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay'
import { useAuthStore } from 'src/stores/auth'
import { useRouter } from 'vue-router'
import AddToTaskDialog from 'src/components/shared/AddToTaskDialog.vue'
import EmailExpandDialog from 'src/components/shared/conversation/EmailExpandDialog.vue'
import { emailSrcdoc } from 'src/composables/useEmailRender'
const $q = useQuasar()
const router = useRouter()
const props = defineProps({ inline: { type: Boolean, default: false } }) // inline=true → rendu pleine page (route) au lieu du tiroir
const {
discussions, activeToken, activeDiscussion, activeConv, tickets, loading, panelOpen, newDialogOpen, newDialogChannel, composePrefill, showAll, activeCount,
selectedIds, selectedDiscussions,
fetchList, fetchTickets, openDiscussion, sendMessage, startConversation, startEmail, resolvePhone, linkCustomer, searchConversations, relateConversation, setPriority, closeConversation,
deleteDiscussion, bulkDelete, archiveDiscussion, bulkArchive, createTicket,
QUEUES, assignQueue, claimConv, releaseConv, assignConv, setConvState, fetchAgents, spamConv, deleteConvByToken, addNote, editNote, deleteNote, fetchAttachments, nlCommand,
splitPreview, splitConversation, mergeConversation, fetchCanned, saveCanned, analyzePayment, summarize, serviceability, fetchInboxRules, saveInboxRules,
toggleSelect, selectAll, clearSelection,
goBack, disconnectSSE, connectGlobalSSE, HUB_URL,
agentTyping, notifyTyping, sharedDraft, pushDraft,
} = useConversations()
// ── Identité d'expédition « De : » — défaut = groupe (anonymisé), modifiable par message ──
const senders = ref({ default: '', identities: [] })
const replySendAs = ref('')
const composeSendAs = ref('')
async function loadSenders () {
try {
const r = await fetch(`${HUB_URL}/conversations/senders`)
if (!r.ok) return
senders.value = await r.json()
if (!replySendAs.value) replySendAs.value = senders.value.default || ''
if (!composeSendAs.value) composeSendAs.value = senders.value.default || ''
} catch { /* le défaut serveur (groupe) s'applique de toute façon */ }
}
const senderOpts = computed(() => [
...(senders.value.identities || []).map(i => ({ label: String(i.from || '').replace(/"/g, ''), value: i.from })),
{ label: 'Signer en mon nom', value: 'personal' },
])
onMounted(loadSenders)
// Racine dynamique : tiroir (défaut) OU bloc pleine page (prop `inline`) — MÊME composant/logique (DRY).
// Fenêtre FLOTTANTE (mode non-inline) : libre, déplaçable par la barre de titre,
// et NON bloquante (aucun fond modal) → on peut continuer à naviguer (y compris
// dans la fiche client) tout en discutant. Remplace l'ancien volet ancré à droite.
const floatPos = ref(null) // { left, top } px ; null = coin bas-droite par défaut (CSS)
const floatStyle = computed(() => floatPos.value
? { left: floatPos.value.left + 'px', top: floatPos.value.top + 'px', right: 'auto', bottom: 'auto' }
: null)
let _drag = null
function startDrag (e) {
if (props.inline || e.button !== 0) return
// Ne pas déclencher le déplacement quand on clique une action de la barre.
if (e.target.closest('button, .q-btn, input, a, .q-chip, .q-toggle')) return
const panel = e.currentTarget.closest('.conv-panel')
if (!panel) return
const r = panel.getBoundingClientRect()
_drag = { dx: e.clientX - r.left, dy: e.clientY - r.top, w: r.width, h: r.height }
window.addEventListener('pointermove', onDrag)
window.addEventListener('pointerup', endDrag)
e.preventDefault()
}
function onDrag (e) {
if (!_drag) return
const m = 6
const left = Math.max(m, Math.min(e.clientX - _drag.dx, window.innerWidth - _drag.w - m))
const top = Math.max(m, Math.min(e.clientY - _drag.dy, window.innerHeight - _drag.h - m))
floatPos.value = { left, top }
}
function endDrag () { _drag = null; window.removeEventListener('pointermove', onDrag); window.removeEventListener('pointerup', endDrag) }
const coordOpen = ref(false) // chrome de coordination replié par défaut (épuration de la vue conversation)
// ── Créer un ticket (ERPNext Issue OUVERT) depuis la conversation — la conversation RESTE ──
const ERP_BASE = 'https://erp.gigafibre.ca'
const ticketCategories = ['Support', 'Facturation', 'Installation', 'Fibre', 'Télévision', 'Téléphonie', 'Commercial', 'Autre']
const creatingTicket = ref(false)
const ticketDialog = ref({ open: false, title: '', category: 'Support', priority: 'Medium' })
const linkedTickets = computed(() => (activeConv.value?.linkedTickets || activeDiscussion.value?.linkedTickets || []))
// ── LIENS / interdépendances (style ClickUp) : autres fils / fiches / tickets reliés à CE fil ──
const convLinks = computed(() => (activeConv.value?.links || activeDiscussion.value?.links || []))
// ── PRIORITÉ (style ClickUp) ──
const convPriority = computed(() => activeConv.value?.priority || activeDiscussion.value?.priority || '')
async function setConvPriority (p) {
if (!coordToken.value) return
try { await setPriority(coordToken.value, p); if (activeDiscussion.value) activeDiscussion.value.priority = p } catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
function linkChip (type) {
if (type === 'customer') return { icon: 'account_circle', color: 'blue-6', label: 'Fiche client' }
if (type === 'ticket') return { icon: 'confirmation_number', color: 'teal-6', label: 'Ticket' }
return { icon: 'forum', color: 'green-5', label: 'Conversation' }
}
function openConvLink (l) {
if (!l) return
if (l.type === 'customer') router.push('/clients/' + encodeURIComponent(l.id))
else if (l.type === 'ticket') openTicket(l.id)
else router.push('/communications/c/' + l.id)
}
async function removeConvLink (l) {
if (!coordToken.value || !l) return
try { await relateConversation(coordToken.value, { targetType: l.type, targetId: l.id, remove: true }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
const relateDialog = ref(false); const relateQuery = ref(''); const relateResults = ref([]); const relateBusy = ref(false)
const relateType = ref('conversation') // 'conversation' | 'customer' | 'ticket'
let _relateSeq = 0
function openRelate () { relateQuery.value = ''; relateResults.value = []; relateType.value = 'conversation'; relateDialog.value = true }
function onRelateType (t) { relateType.value = t; relateResults.value = []; if (relateQuery.value.trim().length >= 2) onRelateSearch(relateQuery.value) }
async function onRelateSearch (v) {
const q = String(v || '').trim()
if (q.length < 2) { relateResults.value = []; relateBusy.value = false; return }
const seq = ++_relateSeq; relateBusy.value = true
let out = []
try {
if (relateType.value === 'customer') {
// Recherche FLOUE (pg_trgm, typo-tolérante) + multi-champs (nom/courriel/tél/adresse) via le hub — pas de listDocs sous-chaîne.
const m = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}`).then(r => r.ok ? r.json() : { matches: [] }).then(d => d.matches || []).catch(() => [])
out = m.map(x => ({ id: x.name, label: x.customer_name || x.name, sub: [x.matched, x.address].filter(Boolean).join(' · '), icon: 'account_circle', color: 'blue-6' }))
} else if (relateType.value === 'ticket') {
const t = await listDocs('Issue', { or_filters: { subject: ['like', '%' + q + '%'], name: ['like', '%' + q + '%'] }, fields: ['name', 'subject', 'status'], limit: 12, orderBy: 'modified desc' }).catch(() => [])
out = (t || []).map(x => ({ id: x.name, label: x.subject || x.name, sub: x.name + (x.status ? ' · ' + x.status : ''), icon: 'confirmation_number', color: 'teal-6' }))
} else {
const r = await searchConversations(q)
out = (r || []).filter(x => x.token !== coordToken.value).map(x => ({ id: x.token, label: x.customerName || x.email || 'Conversation', sub: x.subject || x.snippet || '', icon: channelMeta(x.channel).icon, color: channelMeta(x.channel).color }))
}
} catch (e) { out = [] }
if (seq !== _relateSeq) return
relateResults.value = out
relateBusy.value = false
}
async function doRelate (r) {
if (!coordToken.value || !r) return
try {
await relateConversation(coordToken.value, { targetType: relateType.value, targetId: r.id, targetLabel: r.label })
relateDialog.value = false
$q.notify({ type: 'positive', icon: 'link', message: 'Lié à ' + (r.label || r.id) })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
function openTicketDialog () {
const d = activeDiscussion.value
const sug = coordSuggested.value // pré-remplissage depuis la suggestion IA si présente
const lastCust = (d && d.messages) ? [...d.messages].reverse().find(m => m.from === 'customer') : null
ticketDialog.value = {
open: true,
title: (sug && sug.title) || (d && d.subject) || (lastCust && (lastCust.text || '').slice(0, 90)) || '',
category: (sug && sug.category) || 'Support',
priority: (sug && sug.priority) || 'Medium',
}
}
// ── SCINDER (split) : l'IA détecte les sujets distincts → 1 ticket par sujet ──
const splitBusy = ref(false)
const splitDialog = ref({ open: false, loading: false, topics: [] })
async function openSplit () {
if (!coordToken.value) return
splitDialog.value = { open: true, loading: true, topics: [] }
try {
const d = await splitPreview(coordToken.value)
splitDialog.value.topics = (d.topics || []).map(t => ({ title: t.title || '', queue: t.queue || '', category: t.category || 'Autre', _sel: true }))
if (!splitDialog.value.topics.length) $q.notify({ type: 'info', message: 'Aucun sujet distinct détecté.' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { splitDialog.value.loading = false }
}
async function confirmSplit () {
const topics = splitDialog.value.topics.filter(t => t._sel && t.title.trim()).map(t => ({ title: t.title.trim(), category: t.category, queue: t.queue }))
if (!topics.length || !coordToken.value) return
splitBusy.value = true
try {
const r = await splitConversation(coordToken.value, topics)
$q.notify({ type: 'positive', message: `${r.count} ticket(s) créé(s) : ${(r.tickets || []).map(x => x.issue).join(', ')}` })
splitDialog.value.open = false
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { splitBusy.value = false }
}
// ── FUSIONNER (merge) : rapatrier une autre conversation (doublon) dans celle-ci ──
const mergeBusy = ref(false)
const mergeSearch = ref('')
const mergeDialog = ref({ open: false, from: null })
function openMerge () { if (!coordToken.value) return; mergeSearch.value = ''; mergeDialog.value = { open: true, from: null } }
const mergeCandidates = computed(() => {
const cur = coordToken.value
const q = mergeSearch.value.trim().toLowerCase()
return (discussions.value || []).filter(d => d.token && d.token !== cur).filter(d => {
if (!q) return true
return [d.customerName, d.email, d.phone].some(x => String(x || '').toLowerCase().includes(q))
}).slice(0, 50)
})
async function confirmMerge () {
if (!mergeDialog.value.from || !coordToken.value) return
mergeBusy.value = true
try {
await mergeConversation(coordToken.value, mergeDialog.value.from)
$q.notify({ type: 'positive', message: 'Conversation fusionnée ici.' })
mergeDialog.value.open = false
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { mergeBusy.value = false }
}
// ── Réponses pré-écrites (canned) : insertion dans le brouillon avec variables {{client}} {{agent}} ──
const cannedList = ref([])
async function loadCanned () { cannedList.value = await fetchCanned() }
function insertCanned (c) {
const d = activeDiscussion.value
const client = (d && (d.customerName || d.email || d.phone)) || 'client'
const agent = shortAgent(coordAssignee.value) || 'l\'équipe TARGO'
const sub = (s) => String(s || '').replace(/\{\{\s*client\s*\}\}/gi, client).replace(/\{\{\s*agent\s*\}\}/gi, agent)
if (isEmailConv.value && c.html) { // réponse RICHE (signature avec images) → insérée telle quelle dans l'éditeur WYSIWYG
const html = sub(c.html)
draftHtml.value = draftHtml.value ? (draftHtml.value + '<br>' + html) : html
return
}
const body = sub(c.body || '')
if (isEmailConv.value) { // canal courriel, réponse texte → conversion sauts de ligne → <br>
const htmlBody = body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>')
draftHtml.value = draftHtml.value ? (draftHtml.value + '<br>' + htmlBody) : htmlBody
} else {
draft.value = draft.value ? (draft.value.trimEnd() + '\n' + body) : body
nextTick(() => scrollToBottom())
}
}
// ── Édition EN PLACE d'une réponse/signature (sans passer par Paramètres) ──
const cannedMailboxes = [{ label: 'Toutes', value: '' }, { label: 'Facturations', value: 'Facturations' }, { label: 'Service à la clientèle', value: 'Service à la clientèle' }, { label: 'Support', value: 'Supports' }, { label: 'Technicien', value: 'Technicien' }]
const cannedEdit = ref({ open: false, isNew: false, id: '', title: '', mailbox: '', html: '' })
const cannedSaving = ref(false)
const cannedUploading = ref(false)
const cannedFileInput = ref(null)
function cannedTextToHtml (t) { return String(t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>') }
function cannedHtmlToText (h) { return String(h || '').replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|li|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').trim() }
function newCanned () { cannedEdit.value = { open: true, isNew: true, id: 'c' + Date.now() + Math.round(Math.random() * 1e5), title: '', mailbox: coordQueue.value || '', html: '' } }
function editCanned (c) { cannedEdit.value = { open: true, isNew: false, id: c.id, title: c.title || '', mailbox: c.mailbox || '', html: c.html || cannedTextToHtml(c.body) } }
function pickCannedImage () { if (cannedFileInput.value) { cannedFileInput.value.value = ''; cannedFileInput.value.click() } }
async function onCannedFile (e) {
const f = e.target.files && e.target.files[0]; if (!f) return
cannedUploading.value = true
try {
const data = await new Promise((resolve, reject) => { const fr = new FileReader(); fr.onload = () => resolve(fr.result); fr.onerror = reject; fr.readAsDataURL(f) })
const r = await fetch(`${HUB_URL}/campaigns/assets/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: f.name, data }) })
const d = await r.json().catch(() => ({})); if (!r.ok || !d.url) throw new Error(d.error || 'Téléversement échoué')
cannedEdit.value.html = (cannedEdit.value.html || '') + `<img src="${d.url}" alt="" style="max-width:280px;height:auto" />`
} catch (err) { $q.notify({ type: 'negative', message: err.message }) } finally { cannedUploading.value = false }
}
async function saveCannedEdit () {
const e = cannedEdit.value
if (!String(e.title || '').trim() && !cannedHtmlToText(e.html).trim()) { $q.notify({ type: 'warning', message: 'Titre ou contenu requis.' }); return }
cannedSaving.value = true
try {
const list = cannedList.value.slice()
const item = { id: e.id, title: e.title, category: 'Général', mailbox: e.mailbox || '', html: e.html || '', body: cannedHtmlToText(e.html) }
const idx = list.findIndex(x => x.id === e.id)
if (idx >= 0) list[idx] = item; else list.push(item)
if (!(await saveCanned(list))) throw new Error('Échec sauvegarde')
cannedList.value = list
$q.notify({ type: 'positive', message: e.isNew ? 'Réponse créée' : 'Réponse mise à jour' })
cannedEdit.value.open = false
} catch (err) { $q.notify({ type: 'negative', message: err.message }) } finally { cannedSaving.value = false }
}
async function deleteCannedEdit () {
const list = cannedList.value.filter(x => x.id !== cannedEdit.value.id)
cannedSaving.value = true
try { if (!(await saveCanned(list))) throw new Error('Échec'); cannedList.value = list; $q.notify({ type: 'positive', message: 'Supprimée' }); cannedEdit.value.open = false } catch (err) { $q.notify({ type: 'negative', message: err.message }) } finally { cannedSaving.value = false }
}
// ── COURRIEL : vue WYSIWYG (iframe sandbox) + réponse riche (q-editor) + lien fiche par courriel ──
const isEmailConv = computed(() => (activeDiscussion.value?.channel || activeConv.value?.channel) === 'email')
const draftHtml = ref('')
// Suppression TEMPORAIRE du push de brouillon : pendant un reset (changement de fil) ou une restauration,
// les mutations programmatiques de draftHtml/draft ne doivent PAS écraser le brouillon persisté côté hub.
const _suppressPush = ref(false)
// ── Diagnostic service/modem du client lié (depuis le courriel) + image attachable à la réponse ──
const coordCustomer = computed(() => activeConv.value?.customer || activeDiscussion.value?.customer || null)
// Adresse de DESTINATION (mailbox reçue, ex. support@targo.ca) du dernier courriel ENTRANT → affichée « à <…> » sous l'expéditeur (style Gmail).
const convTo = computed(() => { const m = activeDiscussion.value?.messages || []; for (let i = m.length - 1; i >= 0; i--) { if (m[i].from === 'customer' && m[i].toEmail) return m[i].toEmail } return '' })
// Libellé du destinataire d'entête : nom d'équipe pour nos boîtes de groupe (support@ → « Support »), sinon l'adresse brute. meEmail est résolu plus bas — computed lazy, OK.
const convToLabel = computed(() => recipientLabel(convTo.value, meEmail.value) || convTo.value)
// Titre du fil (sujet du courriel) affiché en tête du détail, style Gmail. Vide pour SMS/chat ou placeholder « (courriel) ».
const convSubject = computed(() => { const s = activeDiscussion.value?.subject || activeDiscussion.value?.lastSubject || ''; return (s && s !== '(courriel)') ? s : '' })
// Nom du COMPTE de la fiche liée (≠ nom de l'expéditeur du courriel) : résolu depuis l'id Customer pour l'afficher dans la pastille.
const coordAccountName = ref('')
watch(coordCustomer, async (id) => {
coordAccountName.value = ''
if (!id) return
try { const d = await getDoc('Customer', id); coordAccountName.value = (d && d.customer_name) || '' } catch (e) { /* non bloquant */ }
}, { immediate: true })
// ── A : Re-synchroniser ce fil depuis Gmail — récupère les réponses envoyées hors hub + les courriels manqués (panne / sortis de la boîte) ──
const resyncing = ref(false)
async function doResync () {
if (!coordToken.value) return
resyncing.value = true
try {
const r = await fetch(`${HUB_URL}/conversations/${coordToken.value}/resync`, { method: 'POST' }).then(x => x.json())
if (!r.ok) throw new Error(r.error || 'échec')
// recharge le fil RÉ-TRIÉ depuis le hub (les messages récupérés peuvent être plus ANCIENS que ceux déjà affichés)
const d = await fetch(`${HUB_URL}/conversations/${coordToken.value}`).then(x => x.ok ? x.json() : null)
if (d && d.messages && activeDiscussion.value) activeDiscussion.value.messages = d.messages
$q.notify({ type: r.added ? 'positive' : 'info', icon: r.added ? 'cloud_download' : 'check', message: r.added ? `${r.added} message(s) récupéré(s) depuis Gmail` : 'Fil déjà à jour — aucun message manquant' })
} catch (e) { $q.notify({ type: 'negative', message: 'Re-synchro : ' + e.message }) } finally { resyncing.value = false }
}
// ── C : « Filtrer comme ceci » (règle masquer/afficher par expéditeur ou sujet) — déplacé de la barre de liste vers le message détaillé ──
const filterDialog = ref({ open: false, field: 'from', contains: '', action: 'mask', srcEmail: '', srcSubject: '' })
function openFilterLike () {
const d = activeDiscussion.value || {}
const email = d.email || ''
filterDialog.value = { open: true, field: 'from', contains: email || d.customerName || '', action: 'mask', srcEmail: email, srcSubject: convSubject.value || d.subject || '' }
}
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 && r.retro ? ` · ${r.retro} conv. mise(s) à jour` : '') })
filterDialog.value.open = false
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
// ── Destinataires de CHAQUE message (style Gmail « à moi, Dominique ») ──
// Liste TOUS les destinataires (en-têtes To + Cc) ; notre boîte de réception = « moi » ; les autres par leur nom.
// Repli sur l'ancien champ toEmail, ou le courriel du client pour nos réponses (messages plus anciens sans To/Cc complets).
// → [{ label, email }] : destinataires To+Cc ; notre boîte = « moi » mais l'ADRESSE réelle est conservée (révélée au survol).
function msgRecipientList (msg) {
if (!msg) return []
let raw = [msg.toRaw || '', msg.ccRaw || ''].filter(Boolean).join(', ')
if (!raw) raw = msg.toEmail || (msg.from === 'agent' ? (activeDiscussion.value?.email || '') : '')
if (!raw) return []
const seen = new Set(); const out = []
for (const part of raw.split(',').map(s => s.trim()).filter(Boolean)) {
const br = part.match(/<([^>]+)>/)
const email = (br ? br[1] : part).trim()
const elc = email.toLowerCase()
if (!elc.includes('@') || seen.has(elc)) continue
seen.add(elc)
// resolver UNIQUE : « moi » SEULEMENT si == utilisateur connecté ; adresse de groupe → nom d'équipe ; sinon display-name / local part.
let label = recipientLabel(elc, meEmail.value)
if (!label) { let name = br ? part.slice(0, part.indexOf('<')).replace(/["']/g, '').trim() : ''; if (!name) name = email.split('@')[0].replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); label = name }
out.push({ label, email })
}
// « moi » d'abord (style Gmail), puis le reste
out.sort((a, b) => (a.label === 'moi' ? -1 : 0) - (b.label === 'moi' ? -1 : 0))
return out.slice(0, 8)
}
function msgTo (msg) { return msgRecipientList(msg).map(r => r.label).join(', ') } // libellés compacts (« moi, Dominique »)
function msgToFull (msg) { return msgRecipientList(msg).map(r => r.label === 'moi' ? ('moi → ' + r.email) : (r.label + ' <' + r.email + '>')).join('\n') } // adresses réelles (révélées au survol/clic)
const svcStatus = ref(null)
const svcLoading = ref(false)
const svcAttaching = ref(false)
const diagSending = ref(false)
// NB: la réinit de svcStatus au changement de conversation est fusionnée dans le watch(coordToken) plus bas
// (coordToken n'est déclaré qu'à la l.~818 — un watch ici lèverait un ReferenceError TDZ au setup → le panneau ne monte plus).
// Insère un lien de diagnostic libre-service (token signé) dans la réponse — le client voit son état en direct.
async function sendDiagLink () {
if (!coordCustomer.value) return
diagSending.value = true
try {
const r = await fetch(`${HUB_URL}/collab/diag-link?customer=${encodeURIComponent(coordCustomer.value)}`)
const d = await r.json(); if (!d.link) throw new Error(d.error || 'lien indisponible')
if (isEmailConv.value) draftHtml.value = (draftHtml.value || '') + `<br><p>Vérifiez l'état de votre service en direct : <a href="${d.link}">${d.link}</a></p>`
else draft.value = (draft.value ? draft.value.trimEnd() + '\n' : '') + `Vérifiez l'état de votre service en direct : ${d.link}`
$q.notify({ type: 'positive', message: 'Lien de diagnostic inséré dans la réponse' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { diagSending.value = false }
}
async function loadServiceStatus () {
if (!coordCustomer.value) return
svcLoading.value = true; svcStatus.value = null
try { const r = await fetch(`${HUB_URL}/collab/service-status?customer=${encodeURIComponent(coordCustomer.value)}`); svcStatus.value = await r.json() } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { svcLoading.value = false }
}
function svcColor (o) { return o === true ? 'green-6' : o === false ? 'red-6' : 'grey-5' }
function svcIcon (o) { return o === true ? 'check_circle' : o === false ? 'cancel' : 'help' }
// Image (SVG → PNG) propre de l'état du service, pour l'attacher à la réponse (courriel = <img>, SMS = MMS).
function buildStatusSvg (s) {
const esc = (t) => String(t == null ? '' : t).replace(/[<&>]/g, c => ({ '<': '&lt;', '&': '&amp;', '>': '&gt;' }[c]))
const W = 520; const devs = (s.devices || []).slice(0, 4)
const H = 100 + devs.length * 22 + 28
const onCol = (o) => o === true ? '#16a34a' : o === false ? '#dc2626' : '#94a3b8'
const sum = s.summary || {}
let y = 92
const lines = devs.map(d => { const t = `<circle cx="26" cy="${y - 4}" r="4" fill="${onCol(d.online)}"/><text x="40" y="${y}" font-size="13" fill="#334155">${esc(d.serial || 'Modem')} — ${d.online ? 'en ligne' : d.online === false ? 'hors ligne' : 'inconnu'}${d.signal ? ' · signal ' + esc(d.signal) : ''}${d.rxPower != null ? ' (' + d.rxPower + ' dBm)' : ''}</text>`; y += 22; return t }).join('')
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
+ `<rect width="${W}" height="${H}" rx="12" fill="#ffffff" stroke="#e2e8f0"/>`
+ `<path d="M0 12 a12 12 0 0 1 12 -12 h${W - 24} a12 12 0 0 1 12 12 v28 h-${W} z" fill="#0e7490"/>`
+ `<text x="20" y="26" font-size="15" font-weight="bold" fill="#ffffff">État du service — ${esc((s.customer && s.customer.customer_name) || '')}</text>`
+ `<text x="20" y="62" font-size="12" fill="#64748b">${esc(s.location ? (s.location.address || s.location.name) : '')}</text>`
+ `<circle cx="26" cy="82" r="6" fill="${onCol(sum.online)}"/><text x="40" y="87" font-size="14" font-weight="bold" fill="#0f172a">${esc(sum.label || '')}${sum.detail ? ' — ' + esc(sum.detail) : ''}</text>`
+ lines
+ `<text x="20" y="${H - 12}" font-size="11" fill="#94a3b8">Gigafibre · TARGO — diagnostic du ${esc(new Date().toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' }))}</text>`
+ `</svg>`
}
function svgToPngDataUrl (svg, w, h) {
return new Promise((resolve, reject) => {
const img = new Image()
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }))
img.onload = () => {
const c = document.createElement('canvas'); c.width = w * 2; c.height = h * 2
const ctx = c.getContext('2d'); ctx.scale(2, 2); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h); ctx.drawImage(img, 0, 0, w, h)
URL.revokeObjectURL(url); resolve(c.toDataURL('image/png'))
}
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('rendu image échoué')) }
img.src = url
})
}
async function attachStatusImage () {
if (!svcStatus.value || !svcStatus.value.found) return
svcAttaching.value = true
try {
const svg = buildStatusSvg(svcStatus.value)
const h = Number((svg.match(/height="(\d+)"/) || [])[1]) || 200
const dataUrl = await svgToPngDataUrl(svg, 520, h)
const up = await fetch(`${HUB_URL}/campaigns/assets/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'etat-service.png', data: dataUrl }) })
const d = await up.json(); if (!d.url) throw new Error(d.error || 'upload échoué')
if (isEmailConv.value) { draftHtml.value = (draftHtml.value || '') + `<br><img src="${d.url}" alt="État du service" style="max-width:100%;border:1px solid #e2e8f0;border-radius:8px"/>`; $q.notify({ type: 'positive', message: 'Image de l\'état insérée dans le courriel' }) }
else { replyMedia.value = d.url; $q.notify({ type: 'positive', message: 'Image de l\'état jointe au texto' }) }
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { svcAttaching.value = false }
}
const hasEmailDraft = computed(() => String(draftHtml.value || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim().length > 0)
// Contenu RÉEL au-delà de la citation auto-insérée : un simple clic « Répondre » pré-remplit la citation — ça ne doit PAS créer un brouillon fantôme (pastille « brouillon ») ni permettre l'envoi d'une réponse vide.
function stripQuoted (html) { return String(html || '').replace(/<blockquote[\s\S]*?<\/blockquote>/gi, '') }
const hasRealReply = computed(() => stripQuoted(draftHtml.value).replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim().length > 0)
// Repli de la citation (style Gmail) : la citation est masquée par défaut → l'agent écrit au propre ; elle reste dans draftHtml (donc envoyée).
const quoteFolded = ref(true)
const hasQuote = computed(() => /<blockquote/i.test(draftHtml.value || ''))
// À / Cc éditables sur la réponse (« répondre à tous » / boucler un collègue) — panneau replié par défaut.
const replyToList = ref([]) // destinataires À (défaut = le client si vide côté hub)
const replyCcList = ref([]) // Cc
const showCcTo = ref(false)
// ── Éditeur avancé (Unlayer) : courriel mis en page ; le HTML exporté est PRIORITAIRE à l'envoi ; design JSON gardé pour ré-édition ──
const advHtml = ref('') // réponse
const composeAdvHtml = ref('') // nouveau courriel
const advDesign = ref({ reply: null, compose: null })
const advDlg = ref({ open: false, target: 'reply' })
const advEditor = ref(null)
const advReady = ref(false)
const advEditorOptions = { appearance: { theme: 'modern_light' } }
function openAdvanced (target) { advReady.value = false; advDlg.value = { open: true, target } }
function onAdvReady () {
advReady.value = true
// Images insérées dans Unlayer → NOTRE store d'actifs (pas le CDN d'Unlayer) ; le courriel pointe vers msg.gigafibre.ca.
try {
advEditor.value.registerCallback('image', (file, done) => {
const f = file && file.attachments && file.attachments[0]
if (!f) { done({ progress: 100 }); return }
done({ progress: 20 })
uploadAttachment(f).then(r => done({ progress: 100, url: r.url })).catch(() => { $q.notify({ type: 'negative', message: "Échec du téléversement de l'image" }); done({ progress: 100 }) })
})
} catch (e) { /* */ }
const d = advDesign.value[advDlg.value.target]
if (d && advEditor.value) { try { advEditor.value.loadDesign(d) } catch (e) { /* */ } }
}
function insertAdvanced () {
if (!advEditor.value) return
advEditor.value.exportHtml((data) => {
const html = (data && data.html) || ''
advDesign.value[advDlg.value.target] = (data && data.design) || null
if (advDlg.value.target === 'compose') composeAdvHtml.value = html; else advHtml.value = html
advDlg.value.open = false
})
}
// Enveloppe le HTML du courriel pour l'iframe : largeur contrainte, liens en nouvel onglet, styles de base. Scripts NEUTRALISÉS par l'attribut sandbox.
// emailSrcdoc() est désormais importé de composables/useEmailRender (partagé avec l'aperçu inline).
const emailExpand = ref({ open: false, html: '', subject: '' })
// Aperçu d'un courriel mis en page (composeAdvHtml/advHtml) — {{rating}} → étoiles fictives pour la visu.
function previewAdvHtml (html) { emailExpand.value = { open: true, subject: 'Aperçu — courriel mis en page', html: String(html || '').replace(/\{\{\s*rating\s*\}\}/gi, '<div style="font-size:30px;color:#f5b50a;letter-spacing:4px;text-align:center">★★★★★</div>') } }
function expandEmail (msg) { emailExpand.value = { open: true, html: msg.html || '', subject: activeDiscussion.value?.lastSubject || activeDiscussion.value?.subject || 'Courriel' } }
async function sendEmailReply () {
if ((!hasRealReply.value && !replyAttachments.value.length && !advHtml.value) || !activeToken.value) return
sending.value = true
try {
const html = advHtml.value || draftHtml.value
const text = html.replace(/<br\s*\/?>(?=)/gi, '\n').replace(/<\/(p|div|li|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').trim()
await sendMessage(activeToken.value, text, undefined, html, replyAttachments.value, replySendAs.value, replyCcList.value.join(', '), replyToList.value.join(', ')) // HTML + PJ + De + Cc/À éditables
draftHtml.value = ''; replyAttachments.value = []; advHtml.value = ''; advDesign.value.reply = null
replyCcList.value = []; replyToList.value = []; showCcTo.value = false
replyOpen.value = false; replyTarget.value = null // envoi terminé → ferme l'éditeur inline ; le message envoyé apparaît dans le fil
nextTick(() => scrollToBottom())
} catch (e) { $q.notify({ type: 'negative', message: 'Échec envoi : ' + e.message }) } finally { sending.value = false }
}
// Lier une fiche : suggestions intelligentes (recoupe expéditeur + adresse/nom extraits du courriel) + recherche manuelle multi-champs
const linkDialog = ref({ open: false, loading: false, matches: [], suggestions: [], extracted: {}, query: '', ticket: null })
const linkIsEmail = computed(() => isEmailConv.value)
async function openLink () {
const d = activeDiscussion.value; if (!d) return
linkDialog.value = { open: true, loading: true, matches: [], suggestions: [], extracted: {}, query: linkIsEmail.value ? (d.email || '') : (d.phone || '') }
loadLinkSuggestions(d)
await runLinkSearch()
}
// Croise expéditeur + adresse/nom mentionnés dans le courriel (titulaire ≠ expéditeur, ex. mandataire) → comptes classés
async function loadLinkSuggestions (d) {
try {
const text = (d.messages || []).filter(m => m.from === 'customer').map(m => m.text || '').join('\n\n').slice(0, 4000)
const r = await fetch(`${HUB_URL}/collab/suggest-account`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: d.email || '', name: d.customerName || '', text }) })
const j = r.ok ? await r.json() : {}
linkDialog.value.suggestions = j.suggestions || []
linkDialog.value.extracted = j.extracted || {}
} catch (e) { /* best-effort */ }
}
// Références détectées par l'IA dans le message (n° de compte/réf, nom, adresse, tél.) → puces CLIQUABLES.
// Un clic lance la recherche (runLinkSearch résout AUSSI les n° de réf via resolve-ref) → lie vite un SMS d'inconnu à la bonne fiche.
const linkRefChips = computed(() => {
const e = linkDialog.value.extracted || {}
const chips = []
if (e.invoice_number) chips.push({ label: '#' + e.invoice_number, query: String(e.invoice_number), icon: 'tag' })
for (const n of (Array.isArray(e.names) ? e.names : [])) if (n && String(n).trim()) chips.push({ label: String(n), query: String(n), icon: 'person' })
if (e.address) chips.push({ label: e.address, query: e.address, icon: 'location_on' })
if (e.phone) chips.push({ label: e.phone, query: e.phone, icon: 'call' })
return chips
})
function useRefChip (c) { linkDialog.value.query = c.query; runLinkSearch() }
async function runLinkSearch () {
linkDialog.value.loading = true
linkDialog.value.ticket = null
try {
const q = (linkDialog.value.query || '').trim()
if (q.length < 2) { linkDialog.value.matches = []; return }
// Recherche UNIFIÉE multi-champs : nom / courriel / téléphone / adresse (même endpoint que l'autosuggest)
// + N° DE RÉFÉRENCE de ticket (ex. « 251382 ») → résout la fiche via le Dispatch Job lié, placé EN TÊTE
const refClean = q.replace(/\D/g, '')
const isRef = /^\d{4,8}$/.test(refClean)
const [base, refRes] = await Promise.all([
fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}`).then(r => r.ok ? r.json() : {}).then(d => d.matches || []),
isRef ? fetch(`${HUB_URL}/conversations/resolve-ref?ref=${refClean}`).then(r => r.ok ? r.json() : {}) : Promise.resolve({}),
])
const refMatches = (refRes && refRes.matches) || []
if (refRes && refRes.ticket && !refMatches.length) linkDialog.value.ticket = refRes.ticket // ticket trouvé mais aucune fiche ERPNext → on montre l'identité
const seen = new Set(); const merged = []
for (const m of [...refMatches, ...base]) { if (!m.name || seen.has(m.name)) continue; seen.add(m.name); merged.push(m) }
linkDialog.value.matches = merged
} catch (e) { linkDialog.value.matches = [] } finally { linkDialog.value.loading = false }
}
async function doLink (m) {
if (!coordToken.value) return
try {
await linkCustomer(coordToken.value, m.name, m.customer_name || m.name)
$q.notify({ type: 'positive', message: `Fiche liée : ${m.customer_name || m.name}` })
linkDialog.value.open = false
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
async function unlinkFiche () { if (!coordToken.value) return; try { await linkCustomer(coordToken.value, '', ''); $q.notify({ type: 'info', message: 'Fiche déliée — vous pouvez relier' }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } }
// ── PREUVE DE PAIEMENT (OCR) — affiche pour décider ; route vers Facturation ; pas d'écriture auto ──
const paymentBusy = ref(null)
const coordPayment = computed(() => activeConv.value?.paymentProof || activeDiscussion.value?.paymentProof || null)
async function runPayment (msg) {
if (!coordToken.value || !msg.media) return
paymentBusy.value = msg.id
try {
const d = await analyzePayment(coordToken.value, msg.media, msg.id)
if (!d.payment?.is_payment) $q.notify({ type: 'info', message: 'Aucun paiement clair détecté dans l\'image.' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { paymentBusy.value = null }
}
async function routePaymentToBilling () {
if (!coordToken.value) return
try {
await assignQueue(coordToken.value, 'Facturations')
const p = coordPayment.value || {}
await addNote(coordToken.value, `💳 Preuve de paiement reçue${p.amount != null ? ' : ' + p.amount + ' ' + (p.currency || 'CAD') : ''}${p.date ? ' (' + p.date + ')' : ''}${p.reference ? ' · réf ' + p.reference : ''}. À vérifier/enregistrer dans F.`)
$q.notify({ type: 'positive', message: 'Routé vers Facturation + note ajoutée' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
// ── Coordination shared-inbox : file / claim / notes (lus depuis activeConv|activeDiscussion) ──
const coordToken = computed(() => activeToken.value || activeDiscussion.value?.token || null)
// « Ajouter à une tâche » (réutilisable) — le fil courant comme source : on ancre sur la fiche client si liée, sinon la conversation.
const addTaskOpen = ref(false)
const taskSource = computed(() => ({
type: coordCustomer.value ? 'Customer' : 'Conversation',
name: coordCustomer.value || coordToken.value || '',
title: convSubject.value || activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Conversation',
customer: coordCustomer.value || '',
url: coordToken.value ? ('/communications/c/' + coordToken.value) : '',
}))
// Tâche depuis UN message précis (titre = extrait du message) OU depuis le fil (toolbar). activeTaskSrc surclasse taskSource.
const activeTaskSrc = ref(null)
function openTask (msg) {
activeTaskSrc.value = (msg && msg.id) ? { ...taskSource.value, title: ('Suivi : ' + msgSnippet(msg)).slice(0, 90) } : null
addTaskOpen.value = true
}
const meEmail = computed(() => { try { return useAuthStore().user } catch { return null } })
// « Vu par » : agents (autres que moi) ayant ouvert la conversation, le plus récent d'abord.
const readers = computed(() => {
const rb = activeConv.value?.readBy || {}
return Object.keys(rb).filter(e => e && e !== meEmail.value).map(e => ({ email: e, ...rb[e] })).sort((a, b) => String(b.readAt || '').localeCompare(String(a.readAt || '')))
})
// Brouillon d'un AUTRE agent sur cette conversation (miroir temps réel ; null si c'est le mien ou aucun).
const closedDrafts = ref(new Set()) // miroirs fermés manuellement via le X (réaffichés au changement de fil)
// (otherDraft remplacé par liveDraft, rendu INLINE dans le fil — voir threadTimeline.)
function closeOtherDraft () { const s = new Set(closedDrafts.value); s.add(coordToken.value); closedDrafts.value = s }
const coordQueue = computed(() => activeConv.value?.queue || activeDiscussion.value?.queue || '')
// Signature SUGGÉRÉE selon la FILE (département) du fil : la réponse pré-écrite dont mailbox === file. One-tap, jamais forcée (le brouillon est partagé → ne pas écraser/diffuser une surprise).
const suggestedSignature = computed(() => { if (!isEmailConv.value) return null; const q = coordQueue.value; return q ? (cannedList.value.find(c => c.html && c.mailbox === q) || null) : null })
// Menu « Réponses types » : la signature suggérée remonte en tête.
const cannedSorted = computed(() => { const s = suggestedSignature.value; return s ? [s, ...cannedList.value.filter(c => c.id !== s.id)] : cannedList.value })
// Charge les réponses pré-écrites dès l'ouverture d'un fil courriel (pour proposer la signature sans clic).
watch(isEmailConv, (v) => { if (v && !cannedList.value.length) loadCanned() }, { immediate: true })
// ── Présence collaborative (style Google Docs) : qui REGARDE / qui RÉPOND (anneau clignotant sur l'avatar) ──
const typingEmails = computed(() => { const s = new Set(); const d = activeDiscussion.value; if (d && d.conversations) for (const c of d.conversations) { const a = agentTyping.value[c.token]; if (a && a !== meEmail.value) s.add(a) } return s })
const presence = computed(() => {
const map = new Map()
for (const r of readers.value) map.set(r.email, { email: r.email, readAt: r.readAt, typing: typingEmails.value.has(r.email) })
for (const e of typingEmails.value) if (!map.has(e)) map.set(e, { email: e, readAt: null, typing: true })
return Array.from(map.values()).sort((a, b) => (b.typing ? 1 : 0) - (a.typing ? 1 : 0))
})
// ── Bouton « Répondre » (Gmail) : composeur masqué jusqu'au clic ; le clic SIGNALE la présence aux collègues (notifyTyping → anneau qui clignote chez eux) ──
const replyOpen = ref(false)
function collapseAllMsgs () { expandedMsgs.value = new Set() }
// Citation ÉDITABLE du message auquel on répond (en-tête + contenu), style Gmail — l'agent écrit au-dessus.
function quotedBlock (m) {
const who = m.fromName || (m.from === 'agent' ? 'Nous' : (activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Client'))
const src = m.emailDate || m.ts // date d'ARRIVÉE du courriel (en-tête Date) en priorité, sinon l'horodatage d'ingestion
const dt = src ? new Date(src) : null
const when = (dt && !isNaN(dt.getTime())) ? formatDate(src) : '' // illisible → on OMET la date (plus de « Le Invalid Date, … »)
const safe = String(m.text || '').replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])).replace(/\n/g, '<br>')
const inner = m.html || ('<p>' + safe + '</p>')
return `<blockquote style="margin:10px 0 0;padding:4px 0 0 12px;border-left:3px solid #e2e8f0;color:#64748b">`
+ `<div style="font-size:.78rem;color:#94a3b8;margin-bottom:4px">${when ? 'Le ' + when + ', ' : ''}${who} a écrit&nbsp;:</div>${inner}</blockquote>`
}
const replyTarget = ref(null) // message PRÉCIS auquel on répond (null = réponse générale au fil)
function openReply (target) {
composeMode.value = 'reply' // bascule en mode réponse (si on était en note interne)
replyOpen.value = true
quoteFolded.value = true // chaque nouvelle réponse repart citation repliée (éditeur au propre)
if (isEmailConv.value && !replyToList.value.length && activeDiscussion.value?.email) replyToList.value = [activeDiscussion.value.email] // À = client par défaut (éditable)
// Cible : le message CLIQUÉ (répondre à un post précis) sinon le DERNIER (bouton « Répondre » du bas).
const ms = (activeDiscussion.value?.messages || []).filter(m => m.from !== 'system')
const tgt = (target && target.id) ? target : (ms.length ? ms[ms.length - 1] : null)
replyTarget.value = tgt
// Pré-remplit une citation ÉDITABLE du message CIBLÉ (si l'éditeur est vide) → la réponse cite ce post.
if (isEmailConv.value && !draftHtml.value && tgt) draftHtml.value = '<p><br></p>' + quotedBlock(tgt)
if (coordToken.value) notifyTyping(coordToken.value)
scrollToComposer()
}
// Bouton « Répondre » de la barre du bas : OUVRE l'éditeur inline (ou y défile s'il est déjà ouvert). Un SEUL « Répondre », plus de doublon avec l'ancien CTA.
function startReplyMode () { if (replyOpen.value && composeMode.value === 'reply') { scrollToComposer(); return } openReply() }
// Softphone : appeler le numéro de la conversation (SMS/chat) — log d'appel lié à la fiche si liée.
const softphone = useSoftphone()
const convPhone = computed(() => String(activeDiscussion.value?.phone || '').trim())
function callConv () { softphone.call(convPhone.value, { customer: coordCustomer.value || '', customerName: activeDiscussion.value?.customerName || '' }) }
// Amène l'ÉDITEUR INLINE (ancré dans le fil) à l'écran + focus — remplace l'ancien saut vers le composeur du bas.
function scrollToComposer () {
nextTick(() => {
try {
const root = messagesEl.value
const el = root && root.querySelector('.conv-compose-inline')
if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.querySelector('textarea, [contenteditable=true]')?.focus() }
} catch (e) { /* */ }
})
}
// Fermer le composeur EN GARDANT le brouillon (auto-persisté → reste dans la liste des brouillons).
function closeComposer () { replyOpen.value = false }
// ── TRANSFÉRER (forward) un message du fil vers d'autres destinataires — réutilise l'envoi email-new ──
const fwdDialog = ref({ open: false, msg: null, to: [], cc: [], subject: '', note: '', includeAtts: true, sending: false })
const fwdAttList = computed(() => (fwdDialog.value.msg && attByMsg.value[fwdDialog.value.msg.id]) || [])
const fwdAttCount = computed(() => fwdAttList.value.length)
function escapeHtml (s) { return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])) }
// Bloc « Message transféré » (en-têtes De/Date/Objet/À + corps), style Gmail.
function forwardedBlock (m) {
if (!m) return ''
const who = msgWho(m) || (m.from === 'agent' ? 'Nous' : (activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Client'))
const when = msgFullDate(m) || relTime(m.emailDate || m.ts)
const subj = convSubject.value || activeDiscussion.value?.lastSubject || ''
const to = msgTo(m)
const atts = fwdAttList.value.map(a => a.filename).filter(Boolean)
const body = m.html || ('<p>' + escapeHtml(m.text).replace(/\n/g, '<br>') + '</p>')
return '<div style="color:#64748b;border-left:3px solid #e2e8f0;padding:6px 0 6px 12px;margin:8px 0">'
+ '<div style="font-size:.8rem;color:#94a3b8;margin-bottom:6px">---------- Message transféré ----------<br>'
+ '<b>De&nbsp;:</b> ' + escapeHtml(who) + '<br><b>Date&nbsp;:</b> ' + escapeHtml(when) + '<br><b>Objet&nbsp;:</b> ' + escapeHtml(subj)
+ (to ? '<br><b>À&nbsp;:</b> ' + escapeHtml(to) : '')
+ (atts.length ? '<br><b>Pièces jointes&nbsp;:</b> ' + escapeHtml(atts.join(', ')) : '')
+ '</div>' + body + '</div>'
}
const fwdPreviewHtml = computed(() => forwardedBlock(fwdDialog.value.msg))
function openForward (m) {
const subj = convSubject.value || activeDiscussion.value?.lastSubject || '(sans objet)'
fwdDialog.value = { open: true, msg: m, to: [], cc: [], subject: /^fw(d)?\s*:/i.test(subj) ? subj : ('Fwd: ' + subj), note: '', includeAtts: !!((attByMsg.value[m && m.id] || []).length), sending: false }
}
// Reporte les PJ d'origine (Gmail) : re-télécharge puis ré-upload comme actifs (best-effort ; sinon le nom reste listé dans le corps).
async function carryFwdAttachments (m) {
const out = []
for (const a of (attByMsg.value[m.id] || [])) {
try {
const url = `${HUB_URL}/conversations/${coordToken.value}/attachment?gmail_id=${encodeURIComponent(a.gmail_id)}&att=${encodeURIComponent(a.attachmentId)}&mime=${encodeURIComponent(a.mimeType || '')}&name=${encodeURIComponent(a.filename || 'doc')}`
const blob = await fetch(url).then(r => r.ok ? r.blob() : Promise.reject(new Error('dl')))
const dataUrl = await new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = rej; fr.readAsDataURL(blob) })
const up = await fetch(`${HUB_URL}/campaigns/assets/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: a.filename || 'doc', data: dataUrl }) }).then(r => r.json())
if (up && up.filename) out.push({ asset: up.filename, filename: a.filename || 'doc', mime: a.mimeType })
} catch (e) { /* PJ illisible → ignorée (nom listé dans le corps) */ }
}
return out
}
async function doForward () {
const fd = fwdDialog.value
const to = (fd.to || []).join(', ').trim()
if (!to || !fd.msg) { $q.notify({ type: 'warning', message: 'Indiquez au moins un destinataire' }); return }
fd.sending = true
try {
const attachments = (fd.includeAtts && fwdAttCount.value) ? await carryFwdAttachments(fd.msg) : []
const noteHtml = fd.note.trim() ? ('<p>' + escapeHtml(fd.note).replace(/\n/g, '<br>') + '</p>') : ''
await startEmail({ to, cc: (fd.cc || []).join(', '), subject: fd.subject.trim() || 'Fwd:', html: noteHtml + forwardedBlock(fd.msg), attachments, sendAs: replySendAs.value })
fd.open = false
$q.notify({ type: 'positive', icon: 'forward', message: 'Message transféré à ' + to })
} catch (e) { $q.notify({ type: 'negative', message: 'Échec du transfert : ' + (e.message || e) }) } finally { fd.sending = false }
}
// Supprimer le brouillon : vide la réponse + efface le brouillon partagé/persisté, puis ferme.
function discardDraft () {
draft.value = ''; draftHtml.value = ''; advHtml.value = ''; if (advDesign.value) advDesign.value.reply = null
replyAttachments.value = []; replyMedia.value = ''
if (coordToken.value) pushDraft(coordToken.value, '', '') // efface le brouillon miroir/persisté côté hub
replyOpen.value = false
$q.notify({ type: 'info', message: 'Brouillon supprimé', icon: 'delete_outline' })
}
// En composition : replier tous les messages (focus = éditeur) ; à la fermeture : ré-ouvrir le dernier.
watch(replyOpen, (open) => { if (open) collapseAllMsgs(); else if (activeDiscussion.value) expandLast() })
// ── BROUILLON INLINE (style Missive/Gmail) : le brouillon s'affiche DANS le fil, ancré juste après le message auquel il répond ──
// Retrouve un message du fil par id (pour ancrer le brouillon + libellé « en réponse à »).
function msgById (id) { return id ? ((activeDiscussion.value?.messages || []).find(m => m.id === id) || null) : null }
// Brouillon MIROIR (lecture seule) à rendre inline : celui d'un COLLÈGUE, ou le MIEN quand le composeur est fermé.
// Quand JE rédige (replyOpen) c'est l'ÉDITEUR inline (_kind:'compose') qui s'affiche directement dans le fil — pas un miroir.
const liveDraft = computed(() => {
const tok = coordToken.value
if (!tok || replyOpen.value || closedDrafts.value.has(tok)) return null
const sd = sharedDraft.value[tok] || activeConv.value?.draft || null
if (sd && (String(sd.html || '').replace(/<[^>]+>/g, '').trim() || String(sd.text || '').trim())) {
return { mine: sd.agent === meEmail.value, agent: sd.agent, html: sd.html, text: sd.text, replyTo: sd.replyTo || null, ts: sd.ts }
}
return null
})
// « Continuer la rédaction » depuis la carte miroir (mien, replié) → rouvre l'éditeur INLINE à son ancre.
function focusComposer () {
composeMode.value = 'reply'; replyOpen.value = true
scrollToComposer()
}
// RESTAURE mon brouillon persisté à l'ouverture (la pastille « LO » de la liste mène enfin à un brouillon éditable).
const _draftRestoredFor = ref('')
watch(() => activeConv.value && activeConv.value.draft, (d) => {
const tok = coordToken.value
if (!tok || !d || _draftRestoredFor.value === tok) return
if (!d.agent || d.agent !== meEmail.value) return // seul le MIEN se recharge dans l'éditeur ; ceux des autres restent en miroir inline
if (hasEmailDraft.value || String(draft.value || '').trim()) return // ne jamais écraser une saisie déjà en cours
_draftRestoredFor.value = tok
composeMode.value = 'reply'
if (isEmailConv.value) draftHtml.value = d.html || ''
else draft.value = d.text || ''
if (d.replyTo) { const m = msgById(d.replyTo); if (m) replyTarget.value = m }
replyOpen.value = true
}, { immediate: false })
// Changement de fil : repartir d'un brouillon VIERGE (sinon le brouillon d'un fil « bave » sur le suivant) ; restore-on-open rechargera le mien.
// Push supprimé le temps du reset → le vidage NE DOIT PAS effacer le brouillon persisté du NOUVEAU fil (activeToken pointe déjà dessus).
watch(coordToken, () => {
_suppressPush.value = true
draftHtml.value = ''; draft.value = ''; advHtml.value = ''; replyAttachments.value = []; replyMedia.value = ''; replyTarget.value = null
replyToList.value = []; replyCcList.value = []; showCcTo.value = false
_draftRestoredFor.value = ''
nextTick(() => { _suppressPush.value = false })
})
// Ajuste la hauteur de l'iframe courriel à SON contenu (plus de 300px fixe / blancs). Sûr : sandbox `allow-same-origin` SANS `allow-scripts` → le parent lit le DOM pour mesurer, mais aucun script du courriel ne s'exécute.
// Ajuste un iframe courriel à SON contenu (fini le 300px fixe / les blancs). Sûr : sandbox `allow-same-origin` SANS `allow-scripts` → le parent lit le DOM pour mesurer, aucun script du courriel ne s'exécute.
function fitFrame (f) {
if (!f) return
// Hauteur = TOUT le contenu → AUCUN scroll interne au message ; c'est le fil
// entier (et la page) qui défile d'un seul bloc. On re-mesure plusieurs fois,
// après le chargement des images, et via ResizeObserver — une mesure trop tôt
// (images/layout) laisserait l'iframe trop court → un scroll interne réapparaît.
const measure = () => {
try {
const doc = f.contentDocument
if (!doc) return
// On COLLAPSE l'iframe avant de mesurer : sinon `scrollHeight` renvoie la
// hauteur du viewport déjà gonflé (et non celle du contenu) → boucle qui
// ratchete jusqu'au plafond 20000px. Collapsé, scrollHeight = le contenu réel.
f.style.height = '0px'
const h = Math.max(doc.documentElement ? doc.documentElement.scrollHeight : 0, doc.body ? doc.body.scrollHeight : 0)
f.style.height = (h > 4 ? Math.min(Math.max(h + 8, 48), 20000) : 48) + 'px'
} catch (err) { /* garder la hauteur courante : NE PAS forcer 300px (recréerait un scroll interne) */ }
}
measure(); setTimeout(measure, 120); setTimeout(measure, 600)
try {
const doc = f.contentDocument
if (doc) doc.querySelectorAll('img').forEach(img => { if (!img.complete) img.addEventListener('load', measure, { once: true }) })
} catch (err) { /* */ }
}
function fitEmailFrame (e) { fitFrame(e.target) }
// Re-mesure les courriels VISIBLES (le @load se produit pendant que le message est replié/display:none → hauteur 0 ; il faut re-mesurer au dépliage).
function refitFrames () { nextTick(() => { const root = messagesEl.value; if (root) root.querySelectorAll('.email-frame').forEach(f => { if (f.offsetParent !== null) fitFrame(f) }) }) }
// ── Résumé IA du fil (« Summarise this email » de Gmail) — pour l'agent, ne modifie rien ──
const summaryText = ref('')
const summaryBusy = ref(false)
const summaryErr = ref('')
const summaryHtml = computed(() => String(summaryText.value || '').split('\n').filter(l => l.trim()).map(l => '<div class="sum-li">' + l.replace(/^[*\-\s]+/, ' ').replace(/\*\*(.+?)\*\*/g, '<b>$1</b>') + '</div>').join(''))
async function summarizeThread () {
if (!coordToken.value) return
summaryBusy.value = true; summaryErr.value = ''
try { const d = await summarize(coordToken.value); summaryText.value = String(d.summary || '').trim() || 'Rien à résumer.' } catch (e) { summaryErr.value = e.message || 'Résumé indisponible' } finally { summaryBusy.value = false }
}
// Créer un ticket À PARTIR du résumé IA (le résumé est mis en tête de la description, le fil complet suit)
const ticketBusy = ref(false)
const ticketTypeLabel = computed(() => activeConv.value?.triage?.category || activeDiscussion.value?.triage?.category || coordQueue.value || 'support')
async function createTicketFromSummary () {
if (!coordToken.value || !summaryText.value) return
ticketBusy.value = true
try {
const firstLine = summaryText.value.split('\n').map(l => l.replace(/^[•*\-\s]+/, '').trim()).find(Boolean) || ''
const title = activeConv.value?.triage?.suggested_title || firstLine.slice(0, 90) || activeDiscussion.value?.subject || 'Demande client'
const category = activeConv.value?.triage?.category || activeDiscussion.value?.triage?.category || 'Support'
const r = await createTicket(coordToken.value, { title, category, priority: 'Medium', summary: summaryText.value })
$q.notify({ type: 'positive', message: 'Ticket ' + (r.issue || '') + ' créé depuis le résumé' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { ticketBusy.value = false }
}
// ── #3 Mauvais aiguillage : l'IA (triage) classe ce fil dans une file file actuelle drapeau + réorientation 1 clic
const QUEUE_OF_AI = { facturation: 'Facturations', vente: 'Service à la clientèle', support: 'Supports', installation: 'Technicien', telephonie: 'Technicien', television: 'Technicien', autre: '' }
const TYPE_LBL = { facturation: 'Facturation', vente: 'Vente', support: 'Support', installation: 'Installation', telephonie: 'Téléphonie', television: 'Télévision', autre: 'Autre' }
const triageType = computed(() => activeConv.value?.triage?.type || activeDiscussion.value?.triage?.type || '')
const aiSuggestedQueue = computed(() => QUEUE_OF_AI[triageType.value] || '')
const misrouted = computed(() => { const s = aiSuggestedQueue.value; return !!(s && coordQueue.value && s !== coordQueue.value) })
function rerouteToAI () { if (aiSuggestedQueue.value) setQueue(aiSuggestedQueue.value) }
function openGroups () { try { window.location.hash = '#/settings' } catch (e) { /* */ } }
// ── #2 Disponibilité du service : vérifie l'adresse (IA) + la fibre (RQA), prépare un brouillon que l'agent INSÈRE puis envoie (jamais auto-envoyé) ──
const svcBusy = ref(false)
const svcResult = ref(null)
async function checkServiceability () {
if (!coordToken.value) return
svcBusy.value = true; svcResult.value = null
try { svcResult.value = await serviceability(coordToken.value) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { svcBusy.value = false }
}
function insertServiceabilityDraft () {
const d = svcResult.value; if (!d || !d.draft_text) return
replyOpen.value = true
if (isEmailConv.value) draftHtml.value = (draftHtml.value ? draftHtml.value + '<br>' : '') + (d.draft_html || '')
else draft.value = (draft.value ? draft.value + '\n' : '') + d.draft_text
if (coordToken.value) notifyTyping(coordToken.value)
$q.notify({ type: 'positive', message: 'Brouillon inséré — relisez puis envoyez' })
svcResult.value = null
}
const coordAssignee = computed(() => activeConv.value?.assignee || activeDiscussion.value?.assignee || null)
// Assignation directe + assistants (chips) + état ticket (ouvert/attente/fermé + date)
const coordAssistants = computed(() => activeConv.value?.assistants || activeDiscussion.value?.assistants || [])
const coordState = computed(() => activeConv.value?.ticketState || activeDiscussion.value?.ticketState || ((activeConv.value?.status || activeDiscussion.value?.status) === 'closed' ? 'closed' : 'open'))
const coordPendingUntil = computed(() => activeConv.value?.pendingUntil || activeDiscussion.value?.pendingUntil || '')
const pendingDateInput = ref('')
watch(coordPendingUntil, v => { pendingDateInput.value = v || '' }, { immediate: true })
const agents = ref([])
function ensureAgents () { fetchAgents().then(a => { if (a && a.length) agents.value = a }) } // charge la liste agents à l'ouverture du menu (pas seulement au changement de conversation)
const assignSearch = ref('')
const filteredAgents = computed(() => {
const q = assignSearch.value.trim().toLowerCase()
return agents.value.filter(a => !q || a.toLowerCase().includes(q) || (noteAuthorName(a) || '').toLowerCase().includes(q))
})
// Prendre/Assigner unifié : 1er choisi = responsable (lead), les suivants = assistants (clic = toggle)
function pickAgent (a) {
if (!coordAssignee.value) { assignTo(a); return }
if (a === coordAssignee.value) return
if (coordAssistants.value.includes(a)) removeAssistant(a)
else addAssistant(a)
}
function stateLabel (s) { return s === 'open' ? 'Ouvert' : s === 'pending' ? 'En attente' : s === 'closed' ? 'Fermé' : s }
// Toast avec « Annuler » (style Missive) sur les actions réversibles : capture l'état précédent puis le ré-applique.
function notifyUndo (message, undoFn) {
$q.notify({ message, color: 'grey-9', icon: 'undo', timeout: 6000, actions: [{ label: 'Annuler', color: 'amber-4', handler: async () => { try { await undoFn() } catch (e) { $q.notify({ type: 'negative', message: e.message }) } } }] })
}
async function setState (state) {
if (!coordToken.value) return
const tok = coordToken.value; const prev = coordState.value; const prevPending = coordPendingUntil.value || null
try {
await setConvState(tok, state, state === 'pending' ? (pendingDateInput.value || null) : null)
if (state !== prev) notifyUndo(`État → ${stateLabel(state)}`, () => setConvState(tok, prev, prev === 'pending' ? prevPending : null))
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
// Presets de snooze (style Missive) : pose la date « Attendre jusqu'au » + passe en « En attente ».
function snoozePreset (kind) {
const d = new Date(); const dow = d.getDay()
if (kind === 'tomorrow') d.setDate(d.getDate() + 1)
else if (kind === 'friday') d.setDate(d.getDate() + (((5 - dow) + 7) % 7 || 7))
else if (kind === 'nextweek') d.setDate(d.getDate() + (((1 - dow) + 7) % 7 || 7))
pendingDateInput.value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
setState('pending')
}
async function assignTo (email) {
if (!coordToken.value) return
const tok = coordToken.value; const prev = coordAssignee.value
try {
await assignConv(tok, { assignee: email })
notifyUndo(email ? `Assigné à ${shortAgent(email)}` : 'Désassigné', () => assignConv(tok, { assignee: prev }))
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
async function addAssistant (email) { if (!coordToken.value) return; try { await assignConv(coordToken.value, { assistants: [...coordAssistants.value, email] }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } }
async function removeAssistant (email) { if (!coordToken.value) return; try { await assignConv(coordToken.value, { assistants: coordAssistants.value.filter(e => e !== email) }) } catch (e) { /* */ } }
// Accordéon des messages (style Gmail) : ancien replié, dernier déplié, nouvel entrant déplié.
const expandedMsgs = ref(new Set())
function isExpanded (m) { return expandedMsgs.value.has(m.id) }
function toggleMsg (m) { const s = new Set(expandedMsgs.value); s.has(m.id) ? s.delete(m.id) : s.add(m.id); expandedMsgs.value = s; refitFrames() }
function expandAllMsgs () { expandedMsgs.value = new Set((activeDiscussion.value?.messages || []).map(m => m.id)); refitFrames() }
const allExpanded = computed(() => { const ms = activeDiscussion.value?.messages || []; return ms.length > 0 && ms.every(m => expandedMsgs.value.has(m.id)) })
function toggleExpandAll () { allExpanded.value ? collapseAllMsgs() : expandAllMsgs() }
// Identité d'affichage d'UN message (nom + initiales + couleur COHÉRENTS, jamais vide sauf système) — resolver unique partagé.
function msgIdent (m) { return messageIdentity(m, { customerName: activeDiscussion.value?.customerName, email: activeDiscussion.value?.email }) }
function msgAvatarColor (m) { return msgIdent(m).color }
function msgAvatarInitials (m) { return msgIdent(m).initials }
// Date/heure complète (infobulle au survol de l'heure du message)
function msgFullDate (m) { const d = new Date(m.emailDate || m.ts); return isNaN(d.getTime()) ? '' : d.toLocaleString('fr-CA', { dateStyle: 'full', timeStyle: 'short' }) }
// Expéditeur d'UN message (nom) — délègue au resolver unique msgIdent (cohérent avec l'avatar ; jamais vide sauf système ; agents capitalisés).
function msgWho (m) { return msgIdent(m).name }
function msgIcon (m) { return m.html ? 'mail' : m.via === 'sms' ? 'sms' : m.via === 'ai' ? 'smart_toy' : m.media ? 'image' : 'chat' }
function msgSnippet (m) { const t = String(m.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/\s+/g, ' ').trim(); return t.slice(0, 64) || (m.media ? '[image]' : m.html ? '[courriel]' : '') }
function cleanBody (t) { return String(t || '').replace(/^✉️[^\n]*\n+/, '') } // retire l'ancien préfixe sujet collé au corps (double en-tête)
const lastMessageId = computed(() => { const ms = activeDiscussion.value?.messages || []; return ms.length ? ms[ms.length - 1].id : null })
function expandLast () { const ms = activeDiscussion.value?.messages || []; expandedMsgs.value = new Set(ms.length ? [ms[ms.length - 1].id] : []); refitFrames() }
const attByMsg = ref({}) // pièces jointes par message (PDF/JPG) → trombone cliquable
watch(coordToken, () => { svcStatus.value = null; expandLast(); replyOpen.value = false; summaryText.value = ''; summaryErr.value = ''; closedDrafts.value = new Set(); attByMsg.value = {}; try { const tk = coordToken.value; if (tk) { const dj = sessionStorage.getItem('reward_prefill_design_' + tk); if (dj) { sessionStorage.removeItem('reward_prefill_design_' + tk); sessionStorage.removeItem('reward_prefill_' + tk); advDesign.value.reply = JSON.parse(dj); replyOpen.value = true; nextTick(() => openAdvanced('reply')) } else { const pf = sessionStorage.getItem('reward_prefill_' + tk); if (pf) { sessionStorage.removeItem('reward_prefill_' + tk); draft.value = pf; replyOpen.value = true } } } } catch (e) { /* prefill récompense */ } fetchAgents().then(a => { agents.value = a }); if (coordToken.value) fetchAttachments(coordToken.value).then(m => { attByMsg.value = m || {} }) })
watch(() => (activeDiscussion.value?.messages || []).length, (n, o) => { if (n > (o || 0)) { const ms = activeDiscussion.value?.messages || []; if (ms.length) { const s = new Set(expandedMsgs.value); s.add(ms[ms.length - 1].id); expandedMsgs.value = s } } })
const gmailReplyUrl = computed(() => activeConv.value?.gmailReplyUrl || activeDiscussion.value?.gmailReplyUrl || null)
const coordNotes = computed(() => activeConv.value?.notes || [])
// Fil UNIFIÉ (style Missive) : messages + notes internes entrelacés par date dans une seule timeline.
const threadTimeline = computed(() => {
const msgs = (activeDiscussion.value?.messages || []).map(m => ({ ...m, _kind: 'msg', _ts: m.emailDate || m.ts || '' }))
const notes = (coordNotes.value || []).map(n => ({ ...n, _kind: 'note', _ts: n.ts || '' }))
const out = [...msgs, ...notes].sort((a, b) => String(a._ts).localeCompare(String(b._ts)))
// Entrée inline ancrée JUSTE APRÈS son message cible (sinon en fin de fil) :
// - MON éditeur de réponse ouvert ('compose') → on répond DIRECTEMENT dans le fil ;
// - sinon un brouillon MIROIR ('draft') d'un collègue / le mien replié.
// Si une nouvelle réponse entre pendant la rédaction, l'entrée garde sa place dans la séquence.
let entry = null
if (replyOpen.value && composeMode.value === 'reply') entry = { _kind: 'compose', id: 'compose', replyTo: (replyTarget.value && replyTarget.value.id) || null }
else if (liveDraft.value) entry = { ...liveDraft.value, _kind: 'draft', id: 'draft' }
if (entry) {
const idx = entry.replyTo ? out.findIndex(x => x._kind === 'msg' && x.id === entry.replyTo) : -1
if (idx >= 0) out.splice(idx + 1, 0, entry)
else out.push(entry)
}
return out
})
const coordSuggested = computed(() => {
const s = activeConv.value?.suggestedTicket || activeDiscussion.value?.suggestedTicket
const linked = activeConv.value?.linkedTickets || activeDiscussion.value?.linkedTickets || []
return (s && !linked.length) ? s : null
})
const showNoteInput = ref(false); const noteText = ref('')
const composeMode = ref('reply') // composeur unifié (style Missive) : 'reply' = répondre au client · 'note' = note interne
// ── @mention dans la note interne (autocomplete style réseaux sociaux) ──
const noteInputEl = ref(null)
const mention = ref({ open: false, query: '', active: 0 })
const mentionMatches = computed(() => {
const q = mention.value.query.toLowerCase()
return (agents.value || []).filter(a => { const n = noteAuthorName(a).toLowerCase(); return !q || a.toLowerCase().includes(q) || n.includes(q) }).slice(0, 6)
})
function onNoteInput (val) {
ensureAgents() // s'assure que la liste d'agents est chargée
const m = String(val == null ? '' : val).match(/@([\w.\-]*)$/) // @token en fin de saisie
mention.value = m ? { open: true, query: m[1], active: 0 } : { open: false, query: '', active: 0 }
}
function pickMention (a) {
noteText.value = String(noteText.value || '').replace(/@([\w.\-]*)$/, '@' + shortAgent(a) + ' ')
mention.value = { open: false, query: '', active: 0 }
nextTick(() => { try { noteInputEl.value && noteInputEl.value.focus() } catch (e) { /* */ } })
}
// Navigation clavier du tag @mention : ↑/↓ déplacent la surbrillance ; Tab/Espace/Entrée insèrent le collègue ACTIF.
function mentionMove (d) { const n = mentionMatches.value.length; if (!mention.value.open || !n) return; mention.value.active = ((mention.value.active || 0) + d + n) % n }
function mentionAccept () { const list = mentionMatches.value; if (!mention.value.open || !list.length) return false; pickMention(list[Math.min(mention.value.active || 0, list.length - 1)]); return true }
function onMentionDown (e) { if (mention.value.open) { e.preventDefault(); mentionMove(1) } }
function onMentionUp (e) { if (mention.value.open) { e.preventDefault(); mentionMove(-1) } }
function onMentionTab (e) { if (mentionAccept()) e.preventDefault() }
function onMentionSpace (e) { if (mentionAccept()) e.preventDefault() }
function onMentionEnter (e) { e.preventDefault(); if (!mentionAccept()) submitNote() }
// Édition / suppression d'une note interne (correction de faute)
const editNoteId = ref(''); const editNoteText = ref('')
function startEditNote (n) { editNoteId.value = n.id; editNoteText.value = n.text }
async function saveEditNote (n) { const t = editNoteText.value.trim(); if (!t) { editNoteId.value = ''; return } try { await editNote(coordToken.value, n.id, t); editNoteId.value = '' } catch (e) { $q.notify({ type: 'negative', message: e.message }) } }
async function removeNote (n) { if (!coordToken.value) return; try { await deleteNote(coordToken.value, n.id) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } }
function attIcon (mime) { const m = String(mime || ''); if (/pdf/i.test(m)) return 'picture_as_pdf'; if (/^image\//i.test(m)) return 'image'; if (/word|document|officedocument/i.test(m)) return 'description'; return 'attach_file' }
function openAtt (a) { if (!coordToken.value || !a) return; const u = `${HUB_URL}/conversations/${coordToken.value}/attachment?gmail_id=${encodeURIComponent(a.gmail_id)}&att=${encodeURIComponent(a.attachmentId)}&mime=${encodeURIComponent(a.mimeType || '')}&name=${encodeURIComponent(a.filename || 'document')}`; window.open(u, '_blank', 'noopener') }
// shortAgent vient de useFormatters (consolidation)
async function setQueue (q) { if (!coordToken.value) return; try { await assignQueue(coordToken.value, coordQueue.value === q ? '' : q) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } }
async function doClaim () { if (!coordToken.value) return; try { await claimConv(coordToken.value) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } }
async function doRelease () {
if (!coordToken.value) return
const tok = coordToken.value; const prev = coordAssignee.value
try { await releaseConv(tok); notifyUndo('Libéré', () => assignConv(tok, { assignee: prev })) } catch (e) {}
}
function openGmailReply () { if (gmailReplyUrl.value) window.open(gmailReplyUrl.value, '_blank') }
async function submitNote () { const t = noteText.value.trim(); if (!t || !coordToken.value) return; try { await addNote(coordToken.value, t); noteText.value = ''; showNoteInput.value = false } catch (e) { $q.notify({ type: 'negative', message: e.message }) } }
// Copilote langage naturel : exécute file/claim/note/ticket + préremplit le BROUILLON de réponse (l'agent valide+envoie).
const nlText = ref(''); const nlBusy = ref(false)
async function runNl () {
const cmd = nlText.value.trim(); if (!cmd || !coordToken.value) return
nlBusy.value = true
try {
const d = await nlCommand(coordToken.value, cmd)
nlText.value = ''
const parts = []
if (d.applied && d.applied.length) parts.push(d.applied.join(' · '))
if (d.draft_reply) { draft.value = d.draft_reply; parts.push('brouillon prêt → révisez et envoyez') }
$q.notify({ type: 'positive', icon: 'auto_awesome', message: parts.join(' — ') || d.summary || 'Fait', timeout: 4500 })
if (d.draft_reply) await nextTick(() => scrollToBottom())
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { nlBusy.value = false }
}
async function submitTicket () {
if (!activeToken.value || !ticketDialog.value.title.trim()) return
creatingTicket.value = true
try {
const r = await createTicket(activeToken.value, { title: ticketDialog.value.title.trim(), category: ticketDialog.value.category, priority: ticketDialog.value.priority })
ticketDialog.value.open = false
$q.notify({ type: 'positive', icon: 'confirmation_number', message: `Ticket ${r.issue} créé`, timeout: 2800 })
} catch (e) { $q.notify({ type: 'negative', message: e.message || 'Échec de création', timeout: 4000 }) } finally { creatingTicket.value = false }
}
function openTicket (name) { window.open(`${ERP_BASE}/app/issue/${encodeURIComponent(name)}`, '_blank') }
// ── Inbox unifié : filtre (conversations + tickets) + helpers d'affichage des tickets ──
const inboxFilter = ref('all') // 'all' | 'conv' | 'tickets'
const hideNoise = ref(true) // masque le bruit (no-reply/pourriel/auto) par défaut
const noiseCount = computed(() => discussions.value.filter(d => d.noise).length)
const shownDiscussions = computed(() => hideNoise.value ? discussions.value.filter(d => !d.noise) : discussions.value)
function refreshAll () { fetchList(); fetchTickets() }
function ticketCategory (t) { const m = /^\[([^\]]+)\]/.exec(t.subject || ''); return m ? m[1] : '' }
function ticketTitle (t) { return String(t.subject || t.name || '').replace(/^\[[^\]]+\]\s*/, '') }
function ticketColor (t) {
const p = String(t.priority || '').toLowerCase()
if (p === 'urgent') return 'red-6'
if (p === 'high') return 'deep-orange-6'
if (String(t.status || '').toLowerCase() === 'on hold') return 'grey-6'
return 'teal-6'
}
watch(inboxFilter, (v) => { if (v !== 'conv' && !tickets.value.length) fetchTickets() })
watch(panelOpen, (v) => { if (v) fetchTickets() })
// Libellé « X est en train d'écrire… » pour la discussion ouverte (autre agent OPS).
function typingLabelFor (d) {
if (!d || !d.conversations) return ''
for (const c of d.conversations) { const a = agentTyping.value[c.token]; if (a) return a.includes('@') ? a.split('@')[0] : a }
return ''
}
const typingAgentLabel = computed(() => typingLabelFor(activeDiscussion.value))
// Courriel BRUT de l'agent qui tape (pour l'avatar/initiales) — ≠ typingAgentLabel qui est tronqué pour l'affichage texte.
const typingAgentRaw = computed(() => { const d = activeDiscussion.value; if (d && d.conversations) for (const c of d.conversations) { const a = agentTyping.value[c.token]; if (a) return a } return '' })
const draft = ref('')
const sending = ref(false)
const messagesEl = ref(null)
const archiving = ref(false)
const bulkDeleting = ref(false)
const newPhone = ref('')
const newMessage = ref('') // texte du texto (chat-style)
const newMedia = ref('') // URL image jointe (MMS)
const newUploading = ref(false)
const imgInput = ref(null)
const newCustomerName = ref('')
const newSubject = ref('')
const newChannel = ref('email') // 'sms' | 'email' — bascule du dialogue « Nouveau » (courriel par défaut ; le FAB texto présélectionne 'sms')
watch(newDialogOpen, (v) => {
if (!v) return
if (newDialogChannel.value) newChannel.value = newDialogChannel.value // FAB / lien peut présélectionner le canal
const p = composePrefill && composePrefill.value
if (p) { // pré-remplissage (ex. fiche client → brouillon d'invitation d'évaluation)
if (p.channel) newChannel.value = p.channel
if (p.to) newEmail.value = p.to
if (p.phone) newPhone.value = String(p.phone).replace(/\D/g, '')
if (p.subject) newSubject.value = p.subject
if (p.html) newHtml.value = p.html
if (p.advHtml) composeAdvHtml.value = p.advHtml // courriel MIS EN PAGE → envoyé tel quel (bannière « prête »)
if (p.text) newMessage.value = p.text
if (p.customer) { custPicked.value = { name: p.customer, customer_name: p.customerName || '', email: p.to || '', phone: p.phone || '' }; newCustomerName.value = p.customerName || ''; newLinkChoice.value = p.customer; newMatches.value = [{ name: p.customer, customer_name: p.customerName || '' }] }
composePrefill.value = null // consommé une fois
}
})
const newEmail = ref('') // destinataire courriel (mode email)
// ── Compose courriel enrichi : éditeur riche (HTML) + Cc/Cci (chips supprimables, autosuggest) ──
const newHtml = ref('')
const newCc = ref([]); const newBcc = ref([])
const ccSearch = ref(''); const ccResults = ref([]); const ccSearching = ref(false); const ccMode = ref('cc')
let _ccT = null
// Autosuggest du destinataire (À) — même recherche client que Cc/Cci ; choisir = remplir À + lier la fiche.
const toResults = ref([]); const toSearching = ref(false); let _toT = null
function onToSearch (v) {
clearTimeout(_toT); const q = String(v || '').trim()
if (q.length < 3 || /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(q)) { toResults.value = []; return } // courriel complet saisi → pas de recherche
_toT = setTimeout(async () => {
toSearching.value = true
try { const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}&team=1`); if (r.ok) { const d = await r.json(); toResults.value = (d.matches || []).filter(m => m.email) } } catch (e) { toResults.value = [] } finally { toSearching.value = false }
}, 280)
}
function pickTo (m) { clearTimeout(_toT); toResults.value = []; if (m.kind === 'équipe') { newEmail.value = m.email; newCustomerName.value = m.customer_name || ''; custPicked.value = null; newLinkChoice.value = ''; newMatches.value = [] } else pickCust(m) } // collègue → remplit À sans lier de fiche ; client → pickCust (À + nom + fiche liée)
const composeHasBody = computed(() => { const t = String(newHtml.value || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim(); return t.length > 0 || /<img/i.test(newHtml.value || '') })
function onCcSearch (v) {
clearTimeout(_ccT); const q = String(v || '').trim()
if (q.length < 3) { ccResults.value = []; return }
_ccT = setTimeout(async () => {
ccSearching.value = true
try { const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}&team=1`); if (r.ok) { const d = await r.json(); ccResults.value = (d.matches || []).filter(m => m.email) } } catch (e) { ccResults.value = [] } finally { ccSearching.value = false }
}, 280)
}
function addCcEmail (raw) {
const e = String(raw || '').trim().replace(/^.*<([^>]+)>.*$/, '$1')
if (!/.+@.+\..+/.test(e)) return
const list = ccMode.value === 'bcc' ? newBcc : newCc
if (!newCc.value.includes(e) && !newBcc.value.includes(e)) list.value.push(e)
clearTimeout(_ccT); ccSearch.value = ''; ccResults.value = [] // annule la recherche en attente → la liste ne réapparaît plus après l'ajout
}
function addCcTyped () { addCcEmail(ccSearch.value) }
function addCcPicked (m) { addCcEmail(m.email) }
// Insère une réponse type / signature dans l'éditeur de COMPOSITION (≠ insertCanned qui vise la réponse). {{rating}} reste littéral → expansé à l'envoi côté hub.
function insertCannedCompose (c) {
const client = (custPicked.value && custPicked.value.customer_name) || newCustomerName.value || newEmail.value || 'client'
const agent = shortAgent(coordAssignee.value) || 'l\'équipe TARGO'
const sub = (s) => String(s || '').replace(/\{\{\s*client\s*\}\}/gi, client).replace(/\{\{\s*agent\s*\}\}/gi, agent)
const frag = c.html ? sub(c.html) : ('<p>' + sub(c.body || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>') + '</p>')
newHtml.value = newHtml.value ? (newHtml.value + frag) : frag
}
const creating = ref(false)
const newMatches = ref([]) // fiches trouvées par téléphone
const newResolving = ref(false)
const newLinkChoice = ref('') // fiche choisie (Customer name) quand plusieurs
// Réponse du fil (compose dans la conversation)
const replyMedia = ref('')
const replyUploading = ref(false)
const replyImgInput = ref(null)
// ── Pièces jointes courriel (images) : réfs vers le store d'actifs + bibliothèque d'équipements ──
const replyAttachments = ref([]) // [{asset, filename, mime, url}]
const composeAttachments = ref([]) // idem pour la fenêtre « Nouveau courriel »
const attUploading = ref(false)
const replyAttInput = ref(null); const composeAttInput = ref(null); const presetAddInput = ref(null)
const presetDlg = ref({ open: false, target: 'reply', loading: false, list: [] })
async function uploadImage (file) {
const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file) })
const r = await fetch(`${HUB_URL}/campaigns/assets/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: file.name, data: dataUrl }) })
const d = await r.json(); if (!d.url) throw new Error(d.error || 'upload'); return d.url
}
function pickImage () { if (imgInput.value) imgInput.value.click() }
async function onImage (e) {
const file = e.target.files && e.target.files[0]; if (!file) return
newUploading.value = true
try { newMedia.value = await uploadImage(file) } catch { $q.notify({ type: 'negative', message: "Échec de l'envoi de l'image" }) }
newUploading.value = false; if (imgInput.value) imgInput.value.value = ''
}
function pickReplyImage () { if (replyImgInput.value) replyImgInput.value.click() }
async function onReplyImage (e) {
const file = e.target.files && e.target.files[0]; if (!file) return
replyUploading.value = true
try { replyMedia.value = await uploadImage(file) } catch { $q.notify({ type: 'negative', message: "Échec de l'envoi de l'image" }) }
replyUploading.value = false; if (replyImgInput.value) replyImgInput.value.value = ''
}
// ── Pièces jointes : upload (images), bibliothèque d'équipements réutilisable ──
async function uploadAttachment (file) {
const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file) })
const r = await fetch(`${HUB_URL}/campaigns/assets/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: file.name, data: dataUrl }) })
const d = await r.json(); if (!d.filename) throw new Error(d.error || 'upload')
return { asset: d.filename, filename: file.name, mime: d.content_type, url: d.url }
}
async function onAttachFiles (e, target) {
const files = Array.from(e.target.files || []); e.target.value = ''
if (!files.length) return
attUploading.value = true
const list = target === 'compose' ? composeAttachments : replyAttachments
for (const f of files) {
if (!/^image\//.test(f.type)) { $q.notify({ type: 'warning', message: f.name + ' : images seulement pour linstant' }); continue }
try { list.value.push(await uploadAttachment(f)) } catch (err) { $q.notify({ type: 'negative', message: 'Échec : ' + f.name }) }
}
attUploading.value = false
}
function pickReplyAtt () { if (replyAttInput.value) replyAttInput.value.click() }
function pickComposeAtt () { if (composeAttInput.value) composeAttInput.value.click() }
function guessExt (mime) { return ({ 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/svg+xml': '.svg' }[mime] || '') }
async function openPresetGallery (target) {
presetDlg.value = { open: true, target, loading: true, list: [] }
try { const r = await fetch(`${HUB_URL}/collab/attach-presets`); const d = r.ok ? await r.json() : {}; presetDlg.value.list = d.presets || [] } catch (e) { presetDlg.value.list = [] } finally { presetDlg.value.loading = false }
}
function pickPreset (p) {
const list = presetDlg.value.target === 'compose' ? composeAttachments : replyAttachments
if (!list.value.some(a => a.asset === p.asset)) list.value.push({ asset: p.asset, filename: (p.name || 'image') + guessExt(p.mime), mime: p.mime, url: p.url })
}
function pickPresetUpload () { if (presetAddInput.value) presetAddInput.value.click() }
async function addPresetFromFile (e) {
const f = (e.target.files || [])[0]; e.target.value = ''
if (!f || !/^image\//.test(f.type)) return
presetDlg.value.loading = true
try {
const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(f) })
const name = (f.name || 'Image').replace(/\.[^.]+$/, '')
const r = await fetch(`${HUB_URL}/collab/attach-presets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, category: 'Équipement', data: dataUrl }) })
const d = r.ok ? await r.json() : {}
if (d.preset) presetDlg.value.list.push(d.preset); else $q.notify({ type: 'negative', message: d.error || 'Échec de lajout' })
} catch (err) { $q.notify({ type: 'negative', message: 'Échec de lajout' }) } finally { presetDlg.value.loading = false }
}
async function removePreset (p) {
try { await fetch(`${HUB_URL}/collab/attach-presets/${encodeURIComponent(p.id)}`, { method: 'DELETE' }); presetDlg.value.list = presetDlg.value.list.filter(x => x.id !== p.id) } catch (e) { /* */ }
}
const matchOptions = computed(() => newMatches.value.map(m => ({ label: (m.customer_name || m.name) + (m.territory ? ' · ' + m.territory : (m.email ? ' · ' + m.email : '')), value: m.name })))
let _resolveT = null
watch(newPhone, (v) => {
if (custPicked.value) return // client déjà choisi via la recherche → on garde le lien
const digits = String(v || '').replace(/\D/g, '')
newMatches.value = []; newLinkChoice.value = ''
if (digits.length < 10) { newResolving.value = false; return }
newResolving.value = true
clearTimeout(_resolveT)
_resolveT = setTimeout(async () => {
const m = await resolvePhone(digits); newMatches.value = m
if (m.length === 1) { newLinkChoice.value = m[0].name; if (!newCustomerName.value) newCustomerName.value = m[0].customer_name || '' }
newResolving.value = false
}, 450)
})
function resetNew () { newPhone.value = ''; newEmail.value = ''; newMessage.value = ''; newMedia.value = ''; newCustomerName.value = ''; newSubject.value = ''; newMatches.value = []; newLinkChoice.value = ''; custSearch.value = ''; custResults.value = []; custPicked.value = null; newHtml.value = ''; newCc.value = []; newBcc.value = []; ccSearch.value = ''; ccResults.value = []; ccMode.value = 'cc'; composeAttachments.value = []; toResults.value = []; composeAdvHtml.value = ''; advDesign.value.compose = null } // newChannel conservé (dernière préférence)
// Recherche client par texte libre (nom / adresse / téléphone) → résumé contacts → présélection du canal.
const custSearch = ref(''); const custResults = ref([]); const custSearching = ref(false); const custPicked = ref(null)
let _custT = null
function onCustSearch (v) {
clearTimeout(_custT); const q = String(v || '').trim()
if (q.length < 3) { custResults.value = []; return }
_custT = setTimeout(async () => {
custSearching.value = true
try { const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}`); if (r.ok) { const d = await r.json(); custResults.value = d.matches || [] } } catch (e) { custResults.value = [] } finally { custSearching.value = false }
}, 350)
}
function pickCust (m) {
clearTimeout(_custT) // annule une recherche en attente → la liste ne réapparaît plus après la sélection (un seul choix suffit)
custPicked.value = m; custResults.value = []; custSearch.value = m.customer_name || ''
newCustomerName.value = m.customer_name || ''
newLinkChoice.value = m.name
newMatches.value = [{ name: m.name, customer_name: m.customer_name, territory: m.territory, email: m.email }]
if (m.phone) newPhone.value = String(m.phone).replace(/\D/g, '')
if (m.email) newEmail.value = m.email
newChannel.value = m.can_sms ? 'sms' : (m.email ? 'email' : 'sms') // présélectionne le canal selon ce qui est dispo
}
function clearCust () { custPicked.value = null; custSearch.value = ''; custResults.value = []; newLinkChoice.value = ''; newMatches.value = []; newCustomerName.value = '' }
// Libellés d'affichage des files (singulier possible) — la valeur/clé reste le nom réel (= F)
const queueLabels = ref({})
function qLabel (q) { return queueLabels.value[q] || q }
async function loadQueueLabels () { try { const r = await fetch(`${HUB_URL}/conversations/queue-members`); if (r.ok) { const d = await r.json(); queueLabels.value = d.labels || {} } } catch (e) { /* */ } }
onMounted(() => {
fetchList()
fetchTickets()
connectGlobalSSE()
loadQueueLabels()
})
onUnmounted(() => {
disconnectSSE()
})
async function send () {
const text = draft.value.trim()
if ((!text && !replyMedia.value) || !activeToken.value) return
sending.value = true
try {
await sendMessage(activeToken.value, text, replyMedia.value || undefined)
draft.value = ''; replyMedia.value = ''
replyOpen.value = false; replyTarget.value = null // envoi terminé → ferme l'éditeur inline
await nextTick()
scrollToBottom()
} catch {
$q.notify({ type: 'negative', message: 'Erreur d\'envoi' })
}
sending.value = false
}
async function createNew () {
const digits = String(newPhone.value).replace(/\D/g, '')
if (digits.length < 10) return
creating.value = true
try {
const chosen = newLinkChoice.value || (newMatches.value.length === 1 ? newMatches.value[0].name : '')
const chosenName = (newMatches.value.find(m => m.name === chosen) || {}).customer_name || undefined
const data = await startConversation({ phone: digits, customer: chosen || undefined, customerName: chosenName, message: newMessage.value.trim() || undefined, media: newMedia.value || undefined })
newDialogOpen.value = false; resetNew()
$q.notify({ type: 'positive', message: 'Texto envoyé' })
// Bascule dans le fil de la conversation (vue messagerie texte) — par token, sinon par téléphone
const tok = data && data.token
const last10 = digits.slice(-10)
const disc = discussions.value.find(d => (tok && d.conversations.some(c => c.token === tok)) ||
(d.status === 'active' && d.phone.replace(/\D/g, '').slice(-10) === last10))
if (disc) { panelOpen.value = true; openDiscussion(disc); await nextTick(); scrollToBottom() }
} catch {
$q.notify({ type: 'negative', message: 'Erreur d\'envoi' })
}
creating.value = false
}
// Nouveau courriel SORTANT → part de cc@/support@ via Gmail, crée une conversation email suivie + bascule dedans.
async function createNewEmail () {
const to = newEmail.value.trim()
if (!/.+@.+\..+/.test(to) || (!composeHasBody.value && !composeAttachments.value.length && !composeAdvHtml.value)) return
creating.value = true
try {
const d = await startEmail({ to, cc: newCc.value.join(', '), bcc: newBcc.value.join(', '), subject: newSubject.value.trim(), html: composeAdvHtml.value || newHtml.value, attachments: composeAttachments.value, customer: (custPicked.value && custPicked.value.name) || '', customerName: newCustomerName.value || '', sendAs: composeSendAs.value })
newDialogOpen.value = false; resetNew()
$q.notify({ type: 'positive', icon: 'mail', message: 'Courriel envoyé' })
const tok = d && d.token
if (tok) { await fetchList(); const disc = discussions.value.find(x => (x.token === tok) || x.conversations.some(c => c.token === tok)); if (disc) { panelOpen.value = true; openDiscussion(disc); await nextTick(); scrollToBottom() } }
} catch (e) { $q.notify({ type: 'negative', message: e.message || 'Erreur d\'envoi' }) }
creating.value = false
}
function confirmClose () {
$q.dialog({
title: 'Fermer la session ?',
message: 'Le client ne pourra plus envoyer de messages dans cette session.',
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'orange', label: 'Fermer' },
}).onOk(async () => {
await closeConversation(activeToken.value)
})
}
function confirmDeleteDiscussion (disc) {
$q.dialog({
title: 'Supprimer la discussion ?',
message: `Supprimer ${disc.messageCount} messages avec ${disc.customerName || disc.phone} ?`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'red', label: 'Supprimer' },
}).onOk(async () => {
try {
await deleteDiscussion(disc)
$q.notify({ type: 'positive', message: 'Discussion supprimee' })
} catch {
$q.notify({ type: 'negative', message: 'Erreur de suppression' })
}
})
}
// Quitter le fil après une action terminale (suppr./spam/archive). En PLEIN ÉCRAN (route /communications/c/:token), goBack() vide l'état mais la vue inline n'a pas de liste → elle reste coincée sur un loader. Il faut donc RETOURNER à la boîte.
function leaveConversation () { goBack(); if (props.inline) router.push('/communications') }
// Suppression du fil ACTIF par token → côté hub, met aussi les courriels liés à la corbeille Gmail (réversible 30 j).
function doDelete () {
if (!coordToken.value) return
const isEmail = isEmailConv.value
$q.dialog({
title: 'Supprimer la conversation ?',
message: isEmail ? 'La conversation est supprimée et les courriels liés sont mis à la corbeille Gmail (réversible 30 j).' : 'Supprimer cette conversation ?',
cancel: { flat: true, label: 'Annuler' }, ok: { color: 'red', label: 'Supprimer' },
}).onOk(async () => {
try { await deleteConvByToken(coordToken.value); $q.notify({ type: 'positive', message: isEmail ? 'Supprimée · courriels → corbeille Gmail' : 'Supprimée' }); leaveConversation() } catch (e) { $q.notify({ type: 'negative', message: e.message }) }
})
}
// Spam → marque le(s) courriel(s) SPAM côté Gmail (Gmail apprend) + retire la conversation de l'Inbox.
function doSpam () {
if (!coordToken.value) return
$q.dialog({
title: 'Marquer comme spam ?',
message: 'Le(s) courriel(s) sont marqués SPAM dans Gmail (Gmail apprend de l\'expéditeur) et la conversation quitte l\'Inbox.',
cancel: { flat: true, label: 'Annuler' }, ok: { color: 'orange-9', label: 'Spam' },
}).onOk(async () => {
try { const d = await spamConv(coordToken.value); $q.notify({ type: 'positive', message: `Spam · ${d.spammed || 0} courriel(s) → Gmail` }); leaveConversation() } catch (e) { $q.notify({ type: 'negative', message: e.message }) }
})
}
async function doArchive (disc) {
$q.dialog({
title: 'Archiver en ticket ?',
message: `Creer un ticket ferme avec les ${disc.messageCount} messages, puis supprimer la discussion ?`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'indigo', label: 'Archiver' },
}).onOk(async () => {
archiving.value = true
try {
const result = await archiveDiscussion(disc)
$q.notify({ type: 'positive', message: `Archive → ticket ${result.issue}` })
leaveConversation()
} catch (e) {
$q.notify({ type: 'negative', message: e.message || 'Erreur d\'archivage' })
}
archiving.value = false
})
}
async function doBulkArchive () {
const sel = selectedDiscussions.value
$q.dialog({
title: `Archiver ${sel.length} discussion${sel.length > 1 ? 's' : ''} ?`,
message: `Un ticket ferme sera cree pour chaque discussion.`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'indigo', label: 'Archiver tout' },
}).onOk(async () => {
archiving.value = true
try {
const results = await bulkArchive(sel)
$q.notify({ type: 'positive', message: `${results.length} discussion${results.length > 1 ? 's' : ''} archivee${results.length > 1 ? 's' : ''}` })
} catch {
$q.notify({ type: 'negative', message: 'Erreur d\'archivage' })
}
archiving.value = false
})
}
async function doBulkDelete () {
const sel = selectedDiscussions.value
const totalMsgs = sel.reduce((n, d) => n + d.messageCount, 0)
$q.dialog({
title: `Supprimer ${sel.length} discussion${sel.length > 1 ? 's' : ''} ?`,
message: `${totalMsgs} messages seront supprimees definitivement.`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'red', label: 'Supprimer tout' },
}).onOk(async () => {
bulkDeleting.value = true
try {
await bulkDelete(sel)
$q.notify({ type: 'positive', message: `${sel.length} discussions supprimees` })
} catch {
$q.notify({ type: 'negative', message: 'Erreur de suppression' })
}
bulkDeleting.value = false
})
}
function scrollToBottom () {
// Le conteneur de défilement est désormais .conv-scroll (messages + compose ensemble), pas .conv-messages.
const sc = messagesEl.value && messagesEl.value.closest('.conv-scroll')
if (sc) sc.scrollTop = sc.scrollHeight
else if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
function openImg (url) { if (url) window.open(url, '_blank', 'noopener') }
watch(() => activeDiscussion.value?.messages?.length, () => nextTick(() => scrollToBottom()))
// Présence : quand l'agent tape une réponse, signale aux autres agents (throttle dans le composable).
watch(draft, () => { if (_suppressPush.value) return; if (activeToken.value) { if (draft.value) notifyTyping(activeToken.value); pushDraft(activeToken.value, '', draft.value, replyTarget.value?.id || null) } })
// Éditeur COURRIEL : diffuse aussi « en train d'écrire » + le brouillon miroir (corrige : avant, seul le champ SMS le faisait)
watch(draftHtml, () => { if (_suppressPush.value) return; if (!activeToken.value) return; const real = hasRealReply.value; if (real) notifyTyping(activeToken.value); pushDraft(activeToken.value, real ? draftHtml.value : '', '', replyTarget.value?.id || null) }) // citation seule → push vide (pas de brouillon fantôme)
// Re-cibler (cliquer « répondre » sur un autre message) re-persiste l'ancre même sans nouvelle frappe.
watch(replyTarget, () => { if (_suppressPush.value) return; const tk = activeToken.value; if (tk && (hasRealReply.value || String(draft.value || '').trim())) pushDraft(tk, draftHtml.value, draft.value, replyTarget.value?.id || null) })
// (formatTime local retiré → on utilise relTime de useFormatters : garde isNaN + cohérent avec la liste)
function formatDate (dateStr) {
if (!dateStr) return ''
const d = new Date(String(dateStr).length <= 10 ? dateStr + 'T00:00:00' : dateStr)
if (isNaN(d.getTime())) return '' // jamais « Invalid Date » (date absente/illisible)
const now = new Date()
const today = now.toISOString().slice(0, 10)
const yesterday = new Date(now - 86400000).toISOString().slice(0, 10)
if (dateStr === today) return "Aujourd'hui"
if (dateStr === yesterday) return 'Hier'
return d.toLocaleDateString('fr-CA', { weekday: 'short', month: 'short', day: 'numeric' })
}
</script>
<style scoped>
/* Courriel complet : carte large + iframe sandbox (rendu WYSIWYG) */
.email-card { width: 100%; border: 0; border-radius: 0; background: transparent; overflow: hidden; }
.email-card-agent { border-color: #c7d2fe; background: #f5f7ff; }
/* Note interne en bulle (style Missive) — entrelacée dans le fil, visuellement distincte (ambre, accent à gauche) */
.conv-note-bubble { margin: 4px 10px 4px 30px; padding: 6px 10px; background: #fff8e1; border: 1px solid #ffe08a; border-left: 3px solid #f5a623; border-radius: 10px; font-size: 0.85rem; }
.conv-note-text { white-space: pre-wrap; color: #5d4037; margin-top: 2px; }
/* Composeur unifié (style Missive) : bascule Répondre / Note interne */
.conv-compose-switch { padding: 8px 12px 2px; border-top: 1px solid #eef1f6; }
.mention-active { background: #fff3cd; }
.conv-note-compose { padding: 8px 12px; background: #fffdf5; border-top: 1px solid #f3edd7; }
.email-card-head { display: flex; align-items: center; padding: 2px 0; background: transparent; }
.email-card-agent .email-card-head { background: #eef2ff; }
.email-frame { width: 100%; height: auto; min-height: 48px; border: 0; background: #fff; display: block; }
.pay-card { margin: 6px 8px; padding: 8px 10px; border: 1px solid #bbf7d0; background: #f0fdf4; border-radius: 8px; }
.pay-card-no { border-color: #e2e8f0; background: #f8fafc; }
.email-editor { border: 1px solid #e2e8f0; border-radius: 8px; }
.giga-btn { background: #00C853 !important; color: #fff !important; }
.adv-ready { background: #E6F9EE; border: 1px solid #b6f0cf; border-radius: 8px; padding: 4px 10px; }
.email-editor :deep(.q-editor__content) { font-size: .85rem; min-height: 8rem; overflow: visible; } /* autogrow → pas de scroll interne ; le bloc .conv-scroll défile pour tout */
.conv-toolbar {
background: #fff;
border-bottom: 1px solid #f1f5f9;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05); /* la bande sticky flotte légèrement au-dessus du scroll (style Chatwoot) */
align-items: center; /* titre sur UNE ligne → centré verticalement */
padding: 4px 6px 4px 10px; /* bande mince */
min-height: 46px;
height: auto;
z-index: 2;
}
/* En fenêtre flottante (hauteur fixe), les rangées d'en-tête NE DOIVENT PAS rétrécir
(sinon leur contenu déborde et chevauche la rangée suivante). Seule la zone des
messages flexe et défile. */
.conv-toolbar, .coord-own, .coord-notes-top, .coord-bar, .coord-bar2, .coord-bar3 { flex: 0 0 auto; }
/* Lignes empilées du titre (nom · date/email · « Lier une fiche » · ticket) aérées */
.conv-toolbar :deep(.q-toolbar__title) { padding: 2px 0; line-height: 1.45; white-space: nowrap; }
.conv-context { padding: 8px 12px 10px; border-bottom: 1px solid #eef1f6; }
.conv-subject-h { font-size: 1.1rem; font-weight: 700; color: #0f172a; line-height: 1.3; margin-bottom: 5px; }
.conv-toolbar :deep(.q-toolbar__title) > div + div { margin-top: 5px; }
.bulk-bar {
display: flex;
align-items: center;
padding: 4px 10px;
background: #eef2ff;
border-bottom: 1px solid #c7d2fe;
}
.disc-list .q-item { padding: 6px 8px; }
/* UN SEUL conteneur de défilement : messages + zone de réponse défilent ensemble (plus de scroll imbriqués). */
.conv-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scroll-behavior: smooth;
display: flex;
flex-direction: column;
}
.conv-messages {
flex: 1 0 auto; /* pousse la zone de réponse en bas quand le fil est court ; défile via .conv-scroll quand il est long */
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.conv-msg { display: block; width: 100%; border-top: 1px solid #eceff3; } /* séparateur UNIQUE entre messages (style Gmail) — pas de boîte arrondie autour de chaque message */
.conv-msg:first-child { border-top: none; }
.conv-msg-customer { justify-content: flex-start; }
.conv-msg-agent { justify-content: flex-end; }
.conv-msg-system { justify-content: center; }
/* En-tête repliable (accordéon style Gmail) */
.msg-head { display: block; width: 100%; padding: 6px 4px; cursor: pointer; transition: background .12s; }
.msg-head:hover { background: #f8fafc; }
.msg-head-open { background: transparent; }
.msg-head-line1 { font-size: 0.82rem; line-height: 1.2; }
.msg-who { font-weight: 600; color: #1e293b; }
.msg-time { color: #94a3b8; font-size: 0.72rem; white-space: nowrap; }
/* Aperçu juste SOUS le nom (indenté au niveau du nom, après l'icône) — style Gmail */
.msg-snippet { color: #64748b; font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-left: 22px; margin-top: 1px; }
.msg-to { color: #64748b; font-size: 0.74rem; padding-left: 22px; margin-top: 1px; }
.msg-to span { color: #334155; }
.msg-to-rcpt { cursor: help; border-bottom: 1px dotted #94a3b8; }
.msg-body { padding: 2px 0 6px; }
.conv-msg-agent .msg-body { display: flex; justify-content: flex-end; }
.conv-msg-customer .msg-body { display: flex; justify-content: flex-start; }
.coord-bar3 { display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: #fff; border-bottom: 1px solid #f1f5f9; }
.coord-assist { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; padding: 2px 8px 5px; background: #f8fafc; border-bottom: 1px solid #f1f5f9; }
/* Coordination shared-inbox */
.coord-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 7px 10px; background: #f8fafc; border-bottom: 1px solid #f1f5f9; }
.coord-own { gap: 6px; padding: 8px 10px; background: #ffffff; border-bottom: 1px solid #eef2f7; }
.coord-notes-top { padding: 2px 10px 8px; background: #fffdf5; border-bottom: 1px solid #f3edd7; }
.coord-notes-top .coord-note { font-size: 0.78rem; color: #6b5e2e; padding: 2px 0; gap: 2px; }
.coord-notes-top .coord-note .coord-note-act { opacity: 0; transition: opacity .12s; }
.coord-notes-top .coord-note:hover .coord-note-act { opacity: 0.75; }
.msg-att { padding: 0 10px 5px 24px; }
.coord-bar2 { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 3px 8px; background: #fff; border-bottom: 1px solid #f1f5f9; }
.svc-bar { padding: 6px 9px; background: #ecfeff; border-bottom: 1px solid #cffafe; }
.coord-lbl { font-size: 0.68rem; font-weight: 700; color: #94a3b8; text-transform: uppercase; margin-right: 2px; }
/* #3 Bandeau mauvais aiguillage (triage IA ≠ file actuelle) */
.coord-misroute { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 6px 12px; background: #fff7ed; border-bottom: 1px solid #fed7aa; font-size: 0.8rem; color: #9a3412; }
.coord-notes { padding: 6px 10px; background: #fffbeb; border-top: 1px solid #fde68a; max-height: 120px; overflow-y: auto; }
.coord-note { font-size: 0.78rem; color: #78350f; line-height: 1.45; }
.coord-note b { color: #92400e; }
.coord-note-toggle { padding: 2px 6px; background: #fff; border-top: 1px solid #f1f5f9; text-align: left; }
.coord-nl { padding: 5px 8px; background: #faf5ff; border-bottom: 1px solid #e9d5ff; }
.noise-toggle { padding: 2px 8px 4px; text-align: right; }
.conv-bubble { max-width: 85%; padding: 8px 12px; border-radius: 12px; font-size: 0.85rem; line-height: 1.4; word-break: break-word; }
.conv-msg-customer .conv-bubble { background: #f1f5f9; color: #1e293b; border-bottom-left-radius: 3px; }
.conv-msg-agent .conv-bubble { background: #4f46e5; color: #fff; border-bottom-right-radius: 3px; }
.conv-msg-system .conv-bubble { background: transparent; color: #94a3b8; font-size: 0.75rem; }
.conv-img { max-width: 220px; max-height: 240px; border-radius: 8px; display: block; cursor: pointer; margin-bottom: 4px; }
.conv-sender { font-size: 0.65rem; font-weight: 600; opacity: 0.7; margin-bottom: 2px; }
.conv-meta { font-size: 0.65rem; opacity: 0.6; margin-top: 3px; text-align: right; }
.conv-via { background: rgba(0,0,0,0.08); padding: 1px 3px; border-radius: 2px; font-size: 0.6rem; margin-right: 3px; }
.conv-msg-agent .conv-via { background: rgba(255,255,255,0.2); }
.conv-via.ai { background: rgba(255,255,255,0.25); }
.conv-typing-line { padding: 4px 12px; font-size: 0.78rem; color: #0d9488; display: flex; align-items: center; gap: 4px; background: #f0fdfa; border-top: 1px solid #ccfbf1; }
.conv-compose { padding: 8px 10px; border-top: 1px solid #e2e8f0; background: #fff; }
.conv-compose-typing { box-shadow: inset 0 3px 0 0 #14b8a6; animation: convTypingPulse 1.1s ease-in-out infinite; }
/* Éditeur de réponse INLINE (ancré dans le fil, après le message référencé) : carte indigo distincte, alignée sur les bulles. */
.conv-compose-inline { margin: 8px 10px 10px 30px; border: 1px solid #c7d2fe; border-left: 3px solid #6366f1; border-radius: 10px; box-shadow: 0 1px 6px rgba(99,102,241,.10); }
.conv-compose-inline.conv-compose-typing { box-shadow: inset 0 3px 0 0 #14b8a6, 0 1px 6px rgba(99,102,241,.10); }
/* Repli de la citation (style Gmail) : on masque le bloc cité dans l'éditeur — il reste dans le HTML envoyé. */
.email-editor.quote-folded :deep(blockquote) { display: none; }
/* Aperçu du contenu transféré dans la fenêtre « Transférer ». */
.fwd-preview { border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px; max-height: 220px; overflow-y: auto; background: #f8fafc; }
.fwd-preview-body { font-size: 0.84rem; color: #334155; line-height: 1.4; }
@keyframes convTypingPulse { 0%, 100% { box-shadow: inset 0 3px 0 0 #14b8a6; } 50% { box-shadow: inset 0 3px 0 0 #99f6e4; } }
.coord-readers { padding: 3px 12px 0; }
.coord-reader-av { font-weight: 600; margin-right: 2px; }
/* Présence « en train de répondre » : anneau teal qui pulse autour de l'avatar (style Google Docs) */
.coord-reader-av.presence-typing { box-shadow: 0 0 0 2px #fff, 0 0 0 4px #14b8a6; animation: presencePulse 1.1s ease-in-out infinite; }
@keyframes presencePulse { 0%, 100% { box-shadow: 0 0 0 2px #fff, 0 0 0 4px #14b8a6; } 50% { box-shadow: 0 0 0 2px #fff, 0 0 0 6px rgba(20, 184, 166, .3); } }
.presence-pulse { box-shadow: 0 0 0 2px #fff, 0 0 0 4px #14b8a6; animation: presencePulse 1.1s ease-in-out infinite; } /* avatar de l'agent qui rédige (ligne « écrit… » + miroir brouillon) */
/* Résumé IA (style Gmail « Summarise this email ») */
.conv-summary-zone { padding: 6px 12px 0; }
.conv-summary-card { background: #faf5ff; border: 1px solid #e9d5ff; border-radius: 10px; padding: 8px 12px; font-size: .82rem; color: #4b3a66; }
.conv-summary-body :deep(.sum-li) { margin: 2px 0; line-height: 1.38; }
/* #2 Carte disponibilité (fibre RQA) */
.conv-svc-card { margin-top: 6px; border-radius: 10px; padding: 8px 12px; font-size: .82rem; border: 1px solid #e2e8f0; }
.conv-svc-card.svc-yes { background: #f0fdf4; border-color: #bbf7d0; color: #166534; }
.conv-svc-card.svc-no { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
.conv-svc-card.svc-warn { background: #fffbeb; border-color: #fde68a; color: #92400e; }
/* CTA Répondre (Gmail) */
.conv-reply-cta { padding: 10px 12px; border-top: 1px solid #eef1f6; }
/* Brouillon INLINE dans le fil (style Missive) : carte indigo pointillée, ancrée après son message cible. */
.conv-draft-inline { margin: 6px 10px 6px 30px; border: 1px dashed #a5b4fc; border-left: 3px solid #6366f1; border-radius: 10px; background: #eef2ff; padding: 6px 10px; }
.conv-draft-inline-mine { border-style: solid; border-color: #c7d2fe; background: #f5f7ff; box-shadow: 0 1px 4px rgba(99,102,241,.12); }
.conv-draft-inline-head { font-size: 0.74rem; color: #4338ca; margin-bottom: 3px; }
.conv-draft-inline-body { font-size: 0.84rem; color: #334155; max-height: 160px; overflow-y: auto; line-height: 1.4; }
.conv-draft-inline-body :deep(blockquote) { margin: 6px 0 0; }
/* Plein écran : colonne flex qui remplit la hauteur ; le fil défile, le composeur reste en bas */
.conv-fullpage { display: flex; flex-direction: column; width: 100%; background: #fff; min-height: 0; }
.conv-fullpage .conv-scroll { flex: 1 1 auto; height: auto; min-height: 0; } /* le SEUL scroller (overflow via .conv-scroll de base) */
/* Fenêtre FLOTTANTE (remplace le volet ancré) : coin bas-droite par défaut, déplaçable,
NON bloquante (pas de fond modal → la page reste utilisable pendant la discussion). */
.conv-float {
position: fixed; right: 18px; bottom: 18px;
width: 400px; height: min(78vh, 720px); max-height: calc(100vh - 36px);
background: #fff; border: 1px solid var(--ops-border, #e2e8f0);
border-radius: 12px; box-shadow: 0 12px 40px rgba(15, 23, 42, .22);
z-index: 3000; display: flex; flex-direction: column; overflow: hidden;
}
.conv-float .conv-scroll { flex: 1 1 auto; min-height: 0; } /* le SEUL scroller (overflow via .conv-scroll de base) */
.conv-float .conv-toolbar { cursor: move; user-select: none; } /* barre de titre = poignée de déplacement */
@media (max-width: 700px) {
/* sur mobile, la fenêtre occupe presque tout l'écran */
.conv-float { right: 8px; left: 8px; bottom: 8px; top: 8px; width: auto; height: auto; max-height: none; }
}
/* Chaque courriel s'ajuste à SON contenu (fitEmailFrame) — plus de hauteur fixe ni d'étirement du dernier message. */
.conv-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; height: 50vh; color: #94a3b8; font-size: 0.9rem; }
</style>