From 6628eaaa5b2b46d1bddb3df990e57482f3b82868 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Tue, 16 Jun 2026 22:41:51 -0400 Subject: [PATCH] feat(ops+hub): sender picker, per-user notifs, editable rating emails, planif & tickets UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/ops/quasar.config.js | 2 +- apps/ops/src/api/campaigns.js | 4 +- apps/ops/src/boot/tooltip-ux.js | 37 ++ .../settings/CannedResponsesCard.vue | 13 +- .../components/shared/ConversationPanel.vue | 567 +++++++++++++++--- .../ops/src/components/shared/MessageList.vue | 55 +- .../components/shared/NotificationBell.vue | 55 ++ apps/ops/src/composables/useConversations.js | 47 +- apps/ops/src/composables/useNotifications.js | 98 +++ apps/ops/src/config/nav.js | 1 + apps/ops/src/config/ticket-config.js | 6 +- apps/ops/src/layouts/MainLayout.vue | 3 + .../campaigns/pages/TemplateEditorPage.vue | 29 +- apps/ops/src/pages/ClientDetailPage.vue | 101 +++- apps/ops/src/pages/EvaluationsPage.vue | 137 +++++ apps/ops/src/pages/PlanificationPage.vue | 102 +++- apps/ops/src/pages/TicketsPage.vue | 5 +- apps/ops/src/router/index.js | 1 + docs/design/prepaid-service-model.md | 130 ++++ .../transactional-rating-invite-en.html | 1 + .../transactional-rating-invite-en.json | 370 ++++++++++++ .../transactional-rating-invite-fr.html | 1 + .../transactional-rating-invite-fr.json | 370 ++++++++++++ services/targo-hub/lib/campaigns.js | 47 +- services/targo-hub/lib/config.js | 1 + services/targo-hub/lib/conversation.js | 153 ++++- services/targo-hub/lib/gmail.js | 52 +- services/targo-hub/lib/legacy-sync.js | 10 +- services/targo-hub/lib/rating.js | 269 +++++++++ services/targo-hub/lib/ticket-collab.js | 131 +++- .../scripts/gen-rating-invite-templates.js | 114 ++++ services/targo-hub/server.js | 9 +- .../templates/transactional-excuse-en.html | 2 +- .../templates/transactional-excuse-en.json | 2 +- .../templates/transactional-excuse-fr.html | 2 +- .../templates/transactional-excuse-fr.json | 2 +- .../transactional-rating-invite-en.html | 1 + .../transactional-rating-invite-en.json | 370 ++++++++++++ .../transactional-rating-invite-fr.html | 1 + .../transactional-rating-invite-fr.json | 370 ++++++++++++ 40 files changed, 3476 insertions(+), 195 deletions(-) create mode 100644 apps/ops/src/boot/tooltip-ux.js create mode 100644 apps/ops/src/components/shared/NotificationBell.vue create mode 100644 apps/ops/src/composables/useNotifications.js create mode 100644 apps/ops/src/pages/EvaluationsPage.vue create mode 100644 docs/design/prepaid-service-model.md create mode 100644 scripts/campaigns/templates/transactional-rating-invite-en.html create mode 100644 scripts/campaigns/templates/transactional-rating-invite-en.json create mode 100644 scripts/campaigns/templates/transactional-rating-invite-fr.html create mode 100644 scripts/campaigns/templates/transactional-rating-invite-fr.json create mode 100644 services/targo-hub/lib/rating.js create mode 100644 services/targo-hub/scripts/gen-rating-invite-templates.js create mode 100644 services/targo-hub/templates/transactional-rating-invite-en.html create mode 100644 services/targo-hub/templates/transactional-rating-invite-en.json create mode 100644 services/targo-hub/templates/transactional-rating-invite-fr.html create mode 100644 services/targo-hub/templates/transactional-rating-invite-fr.json diff --git a/apps/ops/quasar.config.js b/apps/ops/quasar.config.js index 8f88588..a6902da 100644 --- a/apps/ops/quasar.config.js +++ b/apps/ops/quasar.config.js @@ -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'], diff --git a/apps/ops/src/api/campaigns.js b/apps/ops/src/api/campaigns.js index aefb323..967d78b 100644 --- a/apps/ops/src/api/campaigns.js +++ b/apps/ops/src/api/campaigns.js @@ -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 }, }) } diff --git a/apps/ops/src/boot/tooltip-ux.js b/apps/ops/src/boot/tooltip-ux.js new file mode 100644 index 0000000..c8ef3c3 --- /dev/null +++ b/apps/ops/src/boot/tooltip-ux.js @@ -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 ). 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 */ } +}) diff --git a/apps/ops/src/components/settings/CannedResponsesCard.vue b/apps/ops/src/components/settings/CannedResponsesCard.vue index 2d9c106..77020fe 100644 --- a/apps/ops/src/components/settings/CannedResponsesCard.vue +++ b/apps/ops/src/components/settings/CannedResponsesCard.vue @@ -1,12 +1,15 @@ @@ -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() } } diff --git a/apps/ops/src/components/shared/ConversationPanel.vue b/apps/ops/src/components/shared/ConversationPanel.vue index e3c0d6c..47dcaa9 100644 --- a/apps/ops/src/components/shared/ConversationPanel.vue +++ b/apps/ops/src/components/shared/ConversationPanel.vue @@ -123,16 +123,13 @@
{{ formatDate(activeDiscussion.date) }} · {{ activeDiscussion.messageCount }} messages · {{ activeDiscussion.email }}
- FicheFiche liée ({{ activeDiscussion.customerName }}) — clic pour changer + {{ activeDiscussion.customerName || activeDiscussion.customer }}Fiche liée — clic : changer · ✕ : délier (puis relier) Associer cette conversation à une fiche client
{{ t.name }}{{ t.subject }} — ouvrir le ticket ↗
- - Créer un ticket à partir de cette conversation (la conversation reste dans l'historique) - Archiver (ferme + retire la discussion) @@ -151,14 +148,59 @@ - + +
+ + + + Je m'en occupe + + Assigner — 1er = responsable, puis assistants + + + + + {{ staffInitials(noteAuthorName(a)) }} + {{ shortAgent(a) }} + ResponsableAssistant + + Aucun agent + + Désassigner + Créer un ticket… + + + + {{ shortAgent(coordAssignee) }}Responsable — ✕ pour libérer + {{ shortAgent(a) }}Assistant + + Indice rapide pour l'équipe + l'IA (jamais envoyé au client) +
+
+
+ + +
+
+ + +
+
+ +
File {{ qLabel(q) }} - - {{ shortAgent(coordAssignee) }}Pris par {{ coordAssignee }} — ✕ pour libérer - {{ coordOpen ? 'Masquer les actions' : 'Coordination : état, assigner, scinder, fusionner, IA…' }} + {{ coordOpen ? 'Masquer les actions' : 'Coordination avancée : état, scinder, fusionner, IA…' }}
@@ -195,31 +237,6 @@ label="Attendre jusqu'au" class="q-ml-xs" style="max-width:160px" @update:model-value="v => setState('pending')"> - - - - Je m'en occupe - - Assigner à - - {{ shortAgent(a) }} - - Désassigner - - -
-
- Assistants - {{ shortAgent(a) }} - aucun - - Ajouter un assistant - - {{ shortAgent(a) }} - Aucun autre agent - -
@@ -346,6 +363,11 @@
{{ msgSnippet(msg) }}
+ +
+ + {{ a.filename }}Ouvrir {{ a.filename }} ({{ a.mimeType }}) +
- -
-
{{ shortAgent(n.agent) }} {{ n.text }}
-
- - -
-
-
-
{{ shortAgent(otherDraft.agent) }} rédige une réponse en direct…Fermer cet aperçu
@@ -399,9 +411,29 @@
@@ -433,20 +468,6 @@ Joindre une image - - Réponses pré-écrites - - - Réponses pré-écrites - - {{ c.title }}{{ c.body }} - Modifier en place - - - Nouvelle réponse… - - - - - + + + + + + + Éditeur avancé — courriel mis en page + + + + +
+ +
+
+
+ + + + + + Bibliothèque d'images + + + + +
+
+
Aucune image. Ajoutez des photos d'équipements fréquents (modem, ONT, branchements…) pour les joindre en un clic. +
+
+
+ + Joindre cette image +
+
{{ p.name }}
+ Retirer de la bibliothèque +
+
+
+
+
+ + +
+
+ @@ -633,16 +772,33 @@ - - +
+
Suggestions recoupées du courriel (titulaire ≠ expéditeur possible)
+ + + {{ (m.customer_name || m.name).charAt(0) }} + + {{ m.customer_name || m.name }} + {{ m.address || m.email || m.phone || m.name }} +
+
+ +
+
+
ou rechercher manuellement :
+
+ + + +
Suggestions d'après {{ linkIsEmail ? 'le courriel' : 'le numéro' }} de la conversation — ou tapez autre chose.
{{ (m.customer_name || m.name).charAt(0) }} {{ m.customer_name || m.name }} - {{ m.email || m.phone || m.name }} · {{ m.territory }} + {{ m.email || m.phone || m.address || m.name }} · par {{ m.matched }} · inactif @@ -667,7 +823,9 @@ diff --git a/apps/ops/src/composables/useConversations.js b/apps/ops/src/composables/useConversations.js index b3733d0..d6c6c09 100644 --- a/apps/ops/src/composables/useConversations.js +++ b/apps/ops/src/composables/useConversations.js @@ -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, diff --git a/apps/ops/src/composables/useNotifications.js b/apps/ops/src/composables/useNotifications.js new file mode 100644 index 0000000..b11e09c --- /dev/null +++ b/apps/ops/src/composables/useNotifications.js @@ -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 } } diff --git a/apps/ops/src/config/nav.js b/apps/ops/src/config/nav.js index 085267c..e2c6126 100644 --- a/apps/ops/src/config/nav.js +++ b/apps/ops/src/config/nav.js @@ -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' }, diff --git a/apps/ops/src/config/ticket-config.js b/apps/ops/src/config/ticket-config.js index af61087..d57eae2 100644 --- a/apps/ops/src/config/ticket-config.js +++ b/apps/ops/src/config/ticket-config.js @@ -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 } diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue index 478a219..ac83969 100644 --- a/apps/ops/src/layouts/MainLayout.vue +++ b/apps/ops/src/layouts/MainLayout.vue @@ -45,6 +45,7 @@ {{ currentNav?.label || 'Targo Ops' }} +
@@ -97,6 +98,7 @@
Aucun résultat
+ @@ -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' diff --git a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue index ea71062..cc91728 100644 --- a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue +++ b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue @@ -151,6 +151,9 @@ +
Envoyer via
+
Variables
@@ -167,7 +170,8 @@
Sauvegarde l'éditeur d'abord pour tester la dernière version. - Envoyé via Mailjet depuis TARGO <support@targointernet.com>. + +
@@ -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 }) diff --git a/apps/ops/src/pages/ClientDetailPage.vue b/apps/ops/src/pages/ClientDetailPage.vue index 30e2410..96807ce 100644 --- a/apps/ops/src/pages/ClientDetailPage.vue +++ b/apps/ops/src/pages/ClientDetailPage.vue @@ -13,6 +13,67 @@ + +
+
+ + Satisfaction + + {{ '★'.repeat(latestRating.stars || 0) }}{{ '☆'.repeat(5 - (latestRating.stars || 0)) }} + + {{ latestRating.stars }}/5 + + {{ formatDateTime(latestRating.stars_ts || latestRating.comment_ts || latestRating.created) }} + +
+
« {{ latestRating.comment }} »
+
+ +
+ + Aucune évaluation pour ce client. + + + Ouvrir un brouillon d'invitation (coordonnées + message pré-remplis) + + + Brouillon d'invitation (langue du client) + Par courriel + Par texto + + + +
+ + + + +
Aucune conversation (courriel / SMS) liée à ce client
+ + + + + {{ commsTitle(d) }} + {{ commsSnippet(d) }} + + + {{ formatDate(d.lastActivity) }} +
+ + +
+
+
+
+
+