feat(ops+hub): sender picker, per-user notifs, editable rating emails, planif & tickets UX

- Comms: sélecteur d'expéditeur « De: » (défaut groupe Support TARGO) via resolveSendFrom + alias vérifiés
- Notifs: prefs de feeds PAR utilisateur (/conversations/notif-prefs) + cloche à bascules ; boot tooltip-ux (clic prioritaire + anti-empilement)
- Courriel: invitation à évaluer = modèle Unlayer éditable (transactional-rating-invite-*) ; test-send via Gmail + expansion {{rating}} ; logo TARGO auto-hébergé sur le magasin d'actifs du hub
- Planif: bloc « sans déplacement » (damier, début de quart, alerte si pas de quart), quart éditable dans l'éditeur de jour, icônes de compétence en vue jour (TV pour télé), clic cellule → éditeur, clic gauche lane → liste / clic droit → menu quart, lien ↗ ticket par job
- Tickets: défaut « Non fermés » + correction du filtre « Mes tickets » (owner)
- Inbox: poll Gmail 1 min + rafraîchir à la demande (poll-now)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-16 22:41:51 -04:00
parent 1b7bad1686
commit 6628eaaa5b
40 changed files with 3476 additions and 195 deletions

View File

@ -3,7 +3,7 @@ const { configure } = require('quasar/wrappers')
module.exports = configure(function () { module.exports = configure(function () {
return { return {
boot: ['pinia'], boot: ['pinia', 'tooltip-ux'],
css: ['app.scss', 'tech.scss'], css: ['app.scss', 'tech.scss'],

View File

@ -200,10 +200,10 @@ export function translateTemplate (srcName, targetName, { override = false } = {
// Send ONE rendered email to a specific address for visual QA. // Send ONE rendered email to a specific address for visual QA.
// Pass { to, vars, from?, subject? } — defaults filled in server-side. // Pass { to, vars, from?, subject? } — defaults filled in server-side.
export function testSendTemplate (name, { to, vars, from, subject } = {}) { export function testSendTemplate (name, { to, vars, from, subject, via } = {}) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, { return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, {
method: 'POST', method: 'POST',
body: { to, vars, from, subject }, body: { to, vars, from, subject, via },
}) })
} }

View File

@ -0,0 +1,37 @@
import { boot } from 'quasar/wrappers'
// Comportement GLOBAL des bulles d'aide (q-tooltip), app-wide :
// 1) Le CLIC a PRÉSÉANCE — un pointerdown masque la bulle immédiatement et tant qu'on reste sur le même
// élément ; elle ne réapparaît qu'en survolant un AUTRE élément.
// 2) JAMAIS deux bulles à la fois — sur des éléments imbriqués (ex. cellule de planif + bloc job, ou
// timeline + job), on ne garde que la PLUS RÉCENTE (la plus interne). Anti-empilement.
//
// Quasar monte chaque tooltip comme élément `.q-tooltip` (téléporté dans <body>). On agit au niveau DOM
// (display) sans toucher chaque q-tooltip individuellement → fix unique pour toute l'app. Entièrement
// défensif : si l'hypothèse DOM change, le pire cas est un no-op (bulles inchangées), jamais une casse.
export default boot(() => {
if (typeof document === 'undefined' || !document.body) return
let suppressed = false // vrai juste après un clic → on masque tout jusqu'au prochain survol d'un autre élément
let clicked = null
const apply = () => {
const tips = Array.from(document.querySelectorAll('.q-tooltip'))
if (!tips.length) return
if (suppressed) { for (const t of tips) t.style.display = 'none'; return }
// ne garder QUE la plus récente (dernière dans le DOM = la plus interne / dernière survolée)
tips.forEach((t, i) => { t.style.display = i === tips.length - 1 ? '' : 'none' })
}
// 1) clic = masquer la bulle (préséance)
document.addEventListener('pointerdown', (e) => { suppressed = true; clicked = e.target; apply() }, true)
// … jusqu'à ce qu'on survole un élément DIFFÉRENT
document.addEventListener('pointerover', (e) => {
if (suppressed && clicked && e.target !== clicked && !(clicked.contains && clicked.contains(e.target))) {
suppressed = false; clicked = null; apply()
}
}, true)
// 2) anti-empilement : à chaque ajout/retrait de bulle (téléport dans body), garder une seule bulle
try { new MutationObserver(apply).observe(document.body, { childList: true }) } catch (e) { /* no-op */ }
})

View File

@ -1,12 +1,15 @@
<template> <template>
<div class="ops-card q-mb-md"> <div class="ops-card q-mb-md">
<div class="row items-center q-mb-xs"> <div class="row items-center q-mb-xs cursor-pointer" @click="expanded = !expanded">
<q-icon name="quickreply" size="20px" color="indigo-6" class="q-mr-sm" /> <q-icon name="quickreply" size="20px" color="indigo-6" class="q-mr-sm" />
<span class="text-subtitle1 text-weight-bold">Signatures & réponses pré-écrites</span> <span class="text-subtitle1 text-weight-bold">Signatures & réponses pré-écrites</span>
<q-chip v-if="list.length" dense square color="indigo-1" text-color="indigo-8" class="q-ml-sm">{{ list.length }}</q-chip>
<q-space /> <q-space />
<q-spinner v-if="loading" size="16px" color="indigo-6" /> <q-spinner v-if="loading" size="16px" color="indigo-6" class="q-mr-sm" />
<q-btn dense unelevated color="indigo-5" icon="add" label="Nouvelle" no-caps @click="addNew" /> <q-btn v-if="expanded" dense unelevated color="indigo-5" icon="add" label="Nouvelle" no-caps @click.stop="addNew" />
<q-icon :name="expanded ? 'keyboard_arrow_up' : 'keyboard_arrow_down'" size="24px" color="grey-7" class="q-ml-sm" />
</div> </div>
<div v-if="expanded">
<div class="text-caption text-grey-6 q-mb-md"> <div class="text-caption text-grey-6 q-mb-md">
Réutilisables dans l'Inbox (menu « Réponses types » sous la réponse). Éditeur HTML : gras, listes, liens et <b>images / logo</b> — ou collez votre signature Gmail. Variables : <code v-pre>{{client}}</code>, <code v-pre>{{agent}}</code> (remplacées à l'insertion). Associez une <b>mailbox</b> (ex. factures@ « Comptes Payables »). Réutilisables dans l'Inbox (menu « Réponses types » sous la réponse). Éditeur HTML : gras, listes, liens et <b>images / logo</b> — ou collez votre signature Gmail. Variables : <code v-pre>{{client}}</code>, <code v-pre>{{agent}}</code> (remplacées à l'insertion). Associez une <b>mailbox</b> (ex. factures@ « Comptes Payables »).
</div> </div>
@ -32,6 +35,7 @@
<q-btn unelevated color="indigo-6" icon="save" label="Enregistrer" no-caps :loading="saving" @click="persist" /> <q-btn unelevated color="indigo-6" icon="save" label="Enregistrer" no-caps :loading="saving" @click="persist" />
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
@ -41,6 +45,7 @@ import { HUB_URL } from 'src/config/hub'
const $q = useQuasar() const $q = useQuasar()
const list = ref([]) const list = ref([])
const expanded = ref(false) // section repliée par défaut dans Paramètres (éditeurs montés à l'ouverture)
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const upBusy = ref(null) const upBusy = ref(null)
@ -65,7 +70,7 @@ async function load () {
if (r.ok) { const d = await r.json(); list.value = (d.responses || []).map(x => ({ id: x.id || uid(), title: x.title || '', mailbox: x.mailbox || '', category: x.category || 'Général', html: x.html || textToHtml(x.body) })) } if (r.ok) { const d = await r.json(); list.value = (d.responses || []).map(x => ({ id: x.id || uid(), title: x.title || '', mailbox: x.mailbox || '', category: x.category || 'Général', html: x.html || textToHtml(x.body) })) }
} catch (e) { /* */ } finally { loading.value = false } } catch (e) { /* */ } finally { loading.value = false }
} }
function addNew () { list.value.push({ id: uid(), title: '', mailbox: '', category: 'Général', html: '' }) } function addNew () { expanded.value = true; list.value.push({ id: uid(), title: '', mailbox: '', category: 'Général', html: '' }) }
function remove (i) { list.value.splice(i, 1) } function remove (i) { list.value.splice(i, 1) }
function pickImage (c) { _target = c; if (fileInput.value) { fileInput.value.value = ''; fileInput.value.click() } } function pickImage (c) { _target = c; if (fileInput.value) { fileInput.value.value = ''; fileInput.value.click() } }

View File

@ -123,16 +123,13 @@
<div class="text-caption text-grey-6">{{ formatDate(activeDiscussion.date) }} &middot; {{ activeDiscussion.messageCount }} messages<span v-if="activeDiscussion.email"> &middot; {{ activeDiscussion.email }}</span></div> <div class="text-caption text-grey-6">{{ formatDate(activeDiscussion.date) }} &middot; {{ activeDiscussion.messageCount }} messages<span v-if="activeDiscussion.email"> &middot; {{ activeDiscussion.email }}</span></div>
<!-- Fiche client liée (même logique que le SMS via le numéro : ici on résout par courriel) --> <!-- Fiche client liée (même logique que le SMS via le numéro : ici on résout par courriel) -->
<div class="q-mt-xs"> <div class="q-mt-xs">
<q-chip v-if="activeDiscussion.customer" dense size="sm" color="blue-1" text-color="blue-9" icon="person" clickable @click="openLink">Fiche<q-tooltip>Fiche liée ({{ activeDiscussion.customerName }}) clic pour changer</q-tooltip></q-chip> <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">{{ activeDiscussion.customerName || activeDiscussion.customer }}<q-tooltip>Fiche liée clic : changer · : délier (puis relier)</q-tooltip></q-chip>
<q-btn v-else dense flat size="sm" color="orange-8" icon="person_add" label="Lier une fiche" no-caps @click="openLink"><q-tooltip>Associer cette conversation à une fiche client</q-tooltip></q-btn> <q-btn v-else dense flat size="sm" color="orange-8" 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>
<div v-if="linkedTickets.length" class="q-mt-xs"> <div v-if="linkedTickets.length" class="q-mt-xs">
<q-chip v-for="t in linkedTickets" :key="t.name" dense size="sm" color="teal-1" text-color="teal-9" icon="confirmation_number" clickable @click="openTicket(t.name)">{{ t.name }}<q-tooltip>{{ t.subject }} ouvrir le ticket </q-tooltip></q-chip> <q-chip v-for="t in linkedTickets" :key="t.name" dense size="sm" color="teal-1" text-color="teal-9" icon="confirmation_number" clickable @click="openTicket(t.name)">{{ t.name }}<q-tooltip>{{ t.subject }} ouvrir le ticket </q-tooltip></q-chip>
</div> </div>
</q-toolbar-title> </q-toolbar-title>
<q-btn v-if="activeToken" unelevated dense size="sm" icon="confirmation_number" color="teal-7" label="Ticket" :loading="creatingTicket" @click="openTicketDialog">
<q-tooltip>Créer un ticket à partir de cette conversation (la conversation reste dans l'historique)</q-tooltip>
</q-btn>
<q-btn flat dense size="sm" icon="archive" color="indigo-6" :loading="archiving" @click="doArchive(activeDiscussion)"> <q-btn flat dense size="sm" icon="archive" color="indigo-6" :loading="archiving" @click="doArchive(activeDiscussion)">
<q-tooltip>Archiver (ferme + retire la discussion)</q-tooltip> <q-tooltip>Archiver (ferme + retire la discussion)</q-tooltip>
</q-btn> </q-btn>
@ -151,14 +148,59 @@
</q-btn> </q-btn>
</q-toolbar> </q-toolbar>
<!-- Barre de coordination shared-inbox : file (label) + « je m'en occupe » + réponse Gmail + ticket suggéré --> <!-- Prendre / Assigner unifié (responsable + assistants) + Note interne regroupés en tête -->
<div v-if="activeToken" class="coord-own row items-center">
<q-btn dense unelevated size="sm" no-caps :color="coordAssignee ? 'indigo-6' : 'green-7'" :icon="coordAssignee ? 'manage_accounts' : 'pan_tool'" :label="coordAssignee ? 'Assigner' : 'Prendre / Assigner'">
<q-menu @show="assignSearch = ''">
<q-list dense style="min-width:250px">
<q-item clickable v-close-popup @click="doClaim"><q-item-section avatar><q-icon name="pan_tool" color="green-7" /></q-item-section><q-item-section>Je m'en occupe</q-item-section></q-item>
<q-separator />
<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-indigo-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>{{ shortAgent(a) }}</q-item-section>
<q-item-section side><q-icon v-if="a === coordAssignee" name="star" color="amber-7" 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-red-7"><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="teal-7" /></q-item-section><q-item-section>Créer un ticket</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<q-chip v-if="coordAssignee" dense size="sm" color="amber-3" 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>
<q-space />
<q-btn dense flat size="sm" color="amber-8" icon="sticky_note_2" :label="showNoteInput ? 'Fermer' : 'Note interne'" no-caps @click="showNoteInput = !showNoteInput"><q-tooltip>Indice rapide pour l'équipe + l'IA (jamais envoyé au client)</q-tooltip></q-btn>
</div>
<div v-if="activeToken && (showNoteInput || coordNotes.length)" class="coord-notes-top">
<div v-for="n in coordNotes" :key="n.id" class="coord-note row items-start no-wrap">
<template v-if="editNoteId === n.id">
<q-input dense outlined v-model="editNoteText" class="col" autofocus @keyup.enter="saveEditNote(n)" @keyup.esc="editNoteId = ''">
<template #append><q-btn flat dense round size="xs" icon="check" color="green-7" @click="saveEditNote(n)" /><q-btn flat dense round size="xs" icon="close" color="grey-6" @click="editNoteId = ''" /></template>
</q-input>
</template>
<template v-else>
<div class="col"><b>{{ shortAgent(n.agent) }}</b> {{ n.text }}<span v-if="n.edited" class="text-grey-5 text-caption"> · modifié</span></div>
<q-btn flat dense round size="xs" icon="edit" color="grey-5" class="coord-note-act" @click="startEditNote(n)"><q-tooltip>Modifier</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="close" color="grey-5" class="coord-note-act" @click="removeNote(n)"><q-tooltip>Supprimer</q-tooltip></q-btn>
</template>
</div>
<div v-if="showNoteInput" class="row items-center q-gutter-xs q-mt-xs">
<q-input dense outlined v-model="noteText" class="col" placeholder="Indice / note interne (équipe + IA)…" autofocus @keyup.enter="submitNote" />
<q-btn dense unelevated color="amber-7" icon="send" @click="submitNote" />
</div>
</div>
<!-- File (département) + bascule vers les actions avancées (repliées) -->
<div class="coord-bar"> <div class="coord-bar">
<span class="coord-lbl">File</span> <span class="coord-lbl">File</span>
<q-chip v-for="q in QUEUES" :key="q" dense clickable size="sm" :color="coordQueue === q ? 'indigo-6' : 'grey-3'" :text-color="coordQueue === q ? 'white' : 'grey-8'" @click="setQueue(q)">{{ qLabel(q) }}</q-chip> <q-chip v-for="q in QUEUES" :key="q" dense clickable size="sm" :color="coordQueue === q ? 'indigo-6' : 'grey-3'" :text-color="coordQueue === q ? 'white' : 'grey-8'" @click="setQueue(q)">{{ qLabel(q) }}</q-chip>
<q-space /> <q-space />
<q-btn v-if="!coordAssignee" dense unelevated size="sm" color="green-7" icon="pan_tool" label="Je m'en occupe" no-caps @click="doClaim" /> <q-btn dense flat round size="sm" class="q-ml-xs" :icon="coordOpen ? 'expand_less' : 'tune'" :color="coordOpen ? 'indigo-6' : 'grey-6'" @click="coordOpen = !coordOpen"><q-tooltip>{{ coordOpen ? 'Masquer les actions' : 'Coordination avancée : état, scinder, fusionner, IA…' }}</q-tooltip></q-btn>
<q-chip v-else dense size="sm" color="amber-3" text-color="amber-9" icon="pan_tool" removable @remove="doRelease"><b>{{ shortAgent(coordAssignee) }}</b><q-tooltip>Pris par {{ coordAssignee }} pour libérer</q-tooltip></q-chip>
<q-btn dense flat round size="sm" class="q-ml-xs" :icon="coordOpen ? 'expand_less' : 'tune'" :color="coordOpen ? 'indigo-6' : 'grey-6'" @click="coordOpen = !coordOpen"><q-tooltip>{{ coordOpen ? 'Masquer les actions' : 'Coordination : état, assigner, scinder, fusionner, IA…' }}</q-tooltip></q-btn>
</div> </div>
<!-- #3 Drapeau « mauvais aiguillage » : le triage IA classe ce fil dans une autre file que l'actuelle --> <!-- #3 Drapeau « mauvais aiguillage » : le triage IA classe ce fil dans une autre file que l'actuelle -->
<div v-if="misrouted" class="coord-misroute"> <div v-if="misrouted" class="coord-misroute">
@ -195,31 +237,6 @@
label="Attendre jusqu'au" class="q-ml-xs" style="max-width:160px" @update:model-value="v => setState('pending')"> 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="amber-8" /></template> <template #prepend><q-icon name="event" size="15px" color="amber-8" /></template>
</q-input> </q-input>
<q-space />
<q-btn dense flat size="sm" no-caps :icon="coordAssignee ? 'person' : 'person_outline'"
:label="coordAssignee ? shortAgent(coordAssignee) : 'Assigner'" :color="coordAssignee ? 'indigo-7' : 'grey-7'">
<q-menu><q-list dense style="min-width:210px">
<q-item clickable v-close-popup @click="doClaim"><q-item-section avatar><q-icon name="pan_tool" color="green-7" /></q-item-section><q-item-section>Je m'en occupe</q-item-section></q-item>
<q-separator />
<q-item-label header class="q-py-xs">Assigner à</q-item-label>
<q-item v-for="a in agents" :key="a" clickable v-close-popup @click="assignTo(a)" :active="a === coordAssignee" active-class="bg-indigo-1">
<q-item-section>{{ shortAgent(a) }}</q-item-section><q-item-section v-if="a === coordAssignee" side><q-icon name="check" color="indigo-6" /></q-item-section>
</q-item>
<q-item v-if="coordAssignee" clickable v-close-popup @click="assignTo(null)"><q-item-section class="text-red-7"><q-icon name="person_off" size="16px" /> Désassigner</q-item-section></q-item>
</q-list></q-menu>
</q-btn>
</div>
<div v-if="coordOpen && activeToken && (coordAssignee || coordAssistants.length)" class="coord-assist row items-center no-wrap">
<span class="text-caption text-grey-6 q-mr-xs">Assistants</span>
<q-chip v-for="a in coordAssistants" :key="a" dense size="sm" color="blue-grey-2" text-color="blue-grey-9" removable @remove="removeAssistant(a)">{{ shortAgent(a) }}</q-chip>
<span v-if="!coordAssistants.length" class="text-caption text-grey-4">aucun</span>
<q-btn dense flat round size="sm" icon="person_add" color="grey-6">
<q-tooltip>Ajouter un assistant</q-tooltip>
<q-menu><q-list dense style="min-width:190px">
<q-item v-for="a in availableAssistants" :key="a" clickable v-close-popup @click="addAssistant(a)"><q-item-section>{{ shortAgent(a) }}</q-item-section></q-item>
<q-item v-if="!availableAssistants.length"><q-item-section class="text-grey-5 text-caption">Aucun autre agent</q-item-section></q-item>
</q-list></q-menu>
</q-btn>
</div> </div>
<!-- DIAGNOSTIC SERVICE/MODEM du client lié visible depuis le courriel ; image de l'état attachable à la réponse --> <!-- DIAGNOSTIC SERVICE/MODEM du client lié visible depuis le courriel ; image de l'état attachable à la réponse -->
@ -346,6 +363,11 @@
<!-- Aperçu DIRECTEMENT sous le nom (style Gmail), pas repoussé à droite --> <!-- Aperçu DIRECTEMENT sous le nom (style Gmail), pas repoussé à droite -->
<div v-if="!isExpanded(msg)" class="msg-snippet">{{ msgSnippet(msg) }}</div> <div v-if="!isExpanded(msg)" class="msg-snippet">{{ msgSnippet(msg) }}</div>
</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="indigo-1" text-color="indigo-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"> <div v-if="isExpanded(msg)" class="msg-body">
<!-- Courriel complet (HTML) carte large + iframe sandbox (rendu WYSIWYG fidèle, scripts désactivés) --> <!-- 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' }"> <div v-if="msg.html" class="email-card" :class="{ 'email-card-agent': msg.from === 'agent' }">
@ -372,16 +394,6 @@
<q-spinner-dots size="20px" color="teal-6" /> <b>{{ typingAgentLabel }}</b> est en train d'écrire <q-spinner-dots size="20px" color="teal-6" /> <b>{{ typingAgentLabel }}</b> est en train d'écrire
</div> </div>
<!-- Notes INTERNES (jamais envoyées au client) remplacent le reply-all « je m'en occupe » -->
<div v-if="coordNotes.length || showNoteInput" class="coord-notes">
<div v-for="n in coordNotes" :key="n.id" class="coord-note"><b>{{ shortAgent(n.agent) }}</b> {{ n.text }}</div>
<div v-if="showNoteInput" class="row items-center q-gutter-xs q-mt-xs">
<q-input dense outlined v-model="noteText" class="col" placeholder="Note interne (équipe)…" autofocus @keyup.enter="submitNote" />
<q-btn dense unelevated color="amber-7" icon="send" @click="submitNote" />
</div>
</div>
<div class="coord-note-toggle"><q-btn flat dense size="sm" color="amber-8" icon="sticky_note_2" label="Note interne" no-caps @click="showNoteInput = !showNoteInput" /></div>
<!-- Brouillon miroir temps réel : ce qu'un AUTRE agent est en train de rédiger (dernier qui écrit gagne) --> <!-- Brouillon miroir temps réel : ce qu'un AUTRE agent est en train de rédiger (dernier qui écrit gagne) -->
<div v-if="otherDraft" class="coord-draft-mirror"> <div v-if="otherDraft" class="coord-draft-mirror">
<div class="coord-draft-head row items-center no-wrap"><q-icon name="edit_note" size="15px" /> <b class="q-ml-xs">{{ shortAgent(otherDraft.agent) }}</b> <span class="q-ml-xs">rédige une réponse en direct</span><q-space /><q-btn flat dense round size="xs" icon="close" color="indigo-4" @click="closeOtherDraft"><q-tooltip>Fermer cet aperçu</q-tooltip></q-btn></div> <div class="coord-draft-head row items-center no-wrap"><q-icon name="edit_note" size="15px" /> <b class="q-ml-xs">{{ shortAgent(otherDraft.agent) }}</b> <span class="q-ml-xs">rédige une réponse en direct</span><q-space /><q-btn flat dense round size="xs" icon="close" color="indigo-4" @click="closeOtherDraft"><q-tooltip>Fermer cet aperçu</q-tooltip></q-btn></div>
@ -399,9 +411,29 @@
</div> </div>
<!-- COURRIEL : éditeur WYSIWYG (type Gmail) envoie du HTML dans le même fil --> <!-- COURRIEL : éditeur WYSIWYG (type Gmail) envoie du HTML dans le même fil -->
<template v-if="isEmailConv"> <template v-if="isEmailConv">
<q-editor v-model="draftHtml" min-height="6rem" max-height="40vh" class="email-editor" <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>
</div>
<q-editor v-model="draftHtml" min-height="8rem" max-height="72vh" class="email-editor"
:toolbar="[['bold','italic','underline','strike'],['unordered','ordered'],['link','removeFormat'],['undo','redo']]" :toolbar="[['bold','italic','underline','strike'],['unordered','ordered'],['link','removeFormat'],['undo','redo']]"
placeholder="Rédiger la réponse… (mise en forme, listes, liens — part dans le fil Gmail)" /> placeholder="Rédiger la réponse… (mise en forme, listes, liens — part dans le fil Gmail)" />
<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="indigo-6" @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="red-5" label="Retirer" @click="advHtml = ''" />
</div>
<div class="row items-center q-mt-xs"> <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-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-menu anchor="top left" self="bottom left">
@ -420,9 +452,12 @@
<q-btn v-if="suggestedSignature" flat dense size="sm" icon="draw" color="indigo-6" no-caps label="Signature" class="q-ml-xs" @click="insertCanned(suggestedSignature)"> <q-btn v-if="suggestedSignature" flat dense size="sm" icon="draw" color="indigo-6" no-caps label="Signature" class="q-ml-xs" @click="insertCanned(suggestedSignature)">
<q-tooltip>Insérer la signature « {{ suggestedSignature.mailbox }} » (suggérée pour cette file)</q-tooltip> <q-tooltip>Insérer la signature « {{ suggestedSignature.mailbox }} » (suggérée pour cette file)</q-tooltip>
</q-btn> </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="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> <span class="text-caption text-grey-5 q-ml-sm"> {{ activeDiscussion.email }}</span>
<q-space /> <q-space />
<q-btn unelevated dense color="indigo-6" icon="send" label="Envoyer le courriel" no-caps :disable="!hasEmailDraft || sending" :loading="sending" @click="sendEmailReply" /> <q-btn unelevated dense icon="send" label="Envoyer le courriel" no-caps class="giga-btn" :disable="(!hasEmailDraft && !replyAttachments.length && !advHtml) || sending" :loading="sending" @click="sendEmailReply" />
</div> </div>
</template> </template>
<!-- SMS / chat : champ simple --> <!-- SMS / chat : champ simple -->
@ -433,20 +468,6 @@
<q-btn flat round dense icon="image" color="grey-6" size="sm" :loading="replyUploading" @click="pickReplyImage"> <q-btn flat round dense icon="image" color="grey-6" size="sm" :loading="replyUploading" @click="pickReplyImage">
<q-tooltip>Joindre une image</q-tooltip> <q-tooltip>Joindre une image</q-tooltip>
</q-btn> </q-btn>
<q-btn flat round dense icon="quickreply" color="grey-6" size="sm" @click="loadCanned">
<q-tooltip>Réponses pré-écrites</q-tooltip>
<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 cannedList" :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><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="indigo-6" size="20px" /></q-item-section><q-item-section class="text-indigo-7">Nouvelle réponse</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
</template> </template>
<template #append> <template #append>
<q-btn flat round dense icon="send" color="indigo-6" size="sm" <q-btn flat round dense icon="send" color="indigo-6" size="sm"
@ -472,11 +493,11 @@
<!-- Nouveau texto : rendu HORS du drawer (ouvrable depuis le FAB même panneau fermé). Résout la fiche par téléphone. --> <!-- 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-dialog v-if="!inline" v-model="newDialogOpen" @hide="resetNew">
<q-card style="min-width:380px"> <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-card-section class="text-weight-bold row items-center">
<q-icon :name="newChannel === 'email' ? 'mail' : 'sms'" color="teal-7" class="q-mr-sm" />{{ newChannel === 'email' ? 'Nouveau courriel' : 'Nouveau texto' }} <q-icon :name="newChannel === 'email' ? 'mail' : 'sms'" style="color:#00C853" class="q-mr-sm" />{{ newChannel === 'email' ? 'Nouveau courriel' : 'Nouveau texto' }}
<q-space /> <q-space />
<q-btn-toggle v-model="newChannel" dense unelevated size="sm" toggle-color="teal-7" color="grey-3" text-color="grey-8" :options="[{ value: 'sms', icon: 'sms' }, { value: 'email', icon: 'mail' }]" /> <q-btn-toggle v-model="newChannel" dense unelevated no-caps size="sm" toggle-color="teal-7" 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>
<q-card-section class="q-pt-none"> <q-card-section class="q-pt-none">
<!-- Recherche client (nom / adresse / téléphone) résumé contacts voir si SMS possible --> <!-- Recherche client (nom / adresse / téléphone) résumé contacts voir si SMS possible -->
@ -527,20 +548,138 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<q-input v-model="newEmail" dense outlined type="email" label="Courriel du destinataire" autofocus class="q-mb-sm" /> <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="indigo-5" /></template>
<template #append><q-spinner v-if="toSearching" size="14px" color="indigo-6" /></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="person" size="15px" color="indigo-5" class="q-mr-sm" />
<div class="col ellipsis"><div class="text-body2 ellipsis">{{ m.customer_name }}</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="indigo-1" text-color="indigo-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="indigo-6" color="grey-5" :options="[{ label: 'Cc', value: 'cc' }, { label: 'Cci', value: 'bcc' }]" />
</template>
<template #append><q-spinner v-if="ccSearching" size="14px" color="indigo-6" /><q-btn v-else-if="ccSearch.trim()" flat dense round size="sm" icon="add" color="indigo-6" @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="person" size="15px" color="indigo-5" class="q-mr-sm" />
<div class="col ellipsis"><div class="text-body2 ellipsis">{{ m.customer_name }}</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" /> <q-input v-model="newSubject" dense outlined label="Sujet" class="q-mb-sm" />
<q-input v-model="newMessage" type="textarea" autogrow :rows="5" dense outlined label="Message" placeholder="Écrire le courriel… (part de cc@ / support@, fil suivi dans l'Inbox)" /> <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="indigo-6" @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="red-5" 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="indigo-2" text-color="indigo-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> </template>
</q-card-section> </q-card-section>
<q-card-actions align="right"> <q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup /> <q-btn flat label="Annuler" v-close-popup />
<q-btn v-if="newChannel === 'sms'" unelevated color="teal-7" icon="send" label="Envoyer le texto" :disable="String(newPhone).replace(/\D/g,'').length < 10 || (!newMessage.trim() && !newMedia) || creating" :loading="creating" @click="createNew" /> <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 color="teal-7" icon="send" label="Envoyer le courriel" :disable="!/.+@.+\..+/.test(newEmail) || !newMessage.trim() || creating" :loading="creating" @click="createNewEmail" /> <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-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Créer un TICKET (ERPNext Issue OUVERT) depuis la conversation la conversation RESTE dans l'historique --> <!-- 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="indigo-6" class="q-mr-sm" />Bibliothèque d'images
<q-space />
<q-btn flat dense no-caps icon="add_photo_alternate" color="indigo-6" 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="indigo-6" 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-dialog v-model="ticketDialog.open">
<q-card style="min-width:380px;max-width:96vw"> <q-card style="min-width:380px;max-width:96vw">
<q-card-section class="row items-center q-pb-none"> <q-card-section class="row items-center q-pb-none">
@ -633,16 +772,33 @@
<q-btn flat round dense icon="close" v-close-popup /> <q-btn flat round dense icon="close" v-close-popup />
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
<q-input dense outlined v-model="linkDialog.query" :label="linkIsEmail ? 'Rechercher par courriel' : 'Rechercher par téléphone'" autofocus @keyup.enter="runLinkSearch" class="q-mb-sm"> <div v-if="linkDialog.suggestions && linkDialog.suggestions.length" class="q-mb-sm">
<template #append><q-btn flat dense round icon="search" :loading="linkDialog.loading" @click="runLinkSearch" /></template> <div class="text-caption text-weight-medium text-indigo-7 q-mb-xs"><q-icon name="auto_awesome" size="14px" /> Suggestions recoupées du courriel (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-indigo-1">
<q-item-section avatar><q-avatar size="30px" color="indigo-3" text-color="indigo-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="indigo-2" text-color="indigo-9" :label="s" /></div>
</q-item-section>
<q-item-section side><q-icon name="link" color="indigo-6" /></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)…" autofocus @update:model-value="runLinkSearch" @keyup.enter="runLinkSearch" class="q-mb-sm">
<template #prepend><q-icon name="person_search" color="blue-6" /></template>
<template #append><q-spinner v-if="linkDialog.loading" size="16px" color="blue-6" /><q-icon v-else-if="linkDialog.query" name="close" class="cursor-pointer" @click="linkDialog.query = ''; linkDialog.matches = []" /></template>
</q-input> </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="amber-7" /> 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="blue-6" size="24px" /></div> <div v-if="linkDialog.loading" class="text-center q-pa-sm"><q-spinner color="blue-6" size="24px" /></div>
<q-list v-else bordered separator style="max-height:300px;overflow-y:auto;border-radius:8px"> <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)"> <q-item v-for="m in linkDialog.matches" :key="m.name" clickable @click="doLink(m)">
<q-item-section avatar><q-avatar size="30px" color="blue-1" text-color="blue-9">{{ (m.customer_name || m.name).charAt(0) }}</q-avatar></q-item-section> <q-item-section avatar><q-avatar size="30px" color="blue-1" text-color="blue-9">{{ (m.customer_name || m.name).charAt(0) }}</q-avatar></q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ m.customer_name || m.name }}</q-item-label> <q-item-label>{{ m.customer_name || m.name }}</q-item-label>
<q-item-label caption>{{ m.email || m.phone || m.name }}<span v-if="m.territory"> · {{ m.territory }}</span></q-item-label> <q-item-label caption>{{ 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></q-item-label>
</q-item-section> </q-item-section>
<q-item-section side><q-icon name="link" color="blue-6" /></q-item-section> <q-item-section side><q-icon name="link" color="blue-6" /></q-item-section>
</q-item> </q-item>
@ -667,7 +823,9 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue' 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 { useQuasar } from 'quasar'
import { useConversations } from 'src/composables/useConversations' import { useConversations } from 'src/composables/useConversations'
import { shortAgent, staffColor, staffInitials, noteAuthorName, relTime } from 'src/composables/useFormatters' import { shortAgent, staffColor, staffInitials, noteAuthorName, relTime } from 'src/composables/useFormatters'
@ -678,17 +836,36 @@ const $q = useQuasar()
const router = useRouter() const router = useRouter()
const props = defineProps({ inline: { type: Boolean, default: false } }) // inline=true rendu pleine page (route) au lieu du tiroir const props = defineProps({ inline: { type: Boolean, default: false } }) // inline=true rendu pleine page (route) au lieu du tiroir
const { const {
discussions, activeToken, activeDiscussion, activeConv, tickets, loading, panelOpen, newDialogOpen, newDialogChannel, showAll, activeCount, discussions, activeToken, activeDiscussion, activeConv, tickets, loading, panelOpen, newDialogOpen, newDialogChannel, composePrefill, showAll, activeCount,
selectedIds, selectedDiscussions, selectedIds, selectedDiscussions,
fetchList, fetchTickets, openDiscussion, sendMessage, startConversation, startEmail, resolvePhone, resolveEmail, linkCustomer, closeConversation, fetchList, fetchTickets, openDiscussion, sendMessage, startConversation, startEmail, resolvePhone, linkCustomer, closeConversation,
deleteDiscussion, bulkDelete, archiveDiscussion, bulkArchive, createTicket, deleteDiscussion, bulkDelete, archiveDiscussion, bulkArchive, createTicket,
QUEUES, assignQueue, claimConv, releaseConv, assignConv, setConvState, fetchAgents, spamConv, deleteConvByToken, addNote, nlCommand, QUEUES, assignQueue, claimConv, releaseConv, assignConv, setConvState, fetchAgents, spamConv, deleteConvByToken, addNote, editNote, deleteNote, fetchAttachments, nlCommand,
splitPreview, splitConversation, mergeConversation, fetchCanned, saveCanned, analyzePayment, summarize, serviceability, splitPreview, splitConversation, mergeConversation, fetchCanned, saveCanned, analyzePayment, summarize, serviceability,
toggleSelect, selectAll, clearSelection, toggleSelect, selectAll, clearSelection,
goBack, disconnectSSE, connectGlobalSSE, HUB_URL, goBack, disconnectSSE, connectGlobalSSE, HUB_URL,
agentTyping, notifyTyping, sharedDraft, pushDraft, agentTyping, notifyTyping, sharedDraft, pushDraft,
} = useConversations() } = useConversations()
// Identité d'expédition « De : » défaut = groupe (anonymisé), modifiable par message
const senders = ref({ default: '', identities: [] })
const replySendAs = ref('')
const composeSendAs = ref('')
async function loadSenders () {
try {
const r = await fetch(`${HUB_URL}/conversations/senders`)
if (!r.ok) return
senders.value = await r.json()
if (!replySendAs.value) replySendAs.value = senders.value.default || ''
if (!composeSendAs.value) composeSendAs.value = senders.value.default || ''
} catch { /* le défaut serveur (groupe) s'applique de toute façon */ }
}
const senderOpts = computed(() => [
...(senders.value.identities || []).map(i => ({ label: String(i.from || '').replace(/"/g, ''), value: i.from })),
{ label: 'Signer en mon nom', value: 'personal' },
])
onMounted(loadSenders)
// Racine dynamique : tiroir (défaut) OU bloc pleine page (prop `inline`) MÊME composant/logique (DRY). // 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, // 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 // et NON bloquante (aucun fond modal) on peut continuer à naviguer (y compris
@ -925,6 +1102,38 @@ async function attachStatusImage () {
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { svcAttaching.value = false } } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { svcAttaching.value = false }
} }
const hasEmailDraft = computed(() => String(draftHtml.value || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim().length > 0) const hasEmailDraft = computed(() => String(draftHtml.value || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim().length > 0)
// É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. // Enveloppe le HTML du courriel pour l'iframe : largeur contrainte, liens en nouvel onglet, styles de base. Scripts NEUTRALISÉS par l'attribut sandbox.
function emailSrcdoc (html) { function emailSrcdoc (html) {
const safe = String(html || '') const safe = String(html || '')
@ -933,33 +1142,49 @@ function emailSrcdoc (html) {
`</head><body>${safe}</body></html>` `</head><body>${safe}</body></html>`
} }
const emailExpand = ref({ open: false, html: '', subject: '' }) 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' } } function expandEmail (msg) { emailExpand.value = { open: true, html: msg.html || '', subject: activeDiscussion.value?.lastSubject || activeDiscussion.value?.subject || 'Courriel' } }
async function sendEmailReply () { async function sendEmailReply () {
if (!hasEmailDraft.value || !activeToken.value) return if ((!hasEmailDraft.value && !replyAttachments.value.length && !advHtml.value) || !activeToken.value) return
sending.value = true sending.value = true
try { try {
const html = draftHtml.value const html = advHtml.value || draftHtml.value
const text = html.replace(/<br\s*\/?>(?=)/gi, '\n').replace(/<\/(p|div|li|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').trim() const text = html.replace(/<br\s*\/?>(?=)/gi, '\n').replace(/<\/(p|div|li|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').trim()
await sendMessage(activeToken.value, text, undefined, html) // envoie le HTML dans le fil Gmail (notifyCustomer) await sendMessage(activeToken.value, text, undefined, html, replyAttachments.value, replySendAs.value) // HTML + pièces jointes + identité « De : »
draftHtml.value = '' draftHtml.value = ''; replyAttachments.value = []; advHtml.value = ''; advDesign.value.reply = null
nextTick(() => scrollToBottom()) nextTick(() => scrollToBottom())
} catch (e) { $q.notify({ type: 'negative', message: 'Échec envoi : ' + e.message }) } finally { sending.value = false } } catch (e) { $q.notify({ type: 'negative', message: 'Échec envoi : ' + e.message }) } finally { sending.value = false }
} }
// Lier une fiche client (résout par courriel pour le canal email, par téléphone pour le SMS même UX) // 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: [], query: '' }) const linkDialog = ref({ open: false, loading: false, matches: [], suggestions: [], query: '' })
const linkIsEmail = computed(() => isEmailConv.value) const linkIsEmail = computed(() => isEmailConv.value)
async function openLink () { async function openLink () {
const d = activeDiscussion.value; if (!d) return const d = activeDiscussion.value; if (!d) return
linkDialog.value = { open: true, loading: true, matches: [], query: linkIsEmail.value ? (d.email || '') : (d.phone || '') } linkDialog.value = { open: true, loading: true, matches: [], suggestions: [], query: linkIsEmail.value ? (d.email || '') : (d.phone || '') }
loadLinkSuggestions(d)
await runLinkSearch() 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 || []
} catch (e) { /* best-effort */ }
}
async function runLinkSearch () { async function runLinkSearch () {
linkDialog.value.loading = true linkDialog.value.loading = true
try { try {
const q = linkDialog.value.query const q = (linkDialog.value.query || '').trim()
linkDialog.value.matches = linkIsEmail.value ? await resolveEmail(q) : await resolvePhone(q) 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)
const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}`)
const d = r.ok ? await r.json() : {}
linkDialog.value.matches = d.matches || []
} catch (e) { linkDialog.value.matches = [] } finally { linkDialog.value.loading = false } } catch (e) { linkDialog.value.matches = [] } finally { linkDialog.value.loading = false }
} }
async function doLink (m) { async function doLink (m) {
@ -970,6 +1195,7 @@ async function doLink (m) {
linkDialog.value.open = false linkDialog.value.open = false
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } } 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 // PREUVE DE PAIEMENT (OCR) affiche pour décider ; route vers Facturation ; pas d'écriture auto
const paymentBusy = ref(null) const paymentBusy = ref(null)
@ -1022,7 +1248,29 @@ const presence = computed(() => {
// Bouton « Répondre » (Gmail) : composeur masqué jusqu'au clic ; le clic SIGNALE la présence aux collègues (notifyTyping anneau qui clignote chez eux) // 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) const replyOpen = ref(false)
const composeEl = ref(null) const composeEl = ref(null)
function openReply () { replyOpen.value = true; if (coordToken.value) notifyTyping(coordToken.value); nextTick(() => { try { composeEl.value?.querySelector('textarea, [contenteditable=true]')?.focus() } catch (e) { /* */ } }) } function collapseAllMsgs () { expandedMsgs.value = new Set() }
// Citation ÉDITABLE du message auquel on répond (en-tête + contenu), style Gmail l'agent écrit au-dessus.
function quotedBlock (m) {
const who = m.fromName || (m.from === 'agent' ? 'Nous' : (activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Client'))
const when = m.ts ? formatDate(m.ts) : ''
const safe = String(m.text || '').replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])).replace(/\n/g, '<br>')
const inner = m.html || ('<p>' + safe + '</p>')
return `<blockquote style="margin:10px 0 0;padding:4px 0 0 12px;border-left:3px solid #e2e8f0;color:#64748b">`
+ `<div style="font-size:.78rem;color:#94a3b8;margin-bottom:4px">Le ${when}, ${who} a écrit&nbsp;:</div>${inner}</blockquote>`
}
function openReply () {
replyOpen.value = true
// Mode composition : courriel pré-remplir une citation ÉDITABLE du dernier message (si l'éditeur est vide).
if (isEmailConv.value && !draftHtml.value) {
const ms = (activeDiscussion.value?.messages || []).filter(m => m.from !== 'system')
const last = ms.length ? ms[ms.length - 1] : null
if (last) draftHtml.value = '<p><br></p>' + quotedBlock(last)
}
if (coordToken.value) notifyTyping(coordToken.value)
nextTick(() => { try { composeEl.value?.querySelector('textarea, [contenteditable=true]')?.focus() } catch (e) { /* */ } })
}
// 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() })
// 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 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. // 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) { function fitFrame (f) {
@ -1109,7 +1357,18 @@ const coordPendingUntil = computed(() => activeConv.value?.pendingUntil || activ
const pendingDateInput = ref('') const pendingDateInput = ref('')
watch(coordPendingUntil, v => { pendingDateInput.value = v || '' }, { immediate: true }) watch(coordPendingUntil, v => { pendingDateInput.value = v || '' }, { immediate: true })
const agents = ref([]) const agents = ref([])
const availableAssistants = computed(() => agents.value.filter(a => a !== coordAssignee.value && !coordAssistants.value.includes(a))) 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)
}
async function setState (state) { if (!coordToken.value) return; try { await setConvState(coordToken.value, state, state === 'pending' ? (pendingDateInput.value || null) : null) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } } async function setState (state) { if (!coordToken.value) return; try { await setConvState(coordToken.value, state, state === 'pending' ? (pendingDateInput.value || null) : null) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } }
async function assignTo (email) { if (!coordToken.value) return; try { await assignConv(coordToken.value, { assignee: email }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } } async function assignTo (email) { if (!coordToken.value) return; try { await assignConv(coordToken.value, { assignee: email }) } 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 addAssistant (email) { if (!coordToken.value) return; try { await assignConv(coordToken.value, { assistants: [...coordAssistants.value, email] }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } }
@ -1133,7 +1392,8 @@ function msgSnippet (m) { const t = String(m.text || '').replace(/^✉️[^\n]*\
function cleanBody (t) { return String(t || '').replace(/^✉️[^\n]*\n+/, '') } // retire l'ancien préfixe sujet collé au corps (double en-tête) 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 }) 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() } function expandLast () { const ms = activeDiscussion.value?.messages || []; expandedMsgs.value = new Set(ms.length ? [ms[ms.length - 1].id] : []); refitFrames() }
watch(coordToken, () => { svcStatus.value = null; expandLast(); replyOpen.value = false; summaryText.value = ''; summaryErr.value = ''; closedDrafts.value = new Set(); fetchAgents().then(a => { agents.value = a }) }) 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 = {}; 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 } } }) 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 gmailReplyUrl = computed(() => activeConv.value?.gmailReplyUrl || activeDiscussion.value?.gmailReplyUrl || null)
const coordNotes = computed(() => activeConv.value?.notes || []) const coordNotes = computed(() => activeConv.value?.notes || [])
@ -1143,6 +1403,13 @@ const coordSuggested = computed(() => {
return (s && !linked.length) ? s : null return (s && !linked.length) ? s : null
}) })
const showNoteInput = ref(false); const noteText = ref('') const showNoteInput = ref(false); const noteText = ref('')
// É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) // 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 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 doClaim () { if (!coordToken.value) return; try { await claimConv(coordToken.value) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } }
@ -1215,9 +1482,66 @@ const newUploading = ref(false)
const imgInput = ref(null) const imgInput = ref(null)
const newCustomerName = ref('') const newCustomerName = ref('')
const newSubject = ref('') const newSubject = ref('')
const newChannel = ref('sms') // 'sms' | 'email' bascule du dialogue « Nouveau » 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 && newDialogChannel.value) newChannel.value = newDialogChannel.value }) // FAB peut présélectionner le canal 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) 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)}`); 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 = []; pickCust(m) } // réutilise pickCust remplit À + nom + fiche liée
const composeHasBody = computed(() => { const t = String(newHtml.value || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim(); return t.length > 0 || /<img/i.test(newHtml.value || '') })
function onCcSearch (v) {
clearTimeout(_ccT); const q = String(v || '').trim()
if (q.length < 3) { ccResults.value = []; return }
_ccT = setTimeout(async () => {
ccSearching.value = true
try { const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}`); if (r.ok) { const d = await r.json(); ccResults.value = (d.matches || []).filter(m => m.email) } } catch (e) { ccResults.value = [] } finally { ccSearching.value = false }
}, 280)
}
function addCcEmail (raw) {
const e = String(raw || '').trim().replace(/^.*<([^>]+)>.*$/, '$1')
if (!/.+@.+\..+/.test(e)) return
const list = ccMode.value === 'bcc' ? newBcc : newCc
if (!newCc.value.includes(e) && !newBcc.value.includes(e)) list.value.push(e)
clearTimeout(_ccT); ccSearch.value = ''; ccResults.value = [] // annule la recherche en attente la liste ne réapparaît plus après l'ajout
}
function addCcTyped () { addCcEmail(ccSearch.value) }
function addCcPicked (m) { addCcEmail(m.email) }
// Insère une réponse type / signature dans l'éditeur de COMPOSITION ( insertCanned qui vise la réponse). {{rating}} reste littéral expansé à l'envoi côté hub.
function insertCannedCompose (c) {
const client = (custPicked.value && custPicked.value.customer_name) || newCustomerName.value || newEmail.value || 'client'
const agent = shortAgent(coordAssignee.value) || 'l\'équipe TARGO'
const sub = (s) => String(s || '').replace(/\{\{\s*client\s*\}\}/gi, client).replace(/\{\{\s*agent\s*\}\}/gi, agent)
const frag = c.html ? sub(c.html) : ('<p>' + sub(c.body || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>') + '</p>')
newHtml.value = newHtml.value ? (newHtml.value + frag) : frag
}
const creating = ref(false) const creating = ref(false)
const newMatches = ref([]) // fiches trouvées par téléphone const newMatches = ref([]) // fiches trouvées par téléphone
const newResolving = ref(false) const newResolving = ref(false)
@ -1226,6 +1550,12 @@ const newLinkChoice = ref('') // fiche choisie (Customer name) quand plusieu
const replyMedia = ref('') const replyMedia = ref('')
const replyUploading = ref(false) const replyUploading = ref(false)
const replyImgInput = ref(null) 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) { 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 dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file) })
@ -1246,6 +1576,51 @@ async function onReplyImage (e) {
try { replyMedia.value = await uploadImage(file) } catch { $q.notify({ type: 'negative', message: "Échec de l'envoi de l'image" }) } 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 = '' replyUploading.value = false; if (replyImgInput.value) replyImgInput.value.value = ''
} }
// Pièces jointes : upload (images), bibliothèque d'équipements réutilisable
async function uploadAttachment (file) {
const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file) })
const r = await fetch(`${HUB_URL}/campaigns/assets/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: file.name, data: dataUrl }) })
const d = await r.json(); if (!d.filename) throw new Error(d.error || 'upload')
return { asset: d.filename, filename: file.name, mime: d.content_type, url: d.url }
}
async function onAttachFiles (e, target) {
const files = Array.from(e.target.files || []); e.target.value = ''
if (!files.length) return
attUploading.value = true
const list = target === 'compose' ? composeAttachments : replyAttachments
for (const f of files) {
if (!/^image\//.test(f.type)) { $q.notify({ type: 'warning', message: f.name + ' : images seulement pour linstant' }); continue }
try { list.value.push(await uploadAttachment(f)) } catch (err) { $q.notify({ type: 'negative', message: 'Échec : ' + f.name }) }
}
attUploading.value = false
}
function pickReplyAtt () { if (replyAttInput.value) replyAttInput.value.click() }
function pickComposeAtt () { if (composeAttInput.value) composeAttInput.value.click() }
function guessExt (mime) { return ({ 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/svg+xml': '.svg' }[mime] || '') }
async function openPresetGallery (target) {
presetDlg.value = { open: true, target, loading: true, list: [] }
try { const r = await fetch(`${HUB_URL}/collab/attach-presets`); const d = r.ok ? await r.json() : {}; presetDlg.value.list = d.presets || [] } catch (e) { presetDlg.value.list = [] } finally { presetDlg.value.loading = false }
}
function pickPreset (p) {
const list = presetDlg.value.target === 'compose' ? composeAttachments : replyAttachments
if (!list.value.some(a => a.asset === p.asset)) list.value.push({ asset: p.asset, filename: (p.name || 'image') + guessExt(p.mime), mime: p.mime, url: p.url })
}
function pickPresetUpload () { if (presetAddInput.value) presetAddInput.value.click() }
async function addPresetFromFile (e) {
const f = (e.target.files || [])[0]; e.target.value = ''
if (!f || !/^image\//.test(f.type)) return
presetDlg.value.loading = true
try {
const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(f) })
const name = (f.name || 'Image').replace(/\.[^.]+$/, '')
const r = await fetch(`${HUB_URL}/collab/attach-presets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, category: 'Équipement', data: dataUrl }) })
const d = r.ok ? await r.json() : {}
if (d.preset) presetDlg.value.list.push(d.preset); else $q.notify({ type: 'negative', message: d.error || 'Échec de lajout' })
} catch (err) { $q.notify({ type: 'negative', message: 'Échec de lajout' }) } finally { presetDlg.value.loading = false }
}
async function removePreset (p) {
try { await fetch(`${HUB_URL}/collab/attach-presets/${encodeURIComponent(p.id)}`, { method: 'DELETE' }); presetDlg.value.list = presetDlg.value.list.filter(x => x.id !== p.id) } catch (e) { /* */ }
}
const matchOptions = computed(() => newMatches.value.map(m => ({ label: (m.customer_name || m.name) + (m.territory ? ' · ' + m.territory : (m.email ? ' · ' + m.email : '')), value: m.name }))) 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 let _resolveT = null
watch(newPhone, (v) => { watch(newPhone, (v) => {
@ -1261,7 +1636,7 @@ watch(newPhone, (v) => {
newResolving.value = false newResolving.value = false
}, 450) }, 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 } // newChannel conservé (dernière préférence) 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. // 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) const custSearch = ref(''); const custResults = ref([]); const custSearching = ref(false); const custPicked = ref(null)
@ -1341,10 +1716,10 @@ async function createNew () {
// Nouveau courriel SORTANT part de cc@/support@ via Gmail, crée une conversation email suivie + bascule dedans. // Nouveau courriel SORTANT part de cc@/support@ via Gmail, crée une conversation email suivie + bascule dedans.
async function createNewEmail () { async function createNewEmail () {
const to = newEmail.value.trim() const to = newEmail.value.trim()
if (!/.+@.+\..+/.test(to) || !newMessage.value.trim()) return if (!/.+@.+\..+/.test(to) || (!composeHasBody.value && !composeAttachments.value.length && !composeAdvHtml.value)) return
creating.value = true creating.value = true
try { try {
const d = await startEmail({ to, subject: newSubject.value.trim(), body: newMessage.value.trim() }) 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() newDialogOpen.value = false; resetNew()
$q.notify({ type: 'positive', icon: 'mail', message: 'Courriel envoyé' }) $q.notify({ type: 'positive', icon: 'mail', message: 'Courriel envoyé' })
const tok = d && d.token const tok = d && d.token
@ -1506,7 +1881,9 @@ function formatDate (dateStr) {
.pay-card { margin: 6px 8px; padding: 8px 10px; border: 1px solid #bbf7d0; background: #f0fdf4; border-radius: 8px; } .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; } .pay-card-no { border-color: #e2e8f0; background: #f8fafc; }
.email-editor { border: 1px solid #e2e8f0; border-radius: 8px; } .email-editor { border: 1px solid #e2e8f0; border-radius: 8px; }
.email-editor :deep(.q-editor__content) { font-size: .85rem; min-height: 6rem; } .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; resize: vertical; overflow: auto; }
.conv-toolbar { .conv-toolbar {
background: #fff; background: #fff;
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0;
@ -1518,7 +1895,7 @@ function formatDate (dateStr) {
/* En fenêtre flottante (hauteur fixe), les rangées d'en-tête NE DOIVENT PAS rétrécir /* 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 (sinon leur contenu déborde et chevauche la rangée suivante). Seule la zone des
messages flexe et défile. */ messages flexe et défile. */
.conv-toolbar, .coord-bar, .coord-bar2, .coord-bar3, .coord-assist { flex: 0 0 auto; } .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 */ /* 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: normal; } .conv-toolbar :deep(.q-toolbar__title) { padding: 2px 0; line-height: 1.45; white-space: normal; }
.conv-toolbar :deep(.q-toolbar__title) > div + div { margin-top: 5px; } .conv-toolbar :deep(.q-toolbar__title) > div + div { margin-top: 5px; }
@ -1562,6 +1939,12 @@ function formatDate (dateStr) {
/* Coordination shared-inbox */ /* Coordination shared-inbox */
.coord-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 7px 10px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; } .coord-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 7px 10px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
.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: #f8fafc; border-bottom: 1px solid #e2e8f0; } .coord-bar2 { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 3px 8px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
.svc-bar { padding: 6px 9px; background: #ecfeff; border-bottom: 1px solid #cffafe; } .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; } .coord-lbl { font-size: 0.68rem; font-weight: 700; color: #94a3b8; text-transform: uppercase; margin-right: 2px; }

View File

@ -1,12 +1,16 @@
<template> <template>
<div class="mlist" :class="{ 'mlist-compact': compact }"> <div class="mlist" :class="{ 'mlist-compact': compact }">
<div class="mlist-head"> <div class="mlist-head">
<q-btn unelevated no-caps rounded icon="edit" label="Composer" class="mlist-compose" @click="openCompose">
<q-tooltip>Nouveau message (courriel par défaut, ou texto)</q-tooltip>
</q-btn>
<q-btn-toggle v-model="chanView" :options="chanOptions" dense unelevated no-caps toggle-color="indigo-6" color="grey-2" text-color="grey-7" class="mlist-chanseg" />
<q-input v-model="search" dense outlined clearable debounce="200" placeholder="Rechercher (nom, courriel, sujet…)" class="mlist-search"> <q-input v-model="search" dense outlined clearable debounce="200" placeholder="Rechercher (nom, courriel, sujet…)" class="mlist-search">
<template #prepend><q-icon name="search" size="18px" /></template> <template #prepend><q-icon name="search" size="18px" /></template>
</q-input> </q-input>
<q-btn flat dense round :icon="compact ? 'density_small' : 'density_medium'" size="sm" @click="toggleDensity"><q-tooltip>{{ compact ? 'Affichage confortable' : 'Affichage compact' }}</q-tooltip></q-btn> <q-btn flat dense round :icon="compact ? 'density_small' : 'density_medium'" size="sm" @click="toggleDensity"><q-tooltip>{{ compact ? 'Affichage confortable' : 'Affichage compact' }}</q-tooltip></q-btn>
<q-btn flat dense round :icon="notifyEnabled ? 'notifications_active' : 'notifications_none'" :color="notifyEnabled ? 'indigo-6' : 'grey-6'" size="sm" @click="toggleNotify"><q-tooltip>{{ notifyEnabled ? 'Notifications navigateur activées' : 'Activer les notifications de nouveaux courriels' }}</q-tooltip></q-btn> <q-btn flat dense round :icon="notifyEnabled ? 'notifications_active' : 'notifications_none'" :color="notifyEnabled ? 'indigo-6' : 'grey-6'" size="sm" @click="toggleNotify"><q-tooltip>{{ notifyEnabled ? 'Notifications navigateur activées' : 'Activer les notifications de nouveaux courriels' }}</q-tooltip></q-btn>
<q-btn flat dense round icon="refresh" size="sm" :loading="loading" @click="fetchList" /> <q-btn flat dense round icon="refresh" size="sm" :loading="refreshing || loading" @click="refreshNow"><q-tooltip>Rafraîchir chercher les nouveaux courriels maintenant</q-tooltip></q-btn>
</div> </div>
<!-- Case « tout cocher » + filtres OU actions de lot DANS LA MÊME LIGNE (hauteur constante pas de décalage au clic) --> <!-- Case « tout cocher » + filtres OU actions de lot DANS LA MÊME LIGNE (hauteur constante pas de décalage au clic) -->
@ -55,7 +59,10 @@
<div class="mrow-right"> <div class="mrow-right">
<q-icon v-if="replyInfo(d).client" name="reply" size="14px" color="teal-6" class="mrow-rep"><q-tooltip>Le client a répondu</q-tooltip></q-icon> <q-icon v-if="replyInfo(d).client" name="reply" size="14px" color="teal-6" class="mrow-rep"><q-tooltip>Le client a répondu</q-tooltip></q-icon>
<q-icon v-if="replyInfo(d).team" name="forum" size="13px" color="indigo-5" class="mrow-rep"><q-tooltip>Un collègue a répondu</q-tooltip></q-icon> <q-icon v-if="replyInfo(d).team" name="forum" size="13px" color="indigo-5" class="mrow-rep"><q-tooltip>Un collègue a répondu</q-tooltip></q-icon>
<span v-if="typingLabel(d)" class="mrow-typing"><q-icon name="edit" size="11px" /> {{ typingLabel(d) }}</span> <q-avatar v-if="draftAgentOf(d)" size="19px" color="indigo-5" text-color="white" class="q-mr-xs mrow-draft">
<span style="font-size:9px;font-weight:700">{{ agentInitials(draftAgentOf(d)) }}</span>
<q-tooltip> Brouillon en cours {{ agentName(draftAgentOf(d)) }}</q-tooltip>
</q-avatar>
<ReaderStack :readers="readersOf(d)" :me="meEmail" :max="3" :size="17" /> <ReaderStack :readers="readersOf(d)" :me="meEmail" :max="3" :size="17" />
<span class="mrow-time">{{ relTime(lastTs(d)) }}</span> <span class="mrow-time">{{ relTime(lastTs(d)) }}</span>
</div> </div>
@ -82,7 +89,7 @@
</template> </template>
</div> </div>
</div> </div>
<div v-if="!shown.length" class="mlist-empty">Aucune conversation{{ sel.size || search ? ' (filtre actif)' : '' }}</div> <div v-if="!shown.length" class="mlist-empty">Aucune conversation{{ sel.size || search || chanView !== 'all' ? ' (filtre actif)' : '' }}</div>
</div> </div>
<!-- « Filtrer les messages comme celui-ci » : crée une règle masquer/afficher (style Gmail) --> <!-- « Filtrer les messages comme celui-ci » : crée une règle masquer/afficher (style Gmail) -->
@ -115,7 +122,10 @@ import ReaderStack from './ReaderStack.vue'
const $q = useQuasar() const $q = useQuasar()
const router = useRouter() const router = useRouter()
const { discussions, QUEUES, fetchList, loading, openDiscussion, panelOpen, agentTyping, bulkDelete, bulkArchive, createTicket, archiveDiscussion, notifyEnabled, requestNotifyPermission, fetchInboxRules, saveInboxRules } = useConversations() const { discussions, QUEUES, fetchList, pollNow, loading, openDiscussion, panelOpen, newDialogOpen, newDialogChannel, agentTyping, sharedDraft, bulkDelete, bulkArchive, createTicket, archiveDiscussion, notifyEnabled, requestNotifyPermission, fetchInboxRules, saveInboxRules } = useConversations()
// Rafraîchir à la demande = forcer un pull Gmail immédiat puis recharger la liste ( simple relecture).
const refreshing = ref(false)
async function refreshNow () { refreshing.value = true; try { await pollNow() } finally { await fetchList(); refreshing.value = false } }
// « Filtrer comme ceci » (style Gmail) : crée une règle masquer/afficher par expéditeur ou sujet // « Filtrer comme ceci » (style Gmail) : crée une règle masquer/afficher par expéditeur ou sujet
const filterDialog = ref({ open: false, field: 'from', contains: '', action: 'mask', srcEmail: '', srcSubject: '' }) const filterDialog = ref({ open: false, field: 'from', contains: '', action: 'mask', srcEmail: '', srcSubject: '' })
function openFilterLike (d) { filterDialog.value = { open: true, field: 'from', contains: d.email || d.customerName || '', action: 'mask', srcEmail: d.email || '', srcSubject: subj(d) || '' } } function openFilterLike (d) { filterDialog.value = { open: true, field: 'from', contains: d.email || d.customerName || '', action: 'mask', srcEmail: d.email || '', srcSubject: subj(d) || '' } }
@ -137,9 +147,30 @@ async function toggleNotify () {
$q.notify({ type: ok ? 'positive' : 'warning', message: ok ? 'Notifications activées — alerte à chaque nouveau courriel.' : 'Refusé — autorisez les notifications dans le navigateur.' }) $q.notify({ type: ok ? 'positive' : 'warning', message: ok ? 'Notifications activées — alerte à chaque nouveau courriel.' : 'Refusé — autorisez les notifications dans le navigateur.' })
} }
const meEmail = computed(() => { try { return useAuthStore().user } catch { return null } }) const meEmail = computed(() => { try { return useAuthStore().user } catch { return null } })
// Brouillons : qui rédige une réponse pour ce client (live SSE OU brouillon persisté) pastille avatar
function draftAgentOf (d) {
for (const c of (d.conversations || [])) { const a = agentTyping.value[c.token]; if (a) return a } // tape en ce moment
for (const c of (d.conversations || [])) { const sd = sharedDraft.value[c.token]; if (sd && sd.agent) return sd.agent } // brouillon mirroré (live)
return d.draftAgent || null // brouillon persisté (au chargement)
}
function agentInitials (a) {
const parts = String(a || '').split('@')[0].replace(/[._-]+/g, ' ').trim().split(/\s+/).filter(Boolean)
return ((parts[0] || '?')[0] + (parts[1] ? parts[1][0] : (parts[0] || '?').slice(1, 2))).toUpperCase()
}
function agentName (a) { return String(a || '').split('@')[0].replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }
const sel = ref(new Set()) // filtres cumulables (départements OU entre eux ; unread/mine = ET) const sel = ref(new Set()) // filtres cumulables (départements OU entre eux ; unread/mine = ET)
const search = ref('') const search = ref('')
// Vue par canal (style Gmail « Mail / Chat ») : Tous (défaut) · Courriel · Clavardage (SMS + chat web temps réel)
const chanView = ref('all')
const CHAT_CH = ['sms', 'webchat', '3cx', 'facebook']
const chanOptions = [
{ value: 'all', label: 'Tous', icon: 'all_inbox' },
{ value: 'email', label: 'Courriel', icon: 'mail' },
{ value: 'chat', label: 'Clavardage', icon: 'forum' },
]
// Composer (style Gmail) : ouvre la fenêtre de composition ; défaut = canal de la vue courante (Clavardage texto).
function openCompose () { newDialogChannel.value = 'email'; newDialogOpen.value = true } // Compose = courriel par défaut ; SMS = choix secondaire dans la fenêtre
const compact = ref(false); try { compact.value = localStorage.getItem('mlist-density') === 'compact' } catch (e) { /* */ } const compact = ref(false); try { compact.value = localStorage.getItem('mlist-density') === 'compact' } catch (e) { /* */ }
function toggleDensity () { compact.value = !compact.value; try { localStorage.setItem('mlist-density', compact.value ? 'compact' : '') } catch (e) { /* */ } } function toggleDensity () { compact.value = !compact.value; try { localStorage.setItem('mlist-density', compact.value ? 'compact' : '') } catch (e) { /* */ } }
const checked = ref(new Set()) // ids de discussions cochées (batch) const checked = ref(new Set()) // ids de discussions cochées (batch)
@ -188,10 +219,10 @@ const chips = computed(() => {
{ key: 'unread', label: 'Non lus', count: visible.value.filter(isUnread).length }, { key: 'unread', label: 'Non lus', count: visible.value.filter(isUnread).length },
{ key: 'mine', label: 'À moi', count: visible.value.filter(d => d.assignee === meEmail.value).length }, { key: 'mine', label: 'À moi', count: visible.value.filter(d => d.assignee === meEmail.value).length },
] ]
const draftN = visible.value.filter(draftAgentOf).length
if (draftN) base.push({ key: 'draft', label: '✎ Brouillons', count: draftN })
for (const q of QUEUES) base.push({ key: q, label: qLabel(q), count: visible.value.filter(d => (d.queue || '') === q).length }) for (const q of QUEUES) base.push({ key: q, label: qLabel(q), count: visible.value.filter(d => (d.queue || '') === q).length })
// Filtre par CANAL seulement si plusieurs canaux présents (sinon inutile dans une boîte tout-courriel) // (Filtre par canal = segment « Tous / Courriel / Clavardage » de l'en-tête, style Gmail.)
const chans = [...new Set(visible.value.map(d => d.channel || 'email'))]
if (chans.length > 1) for (const ch of chans) base.push({ key: 'ch:' + ch, label: CH_LABELS[ch] || ch, count: visible.value.filter(d => (d.channel || 'email') === ch).length })
if (maskedCount.value) base.push({ key: 'masked', label: '🔇 Masqués', count: maskedCount.value }) // voir ce qui est filtré/masqué if (maskedCount.value) base.push({ key: 'masked', label: '🔇 Masqués', count: maskedCount.value }) // voir ce qui est filtré/masqué
return base return base
}) })
@ -204,11 +235,13 @@ function toggle (k) {
const shown = computed(() => { const shown = computed(() => {
const q = search.value.trim().toLowerCase() const q = search.value.trim().toLowerCase()
const depts = [...sel.value].filter(k => QUEUES.includes(k)) const depts = [...sel.value].filter(k => QUEUES.includes(k))
const chans = [...sel.value].filter(k => k.startsWith('ch:')).map(k => k.slice(3)) const needU = sel.value.has('unread'); const needM = sel.value.has('mine'); const needD = sel.value.has('draft')
const needU = sel.value.has('unread'); const needM = sel.value.has('mine')
return visible.value.filter(d => { return visible.value.filter(d => {
if (depts.length && !depts.includes(d.queue || '')) return false if (depts.length && !depts.includes(d.queue || '')) return false
if (chans.length && !chans.includes(d.channel || 'email')) return false const isChat = CHAT_CH.includes(d.channel || '')
if (chanView.value === 'email' && isChat) return false
if (chanView.value === 'chat' && !isChat) return false
if (needD && !draftAgentOf(d)) return false
if (needU && !isUnread(d)) return false if (needU && !isUnread(d)) return false
if (needM && d.assignee !== meEmail.value) return false if (needM && d.assignee !== meEmail.value) return false
if (q && !matchSearch(d, q)) return false if (q && !matchSearch(d, q)) return false
@ -298,6 +331,8 @@ onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList
.mlist { display: flex; flex-direction: column; height: calc(100vh - 50px); width: 100%; } .mlist { display: flex; flex-direction: column; height: calc(100vh - 50px); width: 100%; }
.mlist-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px 4px; } .mlist-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px 4px; }
.mlist-search { flex: 1; max-width: 520px; } .mlist-search { flex: 1; max-width: 520px; }
.mlist-compose { font-weight: 600; padding: 5px 18px; flex: 0 0 auto; background: #00C853 !important; color: #fff !important; }
.mlist-chanseg { border: 1px solid #e2e8f0; border-radius: 999px; overflow: hidden; flex: 0 0 auto; }
/* Une SEULE ligne (les puces défilent horizontalement si trop nombreuses) → hauteur constante, donc la sélection ne décale jamais la liste */ /* Une SEULE ligne (les puces défilent horizontalement si trop nombreuses) → hauteur constante, donc la sélection ne décale jamais la liste */
.mlist-tabs { display: flex; flex-wrap: nowrap; align-items: center; gap: 4px; padding: 6px 12px; border-bottom: 1px solid var(--ops-border, #e2e8f0); overflow-x: auto; min-height: 40px; } .mlist-tabs { display: flex; flex-wrap: nowrap; align-items: center; gap: 4px; padding: 6px 12px; border-bottom: 1px solid var(--ops-border, #e2e8f0); overflow-x: auto; min-height: 40px; }
.mlist-tabs::-webkit-scrollbar { height: 0; } .mlist-tabs::-webkit-scrollbar { height: 0; }

View File

@ -0,0 +1,55 @@
<template>
<q-btn flat round dense icon="notifications" :color="dark ? 'white' : 'grey-8'">
<q-badge v-if="unread" floating color="red" rounded>{{ unread > 9 ? '9+' : unread }}</q-badge>
<q-tooltip>Notifications</q-tooltip>
<q-menu anchor="bottom right" self="top right" @show="onShow">
<div style="min-width:330px;max-width:390px">
<div class="row items-center q-px-md q-py-sm" style="border-bottom:1px solid #eef1f6">
<q-icon name="notifications" color="indigo-6" class="q-mr-xs" />
<span class="text-weight-bold">Notifications</span>
<q-space />
<q-btn flat dense round size="sm" icon="tune" :color="showPrefs ? 'indigo-6' : 'grey-7'" @click.stop="showPrefs = !showPrefs"><q-tooltip>Choisir mes notifications</q-tooltip></q-btn>
<q-btn v-if="notifications.length" flat dense size="sm" no-caps label="Tout lu" color="grey-7" @click="markAllRead" />
</div>
<!-- Préférences PAR UTILISATEUR : activer/désactiver chaque feed (suivent le compte) -->
<div v-if="showPrefs" class="q-px-md q-py-sm" style="border-bottom:1px solid #eef1f6;background:#fafbfc">
<div class="text-caption text-grey-6 q-mb-xs">Me notifier pour&nbsp;:</div>
<div v-for="f in FEEDS" :key="f.key" class="row items-center no-wrap q-py-xs">
<q-icon :name="f.icon" :color="f.color" size="18px" class="q-mr-sm" />
<span class="text-body2 col">{{ f.label }}</span>
<q-toggle :model-value="prefs[f.key]" @update:model-value="v => setFeed(f.key, v)" dense color="indigo-6" />
</div>
<div class="text-caption text-grey-5 q-mt-xs">Préférences propres à ton compte.</div>
</div>
<q-list v-if="notifications.length" style="max-height:60vh;overflow:auto">
<q-item v-for="n in notifications" :key="n.id" clickable v-close-popup @click="go(n)">
<q-item-section avatar>
<q-icon :name="n.icon || (n.type === 'comment' ? 'rate_review' : 'star')" :color="n.low ? 'orange-8' : (n.type === 'comment' ? 'indigo-6' : (n.type === 'rating' ? 'amber-7' : 'teal-7'))" size="22px" />
</q-item-section>
<q-item-section>
<q-item-label lines="1">{{ n.title }}</q-item-label>
<q-item-label caption lines="2">{{ n.caption }}</q-item-label>
</q-item-section>
<q-item-section side><q-item-label caption>{{ relTime(n.ts) }}</q-item-label></q-item-section>
</q-item>
</q-list>
<div v-else class="text-grey-6 text-center q-pa-lg"><q-icon name="notifications_none" size="32px" color="grey-4" /><br>Aucune notification</div>
</div>
</q-menu>
</q-btn>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useNotifications } from 'src/composables/useNotifications'
import { relTime } from 'src/composables/useFormatters'
defineProps({ dark: Boolean })
const { notifications, unread, prefs, FEEDS, initNotifications, markAllRead, savePrefs, go } = useNotifications()
const showPrefs = ref(false)
function onShow () { markAllRead() } // ouvrir = marquer lu (pastille à zéro)
function setFeed (key, v) { prefs.value[key] = v; savePrefs() }
onMounted(initNotifications)
</script>

View File

@ -22,6 +22,9 @@ const loading = ref(false)
const panelOpen = ref(false) const panelOpen = ref(false)
const newDialogOpen = ref(false) // dialogue « Nouveau texto » partagé (ouvrable depuis le FAB sticky ou le panneau) const newDialogOpen = ref(false) // dialogue « Nouveau texto » partagé (ouvrable depuis le FAB sticky ou le panneau)
const newDialogChannel = ref('sms') // canal présélectionné quand on ouvre le dialogue (sms | email) — posé par le FAB const newDialogChannel = ref('sms') // canal présélectionné quand on ouvre le dialogue (sms | email) — posé par le FAB
// Pré-remplissage de la fenêtre Compose depuis une autre page (ex. fiche client → brouillon d'invitation d'évaluation).
const composePrefill = ref(null) // { channel, to, phone, subject, html, text, customer, customerName }
function openComposeDraft (payload) { composePrefill.value = payload || null; if (payload && payload.channel) newDialogChannel.value = payload.channel; newDialogOpen.value = true }
const selectedIds = ref(new Set()) const selectedIds = ref(new Set())
let sseSource = null let sseSource = null
@ -216,12 +219,17 @@ export function useConversations () {
sseSource.addEventListener('conv-email', e => { try { const d = JSON.parse(e.data); fetchList(); notifyNewEmail(d) } catch (er) { /* */ } }) // NOUVEAU courriel ingéré → rafraîchit la liste EN DIRECT + notif navigateur sseSource.addEventListener('conv-email', e => { try { const d = JSON.parse(e.data); fetchList(); notifyNewEmail(d) } catch (er) { /* */ } }) // NOUVEAU courriel ingéré → rafraîchit la liste EN DIRECT + notif navigateur
} }
async function sendMessage (token, text, media, html) { async function sendMessage (token, text, media, html, attachments, sendAs) {
const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ text, media, html }) }) const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ text, media, html, attachments, sendAs }) })
if (!res.ok) throw new Error('Failed to send') if (!res.ok) throw new Error('Failed to send')
return res.json() return res.json()
} }
// Rafraîchir À LA DEMANDE : force le hub à interroger Gmail tout de suite (≠ fetchList qui relit l'état déjà ingéré).
async function pollNow () {
try { const r = await fetch(`${HUB_URL}/conversations/poll-now`, { method: 'POST', headers: agentHeaders() }); return r.ok ? await r.json() : null } catch { return null }
}
// Cherche les fiches client par téléphone → pour rattacher / choisir avant de démarrer le texto. // Cherche les fiches client par téléphone → pour rattacher / choisir avant de démarrer le texto.
async function resolvePhone (phone) { async function resolvePhone (phone) {
if (!phone || phone.replace(/\D/g, '').length < 7) return [] if (!phone || phone.replace(/\D/g, '').length < 7) return []
@ -250,8 +258,8 @@ export function useConversations () {
} }
// Nouveau courriel SORTANT (part de cc@/support@ via Gmail) → conversation email suivie // Nouveau courriel SORTANT (part de cc@/support@ via Gmail) → conversation email suivie
async function startEmail ({ to, subject, body, customer, customerName }) { async function startEmail ({ to, cc, bcc, subject, body, html, attachments, customer, customerName, sendAs }) {
const res = await fetch(`${HUB_URL}/conversations/email-new`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ to, subject, body, customer, customerName }) }) const res = await fetch(`${HUB_URL}/conversations/email-new`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ to, cc, bcc, subject, body, html, attachments, customer, customerName, sendAs }) })
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || 'Envoi du courriel échoué') } if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || 'Envoi du courriel échoué') }
const data = await res.json(); await fetchList(); return data const data = await res.json(); await fetchList(); return data
} }
@ -366,6 +374,31 @@ export function useConversations () {
if (d.note) { if (!activeConv.value) activeConv.value = {}; activeConv.value.notes = activeConv.value.notes || []; activeConv.value.notes.push(d.note) } if (d.note) { if (!activeConv.value) activeConv.value = {}; activeConv.value.notes = activeConv.value.notes || []; activeConv.value.notes.push(d.note) }
return d return d
} }
async function editNote (token, id, text) {
const r = await fetch(`${HUB_URL}/conversations/${token}/note`, { method: 'PATCH', headers: agentHeaders(), body: JSON.stringify({ id, text }) })
const d = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(d.error || `Hub ${r.status}`)
const n = activeConv.value?.notes?.find(x => x.id === id); if (n) { n.text = d.note?.text ?? text; n.edited = d.note?.edited }
return d
}
async function deleteNote (token, id) {
const r = await fetch(`${HUB_URL}/conversations/${token}/note`, { method: 'DELETE', headers: agentHeaders(), body: JSON.stringify({ id }) })
const d = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(d.error || `Hub ${r.status}`)
if (activeConv.value?.notes) activeConv.value.notes = activeConv.value.notes.filter(x => x.id !== id)
return d
}
// Pièces jointes (PDF/JPG) du fil → { msgId: [{filename, mimeType, attachmentId, size, gmail_id}] }
async function fetchAttachments (token) {
try { const r = await fetch(`${HUB_URL}/conversations/${token}/attachments`, { headers: agentHeaders() }); const d = await r.json().catch(() => ({})); return d.attachments || {} } catch (e) { return {} }
}
// Fils (email + SMS) d'UN client → pour la timeline « Communications » sur la fiche
async function fetchForCustomer ({ customer, email, phone } = {}) {
const qs = new URLSearchParams()
if (customer) qs.set('customer', customer); if (email) qs.set('email', email); if (phone) qs.set('phone', phone)
if (!qs.toString()) return []
try { const r = await fetch(`${HUB_URL}/conversations/for-customer?${qs}`, { headers: agentHeaders() }); const d = await r.json().catch(() => ({})); return d.discussions || [] } catch (e) { return [] }
}
// Crée un ticket (ERPNext Issue OUVERT) depuis une conversation — la conversation RESTE (≠ archive). // Crée un ticket (ERPNext Issue OUVERT) depuis une conversation — la conversation RESTE (≠ archive).
async function createTicket (token, payload) { async function createTicket (token, payload) {
@ -478,10 +511,10 @@ export function useConversations () {
return { return {
conversations, discussions, activeToken, activeDiscussion, activeConv, tickets, conversations, discussions, activeToken, activeDiscussion, activeConv, tickets,
loading, panelOpen, newDialogOpen, newDialogChannel, createStandaloneTicket, showAll, selectedIds, selectedDiscussions, loading, panelOpen, newDialogOpen, newDialogChannel, composePrefill, openComposeDraft, createStandaloneTicket, showAll, selectedIds, selectedDiscussions,
fetchList, fetchTickets, openDiscussion, openConversation, sendMessage, startConversation, startEmail, resolvePhone, resolveEmail, linkCustomer, fetchList, fetchTickets, openDiscussion, openConversation, sendMessage, pollNow, startConversation, startEmail, resolvePhone, resolveEmail, linkCustomer,
closeConversation, deleteDiscussion, bulkDelete, archiveDiscussion, bulkArchive, createTicket, closeConversation, deleteDiscussion, bulkDelete, archiveDiscussion, bulkArchive, createTicket,
QUEUES, assignQueue, claimConv, releaseConv, assignConv, setConvState, fetchAgents, spamConv, deleteConvByToken, addNote, nlCommand, pipelineBoard, setPipeline, QUEUES, assignQueue, claimConv, releaseConv, assignConv, setConvState, fetchAgents, spamConv, deleteConvByToken, addNote, editNote, deleteNote, fetchAttachments, fetchForCustomer, nlCommand, pipelineBoard, setPipeline,
splitPreview, splitConversation, mergeConversation, fetchCanned, saveCanned, analyzePayment, summarize, serviceability, fetchInboxRules, saveInboxRules, splitPreview, splitConversation, mergeConversation, fetchCanned, saveCanned, analyzePayment, summarize, serviceability, fetchInboxRules, saveInboxRules,
toggleSelect, selectAll, clearSelection, toggleSelect, selectAll, clearSelection,
goBack, disconnectSSE, connectGlobalSSE, activeCount, HUB_URL, goBack, disconnectSSE, connectGlobalSSE, activeCount, HUB_URL,

View File

@ -0,0 +1,98 @@
import { ref } from 'vue'
import { Notify } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { useAuthStore } from 'src/stores/auth'
// ── Centre de notifications (cloche) — PAR UTILISATEUR, feeds activables ──────
// Écoute SSE 'outbox' (évaluations) + 'conversations' (messages ENTRANTS client).
// Chaque feed (SMS / clavardage / appels / Messenger / courriel / évaluations) est
// activable/désactivable PAR utilisateur — prefs stockées côté hub (clé = courriel
// agent), donc elles suivent l'usager. État SINGLETON (module-level) partagé par la
// cloche montée dans MainLayout. /sse est PUBLIC (EventSource sans en-tête).
export const FEEDS = [
{ key: 'sms', label: 'Textos (SMS)', icon: 'sms', color: 'teal-7' },
{ key: 'webchat', label: 'Clavardage web', icon: 'chat', color: 'indigo-6' },
{ key: '3cx', label: 'Appels (3CX)', icon: 'call', color: 'blue-7' },
{ key: 'facebook', label: 'Messenger', icon: 'facebook', color: 'blue-9' },
{ key: 'email', label: 'Courriels', icon: 'mail', color: 'deep-purple-6' },
{ key: 'ratings', label: 'Évaluations', icon: 'star', color: 'amber-7' },
]
// Défaut : canaux TEMPS RÉEL + évaluations ON ; courriel OFF (asynchrone, fort volume → l'Inbox suffit).
const DEFAULTS = { sms: true, webchat: true, '3cx': true, facebook: true, email: false, ratings: true }
const notifications = ref([]) // { id, type, feed, title, caption, ts, customer, conv, icon, stars, low }
const unread = ref(0)
const prefs = ref({ ...DEFAULTS })
let es = null
let _seq = 0
function headers () {
try { const email = useAuthStore().user; if (email && email !== 'authenticated') return { 'Content-Type': 'application/json', 'X-Authentik-Email': email } } catch {}
return { 'Content-Type': 'application/json' }
}
function add (n) {
notifications.value.unshift({ id: 'n' + (++_seq), ts: new Date().toISOString(), ...n })
if (notifications.value.length > 60) notifications.value.length = 60
unread.value++
}
function toast (opts) { try { Notify.create(opts) } catch (e) { /* */ } }
function nav (path) { window.location.hash = '#' + path }
function go (n) {
markAllRead()
if (n && (n.type === 'rating' || n.type === 'comment')) return nav('/evaluations')
if (n && n.conv) return nav('/communications/c/' + encodeURIComponent(n.conv))
nav('/communications')
}
async function loadPrefs () {
try { const r = await fetch(`${HUB_URL}/conversations/notif-prefs`, { headers: headers() }); if (r.ok) { const d = await r.json(); prefs.value = { ...DEFAULTS, ...(d.prefs || {}) } } } catch { /* défauts conservés */ }
}
async function savePrefs () {
try { await fetch(`${HUB_URL}/conversations/notif-prefs`, { method: 'PUT', headers: headers(), body: JSON.stringify({ prefs: prefs.value }) }) } catch { /* */ }
}
function onRating (d) {
if (!prefs.value.ratings) return
const name = d.name || d.customer || 'Client'
add({ type: 'rating', feed: 'ratings', stars: d.stars, low: !!d.low, customer: d.customer || '', conv: d.conv || '', title: `${'★'.repeat(d.stars || 0)} ${name}`, caption: d.low ? 'Évaluation à traiter' : 'Nouvel avis' })
toast({ message: `${d.stars}★ — ${name}`, caption: d.low ? 'Évaluation basse à traiter' : 'Nouvel avis', color: d.low ? 'orange-9' : 'green-7', textColor: 'white', icon: 'star', position: 'top-right', timeout: 6000, actions: [{ label: 'Voir', color: 'white', handler: () => go({ type: 'rating' }) }] })
}
function onComment (d) {
if (!prefs.value.ratings) return
const name = d.name || d.customer || 'Client'
add({ type: 'comment', feed: 'ratings', stars: d.stars, low: d.stars != null && d.stars < 5, customer: d.customer || '', conv: d.conv || '', title: `💬 ${name}`, caption: (d.comment || '').slice(0, 90) })
toast({ message: `Commentaire — ${name}`, caption: (d.comment || '').slice(0, 90), color: 'indigo-8', textColor: 'white', icon: 'rate_review', position: 'top-right', timeout: 7000, actions: [{ label: 'Voir', color: 'white', handler: () => go({ type: 'comment' }) }] })
}
const CH_META = { sms: { icon: 'sms', label: 'SMS', color: 'teal-8' }, webchat: { icon: 'chat', label: 'Clavardage', color: 'indigo-8' }, '3cx': { icon: 'call', label: 'Appel', color: 'blue-8' }, facebook: { icon: 'facebook', label: 'Messenger', color: 'blue-9' } }
function onConvMessage (d) {
if (!d || !d.message || d.message.from !== 'customer') return // UNIQUEMENT les messages entrants client (pas nos réponses)
const feed = d.channel // sms / webchat / 3cx / facebook (email géré par conv-email)
if (!CH_META[feed] || !prefs.value[feed]) return
const name = d.customerName || d.customer || 'Client'
const meta = CH_META[feed]
const body = (d.message.text || '').trim() || (d.message.media ? '[image]' : '…')
add({ type: 'conv', feed, conv: d.token || '', customer: d.customer || '', title: name, caption: `${meta.label} · ${body.slice(0, 80)}`, icon: meta.icon })
toast({ message: `${meta.label}${name}`, caption: body.slice(0, 80), color: meta.color, textColor: 'white', icon: meta.icon, position: 'top-right', timeout: 6000, actions: [{ label: 'Voir', color: 'white', handler: () => go({ type: 'conv', conv: d.token }) }] })
}
function onConvEmail (d) {
if (!prefs.value.email || !d) return
const who = d.email || 'Courriel'
add({ type: 'conv', feed: 'email', conv: d.token || '', title: who, caption: 'Nouveau courriel entrant', icon: 'mail' })
toast({ message: `Courriel — ${who}`, caption: 'Nouveau courriel entrant', color: 'deep-purple-7', textColor: 'white', icon: 'mail', position: 'top-right', timeout: 6000, actions: [{ label: 'Voir', color: 'white', handler: () => go({ type: 'conv', conv: d.token }) }] })
}
function initNotifications () {
loadPrefs()
if (es) return
try {
es = new EventSource(`${HUB_URL}/sse?topics=outbox,conversations`)
es.addEventListener('rating', e => { try { onRating(JSON.parse(e.data)) } catch {} })
es.addEventListener('rating-comment', e => { try { onComment(JSON.parse(e.data)) } catch {} })
es.addEventListener('conv-message', e => { try { onConvMessage(JSON.parse(e.data)) } catch {} })
es.addEventListener('conv-email', e => { try { onConvEmail(JSON.parse(e.data)) } catch {} })
} catch (e) { /* SSE indisponible */ }
}
function markAllRead () { unread.value = 0 }
export function useNotifications () { return { notifications, unread, prefs, FEEDS, initNotifications, markAllRead, savePrefs, loadPrefs, go } }

View File

@ -11,6 +11,7 @@ export const navItems = [
{ path: '/rdv', icon: 'CalendarClock', label: 'Rendez-vous', requires: 'view_all_jobs' }, { path: '/rdv', icon: 'CalendarClock', label: 'Rendez-vous', requires: 'view_all_jobs' },
{ path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs' }, { path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs' },
{ path: '/historique', icon: 'History', label: 'Historique', requires: 'view_all_jobs' }, { path: '/historique', icon: 'History', label: 'Historique', requires: 'view_all_jobs' },
{ path: '/evaluations', icon: 'Star', label: 'Évaluations', requires: 'view_clients' },
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' }, { path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
// ── Administration (replié) ── // ── Administration (replié) ──
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', group: 'admin' }, { path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', group: 'admin' },

View File

@ -25,7 +25,7 @@ export const columns = [
{ name: 'status', label: 'Statut', field: 'status', align: 'center' }, { name: 'status', label: 'Statut', field: 'status', align: 'center' },
] ]
export function buildFilters ({ statusFilter, typeFilter, priorityFilter, search, ownerFilter }) { export function buildFilters ({ statusFilter, typeFilter, priorityFilter, search, ownerFilter, me }) {
const filters = {} const filters = {}
if (statusFilter === 'not_closed') { if (statusFilter === 'not_closed') {
filters.status = ['!=', 'Closed'] filters.status = ['!=', 'Closed']
@ -42,8 +42,8 @@ export function buildFilters ({ statusFilter, typeFilter, priorityFilter, search
filters.subject = ['like', '%' + q + '%'] filters.subject = ['like', '%' + q + '%']
} }
} }
if (ownerFilter === 'mine') { if (ownerFilter === 'mine' && me) {
filters.owner = ['like', '%'] filters.owner = me // « Mes tickets » = créés/possédés par l'agent connecté (champ owner, toujours requêtable)
} }
return filters return filters
} }

View File

@ -45,6 +45,7 @@
<q-btn flat round dense icon="menu" color="white" @click="drawer = !drawer" /> <q-btn flat round dense icon="menu" color="white" @click="drawer = !drawer" />
<q-toolbar-title class="text-weight-bold" style="font-size:1rem;color:#fff">{{ currentNav?.label || 'Targo Ops' }}</q-toolbar-title> <q-toolbar-title class="text-weight-bold" style="font-size:1rem;color:#fff">{{ currentNav?.label || 'Targo Ops' }}</q-toolbar-title>
<q-space /> <q-space />
<NotificationBell v-if="can('view_clients')" dark />
<q-btn v-if="!isDispatch" flat round dense icon="search" color="white" @click="mobileSearchOpen = !mobileSearchOpen" /> <q-btn v-if="!isDispatch" flat round dense icon="search" color="white" @click="mobileSearchOpen = !mobileSearchOpen" />
</q-toolbar> </q-toolbar>
<div v-if="mobileSearchOpen && !isDispatch" class="q-px-sm q-pb-sm" style="background:var(--ops-sidebar-bg)"> <div v-if="mobileSearchOpen && !isDispatch" class="q-px-sm q-pb-sm" style="background:var(--ops-sidebar-bg)">
@ -97,6 +98,7 @@
<div class="ops-search-result" style="justify-content:center;color:#94a3b8">Aucun résultat</div> <div class="ops-search-result" style="justify-content:center;color:#94a3b8">Aucun résultat</div>
</div> </div>
</div> </div>
<NotificationBell v-if="can('view_clients')" class="q-ml-sm" />
</div> </div>
<router-view /> <router-view />
</q-page-container> </q-page-container>
@ -156,6 +158,7 @@ import {
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import ConversationPanel from 'src/components/shared/ConversationPanel.vue' import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
import NotificationBell from 'src/components/shared/NotificationBell.vue'
import { useConversations } from 'src/composables/useConversations' import { useConversations } from 'src/composables/useConversations'
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue' import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
import NewTicketDialog from 'src/components/shared/NewTicketDialog.vue' import NewTicketDialog from 'src/components/shared/NewTicketDialog.vue'

View File

@ -151,6 +151,9 @@
<q-card-section> <q-card-section>
<q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense type="email" autofocus class="q-mb-md" /> <q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense type="email" autofocus class="q-mb-md" />
<q-input v-model="testSendForm.subject" label="Sujet" outlined dense class="q-mb-md" /> <q-input v-model="testSendForm.subject" label="Sujet" outlined dense class="q-mb-md" />
<div class="text-caption text-grey-7 q-mb-xs">Envoyer via</div>
<q-btn-toggle v-model="testSendForm.via" spread no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" class="q-mb-md"
:options="[{ label: 'Compte Gmail', value: 'gmail', icon: 'mail' }, { label: 'Mailjet', value: 'mailjet', icon: 'send' }]" />
<div class="text-subtitle2 q-mb-sm">Variables</div> <div class="text-subtitle2 q-mb-sm">Variables</div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<q-input v-model="testSendForm.vars.firstname" label="firstname" outlined dense class="col-6" /> <q-input v-model="testSendForm.vars.firstname" label="firstname" outlined dense class="col-6" />
@ -167,7 +170,8 @@
<q-card-section class="bg-grey-2"> <q-card-section class="bg-grey-2">
<div class="text-caption text-grey-8"> <div class="text-caption text-grey-8">
Sauvegarde l'éditeur d'abord pour tester la dernière version. Sauvegarde l'éditeur d'abord pour tester la dernière version.
Envoyé via Mailjet depuis <code>TARGO &lt;support@targointernet.com&gt;</code>. <template v-if="testSendForm.via === 'gmail'">Envoyé via le <b>compte Gmail</b> surveillé même canal que les vraies invitations (étoiles d'évaluation cliquables incluses).</template>
<template v-else>Envoyé via <b>Mailjet</b> depuis <code>TARGO &lt;support@targointernet.com&gt;</code>.</template>
</div> </div>
</q-card-section> </q-card-section>
<q-card-actions align="right"> <q-card-actions align="right">
@ -238,6 +242,7 @@ import { useQuasar } from 'quasar'
import { EmailEditor } from 'vue-email-editor' import { EmailEditor } from 'vue-email-editor'
import { listTemplates, getTemplate, saveTemplate as saveTemplateApi, import { listTemplates, getTemplate, saveTemplate as saveTemplateApi,
previewTemplate, testSendTemplate, translateTemplate } from 'src/api/campaigns' previewTemplate, testSendTemplate, translateTemplate } from 'src/api/campaigns'
import { HUB_URL } from 'src/config/hub'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -473,6 +478,24 @@ function onEditorLoad () {
async function onEditorReady () { async function onEditorReady () {
// Fired when the editor INSIDE the iframe is ready to accept loadDesign() // Fired when the editor INSIDE the iframe is ready to accept loadDesign()
editorReady.value = true editorReady.value = true
// Images insérées NOTRE store d'actifs auto-hébergé (pas le CDN d'Unlayer) ; les URLs des courriels pointent vers msg.gigafibre.ca.
try {
editor.value.registerCallback('image', (file, done) => {
const f = file && file.attachments && file.attachments[0]
if (!f) { done({ progress: 100 }); return }
done({ progress: 20 })
const fr = new FileReader()
fr.onload = async () => {
try {
const r = await fetch(`${HUB_URL}/campaigns/assets/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: f.name, data: fr.result }) })
const d = await r.json()
done({ progress: 100, url: d.url || '' })
} catch (e) { done({ progress: 100 }) }
}
fr.onerror = () => done({ progress: 100 })
fr.readAsDataURL(f)
})
} catch (e) { /* */ }
await loadTemplateIntoEditor(currentName.value) await loadTemplateIntoEditor(currentName.value)
} }
@ -541,6 +564,7 @@ const sampleExpiryDate = new Date(Date.now() + 30 * 86400 * 1000)
const testSendForm = ref({ const testSendForm = ref({
to: 'louis@targo.ca', to: 'louis@targo.ca',
subject: '[TEST] Aperçu du courriel TARGO', subject: '[TEST] Aperçu du courriel TARGO',
via: 'gmail',
vars: { vars: {
firstname: 'Louis', lastname: 'Test', amount: '60 $', firstname: 'Louis', lastname: 'Test', amount: '60 $',
commitment_months: '3', commitment_months: '3',
@ -559,8 +583,9 @@ async function doTestSend () {
to: testSendForm.value.to.trim(), to: testSendForm.value.to.trim(),
subject: testSendForm.value.subject, subject: testSendForm.value.subject,
vars: testSendForm.value.vars, vars: testSendForm.value.vars,
via: testSendForm.value.via,
}) })
$q.notify({ type: 'positive', message: `Test envoyé à ${r.to}`, caption: `${r.bytes} octets`, timeout: 5000 }) $q.notify({ type: 'positive', message: `Test envoyé à ${r.to}`, caption: `via ${r.channel === 'gmail' ? 'Gmail' : 'Mailjet'} · ${r.bytes} octets`, timeout: 5000 })
testSendOpen.value = false testSendOpen.value = false
} catch (e) { } catch (e) {
$q.notify({ type: 'negative', message: 'Échec envoi: ' + e.message }) $q.notify({ type: 'negative', message: 'Échec envoi: ' + e.message })

View File

@ -13,6 +13,67 @@
<template #info><CustomerInfoCard :customer="customer" /></template> <template #info><CustomerInfoCard :customer="customer" /></template>
</CustomerHeader> </CustomerHeader>
<!-- Satisfaction : dernière évaluation laissée par CE client (étoiles + commentaire) -->
<div v-if="latestRating" class="ops-card q-pa-md q-mb-sm" style="border-radius:10px">
<div class="row items-center no-wrap">
<q-icon name="star" color="amber-7" size="20px" class="q-mr-xs" />
<span class="section-title" style="font-size:1rem">Satisfaction</span>
<span class="q-ml-md" :style="{ color: latestRating.stars >= 5 ? '#00C853' : (latestRating.stars <= 2 ? '#e53935' : '#f5b50a'), letterSpacing: '2px', fontSize: '1.1rem' }">
<span>{{ '★'.repeat(latestRating.stars || 0) }}</span><span style="color:#e2e8f0">{{ '☆'.repeat(5 - (latestRating.stars || 0)) }}</span>
</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ latestRating.stars }}/5</span>
<q-space />
<span class="text-caption text-grey-5">{{ formatDateTime(latestRating.stars_ts || latestRating.comment_ts || latestRating.created) }}</span>
<q-btn v-if="custRatings.length > 1" dense flat size="sm" no-caps color="indigo-6" :label="`${custRatings.length} évaluations`" class="q-ml-sm" @click="$router.push('/evaluations')" />
</div>
<div v-if="latestRating.comment" class="q-mt-xs" style="white-space:pre-wrap;color:#334155">« {{ latestRating.comment }} »</div>
</div>
<!-- Pas encore d'évaluation : proposer d'en demander une (brouillon courriel/texto pré-rempli, langue du client) -->
<div v-else class="ops-card q-px-md q-py-sm q-mb-sm row items-center" style="border-radius:10px">
<q-icon name="star_outline" color="amber-7" size="20px" class="q-mr-sm" />
<span class="text-grey-7">Aucune évaluation pour ce client.</span>
<q-space />
<q-btn dense unelevated no-caps color="amber-8" text-color="white" icon="reviews" label="Demander une évaluation">
<q-tooltip>Ouvrir un brouillon d'invitation (coordonnées + message pré-remplis)</q-tooltip>
<q-menu auto-close>
<q-list dense style="min-width:210px">
<q-item-label header class="q-py-xs">Brouillon d'invitation (langue du client)</q-item-label>
<q-item clickable @click="draftRatingInvite('email')"><q-item-section avatar><q-icon name="mail" color="indigo-6" /></q-item-section><q-item-section>Par courriel</q-item-section></q-item>
<q-item clickable @click="draftRatingInvite('sms')"><q-item-section avatar><q-icon name="sms" color="teal-6" /></q-item-section><q-item-section>Par texto</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<!-- Communications unifiées (courriel + SMS) timeline customer-360, clic = ouvre le panneau flottant -->
<q-expansion-item default-opened header-class="section-header" class="q-mb-sm">
<template #header>
<div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center;gap:6px">
<q-icon name="forum" size="20px" />
<span>Communications ({{ commsDiscussions.length }})</span>
<q-space />
<q-btn dense flat round size="sm" icon="refresh" color="grey-6" @click.stop="loadComms"><q-tooltip>Rafraîchir (courriels + SMS de la boîte unifiée)</q-tooltip></q-btn>
</div>
</template>
<div v-if="!commsDiscussions.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucune conversation (courriel / SMS) liée à ce client</div>
<q-list v-else bordered separator class="ops-card q-mb-md" style="border-radius:10px;overflow:hidden">
<q-item v-for="d in commsDiscussions" :key="d.id" clickable @click="openComms(d)">
<q-item-section avatar><q-icon :name="d.channel === 'email' ? 'mail' : 'sms'" :color="d.channel === 'email' ? 'red-5' : 'teal-6'" size="22px" /></q-item-section>
<q-item-section>
<q-item-label lines="1">{{ commsTitle(d) }}</q-item-label>
<q-item-label caption lines="1">{{ commsSnippet(d) }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>{{ formatDate(d.lastActivity) }}</q-item-label>
<div class="row items-center q-gutter-xs q-mt-xs">
<q-badge v-if="d.status === 'active'" color="green-5" label="actif" style="font-size:0.6rem" />
<q-badge v-if="d.queue" color="indigo-1" text-color="indigo-8" :label="d.queue" style="font-size:0.6rem" />
</div>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
<q-expansion-item v-model="sectionsOpen.locations" header-class="section-header" class="q-mb-sm"> <q-expansion-item v-model="sectionsOpen.locations" header-class="section-header" class="q-mb-sm">
<template #header> <template #header>
<div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center;gap:6px"> <div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center;gap:6px">
@ -979,12 +1040,13 @@ import { deleteDoc, createDoc, listDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth' import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub' import { HUB_URL } from 'src/config/hub'
import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters' import { formatDate, formatDateTime, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses' import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
import { useDetailModal } from 'src/composables/useDetailModal' import { useDetailModal } from 'src/composables/useDetailModal'
import { useSubscriptionGroups, isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups' import { useSubscriptionGroups, isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
import { useSubscriptionActions } from 'src/composables/useSubscriptionActions' import { useSubscriptionActions } from 'src/composables/useSubscriptionActions'
import { useCustomerNotes } from 'src/composables/useCustomerNotes' import { useCustomerNotes } from 'src/composables/useCustomerNotes'
import { useConversations } from 'src/composables/useConversations'
import { invoiceCols, paymentCols, ticketCols, voipCols, paymentMethodCols, arrangementCols, quotationCols, serviceContractCols } from 'src/config/table-columns' import { invoiceCols, paymentCols, ticketCols, voipCols, paymentMethodCols, arrangementCols, quotationCols, serviceContractCols } from 'src/config/table-columns'
import { deviceLucideIcon } from 'src/config/device-icons' import { deviceLucideIcon } from 'src/config/device-icons'
import DetailModal from 'src/components/shared/DetailModal.vue' import DetailModal from 'src/components/shared/DetailModal.vue'
@ -1442,6 +1504,41 @@ function onContractTerminated (payload) {
} }
const sectionsOpen = ref({ ...defaultSectionsOpen }) const sectionsOpen = ref({ ...defaultSectionsOpen })
// Communications unifiées (courriel + SMS) du client réutilise le panneau global flottant (singleton)
const { openDiscussion, panelOpen, fetchForCustomer, openComposeDraft } = useConversations()
const commsDiscussions = ref([])
async function loadComms () {
const c = customer.value; if (!c) return
commsDiscussions.value = await fetchForCustomer({ customer: c.name, email: c.email_id || c.email_billing || '', phone: c.cell_phone || c.tel_home || '' })
}
function openComms (d) { openDiscussion(d); panelOpen.value = true }
// Évaluations laissées par CE client (étoiles + commentaire) en tête de fiche (lien fiche garanti côté hub).
const custRatings = ref([])
const latestRating = computed(() => custRatings.value[0] || null)
async function loadRatings () {
const c = customer.value; if (!c || !c.name) return
try { const r = await fetch(`${HUB_URL}/collab/ratings?customer=${encodeURIComponent(c.name)}`); const d = r.ok ? await r.json() : {}; custRatings.value = d.ratings || [] } catch (e) { custRatings.value = [] }
}
// Pas d'évaluation ouvrir un BROUILLON d'invitation (courriel/texto) pré-rempli dans la fenêtre Compose, langue du client.
async function draftRatingInvite (channel) {
const id = customer.value && customer.value.name; if (!id) return
try {
const r = await fetch(`${HUB_URL}/collab/rating-invite-draft?customer=${encodeURIComponent(id)}&channel=${channel}`)
const d = r.ok ? await r.json() : {}
if (!d.ok || !d.prefill) { $q.notify({ type: 'negative', message: d.error || 'Impossible de préparer le brouillon' }); return }
openComposeDraft(d.prefill)
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
function commsTitle (d) {
if (d.channel === 'email') return (d.conversations && d.conversations[0] && d.conversations[0].subject) || d.subject || '(courriel sans objet)'
return 'SMS' + (d.phone ? ' · ' + d.phone : '')
}
function commsSnippet (d) {
const msgs = d.messages || []
const last = msgs.length ? msgs[msgs.length - 1] : (d.lastMessage || null)
const t = last ? String(last.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() : ''
return t.slice(0, 120) || '—'
}
const openTicketCount = computed(() => tickets.value.filter(t => t.status === 'Open').length) const openTicketCount = computed(() => tickets.value.filter(t => t.status === 'Open').length)
const totalOutstanding = computed(() => accountBalance.value?.balance ?? 0) const totalOutstanding = computed(() => accountBalance.value?.balance ?? 0)
@ -1594,6 +1691,8 @@ async function focusLocationFromQuery () {
onMounted(async () => { onMounted(async () => {
await loadCustomer(props.id) await loadCustomer(props.id)
focusLocationFromQuery() focusLocationFromQuery()
loadComms()
loadRatings()
}) })
watch(() => route.query?.location, () => focusLocationFromQuery()) watch(() => route.query?.location, () => focusLocationFromQuery())
</script> </script>

View File

@ -0,0 +1,137 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<div class="text-h6 text-weight-bold">Évaluations clients</div>
<q-chip v-if="lowCount" dense color="red-5" text-color="white" class="q-ml-sm">{{ lowCount }} basse(s)<q-tooltip>Évaluations sous 5</q-tooltip></q-chip>
<q-space />
<q-select v-model="sortKey" :options="sortOptions" dense outlined options-dense emit-value map-options class="q-mr-sm" style="min-width:215px">
<template #prepend><q-icon name="sort" size="18px" /></template>
</q-select>
<q-btn flat round dense icon="refresh" color="grey-7" :loading="loading" @click="load"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
</div>
<div v-if="loading && !ratings.length" class="flex flex-center q-pa-xl"><q-spinner size="36px" color="indigo-6" /></div>
<div v-else-if="!sorted.length" class="ops-card text-center text-grey-6 q-pa-xl" style="border-radius:12px">
<q-icon name="reviews" size="40px" color="grey-4" class="q-mb-sm" /><br>
Aucune évaluation pour le moment.
</div>
<q-list v-else bordered separator class="ops-card" style="border-radius:12px;overflow:hidden">
<q-item v-for="r in sorted" :key="r.token"
:class="r.stars != null && r.stars <= 2 ? 'bg-red-1' : (r.stars === 3 || r.stars === 4 ? 'bg-orange-1' : '')">
<q-item-section avatar style="min-width:104px">
<div class="text-h6" :style="{ color: r.stars >= 5 ? '#00C853' : (r.stars <= 2 ? '#e53935' : '#f5b50a'), letterSpacing: '1px' }">
<span>{{ '★'.repeat(r.stars || 0) }}</span><span style="color:#e2e8f0">{{ '☆'.repeat(5 - (r.stars || 0)) }}</span>
</div>
</q-item-section>
<q-item-section>
<q-item-label class="text-weight-medium">{{ r.name || r.customerName || r.customer || r.email || 'Client' }}</q-item-label>
<q-item-label v-if="r.comment" class="q-mt-xs" style="white-space:pre-wrap;color:#334155">« {{ r.comment }} »</q-item-label>
<q-item-label v-else caption class="text-grey-5">Aucun commentaire</q-item-label>
</q-item-section>
<q-item-section side top style="min-width:160px">
<q-item-label caption>{{ formatDateTime(r.stars_ts || r.comment_ts || r.created) }}</q-item-label>
<div class="q-gutter-xs q-mt-xs">
<q-btn v-if="r.customer" dense flat size="sm" no-caps color="indigo-6" icon="person" label="Fiche" @click="openFiche(r)" />
<q-btn v-if="r.conv" dense flat size="sm" no-caps color="teal-6" icon="forum" label="Fil" @click="openThread(r)" />
<q-btn v-if="r.customer || r.email" dense flat round size="sm" icon="forward_to_inbox" color="grey-6">
<q-tooltip>Renvoyer l'invitation à évaluer (langue du client)</q-tooltip>
<q-menu auto-close>
<q-list dense style="min-width:190px">
<q-item-label header class="q-py-xs">Renvoyer l'invitation</q-item-label>
<q-item clickable @click="resend(r, 'email')"><q-item-section avatar><q-icon name="mail" color="indigo-6" /></q-item-section><q-item-section>Par courriel</q-item-section></q-item>
<q-item clickable @click="resend(r, 'sms')"><q-item-section avatar><q-icon name="sms" color="teal-6" /></q-item-section><q-item-section>Par texto</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<q-btn v-if="r.customer || r.email" dense flat round size="sm" icon="edit_note" color="grey-6">
<q-tooltip>Écrire au client (brouillon utile pour un avis mécontent)</q-tooltip>
<q-menu auto-close>
<q-list dense style="min-width:190px">
<q-item-label header class="q-py-xs">Écrire au client</q-item-label>
<q-item clickable @click="draftFeedback(r, 'email')"><q-item-section avatar><q-icon name="mail" color="indigo-6" /></q-item-section><q-item-section>Par courriel</q-item-section></q-item>
<q-item clickable @click="draftFeedback(r, 'sms')"><q-item-section avatar><q-icon name="sms" color="teal-6" /></q-item-section><q-item-section>Par texto</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<q-btn dense flat round size="sm" icon="delete" color="grey-5" @click="removeRating(r)"><q-tooltip>Supprimer (ex. avis de test)</q-tooltip></q-btn>
</div>
</q-item-section>
</q-item>
</q-list>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { formatDateTime } from 'src/composables/useFormatters'
import { useConversations } from 'src/composables/useConversations'
const router = useRouter()
const $q = useQuasar()
const { openComposeDraft } = useConversations()
const ratings = ref([])
const lowCount = ref(0)
const loading = ref(false)
const sortKey = ref('date')
const sortOptions = [
{ label: 'Date (plus récent)', value: 'date' },
{ label: 'Étoiles ↑ (basses d\'abord)', value: 'stars_asc' },
{ label: 'Étoiles ↓ (hautes d\'abord)', value: 'stars_desc' },
]
const dateOf = (r) => String(r.stars_ts || r.comment_ts || r.created || '')
// Tout afficher (plus de distinction « à traiter ») + tri par date ou par nombre d'étoiles.
const sorted = computed(() => {
const arr = [...ratings.value]
if (sortKey.value === 'stars_asc') arr.sort((a, b) => (a.stars ?? 99) - (b.stars ?? 99) || dateOf(b).localeCompare(dateOf(a)))
else if (sortKey.value === 'stars_desc') arr.sort((a, b) => (b.stars ?? -1) - (a.stars ?? -1) || dateOf(b).localeCompare(dateOf(a)))
else arr.sort((a, b) => dateOf(b).localeCompare(dateOf(a)))
return arr
})
async function load () {
loading.value = true
try {
const r = await fetch(HUB_URL + '/collab/ratings', { headers: { 'Content-Type': 'application/json' } })
const d = r.ok ? await r.json() : {}
ratings.value = d.ratings || []
lowCount.value = d.low || 0
} catch (e) { /* silencieux */ } finally { loading.value = false }
}
function openFiche (r) { if (r.customer) router.push('/clients/' + encodeURIComponent(r.customer)) }
function openThread (r) { if (r.conv) router.push('/communications/c/' + encodeURIComponent(r.conv)) }
// Renvoyer l'invitation à évaluer (courriel/SMS) le hub résout courriel/téléphone/langue depuis la fiche.
async function resend (r, channel) {
try {
const res = await fetch(HUB_URL + '/collab/send-rating-invite', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ customer: r.customer || '', email: r.email || '', name: r.name || r.customerName || '', conv: r.conv || '', channel }) })
const d = await res.json().catch(() => ({}))
if (res.ok && d.ok !== false) $q.notify({ type: 'positive', icon: channel === 'sms' ? 'sms' : 'mail', message: `Invitation renvoyée par ${channel === 'sms' ? 'texto' : 'courriel'}${d.to ? ' → ' + d.to : ''}` })
else $q.notify({ type: 'negative', message: d.error || "Échec de l'envoi" })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
// Écrire au client (brouillon courriel/texto pré-rempli) utile pour relancer un client mécontent.
async function draftFeedback (r, channel) {
try {
const qs = new URLSearchParams({ customer: r.customer || '', email: r.email || '', name: r.name || r.customerName || '', channel, kind: 'feedback' })
const res = await fetch(HUB_URL + '/collab/rating-invite-draft?' + qs.toString())
const d = res.ok ? await res.json() : {}
if (!d.ok || !d.prefill) { $q.notify({ type: 'negative', message: d.error || 'Brouillon impossible' }); return }
openComposeDraft(d.prefill)
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
// Supprimer une évaluation (avis de test) confirmation puis retrait de la liste.
function removeRating (r) {
$q.dialog({ title: 'Supprimer cette évaluation ?', message: 'Action définitive — utile pour retirer un avis de test.', cancel: true, ok: { label: 'Supprimer', color: 'negative' }, persistent: true }).onOk(async () => {
try {
const res = await fetch(HUB_URL + '/collab/ratings/' + encodeURIComponent(r.token), { method: 'DELETE' })
const d = await res.json().catch(() => ({}))
if (res.ok && d.ok) { ratings.value = ratings.value.filter(x => x.token !== r.token); lowCount.value = ratings.value.filter(x => x.stars != null && x.stars < 5).length; $q.notify({ type: 'positive', message: 'Évaluation supprimée' }) }
else $q.notify({ type: 'negative', message: 'Suppression impossible' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
})
}
onMounted(load)
</script>

View File

@ -210,43 +210,31 @@
</q-menu> </q-menu>
</q-btn> </q-btn>
<div class="tech-skills clk" @click.stop="openSkillEditor(t, $event)"> <div class="tech-skills clk" @click.stop="openSkillEditor(t, $event)">
<span v-for="sk in [...(t.skills || [])].sort((a, b) => a.localeCompare(b))" :key="sk" class="skill-dot" :class="{ 'has-lvl': !!(t.skill_levels || {})[sk] }" :style="{ background: getTagColor(sk) }"><q-icon :name="skillSym(sk)" size="11px" /><span v-if="(t.skill_levels || {})[sk]" class="sd-lvl">{{ (t.skill_levels || {})[sk] }}</span><q-tooltip class="bg-grey-9"><b>{{ sk }}</b> · niveau {{ (t.skill_levels || {})[sk] || '—' }}<template v-if="(t.skill_eff || {})[sk]"> · {{ effSuffix(skillEffOf(t, sk)) }}</template></q-tooltip></span> <span v-if="tSkills(t).length" class="skill-dot" :class="{ 'has-lvl': !!(t.skill_levels || {})[tSkills(t)[0]] }" :style="{ background: getTagColor(tSkills(t)[0]) }"><q-icon :name="skillSym(tSkills(t)[0])" size="11px" /><span v-if="(t.skill_levels || {})[tSkills(t)[0]]" class="sd-lvl">{{ (t.skill_levels || {})[tSkills(t)[0]] }}</span><q-tooltip class="bg-grey-9"><b>{{ tSkills(t)[0] }}</b> · niveau {{ (t.skill_levels || {})[tSkills(t)[0]] || '—' }}<template v-if="(t.skill_eff || {})[tSkills(t)[0]]"> · {{ effSuffix(skillEffOf(t, tSkills(t)[0])) }}</template></q-tooltip></span><span v-if="tSkills(t).length > 1" class="skill-more">+{{ tSkills(t).length - 1 }}<q-tooltip class="bg-grey-9" style="font-size:11px"><div class="text-weight-medium q-mb-xs">Autres compétences</div><div v-for="sk in tSkills(t).slice(1)" :key="sk" class="row items-center no-wrap" style="gap:5px;line-height:1.7"><q-icon :name="skillSym(sk)" size="13px" :style="{ color: getTagColor(sk) }" /><span>{{ sk }}</span><span v-if="(t.skill_levels || {})[sk]" class="text-grey-5">· niv {{ (t.skill_levels || {})[sk] }}</span></div></q-tooltip></span>
<span v-if="!(t.skills || []).length" class="add-skill-hint">+ compétences</span> <span v-if="!(t.skills || []).length" class="add-skill-hint">+ compétences</span>
</div> </div>
<q-btn flat dense round size="9px" class="hide-eye" :icon="isHidden(t.id) ? 'visibility_off' : 'visibility'" :color="isHidden(t.id) ? 'grey-5' : 'grey-6'" @click.stop="toggleHidden(t.id)"><q-tooltip>{{ isHidden(t.id) ? 'Réafficher / considérer cette ressource' : 'Masquer & ignorer (hors front-line)' }}</q-tooltip></q-btn> <q-btn flat dense round size="9px" class="hide-eye" :icon="isHidden(t.id) ? 'visibility_off' : 'visibility'" :color="isHidden(t.id) ? 'grey-5' : 'grey-6'" @click.stop="toggleHidden(t.id)"><q-tooltip>{{ isHidden(t.id) ? 'Réafficher / considérer cette ressource' : 'Masquer & ignorer (hors front-line)' }}</q-tooltip></q-btn>
</div> </div>
</td> </td>
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso), over: cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).over, noshift: cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).noShift, 'drop-hover': dropCell === t.id + '|' + d.iso }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)" @contextmenu.prevent="onCellContext(t, d, $event, ti, di)" @dragover.prevent="onCellDragOver(t, d)" @dragleave="dropCell === t.id + '|' + d.iso && (dropCell = null, dropPreview.key = null)" @drop.prevent="onCellDrop($event, t, d)"> <td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso), over: cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).over, noshift: (cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).noShift) || (officeBlockH(t.id, d.iso) && !hasReg(t.id, d.iso)), 'drop-hover': dropCell === t.id + '|' + d.iso }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)" @contextmenu.prevent="onCellContext(t, d, $event, ti, di)" @dragover.prevent="onCellDragOver(t, d)" @dragleave="dropCell === t.id + '|' + d.iso && (dropCell = null, dropPreview.key = null)" @drop.prevent="onCellDrop($event, t, d)">
<template v-if="isAbsent(t.id, d.iso) || isPaused(t)"> <template v-if="isAbsent(t.id, d.iso) || isPaused(t)">
<div class="tl"><div class="tl-absent"></div><q-tooltip class="bg-grey-9">Absent · {{ absenceLabel(t.id, d.iso) }}</q-tooltip></div> <div class="tl"><div class="tl-absent"></div><q-tooltip class="bg-grey-9">Absent · {{ absenceLabel(t.id, d.iso) }}</q-tooltip></div>
</template> </template>
<template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso) || (cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).noShift)"> <template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso) || (cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).noShift)">
<div class="tl"> <div class="tl">
<div v-if="cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).hasReg" class="tl-shift tl-shift-click" :class="{ over: cellOcc(t.id, d.iso).over }" :style="pos(cellOcc(t.id, d.iso).shiftS, cellOcc(t.id, d.iso).shiftE)" @click.stop="openDayFromCell(t, d)"><q-tooltip class="bg-grey-9" :delay="500" style="font-size:11px">{{ cellOcc(t.id, d.iso).over ? 'Quart en surcharge voir la tournée' : 'Quart voir les jobs / la tournée' }}</q-tooltip></div> <div v-if="cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).hasReg" class="tl-shift tl-shift-click" :class="{ over: cellOcc(t.id, d.iso).over }" :style="pos(cellOcc(t.id, d.iso).shiftS, cellOcc(t.id, d.iso).shiftE)" @click.stop="openDayFromCell(t, d)"></div>
<div v-for="(b, bi) in cellGardeBands(t.id, d.iso)" :key="'g' + bi" class="tl-shift tl-shift-click oncall" :style="{ left: b.left, width: b.width }" @click.stop="openDayFromCell(t, d)"><q-tooltip class="bg-grey-9" :delay="500" style="font-size:11px">Garde voir le jour</q-tooltip></div> <div v-for="(b, bi) in cellGardeBands(t.id, d.iso)" :key="'g' + bi" class="tl-shift tl-shift-click oncall" :style="{ left: b.left, width: b.width }" @click.stop="openDayFromCell(t, d)"></div>
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :class="{ 'tl-blk-legacy': b.legacy }" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="b.legacy ? openLegacyBlock(b) : openDayFromCell(t, d)" @mousedown.stop><q-icon v-if="(b.e - b.s) >= (dayMode ? 0.75 : 2.5)" :name="b.assist ? 'group' : (b.legacy ? 'confirmation_number' : skillSym(b.skill))" :style="{ color: blkColor(b) }" size="12px" /><q-tooltip class="bg-grey-9" :delay="350" style="font-size:11px"><template v-if="b.assist">👥 <b>Assistant (renfort)</b><br>{{ b.skill || 'Job' }} · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h</template><template v-else-if="b.legacy">🗂 <b>{{ b.subject || 'Ticket legacy' }}</b><br>{{ b.dept || 'osTicket' }} · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h · #{{ b.lid }}<br><span class="text-light-green-4">✓ Assigné dans F (autoritaire)</span><br><span class="text-blue-3">clic = ouvrir le ticket ↗</span></template><template v-else>{{ b.skill || 'Job' }} · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h<span v-if="blkOpsOnly(b)"><br><span class="text-deep-orange-4">⚠ Dispatché dans OPS — pas encore publié dans F (pâle + pointillé)</span></span><br><span class="text-blue-3">clic = voir la tournée · clic droit = renvoyer au pool</span></template></q-tooltip><q-menu v-if="b.job && !b.legacy && !b.assist" context-menu touch-position auto-close><q-list dense style="min-width:180px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool</q-item-section></q-item></q-list></q-menu></div> <div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :class="{ 'tl-blk-legacy': b.legacy }" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="b.legacy ? openLegacyBlock(b) : openJobDetail({ ...b, name: b.job }, t)" @mousedown.stop><q-icon v-if="(b.e - b.s) >= (dayMode ? 0.75 : 2.5)" :name="blkIcon(b)" :style="{ color: blkColor(b) }" size="12px" /><q-tooltip class="bg-grey-9" :delay="350" style="font-size:11px"><template v-if="b.assist">👥 <b>Assistant (renfort)</b> · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h</template><template v-else><b>{{ b.subject || b.skill || 'Job' }}</b><br>{{ b.legacy ? (b.legacy_dept || b.dept || 'Ticket') : (b.skill || 'Job') }} · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h<span v-if="blkOpsOnly(b)"><br><span class="text-deep-orange-4">⚠ Dispatché OPS — pas publié dans F</span></span><br><span class="text-blue-3">clic = ouvrir le ticket · ailleurs dans la cellule = la tournée</span></template></q-tooltip><q-menu v-if="b.job && !b.legacy && !b.assist" context-menu touch-position auto-close><q-list dense style="min-width:180px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool</q-item-section></q-item></q-list></q-menu></div>
<!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta --> <!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta -->
<div v-if="isDropTarget(t.id, d.iso) && projPct(t.id, d.iso) != null" class="tl-proj" :style="{ width: Math.min(100, projPct(t.id, d.iso)) + '%', background: occColor(projPct(t.id, d.iso)) }"></div> <div v-if="isDropTarget(t.id, d.iso) && projPct(t.id, d.iso) != null" class="tl-proj" :style="{ width: Math.min(100, projPct(t.id, d.iso)) + '%', background: occColor(projPct(t.id, d.iso)) }"></div>
<q-tooltip class="bg-grey-9" :offset="[0, 6]" max-width="320px"> <!-- pas de bulle sur le FOND de cellule : clic sur un bloc = son ticket · clic ailleurs dans la cellule = éditeur de jour (liste + timeline + carte) -->
<div class="text-weight-medium">{{ cellTip(t.id, d.iso) }}</div>
<div class="text-blue-3" style="font-size:10px">Clic sur le quart / les jobs = tournée · clic sur le fond = horaire · glisser = sélection</div>
<template v-if="cellJobs(t.id, d.iso).length">
<div class="text-amber-4 q-mt-xs" style="font-size:11px">{{ cellJobs(t.id, d.iso).length }} job(s) · par priorité</div>
<div v-for="j in cellJobs(t.id, d.iso)" :key="j.name" class="row items-center no-wrap" style="gap:5px;font-size:11px;line-height:1.5">
<span :style="{ display:'inline-block', width:'7px', height:'7px', borderRadius:'50%', background: prioColor(j.priority), flex:'0 0 auto' }"></span>
<span v-if="j.start" class="text-grey-4" style="flex:0 0 auto">{{ j.start }}</span>
<span class="ellipsis">{{ j.subject }}</span><span v-if="j.customer" class="text-grey-5" style="flex:0 0 auto">· {{ j.customer }}</span>
</div>
<div class="text-grey-5 q-mt-xs" style="font-size:10px">Outils Tableau Dispatch pour le détail complet</div>
</template>
</q-tooltip>
</div> </div>
</template> </template>
<span v-else-if="offShiftJobs(t.id, d.iso).length" class="offshift-warn" @click.stop="openTimeline(t)"><q-icon name="warning" size="13px" color="orange-8" />{{ offShiftJobs(t.id, d.iso).length }}<q-tooltip class="bg-grey-9">{{ offShiftJobs(t.id, d.iso).length }} job(s) assigné(s) ce jour SANS quart publié publier un quart ou réassigner. Clic timeline.</q-tooltip></span> <span v-else-if="offShiftJobs(t.id, d.iso).length" class="offshift-warn" @click.stop="openTimeline(t)"><q-icon name="warning" size="13px" color="orange-8" />{{ offShiftJobs(t.id, d.iso).length }}<q-tooltip class="bg-grey-9">{{ offShiftJobs(t.id, d.iso).length }} job(s) assigné(s) ce jour SANS quart publié publier un quart ou réassigner. Clic timeline.</q-tooltip></span>
<span v-else class="free">·</span> <span v-else class="free">·</span>
<div v-if="isDropTarget(t.id, d.iso)" class="drop-badge" :class="{ over: projPct(t.id, d.iso) >= 100 }">+{{ dropPreview.addH }}h<template v-if="projPct(t.id, d.iso) != null"> {{ projPct(t.id, d.iso) }}%</template></div> <div v-if="isDropTarget(t.id, d.iso)" class="drop-badge" :class="{ over: projPct(t.id, d.iso) >= 100 }">+{{ dropPreview.addH }}h<template v-if="projPct(t.id, d.iso) != null"> {{ projPct(t.id, d.iso) }}%</template></div>
<q-icon v-if="cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).over" name="warning" color="orange-8" size="14px" class="ovl-warn"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b> Surcharge</b><br>{{ overWhy(t.id, d.iso) }}</q-tooltip></q-icon> <q-icon v-if="cellOcc(t.id, d.iso) && cellOcc(t.id, d.iso).over" name="warning" color="orange-8" size="14px" class="ovl-warn"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b> Surcharge</b><br>{{ overWhy(t.id, d.iso) }}</q-tooltip></q-icon>
<div v-if="cellOfficeN(t.id, d.iso)" class="office-dots" @click.stop><span v-for="(j, ji) in officeJobs(t.id, d.iso).slice(0, 12)" :key="'od' + ji" class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span><q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px; max-width:320px"><b>{{ cellOfficeN(t.id, d.iso) }} ticket(s) sans déplacement</b> (admin / support / NP) — hors charge dispatch.<br><span v-for="(j, ji) in officeJobs(t.id, d.iso).slice(0, 12)" :key="'op' + ji">• {{ j.subject }} <span class="text-grey-5">· {{ j.dept }}</span><br></span><span v-if="cellOfficeN(t.id, d.iso) > 12" class="text-grey-5">… +{{ cellOfficeN(t.id, d.iso) - 12 }} de plus</span><br><span class="text-blue-3">clic = liste · ouvrir un ticket ↗</span></q-tooltip><q-menu anchor="bottom right" self="top right" auto-close><q-list dense style="min-width:250px; max-width:360px"><q-item-label header class="q-py-xs">{{ cellOfficeN(t.id, d.iso) }} ticket(s) sans déplacement · {{ t.name }}</q-item-label><q-item v-for="(j, ji) in officeJobs(t.id, d.iso)" :key="'om' + ji" clickable @click="openLegacyBlock({ lid: j.id, subject: j.subject, dept: j.dept })"><q-item-section avatar style="min-width:18px"><span class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span></q-item-section><q-item-section><q-item-label lines="2">{{ j.subject }}</q-item-label><q-item-label caption>{{ j.dept }} · #{{ j.id }}</q-item-label></q-item-section><q-item-section side><q-icon name="open_in_new" size="14px" color="grey-6" /></q-item-section></q-item></q-list></q-menu></div> <div v-if="officeBlockH(t.id, d.iso)" class="office-blk" :style="officeBlockStyle(t.id, d.iso)" @click.stop><q-menu anchor="bottom right" self="top right" auto-close><q-list dense style="min-width:250px; max-width:360px"><q-item-label header class="q-py-xs">{{ cellOfficeN(t.id, d.iso) }} ticket(s) sans déplacement · ≈{{ fmtOfficeH(t.id, d.iso) }} · {{ t.name }}</q-item-label><q-item v-for="(j, ji) in officeJobs(t.id, d.iso)" :key="'om' + ji" clickable @click="openLegacyBlock({ lid: j.id, subject: j.subject, dept: j.dept })"><q-item-section avatar style="min-width:18px"><span class="office-dot" :style="{ background: legacyDeptColor(j.dept) || '#9fa8da' }"></span></q-item-section><q-item-section><q-item-label lines="2">{{ j.subject }}</q-item-label><q-item-label caption>{{ j.dept }} · #{{ j.id }}<span v-if="j.est_min || j.duration_h"> · {{ j.est_min ? fmtMin(j.est_min) : (j.duration_h + 'h') }}</span></q-item-label></q-item-section><q-item-section side><q-icon name="open_in_new" size="14px" color="grey-6" /></q-item-section></q-item></q-list></q-menu></div>
</td> </td>
</tr> </tr>
<tr v-if="!visibleTechs.length"><td :colspan="dayList.length + 1" class="text-grey-6 q-pa-md text-center">Aucun technicien (filtre ?).</td></tr> <tr v-if="!visibleTechs.length"><td :colspan="dayList.length + 1" class="text-grey-6 q-pa-md text-center">Aucun technicien (filtre ?).</td></tr>
@ -289,7 +277,7 @@
<div class="kbb-name"><span class="ellipsis">{{ t.name }}</span><q-icon v-if="kanbanDay && cellOcc(t.id, kanbanDay.iso) && cellOcc(t.id, kanbanDay.iso).over" name="warning" color="orange-8" size="15px"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b> Surcharge</b><br>{{ overWhy(t.id, kanbanDay.iso) }}</q-tooltip></q-icon></div> <div class="kbb-name"><span class="ellipsis">{{ t.name }}</span><q-icon v-if="kanbanDay && cellOcc(t.id, kanbanDay.iso) && cellOcc(t.id, kanbanDay.iso).over" name="warning" color="orange-8" size="15px"><q-tooltip class="bg-grey-9" style="font-size:11.5px; max-width:260px"><b> Surcharge</b><br>{{ overWhy(t.id, kanbanDay.iso) }}</q-tooltip></q-icon></div>
<div class="kbb-lane" :style="kbLaneStyle" :class="{ 'drop-hover': kanbanDay && dropCell === t.id + '|' + kanbanDay.iso }" <div class="kbb-lane" :style="kbLaneStyle" :class="{ 'drop-hover': kanbanDay && dropCell === t.id + '|' + kanbanDay.iso }"
@dragover.prevent="kanbanDay && onCellDragOver(t, kanbanDay)" @dragleave="dropCell = null" @drop="kanbanDay && onKbDrop($event, t)" @dragover.prevent="kanbanDay && onCellDragOver(t, kanbanDay)" @dragleave="dropCell = null" @drop="kanbanDay && onKbDrop($event, t)"
@click.self="onKbLaneMenu(t, $event)" @contextmenu.self.prevent="onKbLaneMenu(t, $event)"> @click.self="kanbanDay && openDayFromCell(t, kanbanDay)" @contextmenu.self.prevent="onKbLaneMenu(t, $event)">
<!-- quart prévu = délimitation pointillée fine (ex. 718h), garde = pointillé ambre --> <!-- quart prévu = délimitation pointillée fine (ex. 718h), garde = pointillé ambre -->
<div v-for="(b, bi) in kbShiftBands(t.id)" :key="'s' + bi" class="kbb-shift" :class="{ oncall: b.oncall }" :style="kbPos(b.s, b.e)"></div> <div v-for="(b, bi) in kbShiftBands(t.id)" :key="'s' + bi" class="kbb-shift" :class="{ oncall: b.oncall }" :style="kbPos(b.s, b.e)"></div>
<!-- garde = shift SUR APPEL : bande jaune pâle + téléphone rouge (occupe la timeline, ne stretche pas l'axe) --> <!-- garde = shift SUR APPEL : bande jaune pâle + téléphone rouge (occupe la timeline, ne stretche pas l'axe) -->
@ -298,7 +286,7 @@
<!-- trajet d'approche (km/min) = zone pâle AVANT le job --> <!-- trajet d'approche (km/min) = zone pâle AVANT le job -->
<div v-if="b.legMin" class="kbb-leg" :style="kbPos(b.legS, b.legE)"><span class="kbb-leg-t">{{ b.legMin }}</span><q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px">{{ b.legReal ? '🚗 Trajet routier (Mapbox)' : ' Trajet estimé' }} : {{ b.legKm }} km · {{ b.legMin }} min avant ce job</q-tooltip></div> <div v-if="b.legMin" class="kbb-leg" :style="kbPos(b.legS, b.legE)"><span class="kbb-leg-t">{{ b.legMin }}</span><q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px">{{ b.legReal ? '🚗 Trajet routier (Mapbox)' : ' Trajet estimé' }} : {{ b.legKm }} km · {{ b.legMin }} min avant ce job</q-tooltip></div>
<div class="kbb-blk" :class="{ assist: b.assist, 'kbb-blk-legacy': b.legacy, 'kbb-blk-opsonly': blkOpsOnly(b) }" :style="{ ...kbPos(b.s, Math.min(b.e, 24)), ...blkFill(b) }" :draggable="!b.assist && !b.legacy" @dragstart="!b.assist && !b.legacy && onJobDragStart($event, b)" @dragend="onJobDragEnd" @click.stop="b.legacy ? openLegacyBlock(b) : (!b.assist && kbBlockClick(t))" @dblclick.stop="!b.legacy && kbBlockDbl(b, t)"> <div class="kbb-blk" :class="{ assist: b.assist, 'kbb-blk-legacy': b.legacy, 'kbb-blk-opsonly': blkOpsOnly(b) }" :style="{ ...kbPos(b.s, Math.min(b.e, 24)), ...blkFill(b) }" :draggable="!b.assist && !b.legacy" @dragstart="!b.assist && !b.legacy && onJobDragStart($event, b)" @dragend="onJobDragEnd" @click.stop="b.legacy ? openLegacyBlock(b) : (!b.assist && kbBlockClick(t))" @dblclick.stop="!b.legacy && kbBlockDbl(b, t)">
<div class="kbb-blk-l1"><q-icon :name="b.assist ? 'group' : (b.legacy ? 'confirmation_number' : skillSym(b.skill))" size="12px" :style="{ color: blkColor(b) }" /><span class="kbb-blk-t">{{ b.subject }}</span></div> <div class="kbb-blk-l1"><q-icon :name="blkIcon(b)" size="12px" :style="{ color: blkColor(b) }" /><span class="kbb-blk-t">{{ b.subject }}</span></div>
<div class="kbb-blk-l2">{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<span v-if="b.legacy"> · {{ b.dept }}</span><span v-else-if="b.customer"> · {{ b.customer }}</span><span v-if="b.assist"> · assistant</span></div> <div class="kbb-blk-l2">{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<span v-if="b.legacy"> · {{ b.dept }}</span><span v-else-if="b.customer"> · {{ b.customer }}</span><span v-if="b.assist"> · assistant</span></div>
<q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px"><template v-if="b.legacy">🗂 <b>{{ b.subject }}</b><br>{{ b.dept || 'osTicket' }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h · #{{ b.lid }}<br><span class="text-light-green-4"> Assigné dans F (autoritaire)</span><br><span class="text-blue-3">clic = ouvrir le ticket </span></template><template v-else>{{ b.subject }}<template v-if="b.customer"><br>{{ b.customer }}</template><template v-if="b.address"><br>📍 {{ b.address }}</template><template v-if="b.assist"><br>👥 assistant (renfort)</template><br>{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<template v-if="blkOpsOnly(b)"><br><span class="text-deep-orange-4"> Dispatché OPS pas encore publié dans F</span></template><template v-if="!b.assist"><br><span class="text-blue-3">clic droit = renvoyer au pool</span></template></template></q-tooltip> <q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px"><template v-if="b.legacy">🗂 <b>{{ b.subject }}</b><br>{{ b.dept || 'osTicket' }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h · #{{ b.lid }}<br><span class="text-light-green-4"> Assigné dans F (autoritaire)</span><br><span class="text-blue-3">clic = ouvrir le ticket </span></template><template v-else>{{ b.subject }}<template v-if="b.customer"><br>{{ b.customer }}</template><template v-if="b.address"><br>📍 {{ b.address }}</template><template v-if="b.assist"><br>👥 assistant (renfort)</template><br>{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<template v-if="blkOpsOnly(b)"><br><span class="text-deep-orange-4"> Dispatché OPS pas encore publié dans F</span></template><template v-if="!b.assist"><br><span class="text-blue-3">clic droit = renvoyer au pool</span></template></template></q-tooltip>
<q-menu v-if="!b.assist && !b.legacy" context-menu touch-position auto-close><q-list dense style="min-width:190px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool<q-item-label caption>désassigner ce tech</q-item-label></q-item-section></q-item><q-item clickable @click="openJobDetail(b, t)"><q-item-section avatar><q-icon name="open_in_full" color="grey-7" /></q-item-section><q-item-section>Détails / équipe</q-item-section></q-item></q-list></q-menu> <q-menu v-if="!b.assist && !b.legacy" context-menu touch-position auto-close><q-list dense style="min-width:190px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool<q-item-label caption>désassigner ce tech</q-item-label></q-item-section></q-item><q-item clickable @click="openJobDetail(b, t)"><q-item-section avatar><q-icon name="open_in_full" color="grey-7" /></q-item-section><q-item-section>Détails / équipe</q-item-section></q-item></q-list></q-menu>
@ -1082,6 +1070,21 @@
<q-btn flat round dense icon="close" v-close-popup /> <q-btn flat round dense icon="close" v-close-popup />
</q-card-section> </q-card-section>
<q-card-section class="q-pt-none"> <q-card-section class="q-pt-none">
<!-- Quart : STATIQUE par défaut ; « Modifier » révèle le curseur (édition accessible depuis le détail ouvert) -->
<div class="row items-center no-wrap q-mb-xs">
<q-icon name="schedule" size="16px" color="indigo-5" class="q-mr-xs" />
<span class="text-caption text-grey-7 q-mr-sm" style="min-width:34px">Quart</span>
<template v-if="!dayShiftEdit">
<span class="text-body2 text-weight-medium">{{ dayShiftLabel() }}</span>
<q-space />
<q-btn flat dense no-caps size="sm" icon="edit" :label="hasReg(dayEditor.tech && dayEditor.tech.id, dayEditor.day && dayEditor.day.iso) ? 'Modifier' : 'Ajouter'" color="indigo-5" @click="dayShiftEdit = true" />
</template>
<template v-else>
<q-range v-model="dayShift" :min="axisBounds.min" :max="axisBounds.max" :step="0.5" snap label :left-label-value="fmtH(dayShift.min) + 'h'" :right-label-value="fmtH(dayShift.max) + 'h'" color="indigo-5" class="col" style="margin:0 8px" @change="saveDayShift" />
<span class="text-caption text-grey-6 q-mr-xs" style="min-width:40px;text-align:right">{{ Math.round((dayShift.max - dayShift.min) * 10) / 10 }} h</span>
<q-btn flat dense round size="sm" icon="check" color="positive" @click="dayShiftEdit = false"><q-tooltip>Terminé</q-tooltip></q-btn>
</template>
</div>
<!-- timeline visuelle (réutilise les blocs colorés par compétence) --> <!-- timeline visuelle (réutilise les blocs colorés par compétence) -->
<div class="tldlg-bar" style="height:20px"> <div class="tldlg-bar" style="height:20px">
<div v-for="(b, bi) in dayBands()" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div> <div v-for="(b, bi) in dayBands()" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
@ -1123,6 +1126,7 @@
<div v-if="j.address || !hasLL(j)" class="ellipsis" style="font-size:11px;color:#90a4ae"><template v-if="j.address"><q-icon name="place" size="11px" /> {{ j.address }}</template><span v-if="!hasLL(j)" class="loc-pick-link text-deep-orange-7" @click.stop="openLocPicker(j)"> · sans coords 📍 situer</span></div> <div v-if="j.address || !hasLL(j)" class="ellipsis" style="font-size:11px;color:#90a4ae"><template v-if="j.address"><q-icon name="place" size="11px" /> {{ j.address }}</template><span v-if="!hasLL(j)" class="loc-pick-link text-deep-orange-7" @click.stop="openLocPicker(j)"> · sans coords 📍 situer</span></div>
</div> </div>
<div class="de-dur"><input type="number" min="5" step="5" :value="jobMinutes(j)" @change="setJobMinutes(j, $event.target.value)" @click.stop @mousedown.stop /><span>min</span></div> <div class="de-dur"><input type="number" min="5" step="5" :value="jobMinutes(j)" @change="setJobMinutes(j, $event.target.value)" @click.stop @mousedown.stop /><span>min</span></div>
<q-btn flat dense round size="sm" icon="open_in_new" color="indigo-5" @click.stop="openJobDetail(j, dayEditor.tech)"><q-tooltip>Détail du ticket (OPS) fil, équipe, client</q-tooltip></q-btn>
<!-- sélecteur de priorité retiré (défaut « Moyenne » conservé en donnée) gain de place ; priorité gérée au Dispatch --> <!-- sélecteur de priorité retiré (défaut « Moyenne » conservé en donnée) gain de place ; priorité gérée au Dispatch -->
<q-btn v-if="!j.actual_start" flat dense round size="sm" icon="play_arrow" color="green-6" @click="startJobChrono(j)"><q-tooltip>Démarrer (chrono réel apprentissage)</q-tooltip></q-btn> <q-btn v-if="!j.actual_start" flat dense round size="sm" icon="play_arrow" color="green-6" @click="startJobChrono(j)"><q-tooltip>Démarrer (chrono réel apprentissage)</q-tooltip></q-btn>
<q-btn v-else-if="!j.actual_end" flat dense round size="sm" icon="stop_circle" color="deep-orange" class="chrono-run" @click="finishJobChrono(j)"><q-tooltip>Terminer (chrono réel)</q-tooltip></q-btn> <q-btn v-else-if="!j.actual_end" flat dense round size="sm" icon="stop_circle" color="deep-orange" class="chrono-run" @click="finishJobChrono(j)"><q-tooltip>Terminer (chrono réel)</q-tooltip></q-btn>
@ -1528,7 +1532,8 @@ async function jdRemoveAssistant (techId) {
let _kbClickT = null let _kbClickT = null
function kbBlockClick (t) { clearTimeout(_kbClickT); _kbClickT = setTimeout(() => { if (kanbanDay.value) openDayFromCell(t, kanbanDay.value) }, 230) } // simple clic (différé) = éditeur de jour function kbBlockClick (t) { clearTimeout(_kbClickT); _kbClickT = setTimeout(() => { if (kanbanDay.value) openDayFromCell(t, kanbanDay.value) }, 230) } // simple clic (différé) = éditeur de jour
function kbBlockDbl (b, t) { clearTimeout(_kbClickT); openJobDetail(b, t) } // double-clic = volet détails function kbBlockDbl (b, t) { clearTimeout(_kbClickT); openJobDetail(b, t) } // double-clic = volet détails
// Clic (ou clic droit) sur le FOND d'une lane (hors job) MÊME menu que la grille (Jour/Soir/Garde/Absent/heures). // Clic DROIT sur le FOND d'une lane (hors job) menu de quart (Jour/Soir/Garde/Absent/heures + créer un quart).
// Clic GAUCHE sur le fond = éditeur de jour (liste complète + carte) voir le template (kbb-lane @click.self).
function onKbLaneMenu (t, ev) { if (!kanbanDay.value) return; openShiftMenu(t, kanbanDay.value, ev, -1, -1) } function onKbLaneMenu (t, ev) { if (!kanbanDay.value) return; openShiftMenu(t, kanbanDay.value, ev, -1, -1) }
// Drop d'une job sur une lane : assigne (onCellDrop hub) PUIS crée un quart 816 auto si le tech n'a aucun quart régulier ce jour. // Drop d'une job sur une lane : assigne (onCellDrop hub) PUIS crée un quart 816 auto si le tech n'a aucun quart régulier ce jour.
async function onKbDrop (ev, t) { async function onKbDrop (ev, t) {
@ -1703,12 +1708,23 @@ function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; retur
// Icône Material (offline, jeu Quasar par défaut) représentant une compétence/type par mots-clés, repli 'bolt'. // Icône Material (offline, jeu Quasar par défaut) représentant une compétence/type par mots-clés, repli 'bolt'.
// Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support, // Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support,
// outils=réparation ; sinon ligature classique unique (skillIcon). NB: les pins carte (HTML brut) gardent skillIcon. // outils=réparation ; sinon ligature classique unique (skillIcon). NB: les pins carte (HTML brut) gardent skillIcon.
function tSkills (t) { return [...(t.skills || [])].sort((a, b) => a.localeCompare(b)) } // compétences triées du tech (pour l'affichage 1 + « +N »)
function skillSym (sk) { function skillSym (sk) {
const s = String(sk || '').toLowerCase() const s = String(sk || '').toLowerCase()
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv' // télé/TV TV AVANT « install » (« Install/Reparation Télé » contient « install »)
if (/install/.test(s)) return symOutlinedToolsLadder if (/install/.test(s)) return symOutlinedToolsLadder
if (/support|service.?client|t[ée]l[ée]assist|\baide\b/.test(s)) return symOutlinedHeadsetMic if (/support|service.?client|t[ée]l[ée]assist|\baide\b/.test(s)) return symOutlinedHeadsetMic
return skillIcon(sk) // réparation → 'build' (clé simple / wrench) via skillIcon ; autres ligature unique return skillIcon(sk) // réparation → 'build' (clé simple / wrench) via skillIcon ; autres ligature unique
} }
// Icône d'un bloc de tournée (vue JOUR et SEMAINE) : renfort group ; sinon icône de COMPÉTENCE
// (skill/required_skill, ou dept pour les tickets legacy ex. « Install/Reparation Télé » = TV).
// Le ticket générique n'est utilisé qu'en dernier recours (legacy sans skill ni dept).
function blkIcon (b) {
if (b && b.assist) return 'group'
const sk = b && (b.skill || b.required_skill || b.dept)
if (sk) return skillSym(sk)
return (b && b.legacy) ? 'confirmation_number' : skillSym('')
}
function skillIcon (sk) { function skillIcon (sk) {
const s = String(sk || '').toLowerCase() const s = String(sk || '').toLowerCase()
if (/install/.test(s)) return 'construction' if (/install/.test(s)) return 'construction'
@ -2160,13 +2176,30 @@ function gotoDispatch (t, dateIso) {
// Éditeur de JOURNÉE (fenêtre contextuelle ciblée clic sur le progressbar) // Éditeur de JOURNÉE (fenêtre contextuelle ciblée clic sur le progressbar)
// Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job. // Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job.
const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null, travelMap: {}, routeReady: false }) const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null, travelMap: {}, routeReady: false })
const dayShift = ref({ min: 8, max: 16 }) // quart de l'éditeur de jour (q-range, révélé par « Modifier »)
const dayShiftEdit = ref(false) // false = affichage statique ; true = curseur d'édition révélé
function openDayEditor (t, d) { function openDayEditor (t, d) {
dayEditor.tech = t; dayEditor.day = d dayEditor.tech = t; dayEditor.day = d
const _w = winOf(t.id, d.iso, false); dayShift.value = _w ? { min: _w.s, max: _w.e } : { min: 8, max: 16 } // init la bande de quart depuis le shift régulier (sinon 8-16 par défaut)
dayShiftEdit.value = false // toujours rouvrir en mode STATIQUE
// RDV confirmé (ou heure légacy précise) = heure FIXE verrouillé ; sinon flexible (replanifiable par la tournée). // RDV confirmé (ou heure légacy précise) = heure FIXE verrouillé ; sinon flexible (replanifiable par la tournée).
dayEditor.list = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false })) dayEditor.list = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false }))
dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true; dayShowTrack.value = false dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true; dayShowTrack.value = false
loadDayRoute() // charge la matrice de temps routiers RÉELS (Mapbox) packedDay les utilise dès l'arrivée (réactif) loadDayRoute() // charge la matrice de temps routiers RÉELS (Mapbox) packedDay les utilise dès l'arrivée (réactif)
} }
// Quart MODIFIABLE depuis l'éditeur de jour : applique un gabarit d'heures (réutilise ensureWindowTpl + setCellReplace,
// comme la saisie rapide « 8-17 ») sur le tech/jour courant. Édition LOCALE « Publier » pour enregistrer.
async function saveDayShift () {
const min = dayShift.value.min; const max = dayShift.value.max
if (!dayEditor.tech || !dayEditor.day || !(max > min)) return
const tpl = await ensureWindowTpl(min, max)
if (tpl) { pushHistory(); setCellReplace(dayEditor.tech.id, dayEditor.tech.name, dayEditor.day.iso, tpl); $q.notify({ type: 'positive', message: `Quart ${fmtH(min)}h${fmtH(max)}h — publier pour enregistrer`, timeout: 2500 }) }
}
function dayShiftLabel () { // affichage statique du quart (lecture seule) dans l'éditeur de jour
const reg = dayEditor.tech && dayEditor.day && hasReg(dayEditor.tech.id, dayEditor.day.iso)
if (!reg) return 'Aucun quart planifié'
return `${fmtH(dayShift.value.min)}h${fmtH(dayShift.value.max)}h · ${Math.round((dayShift.value.max - dayShift.value.min) * 10) / 10} h`
}
// Tracé GPS réel (Traccar) SUPERPOSÉ sur la carte de l'éditeur de jour (jobs + itinéraire planifié déjà présents) // Tracé GPS réel (Traccar) SUPERPOSÉ sur la carte de l'éditeur de jour (jobs + itinéraire planifié déjà présents)
// permet de voir si le tech est réellement passé par ses jobs. Toggle dispo AUJOURD'HUI/HIER seulement (Traccar lourd). // permet de voir si le tech est réellement passé par ses jobs. Toggle dispo AUJOURD'HUI/HIER seulement (Traccar lourd).
const dayShowTrack = ref(false); const dayTrackMsg = ref('') const dayShowTrack = ref(false); const dayTrackMsg = ref('')
@ -2781,6 +2814,19 @@ function hasReg (techId, iso) { return cellsOf(techId, iso).some(a => { const t
function cellBlocks (techId, iso) { const o = cellOcc(techId, iso); return o ? o.blocks : [] } function cellBlocks (techId, iso) { const o = cellOcc(techId, iso); return o ? o.blocks : [] }
function cellOfficeN (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.officeN || 0) : 0 } // nb de tickets admin/bureau (non-terrain) assignés à ce tech ce jour function cellOfficeN (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.officeN || 0) : 0 } // nb de tickets admin/bureau (non-terrain) assignés à ce tech ce jour
function officeJobs (techId, iso) { const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null; return lg ? (lg.jobs || []).filter(j => !j.field) : [] } // détail des tickets admin (pour la pastille) function officeJobs (techId, iso) { const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null; return lg ? (lg.jobs || []).filter(j => !j.field) : [] } // détail des tickets admin (pour la pastille)
// Bloc « sans déplacement » (vue semaine) : un SEUL bloc pointillé par jour. Largeur = somme des estimés
// des tickets office (est_min ou duration_h), PLANCHER 1h dès qu'il y a au moins un ticket, sur l'échelle du timeline.
function officeBlockH (techId, iso) {
const jobs = officeJobs(techId, iso); if (!jobs.length) return 0
let h = 0; for (const j of jobs) h += (j.est_min ? j.est_min / 60 : Number(j.duration_h || 0))
return Math.max(1, h)
}
function officeBlockStyle (techId, iso) {
const h = officeBlockH(techId, iso); if (!h) return { display: 'none' }
const occ = cellOcc(techId, iso); const startH = (occ && occ.shiftS != null) ? occ.shiftS : 8
return pos(startH, startH + h) // ancré au DÉBUT du quart (8h) occupe le matin, pris en compte par le dispatch
}
function fmtOfficeH (techId, iso) { return fmtMin(Math.round(officeBlockH(techId, iso) * 60)) }
function cellGardeBands (techId, iso) { return cellBands(techId, iso).filter(b => b.oncall) } // garde seulement (la bande blanche du quart régulier vient de occCells.shiftS/E étendu) function cellGardeBands (techId, iso) { return cellBands(techId, iso).filter(b => b.oncall) } // garde seulement (la bande blanche du quart régulier vient de occCells.shiftS/E étendu)
function cellPct (techId, iso) { const o = cellOcc(techId, iso); return o ? o.pct : null } function cellPct (techId, iso) { const o = cellOcc(techId, iso); return o ? o.pct : null }
// Explique PRÉCISÉMENT pourquoi le triangle de surcharge apparaît (heures vs quart + débordements) pour le tooltip. // Explique PRÉCISÉMENT pourquoi le triangle de surcharge apparaît (heures vs quart + débordements) pour le tooltip.
@ -3132,7 +3178,11 @@ function onCellClick (t, d, ev, ti, di) {
activeCell.value = { id: t.id, name: t.name, iso: d.iso } // mémorise la case pour Cmd+C/V activeCell.value = { id: t.id, name: t.name, iso: d.iso } // mémorise la case pour Cmd+C/V
if (ev.shiftKey && anchor.value) { selectBlock(ti, di); return } if (ev.shiftKey && anchor.value) { selectBlock(ti, di); return }
if (ev.ctrlKey || ev.metaKey) { const k = t.id + '|' + d.iso; selection.value = selSet.value.has(k) ? selection.value.filter(x => x !== k) : [...selection.value, k]; anchor.value = { ti, di }; return } if (ev.ctrlKey || ev.metaKey) { const k = t.id + '|' + d.iso; selection.value = selSet.value.has(k) ? selection.value.filter(x => x !== k) : [...selection.value, k]; anchor.value = { ti, di }; return }
openShiftMenu(t, d, ev, ti, di) // Clic = LISTE DE TÂCHES du tech (tournée du jour) dès que la cellule a du contenu (quart, jobs terrain, ou tickets sans déplacement).
// Cellule vide = menu d'horaire (on garde la pose de quart au clic). La pose en lot reste : glisser-sélectionner + saisie rapide / clic droit.
const occ = cellOcc(t.id, d.iso)
if (hasReg(t.id, d.iso) || onGarde(t.id, d.iso) || (occ && (occ.noShift || occ.officeN))) openDayFromCell(t, d)
else openShiftMenu(t, d, ev, ti, di)
} }
// Clic sur la BANDE de quart/garde ou un BLOC de job ouvre la tournée du jour. Guardé contre les fins de drag. // Clic sur la BANDE de quart/garde ou un BLOC de job ouvre la tournée du jour. Guardé contre les fins de drag.
function openDayFromCell (t, d) { if (justDragged.value) { justDragged.value = false; return } activeCell.value = { id: t.id, name: t.name, iso: d.iso }; openDayEditor(t, d) } function openDayFromCell (t, d) { if (justDragged.value) { justDragged.value = false; return } activeCell.value = { id: t.id, name: t.name, iso: d.iso }; openDayEditor(t, d) }
@ -3377,6 +3427,8 @@ th.clk, td.clk { cursor: pointer; }
.skill-dot.has-lvl { border-radius: 8px; padding: 0 4px 0 2px; } .skill-dot.has-lvl { border-radius: 8px; padding: 0 4px 0 2px; }
.skill-dot .q-icon { color: #fff; } .skill-dot .q-icon { color: #fff; }
.sd-lvl { font-size: 9px; font-weight: 800; line-height: 1; color: #fff; text-shadow: 0 1px 1px rgba(0,0,0,.4); } .sd-lvl { font-size: 9px; font-weight: 800; line-height: 1; color: #fff; text-shadow: 0 1px 1px rgba(0,0,0,.4); }
.skill-more { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; font-size: 9px; font-weight: 800; color: #5a6678; background: #e8ebf0; box-shadow: inset 0 0 0 1px rgba(0,0,0,.10); cursor: pointer; flex-shrink: 0; } /* « +N » compétences restantes (détail au survol) */
.skill-more:hover { background: #dde1e8; }
/* Barre de charge AGRÉGÉE par tech sur les journées visibles (occupé / offrable) */ /* Barre de charge AGRÉGÉE par tech sur les journées visibles (occupé / offrable) */
.tech-load { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px; flex-shrink: 0; cursor: default; } .tech-load { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px; flex-shrink: 0; cursor: default; }
.tech-load-bar { width: 44px; height: 6px; border-radius: 3px; background: #e3e7ea; overflow: hidden; display: inline-block; } .tech-load-bar { width: 44px; height: 6px; border-radius: 3px; background: #e3e7ea; overflow: hidden; display: inline-block; }
@ -3403,6 +3455,10 @@ tr.res-hidden .hide-eye { opacity: 1; }
.offshift-warn { display: inline-flex; align-items: center; gap: 1px; font-size: 10px; font-weight: 700; color: #ef6c00; cursor: pointer; line-height: 1; } /* job assigné un jour sans quart publié */ .offshift-warn { display: inline-flex; align-items: center; gap: 1px; font-size: 10px; font-weight: 700; color: #ef6c00; cursor: pointer; line-height: 1; } /* job assigné un jour sans quart publié */
.office-dots { position: absolute; right: 2px; top: 2px; bottom: 2px; z-index: 2; display: flex; flex-direction: column; flex-wrap: wrap; align-content: flex-end; gap: 1.5px; cursor: pointer; } /* tickets SANS déplacement (admin/support/NP) = points ronds empilés (≈3-4 de haut puis nouvelle colonne vers la gauche), colorés par dept ; clic = liste cliquable, survol = aperçu ; n'occupent pas la charge */ .office-dots { position: absolute; right: 2px; top: 2px; bottom: 2px; z-index: 2; display: flex; flex-direction: column; flex-wrap: wrap; align-content: flex-end; gap: 1.5px; cursor: pointer; } /* tickets SANS déplacement (admin/support/NP) = points ronds empilés (≈3-4 de haut puis nouvelle colonne vers la gauche), colorés par dept ; clic = liste cliquable, survol = aperçu ; n'occupent pas la charge */
.office-dot { width: 5px; height: 5px; min-width: 5px; border-radius: 50%; opacity: .9; display: inline-block; box-shadow: 0 0 0 .5px rgba(255,255,255,.5); } .office-dot { width: 5px; height: 5px; min-width: 5px; border-radius: 50%; opacity: .9; display: inline-block; box-shadow: 0 0 0 .5px rgba(255,255,255,.5); }
/* tickets SANS déplacement (vue semaine) = 1 SEUL bloc POINTILLÉ ; largeur ∝ somme des estimés (plancher 1h) ; survol/clic = liste des tâches */
.office-blk { position: absolute; top: auto; bottom: 6px; height: 10px; z-index: 3; min-width: 7px; box-sizing: border-box; border-radius: 3px; cursor: pointer; border: 1px solid rgba(90,99,140,.5); background-image: repeating-conic-gradient(rgba(99,102,241,.26) 0% 25%, rgba(99,102,241,.06) 0% 50%); background-size: 7px 7px; display: flex; align-items: center; justify-content: center; } /* bande damier, DÉCOLLÉE du bas (≥5px) → laisse le haut de la cellule libre pour clic / drop ; l'alerte « pas de quart » passe par le fond ambre de la cellule (.cell.noshift) */
.office-blk:hover { border-color: rgba(79,70,229,.9); filter: brightness(1.05); }
.office-blk-ic { color: rgba(79,70,229,.7); }
.hdr-ruler { position: relative; height: 11px; margin-top: 3px; } .hdr-ruler { position: relative; height: 11px; margin-top: 3px; }
.hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; } .hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; }
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; } .hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }

View File

@ -162,6 +162,7 @@ import { formatDate } from 'src/composables/useFormatters'
import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses' import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
import { useDetailModal } from 'src/composables/useDetailModal' import { useDetailModal } from 'src/composables/useDetailModal'
import { statusOptions, priorityOptions, columns, buildFilters, getSortField } from 'src/config/ticket-config' import { statusOptions, priorityOptions, columns, buildFilters, getSortField } from 'src/config/ticket-config'
import { useAuthStore } from 'src/stores/auth'
import InterveneDialog from 'src/components/shared/InterveneDialog.vue' import InterveneDialog from 'src/components/shared/InterveneDialog.vue'
const intervene = ref({ open: false, name: '', title: '' }) const intervene = ref({ open: false, name: '', title: '' })
@ -170,8 +171,9 @@ import DetailModal from 'src/components/shared/DetailModal.vue'
import InlineField from 'src/components/shared/InlineField.vue' import InlineField from 'src/components/shared/InlineField.vue'
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue' import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
const me = useAuthStore().user // courriel de l'agent connecté (pour « Mes tickets »)
const search = ref('') const search = ref('')
const statusFilter = ref('all') const statusFilter = ref('not_closed') // défaut = NON FERMÉS évite les vieux tickets fermés (clients inactifs depuis des années)
const typeFilter = ref(null) const typeFilter = ref(null)
const priorityFilter = ref(null) const priorityFilter = ref(null)
const ownerFilter = ref('all') const ownerFilter = ref('all')
@ -198,6 +200,7 @@ async function loadTickets () {
priorityFilter: priorityFilter.value, priorityFilter: priorityFilter.value,
search: search.value, search: search.value,
ownerFilter: ownerFilter.value, ownerFilter: ownerFilter.value,
me,
}) })
const limit = Math.min(pagination.value.rowsPerPage, 100) const limit = Math.min(pagination.value.rowsPerPage, 100)
try { try {

View File

@ -45,6 +45,7 @@ const routes = [
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') }, { path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') }, { path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
{ path: 'historique', component: () => import('src/pages/HistoriquePage.vue') }, { path: 'historique', component: () => import('src/pages/HistoriquePage.vue') },
{ path: 'evaluations', component: () => import('src/pages/EvaluationsPage.vue') },
{ path: 'planification', component: () => import('src/pages/PlanificationPage.vue') }, { path: 'planification', component: () => import('src/pages/PlanificationPage.vue') },
{ path: 'conformite-adresses', component: () => import('src/pages/AddressConformityPage.vue') }, { path: 'conformite-adresses', component: () => import('src/pages/AddressConformityPage.vue') },
{ path: 'rdv', component: () => import('src/pages/RendezVousPage.vue') }, { path: 'rdv', component: () => import('src/pages/RendezVousPage.vue') },

View File

@ -0,0 +1,130 @@
# Prepaid Service Model — Design (design-first, not yet built)
Status: **DRAFT for review** · 2026-06-16 · grounded in an infra audit of the hub/ERPNext/F/RADIUS/OLT stack.
Goal: offer a **prepaid** internet service. When the paid-through date lapses, give a **3-day grace** (full speed), then **reduce speed**, then **disable** internet — all reversible instantly on top-up.
---
## 0. The critical reality (read this first)
**Today there is ZERO automated network enforcement.** Marking a service `Suspendu`/`disabled` in F or ERPNext only changes records and what staff see — no code cuts or restores a customer's internet. Verified across the whole hub + ops app + legacy bridge.
**The actual speed/access lever is RADIUS:**
- A FreeRADIUS + MySQL server at `10.5.2.25` (db `radiusdb`). Subscriber auth = `radcheck` (Cleartext-Password) + `radusergroup` (policy class). The group name (e.g. `residentiel`) is the **speed bucket** — FreeRADIUS reply attributes for that group define the rate limit.
- Per-service RADIUS creds live on the service record: legacy `service.radius_user`/`radius_pwd`, mirrored to ERPNext `Service Subscription.radius_user/radius_pwd`. ERPNext `speed_down`/`speed_up` are **descriptive only** (not enforced).
- **The hub has no RADIUS code at all** — no `radiusdb` connection, no CoA/Disconnect.
**Implication:** the prepaid *ledger + grace state machine* is straightforward to build in the hub/ERPNext. The hard part — and the real prerequisite — is a **RADIUS control plane**: write `radusergroup` + push a live change to the subscriber session (CoA/Disconnect) on the BNG/NAS. That actuator must be built, and the BNG/NAS that terminates PPPoE/IPoE sessions (for CoA) is **not identified anywhere in the repo** — only the FreeRADIUS DB host is known.
### Enforcement levers, ranked
| Lever | State today | Use |
|---|---|---|
| **RADIUS group reassign + CoA/Disconnect** | Not built; `radiusdb`@10.5.2.25 known, BNG unknown | **Primary** — matches how speed already works |
| OLT ONU service-profile (`profileid`) swap via n8n `dostuff` PATCH | Contract exists, not wired (hub only calls GET) | Secondary GPON-layer throttle |
| OLT ONU enable/disable | CLI strings generated (`provision.js`), not executed; SNMP read-only | Disable fallback via n8n SSH |
| TR-069 `setParameterValues` (rate-limit/disable WAN) | Raw `/tasks` proxy exists (`devices.js:523`); no caller; CPE param support unverified | Tertiary, if CPE model supports it |
| Modem GUI (modem-bridge) | Read-only Playwright scraper | Not a control lever as built |
---
## 1. Customer flow & state machine
```
top-up (any state) ─────────────────────────────┐
ACTIVE ──paid_through passes──▶ GRACE (3 d, full speed, notify) ──▶ THROTTLED ──▶ SUSPENDED
▲ (full speed) │ remind day 1/2/3 (slow group) (walled-garden / reject)
└──────────────────────── top-up restores group + CoA ◀───────────────────────────┘
```
- **ACTIVE**: paid_through_date in the future. Normal RADIUS group.
- **GRACE** (`0 < days_past 3`): full speed, escalating notifications (SMS + email + portal banner).
- **THROTTLED** (`3 < days_past disable_after`): RADIUS group `residentiel-throttle` (e.g. 1 Mbps) + CoA to apply live.
- **SUSPENDED** (`days_past > disable_after`): RADIUS → walled-garden/reject + Disconnect-Message.
- **Top-up** from any state: extend paid_through_date, restore full group, CoA → back to ACTIVE.
All transitions **idempotent** (act only on change) and **audited** (every customer-impacting action logged) — this is the hard lesson from the PPA double-charge incident.
---
## 2. Data model (the prepaid ledger — does not exist today)
New ERPNext doctype **`Prepaid Account`** (hub/OPS-owned; see §8 decision on F vs ERPNext):
- `customer` (link), `service` (link to Service Subscription), `radius_user` (denormalized for the actuator)
- `daily_rate` (derived from the plan price ÷ cycle, or a flat prepaid daily price)
- `paid_through_date` (the anchor — everything derives from this)
- `state` (Active / Grace / Throttled / Suspended)
- `grace_days` (default 3), `disable_after_days` (e.g. 10), `throttle_group`, `active_group`
- `last_enforced_state` + `last_enforced_at` (idempotency)
Top-ups recorded as ERPNext **Payment Entry** (existing, idempotent by `reference_no`) and **extend `paid_through_date`** by `amount / daily_rate`.
---
## 3. Enforcement actuator — new hub module `lib/service-control.js`
```
setGroup(radius_user, group) // UPDATE radusergroup.groupname (radiusdb @ 10.5.2.25)
coa(radius_user, action) // CoA (apply new policy) or Disconnect (drop session) → BNG/NAS
throttle(svc) / suspend(svc) / restore(svc) // setGroup + coa, idempotent + audited
```
- New env `RADIUS_DB_URL` (mysql @ 10.5.2.25/radiusdb) + `BNG_HOST`/`BNG_COA_SECRET` (OPEN — see §8).
- If live CoA is infeasible at first: change the group anyway; it applies on next re-auth/lease renewal (slower, but functional). CoA is the upgrade for instant effect.
- Fallbacks wired behind the same interface: OLT `profileid` swap (n8n `dostuff` PATCH) and TR-069 set-param.
---
## 4. Daily cron (gated, dry-run first)
Hub cron (once daily, low-traffic hour): for each Prepaid Account, compute `days_past = today paid_through_date`, derive target state, and **only act if it differs from `last_enforced_state`**. Emit notifications on entering GRACE (day 1/2/3), THROTTLED, SUSPENDED.
Safety (mandatory, per PPA-incident lessons):
- `PREPAID_ENFORCE` env: unset/`report` = log intended actions, change nothing; `enforce` = act. (Same pattern as `PAYMENTS_AUTH`.)
- Idempotency on `last_enforced_state`; never re-disable/re-charge.
- Per-run cap + kill switch; structured audit log of every state change.
---
## 5. Top-up (reuse existing Stripe flow — already usable)
- New `POST /prepaid/topup` (or reuse `/payments/checkout-product`): ad-hoc Stripe Checkout for $X. `ensureStripeCustomer` already reuses F's authoritative `cus_…`, so no double customer.
- On payment success (webhook → `recordPayment`, idempotent): credit balance → extend `paid_through_date` → if state was Throttled/Suspended, `restore()` (group + CoA).
- Saved-card off-session top-up possible via `POST /payments/charge` for auto-renew prepaid.
---
## 6. Read-side (extend what exists)
- `GET /collab/service-status` → add a `prepaid` block: `{state, balance_days, paid_through, daily_rate}`.
- Client `/diag/<jwt>` page → show "X days remaining / topped through DATE", state banner, **Top-up now** CTA.
---
## 7. Phasing (value early, risk late)
- **Phase 1 — ledger + top-up + reminders, NO enforcement** (SAFE, no network actions): `Prepaid Account` doctype, Stripe top-up, balance/days on service-status + `/diag`, grace/expiry notifications. Delivers prepaid billing + dunning immediately with zero risk.
- **Phase 2 — actuator in report mode**: `lib/service-control.js` connects to `radiusdb`, logs what it *would* change. Identify the BNG/NAS for CoA. No live changes.
- **Phase 3 — throttle (gated)**: define `residentiel-throttle` group attrs in FreeRADIUS; enable group reassign + CoA for the THROTTLED transition.
- **Phase 4 — disable (gated)**: walled-garden/reject group + Disconnect for SUSPENDED.
---
## 8. Open decisions (need your input before Phase 1)
1. **Balance home — ERPNext/hub-owned ledger (recommended) vs F.** F is authoritative for *postpaid* billing but has no prepaid concept; bolting prepaid onto legacy F is heavy. Recommend the hub/ERPNext owns the prepaid ledger, with prepaid services flagged distinctly. (This is a deliberate divergence from "F authoritative" — for a model F doesn't have.)
2. **The BNG/NAS** that terminates subscriber PPPoE/IPoE sessions — IP + CoA shared secret? Without it, enforcement relies on re-auth/lease expiry (minutes-to-hours lag) instead of instant CoA.
3. **Throttle target** — a new FreeRADIUS group (`residentiel-throttle`, e.g. 1 Mbps)? Its reply attributes must be defined on the RADIUS server.
4. **Disable UX** — hard reject (no internet) vs walled-garden (captive redirect to the top-up page). Walled-garden is far better UX but needs a captive portal.
5. **Windows** — grace = 3 d (given). Gap before disable? (e.g. throttle at d3, disable at d10.)
6. **Who is prepaid** — new signups only? Opt-in per service? A `billing_mode = prepaid|postpaid` flag on the service.
---
## Key files (for whoever builds this)
- `services/targo-hub/lib/payments.js` — Stripe (`ensureStripeCustomer`, `checkout-product`, `charge`, `recordPayment`); reuse for top-ups.
- `services/targo-hub/lib/ticket-collab.js``serviceStatus()` (read-side), `dostuffStatus()` (n8n `dostuff`, GET only).
- `services/targo-hub/lib/devices.js` — TR-069 `/tasks` passthrough (`:523`).
- `services/targo-hub/lib/olt-snmp.js` (read-only), `lib/provision.js` (OLT CLI gen, not executed), `lib/client-diag.js` (`/diag`).
- `docs/reference/legacy-wizard/account_wizard.php:142-169` — how RADIUS users/groups are provisioned today (the template for the actuator).
- RADIUS: FreeRADIUS + MySQL `radiusdb` @ `10.5.2.25` (creds in the legacy wizard). **BNG/NAS for CoA: unknown — must be located.**

View File

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;border:1px solid #e2e8f0"><tr><td style="background:#ffffff;padding:24px 28px 14px"><img src="https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png" alt="TARGO" width="132" style="width:132px;max-width:132px;height:auto;display:block;border:0"></td></tr><tr><td style="height:4px;background:#00C853;font-size:0;line-height:0;mso-line-height-rule:exactly">&nbsp;</td></tr><tr><td style="padding:28px 30px 4px"><h1 style="margin:0 0 10px;font-size:21px;font-weight:800;color:#0f172a">Your feedback matters</h1><p style="margin:0 0 4px;font-size:15px;line-height:1.6;color:#475569">Hi {{firstname}},</p><p style="margin:0;font-size:15px;line-height:1.6;color:#475569">Thanks for choosing TARGO. If you have a few seconds to rate us, it would mean a lot:</p></td></tr><tr><td style="padding:14px 24px 6px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#E6F9EE;border:1px solid #b6f0cf;border-radius:12px"><tr><td align="center" style="padding:14px 8px">{{rating}}</td></tr></table></td></tr><tr><td style="padding:8px 30px 26px"><p style="margin:0;font-size:14px;line-height:1.6;color:#64748b">Your feedback helps us serve you better. If something went wrong, just reply to this email — we're here to help.</p></td></tr><tr><td style="background:#f8fafc;padding:16px 30px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">TARGO — thank you for your trust</td></tr></table></td></tr></table></body></html>

View File

@ -0,0 +1,370 @@
{
"counters": {
"u_row": 2,
"u_column": 2,
"u_content_text": 5,
"u_content_image": 1,
"u_content_html": 2
},
"body": {
"id": "u_body",
"rows": [
{
"id": "u_row_1",
"cells": [
1
],
"columns": [
{
"id": "u_column_1",
"contents": [
{
"id": "u_content_image_1",
"type": "image",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "24px 30px 4px",
"anchor": "",
"src": {
"url": "https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png",
"width": 132,
"height": "auto",
"autoWidth": false,
"maxWidth": "132px"
},
"textAlign": "left",
"altText": "TARGO",
"action": {
"name": "web",
"values": {
"href": "",
"target": "_blank"
}
},
"_meta": {
"htmlID": "u_content_image_1",
"htmlClassNames": "u_content_image"
}
}
},
{
"id": "u_content_html_1",
"type": "html",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "0px",
"anchor": "",
"_meta": {
"htmlID": "u_content_html_1",
"htmlClassNames": "u_content_html"
},
"html": "<div style=\"height:4px;line-height:4px;font-size:0;background:#00C853\">&nbsp;</div>"
}
},
{
"id": "u_content_text_1",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "24px 30px 2px",
"anchor": "",
"fontWeight": 700,
"fontSize": "21px",
"color": "#0f172a",
"textAlign": "left",
"lineHeight": "130%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_1",
"htmlClassNames": "u_content_text"
},
"text": "Your feedback matters"
}
},
{
"id": "u_content_text_2",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "10px 30px 2px",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#475569",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_2",
"htmlClassNames": "u_content_text"
},
"text": "Hi {{firstname}},"
}
},
{
"id": "u_content_text_3",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "2px 30px 12px",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#475569",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_3",
"htmlClassNames": "u_content_text"
},
"text": "Thanks for choosing TARGO. If you have a few seconds to rate us, it would mean a lot:"
}
},
{
"id": "u_content_html_2",
"type": "html",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "6px 24px 8px",
"anchor": "",
"_meta": {
"htmlID": "u_content_html_2",
"htmlClassNames": "u_content_html"
},
"html": "<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#E6F9EE;border:1px solid #b6f0cf;border-radius:12px\"><tr><td align=\"center\" style=\"padding:14px 8px\">{{rating}}</td></tr></table>"
}
},
{
"id": "u_content_text_4",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "10px 30px 26px",
"anchor": "",
"fontWeight": 400,
"fontSize": "14px",
"color": "#64748b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_4",
"htmlClassNames": "u_content_text"
},
"text": "Your feedback helps us serve you better. If something went wrong, just reply to this email — we're here to help."
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_1",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#ffffff",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_1",
"htmlClassNames": "u_row"
}
}
},
{
"id": "u_row_2",
"cells": [
1
],
"columns": [
{
"id": "u_column_2",
"contents": [
{
"id": "u_content_text_5",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "16px 30px",
"anchor": "",
"fontWeight": 400,
"fontSize": "12px",
"color": "#94a3b8",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_5",
"htmlClassNames": "u_content_text"
},
"text": "TARGO — thank you for your trust"
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_2",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#f8fafc",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_2",
"htmlClassNames": "u_row"
}
}
}
],
"values": {
"popupPosition": "center",
"popupWidth": "600px",
"popupHeight": "auto",
"borderRadius": "10px",
"contentAlign": "center",
"contentVerticalAlign": "center",
"contentWidth": "600px",
"fontFamily": {
"label": "Arial",
"value": "arial,helvetica,sans-serif"
},
"textColor": "#1e293b",
"popupBackgroundColor": "#FFFFFF",
"backgroundColor": "#f4f6f8",
"preheaderText": "How would you rate your experience with TARGO?",
"linkStyle": {
"body": true,
"linkColor": "#00C853",
"linkHoverColor": "#005026",
"linkUnderline": true,
"linkHoverUnderline": true
},
"_meta": {
"htmlID": "u_body",
"htmlClassNames": "u_body"
}
}
},
"schemaVersion": 16
}

View File

@ -0,0 +1 @@
<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;border:1px solid #e2e8f0"><tr><td style="background:#ffffff;padding:24px 28px 14px"><img src="https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png" alt="TARGO" width="132" style="width:132px;max-width:132px;height:auto;display:block;border:0"></td></tr><tr><td style="height:4px;background:#00C853;font-size:0;line-height:0;mso-line-height-rule:exactly">&nbsp;</td></tr><tr><td style="padding:28px 30px 4px"><h1 style="margin:0 0 10px;font-size:21px;font-weight:800;color:#0f172a">Ton avis compte pour nous</h1><p style="margin:0 0 4px;font-size:15px;line-height:1.6;color:#475569">Bonjour {{firstname}},</p><p style="margin:0;font-size:15px;line-height:1.6;color:#475569">Merci de faire confiance à TARGO. Si tu as quelques secondes pour nous évaluer, ce serait vraiment apprécié :</p></td></tr><tr><td style="padding:14px 24px 6px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#E6F9EE;border:1px solid #b6f0cf;border-radius:12px"><tr><td align="center" style="padding:14px 8px">{{rating}}</td></tr></table></td></tr><tr><td style="padding:8px 30px 26px"><p style="margin:0;font-size:14px;line-height:1.6;color:#64748b">C'est grâce à ta rétroaction qu'on peut toujours mieux te servir. Si quelque chose ne va pas, réponds simplement à ce courriel — on est là pour t'aider.</p></td></tr><tr><td style="background:#f8fafc;padding:16px 30px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">TARGO — merci pour ta confiance</td></tr></table></td></tr></table></body></html>

View File

@ -0,0 +1,370 @@
{
"counters": {
"u_row": 2,
"u_column": 2,
"u_content_text": 5,
"u_content_image": 1,
"u_content_html": 2
},
"body": {
"id": "u_body",
"rows": [
{
"id": "u_row_1",
"cells": [
1
],
"columns": [
{
"id": "u_column_1",
"contents": [
{
"id": "u_content_image_1",
"type": "image",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "24px 30px 4px",
"anchor": "",
"src": {
"url": "https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png",
"width": 132,
"height": "auto",
"autoWidth": false,
"maxWidth": "132px"
},
"textAlign": "left",
"altText": "TARGO",
"action": {
"name": "web",
"values": {
"href": "",
"target": "_blank"
}
},
"_meta": {
"htmlID": "u_content_image_1",
"htmlClassNames": "u_content_image"
}
}
},
{
"id": "u_content_html_1",
"type": "html",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "0px",
"anchor": "",
"_meta": {
"htmlID": "u_content_html_1",
"htmlClassNames": "u_content_html"
},
"html": "<div style=\"height:4px;line-height:4px;font-size:0;background:#00C853\">&nbsp;</div>"
}
},
{
"id": "u_content_text_1",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "24px 30px 2px",
"anchor": "",
"fontWeight": 700,
"fontSize": "21px",
"color": "#0f172a",
"textAlign": "left",
"lineHeight": "130%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_1",
"htmlClassNames": "u_content_text"
},
"text": "Ton avis compte pour nous"
}
},
{
"id": "u_content_text_2",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "10px 30px 2px",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#475569",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_2",
"htmlClassNames": "u_content_text"
},
"text": "Bonjour {{firstname}},"
}
},
{
"id": "u_content_text_3",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "2px 30px 12px",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#475569",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_3",
"htmlClassNames": "u_content_text"
},
"text": "Merci de faire confiance à TARGO. Si tu as quelques secondes pour nous évaluer, ce serait vraiment apprécié :"
}
},
{
"id": "u_content_html_2",
"type": "html",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "6px 24px 8px",
"anchor": "",
"_meta": {
"htmlID": "u_content_html_2",
"htmlClassNames": "u_content_html"
},
"html": "<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#E6F9EE;border:1px solid #b6f0cf;border-radius:12px\"><tr><td align=\"center\" style=\"padding:14px 8px\">{{rating}}</td></tr></table>"
}
},
{
"id": "u_content_text_4",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "10px 30px 26px",
"anchor": "",
"fontWeight": 400,
"fontSize": "14px",
"color": "#64748b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_4",
"htmlClassNames": "u_content_text"
},
"text": "C'est grâce à ta rétroaction qu'on peut toujours mieux te servir. Si quelque chose ne va pas, réponds simplement à ce courriel — on est là pour t'aider."
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_1",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#ffffff",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_1",
"htmlClassNames": "u_row"
}
}
},
{
"id": "u_row_2",
"cells": [
1
],
"columns": [
{
"id": "u_column_2",
"contents": [
{
"id": "u_content_text_5",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "16px 30px",
"anchor": "",
"fontWeight": 400,
"fontSize": "12px",
"color": "#94a3b8",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_5",
"htmlClassNames": "u_content_text"
},
"text": "TARGO — merci pour ta confiance"
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_2",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#f8fafc",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_2",
"htmlClassNames": "u_row"
}
}
}
],
"values": {
"popupPosition": "center",
"popupWidth": "600px",
"popupHeight": "auto",
"borderRadius": "10px",
"contentAlign": "center",
"contentVerticalAlign": "center",
"contentWidth": "600px",
"fontFamily": {
"label": "Arial",
"value": "arial,helvetica,sans-serif"
},
"textColor": "#1e293b",
"popupBackgroundColor": "#FFFFFF",
"backgroundColor": "#f4f6f8",
"preheaderText": "Comment évaluerais-tu ton expérience avec TARGO ?",
"linkStyle": {
"body": true,
"linkColor": "#00C853",
"linkHoverColor": "#005026",
"linkUnderline": true,
"linkHoverUnderline": true
},
"_meta": {
"htmlID": "u_body",
"htmlClassNames": "u_body"
}
}
},
"schemaVersion": 16
}

View File

@ -41,6 +41,7 @@ const { log, json, parseBody } = require('./helpers')
const erp = require('./erp') const erp = require('./erp')
const email = require('./email') const email = require('./email')
const gmail = require('./gmail') const gmail = require('./gmail')
const rating = require('./rating')
const sse = require('./sse') const sse = require('./sse')
// MJML v5 is async — compile MJML source to email-safe HTML server-side at // MJML v5 is async — compile MJML source to email-safe HTML server-side at
// save time so the send-worker only ever reads pre-compiled HTML. // save time so the send-worker only ever reads pre-compiled HTML.
@ -1896,23 +1897,34 @@ async function handle (req, res, method, path) {
view_url: '', view_url: '',
...(body.vars || {}), ...(body.vars || {}),
} }
// Étoiles d'évaluation : {{rating}} n'est PAS une variable de campagne → renderTemplate
// le viderait (encart vide, cf. courriel test sans étoiles). On l'expanse ici en un vrai
// bloc tokenisé (cliquable) pour que le test montre les étoiles. No-op si pas de marqueur.
html = await rating.expandRatingMarker(html, { email: to, customerName: vars.firstname })
const rendered = renderTemplate(html, vars) const rendered = renderTemplate(html, vars)
const subject = body.subject || `[TEST] Template ${name}`
const via = String(body.via || '').toLowerCase()
const fromAddr = body.from || cfg.MAIL_FROM || 'TARGO <support@targointernet.com>' const fromAddr = body.from || cfg.MAIL_FROM || 'TARGO <support@targointernet.com>'
try { try {
let messageId = null; let usedFrom = fromAddr; let channel = 'mailjet'
if (via === 'gmail') {
// Envoi via le compte Gmail surveillé = MÊME canal que les vraies invitations
// (rating.js → conversation → gmail.sendMessage). From = compte Gmail par défaut
// (alias vérifié) si body.from absent.
channel = 'gmail'
const g = await gmail.sendMessage({ to, subject, body: '', html: rendered, from: body.from || undefined })
messageId = (g && g.id) || null
usedFrom = body.from || (typeof gmail.mailbox === 'function' ? gmail.mailbox() : 'compte Gmail')
} else {
const info = await email.sendEmail({ const info = await email.sendEmail({
to, to, from: fromAddr, subject, html: rendered,
from: fromAddr,
subject: body.subject || `[TEST] Template ${name}`,
html: rendered,
headers: { 'X-MJ-CustomID': `test-send:${name}:${Date.now()}` }, headers: { 'X-MJ-CustomID': `test-send:${name}:${Date.now()}` },
}) })
if (!info) return json(res, 500, { error: 'SMTP send returned false (see hub logs)' }) if (!info) return json(res, 500, { error: 'SMTP send returned false (see hub logs)' })
log(`test-send: ${name}${to} (${rendered.length} bytes)`) messageId = info.messageId || null
return json(res, 200, { }
sent: true, to, from: fromAddr, log(`test-send: ${name}${to} via ${channel} (${rendered.length} bytes)`)
message_id: info.messageId || null, return json(res, 200, { sent: true, to, from: usedFrom, channel, message_id: messageId, bytes: rendered.length })
bytes: rendered.length,
})
} catch (e) { } catch (e) {
return json(res, 500, { error: e.message }) return json(res, 500, { error: e.message })
} }
@ -1923,7 +1935,10 @@ async function handle (req, res, method, path) {
const tplPreview = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/preview$/) const tplPreview = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/preview$/)
if (tplPreview && method === 'POST') { if (tplPreview && method === 'POST') {
const body = await parseBody(req) const body = await parseBody(req)
const html = body.html || fs.readFileSync(templatePath(tplPreview[1]), 'utf8') let html = body.html || fs.readFileSync(templatePath(tplPreview[1]), 'utf8')
// Aperçu : montrer les étoiles {{rating}} avec un bloc d'exemple NON persistant (token
// factice « apercu » — pas de pollution de ratings.json ; l'envoi réel/test crée un vrai token).
html = html.replace(/\{\{\s*(rating|etoiles|évaluation|evaluation)\s*\}\}/gi, rating.ratingBlock('apercu'))
const sampleExpAt = new Date(Date.now() + 30 * 86400 * 1000) const sampleExpAt = new Date(Date.now() + 30 * 86400 * 1000)
.toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'America/Montreal' }) .toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'America/Montreal' })
const vars = { const vars = {
@ -2445,6 +2460,8 @@ rebuildTokenIndex()
module.exports = { module.exports = {
handle, handle,
handleGiftRedirect, handleGiftRedirect,
// Store d'images auto-hébergé réutilisable (pièces jointes courriel + bibliothèque d'équipements)
readUpload, persistUpload, decodeDataUrl, uploadUrl, deleteUpload, listUploads,
// Exposed for testing // Exposed for testing
parseCsv, parseMapCsv, parseGiftbitCsv, parseCsv, parseMapCsv, parseGiftbitCsv,
matchCustomer, normalizeCivic, normalizePhone, normalizePostal, matchCustomer, normalizeCivic, normalizePhone, normalizePostal,

View File

@ -9,6 +9,7 @@ module.exports = {
ERP_TOKEN: env('ERP_TOKEN'), ERP_TOKEN: env('ERP_TOKEN'),
ERP_SITE: env('ERP_SITE', 'erp.gigafibre.ca'), ERP_SITE: env('ERP_SITE', 'erp.gigafibre.ca'),
HUB_PUBLIC_URL: env('HUB_PUBLIC_URL', 'https://msg.gigafibre.ca'), HUB_PUBLIC_URL: env('HUB_PUBLIC_URL', 'https://msg.gigafibre.ca'),
GOOGLE_REVIEW_URL: env('GOOGLE_REVIEW_URL'), // page d'avis Google (clic 5★) — définir dans .env (https://search.google.com/local/writereview?placeid=…)
TWILIO_ACCOUNT_SID: env('TWILIO_ACCOUNT_SID'), TWILIO_ACCOUNT_SID: env('TWILIO_ACCOUNT_SID'),
TWILIO_AUTH_TOKEN: env('TWILIO_AUTH_TOKEN'), TWILIO_AUTH_TOKEN: env('TWILIO_AUTH_TOKEN'),
TWILIO_FROM: env('TWILIO_FROM'), TWILIO_FROM: env('TWILIO_FROM'),

View File

@ -61,20 +61,21 @@ function getConversation (token) {
return conv return conv
} }
function addMessage (conv, { from, text, type = 'text', via = 'web', media = '', html = '', agent = '', fromName = '', fromEmail = '', gmail_id = '' }) { function addMessage (conv, { from, text, type = 'text', via = 'web', media = '', html = '', agent = '', fromName = '', fromEmail = '', gmail_id = '', sendAs = '' }) {
const msg = { id: crypto.randomUUID(), from, text, type, via, media, ts: new Date().toISOString() } const msg = { id: crypto.randomUUID(), from, text, type, via, media, ts: new Date().toISOString() }
if (html) msg.html = html // courriel : HTML complet (rendu WYSIWYG iframe côté Ops) if (html) msg.html = html // courriel : HTML complet (rendu WYSIWYG iframe côté Ops)
if (from === 'agent' && agent) msg.agent = agent // identité de l'agent qui répond → stats « réponses par employé » (classement Inbox) if (from === 'agent' && agent) msg.agent = agent // identité de l'agent qui répond → stats « réponses par employé » (classement Inbox)
if (fromName) msg.fromName = fromName // expéditeur RÉEL (en-tête From) PAR message → fil multi-expéditeurs affiche le bon nom if (fromName) msg.fromName = fromName // expéditeur RÉEL (en-tête From) PAR message → fil multi-expéditeurs affiche le bon nom
if (fromEmail) msg.fromEmail = fromEmail if (fromEmail) msg.fromEmail = fromEmail
if (gmail_id) msg.gmail_id = gmail_id // mapping message↔Gmail (backfill / robustesse) if (gmail_id) msg.gmail_id = gmail_id // mapping message↔Gmail (backfill / robustesse)
if (from === 'agent' && sendAs) msg.sendAs = sendAs // identité d'expédition choisie (From) → lue par notifyCustomer
conv.messages.push(msg) conv.messages.push(msg)
if (!conv.initiatedBy) conv.initiatedBy = from // qui a OUVERT le fil : 'agent' = nous (notif/relance → l'IA n'auto-répond pas) ; 'customer' = demande entrante if (!conv.initiatedBy) conv.initiatedBy = from // qui a OUVERT le fil : 'agent' = nous (notif/relance → l'IA n'auto-répond pas) ; 'customer' = demande entrante
conv.lastActivity = msg.ts conv.lastActivity = msg.ts
saveToDisk() saveToDisk()
sse.broadcast('conv:' + conv.token, 'conv-message', { token: conv.token, message: msg }) sse.broadcast('conv:' + conv.token, 'conv-message', { token: conv.token, message: msg })
sse.broadcast('conv-client:' + conv.token, 'conv-message', { message: msg }) sse.broadcast('conv-client:' + conv.token, 'conv-message', { message: msg })
sse.broadcast('conversations', 'conv-message', { token: conv.token, customer: conv.customer, customerName: conv.customerName, message: msg }) sse.broadcast('conversations', 'conv-message', { token: conv.token, channel: conv.channel, customer: conv.customer, customerName: conv.customerName, message: msg })
return msg return msg
} }
@ -118,6 +119,25 @@ async function sendSmsFallback (conv, text, media) {
return success return success
} }
// Résout des réfs de pièces jointes [{asset, filename, mime}] → [{filename, mime, content(base64)}] depuis le store d'actifs.
// Garde-fous : max 10 pièces, ~20 Mo cumulés.
function resolveAttachments (list) {
if (!Array.isArray(list) || !list.length) return []
const campaigns = require('./campaigns')
const out = []; let total = 0
for (const a of list.slice(0, 10)) {
try {
if (!a || !a.asset) continue
const r = campaigns.readUpload(a.asset)
if (!r || !r.buffer) continue
total += r.buffer.length
if (total > 20 * 1024 * 1024) break
out.push({ filename: String(a.filename || a.asset).slice(0, 200), mime: a.mime || r.mime, content: r.buffer.toString('base64') })
} catch (e) { /* pièce illisible → ignorée */ }
}
return out
}
async function notifyCustomer (conv, message) { async function notifyCustomer (conv, message) {
// Conversation EMAIL → on RÉPOND par courriel dans le MÊME fil Gmail (pas SMS/push). // Conversation EMAIL → on RÉPOND par courriel dans le MÊME fil Gmail (pas SMS/push).
if (conv.channel === 'email' && conv.email) { if (conv.channel === 'email' && conv.email) {
@ -125,7 +145,8 @@ async function notifyCustomer (conv, message) {
const gmail = require('./gmail') const gmail = require('./gmail')
const base = conv.lastSubject || conv.subject || '' const base = conv.lastSubject || conv.subject || ''
const subj = withTicketTag(conv, /^re\s*:/i.test(base) ? base : ('Re: ' + base)) const subj = withTicketTag(conv, /^re\s*:/i.test(base) ? base : ('Re: ' + base))
const r = await gmail.sendMessage({ to: conv.email, subject: subj, body: message.text || '', html: message.html || '', threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: agentSendFrom(message.agent) }) const outHtml = await require('./rating').expandRatingMarker(message.html || '', conv)
const r = await gmail.sendMessage({ to: conv.email, subject: subj, body: message.text || '', html: outHtml, attachments: resolveAttachments(message.attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: resolveSendFrom(message.sendAs, message.agent) })
if (r && r.id) conv.lastGmailId = r.id if (r && r.id) conv.lastGmailId = r.id
log(`Email reply sent for conv ${conv.token}${conv.email} (gmail ${r.id})`) log(`Email reply sent for conv ${conv.token}${conv.email} (gmail ${r.id})`)
return 'email' return 'email'
@ -303,6 +324,27 @@ function agentSendFrom (email) {
const addr = (String(base).match(/<([^>]+)>/) || [null, base])[1].trim() const addr = (String(base).match(/<([^>]+)>/) || [null, base])[1].trim()
return `"${name.replace(/["<>]/g, '')}" <${addr}>` return `"${name.replace(/["<>]/g, '')}" <${addr}>`
} }
// ── Identités d'expédition (anonymisation « groupe » par DÉFAUT) ──────────────
// Avant : le client voyait le nom PERSONNEL de l'agent (« Louis » <support@targo.ca>).
// Maintenant : par défaut on expédie sous le GROUPE (« Support TARGO » <support@targo.ca>).
// L'agent peut choisir une autre identité (alias Gmail VÉRIFIÉ) ou 'personal' (signer de son nom).
// Adresses bornées aux alias vérifiés (sinon Gmail refuse). Configurable :
// GMAIL_SENDER_IDENTITIES (JSON [{name,address}]) + GMAIL_DEFAULT_SENDER.
function parseIdentities (s) { try { const a = JSON.parse(s); return Array.isArray(a) && a.length ? a.filter(x => x && x.address) : null } catch { return null } }
const SENDER_IDENTITIES = parseIdentities(process.env.GMAIL_SENDER_IDENTITIES) || [
{ name: 'Support TARGO', address: 'support@targo.ca' },
{ name: 'Facturation TARGO', address: 'facturation@targo.ca' },
]
function identityToFrom (i) { if (!i || !i.address) return ''; const n = String(i.name || '').replace(/["<>\r\n]/g, '').trim(); return n ? `"${n}" <${i.address}>` : String(i.address) }
const SEND_ALIASES = SENDER_IDENTITIES.map(i => String(i.address).toLowerCase())
const DEFAULT_SENDER = process.env.GMAIL_DEFAULT_SENDER || identityToFrom(SENDER_IDENTITIES[0]) || require('./gmail').sendFrom()
// From sortant : 'personal' = nom de l'agent ; "Nom <alias vérifié>" = tel quel ; sinon défaut groupe.
function resolveSendFrom (from, agentEmail) {
const v = String(from || '').trim()
if (v === 'personal') return agentSendFrom(agentEmail) || DEFAULT_SENDER
if (v) { const addr = (v.match(/<([^>]+)>/) || [null, v])[1].trim().toLowerCase(); if (SEND_ALIASES.includes(addr)) return v.replace(/[\r\n]/g, '') }
return DEFAULT_SENDER
}
function ingestGuard (email, subject) { function ingestGuard (email, subject) {
const s = String(subject || '') const s = String(subject || '')
const e = String(email || '').trim() const e = String(email || '').trim()
@ -421,6 +463,8 @@ function listConversations ({ includeAll = false } = {}) {
d.gmailReplyUrl = gmailReplyUrl(latest) d.gmailReplyUrl = gmailReplyUrl(latest)
d.noise = !!(latest && latest.noise) d.noise = !!(latest && latest.noise)
d.readBy = (latest && latest.readBy) || {} // liste : « vu par » + détection non-lu par soi d.readBy = (latest && latest.readBy) || {} // liste : « vu par » + détection non-lu par soi
d.draftAgent = (latest && latest.draft && latest.draft.agent) || null // brouillon en cours → pastille « X rédige » + filtre Brouillons (au chargement)
d.draftTs = (latest && latest.draft && latest.draft.ts) || null
} }
return { conversations: out, discussions: Object.values(discussions).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)) } return { conversations: out, discussions: Object.values(discussions).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)) }
@ -747,7 +791,7 @@ async function channelIngest ({ channel, external_id, from, name, text, html, cu
} catch (e) { /* triage best-effort */ } } catch (e) { /* triage best-effort */ }
if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.subject).catch(() => {}) if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.subject).catch(() => {})
saveToDisk() saveToDisk()
sse.broadcast('conversations', 'conv-message', { token: conv.token, customer: conv.customer, customerName: conv.customerName, message: msg }) sse.broadcast('conversations', 'conv-message', { token: conv.token, channel: conv.channel, customer: conv.customer, customerName: conv.customerName, message: msg })
if (conv.queue && !conv.noise && !conv.assignee) notifyQueue(conv).catch(() => {}) // file notifiée → personne n'est « propriétaire » unique if (conv.queue && !conv.noise && !conv.assignee) notifyQueue(conv).catch(() => {}) // file notifiée → personne n'est « propriétaire » unique
return { ok: true, token: conv.token, channel: ch } return { ok: true, token: conv.token, channel: ch }
} }
@ -767,7 +811,7 @@ async function sendSms ({ phone, customer, customerName, message, media } = {})
} }
// Compose un NOUVEAU courriel SORTANT (≠ réponse) : envoie via Gmail + crée/append une conversation EMAIL suivie. // Compose un NOUVEAU courriel SORTANT (≠ réponse) : envoie via Gmail + crée/append une conversation EMAIL suivie.
async function sendNewEmail ({ to, subject, body, html, customer, customerName, agentEmail } = {}) { async function sendNewEmail ({ to, cc, bcc, subject, body, html, attachments, customer, customerName, agentEmail, sendAs } = {}) {
const triage = require('./inbox-triage') const triage = require('./inbox-triage')
const dest = triage.extractEmail(to) || String(to || '').trim() const dest = triage.extractEmail(to) || String(to || '').trim()
if (!dest || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'destinataire courriel invalide' } if (!dest || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'destinataire courriel invalide' }
@ -775,11 +819,12 @@ async function sendNewEmail ({ to, subject, body, html, customer, customerName,
let conv = findConversationByEmail(dest) let conv = findConversationByEmail(dest)
if (!conv) { conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || dest, subject: subject || 'Courriel' }); conv.channel = 'email'; conv.email = dest } if (!conv) { conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || dest, subject: subject || 'Courriel' }); conv.channel = 'email'; conv.email = dest }
// réponse dans le fil si la conversation existe déjà (threadId connu) // réponse dans le fil si la conversation existe déjà (threadId connu)
const r = await gmail.sendMessage({ to: dest, subject: withTicketTag(conv, subject || conv.lastSubject || '(sans objet)'), body: body || '', html: html || '', threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: agentSendFrom(agentEmail) }) const outHtml = await require('./rating').expandRatingMarker(html || '', conv)
const r = await gmail.sendMessage({ to: dest, cc: cc || '', bcc: bcc || '', subject: withTicketTag(conv, subject || conv.lastSubject || '(sans objet)'), body: body || '', html: outHtml, attachments: resolveAttachments(attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: resolveSendFrom(sendAs, agentEmail) })
if (r && r.threadId) conv.threadId = r.threadId if (r && r.threadId) conv.threadId = r.threadId
if (r && r.id) conv.lastGmailId = r.id if (r && r.id) conv.lastGmailId = r.id
conv.lastSubject = subject || conv.lastSubject conv.lastSubject = subject || conv.lastSubject
const m = addMessage(conv, { from: 'agent', text: (body || ''), via: 'email', html: html || '', agent: agentEmail || '' }) const m = addMessage(conv, { from: 'agent', text: (body || ''), via: 'email', html: html || '', agent: agentEmail || '', cc: cc || '' })
if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, m, subject).catch(() => {}) if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, m, subject).catch(() => {})
conv.lastHumanDate = todayET(); saveToDisk() conv.lastHumanDate = todayET(); saveToDisk()
log(`Nouveau courriel sortant → ${dest} (gmail ${r && r.id})`) log(`Nouveau courriel sortant → ${dest} (gmail ${r && r.id})`)
@ -1043,8 +1088,44 @@ async function handle (req, res, method, p, url) {
if (p === '/conversations' && method === 'GET') if (p === '/conversations' && method === 'GET')
return json(res, 200, listConversations({ includeAll: url.searchParams.get('all') === '1' })) return json(res, 200, listConversations({ includeAll: url.searchParams.get('all') === '1' }))
// GET /conversations/for-customer?customer=&email=&phone= — fils (email+SMS) d'UN client pour la fiche (timeline customer-360)
if (p === '/conversations/for-customer' && method === 'GET') {
const cust = (url.searchParams.get('customer') || '').trim()
const email = (url.searchParams.get('email') || '').trim().toLowerCase()
const phone = (url.searchParams.get('phone') || '').replace(/\D/g, '').slice(-10)
if (!cust && !email && !phone) return json(res, 200, { discussions: [] })
const { discussions } = listConversations({ includeAll: true })
const mine = discussions.filter(d =>
(cust && d.customer === cust) ||
(email && String(d.email || '').toLowerCase() === email) ||
(phone && String(d.phone || '').replace(/\D/g, '').slice(-10) === phone))
return json(res, 200, { discussions: mine })
}
// GET /conversations/leaderboard?from=YYYY-MM-DD&to=YYYY-MM-DD — classement des agents Inbox (reconnaissance / motivation). // GET /conversations/leaderboard?from=YYYY-MM-DD&to=YYYY-MM-DD — classement des agents Inbox (reconnaissance / motivation).
// « pris » = conversations dont l'agent est assigné (claim, via assigneeAt) ; « réponses » = messages agent horodatés à son nom (stamping msg.agent — historique avant stamping = unattributed). // « pris » = conversations dont l'agent est assigné (claim, via assigneeAt) ; « réponses » = messages agent horodatés à son nom (stamping msg.agent — historique avant stamping = unattributed).
// Identités d'expédition pour le sélecteur « De : » (défaut groupe + alias vérifiés).
if (p === '/conversations/senders' && method === 'GET') {
return json(res, 200, { default: DEFAULT_SENDER, identities: SENDER_IDENTITIES.map(i => ({ name: i.name || '', address: i.address, from: identityToFrom(i) })) })
}
// Rafraîchir À LA DEMANDE : force un pull Gmail immédiat (≠ fetchList qui ne lit que l'état hub déjà ingéré).
if (p === '/conversations/poll-now' && method === 'POST') {
try { const r = await require('./gmail').poll(); return json(res, 200, { ok: true, scanned: (r && r.scanned) || 0, ingested: (r && r.ingested) || 0 }) } catch (e) { return json(res, 200, { ok: false, error: e.message }) }
}
// Préférences de notification PAR UTILISATEUR (clé = courriel agent) : activer/désactiver chaque feed (cloche).
if (p === '/conversations/notif-prefs' && (method === 'GET' || method === 'PUT')) {
const NOTIF_DEFAULTS = { sms: true, webchat: true, '3cx': true, facebook: true, email: false, ratings: true }
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
const all = readJsonFile('/app/data/notif_prefs.json', {})
if (method === 'PUT') {
if (!email) return json(res, 400, { error: 'utilisateur inconnu' })
const b = await parseBody(req); const inp = (b && b.prefs) || {}
const clean = {}; for (const k of Object.keys(NOTIF_DEFAULTS)) clean[k] = typeof inp[k] === 'boolean' ? inp[k] : NOTIF_DEFAULTS[k]
all[email] = clean; writeJsonFile('/app/data/notif_prefs.json', all)
return json(res, 200, { ok: true, prefs: clean })
}
return json(res, 200, { prefs: { ...NOTIF_DEFAULTS, ...(all[email] || {}) }, defaults: NOTIF_DEFAULTS })
}
if (p === '/conversations/leaderboard' && method === 'GET') { if (p === '/conversations/leaderboard' && method === 'GET') {
const to = url.searchParams.get('to') || new Date().toISOString().slice(0, 10) const to = url.searchParams.get('to') || new Date().toISOString().slice(0, 10)
const from = url.searchParams.get('from') || new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10) const from = url.searchParams.get('from') || new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10)
@ -1201,6 +1282,32 @@ async function handle (req, res, method, p, url) {
const r = await analyzePaymentProof(token, b) const r = await analyzePaymentProof(token, b)
return json(res, r.ok ? 200 : 400, r) return json(res, r.ok ? 200 : 400, r)
} }
// Pièces jointes (PDF/JPG) du fil — pour le trombone. Lues à la demande via Gmail puis mises en cache sur le message.
if (sub === 'attachments' && method === 'GET') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const map = {}; let fetched = false
for (const m of (conv.messages || [])) {
if (!m.gmail_id) continue
if (m._att === undefined) {
try { const gm = await gmail.getMessage(m.gmail_id); m._att = (gm.attachments || []).map(a => ({ filename: a.filename, mimeType: a.mimeType, attachmentId: a.attachmentId, size: a.size })); fetched = true } catch (e) { m._att = [] }
}
if (m._att && m._att.length) map[m.id] = m._att.map(a => ({ ...a, gmail_id: m.gmail_id }))
}
if (fetched) saveToDisk()
return json(res, 200, { attachments: map })
}
// Sert une pièce jointe en ligne (clic trombone / source pour la Vision). mime/name fournis par le client (issus de la liste).
if (sub === 'attachment' && method === 'GET') {
const gid = url.searchParams.get('gmail_id') || ''; const att = url.searchParams.get('att') || ''
const mime = url.searchParams.get('mime') || 'application/octet-stream'
const fname = (url.searchParams.get('name') || 'document').replace(/[^\w.\- ]/g, '_')
if (!gid || !att) return json(res, 400, { error: 'gmail_id + att requis' })
try {
const buf = Buffer.from(String(await gmail.getAttachment(gid, att) || '').replace(/-/g, '+').replace(/_/g, '/'), 'base64')
res.writeHead(200, { 'Content-Type': mime, 'Content-Disposition': 'inline; filename="' + fname + '"', 'Cache-Control': 'private, max-age=300' })
return res.end(buf)
} catch (e) { return json(res, 502, { error: 'Gmail: ' + e.message }) }
}
// RÉSUMÉ IA du fil (Gemini) — « Summarise this email » de Gmail, pour l'agent. // RÉSUMÉ IA du fil (Gemini) — « Summarise this email » de Gmail, pour l'agent.
if (sub === 'summarize' && (method === 'POST' || method === 'GET')) { if (sub === 'summarize' && (method === 'POST' || method === 'GET')) {
const r = await summarizeConversation(token) const r = await summarizeConversation(token)
@ -1285,6 +1392,25 @@ async function handle (req, res, method, p, url) {
sse.broadcast('conversations', 'conv-note', { token, note }) // note INTERNE (jamais envoyée au client) → remplace le reply-all « je m'en occupe » sse.broadcast('conversations', 'conv-note', { token, note }) // note INTERNE (jamais envoyée au client) → remplace le reply-all « je m'en occupe »
return json(res, 200, { ok: true, note }) return json(res, 200, { ok: true, note })
} }
if (sub === 'note' && method === 'PATCH') { // corriger une note (faute de frappe…)
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req); const id = String(b.id || ''); const text = String(b.text || '').trim()
if (!id || !text) return json(res, 400, { error: 'id + text requis' })
const note = (conv.notes || []).find(n => n.id === id); if (!note) return json(res, 404, { error: 'Note introuvable' })
note.text = text; note.edited = new Date().toISOString(); saveToDisk()
sse.broadcast('conversations', 'conv-note-upd', { token, note })
return json(res, 200, { ok: true, note })
}
if (sub === 'note' && method === 'DELETE') { // supprimer une note (erreur)
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req); const id = String(b.id || ''); if (!id) return json(res, 400, { error: 'id requis' })
const before = (conv.notes || []).length
conv.notes = (conv.notes || []).filter(n => n.id !== id)
if (conv.notes.length === before) return json(res, 404, { error: 'Note introuvable' })
saveToDisk()
sse.broadcast('conversations', 'conv-note-del', { token, id })
return json(res, 200, { ok: true, id })
}
if (sub === 'nl' && method === 'POST') { // commande en langage naturel (copilote) if (sub === 'nl' && method === 'POST') { // commande en langage naturel (copilote)
const b = await parseBody(req) const b = await parseBody(req)
const r = await nlCommand(token, b.command || '', req.headers['x-authentik-email'] || '') const r = await nlCommand(token, b.command || '', req.headers['x-authentik-email'] || '')
@ -1306,7 +1432,13 @@ async function handle (req, res, method, p, url) {
// Rattacher la conversation à une fiche client choisie (résout le cas multi-fiches). // Rattacher la conversation à une fiche client choisie (résout le cas multi-fiches).
if (sub === 'link' && method === 'POST') { if (sub === 'link' && method === 'POST') {
const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' }) const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
const b = await parseBody(req); if (!b.customer) return json(res, 400, { error: 'customer requis' }) const b = await parseBody(req)
if (b.unlink || b.customer === '') { // délier la fiche → réaffiche « Lier une fiche »
conv.customer = ''; conv.customerName = ''; conv.candidates = null; saveToDisk()
sse.broadcast('conversations', 'conv-update', { token: conv.token, customer: '', customerName: '' })
return json(res, 200, { ok: true, customer: '', customerName: '' })
}
if (!b.customer) return json(res, 400, { error: 'customer requis' })
conv.customer = String(b.customer); conv.customerName = b.customerName || String(b.customer); conv.candidates = null conv.customer = String(b.customer); conv.customerName = b.customerName || String(b.customer); conv.candidates = null
// Compte lié → on suggère de PROMOUVOIR le fil en ticket (le bouton apparaît dans l'Inbox ; clic = import du fil + réponses [Ticket #N]). // Compte lié → on suggère de PROMOUVOIR le fil en ticket (le bouton apparaît dans l'Inbox ; clic = import du fil + réponses [Ticket #N]).
if (!conv.suggestedTicket && !(conv.linkedTickets && conv.linkedTickets.length)) { if (!conv.suggestedTicket && !(conv.linkedTickets && conv.linkedTickets.length)) {
@ -1371,12 +1503,13 @@ async function handle (req, res, method, p, url) {
const text = (body.text || '').trim() const text = (body.text || '').trim()
const media = body.media || '' const media = body.media || ''
const html = body.html || '' // réponse WYSIWYG (canal email) const html = body.html || '' // réponse WYSIWYG (canal email)
if (!text && !media && !html) return json(res, 400, { error: 'Missing text or media' }) const attachments = Array.isArray(body.attachments) ? body.attachments : []
if (!text && !media && !html && !attachments.length) return json(res, 400, { error: 'Missing text or media' })
const agentEmail = req.headers['x-authentik-email'] const agentEmail = req.headers['x-authentik-email']
const from = agentEmail ? 'agent' : 'customer' const from = agentEmail ? 'agent' : 'customer'
const isEmail = conv.channel === 'email' const isEmail = conv.channel === 'email'
if (from === 'agent') conv.lastHumanDate = todayET() // humain (agent OPS) actif aujourd'hui → l'IA se tait ce jour (posé AVANT addMessage pour être persisté par son saveToDisk) if (from === 'agent') conv.lastHumanDate = todayET() // humain (agent OPS) actif aujourd'hui → l'IA se tait ce jour (posé AVANT addMessage pour être persisté par son saveToDisk)
const msg = addMessage(conv, { from, text, via: isEmail ? 'email' : 'web', type: media ? 'image' : 'text', media, html, agent: agentEmail || '' }) const msg = addMessage(conv, { from, text, via: isEmail ? 'email' : 'web', type: media ? 'image' : 'text', media, html, agent: agentEmail || '', attachments, sendAs: body.sendAs || '' })
if (from === 'agent') msg.notifiedVia = await notifyCustomer(conv, msg) // email → envoi HTML dans le fil ; sinon push/SMS if (from === 'agent') msg.notifiedVia = await notifyCustomer(conv, msg) // email → envoi HTML dans le fil ; sinon push/SMS
if (from === 'customer' && conv.customer && !isEmail) triggerAgent(conv) // pas d'auto-réponse IA sur courriel if (from === 'customer' && conv.customer && !isEmail) triggerAgent(conv) // pas d'auto-réponse IA sur courriel
if (isEmail && conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.lastSubject).catch(() => {}) // chaîne : alimente le ticket lié if (isEmail && conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.lastSubject).catch(() => {}) // chaîne : alimente le ticket lié

View File

@ -111,29 +111,55 @@ const htmlToText = (html) => String(html || '')
.replace(/<!--[\s\S]*?-->/g, ' ') // commentaires HTML, incl. conditionnels MSO (<o:PixelsPerInch>96</o:…>) .replace(/<!--[\s\S]*?-->/g, ' ') // commentaires HTML, incl. conditionnels MSO (<o:PixelsPerInch>96</o:…>)
.replace(/<head[\s\S]*?<\/head>/gi, ' ') // <head> entier (style/meta/xml/title) — hors du texte lisible .replace(/<head[\s\S]*?<\/head>/gi, ' ') // <head> entier (style/meta/xml/title) — hors du texte lisible
.replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<br\s*\/?>(?=)/gi, '\n').replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim() .replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<br\s*\/?>(?=)/gi, '\n').replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
function buildRfc822 ({ to, subject, body, html, inReplyTo, references, from }) { // Encode une LISTE d'adresses séparées par des virgules (« a@x, Nom <b@y> ») — chaque adresse en RFC 2047.
const h = ['From: ' + encodeAddress(from || sendFrom()), 'To: ' + encodeAddress(to), 'Subject: ' + encodeHeader(subject || ''), 'MIME-Version: 1.0'] function encodeAddressList (s) { return String(s || '').split(',').map(x => x.trim()).filter(Boolean).map(encodeAddress).join(', ') }
if (inReplyTo) h.push('In-Reply-To: ' + inReplyTo) // Entité MIME du CORPS (sans en-têtes d'enveloppe) : multipart/alternative si HTML, sinon texte simple.
if (references || inReplyTo) h.push('References: ' + (references || inReplyTo)) function bodyEntity ({ body, html }) {
if (html) { // multipart/alternative : repli texte + HTML (déliverabilité + clients sans HTML) if (html) { // repli texte + HTML (déliverabilité + clients sans HTML)
const bnd = 'alt_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) const bnd = 'alt_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
h.push('Content-Type: multipart/alternative; boundary="' + bnd + '"')
const plain = body || htmlToText(html) const plain = body || htmlToText(html)
const parts = [ return [
'Content-Type: multipart/alternative; boundary="' + bnd + '"', '',
'--' + bnd, 'Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', plain, '', '--' + bnd, 'Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', plain, '',
'--' + bnd, 'Content-Type: text/html; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', html, '', '--' + bnd, 'Content-Type: text/html; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', html, '',
'--' + bnd + '--', '', '--' + bnd + '--',
] ].join('\r\n')
}
return ['Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', (body || '')].join('\r\n')
}
// Une pièce jointe { filename, mime, content(base64) } → partie MIME pour multipart/mixed.
function attachmentPart (a, bnd) {
const fn = encodeHeader(String(a.filename || 'piece').replace(/["\r\n]/g, ''))
const b64 = String(a.content || '').replace(/[^A-Za-z0-9+/=]/g, '').replace(/(.{76})/g, '$1\r\n')
return [
'--' + bnd,
'Content-Type: ' + (a.mime || 'application/octet-stream') + '; name="' + fn + '"',
'Content-Transfer-Encoding: base64',
'Content-Disposition: attachment; filename="' + fn + '"', '',
b64,
].join('\r\n')
}
function buildRfc822 ({ to, cc, bcc, subject, body, html, attachments, inReplyTo, references, from }) {
const h = ['From: ' + encodeAddress(from || sendFrom()), 'To: ' + encodeAddressList(to)]
if (cc) h.push('Cc: ' + encodeAddressList(cc))
if (bcc) h.push('Bcc: ' + encodeAddressList(bcc))
h.push('Subject: ' + encodeHeader(subject || ''), 'MIME-Version: 1.0')
if (inReplyTo) h.push('In-Reply-To: ' + inReplyTo)
if (references || inReplyTo) h.push('References: ' + (references || inReplyTo))
const atts = Array.isArray(attachments) ? attachments.filter(a => a && a.content) : []
if (atts.length) { // pièces jointes → multipart/mixed { corps, pièce(s) }
const mix = 'mix_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
h.push('Content-Type: multipart/mixed; boundary="' + mix + '"')
const parts = ['--' + mix, bodyEntity({ body, html }), ...atts.map(a => attachmentPart(a, mix)), '--' + mix + '--', '']
return h.join('\r\n') + '\r\n\r\n' + parts.join('\r\n') return h.join('\r\n') + '\r\n\r\n' + parts.join('\r\n')
} }
h.push('Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit') return h.join('\r\n') + '\r\n' + bodyEntity({ body, html })
return h.join('\r\n') + '\r\n\r\n' + (body || '')
} }
// Envoie (ou répond, si threadId/inReplyTo) un courriel DEPUIS la boîte impersonnée. `html` => multipart/alternative. // Envoie (ou répond, si threadId/inReplyTo) un courriel DEPUIS la boîte impersonnée. `html` => multipart/alternative.
async function sendMessage ({ to, subject, body, html, threadId, inReplyTo, references, from } = {}) { async function sendMessage ({ to, cc, bcc, subject, body, html, attachments, threadId, inReplyTo, references, from } = {}) {
if (!to) throw new Error('destinataire requis') if (!to) throw new Error('destinataire requis')
const tok = await getToken() const tok = await getToken()
const payload = { raw: b64url(buildRfc822({ to, subject, body, html, inReplyTo, references, from })) } const payload = { raw: b64url(buildRfc822({ to, cc, bcc, subject, body, html, attachments, inReplyTo, references, from })) }
if (threadId) payload.threadId = threadId if (threadId) payload.threadId = threadId
const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/send', { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/send', { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
const j = await res.json().catch(() => ({})) const j = await res.json().catch(() => ({}))

View File

@ -165,6 +165,8 @@ function mapAccount (a) {
ppa_enabled: Number(a.ppa) ? 1 : 0, // pont PHP renvoie "0"/"1" (string) → Number() requis ("0" est truthy en JS) ppa_enabled: Number(a.ppa) ? 1 : 0, // pont PHP renvoie "0"/"1" (string) → Number() requis ("0" est truthy en JS)
is_commercial: Number(a.commercial) ? 1 : 0, is_commercial: Number(a.commercial) ? 1 : 0,
is_bad_payer: Number(a.mauvais_payeur) ? 1 : 0, is_bad_payer: Number(a.mauvais_payeur) ? 1 : 0,
mandataire: clean(a.mandataire) || null, // représentant autorisé (titulaire ≠ payeur/écrivain) — recherchable
contact_name_legacy: clean(a.contact) || null, // personne-contact détaillée
_status: Number(a.status), _status: Number(a.status),
} }
} }
@ -192,7 +194,7 @@ async function fResiliatedAccountIds () {
} }
// Champs texte : politique ADD / CHANGE / NEVER-CLOBBER (cf. preview 2026-06-11). // Champs texte : politique ADD / CHANGE / NEVER-CLOBBER (cf. preview 2026-06-11).
const TEXT_FIELDS = ['customer_name', 'customer_type', 'customer_group', 'language', 'email_id', 'email_billing', 'tel_home', 'cell_phone', 'stripe_id'] const TEXT_FIELDS = ['customer_name', 'customer_type', 'customer_group', 'language', 'email_id', 'email_billing', 'tel_home', 'cell_phone', 'stripe_id', 'mandataire', 'contact_name_legacy']
// Drapeaux booléens SYNCHRONISÉS (F autoritaire, vont dans « à réviser »). // Drapeaux booléens SYNCHRONISÉS (F autoritaire, vont dans « à réviser »).
const BOOL_FIELDS = ['ppa_enabled', 'is_commercial', 'is_bad_payer'] const BOOL_FIELDS = ['ppa_enabled', 'is_commercial', 'is_bad_payer']
// Statut client = DÉRIVÉ des abonnements (Service Subscription) — affiché en divergence // Statut client = DÉRIVÉ des abonnements (Service Subscription) — affiché en divergence
@ -263,7 +265,7 @@ async function previewCustomers ({ sinceDateLast = 0, sinceId = 0, source = 'mir
} else { } else {
const p = pool(); if (!p) throw new Error('miroir legacy-db indisponible (mysql2 absent)') const p = pool(); if (!p) throw new Error('miroir legacy-db indisponible (mysql2 absent)')
const where = Number(sinceDateLast) > 0 ? ` WHERE date_last > ${Number(sinceDateLast)}` : '' const where = Number(sinceDateLast) > 0 ? ` WHERE date_last > ${Number(sinceDateLast)}` : ''
;[rows] = await p.query('SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, stripe_id, ppa, date_last FROM account' + where) ;[rows] = await p.query('SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, contact, mandataire, stripe_id, ppa, date_last FROM account' + where)
} }
const maxDateLast = rows.reduce((m, r) => Math.max(m, Number(r.date_last) || 0), Number(sinceDateLast) || 0) const maxDateLast = rows.reduce((m, r) => Math.max(m, Number(r.date_last) || 0), Number(sinceDateLast) || 0)
const maxId = rows.reduce((m, r) => Math.max(m, Number(r.id) || 0), Number(sinceId) || 0) const maxId = rows.reduce((m, r) => Math.max(m, Number(r.id) || 0), Number(sinceId) || 0)
@ -521,7 +523,7 @@ async function createCustomersByIds ({ ids = [], confirm = false, limit = 2000 }
const accts = [] const accts = []
for (let i = 0; i < missing.length; i += 1000) { for (let i = 0; i < missing.length; i += 1000) {
const b = missing.slice(i, i + 1000) const b = missing.slice(i, i + 1000)
const [rows] = await p.query(`SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, stripe_id, ppa FROM account WHERE id IN (${b.map(() => '?').join(',')})`, b) const [rows] = await p.query(`SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, contact, mandataire, stripe_id, ppa FROM account WHERE id IN (${b.map(() => '?').join(',')})`, b)
accts.push(...rows) accts.push(...rows)
} }
if (confirm !== 'F-WINS') return { dry_run: true, requested: ids.length, missing: missing.length, would_create: accts.length, sample: accts.slice(0, 8).map(a => ({ legacy_account_id: Number(a.id), customer_name: mapAccount(a).customer_name })) } if (confirm !== 'F-WINS') return { dry_run: true, requested: ids.length, missing: missing.length, would_create: accts.length, sample: accts.slice(0, 8).map(a => ({ legacy_account_id: Number(a.id), customer_name: mapAccount(a).customer_name })) }
@ -575,7 +577,7 @@ async function applyAccountChange ({ legacy_account_id, fields = null, confirm =
legacy_account_id = Number(legacy_account_id) legacy_account_id = Number(legacy_account_id)
if (!legacy_account_id) throw new Error('legacy_account_id requis') if (!legacy_account_id) throw new Error('legacy_account_id requis')
const p = pool(); if (!p) throw new Error('F indisponible') const p = pool(); if (!p) throw new Error('F indisponible')
const [rows] = await p.query('SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, stripe_id, ppa FROM account WHERE id=?', [legacy_account_id]) const [rows] = await p.query('SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, contact, mandataire, stripe_id, ppa FROM account WHERE id=?', [legacy_account_id])
if (!rows.length) throw new Error('compte F introuvable') if (!rows.length) throw new Error('compte F introuvable')
const m = mapAccount(rows[0]) const m = mapAccount(rows[0])
const PROTECT = new Set(['customer_name']) // nettoyé à la migration, jamais réécrit par le brut F const PROTECT = new Set(['customer_name']) // nettoyé à la migration, jamais réécrit par le brut F

View File

@ -0,0 +1,269 @@
'use strict'
/**
* Évaluation client par étoiles (review-gating) insérable dans un courriel via le marqueur {{rating}}.
*
* - Bloc 5 étoiles : chaque étoile = un lien <a> (les courriels n'exécutent pas de JS).
* Survol = remplit les étoiles à GAUCHE (truc CSS reverse-order + direction:rtl + `a:hover ~ a`).
* Marche dans l'aperçu du composeur Ops + les clients qui gardent <style>/:hover ; dégrade en étoiles
* cliquables ailleurs (le clic redirige toujours).
* - Clic : GET /rate?t=<token>&s=<1-5> enregistre + redirige.
* s == 5 page d'avis Google (GOOGLE_REVIEW_URL)
* s < 5 page interne « merci + qu'est-ce qu'on pourrait améliorer ? » (capte le mécontentement EN PRIVÉ).
* - Le marqueur {{rating}} est remplacé à l'ENVOI (côté hub) par le bloc, avec un token par destinataire.
*/
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { log, json, parseBody, readJsonFile, writeJsonFile, lookupCustomersByEmail } = require('./helpers')
const sse = require('./sse')
const FILE = '/app/data/ratings.json'
const TPL_DIR = path.join(__dirname, '..', 'templates')
const pub = () => cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'
const googleUrl = () => cfg.GOOGLE_REVIEW_URL || 'https://search.google.com/local/writereview?placeid='
function load () { const d = readJsonFile(FILE, {}); return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {} }
function save (m) { writeJsonFile(FILE, m) }
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
// Crée un jeton d'évaluation (1 par destinataire) → mappe vers {customer, conv, email}.
function newRatingToken ({ customer = '', conv = '', email = '', name = '', lang = '' } = {}) {
const token = crypto.randomBytes(9).toString('base64url')
const m = load()
m[token] = { customer, conv, email, name, lang: normLang(lang), created: new Date().toISOString(), stars: null, stars_ts: null, comment: null, comment_ts: null }
save(m)
return token
}
// Bloc « étoiles cliquables » compatible courriel (technique lang-hack + entités Unicode &#9733;/&#9734; — aucune police externe).
// ORDRE garanti PARTOUT : <table dir="rtl"> (attribut HTML) + cellules DOM 5→1 → visuellement GAUCHE=1 (mauvais → page feedback) … DROITE=5 (excellent → Google).
// SURVOL (remplir à gauche) : <style> dédié (le hack `lang` survit mieux au filtrage Gmail que class/id) ; dégrade en étoiles vides cliquables + chiffres 1-5 partout.
function ratingBlock (token) {
const base = `${pub()}/rate?t=${encodeURIComponent(token)}`
const cell = (i) => `<td width="48" class="star-wrapper" lang="x-star-wrapper" style="padding:0;vertical-align:top">`
+ `<div style="display:block;text-align:center;float:left;width:48px;overflow:hidden;line-height:54px">`
+ `<a href="${base}&s=${i}" target="_blank" rel="noopener" class="star" lang="x-star-divbox" title="${i} étoile${i > 1 ? 's' : ''} sur 5" style="font-family:'Segoe UI Symbol','Apple Symbols','Noto Sans Symbols','Arial Unicode MS',Arial,sans-serif;color:#f5b50a;text-decoration:none;display:inline-block;height:46px;width:48px;overflow:hidden;line-height:54px">`
+ `<div lang="x-empty-star" style="margin:0;display:inline-block">&#9734;</div>`
+ `<div lang="x-full-star" style="margin:0;display:none;width:0;height:0;max-height:0;overflow:hidden;float:left">&#9733;</div>`
+ `</a>`
+ `<a href="${base}&s=${i}" target="_blank" rel="noopener" class="star-number" lang="x-star-number" style="font-family:sans-serif;color:#9aa0a6;font-size:13px;line-height:13px;text-decoration:none;display:block;width:48px;text-align:center;border-bottom:3px solid #ffffff">${i}</a>`
+ `</div></td>`
const cells = [5, 4, 3, 2, 1].map(cell).join('')
const hover = `<style type="text/css">`
+ `* [lang~="x-star-wrapper"]:hover *[lang~="x-star-number"]{color:#00C853 !important;border-color:#00C853 !important}`
+ `* [lang~="x-star-wrapper"]:hover *[lang~="x-full-star"],* [lang~="x-star-wrapper"]:hover ~ *[lang~="x-star-wrapper"] *[lang~="x-full-star"]{display:block !important;width:auto !important;height:auto !important;max-height:none !important;overflow:visible !important;float:none !important}`
+ `* [lang~="x-star-wrapper"]:hover *[lang~="x-empty-star"],* [lang~="x-star-wrapper"]:hover ~ *[lang~="x-star-wrapper"] *[lang~="x-empty-star"]{display:none !important}`
+ `@media (max-width:480px){* [lang~="x-star-wrapper"]:hover *[lang~="x-full-star"]{display:none !important}* [lang~="x-star-wrapper"]:hover *[lang~="x-empty-star"]{display:inline-block !important}}`
+ `</style>`
return hover + `<table role="presentation" cellpadding="0" cellspacing="0" border="0" dir="rtl" style="border-collapse:collapse;border-spacing:0;width:240px;margin:8px auto;font-size:44px;direction:rtl"><tbody><tr>${cells}</tr></tbody></table>`
}
// Remplace le marqueur {{rating}} / {{etoiles}} dans le HTML sortant par un bloc tokenisé pour CE destinataire.
async function expandRatingMarker (html, conv = {}) {
const s = String(html || '')
if (!/\{\{\s*(rating|etoiles|évaluation|evaluation)\s*\}\}/i.test(s)) return s
// Une demande d'évaluation s'adresse à un client CONNU → on garantit le lien à la fiche.
// Si la conversation n'est pas liée (conv.customer vide), on résout la fiche par COURRIEL.
let customer = conv.customer || ''
let name = conv.customerName || ''
if (!customer && conv.email) {
try { const r = await lookupCustomersByEmail(conv.email, 1); if (r && r[0]) { customer = r[0].name; if (!name) name = r[0].customer_name || '' } } catch (e) { /* lookup best-effort */ }
}
const token = newRatingToken({ customer, conv: conv.token || '', email: conv.email || '', name })
return s.replace(/\{\{\s*(rating|etoiles|évaluation|evaluation)\s*\}\}/gi, ratingBlock(token))
}
// Page brandée Gigafibre (vert #00C853, encre #1B2E24, Plus Jakarta Sans) — même esprit que gigafibre.ca.
function shell (title, bodyHtml) {
return `<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{--g:#00C853;--ink:#1B2E24;--muted:#64748B;--line:#e6ebe8}
*{box-sizing:border-box}
body{margin:0;font-family:'Plus Jakarta Sans',-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:var(--ink);background:linear-gradient(165deg,#E6F9EE 0%,#F5FAF7 40%,#ffffff 100%);min-height:100vh}
.wrap{max-width:560px;margin:0 auto;padding:7vh 20px 40px}
.brand{display:flex;align-items:center;gap:9px;font-weight:800;font-size:1.4rem;letter-spacing:-.6px;margin-bottom:26px}
.brand .dot{width:12px;height:12px;border-radius:50%;background:var(--g);box-shadow:0 0 0 4px rgba(0,200,83,.18)}
.brand b{color:var(--g)}
.card{background:#fff;border:1px solid #eef2f0;border-radius:22px;box-shadow:0 18px 50px rgba(27,46,36,.10);padding:34px 30px}
h1{font-size:1.5rem;font-weight:800;letter-spacing:-.5px;margin:0 0 10px}
p{color:var(--muted);line-height:1.6;font-size:1rem;margin:0 0 8px}
.stars{font-size:2.1rem;letter-spacing:5px;color:#f5b50a;margin:2px 0 16px}
textarea{width:100%;border:1.5px solid var(--line);border-radius:14px;padding:14px 16px;font:inherit;font-size:1rem;min-height:130px;resize:vertical;background:#fcfdfc;transition:border-color .15s,box-shadow .15s}
textarea:focus{outline:none;border-color:var(--g);box-shadow:0 0 0 4px rgba(0,200,83,.15)}
button{margin-top:16px;background:var(--g);color:#06351d;border:0;border-radius:14px;padding:14px 26px;font:inherit;font-weight:700;font-size:1rem;cursor:pointer;box-shadow:0 8px 20px rgba(0,200,83,.28);transition:filter .15s,transform .08s}
button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}
.ok{color:var(--g);font-weight:700;margin-top:16px;font-size:1.05rem}
.foot{text-align:center;color:#9aa7a0;font-size:.8rem;margin-top:22px}
</style></head>
<body><div class="wrap">
<div class="brand"><img src="https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png" alt="TARGO" style="height:34px;width:auto;display:block" /></div>
<div class="card">${bodyHtml}</div>
<div class="foot">TARGO merci pour votre confiance</div>
</div></body></html>`
}
function feedbackPage (token, stars) {
const filled = '★'.repeat(Math.max(0, stars)) + '☆'.repeat(Math.max(0, 5 - stars))
return shell('Merci pour votre évaluation', `
<div class="stars">${filled}</div>
<h1>Merci pour votre évaluation&nbsp;!</h1>
<p>On vise toujours le 5&nbsp;étoiles. En quelques mots, qu'est-ce qui n'a pas été à la hauteur ou comment pourrait-on faire mieux&nbsp;? Votre réponse va directement à notre équipe.</p>
<form id="rf" onsubmit="return sendComment(event)">
<textarea id="rc" name="comment" placeholder="Votre commentaire (optionnel, mais très apprécié)…"></textarea>
<button type="submit">Envoyer mon commentaire</button>
</form>
<div id="done" class="ok" style="display:none"> Merci&nbsp;! C'est transmis à notre équipe on vous revient au besoin.</div>
<script>
function sendComment(e){e.preventDefault();var f=document.getElementById('rf');var c=(f.comment.value||'').trim();if(!c){f.comment.focus();return false;}var b=f.querySelector('button');b.disabled=true;fetch('/rate/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:${JSON.stringify(token)},comment:c})}).then(function(){f.style.display='none';document.getElementById('done').style.display='block';}).catch(function(){b.disabled=false;});return false;}
</script>`)
}
// ── Invitation d'évaluation bilingue (FR/EN) : renvoi courriel/SMS + page d'étoiles autonome (lien SMS) ──
// Invitation TUTOIEMENT (« tu ») — plus chaleureux/efficace que le vouvoiement.
const INVITE = {
fr: { subject: 'Ton avis compte pour nous', title: 'Ton avis compte pour nous', lead: 'Comment évaluerais-tu ton expérience avec TARGO ?',
greet: (n) => n ? `Bonjour ${n},` : 'Bonjour,',
intro: "Merci de faire confiance à TARGO. Si tu as quelques secondes pour nous évaluer, ce serait vraiment apprécié :",
outro: "C'est grâce à ta rétroaction qu'on peut toujours mieux te servir. Si quelque chose ne va pas, réponds simplement à ce courriel — on est là pour t'aider.",
foot: 'TARGO — merci pour ta confiance',
sms: "Merci d'avoir choisi TARGO ! Évalue ton expérience en quelques secondes : " },
en: { subject: 'Your feedback matters to us', title: 'Your feedback matters', lead: 'How would you rate your experience with TARGO?',
greet: (n) => n ? `Hi ${n},` : 'Hi,',
intro: 'Thanks for choosing TARGO. If you have a few seconds to rate us, it would mean a lot:',
outro: "Your feedback helps us serve you better. If something went wrong, just reply to this email — we're here to help.",
foot: 'TARGO — thank you for your trust',
sms: 'Thanks for choosing TARGO! Rate your experience in seconds: ' },
}
const INVITE_LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png'
function normLang (l) { return /^en/i.test(String(l || '')) ? 'en' : 'fr' }
function firstName (n) { return String(n || '').trim().split(/\s+/)[0] || '' }
function inviteSubject (lang) { return INVITE[normLang(lang)].subject }
// Courriel d'invitation MIS EN PAGE (même charte que le transactionnel d'excuse : carte blanche 600px + logo + pied),
// rehaussé d'un filet vert TARGO et des étoiles dans un encart vert. `stars` = ratingBlock(token) OU le marqueur {{rating}}.
function inviteEmailDesign (lang, name, stars) {
const t = INVITE[normLang(lang)]
return `<!doctype html><html lang="${normLang(lang)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>`
+ `<body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px">`
+ `<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;border:1px solid #e2e8f0">`
+ `<tr><td style="background:#ffffff;padding:24px 28px 14px"><img src="${INVITE_LOGO}" alt="TARGO" width="132" style="width:132px;max-width:132px;height:auto;display:block;border:0"></td></tr>`
+ `<tr><td style="height:4px;background:#00C853;font-size:0;line-height:0;mso-line-height-rule:exactly">&nbsp;</td></tr>`
+ `<tr><td style="padding:28px 30px 4px"><h1 style="margin:0 0 10px;font-size:21px;font-weight:800;color:#0f172a">${esc(t.title)}</h1>`
+ `<p style="margin:0 0 4px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)))}</p>`
+ `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.intro)}</p></td></tr>`
+ `<tr><td style="padding:14px 24px 6px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#E6F9EE;border:1px solid #b6f0cf;border-radius:12px"><tr><td align="center" style="padding:14px 8px">${stars}</td></tr></table></td></tr>`
+ `<tr><td style="padding:8px 30px 26px"><p style="margin:0;font-size:14px;line-height:1.6;color:#64748b">${esc(t.outro)}</p></td></tr>`
+ `<tr><td style="background:#f8fafc;padding:16px 30px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">${esc(t.foot)}</td></tr>`
+ `</table></td></tr></table></body></html>`
}
// Modèle ÉDITABLE DANS UNLAYER : templates/transactional-rating-invite-<lang>.html (envoyé tel quel),
// avec marqueurs {{firstname}} (prénom du destinataire) + {{rating}} (bloc étoiles tokenisé à l'envoi).
// Repli sur le design codé en dur (inviteEmailDesign) si le fichier est absent/illisible.
// PAS de cache : une modif sauvegardée dans Unlayer (qui réécrit le .html) prend effet sans redémarrer le hub.
function loadInviteTpl (lang) {
try { return fs.readFileSync(path.join(TPL_DIR, `transactional-rating-invite-${normLang(lang)}.html`), 'utf8') } catch { return null }
}
function mergeName (html, name) {
const fn = esc(firstName(name))
return String(html).replace(/\s*\{\{\s*firstname\s*\}\}/gi, fn ? ' ' + fn : '')
}
function inviteEmailHtml (token, lang, name) {
const tpl = loadInviteTpl(lang)
if (!tpl) return inviteEmailDesign(lang, name, ratingBlock(token))
return mergeName(tpl, name).replace(/\{\{\s*rating\s*\}\}/gi, ratingBlock(token))
}
function inviteEmailMarkerHtml (lang, name) {
const tpl = loadInviteTpl(lang)
if (!tpl) return inviteEmailDesign(lang, name, '{{rating}}')
return mergeName(tpl, name) // {{rating}} laissé en place → expansé à l'envoi par expandRatingMarker
}
function inviteSmsText (token, lang) { return INVITE[normLang(lang)].sms + `${pub()}/rate/start?t=${encodeURIComponent(token)}` }
function invitePage (token, lang) { const t = INVITE[normLang(lang)]; return shell(t.lead, `<h1>${esc(t.lead)}</h1><p>${esc(t.intro)}</p>${ratingBlock(token)}`) }
// ── Relance « écrire au client » (suite à un avis, surtout mauvais) — brouillon bilingue, PAS de lien d'évaluation ──
const FOLLOWUP = {
fr: { subject: 'Suite à votre évaluation',
html: (n) => `<p>Bonjour${n ? ' ' + n : ''},</p><p>Merci d'avoir pris le temps de nous évaluer. On aimerait mieux comprendre votre expérience et voir comment on peut s'améliorer.</p><p>N'hésitez pas à répondre à ce courriel — on est là pour vous aider.</p>`,
sms: (n) => `Bonjour${n ? ' ' + n : ''}, merci pour votre évaluation. On aimerait mieux comprendre votre expérience — comment peut-on vous aider ?` },
en: { subject: 'Following up on your review',
html: (n) => `<p>Hi${n ? ' ' + n : ''},</p><p>Thank you for taking the time to rate us. We'd like to better understand your experience and see how we can improve.</p><p>Feel free to reply to this email — we're here to help.</p>`,
sms: (n) => `Hi${n ? ' ' + n : ''}, thanks for your review. We'd like to understand your experience better — how can we help?` },
}
function feedbackSubject (lang) { return FOLLOWUP[normLang(lang)].subject }
function feedbackHtml (name, lang) { return FOLLOWUP[normLang(lang)].html(name || '') }
function feedbackSms (name, lang) { return FOLLOWUP[normLang(lang)].sms(name || '') }
async function handle (req, res, method, path, url) {
try {
// GET /rate?t=&s= — enregistre la note + redirige (5 → Google, sinon → feedback interne)
if (path === '/rate' && method === 'GET') {
const token = url.searchParams.get('t') || ''
const s = Math.max(0, Math.min(5, parseInt(url.searchParams.get('s'), 10) || 0))
// Anti-prefetch : les scanners de sécurité / pré-chargeurs de liens (Gmail, Proofpoint, Slackbot…) GETtent les liens
// et FAUSSERAIENT la note. On n'enregistre PAS sur ces requêtes ; un vrai clic humain n'a pas ces signaux. (On redirige quand même.)
const ua = String(req.headers['user-agent'] || '')
const isPrefetch = /prefetch|preview/i.test(String(req.headers.purpose || req.headers['x-purpose'] || req.headers['x-moz'] || '')) ||
/bot|crawler|spider|scan|preview|proofpoint|mimecast|barracuda|googleimageproxy|google-read-aloud|slackbot|facebookexternalhit|whatsapp|bingbot|linkpreview|curl|wget|python-requests|headless/i.test(ua)
const m = load(); const rec = m[token]
if (rec && !isPrefetch) {
// Filet : si le lien fiche manque (conv non liée à l'envoi), on résout par courriel au moment du clic.
if (!rec.customer && rec.email) { try { const cr = await lookupCustomersByEmail(rec.email, 1); if (cr && cr[0]) { rec.customer = cr[0].name; if (!rec.name) rec.name = cr[0].customer_name || '' } } catch (e) { /* */ } }
const changed = rec.stars !== s
if (changed) { // re-notation permise → historique (évolution) + note courante mise à jour à chaque clic
rec.history = (rec.history || []).slice(-19)
rec.history.push({ stars: s, ts: new Date().toISOString() })
rec.stars = s; rec.stars_ts = new Date().toISOString()
}
save(m)
if (changed) {
log(`Rating ${s}★ — customer=${rec.customer || '?'} conv=${rec.conv || '?'}`)
try { sse.broadcast('outbox', 'rating', { token, stars: s, customer: rec.customer, name: rec.name, low: s < 5 }) } catch (e) { /* */ }
}
}
const dest = s >= 5 ? googleUrl() : `${pub()}/rate/feedback?t=${encodeURIComponent(token)}&s=${s}`
res.writeHead(302, { Location: dest }); return res.end()
}
// GET /rate/feedback?t=&s= — page interne (merci + commentaire)
if (path === '/rate/feedback' && method === 'GET') {
const token = url.searchParams.get('t') || ''
const s = Math.max(0, Math.min(5, parseInt(url.searchParams.get('s'), 10) || 0))
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(feedbackPage(token, s))
}
// GET /rate/start?t= — page d'étoiles AUTONOME (ouverte depuis le lien SMS d'invitation) ; mêmes étoiles cliquables que le courriel.
if (path === '/rate/start' && method === 'GET') {
const token = url.searchParams.get('t') || ''
const rec = load()[token]
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(invitePage(token, (rec && rec.lang) || 'fr'))
}
// POST /rate/comment {token, comment} — capte le commentaire (avis privé)
if (path === '/rate/comment' && method === 'POST') {
const b = await parseBody(req); const token = String(b.token || ''); const comment = String(b.comment || '').trim().slice(0, 2000)
const m = load(); const rec = m[token]
if (rec && comment) { // ne JAMAIS écraser un commentaire par du vide (soumission accidentelle / double-envoi)
rec.comment = comment; rec.comment_ts = new Date().toISOString(); save(m)
try { sse.broadcast('outbox', 'rating-comment', { token, comment, stars: rec.stars, customer: rec.customer, name: rec.name }) } catch (e) { /* */ }
}
return json(res, 200, { ok: true })
}
return json(res, 404, { error: 'not found' })
} catch (e) { log('rating handle: ' + e.message); return json(res, 500, { error: e.message }) }
}
// Liste des évaluations (récentes d'abord) — pour la vue Ops « Évaluations » (basses prioritaires).
function listRatings ({ limit = 150, customer = '' } = {}) {
const m = load()
let out = Object.entries(m)
.map(([token, r]) => ({ token, ...r }))
.filter(r => r.stars != null || r.comment) // seulement celles où le client a agi
if (customer) out = out.filter(r => r.customer === customer) // évaluations D'UN client précis (pour sa fiche)
out.sort((a, b) => String(b.stars_ts || b.comment_ts || b.created || '').localeCompare(String(a.stars_ts || a.comment_ts || a.created || '')))
const low = out.filter(r => r.stars != null && r.stars < 5).length
return { ratings: out.slice(0, limit), total: out.length, low }
}
// Supprimer une évaluation (ex. avis de test → pollution visuelle dans /Évaluations).
function deleteRating (token) { const m = load(); if (m[token]) { delete m[token]; save(m); return true } return false }
module.exports = { handle, ratingBlock, newRatingToken, expandRatingMarker, listRatings, deleteRating, inviteSubject, inviteEmailHtml, inviteEmailMarkerHtml, inviteSmsText, normLang, feedbackSubject, feedbackHtml, feedbackSms }

View File

@ -27,6 +27,8 @@ async function searchCustomers (q) {
const found = new Map() // name -> { matched, address? } const found = new Map() // name -> { matched, address? }
// 1) Téléphone (REST multi-champs) // 1) Téléphone (REST multi-champs)
if (phone) { try { for (const c of await lookupCustomersByPhone(phone, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'téléphone' }) } catch (e) { /* */ } } if (phone) { try { for (const c of await lookupCustomersByPhone(phone, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'téléphone' }) } catch (e) { /* */ } }
// 1b) Courriel — la requête ressemble à une adresse e-mail
if (/.+@.+/.test(s)) { try { for (const c of await lookupCustomersByEmail(s, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'courriel' }) } catch (e) { /* */ } }
let p = null let p = null
try { p = require('./address-db').pool() } catch (e) { /* address-db indispo */ } try { p = require('./address-db').pool() } catch (e) { /* address-db indispo */ }
// 2) No civique → adresse (Service Location → client) // 2) No civique → adresse (Service Location → client)
@ -61,6 +63,21 @@ async function searchCustomers (q) {
for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'nom', score: row.score != null ? Math.round(row.score * 100) / 100 : null }) for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'nom', score: row.score != null ? Math.round(row.score * 100) / 100 : null })
} }
} }
// 3b) Mandataire / contact détaillé (titulaire ≠ demandeur, ex. représentant qui paie/écrit). FUZZY sur les champs legacy.
// try/catch : si les colonnes custom n'existent pas encore, on ignore silencieusement (pas de régression).
if (p && found.size < 8) {
const qn = normName(text)
if (qn.length >= 3) {
const NORMM = "regexp_replace(unaccent(lower(coalesce(mandataire,'') || ' ' || coalesce(contact_name_legacy,''))), '[^a-z0-9]', '', 'g')"
try {
const rows = (await p.query(
`SELECT name, customer_name, word_similarity($1, ${NORMM}) AS score FROM "tabCustomer"
WHERE (${NORMM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORMM}) > 0.45)
ORDER BY (${NORMM} LIKE '%'||$1||'%') DESC, coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 6`, [qn])).rows
for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'mandataire', score: row.score != null ? Math.round(row.score * 100) / 100 : null })
} catch (e) { log('searchCustomers mandataire: ' + e.message) }
}
}
if (!found.size) return [] if (!found.size) return []
// Enrichissement contacts (1 requête) : courriel + téléphones → résumé + « SMS dispo ? » (mobile présent). // Enrichissement contacts (1 requête) : courriel + téléphones → résumé + « SMS dispo ? » (mobile présent).
const names = [...found.keys()].slice(0, 8) const names = [...found.keys()].slice(0, 8)
@ -520,10 +537,95 @@ async function handle (req, res, method, p, url) {
if (p === '/collab/orchestrate/run' && method === 'POST') { if (p === '/collab/orchestrate/run' && method === 'POST') {
const b = await parseBody(req); return json(res, 200, await orchestrateRun(b.actions, req.headers['x-authentik-email'] || b.agent || '')) const b = await parseBody(req); return json(res, 200, await orchestrateRun(b.actions, req.headers['x-authentik-email'] || b.agent || ''))
} }
// GET /collab/customer-search?q= — autosuggest client par texte libre (nom / téléphone / adresse) // GET /collab/customer-search?q= — autosuggest client par texte libre (nom / courriel / téléphone / adresse)
if (p === '/collab/customer-search' && method === 'GET') { if (p === '/collab/customer-search' && method === 'GET') {
return json(res, 200, { matches: await searchCustomers(url.searchParams.get('q') || '') }) return json(res, 200, { matches: await searchCustomers(url.searchParams.get('q') || '') })
} }
// GET /collab/ratings — évaluations clients (basses prioritaires) pour la vue Ops « satisfaction »
if (p === '/collab/ratings' && method === 'GET') {
return json(res, 200, require('./rating').listRatings({ limit: Number(url.searchParams.get('limit')) || 150, customer: url.searchParams.get('customer') || '' }))
}
// DELETE /collab/ratings/<token> — supprimer une évaluation (avis de test)
const mRatingDel = p.match(/^\/collab\/ratings\/([\w-]+)$/)
if (mRatingDel && method === 'DELETE') {
return json(res, 200, { ok: require('./rating').deleteRating(mRatingDel[1]) })
}
// GET /collab/rating-invite-draft?customer=&channel=&kind=invite|feedback — BROUILLON pré-rempli (coords + message, langue du client) pour la fenêtre Compose.
// kind=invite (défaut) : invitation à évaluer (courriel = {{rating}} ; SMS = lien /rate/start tokenisé). kind=feedback : « écrire au client » (suite à un avis, ex. mécontent ; aucun lien).
if (p === '/collab/rating-invite-draft' && method === 'GET') {
const customer = url.searchParams.get('customer') || ''
const channel = url.searchParams.get('channel') === 'sms' ? 'sms' : 'email'
const kind = url.searchParams.get('kind') === 'feedback' ? 'feedback' : 'invite'
let email = url.searchParams.get('email') || ''; let phone = url.searchParams.get('phone') || ''; let name = url.searchParams.get('name') || ''; let lang = url.searchParams.get('lang') || ''
if (customer) { try { const c = await erp.get('Customer', customer); if (c) { email = email || c.email_id || c.email_billing || ''; phone = phone || c.cell_phone || c.mobile_no || ''; lang = lang || c.language || ''; name = name || c.customer_name || '' } } catch (e) { /* */ } }
if (!customer && !email && !phone) return json(res, 400, { error: 'customer ou coordonnées requis' })
const rating = require('./rating')
if (channel === 'sms') {
if (!phone) return json(res, 200, { ok: false, error: 'Aucun numéro mobile pour ce client' })
const text = kind === 'feedback' ? rating.feedbackSms(name, lang) : rating.inviteSmsText(rating.newRatingToken({ customer, email, name, lang }), lang)
return json(res, 200, { ok: true, prefill: { channel: 'sms', phone, customer, customerName: name, text } })
}
if (!email) return json(res, 200, { ok: false, error: 'Aucun courriel pour ce client' })
const subject = kind === 'feedback' ? rating.feedbackSubject(lang) : rating.inviteSubject(lang)
const prefill = { channel: 'email', to: email, customer, customerName: name, subject }
if (kind === 'feedback') prefill.html = rating.feedbackHtml(name, lang) // texte simple → éditable dans l'éditeur
else prefill.advHtml = rating.inviteEmailMarkerHtml(lang, name) // courriel MIS EN PAGE → envoyé tel quel (composeAdvHtml)
return json(res, 200, { ok: true, prefill })
}
// POST /collab/send-rating-invite {customer|email|phone, channel} — renvoie l'invitation d'évaluation (courriel/SMS) DANS LA LANGUE DU CLIENT.
if (p === '/collab/send-rating-invite' && method === 'POST') {
const b = await parseBody(req)
const channel = b.channel === 'sms' ? 'sms' : 'email'
let email = String(b.email || ''); let phone = String(b.phone || ''); let name = String(b.name || ''); let lang = String(b.lang || '')
const customer = String(b.customer || ''); const conv = String(b.conv || '')
if (customer) { // résout courriel / mobile / langue depuis la fiche
try { const c = await erp.get('Customer', customer); if (c) { email = email || c.email_id || c.email_billing || ''; phone = phone || c.cell_phone || c.mobile_no || ''; lang = lang || c.language || ''; name = name || c.customer_name || '' } } catch (e) { /* */ }
}
const rating = require('./rating')
const token = rating.newRatingToken({ customer, conv, email, name, lang })
if (channel === 'sms') {
if (!phone) return json(res, 400, { error: 'Aucun numéro mobile pour ce client' })
try { const { sendSmsInternal } = require('./twilio'); const ok = await sendSmsInternal(phone, rating.inviteSmsText(token, lang), customer || null); return json(res, ok ? 200 : 502, { ok: !!ok, channel: 'sms', to: phone }) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (!email) return json(res, 400, { error: 'Aucun courriel pour ce client' })
try { const r = await require('./conversation').sendNewEmail({ to: email, subject: rating.inviteSubject(lang), html: rating.inviteEmailHtml(token, lang, name), customer, customerName: name, agentEmail: req.headers['x-authentik-email'] || '' }); return json(res, r && r.ok ? 200 : 400, { ok: !!(r && r.ok), channel: 'email', to: email }) } catch (e) { return json(res, 500, { error: e.message }) }
}
// POST /collab/suggest-account — croise les signaux d'un courriel pour proposer le BON compte même quand
// l'expéditeur ≠ le titulaire (ex. mandataire). IA extrait adresse/nom/tél du CORPS, puis recoupe + classe.
if (p === '/collab/suggest-account' && method === 'POST') {
const b = await parseBody(req)
const senderEmail = String(b.email || '').trim()
const senderName = String(b.name || '').trim()
const text = String(b.text || '').slice(0, 4000)
let ex = {}
if (text) {
try {
const sys = 'Tu extrais les identifiants de compte qu\'un client mentionne dans un courriel à un fournisseur Internet. Le TITULAIRE du compte peut différer de l\'expéditeur (ex. un mandataire écrit pour le titulaire). Renvoie UNIQUEMENT du JSON : {"address":"adresse civique complète si mentionnée, sinon vide","names":["noms complets de personnes mentionnés"],"invoice_number":"","phone":""}. N\'invente rien ; vide si absent.'
const out = await require('./ai').chat({ task: 'default', maxTokens: 300, temperature: 0, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: text }] })
ex = JSON.parse((String(out || '').match(/\{[\s\S]*\}/) || ['{}'])[0]) || {}
} catch (e) { ex = {} }
}
// Signaux pondérés : l'adresse du corps est la plus discriminante ; corroboration par un nom = forte confiance.
const queries = []
if (ex.address) queries.push({ q: ex.address, signal: 'adresse du courriel', w: 5 })
for (const n of (Array.isArray(ex.names) ? ex.names : [])) if (n && String(n).trim().length > 2) queries.push({ q: String(n), signal: 'nom mentionné', w: 3 })
if (ex.phone) queries.push({ q: ex.phone, signal: 'téléphone du courriel', w: 3 })
if (senderName) queries.push({ q: senderName, signal: 'expéditeur', w: 2 })
if (senderEmail) queries.push({ q: senderEmail, signal: 'courriel expéditeur', w: 2 })
const acc = new Map()
for (const { q, signal, w } of queries) {
let matches = []
try { matches = await searchCustomers(q) } catch (e) { matches = [] }
for (const m of matches.slice(0, 5)) {
const cur = acc.get(m.name) || { ...m, score: 0, signals: [] }
cur.score += w
if (!cur.signals.includes(signal)) cur.signals.push(signal)
acc.set(m.name, cur)
}
}
const suggestions = [...acc.values()].sort((a, b) => b.score - a.score).slice(0, 6)
return json(res, 200, { suggestions, extracted: ex })
}
// GET /collab/service-locations?customer= — adresses de service d'un compte (multi-adresses) // GET /collab/service-locations?customer= — adresses de service d'un compte (multi-adresses)
if (p === '/collab/service-locations' && method === 'GET') { if (p === '/collab/service-locations' && method === 'GET') {
return json(res, 200, { locations: await serviceLocations(url.searchParams.get('customer') || '') }) return json(res, 200, { locations: await serviceLocations(url.searchParams.get('customer') || '') })
@ -569,6 +671,33 @@ async function handle (req, res, method, p, url) {
const base = (cfg.CLIENT_PUBLIC_URL || 'https://app.gigafibre.ca').replace(/\/$/, '') const base = (cfg.CLIENT_PUBLIC_URL || 'https://app.gigafibre.ca').replace(/\/$/, '')
return json(res, 200, { ok: true, link: base + '/diag/' + token, customer, name }) return json(res, 200, { ok: true, link: base + '/diag/' + token, customer, name })
} }
// ── Bibliothèque d'images de pièces jointes (équipements fréquents, réutilisables — mêmes images qu'une FAQ) ──
// Manifeste léger /app/data/attach_presets.json ; les octets vivent dans le store d'actifs (content-hash, partagé).
if (p === '/collab/attach-presets' && method === 'GET') {
const list = (readJsonFile('/app/data/attach_presets.json') || []).map(x => ({ ...x, url: require('./campaigns').uploadUrl(x.asset, req) }))
return json(res, 200, { presets: list })
}
if (p === '/collab/attach-presets' && method === 'POST') {
const b = await parseBody(req)
const campaigns = require('./campaigns')
const dec = campaigns.decodeDataUrl(b && b.data)
if (!dec || dec.error) return json(res, 400, { error: dec && dec.error === 'too_large' ? 'image trop volumineuse' : 'image invalide' })
const asset = campaigns.persistUpload(dec.buffer, dec.ext)
const id = 'ap_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
const fs = require('fs'); const PRESETS = '/app/data/attach_presets.json'
const list = readJsonFile(PRESETS) || []
const preset = { id, name: String((b && b.name) || 'Image').slice(0, 80), category: String((b && b.category) || 'Équipement').slice(0, 40), asset, mime: dec.mime }
list.push(preset)
try { fs.writeFileSync(PRESETS, JSON.stringify(list, null, 2)) } catch (e) { return json(res, 500, { error: 'écriture manifeste' }) }
return json(res, 200, { ok: true, preset: { ...preset, url: campaigns.uploadUrl(asset, req) } })
}
const mPreset = p.match(/^\/collab\/attach-presets\/([\w-]+)$/)
if (mPreset && method === 'DELETE') {
const fs = require('fs'); const PRESETS = '/app/data/attach_presets.json'
const list = (readJsonFile(PRESETS) || []).filter(x => x.id !== mPreset[1]) // retiré du manifeste ; l'actif content-hash reste (potentiellement partagé)
try { fs.writeFileSync(PRESETS, JSON.stringify(list, null, 2)) } catch (e) { return json(res, 500, { error: 'écriture manifeste' }) }
return json(res, 200, { ok: true })
}
// POST /collab/ticket {title, category, priority, description, customer, customer_name, queue} — créer un ticket autonome // POST /collab/ticket {title, category, priority, description, customer, customer_name, queue} — créer un ticket autonome
if (p === '/collab/ticket' && method === 'POST') { if (p === '/collab/ticket' && method === 'POST') {
const b = await parseBody(req) const b = await parseBody(req)

View File

@ -0,0 +1,114 @@
'use strict'
// Génère les modèles « invitation à évaluer » éditables dans Unlayer :
// transactional-rating-invite-<lang>.html (envoyé ; marqueurs {{firstname}} + {{rating}})
// transactional-rating-invite-<lang>.json (design Unlayer natif — schéma calqué sur gift-email-fr-native)
const fs = require('fs')
const path = require('path')
const DIR = process.argv[2] || __dirname
const LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png'
const C = {
fr: {
lang: 'fr',
title: 'Ton avis compte pour nous',
preheader: 'Comment évaluerais-tu ton expérience avec TARGO ?',
greet: 'Bonjour {{firstname}},',
intro: "Merci de faire confiance à TARGO. Si tu as quelques secondes pour nous évaluer, ce serait vraiment apprécié :",
outro: "C'est grâce à ta rétroaction qu'on peut toujours mieux te servir. Si quelque chose ne va pas, réponds simplement à ce courriel — on est là pour t'aider.",
foot: 'TARGO — merci pour ta confiance',
},
en: {
lang: 'en',
title: 'Your feedback matters',
preheader: 'How would you rate your experience with TARGO?',
greet: 'Hi {{firstname}},',
intro: 'Thanks for choosing TARGO. If you have a few seconds to rate us, it would mean a lot:',
outro: "Your feedback helps us serve you better. If something went wrong, just reply to this email — we're here to help.",
foot: 'TARGO — thank you for your trust',
},
}
const STRIP = '<div style="height:4px;line-height:4px;font-size:0;background:#00C853">&nbsp;</div>'
const STARS_BOX = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#E6F9EE;border:1px solid #b6f0cf;border-radius:12px"><tr><td align="center" style="padding:14px 8px">{{rating}}</td></tr></table>'
// ── HTML envoyé (table email ; identique au design approuvé, avec marqueurs) ──
function htmlDoc (c) {
return `<!doctype html><html lang="${c.lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>`
+ `<body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px">`
+ `<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;border:1px solid #e2e8f0">`
+ `<tr><td style="background:#ffffff;padding:24px 28px 14px"><img src="${LOGO}" alt="TARGO" width="132" style="width:132px;max-width:132px;height:auto;display:block;border:0"></td></tr>`
+ `<tr><td style="height:4px;background:#00C853;font-size:0;line-height:0;mso-line-height-rule:exactly">&nbsp;</td></tr>`
+ `<tr><td style="padding:28px 30px 4px"><h1 style="margin:0 0 10px;font-size:21px;font-weight:800;color:#0f172a">${c.title}</h1>`
+ `<p style="margin:0 0 4px;font-size:15px;line-height:1.6;color:#475569">${c.greet}</p>`
+ `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${c.intro}</p></td></tr>`
+ `<tr><td style="padding:14px 24px 6px">${STARS_BOX}</td></tr>`
+ `<tr><td style="padding:8px 30px 26px"><p style="margin:0;font-size:14px;line-height:1.6;color:#64748b">${c.outro}</p></td></tr>`
+ `<tr><td style="background:#f8fafc;padding:16px 30px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">${c.foot}</td></tr>`
+ `</table></td></tr></table></body></html>`
}
// ── Design Unlayer natif (blocs éditables) ──
const FLAGS = { selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true, hideDesktop: false, displayCondition: null }
function txt (n, text, o) {
return { id: `u_content_text_${n}`, type: 'text', values: Object.assign({}, FLAGS, {
containerPadding: o.pad, anchor: '', fontWeight: o.weight || 400, fontSize: o.size, color: o.color,
textAlign: o.align || 'left', lineHeight: o.lh || '160%', linkStyle: { inherit: true },
_meta: { htmlID: `u_content_text_${n}`, htmlClassNames: 'u_content_text' }, text }) }
}
function img (n, o) {
return { id: `u_content_image_${n}`, type: 'image', values: Object.assign({}, FLAGS, {
containerPadding: o.pad, anchor: '',
src: { url: o.url, width: o.width, height: 'auto', autoWidth: false, maxWidth: o.width + 'px' },
textAlign: o.align || 'left', altText: o.alt || '', action: { name: 'web', values: { href: '', target: '_blank' } },
_meta: { htmlID: `u_content_image_${n}`, htmlClassNames: 'u_content_image' } }) }
}
function htm (n, html, pad) {
return { id: `u_content_html_${n}`, type: 'html', values: Object.assign({}, FLAGS, {
containerPadding: pad, anchor: '', _meta: { htmlID: `u_content_html_${n}`, htmlClassNames: 'u_content_html' }, html }) }
}
function col (n, contents) {
return { id: `u_column_${n}`, contents, values: Object.assign({}, FLAGS, {
_meta: { htmlID: `u_column_${n}`, htmlClassNames: 'u_column' }, padding: '0px', border: {}, borderRadius: '0px', backgroundColor: '' }) }
}
function row (n, cn, contents, bg) {
return { id: `u_row_${n}`, cells: [1], columns: [col(cn, contents)], values: Object.assign({}, FLAGS, {
columns: false, backgroundColor: bg, columnsBackgroundColor: '',
backgroundImage: { url: '', fullWidth: true, repeat: 'no-repeat', size: 'custom', position: 'center' },
padding: '0px', anchor: '', borderRadius: '', _meta: { htmlID: `u_row_${n}`, htmlClassNames: 'u_row' } }) }
}
function design (c) {
const rows = [
row(1, 1, [
img(1, { pad: '24px 30px 4px', url: LOGO, width: 132, align: 'left', alt: 'TARGO' }),
htm(1, STRIP, '0px'),
txt(1, c.title, { pad: '24px 30px 2px', size: '21px', weight: 700, color: '#0f172a', lh: '130%' }),
txt(2, c.greet, { pad: '10px 30px 2px', size: '15px', color: '#475569' }),
txt(3, c.intro, { pad: '2px 30px 12px', size: '15px', color: '#475569' }),
htm(2, STARS_BOX, '6px 24px 8px'),
txt(4, c.outro, { pad: '10px 30px 26px', size: '14px', color: '#64748b' }),
], '#ffffff'),
row(2, 2, [
txt(5, c.foot, { pad: '16px 30px', size: '12px', color: '#94a3b8' }),
], '#f8fafc'),
]
return {
counters: { u_row: 2, u_column: 2, u_content_text: 5, u_content_image: 1, u_content_html: 2 },
body: { id: 'u_body', rows, values: {
popupPosition: 'center', popupWidth: '600px', popupHeight: 'auto', borderRadius: '10px',
contentAlign: 'center', contentVerticalAlign: 'center', contentWidth: '600px',
fontFamily: { label: 'Arial', value: 'arial,helvetica,sans-serif' },
textColor: '#1e293b', popupBackgroundColor: '#FFFFFF', backgroundColor: '#f4f6f8', preheaderText: c.preheader,
linkStyle: { body: true, linkColor: '#00C853', linkHoverColor: '#005026', linkUnderline: true, linkHoverUnderline: true },
_meta: { htmlID: 'u_body', htmlClassNames: 'u_body' } } },
schemaVersion: 16,
}
}
for (const lang of ['fr', 'en']) {
const c = C[lang]
fs.writeFileSync(path.join(DIR, `transactional-rating-invite-${lang}.html`), htmlDoc(c), 'utf8')
fs.writeFileSync(path.join(DIR, `transactional-rating-invite-${lang}.json`), JSON.stringify(design(c), null, 2), 'utf8')
console.log(`wrote transactional-rating-invite-${lang}.html + .json`)
}

View File

@ -57,7 +57,7 @@ const server = http.createServer(async (req, res) => {
{ {
const PUBLIC = [ const PUBLIC = [
'/health', '/sse', '/g/', '/c/', '/book', '/field', '/signup', '/store', '/health', '/sse', '/g/', '/c/', '/book', '/field', '/signup', '/store',
'/accept', '/portal', '/magic-link', '/t/', '/acs/', '/diag/', '/accept', '/portal', '/magic-link', '/t/', '/acs/', '/diag/', '/rate',
'/voice/inbound', '/voice/gather', '/voice/connect-agent', '/voice/twiml', '/voice/status', '/voice/inbound', '/voice/gather', '/voice/connect-agent', '/voice/twiml', '/voice/status',
'/api/checkout', '/api/catalog', '/api/order', '/api/address', '/api/otp', '/api/checkout', '/api/catalog', '/api/order', '/api/address', '/api/otp',
'/api/referral', '/api/accept-for-client', '/api/referral', '/api/accept-for-client',
@ -102,7 +102,11 @@ const server = http.createServer(async (req, res) => {
if (sub === 'sse') return method === 'GET' // flux SSE client if (sub === 'sse') return method === 'GET' // flux SSE client
return method === 'POST' // messages | push return method === 'POST' // messages | push
})() })()
const isPublic = convChat || PUBLIC.some(p => path.startsWith(p)) // Lecture seule des actifs (images de courriels + bibliothèque) : GET /campaigns/assets/<hash>.<ext> PUBLIC.
// Les <img> (courriels chez le client, vignettes Ops) n'ont pas de token ; le nom = hash de contenu (64 hex, indevinable).
// Le POST /campaigns/assets/upload reste GATÉ (≠ GET et « upload » ne matche pas le motif hash.ext).
const assetGet = method === 'GET' && /^\/campaigns\/assets\/[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(path)
const isPublic = convChat || assetGet || PUBLIC.some(p => path.startsWith(p))
if (!isPublic) { if (!isPublic) {
const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '') const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
const hasToken = !!process.env.HUB_SERVICE_TOKEN && tok === process.env.HUB_SERVICE_TOKEN const hasToken = !!process.env.HUB_SERVICE_TOKEN && tok === process.env.HUB_SERVICE_TOKEN
@ -226,6 +230,7 @@ const server = http.createServer(async (req, res) => {
// Gift redirect wrapper — short public URLs in campaign emails that // Gift redirect wrapper — short public URLs in campaign emails that
// 302 to the underlying Giftbit shortlink (subject to our expiry/revoke). // 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).
if (path.startsWith('/g/') && method === 'GET') return require('./lib/campaigns').handleGiftRedirect(req, res, path) if (path.startsWith('/g/') && method === 'GET') return require('./lib/campaigns').handleGiftRedirect(req, res, path)
if (path.startsWith('/rate')) return require('./lib/rating').handle(req, res, method, path, url)
if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path) if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path)
if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url) if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url)
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res) if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)

View File

@ -4,7 +4,7 @@
<body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b"> <body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:10px;overflow:hidden;border:1px solid #e2e8f0"> <table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:10px;overflow:hidden;border:1px solid #e2e8f0">
<tr><td style="background:#ffffff;padding:24px 28px 6px;text-align:left"><img src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" alt="TARGO" width="140" style="width:140px;max-width:140px;height:auto;display:block;border:0"></td></tr> <tr><td style="background:#ffffff;padding:24px 28px 6px;text-align:left"><img src="https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png" alt="TARGO" width="140" style="width:140px;max-width:140px;height:auto;display:block;border:0"></td></tr>
<tr><td style="padding:28px"> <tr><td style="padding:28px">
<p style="margin:0 0 14px;font-size:15px">Hello {{firstname}},</p> <p style="margin:0 0 14px;font-size:15px">Hello {{firstname}},</p>
<p style="margin:0 0 14px;font-size:15px;line-height:1.6"> <p style="margin:0 0 14px;font-size:15px;line-height:1.6">

View File

@ -34,7 +34,7 @@
"containerPadding": "28px 28px 10px", "containerPadding": "28px 28px 10px",
"anchor": "", "anchor": "",
"src": { "src": {
"url": "https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content", "url": "https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png",
"width": 140, "width": 140,
"height": "auto", "height": "auto",
"autoWidth": false, "autoWidth": false,

View File

@ -4,7 +4,7 @@
<body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b"> <body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:10px;overflow:hidden;border:1px solid #e2e8f0"> <table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:10px;overflow:hidden;border:1px solid #e2e8f0">
<tr><td style="background:#ffffff;padding:24px 28px 6px;text-align:left"><img src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" alt="TARGO" width="140" style="width:140px;max-width:140px;height:auto;display:block;border:0"></td></tr> <tr><td style="background:#ffffff;padding:24px 28px 6px;text-align:left"><img src="https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png" alt="TARGO" width="140" style="width:140px;max-width:140px;height:auto;display:block;border:0"></td></tr>
<tr><td style="padding:28px"> <tr><td style="padding:28px">
<p style="margin:0 0 14px;font-size:15px">Bonjour {{firstname}},</p> <p style="margin:0 0 14px;font-size:15px">Bonjour {{firstname}},</p>
<p style="margin:0 0 14px;font-size:15px;line-height:1.6"> <p style="margin:0 0 14px;font-size:15px;line-height:1.6">

View File

@ -34,7 +34,7 @@
"containerPadding": "28px 28px 10px", "containerPadding": "28px 28px 10px",
"anchor": "", "anchor": "",
"src": { "src": {
"url": "https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content", "url": "https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png",
"width": 140, "width": 140,
"height": "auto", "height": "auto",
"autoWidth": false, "autoWidth": false,

View File

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;border:1px solid #e2e8f0"><tr><td style="background:#ffffff;padding:24px 28px 14px"><img src="https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png" alt="TARGO" width="132" style="width:132px;max-width:132px;height:auto;display:block;border:0"></td></tr><tr><td style="height:4px;background:#00C853;font-size:0;line-height:0;mso-line-height-rule:exactly">&nbsp;</td></tr><tr><td style="padding:28px 30px 4px"><h1 style="margin:0 0 10px;font-size:21px;font-weight:800;color:#0f172a">Your feedback matters</h1><p style="margin:0 0 4px;font-size:15px;line-height:1.6;color:#475569">Hi {{firstname}},</p><p style="margin:0;font-size:15px;line-height:1.6;color:#475569">Thanks for choosing TARGO. If you have a few seconds to rate us, it would mean a lot:</p></td></tr><tr><td style="padding:14px 24px 6px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#E6F9EE;border:1px solid #b6f0cf;border-radius:12px"><tr><td align="center" style="padding:14px 8px">{{rating}}</td></tr></table></td></tr><tr><td style="padding:8px 30px 26px"><p style="margin:0;font-size:14px;line-height:1.6;color:#64748b">Your feedback helps us serve you better. If something went wrong, just reply to this email — we're here to help.</p></td></tr><tr><td style="background:#f8fafc;padding:16px 30px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">TARGO — thank you for your trust</td></tr></table></td></tr></table></body></html>

View File

@ -0,0 +1,370 @@
{
"counters": {
"u_row": 2,
"u_column": 2,
"u_content_text": 5,
"u_content_image": 1,
"u_content_html": 2
},
"body": {
"id": "u_body",
"rows": [
{
"id": "u_row_1",
"cells": [
1
],
"columns": [
{
"id": "u_column_1",
"contents": [
{
"id": "u_content_image_1",
"type": "image",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "24px 30px 4px",
"anchor": "",
"src": {
"url": "https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png",
"width": 132,
"height": "auto",
"autoWidth": false,
"maxWidth": "132px"
},
"textAlign": "left",
"altText": "TARGO",
"action": {
"name": "web",
"values": {
"href": "",
"target": "_blank"
}
},
"_meta": {
"htmlID": "u_content_image_1",
"htmlClassNames": "u_content_image"
}
}
},
{
"id": "u_content_html_1",
"type": "html",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "0px",
"anchor": "",
"_meta": {
"htmlID": "u_content_html_1",
"htmlClassNames": "u_content_html"
},
"html": "<div style=\"height:4px;line-height:4px;font-size:0;background:#00C853\">&nbsp;</div>"
}
},
{
"id": "u_content_text_1",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "24px 30px 2px",
"anchor": "",
"fontWeight": 700,
"fontSize": "21px",
"color": "#0f172a",
"textAlign": "left",
"lineHeight": "130%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_1",
"htmlClassNames": "u_content_text"
},
"text": "Your feedback matters"
}
},
{
"id": "u_content_text_2",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "10px 30px 2px",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#475569",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_2",
"htmlClassNames": "u_content_text"
},
"text": "Hi {{firstname}},"
}
},
{
"id": "u_content_text_3",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "2px 30px 12px",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#475569",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_3",
"htmlClassNames": "u_content_text"
},
"text": "Thanks for choosing TARGO. If you have a few seconds to rate us, it would mean a lot:"
}
},
{
"id": "u_content_html_2",
"type": "html",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "6px 24px 8px",
"anchor": "",
"_meta": {
"htmlID": "u_content_html_2",
"htmlClassNames": "u_content_html"
},
"html": "<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#E6F9EE;border:1px solid #b6f0cf;border-radius:12px\"><tr><td align=\"center\" style=\"padding:14px 8px\">{{rating}}</td></tr></table>"
}
},
{
"id": "u_content_text_4",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "10px 30px 26px",
"anchor": "",
"fontWeight": 400,
"fontSize": "14px",
"color": "#64748b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_4",
"htmlClassNames": "u_content_text"
},
"text": "Your feedback helps us serve you better. If something went wrong, just reply to this email — we're here to help."
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_1",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#ffffff",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_1",
"htmlClassNames": "u_row"
}
}
},
{
"id": "u_row_2",
"cells": [
1
],
"columns": [
{
"id": "u_column_2",
"contents": [
{
"id": "u_content_text_5",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "16px 30px",
"anchor": "",
"fontWeight": 400,
"fontSize": "12px",
"color": "#94a3b8",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_5",
"htmlClassNames": "u_content_text"
},
"text": "TARGO — thank you for your trust"
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_2",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#f8fafc",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_2",
"htmlClassNames": "u_row"
}
}
}
],
"values": {
"popupPosition": "center",
"popupWidth": "600px",
"popupHeight": "auto",
"borderRadius": "10px",
"contentAlign": "center",
"contentVerticalAlign": "center",
"contentWidth": "600px",
"fontFamily": {
"label": "Arial",
"value": "arial,helvetica,sans-serif"
},
"textColor": "#1e293b",
"popupBackgroundColor": "#FFFFFF",
"backgroundColor": "#f4f6f8",
"preheaderText": "How would you rate your experience with TARGO?",
"linkStyle": {
"body": true,
"linkColor": "#00C853",
"linkHoverColor": "#005026",
"linkUnderline": true,
"linkHoverUnderline": true
},
"_meta": {
"htmlID": "u_body",
"htmlClassNames": "u_body"
}
}
},
"schemaVersion": 16
}

View File

@ -0,0 +1 @@
<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;border:1px solid #e2e8f0"><tr><td style="background:#ffffff;padding:24px 28px 14px"><img src="https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png" alt="TARGO" width="132" style="width:132px;max-width:132px;height:auto;display:block;border:0"></td></tr><tr><td style="height:4px;background:#00C853;font-size:0;line-height:0;mso-line-height-rule:exactly">&nbsp;</td></tr><tr><td style="padding:28px 30px 4px"><h1 style="margin:0 0 10px;font-size:21px;font-weight:800;color:#0f172a">Ton avis compte pour nous</h1><p style="margin:0 0 4px;font-size:15px;line-height:1.6;color:#475569">Bonjour {{firstname}},</p><p style="margin:0;font-size:15px;line-height:1.6;color:#475569">Merci de faire confiance à TARGO. Si tu as quelques secondes pour nous évaluer, ce serait vraiment apprécié :</p></td></tr><tr><td style="padding:14px 24px 6px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#E6F9EE;border:1px solid #b6f0cf;border-radius:12px"><tr><td align="center" style="padding:14px 8px">{{rating}}</td></tr></table></td></tr><tr><td style="padding:8px 30px 26px"><p style="margin:0;font-size:14px;line-height:1.6;color:#64748b">C'est grâce à ta rétroaction qu'on peut toujours mieux te servir. Si quelque chose ne va pas, réponds simplement à ce courriel — on est là pour t'aider.</p></td></tr><tr><td style="background:#f8fafc;padding:16px 30px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">TARGO — merci pour ta confiance</td></tr></table></td></tr></table></body></html>

View File

@ -0,0 +1,370 @@
{
"counters": {
"u_row": 2,
"u_column": 2,
"u_content_text": 5,
"u_content_image": 1,
"u_content_html": 2
},
"body": {
"id": "u_body",
"rows": [
{
"id": "u_row_1",
"cells": [
1
],
"columns": [
{
"id": "u_column_1",
"contents": [
{
"id": "u_content_image_1",
"type": "image",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "24px 30px 4px",
"anchor": "",
"src": {
"url": "https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png",
"width": 132,
"height": "auto",
"autoWidth": false,
"maxWidth": "132px"
},
"textAlign": "left",
"altText": "TARGO",
"action": {
"name": "web",
"values": {
"href": "",
"target": "_blank"
}
},
"_meta": {
"htmlID": "u_content_image_1",
"htmlClassNames": "u_content_image"
}
}
},
{
"id": "u_content_html_1",
"type": "html",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "0px",
"anchor": "",
"_meta": {
"htmlID": "u_content_html_1",
"htmlClassNames": "u_content_html"
},
"html": "<div style=\"height:4px;line-height:4px;font-size:0;background:#00C853\">&nbsp;</div>"
}
},
{
"id": "u_content_text_1",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "24px 30px 2px",
"anchor": "",
"fontWeight": 700,
"fontSize": "21px",
"color": "#0f172a",
"textAlign": "left",
"lineHeight": "130%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_1",
"htmlClassNames": "u_content_text"
},
"text": "Ton avis compte pour nous"
}
},
{
"id": "u_content_text_2",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "10px 30px 2px",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#475569",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_2",
"htmlClassNames": "u_content_text"
},
"text": "Bonjour {{firstname}},"
}
},
{
"id": "u_content_text_3",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "2px 30px 12px",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#475569",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_3",
"htmlClassNames": "u_content_text"
},
"text": "Merci de faire confiance à TARGO. Si tu as quelques secondes pour nous évaluer, ce serait vraiment apprécié :"
}
},
{
"id": "u_content_html_2",
"type": "html",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "6px 24px 8px",
"anchor": "",
"_meta": {
"htmlID": "u_content_html_2",
"htmlClassNames": "u_content_html"
},
"html": "<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#E6F9EE;border:1px solid #b6f0cf;border-radius:12px\"><tr><td align=\"center\" style=\"padding:14px 8px\">{{rating}}</td></tr></table>"
}
},
{
"id": "u_content_text_4",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "10px 30px 26px",
"anchor": "",
"fontWeight": 400,
"fontSize": "14px",
"color": "#64748b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_4",
"htmlClassNames": "u_content_text"
},
"text": "C'est grâce à ta rétroaction qu'on peut toujours mieux te servir. Si quelque chose ne va pas, réponds simplement à ce courriel — on est là pour t'aider."
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_1",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#ffffff",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_1",
"htmlClassNames": "u_row"
}
}
},
{
"id": "u_row_2",
"cells": [
1
],
"columns": [
{
"id": "u_column_2",
"contents": [
{
"id": "u_content_text_5",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "16px 30px",
"anchor": "",
"fontWeight": 400,
"fontSize": "12px",
"color": "#94a3b8",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_5",
"htmlClassNames": "u_content_text"
},
"text": "TARGO — merci pour ta confiance"
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_2",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#f8fafc",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_2",
"htmlClassNames": "u_row"
}
}
}
],
"values": {
"popupPosition": "center",
"popupWidth": "600px",
"popupHeight": "auto",
"borderRadius": "10px",
"contentAlign": "center",
"contentVerticalAlign": "center",
"contentWidth": "600px",
"fontFamily": {
"label": "Arial",
"value": "arial,helvetica,sans-serif"
},
"textColor": "#1e293b",
"popupBackgroundColor": "#FFFFFF",
"backgroundColor": "#f4f6f8",
"preheaderText": "Comment évaluerais-tu ton expérience avec TARGO ?",
"linkStyle": {
"body": true,
"linkColor": "#00C853",
"linkHoverColor": "#005026",
"linkUnderline": true,
"linkHoverUnderline": true
},
"_meta": {
"htmlID": "u_body",
"htmlClassNames": "u_body"
}
}
},
"schemaVersion": 16
}