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:
parent
1b7bad1686
commit
6628eaaa5b
|
|
@ -3,7 +3,7 @@ const { configure } = require('quasar/wrappers')
|
|||
|
||||
module.exports = configure(function () {
|
||||
return {
|
||||
boot: ['pinia'],
|
||||
boot: ['pinia', 'tooltip-ux'],
|
||||
|
||||
css: ['app.scss', 'tech.scss'],
|
||||
|
||||
|
|
|
|||
|
|
@ -200,10 +200,10 @@ export function translateTemplate (srcName, targetName, { override = false } = {
|
|||
|
||||
// Send ONE rendered email to a specific address for visual QA.
|
||||
// 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`, {
|
||||
method: 'POST',
|
||||
body: { to, vars, from, subject },
|
||||
body: { to, vars, from, subject, via },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
37
apps/ops/src/boot/tooltip-ux.js
Normal file
37
apps/ops/src/boot/tooltip-ux.js
Normal 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 */ }
|
||||
})
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
<template>
|
||||
<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" />
|
||||
<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-spinner v-if="loading" size="16px" color="indigo-6" />
|
||||
<q-btn dense unelevated color="indigo-5" icon="add" label="Nouvelle" no-caps @click="addNew" />
|
||||
<q-spinner v-if="loading" size="16px" color="indigo-6" class="q-mr-sm" />
|
||||
<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 v-if="expanded">
|
||||
<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 »).
|
||||
</div>
|
||||
|
|
@ -31,6 +34,7 @@
|
|||
<div class="row justify-end q-mt-sm">
|
||||
<q-btn unelevated color="indigo-6" icon="save" label="Enregistrer" no-caps :loading="saving" @click="persist" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -41,6 +45,7 @@ import { HUB_URL } from 'src/config/hub'
|
|||
|
||||
const $q = useQuasar()
|
||||
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 saving = ref(false)
|
||||
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) })) }
|
||||
} 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 pickImage (c) { _target = c; if (fileInput.value) { fileInput.value.value = ''; fileInput.value.click() } }
|
||||
|
|
|
|||
|
|
@ -123,16 +123,13 @@
|
|||
<div class="text-caption text-grey-6">{{ formatDate(activeDiscussion.date) }} · {{ activeDiscussion.messageCount }} messages<span v-if="activeDiscussion.email"> · {{ activeDiscussion.email }}</span></div>
|
||||
<!-- 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">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</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-tooltip>Archiver (ferme + retire la discussion)</q-tooltip>
|
||||
</q-btn>
|
||||
|
|
@ -151,14 +148,59 @@
|
|||
</q-btn>
|
||||
</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">
|
||||
<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-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-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>
|
||||
<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>
|
||||
</div>
|
||||
<!-- #3 Drapeau « mauvais aiguillage » : le triage IA classe ce fil dans une autre file que l'actuelle -->
|
||||
<div v-if="misrouted" class="coord-misroute">
|
||||
|
|
@ -195,31 +237,6 @@
|
|||
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>
|
||||
</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>
|
||||
|
||||
<!-- 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 -->
|
||||
<div v-if="!isExpanded(msg)" class="msg-snippet">{{ msgSnippet(msg) }}</div>
|
||||
</div>
|
||||
<!-- Pièces jointes (PDF/JPG…) — trombone cliquable (ouvre le document) -->
|
||||
<div v-if="attByMsg[msg.id] && attByMsg[msg.id].length" class="msg-att row items-center q-gutter-xs">
|
||||
<q-icon name="attach_file" size="14px" color="grey-6" />
|
||||
<q-chip v-for="a in attByMsg[msg.id]" :key="a.attachmentId" dense clickable size="sm" color="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">
|
||||
<!-- 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' }">
|
||||
|
|
@ -372,16 +394,6 @@
|
|||
<q-spinner-dots size="20px" color="teal-6" /> <b>{{ typingAgentLabel }}</b> est en train d'écrire…
|
||||
</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) -->
|
||||
<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>
|
||||
|
|
@ -399,9 +411,29 @@
|
|||
</div>
|
||||
<!-- COURRIEL : éditeur WYSIWYG (type Gmail) → envoie du HTML dans le même fil -->
|
||||
<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']]"
|
||||
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">
|
||||
<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">
|
||||
|
|
@ -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-tooltip>Insérer la signature « {{ suggestedSignature.mailbox }} » (suggérée pour cette file)</q-tooltip>
|
||||
</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>
|
||||
<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>
|
||||
</template>
|
||||
<!-- 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-tooltip>Joindre une image</q-tooltip>
|
||||
</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 #append>
|
||||
<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. -->
|
||||
<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-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-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 class="q-pt-none">
|
||||
<!-- Recherche client (nom / adresse / téléphone) → résumé contacts → voir si SMS possible -->
|
||||
|
|
@ -527,20 +548,138 @@
|
|||
</div>
|
||||
</template>
|
||||
<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="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>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<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-else unelevated color="teal-7" icon="send" label="Envoyer le courriel" :disable="!/.+@.+\..+/.test(newEmail) || !newMessage.trim() || creating" :loading="creating" @click="createNewEmail" />
|
||||
<q-btn v-if="newChannel === 'sms'" unelevated icon="send" label="Envoyer le texto" class="giga-btn" :disable="String(newPhone).replace(/\D/g,'').length < 10 || (!newMessage.trim() && !newMedia) || creating" :loading="creating" @click="createNew" />
|
||||
<q-btn v-else unelevated icon="send" label="Envoyer le courriel" class="giga-btn" :disable="!/.+@.+\..+/.test(newEmail) || (!composeHasBody && !composeAttachments.length && !composeAdvHtml) || creating" :loading="creating" @click="createNewEmail" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Créer un TICKET (ERPNext Issue OUVERT) depuis la conversation — la conversation RESTE dans l'historique -->
|
||||
<!-- Éditeur avancé (Unlayer) : composer un courriel mis en page (blocs glisser-déposer) -->
|
||||
<q-dialog v-model="advDlg.open" maximized>
|
||||
<q-card class="column no-wrap">
|
||||
<q-card-section class="row items-center q-py-sm" style="border-bottom:1px solid #e2e8f0">
|
||||
<q-icon name="auto_awesome" class="q-mr-sm" style="color:#00C853" /><span class="text-weight-bold">Éditeur avancé — courriel mis en page</span>
|
||||
<q-space />
|
||||
<q-btn flat no-caps icon="close" label="Annuler" v-close-popup />
|
||||
<q-btn unelevated no-caps icon="check" label="Insérer dans le courriel" class="giga-btn q-ml-sm" :disable="!advReady" @click="insertAdvanced" />
|
||||
</q-card-section>
|
||||
<div class="col" style="position:relative;min-height:0">
|
||||
<EmailEditor ref="advEditor" :options="advEditorOptions" :min-height="'100%'" style="height:100%;width:100%" @ready="onAdvReady" />
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Bibliothèque d'images : pièces jointes d'équipements fréquents, réutilisables (mêmes images qu'une FAQ) -->
|
||||
<q-dialog v-model="presetDlg.open">
|
||||
<q-card style="min-width:440px;max-width:96vw">
|
||||
<q-card-section class="row items-center text-weight-bold">
|
||||
<q-icon name="collections" color="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-card style="min-width:380px;max-width:96vw">
|
||||
<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-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">
|
||||
<template #append><q-btn flat dense round icon="search" :loading="linkDialog.loading" @click="runLinkSearch" /></template>
|
||||
<div v-if="linkDialog.suggestions && linkDialog.suggestions.length" class="q-mb-sm">
|
||||
<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>
|
||||
<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>
|
||||
<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-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-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 side><q-icon name="link" color="blue-6" /></q-item-section>
|
||||
</q-item>
|
||||
|
|
@ -667,7 +823,9 @@
|
|||
</template>
|
||||
|
||||
<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 { useConversations } from 'src/composables/useConversations'
|
||||
import { shortAgent, staffColor, staffInitials, noteAuthorName, relTime } from 'src/composables/useFormatters'
|
||||
|
|
@ -678,17 +836,36 @@ const $q = useQuasar()
|
|||
const router = useRouter()
|
||||
const props = defineProps({ inline: { type: Boolean, default: false } }) // inline=true → rendu pleine page (route) au lieu du tiroir
|
||||
const {
|
||||
discussions, activeToken, activeDiscussion, activeConv, tickets, loading, panelOpen, newDialogOpen, newDialogChannel, showAll, activeCount,
|
||||
discussions, activeToken, activeDiscussion, activeConv, tickets, loading, panelOpen, newDialogOpen, newDialogChannel, composePrefill, showAll, activeCount,
|
||||
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,
|
||||
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,
|
||||
toggleSelect, selectAll, clearSelection,
|
||||
goBack, disconnectSSE, connectGlobalSSE, HUB_URL,
|
||||
agentTyping, notifyTyping, sharedDraft, pushDraft,
|
||||
} = useConversations()
|
||||
|
||||
// ── Identité d'expédition « De : » — défaut = groupe (anonymisé), modifiable par message ──
|
||||
const senders = ref({ default: '', identities: [] })
|
||||
const replySendAs = ref('')
|
||||
const composeSendAs = ref('')
|
||||
async function loadSenders () {
|
||||
try {
|
||||
const r = await fetch(`${HUB_URL}/conversations/senders`)
|
||||
if (!r.ok) return
|
||||
senders.value = await r.json()
|
||||
if (!replySendAs.value) replySendAs.value = senders.value.default || ''
|
||||
if (!composeSendAs.value) composeSendAs.value = senders.value.default || ''
|
||||
} catch { /* le défaut serveur (groupe) s'applique de toute façon */ }
|
||||
}
|
||||
const senderOpts = computed(() => [
|
||||
...(senders.value.identities || []).map(i => ({ label: String(i.from || '').replace(/"/g, ''), value: i.from })),
|
||||
{ label: 'Signer en mon nom', value: 'personal' },
|
||||
])
|
||||
onMounted(loadSenders)
|
||||
|
||||
// Racine dynamique : tiroir (défaut) OU bloc pleine page (prop `inline`) — MÊME composant/logique (DRY).
|
||||
// Fenêtre FLOTTANTE (mode non-inline) : libre, déplaçable par la barre de titre,
|
||||
// et NON bloquante (aucun fond modal) → on peut continuer à naviguer (y compris
|
||||
|
|
@ -925,6 +1102,38 @@ async function attachStatusImage () {
|
|||
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { svcAttaching.value = false }
|
||||
}
|
||||
const hasEmailDraft = computed(() => String(draftHtml.value || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim().length > 0)
|
||||
// ── É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.
|
||||
function emailSrcdoc (html) {
|
||||
const safe = String(html || '')
|
||||
|
|
@ -933,33 +1142,49 @@ function emailSrcdoc (html) {
|
|||
`</head><body>${safe}</body></html>`
|
||||
}
|
||||
const emailExpand = ref({ open: false, html: '', subject: '' })
|
||||
// Aperçu d'un courriel mis en page (composeAdvHtml/advHtml) — {{rating}} → étoiles fictives pour la visu.
|
||||
function previewAdvHtml (html) { emailExpand.value = { open: true, subject: 'Aperçu — courriel mis en page', html: String(html || '').replace(/\{\{\s*rating\s*\}\}/gi, '<div style="font-size:30px;color:#f5b50a;letter-spacing:4px;text-align:center">★★★★★</div>') } }
|
||||
function expandEmail (msg) { emailExpand.value = { open: true, html: msg.html || '', subject: activeDiscussion.value?.lastSubject || activeDiscussion.value?.subject || 'Courriel' } }
|
||||
|
||||
async function sendEmailReply () {
|
||||
if (!hasEmailDraft.value || !activeToken.value) return
|
||||
if ((!hasEmailDraft.value && !replyAttachments.value.length && !advHtml.value) || !activeToken.value) return
|
||||
sending.value = true
|
||||
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(/ /g, ' ').replace(/&/g, '&').trim()
|
||||
await sendMessage(activeToken.value, text, undefined, html) // envoie le HTML dans le fil Gmail (notifyCustomer)
|
||||
draftHtml.value = ''
|
||||
await sendMessage(activeToken.value, text, undefined, html, replyAttachments.value, replySendAs.value) // HTML + pièces jointes + identité « De : »
|
||||
draftHtml.value = ''; replyAttachments.value = []; advHtml.value = ''; advDesign.value.reply = null
|
||||
nextTick(() => scrollToBottom())
|
||||
} 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)
|
||||
const linkDialog = ref({ open: false, loading: false, matches: [], query: '' })
|
||||
// Lier une fiche : suggestions intelligentes (recoupe expéditeur + adresse/nom extraits du courriel) + recherche manuelle multi-champs
|
||||
const linkDialog = ref({ open: false, loading: false, matches: [], suggestions: [], query: '' })
|
||||
const linkIsEmail = computed(() => isEmailConv.value)
|
||||
async function openLink () {
|
||||
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()
|
||||
}
|
||||
// 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 () {
|
||||
linkDialog.value.loading = true
|
||||
try {
|
||||
const q = linkDialog.value.query
|
||||
linkDialog.value.matches = linkIsEmail.value ? await resolveEmail(q) : await resolvePhone(q)
|
||||
const q = (linkDialog.value.query || '').trim()
|
||||
if (q.length < 2) { linkDialog.value.matches = []; return }
|
||||
// Recherche UNIFIÉE multi-champs : nom / courriel / téléphone / adresse (même endpoint que l'autosuggest)
|
||||
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 }
|
||||
}
|
||||
async function doLink (m) {
|
||||
|
|
@ -970,6 +1195,7 @@ async function doLink (m) {
|
|||
linkDialog.value.open = false
|
||||
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
||||
}
|
||||
async function unlinkFiche () { if (!coordToken.value) return; try { await linkCustomer(coordToken.value, '', ''); $q.notify({ type: 'info', message: 'Fiche déliée — vous pouvez relier' }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } }
|
||||
|
||||
// ── PREUVE DE PAIEMENT (OCR) — affiche pour décider ; route vers Facturation ; pas d'écriture auto ──
|
||||
const paymentBusy = ref(null)
|
||||
|
|
@ -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) ──
|
||||
const replyOpen = ref(false)
|
||||
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 => ({ '&': '&', '<': '<', '>': '>' }[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 :</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 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) {
|
||||
|
|
@ -1109,7 +1357,18 @@ const coordPendingUntil = computed(() => activeConv.value?.pendingUntil || activ
|
|||
const pendingDateInput = ref('')
|
||||
watch(coordPendingUntil, v => { pendingDateInput.value = v || '' }, { immediate: true })
|
||||
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 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 }) } }
|
||||
|
|
@ -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)
|
||||
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() }
|
||||
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 } } })
|
||||
const gmailReplyUrl = computed(() => activeConv.value?.gmailReplyUrl || activeDiscussion.value?.gmailReplyUrl || null)
|
||||
const coordNotes = computed(() => activeConv.value?.notes || [])
|
||||
|
|
@ -1143,6 +1403,13 @@ const coordSuggested = computed(() => {
|
|||
return (s && !linked.length) ? s : null
|
||||
})
|
||||
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)
|
||||
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 }) } }
|
||||
|
|
@ -1215,9 +1482,66 @@ const newUploading = ref(false)
|
|||
const imgInput = ref(null)
|
||||
const newCustomerName = ref('')
|
||||
const newSubject = ref('')
|
||||
const newChannel = ref('sms') // 'sms' | 'email' — bascule du dialogue « Nouveau »
|
||||
watch(newDialogOpen, v => { if (v && newDialogChannel.value) newChannel.value = newDialogChannel.value }) // FAB peut présélectionner le canal
|
||||
const newChannel = ref('email') // 'sms' | 'email' — bascule du dialogue « Nouveau » (courriel par défaut ; le FAB texto présélectionne 'sms')
|
||||
watch(newDialogOpen, (v) => {
|
||||
if (!v) return
|
||||
if (newDialogChannel.value) newChannel.value = newDialogChannel.value // FAB / lien peut présélectionner le canal
|
||||
const p = composePrefill && composePrefill.value
|
||||
if (p) { // pré-remplissage (ex. fiche client → brouillon d'invitation d'évaluation)
|
||||
if (p.channel) newChannel.value = p.channel
|
||||
if (p.to) newEmail.value = p.to
|
||||
if (p.phone) newPhone.value = String(p.phone).replace(/\D/g, '')
|
||||
if (p.subject) newSubject.value = p.subject
|
||||
if (p.html) newHtml.value = p.html
|
||||
if (p.advHtml) composeAdvHtml.value = p.advHtml // courriel MIS EN PAGE → envoyé tel quel (bannière « prête »)
|
||||
if (p.text) newMessage.value = p.text
|
||||
if (p.customer) { custPicked.value = { name: p.customer, customer_name: p.customerName || '', email: p.to || '', phone: p.phone || '' }; newCustomerName.value = p.customerName || ''; newLinkChoice.value = p.customer; newMatches.value = [{ name: p.customer, customer_name: p.customerName || '' }] }
|
||||
composePrefill.value = null // consommé une fois
|
||||
}
|
||||
})
|
||||
const newEmail = ref('') // destinataire courriel (mode email)
|
||||
// ── Compose courriel enrichi : éditeur riche (HTML) + Cc/Cci (chips supprimables, autosuggest) ──
|
||||
const newHtml = ref('')
|
||||
const newCc = ref([]); const newBcc = ref([])
|
||||
const ccSearch = ref(''); const ccResults = ref([]); const ccSearching = ref(false); const ccMode = ref('cc')
|
||||
let _ccT = null
|
||||
// Autosuggest du destinataire (À) — même recherche client que Cc/Cci ; choisir = remplir À + lier la fiche.
|
||||
const toResults = ref([]); const toSearching = ref(false); let _toT = null
|
||||
function onToSearch (v) {
|
||||
clearTimeout(_toT); const q = String(v || '').trim()
|
||||
if (q.length < 3 || /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(q)) { toResults.value = []; return } // courriel complet saisi → pas de recherche
|
||||
_toT = setTimeout(async () => {
|
||||
toSearching.value = true
|
||||
try { const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}`); 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(/ /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, '&').replace(/</g, '<').replace(/\n/g, '<br>') + '</p>')
|
||||
newHtml.value = newHtml.value ? (newHtml.value + frag) : frag
|
||||
}
|
||||
const creating = ref(false)
|
||||
const newMatches = ref([]) // fiches trouvées par téléphone
|
||||
const newResolving = ref(false)
|
||||
|
|
@ -1226,6 +1550,12 @@ const newLinkChoice = ref('') // fiche choisie (Customer name) quand plusieu
|
|||
const replyMedia = ref('')
|
||||
const replyUploading = ref(false)
|
||||
const replyImgInput = ref(null)
|
||||
// ── Pièces jointes courriel (images) : réfs vers le store d'actifs + bibliothèque d'équipements ──
|
||||
const replyAttachments = ref([]) // [{asset, filename, mime, url}]
|
||||
const composeAttachments = ref([]) // idem pour la fenêtre « Nouveau courriel »
|
||||
const attUploading = ref(false)
|
||||
const replyAttInput = ref(null); const composeAttInput = ref(null); const presetAddInput = ref(null)
|
||||
const presetDlg = ref({ open: false, target: 'reply', loading: false, list: [] })
|
||||
|
||||
async function uploadImage (file) {
|
||||
const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file) })
|
||||
|
|
@ -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" }) }
|
||||
replyUploading.value = false; if (replyImgInput.value) replyImgInput.value.value = ''
|
||||
}
|
||||
// ── Pièces jointes : upload (images), bibliothèque d'équipements réutilisable ──
|
||||
async function uploadAttachment (file) {
|
||||
const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file) })
|
||||
const r = await fetch(`${HUB_URL}/campaigns/assets/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: file.name, data: dataUrl }) })
|
||||
const d = await r.json(); if (!d.filename) throw new Error(d.error || 'upload')
|
||||
return { asset: d.filename, filename: file.name, mime: d.content_type, url: d.url }
|
||||
}
|
||||
async function onAttachFiles (e, target) {
|
||||
const files = Array.from(e.target.files || []); e.target.value = ''
|
||||
if (!files.length) return
|
||||
attUploading.value = true
|
||||
const list = target === 'compose' ? composeAttachments : replyAttachments
|
||||
for (const f of files) {
|
||||
if (!/^image\//.test(f.type)) { $q.notify({ type: 'warning', message: f.name + ' : images seulement pour l’instant' }); continue }
|
||||
try { list.value.push(await uploadAttachment(f)) } catch (err) { $q.notify({ type: 'negative', message: 'Échec : ' + f.name }) }
|
||||
}
|
||||
attUploading.value = false
|
||||
}
|
||||
function pickReplyAtt () { if (replyAttInput.value) replyAttInput.value.click() }
|
||||
function pickComposeAtt () { if (composeAttInput.value) composeAttInput.value.click() }
|
||||
function guessExt (mime) { return ({ 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/svg+xml': '.svg' }[mime] || '') }
|
||||
async function openPresetGallery (target) {
|
||||
presetDlg.value = { open: true, target, loading: true, list: [] }
|
||||
try { const r = await fetch(`${HUB_URL}/collab/attach-presets`); const d = r.ok ? await r.json() : {}; presetDlg.value.list = d.presets || [] } catch (e) { presetDlg.value.list = [] } finally { presetDlg.value.loading = false }
|
||||
}
|
||||
function pickPreset (p) {
|
||||
const list = presetDlg.value.target === 'compose' ? composeAttachments : replyAttachments
|
||||
if (!list.value.some(a => a.asset === p.asset)) list.value.push({ asset: p.asset, filename: (p.name || 'image') + guessExt(p.mime), mime: p.mime, url: p.url })
|
||||
}
|
||||
function pickPresetUpload () { if (presetAddInput.value) presetAddInput.value.click() }
|
||||
async function addPresetFromFile (e) {
|
||||
const f = (e.target.files || [])[0]; e.target.value = ''
|
||||
if (!f || !/^image\//.test(f.type)) return
|
||||
presetDlg.value.loading = true
|
||||
try {
|
||||
const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(f) })
|
||||
const name = (f.name || 'Image').replace(/\.[^.]+$/, '')
|
||||
const r = await fetch(`${HUB_URL}/collab/attach-presets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, category: 'Équipement', data: dataUrl }) })
|
||||
const d = r.ok ? await r.json() : {}
|
||||
if (d.preset) presetDlg.value.list.push(d.preset); else $q.notify({ type: 'negative', message: d.error || 'Échec de l’ajout' })
|
||||
} catch (err) { $q.notify({ type: 'negative', message: 'Échec de l’ajout' }) } finally { presetDlg.value.loading = false }
|
||||
}
|
||||
async function removePreset (p) {
|
||||
try { await fetch(`${HUB_URL}/collab/attach-presets/${encodeURIComponent(p.id)}`, { method: 'DELETE' }); presetDlg.value.list = presetDlg.value.list.filter(x => x.id !== p.id) } catch (e) { /* */ }
|
||||
}
|
||||
const matchOptions = computed(() => newMatches.value.map(m => ({ label: (m.customer_name || m.name) + (m.territory ? ' · ' + m.territory : (m.email ? ' · ' + m.email : '')), value: m.name })))
|
||||
let _resolveT = null
|
||||
watch(newPhone, (v) => {
|
||||
|
|
@ -1261,7 +1636,7 @@ watch(newPhone, (v) => {
|
|||
newResolving.value = false
|
||||
}, 450)
|
||||
})
|
||||
function resetNew () { newPhone.value = ''; newEmail.value = ''; newMessage.value = ''; newMedia.value = ''; newCustomerName.value = ''; newSubject.value = ''; newMatches.value = []; newLinkChoice.value = ''; custSearch.value = ''; custResults.value = []; custPicked.value = null } // 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.
|
||||
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.
|
||||
async function createNewEmail () {
|
||||
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
|
||||
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()
|
||||
$q.notify({ type: 'positive', icon: 'mail', message: 'Courriel envoyé' })
|
||||
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-no { border-color: #e2e8f0; background: #f8fafc; }
|
||||
.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 {
|
||||
background: #fff;
|
||||
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
|
||||
(sinon leur contenu déborde et chevauche la rangée suivante). Seule la zone des
|
||||
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 */
|
||||
.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; }
|
||||
|
|
@ -1562,6 +1939,12 @@ function formatDate (dateStr) {
|
|||
|
||||
/* 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-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; }
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
<template>
|
||||
<div class="mlist" :class="{ 'mlist-compact': compact }">
|
||||
<div class="mlist-head">
|
||||
<q-btn unelevated no-caps rounded icon="edit" label="Composer" class="mlist-compose" @click="openCompose">
|
||||
<q-tooltip>Nouveau message (courriel par défaut, ou texto)</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn-toggle v-model="chanView" :options="chanOptions" dense unelevated no-caps toggle-color="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">
|
||||
<template #prepend><q-icon name="search" size="18px" /></template>
|
||||
</q-input>
|
||||
<q-btn flat dense round :icon="compact ? 'density_small' : 'density_medium'" size="sm" @click="toggleDensity"><q-tooltip>{{ compact ? 'Affichage confortable' : 'Affichage compact' }}</q-tooltip></q-btn>
|
||||
<q-btn flat dense round :icon="notifyEnabled ? 'notifications_active' : 'notifications_none'" :color="notifyEnabled ? 'indigo-6' : 'grey-6'" size="sm" @click="toggleNotify"><q-tooltip>{{ notifyEnabled ? 'Notifications navigateur activées' : 'Activer les notifications de nouveaux courriels' }}</q-tooltip></q-btn>
|
||||
<q-btn flat dense round icon="refresh" size="sm" :loading="loading" @click="fetchList" />
|
||||
<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>
|
||||
|
||||
<!-- 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">
|
||||
<q-icon v-if="replyInfo(d).client" name="reply" size="14px" color="teal-6" class="mrow-rep"><q-tooltip>Le client a répondu</q-tooltip></q-icon>
|
||||
<q-icon v-if="replyInfo(d).team" name="forum" size="13px" color="indigo-5" class="mrow-rep"><q-tooltip>Un collègue a répondu</q-tooltip></q-icon>
|
||||
<span v-if="typingLabel(d)" class="mrow-typing"><q-icon name="edit" size="11px" /> {{ typingLabel(d) }}</span>
|
||||
<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" />
|
||||
<span class="mrow-time">{{ relTime(lastTs(d)) }}</span>
|
||||
</div>
|
||||
|
|
@ -82,7 +89,7 @@
|
|||
</template>
|
||||
</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>
|
||||
|
||||
<!-- « 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 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
|
||||
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) || '' } }
|
||||
|
|
@ -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.' })
|
||||
}
|
||||
const meEmail = computed(() => { try { return useAuthStore().user } catch { return null } })
|
||||
// ── Brouillons : qui rédige une réponse pour ce client (live SSE OU brouillon persisté) → pastille avatar ──
|
||||
function draftAgentOf (d) {
|
||||
for (const c of (d.conversations || [])) { const a = agentTyping.value[c.token]; if (a) return a } // tape en ce moment
|
||||
for (const c of (d.conversations || [])) { const sd = sharedDraft.value[c.token]; if (sd && sd.agent) return sd.agent } // brouillon mirroré (live)
|
||||
return d.draftAgent || null // brouillon persisté (au chargement)
|
||||
}
|
||||
function agentInitials (a) {
|
||||
const parts = String(a || '').split('@')[0].replace(/[._-]+/g, ' ').trim().split(/\s+/).filter(Boolean)
|
||||
return ((parts[0] || '?')[0] + (parts[1] ? parts[1][0] : (parts[0] || '?').slice(1, 2))).toUpperCase()
|
||||
}
|
||||
function agentName (a) { return String(a || '').split('@')[0].replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }
|
||||
|
||||
const sel = ref(new Set()) // filtres cumulables (départements OU entre eux ; unread/mine = ET)
|
||||
const search = ref('')
|
||||
// ── 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) { /* */ }
|
||||
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)
|
||||
|
|
@ -188,10 +219,10 @@ const chips = computed(() => {
|
|||
{ key: 'unread', label: 'Non lus', count: visible.value.filter(isUnread).length },
|
||||
{ key: 'mine', label: 'À moi', count: visible.value.filter(d => d.assignee === meEmail.value).length },
|
||||
]
|
||||
const draftN = visible.value.filter(draftAgentOf).length
|
||||
if (draftN) base.push({ key: 'draft', label: '✎ Brouillons', count: draftN })
|
||||
for (const q of QUEUES) base.push({ key: q, label: qLabel(q), count: visible.value.filter(d => (d.queue || '') === q).length })
|
||||
// Filtre par CANAL — seulement si plusieurs canaux présents (sinon inutile dans une boîte tout-courriel)
|
||||
const chans = [...new Set(visible.value.map(d => d.channel || 'email'))]
|
||||
if (chans.length > 1) for (const ch of chans) base.push({ key: 'ch:' + ch, label: CH_LABELS[ch] || ch, count: visible.value.filter(d => (d.channel || 'email') === ch).length })
|
||||
// (Filtre par canal = segment « Tous / Courriel / Clavardage » de l'en-tête, style Gmail.)
|
||||
if (maskedCount.value) base.push({ key: 'masked', label: '🔇 Masqués', count: maskedCount.value }) // voir ce qui est filtré/masqué
|
||||
return base
|
||||
})
|
||||
|
|
@ -204,11 +235,13 @@ function toggle (k) {
|
|||
const shown = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
const depts = [...sel.value].filter(k => QUEUES.includes(k))
|
||||
const chans = [...sel.value].filter(k => k.startsWith('ch:')).map(k => k.slice(3))
|
||||
const needU = sel.value.has('unread'); const needM = sel.value.has('mine')
|
||||
const needU = sel.value.has('unread'); const needM = sel.value.has('mine'); const needD = sel.value.has('draft')
|
||||
return visible.value.filter(d => {
|
||||
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 (needM && d.assignee !== meEmail.value) 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-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px 4px; }
|
||||
.mlist-search { flex: 1; max-width: 520px; }
|
||||
.mlist-compose { font-weight: 600; padding: 5px 18px; flex: 0 0 auto; background: #00C853 !important; color: #fff !important; }
|
||||
.mlist-chanseg { border: 1px solid #e2e8f0; border-radius: 999px; overflow: hidden; flex: 0 0 auto; }
|
||||
/* Une SEULE ligne (les puces défilent horizontalement si trop nombreuses) → hauteur constante, donc la sélection ne décale jamais la liste */
|
||||
.mlist-tabs { display: flex; flex-wrap: nowrap; align-items: center; gap: 4px; padding: 6px 12px; border-bottom: 1px solid var(--ops-border, #e2e8f0); overflow-x: auto; min-height: 40px; }
|
||||
.mlist-tabs::-webkit-scrollbar { height: 0; }
|
||||
|
|
|
|||
55
apps/ops/src/components/shared/NotificationBell.vue
Normal file
55
apps/ops/src/components/shared/NotificationBell.vue
Normal 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 :</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>
|
||||
|
|
@ -22,6 +22,9 @@ const loading = ref(false)
|
|||
const panelOpen = ref(false)
|
||||
const newDialogOpen = ref(false) // dialogue « Nouveau texto » partagé (ouvrable depuis le FAB sticky ou le panneau)
|
||||
const newDialogChannel = ref('sms') // canal présélectionné quand on ouvre le dialogue (sms | email) — posé par le FAB
|
||||
// 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())
|
||||
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
|
||||
}
|
||||
|
||||
async function sendMessage (token, text, media, html) {
|
||||
const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ text, media, html }) })
|
||||
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, attachments, sendAs }) })
|
||||
if (!res.ok) throw new Error('Failed to send')
|
||||
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.
|
||||
async function resolvePhone (phone) {
|
||||
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
|
||||
async function startEmail ({ to, subject, body, customer, customerName }) {
|
||||
const res = await fetch(`${HUB_URL}/conversations/email-new`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ to, subject, body, customer, customerName }) })
|
||||
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, 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é') }
|
||||
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) }
|
||||
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).
|
||||
async function createTicket (token, payload) {
|
||||
|
|
@ -478,10 +511,10 @@ export function useConversations () {
|
|||
|
||||
return {
|
||||
conversations, discussions, activeToken, activeDiscussion, activeConv, tickets,
|
||||
loading, panelOpen, newDialogOpen, newDialogChannel, createStandaloneTicket, showAll, selectedIds, selectedDiscussions,
|
||||
fetchList, fetchTickets, openDiscussion, openConversation, sendMessage, startConversation, startEmail, resolvePhone, resolveEmail, linkCustomer,
|
||||
loading, panelOpen, newDialogOpen, newDialogChannel, composePrefill, openComposeDraft, createStandaloneTicket, showAll, selectedIds, selectedDiscussions,
|
||||
fetchList, fetchTickets, openDiscussion, openConversation, sendMessage, pollNow, startConversation, startEmail, resolvePhone, resolveEmail, linkCustomer,
|
||||
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,
|
||||
toggleSelect, selectAll, clearSelection,
|
||||
goBack, disconnectSSE, connectGlobalSSE, activeCount, HUB_URL,
|
||||
|
|
|
|||
98
apps/ops/src/composables/useNotifications.js
Normal file
98
apps/ops/src/composables/useNotifications.js
Normal 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 } }
|
||||
|
|
@ -11,6 +11,7 @@ export const navItems = [
|
|||
{ path: '/rdv', icon: 'CalendarClock', label: 'Rendez-vous', 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: '/evaluations', icon: 'Star', label: 'Évaluations', requires: 'view_clients' },
|
||||
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
||||
// ── Administration (replié) ──
|
||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', group: 'admin' },
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const columns = [
|
|||
{ 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 = {}
|
||||
if (statusFilter === 'not_closed') {
|
||||
filters.status = ['!=', 'Closed']
|
||||
|
|
@ -42,8 +42,8 @@ export function buildFilters ({ statusFilter, typeFilter, priorityFilter, search
|
|||
filters.subject = ['like', '%' + q + '%']
|
||||
}
|
||||
}
|
||||
if (ownerFilter === 'mine') {
|
||||
filters.owner = ['like', '%']
|
||||
if (ownerFilter === 'mine' && me) {
|
||||
filters.owner = me // « Mes tickets » = créés/possédés par l'agent connecté (champ owner, toujours requêtable)
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
<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-space />
|
||||
<NotificationBell v-if="can('view_clients')" dark />
|
||||
<q-btn v-if="!isDispatch" flat round dense icon="search" color="white" @click="mobileSearchOpen = !mobileSearchOpen" />
|
||||
</q-toolbar>
|
||||
<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>
|
||||
</div>
|
||||
<NotificationBell v-if="can('view_clients')" class="q-ml-sm" />
|
||||
</div>
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
|
|
@ -156,6 +158,7 @@ import {
|
|||
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History,
|
||||
} from 'lucide-vue-next'
|
||||
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
||||
import NotificationBell from 'src/components/shared/NotificationBell.vue'
|
||||
import { useConversations } from 'src/composables/useConversations'
|
||||
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
||||
import NewTicketDialog from 'src/components/shared/NewTicketDialog.vue'
|
||||
|
|
|
|||
|
|
@ -151,6 +151,9 @@
|
|||
<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.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="row q-col-gutter-sm">
|
||||
<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">
|
||||
<div class="text-caption text-grey-8">
|
||||
Sauvegarde l'éditeur d'abord pour tester la dernière version.
|
||||
Envoyé via Mailjet depuis <code>TARGO <support@targointernet.com></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 <support@targointernet.com></code>.</template>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
|
|
@ -238,6 +242,7 @@ import { useQuasar } from 'quasar'
|
|||
import { EmailEditor } from 'vue-email-editor'
|
||||
import { listTemplates, getTemplate, saveTemplate as saveTemplateApi,
|
||||
previewTemplate, testSendTemplate, translateTemplate } from 'src/api/campaigns'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -473,6 +478,24 @@ function onEditorLoad () {
|
|||
async function onEditorReady () {
|
||||
// Fired when the editor INSIDE the iframe is ready to accept loadDesign()
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -541,6 +564,7 @@ const sampleExpiryDate = new Date(Date.now() + 30 * 86400 * 1000)
|
|||
const testSendForm = ref({
|
||||
to: 'louis@targo.ca',
|
||||
subject: '[TEST] Aperçu du courriel TARGO',
|
||||
via: 'gmail',
|
||||
vars: {
|
||||
firstname: 'Louis', lastname: 'Test', amount: '60 $',
|
||||
commitment_months: '3',
|
||||
|
|
@ -559,8 +583,9 @@ async function doTestSend () {
|
|||
to: testSendForm.value.to.trim(),
|
||||
subject: testSendForm.value.subject,
|
||||
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
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: 'Échec envoi: ' + e.message })
|
||||
|
|
|
|||
|
|
@ -13,6 +13,67 @@
|
|||
<template #info><CustomerInfoCard :customer="customer" /></template>
|
||||
</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">
|
||||
<template #header>
|
||||
<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 { BASE_URL } from 'src/config/erpnext'
|
||||
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 { useDetailModal } from 'src/composables/useDetailModal'
|
||||
import { useSubscriptionGroups, isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
|
||||
import { useSubscriptionActions } from 'src/composables/useSubscriptionActions'
|
||||
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 { deviceLucideIcon } from 'src/config/device-icons'
|
||||
import DetailModal from 'src/components/shared/DetailModal.vue'
|
||||
|
|
@ -1442,6 +1504,41 @@ function onContractTerminated (payload) {
|
|||
}
|
||||
|
||||
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 totalOutstanding = computed(() => accountBalance.value?.balance ?? 0)
|
||||
|
|
@ -1594,6 +1691,8 @@ async function focusLocationFromQuery () {
|
|||
onMounted(async () => {
|
||||
await loadCustomer(props.id)
|
||||
focusLocationFromQuery()
|
||||
loadComms()
|
||||
loadRatings()
|
||||
})
|
||||
watch(() => route.query?.location, () => focusLocationFromQuery())
|
||||
</script>
|
||||
|
|
|
|||
137
apps/ops/src/pages/EvaluationsPage.vue
Normal file
137
apps/ops/src/pages/EvaluationsPage.vue
Normal 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>
|
||||
|
|
@ -210,43 +210,31 @@
|
|||
</q-menu>
|
||||
</q-btn>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</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)">
|
||||
<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 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 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-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 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-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)"></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 -->
|
||||
<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">
|
||||
<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>
|
||||
<!-- 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>
|
||||
</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 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>
|
||||
<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>
|
||||
</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-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)"
|
||||
@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. 7–18h), 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>
|
||||
<!-- 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 -->
|
||||
<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-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>
|
||||
<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>
|
||||
|
|
@ -1082,6 +1070,21 @@
|
|||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<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) -->
|
||||
<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>
|
||||
|
|
@ -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>
|
||||
<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 -->
|
||||
<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>
|
||||
|
|
@ -1528,7 +1532,8 @@ async function jdRemoveAssistant (techId) {
|
|||
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 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) }
|
||||
// Drop d'une job sur une lane : assigne (onCellDrop → hub) PUIS crée un quart 8–16 auto si le tech n'a aucun quart régulier ce jour.
|
||||
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 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.
|
||||
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) {
|
||||
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 (/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
|
||||
}
|
||||
// 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) {
|
||||
const s = String(sk || '').toLowerCase()
|
||||
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) ──
|
||||
// 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 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) {
|
||||
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).
|
||||
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
|
||||
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) ──
|
||||
// → 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('')
|
||||
|
|
@ -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 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)
|
||||
// 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 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.
|
||||
|
|
@ -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
|
||||
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 }
|
||||
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.
|
||||
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 .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); }
|
||||
.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) */
|
||||
.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; }
|
||||
|
|
@ -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é */
|
||||
.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); }
|
||||
/* 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 .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; }
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ import { formatDate } from 'src/composables/useFormatters'
|
|||
import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
|
||||
import { useDetailModal } from 'src/composables/useDetailModal'
|
||||
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'
|
||||
|
||||
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 FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
||||
|
||||
const me = useAuthStore().user // courriel de l'agent connecté (pour « Mes tickets »)
|
||||
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 priorityFilter = ref(null)
|
||||
const ownerFilter = ref('all')
|
||||
|
|
@ -198,6 +200,7 @@ async function loadTickets () {
|
|||
priorityFilter: priorityFilter.value,
|
||||
search: search.value,
|
||||
ownerFilter: ownerFilter.value,
|
||||
me,
|
||||
})
|
||||
const limit = Math.min(pagination.value.rowsPerPage, 100)
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const routes = [
|
|||
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
|
||||
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
||||
{ path: 'historique', component: () => import('src/pages/HistoriquePage.vue') },
|
||||
{ path: 'evaluations', component: () => import('src/pages/EvaluationsPage.vue') },
|
||||
{ path: 'planification', component: () => import('src/pages/PlanificationPage.vue') },
|
||||
{ path: 'conformite-adresses', component: () => import('src/pages/AddressConformityPage.vue') },
|
||||
{ path: 'rdv', component: () => import('src/pages/RendezVousPage.vue') },
|
||||
|
|
|
|||
130
docs/design/prepaid-service-model.md
Normal file
130
docs/design/prepaid-service-model.md
Normal 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.**
|
||||
|
|
@ -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"> </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>
|
||||
370
scripts/campaigns/templates/transactional-rating-invite-en.json
Normal file
370
scripts/campaigns/templates/transactional-rating-invite-en.json
Normal 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\"> </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
|
||||
}
|
||||
|
|
@ -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"> </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>
|
||||
370
scripts/campaigns/templates/transactional-rating-invite-fr.json
Normal file
370
scripts/campaigns/templates/transactional-rating-invite-fr.json
Normal 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\"> </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
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ const { log, json, parseBody } = require('./helpers')
|
|||
const erp = require('./erp')
|
||||
const email = require('./email')
|
||||
const gmail = require('./gmail')
|
||||
const rating = require('./rating')
|
||||
const sse = require('./sse')
|
||||
// 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.
|
||||
|
|
@ -1896,23 +1897,34 @@ async function handle (req, res, method, path) {
|
|||
view_url: '',
|
||||
...(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 subject = body.subject || `[TEST] Template ${name}`
|
||||
const via = String(body.via || '').toLowerCase()
|
||||
const fromAddr = body.from || cfg.MAIL_FROM || 'TARGO <support@targointernet.com>'
|
||||
try {
|
||||
const info = await email.sendEmail({
|
||||
to,
|
||||
from: fromAddr,
|
||||
subject: body.subject || `[TEST] Template ${name}`,
|
||||
html: rendered,
|
||||
headers: { 'X-MJ-CustomID': `test-send:${name}:${Date.now()}` },
|
||||
})
|
||||
if (!info) return json(res, 500, { error: 'SMTP send returned false (see hub logs)' })
|
||||
log(`test-send: ${name} → ${to} (${rendered.length} bytes)`)
|
||||
return json(res, 200, {
|
||||
sent: true, to, from: fromAddr,
|
||||
message_id: info.messageId || null,
|
||||
bytes: rendered.length,
|
||||
})
|
||||
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({
|
||||
to, from: fromAddr, subject, html: rendered,
|
||||
headers: { 'X-MJ-CustomID': `test-send:${name}:${Date.now()}` },
|
||||
})
|
||||
if (!info) return json(res, 500, { error: 'SMTP send returned false (see hub logs)' })
|
||||
messageId = info.messageId || null
|
||||
}
|
||||
log(`test-send: ${name} → ${to} via ${channel} (${rendered.length} bytes)`)
|
||||
return json(res, 200, { sent: true, to, from: usedFrom, channel, message_id: messageId, bytes: rendered.length })
|
||||
} catch (e) {
|
||||
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$/)
|
||||
if (tplPreview && method === 'POST') {
|
||||
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)
|
||||
.toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'America/Montreal' })
|
||||
const vars = {
|
||||
|
|
@ -2445,6 +2460,8 @@ rebuildTokenIndex()
|
|||
module.exports = {
|
||||
handle,
|
||||
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
|
||||
parseCsv, parseMapCsv, parseGiftbitCsv,
|
||||
matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ module.exports = {
|
|||
ERP_TOKEN: env('ERP_TOKEN'),
|
||||
ERP_SITE: env('ERP_SITE', 'erp.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_AUTH_TOKEN: env('TWILIO_AUTH_TOKEN'),
|
||||
TWILIO_FROM: env('TWILIO_FROM'),
|
||||
|
|
|
|||
|
|
@ -61,20 +61,21 @@ function getConversation (token) {
|
|||
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() }
|
||||
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 (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 (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)
|
||||
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
|
||||
saveToDisk()
|
||||
sse.broadcast('conv:' + conv.token, 'conv-message', { token: conv.token, 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
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +119,25 @@ async function sendSmsFallback (conv, text, media) {
|
|||
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) {
|
||||
// Conversation EMAIL → on RÉPOND par courriel dans le MÊME fil Gmail (pas SMS/push).
|
||||
if (conv.channel === 'email' && conv.email) {
|
||||
|
|
@ -125,7 +145,8 @@ async function notifyCustomer (conv, message) {
|
|||
const gmail = require('./gmail')
|
||||
const base = conv.lastSubject || conv.subject || ''
|
||||
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
|
||||
log(`Email reply sent for conv ${conv.token} → ${conv.email} (gmail ${r.id})`)
|
||||
return 'email'
|
||||
|
|
@ -303,6 +324,27 @@ function agentSendFrom (email) {
|
|||
const addr = (String(base).match(/<([^>]+)>/) || [null, base])[1].trim()
|
||||
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) {
|
||||
const s = String(subject || '')
|
||||
const e = String(email || '').trim()
|
||||
|
|
@ -421,6 +463,8 @@ function listConversations ({ includeAll = false } = {}) {
|
|||
d.gmailReplyUrl = gmailReplyUrl(latest)
|
||||
d.noise = !!(latest && latest.noise)
|
||||
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)) }
|
||||
|
|
@ -747,7 +791,7 @@ async function channelIngest ({ channel, external_id, from, name, text, html, cu
|
|||
} catch (e) { /* triage best-effort */ }
|
||||
if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.subject).catch(() => {})
|
||||
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
|
||||
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.
|
||||
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 dest = triage.extractEmail(to) || String(to || '').trim()
|
||||
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)
|
||||
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)
|
||||
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.id) conv.lastGmailId = r.id
|
||||
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(() => {})
|
||||
conv.lastHumanDate = todayET(); saveToDisk()
|
||||
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')
|
||||
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).
|
||||
// « 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') {
|
||||
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)
|
||||
|
|
@ -1201,6 +1282,32 @@ async function handle (req, res, method, p, url) {
|
|||
const r = await analyzePaymentProof(token, b)
|
||||
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.
|
||||
if (sub === 'summarize' && (method === 'POST' || method === 'GET')) {
|
||||
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 »
|
||||
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)
|
||||
const b = await parseBody(req)
|
||||
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).
|
||||
if (sub === 'link' && method === 'POST') {
|
||||
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
|
||||
// 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)) {
|
||||
|
|
@ -1371,12 +1503,13 @@ async function handle (req, res, method, p, url) {
|
|||
const text = (body.text || '').trim()
|
||||
const media = body.media || ''
|
||||
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 from = agentEmail ? 'agent' : 'customer'
|
||||
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)
|
||||
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 === '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é
|
||||
|
|
|
|||
|
|
@ -111,29 +111,55 @@ const htmlToText = (html) => String(html || '')
|
|||
.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(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<br\s*\/?>(?=)/gi, '\n').replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
|
||||
function buildRfc822 ({ to, subject, body, html, inReplyTo, references, from }) {
|
||||
const h = ['From: ' + encodeAddress(from || sendFrom()), 'To: ' + encodeAddress(to), 'Subject: ' + encodeHeader(subject || ''), 'MIME-Version: 1.0']
|
||||
if (inReplyTo) h.push('In-Reply-To: ' + inReplyTo)
|
||||
if (references || inReplyTo) h.push('References: ' + (references || inReplyTo))
|
||||
if (html) { // multipart/alternative : repli texte + HTML (déliverabilité + clients sans HTML)
|
||||
// Encode une LISTE d'adresses séparées par des virgules (« a@x, Nom <b@y> ») — chaque adresse en RFC 2047.
|
||||
function encodeAddressList (s) { return String(s || '').split(',').map(x => x.trim()).filter(Boolean).map(encodeAddress).join(', ') }
|
||||
// Entité MIME du CORPS (sans en-têtes d'enveloppe) : multipart/alternative si HTML, sinon texte simple.
|
||||
function bodyEntity ({ body, 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)
|
||||
h.push('Content-Type: multipart/alternative; boundary="' + bnd + '"')
|
||||
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/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')
|
||||
}
|
||||
h.push('Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit')
|
||||
return h.join('\r\n') + '\r\n\r\n' + (body || '')
|
||||
return h.join('\r\n') + '\r\n' + bodyEntity({ body, html })
|
||||
}
|
||||
// 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')
|
||||
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
|
||||
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(() => ({}))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
is_commercial: Number(a.commercial) ? 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),
|
||||
}
|
||||
}
|
||||
|
|
@ -192,7 +194,7 @@ async function fResiliatedAccountIds () {
|
|||
}
|
||||
|
||||
// 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 »).
|
||||
const BOOL_FIELDS = ['ppa_enabled', 'is_commercial', 'is_bad_payer']
|
||||
// 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 {
|
||||
const p = pool(); if (!p) throw new Error('miroir legacy-db indisponible (mysql2 absent)')
|
||||
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 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 = []
|
||||
for (let i = 0; i < missing.length; 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)
|
||||
}
|
||||
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)
|
||||
if (!legacy_account_id) throw new Error('legacy_account_id requis')
|
||||
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')
|
||||
const m = mapAccount(rows[0])
|
||||
const PROTECT = new Set(['customer_name']) // nettoyé à la migration, jamais réécrit par le brut F
|
||||
|
|
|
|||
269
services/targo-hub/lib/rating.js
Normal file
269
services/targo-hub/lib/rating.js
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 ★/☆ — 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">☆</div>`
|
||||
+ `<div lang="x-full-star" style="margin:0;display:none;width:0;height:0;max-height:0;overflow:hidden;float:left">★</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 !</h1>
|
||||
<p>On vise toujours le 5 étoiles. En quelques mots, qu'est-ce qui n'a pas été à la hauteur — ou comment pourrait-on faire mieux ? 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 ! 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"> </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 }
|
||||
|
|
@ -27,6 +27,8 @@ async function searchCustomers (q) {
|
|||
const found = new Map() // name -> { matched, address? }
|
||||
// 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) { /* */ } }
|
||||
// 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
|
||||
try { p = require('./address-db').pool() } catch (e) { /* address-db indispo */ }
|
||||
// 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 })
|
||||
}
|
||||
}
|
||||
// 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 []
|
||||
// Enrichissement contacts (1 requête) : courriel + téléphones → résumé + « SMS dispo ? » (mobile présent).
|
||||
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') {
|
||||
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') {
|
||||
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)
|
||||
if (p === '/collab/service-locations' && method === 'GET') {
|
||||
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(/\/$/, '')
|
||||
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
|
||||
if (p === '/collab/ticket' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
|
|
|
|||
114
services/targo-hub/scripts/gen-rating-invite-templates.js
Normal file
114
services/targo-hub/scripts/gen-rating-invite-templates.js
Normal 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"> </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"> </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`)
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ const server = http.createServer(async (req, res) => {
|
|||
{
|
||||
const PUBLIC = [
|
||||
'/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',
|
||||
'/api/checkout', '/api/catalog', '/api/order', '/api/address', '/api/otp',
|
||||
'/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
|
||||
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) {
|
||||
const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
|
||||
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
|
||||
// 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('/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('/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)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<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: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">
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"containerPadding": "28px 28px 10px",
|
||||
"anchor": "",
|
||||
"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,
|
||||
"height": "auto",
|
||||
"autoWidth": false,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<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: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">
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"containerPadding": "28px 28px 10px",
|
||||
"anchor": "",
|
||||
"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,
|
||||
"height": "auto",
|
||||
"autoWidth": false,
|
||||
|
|
|
|||
|
|
@ -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"> </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>
|
||||
370
services/targo-hub/templates/transactional-rating-invite-en.json
Normal file
370
services/targo-hub/templates/transactional-rating-invite-en.json
Normal 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\"> </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
|
||||
}
|
||||
|
|
@ -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"> </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>
|
||||
370
services/targo-hub/templates/transactional-rating-invite-fr.json
Normal file
370
services/targo-hub/templates/transactional-rating-invite-fr.json
Normal 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\"> </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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user