Lot de travaux accumulés non commités (plusieurs sessions), vérifiés : hub `node --check` tout OK + build SPA propre. - ops : AssignmentField, CommandPalette, IssueDetail/DetailModal + detail-sections/modules, UserAvatar/useAvatar, UserProfileDialog/EmployeeEditDialog, HelpHint, useSkillIcons, pages (Dashboard/Equipe/Clients/Tickets/Reports/Settings/Evaluations/LegacySync…), usePermissions/useUserGroups/useDetailModal, MainLayout, TicketStatusControl - hub : staff-agent.js (couche commandes NL), agent.js/agent-tools.json/voice-agent.js, avatars.js, conversation.js, payments.js, sync-orchestrator.js, auth.js, campaigns.js, legacy-dispatch-sync.js, server.js Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2693 lines
206 KiB
Vue
2693 lines
206 KiB
Vue
<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>
|
||
<router-link v-if="t.customer" :to="'/clients/' + encodeURIComponent(t.customer)" class="erp-link" @click.stop>{{ t.customer }}</router-link><span v-else class="text-grey-6">—</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 flat dense size="sm" icon="fact_check" color="indigo-6" @click="availReasonOpen = true">
|
||
<q-tooltip>Dispo par motif : compétence probable de cette demande → disponibilité ajustée</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) }} · {{ activeDiscussion.messageCount }} messages<span v-if="activeDiscussion.email"> · {{ 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 (photo sinon initiales) + nom par message → on voit QUI a écrit.
|
||
Affiché dès qu'un fil a >1 message ET TOUJOURS pour un message d'agent (staff),
|
||
même seul dans le fil (sinon on ne sait pas quel collègue l'a envoyé — ex. courriel transféré). -->
|
||
<UserAvatar v-if="showMsgWho(msg)" :email="msg.agent || msg.fromEmail" :name="msgWho(msg)" :size="22" class="q-mr-xs msg-av" />
|
||
<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 && showMsgWho(msg)" :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="showMsgWho(msg)" 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> 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" />
|
||
|
||
<!-- « Dispo par motif » depuis un courriel/SMS : sujet pré-rempli → l'IA/les chips évaluent la compétence → disponibilité filtrée -->
|
||
<AvailabilityByReason v-model="availReasonOpen"
|
||
:initial-text="convSubject || (activeDiscussion && activeDiscussion.subject) || ''"
|
||
:customer-name="(activeDiscussion && (activeDiscussion.customerName || activeDiscussion.email || activeDiscussion.phone)) || ''" />
|
||
|
||
<!-- Ajouter le fil courant à une tâche (réutilisable — conversation/ticket/projet) -->
|
||
<AddToTaskDialog v-model="addTaskOpen" :source="activeTaskSrc || taskSource" />
|
||
|
||
<!-- Détail ticket NATIF (ex-lien ERPNext desk) : ouvre la fiche ticket dans OPS -->
|
||
<DetailModal
|
||
v-model:open="tkModalOpen" :loading="tkModalLoading" :doctype="tkModalDoctype"
|
||
:doc-name="tkModalDocName" :title="tkModalTitle" :doc="tkModalDoc"
|
||
:comments="tkModalComments" :comms="tkModalComms" :files="tkModalFiles"
|
||
:doc-fields="tkModalDocFields" :dispatch-jobs="tkModalDispatchJobs"
|
||
@navigate="(dt, name, t) => tkOpenModal(dt, name, t)" />
|
||
</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 UserAvatar from 'src/components/shared/UserAvatar.vue'
|
||
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 DetailModal from 'src/components/shared/DetailModal.vue'
|
||
import { useDetailModal } from 'src/composables/useDetailModal'
|
||
|
||
// Détail ticket NATIF (remplace l'ancien lien ERPNext desk) — ERPNext = base de données.
|
||
const { modalOpen: tkModalOpen, modalLoading: tkModalLoading, modalDoctype: tkModalDoctype, modalDocName: tkModalDocName, modalTitle: tkModalTitle, modalDoc: tkModalDoc, modalComments: tkModalComments, modalComms: tkModalComms, modalFiles: tkModalFiles, modalDocFields: tkModalDocFields, modalDispatchJobs: tkModalDispatchJobs, openModal: tkOpenModal } = useDetailModal()
|
||
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison → compétence → disponibilité (dénominateur filtré)
|
||
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 availReasonOpen = ref(false) // « Dispo par motif » depuis la conversation
|
||
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 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, '&').replace(/</g, '<').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, '&').replace(/</g, '<').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(/ /g, ' ').replace(/&/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 => ({ '<': '<', '&': '&', '>': '>' }[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(/ /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(/ /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(/ /g, ' ').replace(/&/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() }
|
||
// Nom d'expéditeur pour le contenu SORTANT (blocs cités / transférés qui partent dans le courriel).
|
||
// RÈGLE : jamais le nom PERSO d'un collègue interne côté externe → identité de GROUPE (« Support TARGO »,
|
||
// « Équipe TARGO »). Le nom réel du collègue reste visible dans l'UI (msgWho), pas dans l'email.
|
||
function outgoingTeamName () {
|
||
const pick = replySendAs.value || senders.value.default || ''
|
||
const m = String(pick).match(/^\s*"?([^"<]+?)"?\s*</) // « "Nom" <addr> » → Nom
|
||
const name = m ? m[1].trim() : ''
|
||
if (name && name.toLowerCase() !== 'personal') return name
|
||
const d = String(senders.value.default || '').match(/^\s*"?([^"<]+?)"?\s*</)
|
||
return (d && d[1].trim()) || 'Équipe TARGO'
|
||
}
|
||
// Nom à AFFICHER dans un bloc sortant pour le message `m` : groupe si interne (agent), sinon le nom réel (client/externe).
|
||
function outgoingSenderName (m, fallback) {
|
||
if (m && m.from === 'agent') return outgoingTeamName()
|
||
return (m && m.fromName) || fallback || (activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Client')
|
||
}
|
||
// Citation ÉDITABLE du message auquel on répond (en-tête + contenu), style Gmail — l'agent écrit au-dessus.
|
||
function quotedBlock (m) {
|
||
const who = outgoingSenderName(m)
|
||
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 => ({ '&': '&', '<': '<', '>': '>' }[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 :</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 => ({ '&': '&', '<': '<', '>': '>' }[c])) }
|
||
// Bloc « Message transféré » (en-têtes De/Date/Objet/À + corps), style Gmail.
|
||
function forwardedBlock (m) {
|
||
if (!m) return ''
|
||
// « De : » sortant — identité de GROUPE pour un collègue interne (jamais le nom perso côté externe).
|
||
const who = outgoingSenderName(m)
|
||
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 :</b> ' + escapeHtml(who) + '<br><b>Date :</b> ' + escapeHtml(when) + '<br><b>Objet :</b> ' + escapeHtml(subj)
|
||
+ (to ? '<br><b>À :</b> ' + escapeHtml(to) : '')
|
||
+ (atts.length ? '<br><b>Pièces jointes :</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 }
|
||
// Afficher le nom/avatar de l'expéditeur ? Oui si le fil a >1 message (contexte utile),
|
||
// et TOUJOURS pour un message d'agent (staff) même seul — sinon on ignore quel collègue
|
||
// l'a envoyé (ex. courriel transféré à un seul message). Jamais pour 'system'.
|
||
function showMsgWho (m) {
|
||
if (!m || m.from === 'system' || !msgWho(m)) return false
|
||
return (activeDiscussion.value?.messages || []).length > 1 || m.from === 'agent'
|
||
}
|
||
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) { tkOpenModal('Issue', name, name) } // ouvre le détail ticket NATIF (DetailModal → IssueDetail)
|
||
|
||
// ── 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(/ /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, '&').replace(/</g, '<').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 l’instant' }); 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 l’ajout' })
|
||
} catch (err) { $q.notify({ type: 'negative', message: 'Échec de l’ajout' }) } 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>
|