feat(ops): dispatch auto complet + perf Boîte/rapports + fix session

Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 08:49:35 -04:00
parent 6628eaaa5b
commit 512c4a5f1b
182 changed files with 13174 additions and 4249 deletions

View File

@ -150,7 +150,11 @@ createQuasarApp(createApp, quasarUserOptions)
return Promise[ method ]([
import('boot/pinia')
import('boot/net-timeout'),
import('boot/pinia'),
import('boot/tooltip-ux')
]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')

View File

@ -17,5 +17,5 @@ import {Notify,Loading,LocalStorage,Dialog} from 'quasar'
export default { config: {},plugins: {Notify,Loading,LocalStorage,Dialog} }
export default { config: {"brand":{"primary":"#16a34a","positive":"#16a34a","negative":"#ef4444","warning":"#f59e0b","info":"#2563eb"}},plugins: {Notify,Loading,LocalStorage,Dialog} }

14
apps/ops/knip.json Normal file
View File

@ -0,0 +1,14 @@
{
"$schema": "https://unpkg.com/knip@latest/schema.json",
"paths": { "src/*": ["./src/*"] },
"entry": [
"quasar.config.js",
"src/App.vue",
"src/router/index.js",
"src/boot/*.js",
"src/modules/**/routes.js"
],
"project": ["src/**/*.{js,vue}"],
"ignore": [".quasar/**", "src-pwa/**", "dist/**"],
"ignoreDependencies": []
}

File diff suppressed because it is too large Load Diff

View File

@ -7,16 +7,21 @@
"scripts": {
"dev": "quasar dev",
"build": "quasar build -m pwa",
"lint": "eslint --ext .js,.vue ./src"
"lint": "eslint --ext .js,.vue ./src",
"knip": "npx --yes knip"
},
"dependencies": {
"@quasar/extras": "^1.16.12",
"@twilio/voice-sdk": "^2.18.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"chart.js": "^4.5.1",
"cytoscape": "^3.33.2",
"grapesjs": "^0.22.16",
"grapesjs-mjml": "^1.0.8",
"grapesjs-preset-newsletter": "^1.0.2",
"hy-vue-gantt": "^5.2.1",
"idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7",

View File

@ -1,9 +1,17 @@
/* eslint-env node */
const { configure } = require('quasar/wrappers')
// ⚠️ HARNAIS D'APERÇU LOCAL UNIQUEMENT — À RETIRER AVANT TOUT BUILD PROD.
// Lit le jeton hub depuis .env.local (gitignored) et l'injecte CÔTÉ PROXY (jamais dans le bundle client).
let _HUB_TOKEN = ''
try {
const _m = require('fs').readFileSync(__dirname + '/.env.local', 'utf8').match(/^HUB_TOKEN=(.+)$/m)
_HUB_TOKEN = _m ? _m[1].trim() : ''
} catch (e) { /* pas de .env.local → proxy /hub inactif */ }
module.exports = configure(function () {
return {
boot: ['pinia', 'tooltip-ux'],
boot: ['net-timeout', 'pinia', 'tooltip-ux'],
css: ['app.scss', 'tech.scss'],
@ -34,11 +42,20 @@ module.exports = configure(function () {
target: 'https://erp.gigafibre.ca',
changeOrigin: true,
},
// ⚠️ HARNAIS APERÇU LOCAL — /hub → hub live, jeton injecté côté proxy (jamais bundlé). À RETIRER avant build prod.
'/hub': {
target: 'https://msg.gigafibre.ca',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/hub/, ''),
headers: { Authorization: 'Bearer ' + _HUB_TOKEN, 'X-Authentik-Email': 'louis@targo.ca' },
},
},
},
framework: {
config: {},
// Aligne les couleurs Quasar sur la palette de marque (vert). primary = accent (--ops-accent).
// Après migration color="indigo-6/7"→"primary", tout l'accent devient vert d'un coup.
config: { brand: { primary: '#16a34a', positive: '#16a34a', negative: '#ef4444', warning: '#f59e0b', info: '#2563eb' } },
plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog'],
},

View File

@ -5,7 +5,13 @@
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from 'src/stores/auth'
import { loadSavedTheme } from 'src/composables/useTheme'
import { startSessionKeepAlive } from 'src/composables/useSessionKeepAlive'
loadSavedTheme() // applique le thème enregistré (couleurs --ops-*) dès le démarrage
const auth = useAuthStore()
onMounted(() => auth.checkSession())
onMounted(() => {
auth.checkSession()
startSessionKeepAlive() // garde la session Authentik vivante plus besoin de recharger pour que les données/recherche reviennent
})
</script>

View File

@ -7,6 +7,11 @@ const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || ''
// Reconnexion auto sur session Authentik expirée, anti-boucle (1 reload / 20 s max).
let _lastReload = 0
function _reauth (status) {
// DEV local (pas d'Authentik devant) : un 401 ERPNext ne doit PAS déclencher un rechargement en boucle.
// On renvoie le 401 et l'UI affiche ses états vides. En prod (Authentik) le comportement est inchangé.
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
return new Response('{}', { status: status || 401 })
}
const now = Date.now()
if (now - _lastReload > 20000) {
_lastReload = now
@ -24,18 +29,23 @@ export async function authFetch (url, opts = {}) {
try {
res = await fetch(url, opts)
} catch (e) {
// Coupure réseau transitoire (ex: backend qui redémarre) → 1 retry court avant d'abandonner
// Coupure réseau transitoire (backend qui redémarre) OU timeout du garde net (session lente) → 1 retry court
await new Promise(r => setTimeout(r, 800))
try {
res = await fetch(url, opts)
} catch (e2) {
// 2e échec (réseau/timeout) : NE PAS planter l'appelant ni boucler sur reload → réponse vide, l'app reste réactive
return new Response('{}', { status: 504 })
}
}
// Détection « session Authentik expirée » (la cause du "il faut recharger") :
// - 401/403 explicites
// - réponse redirigée vers l'IdP (auth.targo.ca / /if/flow/)
// - page HTML de login renvoyée à la place du JSON attendu sur un /api/
// - page HTML de login renvoyée à la place du JSON attendu sur un endpoint app (/api, /auth, /hub)
const ct = (res.headers && res.headers.get && res.headers.get('content-type')) || ''
const redirectedToIdp = res.redirected && /auth\.targo\.ca|\/if\/flow\//.test(res.url || '')
const htmlForApi = res.status === 200 && ct.includes('text/html') && /\/api\//.test(String(url))
const htmlForApi = res.status === 200 && ct.includes('text/html') && /\/(api|auth|hub)\//.test(String(url))
if (res.status === 401 || res.status === 403 || redirectedToIdp || htmlForApi) {
return _reauth(res.status)
}
@ -43,6 +53,8 @@ export async function authFetch (url, opts = {}) {
}
export async function getLoggedUser () {
// ⚠️ APERÇU LOCAL UNIQUEMENT : identité forcée via VITE_DEV_USER (gardé localhost + env). Inerte en prod. À RETIRER avec le harnais.
if ((window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && import.meta.env.VITE_DEV_USER) return import.meta.env.VITE_DEV_USER
// First try nginx whoami endpoint (returns Authentik email header)
try {
const res = await fetch(BASE_URL + '/auth/whoami')

View File

@ -0,0 +1,35 @@
/**
* api/billing.js Client for Hub /billing approval endpoints (P1).
*
* Porte d'approbation de facturation : lister les installations complétées en
* attente, prévisualiser le prorata (LECTURE SEULE), approuver (= active le
* service ; la facturation reste gatée côté hub par PRORATION_WRITE).
*/
import { HUB_URL } from 'src/config/hub'
async function hubFetch (path, { method = 'GET', body } = {}) {
const opts = { method, headers: { 'Content-Type': 'application/json' } }
if (body) opts.body = JSON.stringify(body)
const res = await fetch(`${HUB_URL}${path}`, opts)
const text = await res.text()
let data
try { data = text ? JSON.parse(text) : {} } catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) }
if (!res.ok) { const err = new Error(data.error || `HTTP ${res.status}`); err.status = res.status; throw err }
return data
}
// Activations en attente d'approbation (enrichies client + adresse).
export function getApprovals () {
return hubFetch('/billing/approvals').then(r => r.pending || [])
}
// Aperçu prorata consolidé pour un job (LECTURE SEULE — aucune facture créée).
export function getApprovalPreview (job) {
return hubFetch(`/billing/approvals/preview?job=${encodeURIComponent(job)}`)
}
// Approuve → active les abonnements. L'approbateur est attribué côté serveur
// (en-tête x-authentik-email). La facturation reste gatée (PRORATION_WRITE).
export function approveActivation (job) {
return hubFetch('/billing/approvals/approve', { method: 'POST', body: { job } })
}

View File

@ -92,6 +92,37 @@ export function listGifts () {
return hubFetch('/campaigns/gifts').then(r => r.gifts || [])
}
// Pool de liens-cadeaux récupérés (jamais cliqués) prêts à réallouer à d'autres clients.
export function getGiftPool () {
return hubFetch('/campaigns/gifts/pool')
}
// Récupère les liens non cliqués d'une campagne vers le pool (dry_run par défaut = aperçu).
export function reclaimGifts ({ campaign_id, dry_run = true, revoke_live = true }) {
return hubFetch('/campaigns/gifts/reclaim', { method: 'POST', body: { campaign_id, dry_run, revoke_live } })
}
// Envoi unitaire d'une récompense depuis le pool vers un client (dry_run pour aperçu).
export function rewardSend (body) {
return hubFetch('/campaigns/reward-send', { method: 'POST', body })
}
// Récompenses déjà préparées/envoyées à un client (pour la fiche : voir / copier le lien).
export function rewardByCustomer (customer_id, email) {
const q = customer_id ? 'customer_id=' + encodeURIComponent(customer_id) : 'email=' + encodeURIComponent(email || '')
return hubFetch('/campaigns/reward/by-customer?' + q)
}
// Supprime un brouillon / révoque une récompense non cliquée (remet le lien en réserve ; refusé si déjà ouverte).
export function revokeRewardByToken (gift_token) {
return hubFetch('/campaigns/reward/revoke', { method: 'POST', body: { gift_token } })
}
// Révoque une récompense déjà envoyée + remet le lien Giftbit au pool (refusé si déjà cliqué/redirigé).
export function revokeReward (gift_url) {
return hubFetch('/campaigns/gifts/revoke-reward', { method: 'POST', body: { gift_url } })
}
// Kill switch — manually expire a single recipient's wrapper token so
// the underlying Giftbit URL becomes reassignable before the natural
// expiry date.

View File

@ -91,3 +91,23 @@ export function overpricedInternetCsvUrl ({ threshold = 90, segment = 'residenti
})
return `${HUB}/reports/legacy/overpriced-internet.csv?${qs}`
}
/**
* Explorateur de revenus MRR net récurrent regroupé par DIMENSION (un seul rapport,
* critères ajoutables). Remplace les rapports F fixes (par service / code postal / ville / segment).
* @param {object} opts { dimension:'category|zip|city|segment|plan', segment, type:'internet|tv|phone|all', active, threshold, limit }
*/
export function fetchRevenueExplorer ({ dimension = 'category', segment = 'all', type = 'all', active = true, threshold = 0, limit = 300 } = {}) {
const qs = new URLSearchParams({
dimension, segment, type, active: active ? '1' : '0', threshold: String(threshold), limit: String(limit),
})
return hubFetch(`/reports/legacy/revenue-explorer?${qs}`)
}
/** URL CSV directe pour l'explorateur de revenus (mêmes critères). */
export function revenueExplorerCsvUrl ({ dimension = 'category', segment = 'all', type = 'all', active = true, threshold = 0 } = {}) {
const qs = new URLSearchParams({
dimension, segment, type, active: active ? '1' : '0', threshold: String(threshold),
})
return `${HUB}/reports/legacy/revenue-explorer.csv?${qs}`
}

View File

@ -28,6 +28,10 @@ async function jput (path, body) {
export const listTechnicians = () => jget('/roster/technicians')
export const listTemplates = () => jget('/roster/templates')
export const createTemplate = (t) => jpost('/roster/templates', t)
// Fériés QC déterministes (calendrier) + préavis de disponibilité avant un férié.
export const holidays = (from, to) => jget(`/roster/holidays?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
export const holidayNotice = (days = 35) => jget('/roster/holiday-notice?days=' + days)
export const sendHolidayNotice = (date) => jpost('/roster/holiday-notice', { date })
export const listRequirements = (start, days = 7) => jget(`/roster/requirements?start=${start}&days=${days}`)
export const createRequirement = (r) => jpost('/roster/requirements', r)
export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`)
@ -98,6 +102,20 @@ export const pushLegacyPreview = () => jget('/dispatch/legacy-sync/push-assignme
export const pushLegacyApply = (notify = true) => jpost('/dispatch/legacy-sync/push-assignments' + (notify ? '' : '?notify=0'), {})
// Fil complet d'un ticket legacy (osTicket) : messages + réponses, pour l'expand au clic
export const ticketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
// Poster au fil d'un ticket : isPublic=false (défaut) = note INTERNE (jamais au client) · true = message public
export const postTicket = (ticket, msg, isPublic) => jpost('/dispatch/legacy-sync/ticket-post', { ticket, msg, public: !!isPublic })
// GÉNÉRALISÉ par JOB (osTicket OU ERPNext-natif) : fil+contact (lecture seule) et résolution de conversation pour répondre.
export const jobThread = (job) => jget('/dispatch/legacy-sync/job-thread?job=' + encodeURIComponent(job))
export const jobConversation = (job) => jget('/dispatch/legacy-sync/job-conversation?job=' + encodeURIComponent(job))
// P2 — réponse au CLIENT via le MÊME chemin que la Boîte (/conversations/{token}/messages → Outbox courriel/SMS + miroir osTicket).
// X-Authentik-Email REQUIS : sans lui le hub enregistre l'envoi comme message CLIENT (from='customer') + déclenche l'IA. À passer (useAuthStore().user).
export async function sendConvMessage (token, text, agentEmail) {
const headers = { 'Content-Type': 'application/json' }
if (agentEmail) headers['X-Authentik-Email'] = agentEmail
const r = await fetch(HUB + '/conversations/' + encodeURIComponent(token) + '/messages', { method: 'POST', headers, body: JSON.stringify({ text }) })
if (!r.ok) throw new Error('Envoi conv ' + r.status)
return r.json()
}
// Fermer un ticket dans le legacy (status=closed + date_closed + closed_by=acteur + réouverture enfants)
export const closeLegacyTicket = (ticket) => jpost('/dispatch/legacy-sync/close-ticket?ticket=' + encodeURIComponent(ticket), {})
// Fermeture EN LOT (1 appel pour N tickets) — efficace
@ -112,6 +130,10 @@ export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte
export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set-location', { job, lat, lon, address })
// Actions rapides du pool (mobile) : patch d'un Dispatch Job (priority/status/required_skill/scheduled_date/notes — liste blanche côté hub).
export const updateJob = (job, patch) => jpost('/roster/job/update', { job, patch })
// Persister UN quart immédiatement (sinon addShift reste local/Proposé et disparaît au rechargement). Idempotent côté hub.
export const createShift = (s) => jpost('/roster/shift', s)
// ÉQUIPE d'un job (assistants en renfort) — le lead reste inchangé ; l'assistant épinglé voit un bloc hachuré dans son horaire.
export const getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job))
export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a })
@ -139,3 +161,14 @@ export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule',
export const gpxUrl = (tech, from, to) => HUB + '/traccar/gpx?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to)
// Tracé GPS (breadcrumb) d'un tech sur UNE journée → { coords:[[lon,lat]…], count }. Plage exacte (1 jour) obligatoire (Traccar lourd sinon).
export const traccarTrack = (tech, from, to) => jget('/traccar/track?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to))
// Validation « service livré » d'un job : statut service+modem (en ligne + signal) pour le client/adresse du job.
// Croise tech-présent (geofence) + service-en-ligne → preuve de complétion d'install.
export const jobServiceStatus = (job) => jget('/roster/job-service-status?job=' + encodeURIComponent(job))
// Aperçu croisement temps-sur-site (Traccar × emplacement job) + ancre d'activation GenieACS (clamped_minutes / service_anchor).
export const onsiteCross = (days = 30) => jget('/traccar/onsite?days=' + days)
// Position GPS LIVE (dernière connue) d'un tech → { ok, lat, lon, time, speed } ; pour fixer son point de départ (domicile).
export const techLivePosition = (tech) => jget('/traccar/live?tech=' + encodeURIComponent(tech))
// Liste des appareils Traccar (pour associer un tech à son GPS) — { id, name, uniqueId, status, lastUpdate }.
export const listTraccarDevices = () => jget('/traccar/devices')
// Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif.
export const setTechTraccarDevice = (id, deviceId) => jpost('/roster/technician/' + encodeURIComponent(id) + '/traccar-device', { device_id: deviceId })

View File

@ -0,0 +1,31 @@
/**
* Sous-traitants payés à l'acte appelle targo-hub /acte/*.
* Barème (prix par acte), génération/envoi du lien à token, revue des soumissions.
* Voir services/targo-hub/lib/subcontractor.js. Token hub injecté par nginx /hub (même patron que roster.js).
*/
import { HUB_URL as HUB } from 'src/config/hub'
async function jget (p) { const r = await fetch(HUB + p); if (!r.ok) throw new Error('Acte API ' + r.status); return r.json() }
async function jpost (p, b) {
const r = await fetch(HUB + p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b || {}) })
if (!r.ok) throw new Error('Acte API ' + r.status); return r.json()
}
export const getRates = (sub) => jget('/acte/rates' + (sub ? '?sub=' + encodeURIComponent(sub) : '')) // sub → barème effectif du sous-traitant (global + surcharge)
export const saveRates = (rates, sub) => jpost('/acte/rates', sub ? { rates, sub } : { rates }) // sub → surcharge par sous-traitant ; sinon barème global
export const listSubmissions = (ticket) => jget('/acte/list' + (ticket ? '?ticket=' + encodeURIComponent(ticket) : ''))
export const sendLink = (ticket, subcontractor, phone) => jpost('/acte/send', { ticket, subcontractor, phone })
export const approve = (id, action, reason) => jpost('/acte/approve', { id, action, reason }) // action: 'approve' | 'reject' (gaté admin/superviseur côté hub)
export const imgUrl = (file) => HUB + '/acte/img?file=' + encodeURIComponent(file) // servi gaté via le proxy /hub
// ── Autofacturation : config fiscale par sous-traitant, relevé par période, marquer payé, export Acomba (CSV) ──
export const getSubs = () => jget('/acte/subs') // { subs:[{name,registrant,gst_no,qst_no,email,phone}], names:[...vus en soumission] }
export const saveSub = (sub) => jpost('/acte/subs', sub) // {name, registrant, gst_no, qst_no, email, phone} — gaté admin/superviseur
const qstr = (sub, from, to) => '?subcontractor=' + encodeURIComponent(sub) + (from ? '&from=' + from : '') + (to ? '&to=' + to : '')
export const getReleve = (sub, from, to) => jget('/acte/releve' + qstr(sub, from, to)) // { releve:{config,lines,subtotal,tps,tvq,total,...} }
export const markPaid = (body) => jpost('/acte/mark-paid', body) // { subcontractor, from, to } OU { ids:[] } — gaté admin/superviseur
export const exportUrl = (sub, from, to) => HUB + '/acte/export' + qstr(sub, from, to) // CSV (téléchargé via le proxy /hub)
// ── Entente de services : lien web ou PDF des conditions (montré au sous-traitant à la certification) ──
export const getEntente = () => jget('/acte/entente') // { url }
export const saveEntente = (body) => jpost('/acte/entente', body) // { url } | { pdf: dataUrl } | { clear: true } — gaté admin/superviseur

View File

@ -0,0 +1,26 @@
// Empêche TOUTE requête de l'app (same-origin : /api, /hub, /auth) de PENDRE indéfiniment.
// Cause du gel « même un clic de menu ne répond plus » : sur session Authentik morte/lente, un fetch
// sans timeout ne résout jamais → la page attendue ne s'affiche pas, la vue ne change pas au clic,
// et les 69 appels /hub (fetch nu) restent suspendus. On borne chaque requête app à 30 s.
// Externes (Mapbox api.mapbox.com, etc.) et appels fournissant déjà un signal : inchangés.
// EventSource (SSE) n'utilise pas window.fetch → non affecté.
import { boot } from 'quasar/wrappers'
const APP_TIMEOUT_MS = 30000
export default boot(() => {
if (typeof window === 'undefined' || window.__netTimeoutInstalled) return
window.__netTimeoutInstalled = true
const orig = window.fetch.bind(window)
const origin = window.location.origin
window.fetch = function (input, init) {
init = init || {}
let url = ''
try { url = typeof input === 'string' ? input : (input && input.url) || '' } catch { /* Request exotique */ }
const sameApp = url.startsWith('/') || url.startsWith(origin) // requêtes app ; exclut absolu cross-origin
if (!sameApp || init.signal) return orig(input, init) // externe ou signal déjà fourni → ne pas toucher
const ctrl = new AbortController()
const id = setTimeout(() => ctrl.abort(), APP_TIMEOUT_MS)
return orig(input, { ...init, signal: ctrl.signal }).finally(() => clearTimeout(id))
}
})

View File

@ -1,544 +0,0 @@
<template>
<div class="chatter-panel">
<div class="chatter-header">
<span class="text-weight-bold" style="font-size:1.1rem">Convos</span>
<q-space />
<q-badge v-if="unreadCount" color="red" text-color="white" class="q-mr-xs">{{ unreadCount }}</q-badge>
<q-btn flat dense round size="sm" icon="refresh" color="grey-6" @click="loadAll" :loading="loading" />
</div>
<ComposeBar
v-model:channel="composeChannel" v-model:text="composeText"
v-model:email-subject="emailSubject" v-model:sms-to="smsTo" v-model:call-to="callTo"
:customer-phone="customerPhone" :phone-options="phoneOptions"
:sending="sending" :calling="calling" :canned-responses="cannedResponses"
@send="send" @call="initiateCall" @use-canned="useCanned" @manage-canned="cannedModal = true" />
<!-- Bulk action bar -->
<div v-if="selectedIds.size > 0" class="chatter-bulk-bar">
<q-checkbox :model-value="selectedIds.size === filteredEntries.length" dense size="sm"
@update:model-value="v => v ? selectAllEntries() : selectedIds.clear()" class="q-mr-xs" />
<span class="text-caption text-weight-medium">{{ selectedIds.size }} sélectionné{{ selectedIds.size > 1 ? 's' : '' }}</span>
<q-space />
<q-btn flat dense size="sm" icon="delete_outline" color="red-5" :loading="bulkDeleting" @click="confirmBulkDelete">
<q-tooltip>Supprimer la sélection</q-tooltip>
</q-btn>
<q-btn flat dense size="sm" icon="close" color="grey-6" @click="selectedIds.clear()" />
</div>
<div class="chatter-tabs">
<q-btn-toggle v-model="activeTab" no-caps dense unelevated size="xs" spread
toggle-color="indigo-6" color="grey-2" text-color="grey-7" :options="tabOptions" />
</div>
<div class="chatter-timeline" ref="timelineRef">
<div v-if="loading && !entries.length" class="flex flex-center q-pa-lg">
<q-spinner size="24px" color="indigo-5" />
</div>
<div v-else-if="!filteredEntries.length" class="text-center text-grey-5 q-pa-lg text-caption">
{{ activeTab === 'all' ? 'Aucune communication' : 'Aucun ' + tabLabel }}
</div>
<template v-else>
<template v-for="(group, idx) in groupedEntries" :key="idx">
<div class="chatter-date-sep"><span>{{ group.label }}</span></div>
<div v-for="entry in group.items" :key="entry.id" class="chatter-entry"
:class="[...entryClass(entry), { 'entry-selected': selectedIds.has(entry.id) }]"
@mouseenter="hoveredEntry = entry.id" @mouseleave="hoveredEntry = null">
<div v-if="selectedIds.size > 0" class="entry-checkbox">
<q-checkbox :model-value="selectedIds.has(entry.id)" dense size="sm"
@update:model-value="toggleEntry(entry.id)" @click.stop />
</div>
<div v-else class="entry-icon" :class="'icon-' + entry.channel"
@click.stop="startSelection(entry.id)">
<q-icon :name="channelIcon(entry)" size="16px" />
</div>
<div class="entry-body">
<div class="entry-header">
<span class="entry-direction">
<q-icon :name="entry.direction === 'out' ? 'call_made' : entry.direction === 'in' ? 'call_received' : 'edit_note'"
size="12px" :color="entry.direction === 'out' ? 'indigo-4' : entry.direction === 'in' ? 'green-6' : 'grey-5'" />
</span>
<span class="entry-author text-weight-medium">{{ entry.author }}</span>
<q-space />
<div v-if="hoveredEntry === entry.id" class="entry-actions">
<q-btn flat dense round size="xs" icon="edit" color="grey-5" @click="startEdit(entry)" title="Modifier" />
<q-btn flat dense round size="xs" icon="delete_outline" color="red-4" @click="confirmDelete(entry)" title="Supprimer" />
</div>
<span class="entry-time text-caption text-grey-5">{{ formatTime(entry.date) }}</span>
</div>
<div v-if="editingEntry === entry.id" class="entry-edit">
<q-input v-model="editText" dense outlined autogrow :input-style="{ fontSize: '0.82rem' }"
@keydown.escape="editingEntry = null"
@keydown.ctrl.enter="saveEdit(entry)" @keydown.meta.enter="saveEdit(entry)" />
<div class="row q-gutter-xs q-mt-xs">
<q-btn dense unelevated size="xs" color="indigo-6" label="Sauver" @click="saveEdit(entry)" :loading="savingEdit" />
<q-btn dense flat size="xs" color="grey-6" label="Annuler" @click="editingEntry = null" />
</div>
</div>
<div v-else class="entry-content" :class="{ 'entry-note': entry.channel === 'note' }">{{ entry.text }}</div>
<div v-if="entry.channel === 'phone' && entry.meta" class="entry-meta text-caption text-grey-5">
<q-icon name="timer" size="12px" class="q-mr-xs" />
{{ entry.meta.duration || '0s' }}
<template v-if="entry.meta.status"> &middot; {{ entry.meta.status }}</template>
</div>
<div v-if="entry.linkedTo && entry.linkedTo !== customerName" class="entry-link">
<q-chip dense size="sm" icon="link" clickable color="grey-2" text-color="grey-8"
@click="$emit('navigate', entry.linkedDoctype, entry.linkedTo)">
{{ entry.linkedDoctype }}: {{ entry.linkedTo }}
</q-chip>
</div>
</div>
</div>
</template>
</template>
</div>
<q-dialog v-model="deleteDialog">
<q-card style="min-width:300px">
<q-card-section>
<div class="text-weight-bold">Supprimer ce message ?</div>
<div class="text-caption text-grey-6 q-mt-xs">Cette action est irréversible.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" color="grey-7" v-close-popup />
<q-btn unelevated label="Supprimer" color="red" :loading="deleting" @click="doDelete" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="bulkDeleteDialog">
<q-card style="min-width:300px">
<q-card-section>
<div class="text-weight-bold">Supprimer {{ selectedIds.size }} message{{ selectedIds.size > 1 ? 's' : '' }} ?</div>
<div class="text-caption text-grey-6 q-mt-xs">Cette action est irréversible.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" color="grey-7" v-close-popup />
<q-btn unelevated label="Supprimer tout" color="red" :loading="bulkDeleting" @click="doBulkDelete" />
</q-card-actions>
</q-card>
</q-dialog>
<PhoneModal v-model="phoneModalOpen" :initial-number="callTo || smsTo || customerPhone"
:customer-name="props.customerName" :customer-erp-name="props.customerName"
:provider="phoneProvider" @call-ended="onCallEnded" />
<q-dialog v-model="cannedModal">
<q-card style="min-width:460px;max-width:600px">
<q-card-section>
<div class="text-weight-bold" style="font-size:1rem">Réponses rapides</div>
<div class="text-caption text-grey-6">Tapez <code>::raccourci</code> dans la zone de texte pour insérer une réponse rapide.</div>
</q-card-section>
<q-separator />
<q-card-section style="max-height:350px;overflow-y:auto;padding:8px 16px">
<div v-for="(cr, i) in cannedResponses" :key="i" class="canned-row">
<div class="canned-shortcut">
<q-input v-model="cr.shortcut" dense borderless prefix="::" :input-style="{ fontSize:'0.82rem', fontWeight:600 }" style="width:100px" />
</div>
<div class="canned-text">
<q-input v-model="cr.text" dense borderless autogrow :input-style="{ fontSize:'0.82rem' }" placeholder="Texte de la réponse..." style="flex:1" />
</div>
<q-btn flat dense round size="xs" icon="delete_outline" color="red-4" @click="removeCanned(i)" />
</div>
<div v-if="!cannedResponses.length" class="text-grey-5 text-caption text-center q-py-md">
Aucune réponse rapide. Cliquez + pour en ajouter.
</div>
</q-card-section>
<q-separator />
<q-card-actions>
<q-btn flat dense icon="add" label="Ajouter" color="indigo-6" @click="addCanned" />
<q-space />
<q-btn flat label="Fermer" color="grey-7" v-close-popup />
<q-btn unelevated label="Sauver" color="indigo-6" @click="saveCanned" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick, onUnmounted } from 'vue'
import { Notify } from 'quasar'
import { listDocs, createDoc, updateDoc, deleteDoc } from 'src/api/erp'
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
import ComposeBar from './ComposeBar.vue'
import PhoneModal from './PhoneModal.vue'
const TAB_OPTIONS = [
{ label: 'Tout', value: 'all', icon: 'forum' },
{ label: 'SMS', value: 'sms', icon: 'sms' },
{ label: 'Appels', value: 'phone', icon: 'phone' },
{ label: 'Email', value: 'email', icon: 'email' },
{ label: 'Notes', value: 'note', icon: 'sticky_note_2' },
]
const TAB_LABEL_MAP = { sms: 'SMS', phone: 'appel', email: 'email', note: 'note' }
const CHANNEL_ICONS = { sms: 'sms', phone: 'phone', email: 'email', note: 'sticky_note_2', other: 'chat' }
const props = defineProps({
customerName: { type: String, required: true },
customerPhone: { type: String, default: '' },
customerPhones: { type: Array, default: () => [] },
customerEmail: { type: String, default: '' },
comments: { type: Array, default: () => [] },
})
const emit = defineEmits(['navigate', 'note-added', 'note-updated'])
const loading = ref(false)
const activeTab = ref('all')
const composeChannel = ref('sms')
const composeText = ref('')
const emailSubject = ref('')
const sending = ref(false)
const calling = ref(false)
const smsTo = ref('')
const callTo = ref('')
const timelineRef = ref(null)
const communications = ref([])
const unreadCount = ref(0)
const hoveredEntry = ref(null)
const editingEntry = ref(null)
const editText = ref('')
const savingEdit = ref(false)
const deleteDialog = ref(false)
const deleteTarget = ref(null)
const deleting = ref(false)
const phoneModalOpen = ref(false)
const phoneProvider = ref('twilio')
const cannedModal = ref(false)
const cannedResponses = ref(loadCannedResponses())
const selectedIds = ref(new Set())
const bulkDeleteDialog = ref(false)
const bulkDeleting = ref(false)
function loadCannedResponses () {
try { return JSON.parse(localStorage.getItem('ops-canned-responses') || '[]') } catch { return [] }
}
function addCanned () { cannedResponses.value.push({ shortcut: '', text: '' }) }
function removeCanned (i) { cannedResponses.value.splice(i, 1) }
function saveCanned () {
const valid = cannedResponses.value.filter(c => c.shortcut.trim() && c.text.trim())
cannedResponses.value = valid
localStorage.setItem('ops-canned-responses', JSON.stringify(valid))
Notify.create({ type: 'positive', message: 'Réponses rapides sauvegardées', timeout: 2000 })
}
function useCanned (cr) { composeText.value = cr.text }
const tabOptions = computed(() => TAB_OPTIONS)
const tabLabel = computed(() => TAB_LABEL_MAP[activeTab.value] || 'message')
const phoneOptions = computed(() => {
if (props.customerPhones.length) return props.customerPhones
return props.customerPhone
? [{ label: props.customerPhone, value: props.customerPhone }]
: [{ label: 'Aucun numero', value: '', disable: true }]
})
watch(() => props.customerPhone, v => {
if (v && !smsTo.value) smsTo.value = v
if (v && !callTo.value) callTo.value = v
}, { immediate: true })
const { connect: sseConnect, disconnect: sseDisconnect, connected: sseConnected } = useSSE({
onMessage: async (data) => {
if (data.customer === props.customerName) {
await loadCommunications()
if (data.direction === 'in') {
playNotificationSound()
Notify.create({
type: 'info', icon: 'sms',
message: `${data.type === 'sms' ? 'SMS' : 'Message'} de ${data.customer_name || data.phone || 'Client'}`,
timeout: 3000, position: 'top-right',
})
}
}
},
})
let fallbackTimer = null
function startFallbackPoll () {
stopFallbackPoll()
fallbackTimer = setInterval(() => loadCommunications(), sseConnected.value ? 30000 : 5000)
}
function stopFallbackPoll () { if (fallbackTimer) { clearInterval(fallbackTimer); fallbackTimer = null } }
async function loadCommunications () {
try {
const data = await listDocs('Communication', {
filters: { reference_doctype: 'Customer', reference_name: props.customerName },
fields: ['name', 'subject', 'content', 'text_content', 'communication_medium',
'sent_or_received', 'communication_date', 'creation', 'phone_no',
'sender', 'sender_full_name', 'status', 'delivery_status',
'reference_doctype', 'reference_name', 'message_id', 'communication_type'],
limit: 100, orderBy: 'communication_date asc',
})
communications.value = data
unreadCount.value = data.filter(m => m.sent_or_received === 'Received' && m.status === 'Open').length
} catch {}
}
function playNotificationSound () {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain); gain.connect(ctx.destination)
osc.type = 'sine'
osc.frequency.setValueAtTime(880, ctx.currentTime)
osc.frequency.setValueAtTime(1100, ctx.currentTime + 0.1)
gain.gain.setValueAtTime(0.15, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3)
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.3)
} catch {}
}
async function loadAll () {
loading.value = true
await loadCommunications()
loading.value = false
sseConnect(['customer:' + props.customerName])
startFallbackPoll()
}
const entries = computed(() => {
const items = []
for (const c of communications.value) {
const medium = (c.communication_medium || '').toLowerCase()
const channel = medium === 'sms' ? 'sms' : medium === 'phone' ? 'phone' : medium === 'email' ? 'email' : 'other'
items.push({
id: c.name, channel,
direction: c.sent_or_received === 'Sent' ? 'out' : 'in',
author: c.sent_or_received === 'Sent' ? (c.sender_full_name || 'Targo Ops') : (c.sender_full_name || c.phone_no || c.sender || 'Client'),
text: stripHtml(c.content || c.text_content || c.subject || ''),
date: c.communication_date || c.creation,
status: c.status, deliveryStatus: c.delivery_status,
linkedDoctype: c.reference_doctype, linkedTo: c.reference_name,
meta: channel === 'phone' ? { duration: null, status: c.delivery_status || null } : null,
raw: c, doctype: 'Communication',
})
}
for (const n of (props.comments || [])) {
items.push({
id: 'note-' + n.name, channel: 'note', direction: 'internal',
author: n.comment_by?.split('@')[0] || 'Admin',
text: stripHtml(n.content || ''),
date: n.creation, status: null, linkedDoctype: null, linkedTo: null, meta: null,
raw: n, doctype: 'Comment', docName: n.name,
})
}
return items.sort((a, b) => new Date(b.date) - new Date(a.date))
})
const filteredEntries = computed(() => activeTab.value === 'all' ? entries.value : entries.value.filter(e => e.channel === activeTab.value))
const groupedEntries = computed(() => {
const groups = []
let currentLabel = ''
for (const entry of filteredEntries.value) {
const label = dateLabel(entry.date)
if (label !== currentLabel) { currentLabel = label; groups.push({ label, items: [] }) }
groups[groups.length - 1].items.push(entry)
}
return groups
})
function startEdit (entry) { editingEntry.value = entry.id; editText.value = entry.text }
async function saveEdit (entry) {
if (!editText.value.trim()) return
savingEdit.value = true
try {
const doctype = entry.doctype || (entry.channel === 'note' ? 'Comment' : 'Communication')
const name = entry.docName || entry.raw?.name
if (!name) return
await updateDoc(doctype, name, { content: editText.value.trim() })
entry.raw.content = editText.value.trim()
editingEntry.value = null
doctype === 'Comment' ? emit('note-updated') : await loadCommunications()
Notify.create({ type: 'positive', message: 'Message modifié', timeout: 2000 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
} finally { savingEdit.value = false }
}
function confirmDelete (entry) { deleteTarget.value = entry; deleteDialog.value = true }
async function doDelete () {
if (!deleteTarget.value) return
const entry = deleteTarget.value
const doctype = entry.doctype || (entry.channel === 'note' ? 'Comment' : 'Communication')
const name = entry.docName || entry.raw?.name
if (!name) return
// Optimistic removal
if (doctype === 'Comment') {
const idx = props.comments.findIndex(n => n.name === entry.raw?.name)
if (idx !== -1) props.comments.splice(idx, 1)
} else {
const idx = communications.value.findIndex(c => c.name === name)
if (idx !== -1) communications.value.splice(idx, 1)
}
deleteDialog.value = false; deleteTarget.value = null
Notify.create({ type: 'positive', message: 'Message supprimé', timeout: 2000 })
try {
await deleteDoc(doctype, name)
if (doctype === 'Comment') emit('note-updated')
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur suppression: ' + e.message, timeout: 3000 })
await loadCommunications()
if (doctype === 'Comment') emit('note-updated')
}
}
// Bulk selection
function toggleEntry (id) {
const s = new Set(selectedIds.value)
s.has(id) ? s.delete(id) : s.add(id)
selectedIds.value = s
}
function startSelection (id) {
selectedIds.value = new Set([id])
}
function selectAllEntries () {
selectedIds.value = new Set(filteredEntries.value.map(e => e.id))
}
function confirmBulkDelete () { bulkDeleteDialog.value = true }
async function doBulkDelete () {
bulkDeleting.value = true
const toDelete = entries.value.filter(e => selectedIds.value.has(e.id))
let ok = 0, fail = 0
for (const entry of toDelete) {
const doctype = entry.doctype || (entry.channel === 'note' ? 'Comment' : 'Communication')
const name = entry.docName || entry.raw?.name
if (!name) continue
try {
await deleteDoc(doctype, name)
ok++
} catch { fail++ }
}
// Remove deleted from local state
for (const entry of toDelete) {
if (entry.doctype === 'Comment' || entry.channel === 'note') {
const idx = props.comments.findIndex(n => n.name === entry.raw?.name)
if (idx !== -1) props.comments.splice(idx, 1)
} else {
const idx = communications.value.findIndex(c => c.name === (entry.docName || entry.raw?.name))
if (idx !== -1) communications.value.splice(idx, 1)
}
}
selectedIds.value = new Set()
bulkDeleteDialog.value = false
bulkDeleting.value = false
Notify.create({ type: ok > 0 ? 'positive' : 'negative', message: `${ok} message${ok > 1 ? 's' : ''} supprimé${ok > 1 ? 's' : ''}${fail ? `, ${fail} erreur${fail > 1 ? 's' : ''}` : ''}`, timeout: 3000 })
if (toDelete.some(e => e.channel === 'note')) emit('note-updated')
}
// Clear selection when switching tabs
watch(activeTab, () => { selectedIds.value = new Set() })
async function send () {
if (!composeText.value.trim() || sending.value) return
sending.value = true
try {
if (composeChannel.value === 'sms') await sendSms()
else if (composeChannel.value === 'note') await addNote()
else if (composeChannel.value === 'email') await sendEmail()
composeText.value = ''; emailSubject.value = ''
await loadCommunications()
setTimeout(loadCommunications, 2000)
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
} finally { sending.value = false }
}
async function sendSms () {
if (!smsTo.value) throw new Error('Aucun numero')
await sendSmsViaHub(smsTo.value, composeText.value.trim(), props.customerName)
Notify.create({ type: 'positive', message: 'SMS envoyé', timeout: 2000 })
}
async function addNote () {
const doc = await createDoc('Comment', {
comment_type: 'Comment', reference_doctype: 'Customer',
reference_name: props.customerName, content: composeText.value.trim(),
})
emit('note-added', doc)
Notify.create({ type: 'positive', message: 'Note ajoutée', timeout: 2000 })
}
async function sendEmail () {
const { authFetch } = await import('src/api/auth')
const { BASE_URL } = await import('src/config/erpnext')
const res = await authFetch(BASE_URL + '/api/method/send_email_notification', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: props.customerEmail, subject: emailSubject.value.trim(), message: composeText.value.trim(), customer: props.customerName }),
})
if (!res.ok) throw new Error('Email failed')
Notify.create({ type: 'positive', message: 'Email envoyé', timeout: 2000 })
}
async function initiateCall (provider = 'twilio') {
const phone = callTo.value || smsTo.value || props.customerPhone
if (!phone) { Notify.create({ type: 'warning', message: 'Aucun numéro', timeout: 3000 }); return }
phoneProvider.value = provider; phoneModalOpen.value = true
}
async function onCallEnded (callInfo) {
const phone = callInfo.remote || callTo.value || smsTo.value || props.customerPhone
const durationStr = `${Math.floor(callInfo.duration / 60)}m${String(callInfo.duration % 60).padStart(2, '0')}s`
try {
await createDoc('Communication', {
subject: (callInfo.direction === 'in' ? 'Appel de ' : 'Appel vers ') + phone,
communication_type: 'Communication', communication_medium: 'Phone',
sent_or_received: callInfo.direction === 'in' ? 'Received' : 'Sent',
status: 'Linked', phone_no: phone, sender: 'sms@gigafibre.ca',
sender_full_name: callInfo.direction === 'in' ? (callInfo.remoteName || phone) : 'Targo Ops',
content: `Appel ${callInfo.direction === 'in' ? 'recu de' : 'vers'} ${phone} — Duree: ${durationStr}`,
reference_doctype: 'Customer', reference_name: props.customerName,
})
await loadCommunications()
} catch {}
}
function channelIcon (entry) { return CHANNEL_ICONS[entry.channel] || 'chat' }
function entryClass (entry) {
return [
'entry-' + entry.channel,
entry.direction === 'in' ? 'entry-inbound' : entry.direction === 'out' ? 'entry-outbound' : 'entry-internal',
entry.status === 'Open' ? 'entry-unread' : '',
]
}
function stripHtml (html) {
if (!html) return ''
const div = document.createElement('div'); div.innerHTML = html
return div.textContent || div.innerText || ''
}
function formatTime (dt) {
return dt ? new Date(dt).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) : ''
}
function dateLabel (dt) {
if (!dt) return ''
const d = new Date(dt), now = new Date()
if (d.toDateString() === now.toDateString()) return "Aujourd'hui"
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1)
if (d.toDateString() === yesterday.toDateString()) return 'Hier'
return d.toLocaleDateString('fr-CA', { weekday: 'short', month: 'short', day: 'numeric' })
}
onMounted(loadAll)
watch(() => props.customerName, loadAll)
onUnmounted(() => { sseDisconnect(); stopFallbackPoll() })
</script>
<style src="./chatter-panel.scss" scoped></style>

View File

@ -1,172 +0,0 @@
<template>
<div class="chatter-compose">
<div class="compose-channel-row">
<q-btn-toggle v-model="channel" no-caps dense unelevated size="xs"
toggle-color="indigo-6" color="grey-2" text-color="grey-7"
:options="composeOptions" />
<q-space />
<q-btn flat dense round size="xs" icon="bolt" color="amber-8" @click="$emit('manage-canned')" title="Réponses rapides">
<q-tooltip>Gérer les réponses rapides</q-tooltip>
</q-btn>
<div v-if="customerPhone" class="call-btn-group">
<q-btn flat dense round size="sm" icon="phone" color="green-7"
@click="$emit('call', 'twilio')" :loading="calling">
<q-tooltip>Appeler via Twilio (WebRTC)</q-tooltip>
</q-btn>
<q-btn flat dense round size="sm" icon="sip" color="indigo-6"
@click="$emit('call', 'sip')" :loading="calling">
<q-tooltip>Appeler via SIP (Fonoster)</q-tooltip>
</q-btn>
</div>
</div>
<!-- Canned response dropdown -->
<div v-if="cannedMatches.length" class="canned-dropdown">
<div v-for="(cr, i) in cannedMatches" :key="i" class="canned-option"
:class="{ 'canned-highlighted': i === cannedHighlight }"
@mousedown.prevent="$emit('use-canned', cr)">
<span class="canned-shortcut-label">::{{ cr.shortcut }}</span>
<span class="canned-preview">{{ cr.text.slice(0, 60) }}{{ cr.text.length > 60 ? '…' : '' }}</span>
</div>
</div>
<!-- SMS -->
<div v-if="channel === 'sms'" class="compose-body">
<q-select v-if="phoneOptions.length > 1" v-model="smsTo" dense outlined emit-value map-options
:options="phoneOptions" style="font-size:0.8rem" class="q-mb-xs" />
<q-input v-model="text" dense outlined placeholder="SMS..."
:input-style="{ fontSize: '0.82rem' }" autogrow
@update:model-value="onTextChange"
@keydown.enter.exact.prevent="handleEnter"
@keydown.down.prevent="cannedDown" @keydown.up.prevent="cannedUp"
@keydown.escape="cannedMatches.length ? closeCanned() : null">
<template #append>
<span class="text-caption text-grey-4 q-mr-xs">{{ text.length }}</span>
<q-btn flat dense round icon="send" color="indigo-6" size="sm"
:disable="!text.trim() || !smsTo" :loading="sending" @click="$emit('send')" />
</template>
</q-input>
</div>
<!-- Email -->
<div v-if="channel === 'email'" class="compose-body">
<q-input v-model="emailSubject" dense outlined placeholder="Sujet"
:input-style="{ fontSize: '0.8rem' }" class="q-mb-xs" />
<q-input v-model="text" dense outlined placeholder="Message email..."
type="textarea" autogrow :input-style="{ fontSize: '0.82rem', minHeight: '50px' }"
@update:model-value="onTextChange"
@keydown.down.prevent="cannedDown" @keydown.up.prevent="cannedUp"
@keydown.escape="cannedMatches.length ? closeCanned() : null">
<template #append>
<q-btn flat dense round icon="send" color="indigo-6" size="sm"
:disable="!text.trim() || !emailSubject.trim()" :loading="sending" @click="$emit('send')" />
</template>
</q-input>
</div>
<!-- Note -->
<div v-if="channel === 'note'" class="compose-body">
<q-input v-model="text" dense outlined placeholder="Note interne..."
type="textarea" autogrow :input-style="{ fontSize: '0.82rem', minHeight: '40px' }"
@update:model-value="onTextChange"
@keydown.ctrl.enter="$emit('send')" @keydown.meta.enter="$emit('send')"
@keydown.down.prevent="cannedDown" @keydown.up.prevent="cannedUp"
@keydown.escape="cannedMatches.length ? closeCanned() : null">
<template #append>
<q-btn flat dense round icon="note_add" color="amber-8" size="sm"
:disable="!text.trim()" :loading="sending" @click="$emit('send')" />
</template>
</q-input>
</div>
<!-- Phone -->
<div v-if="channel === 'phone'" class="compose-body">
<div class="row items-center q-gutter-sm">
<q-select v-model="callTo" dense outlined emit-value map-options
:options="phoneOptions" style="font-size:0.8rem; flex:1" label="Appeler" />
<q-btn unelevated dense color="green-7" icon="phone" label="Appeler"
:disable="!callTo" :loading="calling" @click="$emit('call')" />
</div>
<div class="text-caption text-grey-5 q-mt-xs">
L'appel connectera votre poste au client via Twilio.
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const composeOptions = [
{ label: 'SMS', value: 'sms', icon: 'sms' },
{ label: 'Appel', value: 'phone', icon: 'phone' },
{ label: 'Email', value: 'email', icon: 'email' },
{ label: 'Note', value: 'note', icon: 'sticky_note_2' },
]
const props = defineProps({
customerPhone: { type: String, default: '' },
phoneOptions: { type: Array, default: () => [] },
sending: Boolean,
calling: Boolean,
cannedResponses: { type: Array, default: () => [] },
})
const emit = defineEmits(['send', 'call', 'use-canned', 'manage-canned'])
const channel = defineModel('channel', { type: String, default: 'sms' })
const text = defineModel('text', { type: String, default: '' })
const emailSubject = defineModel('emailSubject', { type: String, default: '' })
const smsTo = defineModel('smsTo', { type: String, default: '' })
const callTo = defineModel('callTo', { type: String, default: '' })
// Canned response detection
const cannedQuery = ref('')
const cannedHighlight = ref(0)
const cannedMatches = computed(() => {
if (!cannedQuery.value) return []
const q = cannedQuery.value.toLowerCase()
return props.cannedResponses.filter(cr =>
cr.shortcut.toLowerCase().startsWith(q) || cr.text.toLowerCase().includes(q)
).slice(0, 6)
})
function onTextChange (val) {
// Detect :: prefix for canned response
const match = val?.match(/::(\S*)$/)
if (match) {
cannedQuery.value = match[1]
cannedHighlight.value = 0
} else {
cannedQuery.value = ''
}
}
function handleEnter () {
if (cannedMatches.value.length) {
selectCanned(cannedMatches.value[cannedHighlight.value])
} else {
emit('send')
}
}
function selectCanned (cr) {
// Replace the ::query with the canned text
text.value = text.value.replace(/::(\S*)$/, cr.text)
cannedQuery.value = ''
emit('use-canned', cr)
}
function cannedDown () {
if (cannedMatches.value.length) {
cannedHighlight.value = (cannedHighlight.value + 1) % cannedMatches.value.length
}
}
function cannedUp () {
if (cannedMatches.value.length) {
cannedHighlight.value = cannedHighlight.value <= 0 ? cannedMatches.value.length - 1 : cannedHighlight.value - 1
}
}
function closeCanned () { cannedQuery.value = '' }
</script>

View File

@ -22,14 +22,14 @@
<div class="notify-section q-mt-sm">
<div class="notify-header" @click="notifyExpanded = !notifyExpanded">
<q-icon :name="notifyExpanded ? 'expand_more' : 'chevron_right'" size="16px" color="grey-5" />
<q-icon name="notifications" size="16px" color="indigo-5" class="q-mr-xs" />
<q-icon name="notifications" size="16px" color="green-5" class="q-mr-xs" />
<span class="text-caption text-weight-medium text-grey-7">Envoyer notification</span>
<q-space />
<q-badge v-if="lastSentLabel" color="green-6" class="text-caption">{{ lastSentLabel }}</q-badge>
</div>
<div v-show="notifyExpanded" class="notify-body">
<q-btn-toggle v-model="channel" no-caps dense unelevated size="sm" class="q-mb-xs full-width"
toggle-color="indigo-6" color="grey-3" text-color="grey-8"
toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[
{ label: 'SMS', value: 'sms', icon: 'sms' },
{ label: 'Email', value: 'email', icon: 'email' },
@ -48,7 +48,7 @@
<span class="text-caption text-grey-5">{{ notifyMessage.length }} car.</span>
<q-space />
<q-btn unelevated dense size="sm" :label="channel === 'sms' ? 'Envoyer SMS' : 'Envoyer Email'"
color="indigo-6" icon="send"
color="primary" icon="send"
:disable="!canSend" :loading="sending" @click="sendNotification" />
</div>
</div>

View File

@ -45,6 +45,9 @@
</div>
</div>
<!-- Actions de contact (message / appel / récompense) tout en haut -->
<slot name="actions" />
<!-- Contact & Info (collapsible) -->
<q-expansion-item v-model="contactOpen" dense header-class="contact-toggle-header" class="q-mt-xs">
<template #header>

View File

@ -24,13 +24,13 @@
<q-toggle v-model="customer.is_bad_payer" dense size="xs" color="red"
@update:model-value="save('is_bad_payer')" />
<q-icon name="warning" size="16px" :color="customer.is_bad_payer ? 'red-6' : 'grey-4'" />
<span :class="customer.is_bad_payer ? 'text-red-8 text-weight-medium' : 'text-grey-5'">Mauvais payeur</span>
<span :class="customer.is_bad_payer ? 'text-negative text-weight-medium' : 'text-grey-5'">Mauvais payeur</span>
</div>
<div class="info-row">
<q-toggle v-model="customer.exclude_fees" dense size="xs" color="orange"
@update:model-value="save('exclude_fees')" />
<q-icon name="money_off" size="16px" :color="customer.exclude_fees ? 'orange-6' : 'grey-4'" />
<span :class="customer.exclude_fees ? 'text-orange-8' : 'text-grey-5'">Exclure frais</span>
<span :class="customer.exclude_fees ? 'text-warning' : 'text-grey-5'">Exclure frais</span>
</div>
<div class="info-row">
<q-toggle v-model="customer.ppa_enabled" dense size="xs" color="green"

View File

@ -12,10 +12,10 @@
<div v-else class="phone-panel">
<div class="phone-header">
<q-icon name="phone" size="20px" color="indigo-6" class="q-mr-xs" />
<q-icon name="phone" size="20px" color="primary" class="q-mr-xs" />
<span class="text-weight-bold" style="font-size:1rem">Telephone</span>
<q-space />
<q-badge v-if="deviceReady" color="green" class="q-mr-sm">{{ provider === 'sip' ? 'SIP' : 'Twilio' }}</q-badge>
<q-badge v-if="deviceReady" color="green" class="q-mr-sm">Twilio</q-badge>
<q-badge v-else-if="deviceError" color="red" class="q-mr-sm">Erreur</q-badge>
<q-badge v-else color="orange" class="q-mr-sm"><q-spinner-dots size="10px" class="q-mr-xs" />Init</q-badge>
<q-btn flat dense round size="xs" icon="minimize" color="grey-6" @click="minimized = true" />
@ -23,7 +23,7 @@
</div>
<div v-if="customerName" class="phone-contact">
<q-icon name="person" size="18px" color="indigo-5" />
<q-icon name="person" size="18px" color="green-5" />
<span class="q-ml-xs text-weight-medium">{{ customerName }}</span>
</div>
@ -84,12 +84,12 @@
<q-icon name="check_circle" size="18px" color="green" class="q-mr-xs" />
<span class="text-caption">Appel termine {{ formattedDuration }}</span>
<q-space />
<q-btn dense unelevated size="sm" color="indigo-6" icon="save" label="Logger" @click="logCall" :loading="loggingCall" />
<q-btn dense unelevated size="sm" color="primary" icon="save" label="Logger" @click="logCall" :loading="loggingCall" />
</div>
<div class="phone-footer">
<q-icon name="circle" size="8px" :color="deviceReady ? 'green' : 'grey-4'" class="q-mr-xs" />
<span class="text-caption text-grey-6">{{ provider === 'sip' ? 'Fonoster SIP' : 'Twilio WebRTC' }} {{ identity }}</span>
<span class="text-caption text-grey-6">Twilio WebRTC {{ identity }}</span>
</div>
</div>
</div>
@ -100,12 +100,9 @@
<script setup>
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { Notify } from 'quasar'
import { createDoc } from 'src/api/erp'
import { Device } from '@twilio/voice-sdk'
import { UserAgent, Registerer, Inviter, SessionState } from 'sip.js'
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
const SIP_WSS_URL = 'wss://voice.gigafibre.ca/ws'
const props = defineProps({
modelValue: { type: Boolean, default: false },
@ -137,16 +134,15 @@ const callDurationSec = ref(0)
let durationTimer = null
let device = null
let activeCall = null
let sipUA = null
let sipRegisterer = null
let audioElement = null
let heartbeatTimer = null
const dialpadKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']
const callControls = computed(() => [
{ label: muted.value ? 'Unmute' : 'Mute', icon: muted.value ? 'mic_off' : 'mic', color: muted.value ? 'red' : 'grey-7', action: toggleMute },
{ label: 'Clavier', icon: 'dialpad', color: 'grey-7', action: toggleDialpad },
{ label: 'HP', icon: speakerOn.value ? 'volume_up' : 'volume_off', color: speakerOn.value ? 'indigo-6' : 'grey-7', action: toggleSpeaker },
{ label: 'HP', icon: speakerOn.value ? 'volume_up' : 'volume_off', color: speakerOn.value ? 'primary' : 'grey-7', action: toggleSpeaker },
])
watch(() => props.modelValue, v => { show.value = v })
@ -161,8 +157,7 @@ const formattedDuration = computed(() => {
})
async function initDevice () {
if (props.provider === 'sip') { if (!sipUA) await initSipDevice() }
else { if (!device) await initTwilioDevice() }
if (!device) await initTwilioDevice()
}
async function initTwilioDevice () {
@ -174,25 +169,33 @@ async function initTwilioDevice () {
identity.value = data.identity
device = new Device(data.token, { edge: 'ashburn', logLevel: 'warn', codecPreferences: ['opus', 'pcmu'] })
device.on('registered', () => { deviceReady.value = true; deviceError.value = '' })
device.on('registered', () => { deviceReady.value = true; deviceError.value = ''; startHeartbeat() })
device.on('error', err => { deviceError.value = err.message || 'Device error' })
device.on('tokenWillExpire', async () => {
try { const r = await fetch(HUB_URL + '/voice/token'); const d = await r.json(); device.updateToken(d.token) } catch {}
})
await device.register()
deviceReady.value = true
deviceReady.value = true; startHeartbeat()
device.on('incoming', call => {
// Screen-pop softphone : le hub a passé le nom du compte + le CallSid parent en Parameters Répondre / Transférer à l'IA / Refuser.
const p = call.customParameters
const get = k => { try { return p && p.get ? (p.get(k) || '') : '' } catch { return '' } }
const account = get('account')
const from = call.parameters.From || get('from') || 'Inconnu'
const parentSid = get('parentCallSid')
show.value = true; minimized.value = false
dialNumber.value = call.parameters.From || 'Inconnu'
dialNumber.value = from
Notify.create({
type: 'info', message: `Appel entrant de ${dialNumber.value}`,
type: 'info', position: 'top-right', timeout: 25000, icon: 'phone_in_talk', color: account ? 'green-8' : 'blue-grey-8', textColor: 'white',
message: account ? `Appel entrant — ${account}` : `Appel entrant — ${from}`,
caption: account ? from : 'numéro non reconnu',
actions: [
{ label: 'Repondre', color: 'green', handler: () => answerIncoming(call) },
{ label: 'Refuser', color: 'red', handler: () => call.reject() },
{ label: 'Répondre', color: 'white', handler: () => answerIncoming(call) },
{ label: 'Transférer à l\'IA', color: 'white', handler: () => { transferToAI(parentSid, from); try { call.reject() } catch {} } },
{ label: 'Refuser', color: 'white', handler: () => { try { call.reject() } catch {} } },
],
timeout: 30000,
})
})
} catch (e) {
@ -200,59 +203,25 @@ async function initTwilioDevice () {
}
}
async function initSipDevice () {
deviceError.value = ''
try {
const res = await fetch(HUB_URL + '/voice/sip-config')
if (!res.ok) throw new Error('SIP config fetch failed: ' + res.status)
const cfg = await res.json()
identity.value = cfg.extension || cfg.identity || 'sip-agent'
const sipDomain = cfg.domain || 'voice.gigafibre.ca'
const uri = UserAgent.makeURI(`sip:${cfg.extension}@${sipDomain}`)
if (!uri) throw new Error('Invalid SIP URI')
sipUA = new UserAgent({
uri,
transportOptions: { server: cfg.wssUrl || SIP_WSS_URL },
authorizationUsername: cfg.authId || cfg.extension,
authorizationPassword: cfg.authPassword,
displayName: cfg.displayName || 'Targo Ops',
logLevel: 'warn',
delegate: {
onInvite: invitation => {
show.value = true; minimized.value = false
const remote = invitation.remoteIdentity?.uri?.user || 'Inconnu'
dialNumber.value = remote
Notify.create({
type: 'info', message: `Appel SIP entrant de ${remote}`,
actions: [
{ label: 'Repondre', color: 'green', handler: () => answerSipIncoming(invitation) },
{ label: 'Refuser', color: 'red', handler: () => { try { invitation.reject() } catch {} } },
],
timeout: 30000,
})
},
},
})
await sipUA.start()
sipRegisterer = new Registerer(sipUA)
await sipRegisterer.register()
deviceReady.value = true
} catch (e) {
deviceError.value = e.message
}
}
function cleanupDevices () {
deviceReady.value = false; deviceError.value = ''
if (device) { try { device.unregister(); device.destroy() } catch {} device = null }
if (sipRegisterer) { try { sipRegisterer.unregister() } catch {} sipRegisterer = null }
if (sipUA) { try { sipUA.stop() } catch {} sipUA = null }
if (audioElement) audioElement.srcObject = null
}
// Battement « je suis en ligne » le hub sait quels softphones faire sonner à l'appel entrant.
function startHeartbeat () {
if (heartbeatTimer) return
const ping = () => { if (identity.value) fetch(HUB_URL + '/voice/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identity: identity.value }) }).catch(() => {}) }
ping(); heartbeatTimer = setInterval(ping, 30000)
}
function stopHeartbeat () { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null } }
// Transférer l'appel (entrant/en cours) vers l'agent IA : le hub redirige la jambe PSTN (REST Twilio).
async function transferToAI (parentSid, from) {
if (!parentSid) return
try { await fetch(HUB_URL + '/voice/to-ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ callSid: parentSid, from }) }) } catch {}
}
function getAudioElement () {
if (!audioElement) {
audioElement = document.createElement('audio')
@ -267,31 +236,6 @@ function answerIncoming (call) {
inCall.value = true; connected.value = true; callEnded.value = false; startDurationTimer()
}
function answerSipIncoming (invitation) {
activeCall = invitation; setupSipSessionListeners(invitation)
invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } })
inCall.value = true; callEnded.value = false
}
function setupSipSessionListeners (session) {
session.stateChange.addListener(state => {
if (state === SessionState.Established) {
connected.value = true; connecting.value = false; startDurationTimer()
const pc = session.sessionDescriptionHandler?.peerConnection
if (pc) {
const audio = getAudioElement()
pc.getReceivers().forEach(r => { if (r.track?.kind === 'audio') audio.srcObject = new MediaStream([r.track]) })
}
} else if (state === SessionState.Terminated) {
stopDurationTimer()
inCall.value = false; connected.value = false; connecting.value = false
callEnded.value = true; muted.value = false; showDtmf.value = false
activeCall = null
if (audioElement) audioElement.srcObject = null
}
})
}
async function startCall () {
if (!dialNumber.value.trim() || !deviceReady.value) return
connecting.value = true; callEnded.value = false; callStatusText.value = 'Connexion...'
@ -301,7 +245,7 @@ async function startCall () {
number = number.length === 10 ? '+1' + number : number.length === 11 && number.startsWith('1') ? '+' + number : '+' + number
}
props.provider === 'sip' ? await startSipCall(number) : await startTwilioCall(number)
await startTwilioCall(number)
}
async function startTwilioCall (number) {
@ -313,19 +257,6 @@ async function startTwilioCall (number) {
}
}
async function startSipCall (number) {
if (!sipUA) { deviceError.value = 'SIP not registered'; connecting.value = false; return }
try {
const target = UserAgent.makeURI(`sip:${number}@voice.gigafibre.ca`)
if (!target) throw new Error('Invalid SIP target')
const inviter = new Inviter(sipUA, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } })
activeCall = inviter; setupSipSessionListeners(inviter)
await inviter.invite(); inCall.value = true
} catch (e) {
deviceError.value = 'SIP call failed: ' + e.message; connecting.value = false
}
}
function setupCallListeners (call) {
call.on('ringing', () => { callStatusText.value = 'Sonnerie...'; connecting.value = false })
call.on('accept', () => { connected.value = true; connecting.value = false; callStatusText.value = ''; startDurationTimer() })
@ -339,18 +270,13 @@ function setupCallListeners (call) {
function endCall () {
if (!activeCall) return
if (props.provider === 'sip') {
try { activeCall.state === SessionState.Established ? activeCall.bye() : (activeCall.cancel?.() || activeCall.reject?.()) } catch {}
} else { activeCall.disconnect() }
activeCall.disconnect()
}
function toggleMute () {
if (!activeCall) return
muted.value = !muted.value
if (props.provider === 'sip') {
const pc = activeCall.sessionDescriptionHandler?.peerConnection
if (pc) pc.getSenders().forEach(s => { if (s.track?.kind === 'audio') s.track.enabled = !muted.value })
} else { activeCall.mute(muted.value) }
activeCall.mute(muted.value)
}
function toggleSpeaker () { speakerOn.value = !speakerOn.value }
@ -358,11 +284,7 @@ function toggleDialpad () { showDtmf.value = !showDtmf.value }
function sendDtmf (digit) {
if (!activeCall) return
if (props.provider === 'sip') {
try { activeCall.sessionDescriptionHandler?.sendDtmf(digit) } catch {}
} else {
activeCall.sendDigits(digit)
}
}
function pressKey (key) { dialNumber.value += key }
@ -378,22 +300,11 @@ async function logCall () {
loggingCall.value = true
const phone = dialNumber.value.trim()
const duration = callDurationSec.value
const durationStr = `${Math.floor(duration / 60)}m${String(duration % 60).padStart(2, '0')}s`
try {
await createDoc('Communication', {
subject: 'Appel vers ' + phone,
communication_type: 'Communication', communication_medium: 'Phone',
sent_or_received: 'Sent', status: 'Linked', phone_no: phone,
sender: 'sms@gigafibre.ca', sender_full_name: 'Targo Ops',
content: `Appel vers ${phone} — Duree: ${durationStr}`,
reference_doctype: 'Customer', reference_name: props.customerErpName || props.customerName,
})
Notify.create({ type: 'positive', message: 'Appel enregistre', timeout: 2000 })
// Le doc « Communication » est créé par le PARENT (ChatterPanel.onCallEnded) = source UNIQUE (gère direction + rafraîchit le fil) évite le DOUBLE enregistrement de chaque appel.
emit('call-ended', { direction: 'out', remote: phone, duration })
Notify.create({ type: 'positive', message: 'Appel enregistré', timeout: 2000 })
callEnded.value = false
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
} finally { loggingCall.value = false }
}
@ -403,8 +314,9 @@ function close () {
callEnded.value = false; callStartTime.value = null; callDurationSec.value = 0
}
onMounted(() => { if (show.value) initDevice() })
onUnmounted(() => { stopDurationTimer(); cleanupDevices() })
// Enregistrement EN ARRIÈRE-PLAN dès le montage (panneau caché) l'agent reçoit les appels entrants sans ouvrir le clavier.
onMounted(() => { initDevice() })
onUnmounted(() => { stopDurationTimer(); stopHeartbeat(); cleanupDevices() })
</script>
<style scoped>

View File

@ -0,0 +1,149 @@
<template>
<!-- Carte-cadeau / récompense : voir/copier le lien (msg.gigafibre.ca/g/<token>), personnaliser (composeur) ou envoyer.
Réutilise le pool de récompenses. Extrait de ClientDetailPage (découpage 2026-06-30). -->
<q-dialog :model-value="modelValue" @update:model-value="v => emit('update:modelValue', v)">
<q-card style="min-width:340px;max-width:520px">
<q-card-section class="row items-center q-pb-none">
<q-icon name="card_giftcard" color="warning" size="22px" class="q-mr-sm" />
<div class="text-h6">Carte-cadeau</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div class="text-caption text-grey-7 q-mb-sm">{{ reward.customerName }}<span v-if="reward.email"> · {{ reward.email }}</span></div>
<q-inner-loading :showing="reward.loading" />
<div v-if="reward.existing.length" class="q-mb-md">
<div class="text-subtitle2 q-mb-xs">Récompenses de ce client</div>
<div v-for="(r, i) in reward.existing" :key="i" class="q-mb-sm">
<div class="row items-center q-gutter-xs">
<q-chip dense :color="rewardStatusColor(r.status)" text-color="white" class="q-ma-none">{{ r.status }}</q-chip>
<span class="text-weight-medium">{{ formatMoney((r.value_cents || 0) / 100) }}</span>
<span v-if="r.clicks" class="text-caption text-green-7">· ouvert</span>
<q-space />
<q-btn v-if="r.is_draft && r.conversation_token" flat dense no-caps size="sm" color="primary" icon="edit" label="Éditer" @click="rewardEditDraft(r)">
<q-tooltip>Éditer ce brouillon dans l'éditeur de courriel</q-tooltip>
</q-btn>
<q-btn flat dense no-caps size="sm" color="primary" icon-right="open_in_new" label="Suivi" :to="{ path: '/campaigns/gifts', query: { q: r.gift_token } }">
<q-tooltip>Voir cette récompense dans le suivi des campagnes</q-tooltip>
</q-btn>
<q-btn v-if="!r.clicked" flat dense round size="sm" color="negative" icon="delete_outline" @click="rewardDelete(r)">
<q-tooltip>{{ r.is_draft ? 'Supprimer ce brouillon (remet le lien en réserve)' : 'Révoquer — remet le lien en réserve' }}</q-tooltip>
</q-btn>
</div>
<q-input dense outlined readonly :model-value="r.link" class="q-mt-xs" @focus="e => e.target.select()">
<template #append><q-btn flat dense round icon="content_copy" @click="copyTxt(r.link)" /></template>
</q-input>
</div>
</div>
<q-banner v-if="!reward.email" dense class="bg-orange-1 text-warning q-mb-sm">Aucun courriel au dossier copie un lien à partager autrement, ou ajoute un courriel pour l'envoyer.</q-banner>
<q-banner v-else-if="reward.poolAvailable === 0" dense class="bg-red-1 text-negative q-mb-sm">Réserve de cartes-cadeaux vide (Campagnes Réserve).</q-banner>
<div v-else-if="reward.poolAvailable !== null" class="text-caption text-grey-7 q-mb-sm">Nouvelle récompense : <b>{{ formatMoney(reward.value / 100) }}</b> · {{ reward.poolAvailable }} en réserve.</div>
<div v-if="reward.error" class="text-negative text-caption">{{ reward.error }}</div>
<div class="q-mt-sm">
<q-btn flat dense no-caps size="sm" color="primary" icon="campaign" label="Voir le suivi des campagnes" :to="{ path: '/campaigns/gifts', query: { q: reward.email || reward.customerName } }" />
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps icon="link" label="Copier le lien" :loading="reward.acting === 'link'" :disable="reward.poolAvailable === 0 || reward.loading" @click="rewardCopyLink" />
<q-btn flat no-caps color="primary" icon="edit" label="Personnaliser" :loading="reward.acting === 'editor'" :disable="!reward.email || reward.poolAvailable === 0 || reward.loading" @click="rewardOpenEditor">
<q-tooltip>Ouvrir un brouillon dans l'éditeur de courriel pour personnaliser le message</q-tooltip>
</q-btn>
<q-btn unelevated no-caps color="warning" text-color="white" icon="send" label="Envoyer" :loading="reward.acting === 'send'" :disable="!reward.email || reward.poolAvailable === 0 || reward.loading" @click="rewardSendEmail">
<q-tooltip>Envoyer la carte-cadeau directement (sans édition)</q-tooltip>
</q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { reactive, watch } from 'vue'
import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router'
import { formatMoney } from 'src/composables/useFormatters'
import { useConversations } from 'src/composables/useConversations'
import { rewardSend, rewardByCustomer, revokeRewardByToken } from 'src/api/campaigns'
const props = defineProps({
modelValue: { type: Boolean, default: false },
customer: { type: Object, default: null },
})
const emit = defineEmits(['update:modelValue'])
const $q = useQuasar()
const router = useRouter()
const { openComposeDraft } = useConversations()
const reward = reactive({ loading: false, acting: '', customerId: '', customerName: '', email: '', existing: [], value: 0, poolAvailable: null, error: '' })
watch(() => props.modelValue, (v) => { if (v) openReward() })
function close () { emit('update:modelValue', false) }
function rewardStatusColor (s) { return s === 'sent' ? 'green-7' : s === 'draft' ? 'blue-grey-6' : s === 'link' ? 'green-4' : s === 'pending' ? 'amber-8' : 'grey-6' }
function copyTxt (txt) { try { navigator.clipboard.writeText(txt); $q.notify({ type: 'positive', message: 'Lien copié', timeout: 1200 }) } catch (e) { /* */ } }
async function refreshReward () {
try { const byc = await rewardByCustomer(reward.customerId, reward.email); reward.existing = byc.rewards || [] } catch (e) { /* */ }
try { const prev = await rewardSend({ customer_id: reward.customerId, channel: 'email', dry_run: true }); if (prev && !prev.error) { reward.poolAvailable = prev.pool_available; reward.value = prev.gift_value_cents || reward.value } } catch (e) { /* */ }
}
async function openReward () {
const c = props.customer; if (!c) return
reward.loading = true; reward.error = ''
reward.customerId = c.name; reward.customerName = c.customer_name || c.name
reward.email = c.email_id || c.email_billing || ''
reward.existing = []; reward.poolAvailable = null; reward.value = 0
try {
const [byc, prev] = await Promise.all([
rewardByCustomer(c.name, reward.email).catch(() => ({ rewards: [] })),
rewardSend({ customer_id: c.name, channel: 'email', dry_run: true }).catch(e => ({ error: e && e.message }))
])
reward.existing = byc.rewards || []
if (prev && prev.error) reward.error = prev.error
else if (prev) { reward.value = prev.gift_value_cents || 0; reward.poolAvailable = prev.pool_available }
} catch (e) { reward.error = e.message } finally { reward.loading = false }
}
function rewardSendEmail () {
if (!reward.email) return
$q.dialog({ title: 'Envoyer la carte-cadeau', message: 'Envoyer ' + formatMoney(reward.value / 100) + ' par courriel à ' + reward.email + ' ?', cancel: true, ok: { label: 'Envoyer', color: 'amber-8' } }).onOk(async () => {
reward.acting = 'send'
try {
const r = await rewardSend({ customer_id: reward.customerId, channel: 'email' })
if (r.error) $q.notify({ type: 'negative', message: r.error })
else { $q.notify({ type: 'positive', message: 'Récompense envoyée à ' + reward.email }); await refreshReward() }
} catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + e.message }) } finally { reward.acting = '' }
})
}
async function rewardCopyLink () {
reward.acting = 'link'
try {
const r = await rewardSend({ customer_id: reward.customerId, channel: 'email', mode: 'link' })
if (r.error) $q.notify({ type: 'negative', message: r.error })
else if (r.client_link) { copyTxt(r.client_link); $q.notify({ type: 'positive', message: 'Lien de récompense copié' }); await refreshReward() }
} catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + e.message }) } finally { reward.acting = '' }
}
async function rewardOpenEditor () {
reward.acting = 'editor'
try {
const r = await rewardSend({ customer_id: reward.customerId, channel: 'email', mode: 'link' })
if (r.error) { $q.notify({ type: 'negative', message: r.error }); return }
const first = (reward.customerName || '').trim().split(/\s+/)[0] || ''
const val = formatMoney((r.gift_value_cents || reward.value || 0) / 100)
const msg = `Bonjour ${first},\n\nNous tenons à vous offrir une récompense de ${val}. Cliquez ici pour la récupérer :\n${r.client_link}\n\nMerci de votre confiance,\nL'équipe Targo`
close()
openComposeDraft({ channel: 'email', to: reward.email, subject: 'Votre récompense Targo 🎁', text: msg, customer: reward.customerId, customerName: reward.customerName })
$q.notify({ type: 'positive', message: 'Récompense prête — personnalise le message puis envoie.' })
} catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + e.message }) } finally { reward.acting = '' }
}
function rewardEditDraft (r) {
if (!r.conversation_token) return
close()
router.push('/communications/c/' + r.conversation_token)
}
function rewardDelete (r) {
$q.dialog({ title: r.is_draft ? 'Supprimer le brouillon' : 'Révoquer la récompense', message: (r.is_draft ? 'Supprimer ce brouillon' : 'Révoquer cette récompense') + ' (' + formatMoney((r.value_cents || 0) / 100) + ') ? Le lien retourne en réserve.', cancel: true, ok: { label: r.is_draft ? 'Supprimer' : 'Révoquer', color: 'negative' } }).onOk(async () => {
try {
const res = await revokeRewardByToken(r.gift_token)
if (res.error) { $q.notify({ type: 'negative', message: res.error }) }
else { $q.notify({ type: 'positive', message: r.is_draft ? 'Brouillon supprimé' : 'Récompense révoquée' }); await refreshReward() }
} catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + e.message }) }
})
}
</script>

View File

@ -1,255 +0,0 @@
<template>
<div class="sms-thread">
<div class="sms-thread-header" @click="expanded = !expanded">
<q-icon :name="expanded ? 'expand_more' : 'chevron_right'" size="16px" color="grey-5" />
<q-icon name="forum" size="18px" color="indigo-5" class="q-mr-xs" />
<span class="text-weight-bold" style="font-size:0.95rem">SMS</span>
<q-space />
<q-badge v-if="unreadCount" color="red" class="q-mr-xs">{{ unreadCount }}</q-badge>
<span class="text-caption text-grey-5">{{ messages.length }}</span>
</div>
<div v-show="expanded" class="sms-thread-body">
<!-- Loading -->
<div v-if="loading" class="flex flex-center q-pa-md">
<q-spinner size="20px" color="indigo-5" />
</div>
<!-- Empty -->
<div v-else-if="!messages.length" class="text-center text-grey-5 q-pa-md text-caption">
Aucun SMS pour ce client
</div>
<!-- Messages -->
<div v-else class="sms-messages" ref="messagesContainer">
<div v-for="msg in messages" :key="msg.name" class="sms-bubble-wrap"
:class="msg.sent_or_received === 'Sent' ? 'sms-outbound' : 'sms-inbound'">
<div class="sms-bubble" :class="msg.sent_or_received === 'Sent' ? 'bubble-sent' : 'bubble-received'">
<div class="sms-text">{{ stripHtml(msg.content || msg.text_content || '') }}</div>
<div class="sms-meta">
<span>{{ formatTime(msg.communication_date || msg.creation) }}</span>
<q-icon v-if="msg.sent_or_received === 'Sent'" name="done_all" size="12px"
:color="msg.delivery_status === 'Read' ? 'blue-5' : 'grey-5'" class="q-ml-xs" />
<q-btn v-if="msg.reference_doctype === 'Issue' || msg.reference_doctype === 'Dispatch Job'"
flat dense round size="xs" icon="link" color="indigo-5" class="q-ml-xs"
@click.stop="$emit('navigate', msg.reference_doctype, msg.reference_name)">
<q-tooltip>{{ msg.reference_doctype }}: {{ msg.reference_name }}</q-tooltip>
</q-btn>
</div>
</div>
<!-- Link to ticket action -->
<q-btn v-if="msg.sent_or_received === 'Received' && msg.reference_doctype === 'Customer'"
flat dense size="xs" icon="link" color="grey-5" class="sms-link-btn"
@click.stop="linkToTicket(msg)">
<q-tooltip>Lier a un ticket</q-tooltip>
</q-btn>
</div>
</div>
<!-- Compose reply -->
<div class="sms-compose">
<q-input v-model="reply" dense outlined placeholder="Repondre par SMS..."
:input-style="{ fontSize: '0.82rem' }" class="col"
@keydown.enter.exact.prevent="sendReply">
<template #append>
<q-btn flat dense round icon="send" color="indigo-6" size="sm"
:disable="!reply.trim()" :loading="sending" @click="sendReply" />
</template>
</q-input>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import { listDocs } from 'src/api/erp'
import { sendTestSms } from 'src/api/sms'
import { Notify } from 'quasar'
const props = defineProps({
customerName: { type: String, required: true },
customerPhone: { type: String, default: '' },
})
const emit = defineEmits(['navigate'])
const expanded = ref(true)
const loading = ref(true)
const messages = ref([])
const reply = ref('')
const sending = ref(false)
const messagesContainer = ref(null)
const unreadCount = ref(0)
async function loadMessages () {
loading.value = true
try {
const data = await listDocs('Communication', {
filters: {
communication_medium: 'SMS',
reference_doctype: 'Customer',
reference_name: props.customerName,
},
fields: [
'name', 'subject', 'content', 'text_content', 'sent_or_received',
'communication_date', 'creation', 'phone_no', 'sender',
'status', 'delivery_status', 'reference_doctype', 'reference_name',
'message_id',
],
limit: 50,
orderBy: 'communication_date asc',
})
messages.value = data
unreadCount.value = data.filter(m => m.sent_or_received === 'Received' && m.status === 'Open').length
await nextTick()
scrollToBottom()
} catch (e) {
console.error('[SmsThread] load error:', e)
} finally {
loading.value = false
}
}
function scrollToBottom () {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
async function sendReply () {
if (!reply.value.trim() || sending.value) return
const text = reply.value.trim()
const phone = props.customerPhone
if (!phone) {
Notify.create({ type: 'warning', message: 'Aucun numero de telephone', timeout: 3000 })
return
}
sending.value = true
try {
await sendTestSms(phone, text, props.customerName)
reply.value = ''
Notify.create({ type: 'positive', message: 'SMS envoye', timeout: 2000 })
// Reload immediately hub logs Communication before returning
await loadMessages()
// Safety: reload again in case of propagation delay
setTimeout(() => loadMessages(), 2000)
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
} finally {
sending.value = false
}
}
function linkToTicket (msg) {
// TODO: open a dialog to select a ticket to link this message to
Notify.create({ type: 'info', message: 'Lier au ticket — a venir', timeout: 2000 })
}
function stripHtml (html) {
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || div.innerText || ''
}
function formatTime (dt) {
if (!dt) return ''
const d = new Date(dt)
const now = new Date()
const isToday = d.toDateString() === now.toDateString()
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
const isYesterday = d.toDateString() === yesterday.toDateString()
const time = d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
if (isToday) return time
if (isYesterday) return 'Hier ' + time
return d.toLocaleDateString('fr-CA', { month: 'short', day: 'numeric' }) + ' ' + time
}
onMounted(loadMessages)
watch(() => props.customerName, loadMessages)
// Auto-refresh every 30s when expanded
let refreshInterval = null
watch(expanded, (val) => {
if (val) {
loadMessages()
refreshInterval = setInterval(loadMessages, 30000)
} else if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
}, { immediate: true })
</script>
<style scoped>
.sms-thread {
border-top: 1px solid #e2e8f0;
margin-top: 8px;
padding-top: 6px;
}
.sms-thread-header {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 4px 0;
user-select: none;
}
.sms-thread-header:hover { background: #f8fafc; border-radius: 4px; }
.sms-thread-body { padding: 4px 0; }
.sms-messages {
max-height: 350px;
overflow-y: auto;
padding: 8px 4px;
display: flex;
flex-direction: column;
gap: 6px;
}
.sms-bubble-wrap {
display: flex;
align-items: flex-end;
gap: 4px;
}
.sms-outbound { justify-content: flex-end; }
.sms-inbound { justify-content: flex-start; }
.sms-bubble {
max-width: 80%;
padding: 8px 12px;
border-radius: 12px;
font-size: 0.85rem;
line-height: 1.35;
}
.bubble-sent {
background: #e8eaf6;
color: #1a237e;
border-bottom-right-radius: 4px;
}
.bubble-received {
background: #f5f5f5;
color: #333;
border-bottom-left-radius: 4px;
}
.sms-text { word-break: break-word; }
.sms-meta {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 2px;
font-size: 0.7rem;
color: #9e9e9e;
}
.sms-link-btn { opacity: 0; transition: opacity 0.15s; }
.sms-bubble-wrap:hover .sms-link-btn { opacity: 1; }
.sms-compose {
padding: 6px 0 2px;
}
</style>

View File

@ -1,200 +0,0 @@
.chatter-panel {
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
display: flex;
flex-direction: column;
height: calc(100vh - 220px);
min-height: 500px;
position: sticky;
top: 16px;
}
.chatter-header {
display: flex;
align-items: center;
padding: 12px 14px 6px;
}
.chatter-bulk-bar {
display: flex;
align-items: center;
padding: 4px 10px;
background: #eef2ff;
border-bottom: 1px solid #c7d2fe;
gap: 4px;
}
.chatter-tabs {
padding: 0 10px 8px;
}
.chatter-timeline {
flex: 1;
overflow-y: auto;
padding: 0 10px;
scroll-behavior: smooth;
}
.chatter-date-sep {
text-align: center;
margin: 12px 0 6px;
}
.chatter-date-sep span {
background: #f1f5f9;
color: #94a3b8;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 10px;
border-radius: 10px;
}
.chatter-entry {
display: flex;
gap: 8px;
padding: 6px 4px;
border-radius: 6px;
transition: background 0.1s;
}
.chatter-entry:hover { background: #f8fafc; }
.chatter-entry.entry-unread { background: #eff6ff; }
.chatter-entry.entry-selected { background: #eef2ff; }
.entry-checkbox {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.entry-icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
cursor: pointer;
transition: opacity 0.1s;
&:hover { opacity: 0.7; }
}
.icon-sms { background: #e8eaf6; color: #3f51b5; }
.icon-phone { background: #e8f5e9; color: #2e7d32; }
.icon-email { background: #fff3e0; color: #e65100; }
.icon-note { background: #fff8e1; color: #f9a825; }
.icon-other { background: #f5f5f5; color: #757575; }
.entry-body { flex: 1; min-width: 0; }
.entry-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 1px;
}
.entry-author { font-size: 0.8rem; color: #334155; }
.entry-time { font-size: 0.7rem; }
.entry-actions {
display: flex;
gap: 0;
margin-right: 4px;
}
.entry-content {
font-size: 0.84rem;
line-height: 1.4;
color: #475569;
word-break: break-word;
}
.entry-note {
font-style: italic;
color: #92400e;
background: #fffbeb;
padding: 4px 8px;
border-radius: 6px;
border-left: 3px solid #f59e0b;
}
.entry-edit {
margin-top: 2px;
}
.entry-meta { margin-top: 2px; }
.entry-link { margin-top: 3px; }
.chatter-compose {
border-top: 1px solid #e2e8f0;
padding: 8px 10px;
background: #fafbfc;
border-radius: 0 0 10px 10px;
position: relative;
}
.compose-channel-row {
display: flex;
align-items: center;
margin-bottom: 6px;
}
// Canned responses dropdown
.canned-dropdown {
position: absolute;
bottom: 100%;
left: 8px;
right: 8px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 -4px 16px rgba(0,0,0,0.1);
max-height: 200px;
overflow-y: auto;
z-index: 10;
}
.canned-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s;
&:hover, &.canned-highlighted { background: #f1f5f9; }
&:not(:last-child) { border-bottom: 1px solid #f1f5f9; }
}
.canned-shortcut-label {
font-size: 0.75rem;
font-weight: 700;
color: #6366f1;
background: #eef2ff;
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
}
.canned-preview {
font-size: 0.8rem;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Canned manager modal
.canned-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
border-bottom: 1px solid #f1f5f9;
}
.canned-shortcut { flex-shrink: 0; }
.canned-text { flex: 1; min-width: 0; }
.chatter-timeline::-webkit-scrollbar { width: 4px; }
.chatter-timeline::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.chatter-timeline::-webkit-scrollbar-track { background: transparent; }

View File

@ -1,7 +1,7 @@
<template>
<div class="nlp-input-bar" :class="{ 'nlp-expanded': showResult }">
<div class="nlp-row">
<q-icon name="auto_awesome" size="18px" color="indigo-5" class="q-mr-xs" />
<q-icon name="auto_awesome" size="18px" color="green-5" class="q-mr-xs" />
<input
ref="inputRef"
v-model="text"
@ -11,8 +11,8 @@
@keydown.escape="clear"
:disabled="loading"
/>
<q-spinner v-if="loading" size="16px" color="indigo-5" class="q-mx-xs" />
<q-btn v-else-if="text.trim()" flat dense round size="sm" icon="send" color="indigo-6" @click="submit" />
<q-spinner v-if="loading" size="16px" color="green-5" class="q-mx-xs" />
<q-btn v-else-if="text.trim()" flat dense round size="sm" icon="send" color="primary" @click="submit" />
<q-btn v-if="showResult" flat dense round size="xs" icon="close" color="grey-6" @click="clear" />
</div>
<transition name="nlp-slide">
@ -45,7 +45,7 @@
<div class="nlp-actions q-mt-sm">
<q-btn v-if="result.action !== 'query' && result.action !== 'unknown'" dense no-caps unelevated
color="indigo-6" size="sm" icon="check" label="Appliquer" @click="apply" :loading="applying" />
color="primary" size="sm" icon="check" label="Appliquer" @click="apply" :loading="applying" />
<q-btn flat dense no-caps size="sm" label="Ignorer" color="grey-6" @click="clear" />
</div>
</div>
@ -84,7 +84,7 @@ const ACTION_ICONS = {
unknown: 'help_outline',
}
const ACTION_COLORS = {
create_job: 'indigo-6',
create_job: 'primary',
move_job: 'blue-6',
redistribute: 'orange-8',
cancel_job: 'red',

View File

@ -0,0 +1,82 @@
<template>
<!-- Éditeur de flows VISUEL node-based (Vue Flow, port Vue de React Flow esthétique n8n). Lit/édite le MÊME modèle de steps (id/parent/branch/type). -->
<div class="flow-canvas">
<div v-if="editable" class="fc-toolbar">
<q-btn dense unelevated no-caps color="primary" icon="add" label="Ajouter une étape" size="sm" @click="emit('add')" />
<span class="fc-hint">Tirez d'un point à l'autre pour relier · clic = éditer · = supprimer · glissez pour ranger</span>
</div>
<VueFlow :nodes="nodes" :edges="edges" :min-zoom="0.2" :max-zoom="2" fit-view-on-init
:default-edge-options="{ type: 'smoothstep' }" :nodes-connectable="editable" :nodes-draggable="true"
@connect="onConnect" @node-click="onNodeClick" @node-drag-stop="onDragStop">
<Background pattern-color="#d6deea" :gap="18" />
<Controls />
<template #node-flowstep="{ id, data }">
<Handle type="target" :position="Position.Left" />
<div class="fc-node" :style="{ borderColor: data.color }">
<button v-if="editable" class="fc-del" title="Supprimer" @click.stop="emit('delete', id)"></button>
<div class="fc-node-type" :style="{ color: data.color }">{{ data.icon }} {{ data.typeLabel }}</div>
<div class="fc-node-label">{{ data.label }}</div>
</div>
<Handle type="source" :position="Position.Right" />
</template>
</VueFlow>
<div v-if="!nodes.length" class="fc-empty">Aucune étape « Ajouter une étape » pour commencer.</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { VueFlow, Handle, Position } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
const props = defineProps({ steps: { type: Array, default: () => [] }, editable: { type: Boolean, default: true } })
const emit = defineEmits(['edit', 'delete', 'add', 'connect', 'move'])
const TYPE_META = {
tool: { c: '#0ea5e9', i: '🔧', l: 'Outil' }, condition: { c: '#f59e0b', i: '❓', l: 'Condition' },
switch: { c: '#f59e0b', i: '🔀', l: 'Switch' }, respond: { c: '#10b981', i: '💬', l: 'Réponse' },
action: { c: '#6366f1', i: '⚙️', l: 'Action' }, goto: { c: '#64748b', i: '↪️', l: 'Aller à' },
}
const nodes = ref([])
const edges = ref([])
// Layout : position manuelle (step._pos, persistée) sinon arbre auto (profondeurX, ordreY).
function rebuild () {
const steps = props.steps || []
const byId = {}; for (const s of steps) byId[s.id] = s
const depthOf = (s) => { let d = 0, cur = s; const seen = new Set(); while (cur && cur.parent != null && byId[cur.parent] && !seen.has(cur.id)) { seen.add(cur.id); cur = byId[cur.parent]; d++ } return d }
const perDepth = {}; const n = []
for (const s of steps) {
const d = depthOf(s); perDepth[d] = perDepth[d] || 0
const m = TYPE_META[s.type] || TYPE_META.action
const pos = (s._pos && typeof s._pos.x === 'number') ? { x: s._pos.x, y: s._pos.y } : { x: d * 300, y: perDepth[d] * 130 }
n.push({ id: String(s.id), type: 'flowstep', position: pos, data: { label: s.label || s.id, typeLabel: m.l, icon: m.i, color: m.c } })
perDepth[d]++
}
const e = []
for (const s of steps) { if (s.parent != null && byId[s.parent]) e.push({ id: `e-${s.parent}-${s.id}`, source: String(s.parent), target: String(s.id), label: s.branch || '', labelBgPadding: [4, 2], style: { stroke: '#94a3b8' }, labelStyle: { fontSize: '11px', fill: '#64748b' } }) }
nodes.value = n; edges.value = e
}
watch(() => props.steps, rebuild, { immediate: true, deep: true })
function onNodeClick (e) { if (e && e.node) emit('edit', e.node.id) }
function onConnect (params) { if (params && params.source && params.target) emit('connect', { source: params.source, target: params.target }) }
function onDragStop (e) { const node = (e && e.node) || (e && e.nodes && e.nodes[0]); if (node) emit('move', { id: node.id, position: { x: node.position.x, y: node.position.y } }) }
</script>
<style scoped>
.flow-canvas { position: relative; height: 580px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fafbfc; overflow: hidden; }
.fc-toolbar { position: absolute; top: 8px; left: 8px; z-index: 5; display: flex; align-items: center; gap: 10px; }
.fc-hint { font-size: 11px; color: #94a3b8; background: rgba(255,255,255,.7); padding: 2px 6px; border-radius: 6px; }
.fc-node { position: relative; background: #fff; border: 2px solid #6366f1; border-radius: 10px; padding: 8px 12px; min-width: 160px; max-width: 230px; box-shadow: 0 1px 5px rgba(15,23,42,.08); }
.fc-node-type { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 2px; }
.fc-node-label { font-size: 13px; color: #1e293b; line-height: 1.25; }
.fc-del { position: absolute; top: -8px; right: -8px; width: 18px; height: 18px; border-radius: 50%; border: none; background: #ef4444; color: #fff; font-size: 11px; line-height: 1; cursor: pointer; display: none; }
.fc-node:hover .fc-del { display: block; }
.fc-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #94a3b8; font-size: 0.9rem; pointer-events: none; }
</style>

View File

@ -53,7 +53,7 @@
<div v-if="!steps.length" class="flow-editor-empty">
<q-icon name="account_tree" size="32px" color="grey-5" />
<div class="text-caption text-grey-7 q-mt-sm">Flow vide</div>
<q-btn v-if="!readonly" color="indigo-6" label="Ajouter une étape" no-caps size="sm"
<q-btn v-if="!readonly" color="primary" label="Ajouter une étape" no-caps size="sm"
icon="add" @click="openNew()" class="q-mt-sm" />
</div>

View File

@ -23,7 +23,7 @@
<!-- Header -->
<q-card-section class="fe-header">
<div class="row items-center no-wrap">
<q-icon :name="template?.icon || 'account_tree'" size="24px" color="indigo-6" class="q-mr-sm" />
<q-icon :name="template?.icon || 'account_tree'" size="24px" color="primary" class="q-mr-sm" />
<div class="col">
<div class="text-subtitle1 text-weight-bold">
{{ mode === 'new' ? 'Nouveau template de flow' : (template?.template_name || templateName || '') }}
@ -31,11 +31,11 @@
<div class="text-caption text-grey-7">
<span v-if="templateName">{{ templateName }}</span>
<span v-if="template?.version" class="q-ml-sm">v{{ template.version }}</span>
<q-badge v-if="template?.is_system" color="amber-2" text-color="amber-9" label="système"
<q-badge v-if="template?.is_system" color="amber-1" text-color="amber-9" label="système"
class="q-ml-sm" />
<q-badge v-if="template && !template.is_active" color="grey-3" text-color="grey-7"
label="inactif" class="q-ml-sm" />
<q-badge v-if="dirty" color="orange-2" text-color="orange-9"
<q-badge v-if="dirty" color="amber-1" text-color="amber-9"
label="non sauvegardé" class="q-ml-sm" />
</div>
</div>
@ -46,7 +46,7 @@
<q-separator />
<q-card-section v-if="loading" class="flex flex-center q-pa-xl">
<q-spinner size="40px" color="indigo-6" />
<q-spinner size="40px" color="primary" />
</q-card-section>
<q-card-section v-else-if="error" class="text-negative q-pa-md">
@ -116,7 +116,7 @@
<div class="text-grey-7 q-mb-xs">{{ ex.desc }}</div>
<div class="row items-start no-wrap">
<div class="condition-code col">{{ ex.code }}</div>
<q-btn flat dense size="xs" icon="north_east" color="indigo-6"
<q-btn flat dense size="xs" icon="north_east" color="primary"
class="q-ml-xs" @click="useExample(ex.code)">
<q-tooltip>Utiliser cet exemple</q-tooltip>
</q-btn>
@ -163,7 +163,7 @@
<div class="row items-center q-mb-sm">
<div class="text-caption text-weight-bold text-grey-7">Étapes du flow</div>
<q-space />
<q-badge v-if="stepCount" color="indigo-1" text-color="indigo-8" :label="`${stepCount} étape${stepCount > 1 ? 's' : ''}`" />
<q-badge v-if="stepCount" color="green-1" text-color="green-8" :label="`${stepCount} étape${stepCount > 1 ? 's' : ''}`" />
</div>
<FlowEditor :model-value="template.flow_definition"
:kind-catalog="PROJECT_KINDS"
@ -178,14 +178,14 @@
<!-- Footer actions -->
<q-card-actions class="fe-footer">
<q-btn v-if="mode === 'edit' && !template?.is_system"
flat color="red-7" icon="delete" label="Supprimer" no-caps
flat color="negative" icon="delete" label="Supprimer" no-caps
@click="onDelete" />
<q-btn v-if="mode === 'edit'"
flat color="indigo-6" icon="content_copy" label="Dupliquer" no-caps
flat color="primary" icon="content_copy" label="Dupliquer" no-caps
@click="onDuplicate" />
<q-space />
<q-btn flat color="grey-7" label="Fermer" no-caps @click="onClose" />
<q-btn unelevated color="indigo-6" icon="save"
<q-btn unelevated color="primary" icon="save"
:label="mode === 'new' ? 'Créer' : 'Enregistrer'" no-caps
:loading="saving" :disable="!canSave"
@click="onSave" />

View File

@ -40,7 +40,7 @@ const props = defineProps({
templateName: { type: String, default: null },
label: { type: String, default: 'Flows' },
icon: { type: String, default: 'account_tree' },
color: { type: String, default: 'indigo-6' },
color: { type: String, default: 'primary' },
tooltip: { type: String, default: '' },
flat: { type: Boolean, default: false },
dense: { type: Boolean, default: true },

View File

@ -28,9 +28,9 @@
:options="CATEGORY_OPTIONS" label="Catégorie" style="min-width:160px" />
<q-select v-model="appliesToFilter" dense outlined emit-value map-options clearable
:options="APPLIES_TO_OPTIONS" label="Applique à" style="min-width:200px" />
<q-toggle v-model="showInactive" label="Inclure inactifs" color="indigo-6" dense />
<q-toggle v-model="showInactive" label="Inclure inactifs" color="primary" dense />
<q-space />
<q-btn unelevated color="indigo-6" icon="add" label="Nouveau" no-caps dense
<q-btn unelevated color="primary" icon="add" label="Nouveau" no-caps dense
@click="onCreate" />
<q-btn flat dense icon="refresh" @click="reload" :loading="loading">
<q-tooltip>Rafraîchir</q-tooltip>
@ -39,12 +39,12 @@
<!-- Loading -->
<div v-if="loading && !rows.length" class="flex flex-center q-pa-xl">
<q-spinner size="32px" color="indigo-6" />
<q-spinner size="32px" color="primary" />
</div>
<!-- Error -->
<q-banner v-else-if="error" class="bg-red-1 text-red-9 q-mb-md">
<template #avatar><q-icon name="error" color="red-7" /></template>
<q-banner v-else-if="error" class="bg-red-1 text-negative q-mb-md">
<template #avatar><q-icon name="error" color="negative" /></template>
{{ error }}
</q-banner>
@ -52,7 +52,7 @@
<div v-else-if="!filtered.length" class="text-center text-grey-6 q-py-lg">
<q-icon name="account_tree" size="32px" color="grey-4" />
<div class="text-caption q-mt-sm">Aucun template trouvé</div>
<q-btn flat dense color="indigo-6" label="Créer le premier template" no-caps
<q-btn flat dense color="primary" label="Créer le premier template" no-caps
class="q-mt-sm" @click="onCreate" />
</div>
@ -74,12 +74,12 @@
<tbody>
<tr v-for="r in filtered" :key="r.name" class="ft-row"
@click="onEdit(r)">
<td><q-icon :name="r.icon || 'account_tree'" :color="r.is_system ? 'amber-8' : 'indigo-6'" /></td>
<td><q-icon :name="r.icon || 'account_tree'" :color="r.is_system ? 'amber-8' : 'primary'" /></td>
<td>
<div class="text-weight-medium">{{ r.template_name }}</div>
<div class="text-caption text-grey-6">
{{ r.name }}
<q-badge v-if="r.is_system" color="amber-2" text-color="amber-9" label="système" class="q-ml-xs" />
<q-badge v-if="r.is_system" color="amber-1" text-color="amber-9" label="système" class="q-ml-xs" />
<span v-if="r.version" class="q-ml-xs">v{{ r.version }}</span>
</div>
</td>
@ -92,11 +92,11 @@
@update:model-value="v => onToggleActive(r, v)" />
</td>
<td class="text-center" @click.stop>
<q-btn flat round dense size="sm" icon="content_copy" color="indigo-6"
<q-btn flat round dense size="sm" icon="content_copy" color="primary"
@click="onDuplicate(r)">
<q-tooltip>Dupliquer</q-tooltip>
</q-btn>
<q-btn v-if="!r.is_system" flat round dense size="sm" icon="delete" color="red-5"
<q-btn v-if="!r.is_system" flat round dense size="sm" icon="delete" color="negative"
@click="onDelete(r)">
<q-tooltip>Supprimer</q-tooltip>
</q-btn>

View File

@ -103,7 +103,7 @@
<q-card-actions class="step-editor-ftr">
<q-btn flat color="grey-7" label="Annuler" @click="close" no-caps />
<q-space />
<q-btn unelevated color="indigo-6" label="Enregistrer" @click="save" no-caps
<q-btn unelevated color="primary" label="Enregistrer" @click="save" no-caps
icon="check" />
</q-card-actions>
</q-card>

View File

@ -0,0 +1,105 @@
<template>
<!-- Graphe de DÉPENDANCES (Vue Flow) lecture OU constructeur (editable) : ajouter des boîtes, les relier (depends_on),
cliquer pour lier une ACTION (kind), supprimer, glisser pour ranger. Mappe le Flow Template (steps). -->
<div class="tdg">
<div class="tdg-bar">
<div class="tdg-legend">
<span class="tdg-lg"><span class="tdg-dot" style="background:#ea580c"></span>🚚 Déplacement (terrain)</span>
<span class="tdg-lg"><span class="tdg-dot" style="background:#2563eb"></span>🏢 À distance (bureau)</span>
<span class="tdg-lg"><span class="tdg-arrow"></span> dépend de</span>
</div>
<q-space />
<q-btn v-if="editable" dense unelevated no-caps color="primary" icon="add" label="Ajouter une étape" size="sm" @click="emit('add')" />
</div>
<div class="tdg-canvas">
<VueFlow :nodes="nodes" :edges="edges" :min-zoom="0.3" :max-zoom="2" fit-view-on-init :nodes-draggable="true"
:nodes-connectable="editable" @connect="onConnect" @node-click="onNodeClick" @node-drag-stop="onDragStop">
<Background pattern-color="#d6deea" :gap="18" />
<Controls />
<template #node-task="{ id, data }">
<Handle type="target" :position="Position.Left" />
<div class="tdg-node" :class="{ dispatch: data.dispatch, editable }">
<button v-if="editable" class="tdg-del" title="Supprimer" @click.stop="emit('delete', id)"></button>
<div class="tdg-node-top">
<span class="tdg-ic">{{ data.dispatch ? '🚚' : '🏢' }}</span>
<span class="tdg-st" :style="{ background: data.statusColor }">{{ data.status }}</span>
</div>
<div class="tdg-node-title">{{ data.subject }}</div>
<div class="tdg-node-meta">{{ data.assignee || '—' }}<span v-if="data.when"> · {{ data.when }}</span></div>
</div>
<Handle type="source" :position="Position.Right" />
</template>
</VueFlow>
</div>
<div v-if="editable" class="tdg-hint">Tirez d'un point à l'autre pour relier · clic = éditer l'action · = supprimer · glissez pour ranger</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { VueFlow, Handle, Position } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
// tasks: [{ id, subject, dependsOn:[ids], requiresDispatch, assignee, status, when, pos? }]
const props = defineProps({ tasks: { type: Array, default: () => [] }, editable: { type: Boolean, default: false } })
const emit = defineEmits(['add', 'connect', 'edit', 'delete', 'move'])
const STATUS_COLOR = { Open: '#64748b', Working: '#2563eb', 'Pending Review': '#d97706', Completed: '#16a34a', Cancelled: '#dc2626', Overdue: '#dc2626' }
const nodes = ref([])
const edges = ref([])
function rebuild () {
const tasks = props.tasks || []
const byId = {}; for (const t of tasks) byId[t.id] = t
const memo = {}
const depth = (id, seen = new Set()) => {
if (memo[id] != null) return memo[id]
if (seen.has(id)) return 0
seen.add(id)
const deps = (byId[id]?.dependsOn || []).filter(d => byId[d])
const d = deps.length ? 1 + Math.max(...deps.map(x => depth(x, seen))) : 0
memo[id] = d; return d
}
const perDepth = {}; const n = []
for (const t of tasks) {
const d = depth(t.id); perDepth[d] = perDepth[d] || 0
const pos = (t.pos && typeof t.pos.x === 'number') ? { x: t.pos.x, y: t.pos.y } : { x: d * 280, y: perDepth[d] * 130 }
n.push({ id: String(t.id), type: 'task', position: pos, data: { subject: t.subject, dispatch: !!t.requiresDispatch, assignee: t.assignee, status: t.status || 'Open', statusColor: STATUS_COLOR[t.status] || '#64748b', when: t.when } })
perDepth[d]++
}
const e = []
for (const t of tasks) for (const dep of (t.dependsOn || [])) { if (byId[dep]) e.push({ id: `e-${dep}-${t.id}`, source: String(dep), target: String(t.id), animated: !!t.requiresDispatch, style: { stroke: t.requiresDispatch ? '#ea580c' : '#94a3b8', strokeWidth: 2 } }) }
nodes.value = n; edges.value = e
}
watch(() => props.tasks, rebuild, { immediate: true, deep: true })
function onNodeClick (e) { if (props.editable && e && e.node) emit('edit', e.node.id) }
function onConnect (params) { if (params && params.source && params.target) emit('connect', { source: params.source, target: params.target }) }
function onDragStop (e) { const node = (e && e.node) || (e && e.nodes && e.nodes[0]); if (node) emit('move', { id: node.id, position: { x: node.position.x, y: node.position.y } }) }
</script>
<style scoped>
.tdg { display: flex; flex-direction: column; gap: 8px; }
.tdg-bar { display: flex; align-items: center; }
.tdg-legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: 12px; color: #475569; padding: 0 4px; }
.tdg-lg { display: inline-flex; align-items: center; gap: 5px; }
.tdg-dot { width: 11px; height: 11px; border-radius: 3px; display: inline-block; }
.tdg-arrow { color: #94a3b8; font-weight: 700; }
.tdg-canvas { height: 560px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fafbfc; overflow: hidden; }
.tdg-node { position: relative; background: #fff; border: 2px solid #2563eb; border-radius: 10px; padding: 8px 11px; min-width: 180px; max-width: 240px; box-shadow: 0 1px 5px rgba(15,23,42,.08); }
.tdg-node.dispatch { border-color: #ea580c; background: #fff7ed; }
.tdg-node.editable { cursor: pointer; }
.tdg-node-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 3px; }
.tdg-ic { font-size: 15px; }
.tdg-st { font-size: 9px; font-weight: 700; color: #fff; padding: 1px 6px; border-radius: 8px; text-transform: uppercase; }
.tdg-node-title { font-size: 13px; font-weight: 600; color: #1e293b; line-height: 1.25; }
.tdg-node-meta { font-size: 11px; color: #64748b; margin-top: 3px; }
.tdg-del { position: absolute; top: -8px; right: -8px; width: 18px; height: 18px; border-radius: 50%; border: none; background: #ef4444; color: #fff; font-size: 11px; line-height: 1; cursor: pointer; display: none; }
.tdg-node:hover .tdg-del { display: block; }
.tdg-hint { font-size: 11px; color: #94a3b8; padding: 0 4px; }
</style>

View File

@ -0,0 +1,62 @@
<template>
<!-- Vue GANTT (HyVueGantt) : planning temporel + flèches de DÉPENDANCE + couleur Terrain(🚚)/Bureau(🏢). Mêmes tâches que le DAG. -->
<div class="tg-wrap">
<div class="tg-legend">
<span class="tg-lg"><span class="tg-dot" style="background:#ea580c"></span>🚚 Terrain (déplacement)</span>
<span class="tg-lg"><span class="tg-dot" style="background:#2563eb"></span>🏢 Bureau (à distance)</span>
<span class="tg-lg"><span class="tg-arrow"></span> dépendance (Fin Début)</span>
</div>
<div class="tg-chart">
<g-gantt-chart :chart-start="chartStart" :chart-end="chartEnd" precision="day" bar-start="start" bar-end="end"
:row-height="38" color-scheme="vue" grid :width="'100%'">
<g-gantt-row v-for="r in rows" :key="r.id" :label="r.label" :bars="r.bars" />
</g-gantt-chart>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { GGanttChart, GGanttRow, extendDayjs } from 'hy-vue-gantt'
extendDayjs() // HyVueGantt a besoin des plugins dayjs (axe de temps) sinon GGanttTimeaxis/Grid plantent au rendu sans app.use(hyvuegantt).
// tasks: [{ id, subject, dependsOn:[ids], requiresDispatch, assignee, status, start:'YYYY-MM-DD', end:'YYYY-MM-DD' }]
const props = defineProps({ tasks: { type: Array, default: () => [] } })
const addDay = (iso, n) => { const d = new Date(iso + 'T00:00:00'); d.setDate(d.getDate() + n); return d.toISOString().slice(0, 10) }
const chartStart = computed(() => { const ds = props.tasks.map(t => t.start).filter(Boolean).sort(); return ds.length ? addDay(ds[0], -1) : '2026-06-21' })
const chartEnd = computed(() => { const de = props.tasks.map(t => t.end).filter(Boolean).sort(); return de.length ? addDay(de[de.length - 1], 1) : '2026-06-30' })
const rows = computed(() => {
// Connexions définies sur la barre SOURCE (la dépendance) cible (la tâche qui en dépend).
const conn = {}
for (const t of props.tasks) for (const dep of (t.dependsOn || [])) {
(conn[dep] = conn[dep] || []).push({ targetId: String(t.id), type: 'bezier', relation: 'FS', animated: !!t.requiresDispatch, color: t.requiresDispatch ? '#ea580c' : '#94a3b8' })
}
return props.tasks.map(t => ({
id: t.id,
label: (t.requiresDispatch ? '🚚 ' : '🏢 ') + t.subject,
bars: [{
start: t.start, end: t.end,
ganttBarConfig: {
id: String(t.id),
label: t.assignee || '',
hasHandles: true,
style: { background: t.requiresDispatch ? '#ea580c' : '#2563eb', color: '#fff', borderRadius: '5px', fontSize: '11px' },
connections: conn[t.id] || [],
},
}],
}))
})
</script>
<style scoped>
.tg-wrap { display: flex; flex-direction: column; gap: 8px; }
.tg-legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: 12px; color: #475569; padding: 0 4px; }
.tg-lg { display: inline-flex; align-items: center; gap: 5px; }
.tg-dot { width: 11px; height: 11px; border-radius: 3px; display: inline-block; }
.tg-arrow { color: #94a3b8; font-weight: 700; }
.tg-chart { border: 1px solid #e2e8f0; border-radius: 10px; overflow: auto; background: #fff; }
</style>

View File

@ -26,7 +26,7 @@
-->
<template>
<q-btn-dropdown flat dense no-caps size="sm" :icon="icon" :label="label"
class="variable-picker" color="indigo-6" content-class="vp-menu">
class="variable-picker" color="primary" content-class="vp-menu">
<q-list dense class="vp-list">
<q-item-label header class="vp-header">
Variables disponibles
@ -55,7 +55,7 @@
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon :name="mode === 'copy' ? 'content_copy' : 'add_circle_outline'" size="16px" color="indigo-5" />
<q-icon :name="mode === 'copy' ? 'content_copy' : 'add_circle_outline'" size="16px" color="green-5" />
</q-item-section>
</q-item>
</q-list>

View File

@ -35,12 +35,12 @@
<q-input class="col-12 col-sm-6" dense outlined v-model="local.baseUrl" label="URL base (OpenAI-compat)" placeholder="http://gpu-host:8000/v1/" hint="Slash final requis" />
<q-input class="col-12 col-sm-6" dense outlined v-model="local.model" label="Modèle" placeholder="Qwen/Qwen2.5-14B-Instruct" />
</div>
<div v-if="!localKeySet" class="text-caption text-orange-8 q-mt-xs">
<div v-if="!localKeySet" class="text-caption text-warning q-mt-xs">
<q-icon name="warning" size="14px" /> Clé absente : définis <code>AI_LOCAL_API_KEY</code> dans <code>/opt/targo-hub/.env</code> (les secrets restent côté serveur, jamais dans l'UI), puis recrée le conteneur.
</div>
<div class="row items-center q-gutter-sm q-mt-sm">
<q-btn dense outline color="deep-purple-6" icon="network_check" label="Tester la connexion" no-caps :loading="testing" :disable="!localAvailable" @click="testLocal" />
<span v-if="testResult" :class="testResult.ok ? 'text-green-8' : 'text-red-7'" class="text-caption">
<span v-if="testResult" :class="testResult.ok ? 'text-green-8' : 'text-negative'" class="text-caption">
<q-icon :name="testResult.ok ? 'check_circle' : 'error'" size="14px" />
{{ testResult.ok ? `OK · ${testResult.model} · ${testResult.latency_ms} ms` : ('Échec : ' + testResult.error) }}
</span>
@ -54,7 +54,7 @@
<q-icon :name="taskIcon(t)" size="18px" color="grey-7" class="q-mr-sm" />
<div class="col">
<div class="text-body2">{{ taskLabel(t) }}</div>
<div v-if="routing[t] && routing[t].wanted === 'local' && routing[t].effective !== 'local'" class="text-caption text-orange-8"> replié sur Gemini (local indisponible)</div>
<div v-if="routing[t] && routing[t].wanted === 'local' && routing[t].effective !== 'local'" class="text-caption text-warning"> replié sur Gemini (local indisponible)</div>
</div>
<q-btn-toggle v-model="wanted[t]" :options="toggleOptions" dense unelevated no-caps toggle-color="deep-purple-6" color="grey-3" text-color="grey-8" size="sm" />
</div>

View File

@ -1,12 +1,12 @@
<template>
<div class="ops-card q-mb-md">
<div class="row items-center q-mb-xs cursor-pointer" @click="expanded = !expanded">
<q-icon name="quickreply" size="20px" color="indigo-6" class="q-mr-sm" />
<q-icon name="quickreply" size="20px" color="primary" 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-chip v-if="list.length" dense square color="green-1" text-color="green-8" class="q-ml-sm">{{ list.length }}</q-chip>
<q-space />
<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-spinner v-if="loading" size="16px" color="primary" class="q-mr-sm" />
<q-btn v-if="expanded" dense unelevated color="green-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">
@ -18,13 +18,13 @@
<div class="row items-center q-gutter-sm q-mb-xs">
<q-input dense outlined v-model="c.title" label="Titre (ex. Signature Support)" class="col" />
<q-select dense outlined v-model="c.mailbox" :options="mailboxes" emit-value map-options label="Mailbox / file" style="min-width:185px" />
<q-btn flat round dense icon="delete" color="red-4" @click="remove(i)"><q-tooltip>Supprimer</q-tooltip></q-btn>
<q-btn flat round dense icon="delete" color="negative" @click="remove(i)"><q-tooltip>Supprimer</q-tooltip></q-btn>
</div>
<q-editor v-model="c.html" min-height="7rem" class="canned-editor"
:toolbar="[['bold','italic','underline'],['unordered','ordered'],['link','removeFormat'],['undo','redo']]"
placeholder="Texte / signature… (image via le bouton ci-dessous, ou collez depuis Gmail)" />
<div class="row items-center q-mt-xs">
<q-btn flat dense size="sm" icon="image" color="indigo-6" no-caps label="Image / logo" :loading="upBusy === c.id" @click="pickImage(c)" />
<q-btn flat dense size="sm" icon="image" color="primary" no-caps label="Image / logo" :loading="upBusy === c.id" @click="pickImage(c)" />
<span class="text-caption text-grey-5 q-ml-sm">PNG/JPG/SVG · hébergée sur nos serveurs (visible chez le destinataire)</span>
</div>
</div>
@ -32,7 +32,7 @@
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" style="display:none" @change="onFile" />
<div class="row justify-end q-mt-sm">
<q-btn unelevated color="indigo-6" icon="save" label="Enregistrer" no-caps :loading="saving" @click="persist" />
<q-btn unelevated color="primary" icon="save" label="Enregistrer" no-caps :loading="saving" @click="persist" />
</div>
</div>
</div>

View File

@ -0,0 +1,163 @@
<template>
<div class="ops-card q-mb-md">
<q-card-section>
<div class="row items-center q-gutter-xs">
<q-icon name="hub" color="primary" size="20px" />
<div class="text-subtitle2">Départements (omnichannel)</div>
<q-space />
<q-btn v-if="dirty" unelevated no-caps dense color="primary" icon="save"
label="Enregistrer" :loading="saving" @click="save" />
</div>
<div class="text-caption text-grey-7 q-mt-xs">
Un membre d'un département reçoit ses <b>courriels</b> ET ses <b>tickets</b> (et les canaux à venir) une seule inscription, tous les canaux.
Cochez les départements de chaque personne.
</div>
<div class="row q-gutter-xs q-mt-sm">
<q-chip v-for="q in queues" :key="q" dense square color="grey-2" text-color="grey-8">
{{ queueLabel(q) }} · <b class="q-ml-xs">{{ queueCount(q) }}</b>
</q-chip>
</div>
<q-input dense outlined v-model="search" placeholder="Chercher une personne…" clearable class="q-mt-sm">
<template #prepend><q-icon name="search" /></template>
</q-input>
<div v-if="loading" class="text-grey-6 q-pa-md text-center"><q-spinner size="20px" /> Chargement</div>
<div v-else class="dept-list q-mt-sm">
<div v-for="a in filtered" :key="a.email" class="dept-row">
<div class="dept-who">
<q-avatar size="26px" color="green-1" text-color="green-8" style="font-size:11px;font-weight:600">{{ ini(a.name) }}</q-avatar>
<div class="dept-who-txt">
<b class="ellipsis">{{ a.name }}</b>
<div class="text-caption text-grey-6 ellipsis">{{ a.email }}</div>
</div>
</div>
<div class="dept-ctrl">
<div class="dept-chips">
<q-chip v-for="q in queues" :key="q" clickable dense
:color="isMember(a.email, q) ? 'primary' : 'grey-3'"
:text-color="isMember(a.email, q) ? 'white' : 'grey-7'"
@click="toggle(a.email, q)">{{ queueLabel(q) }}</q-chip>
</div>
<div class="dept-mode">
<q-btn-toggle :model-value="modeOf(a.email)" @update:model-value="m => setMode(a.email, m)"
dense unelevated no-caps size="sm" toggle-color="primary" color="grey-2" text-color="grey-8"
:options="MODES.map(x => ({ label: x.label, value: x.value, icon: x.icon }))" />
<q-select v-if="modeOf(a.email) === 'tags'" :model-value="tagsOf(a.email)" @update:model-value="t => setTags(a.email, t)"
dense outlined multiple use-chips use-input new-value-mode="add-unique"
:options="skillOptions" placeholder="compétences…" class="dept-tagsel" />
</div>
</div>
</div>
<div v-if="!filtered.length" class="text-grey-6 q-pa-md text-center">Aucune personne.</div>
</div>
</q-card-section>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Notify } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { staffInitials } from 'src/composables/useFormatters'
import { fetchTags } from 'src/api/dispatch' // compétences PARTAGÉES (Dispatch Tag, catégorie « Compétence ») taxonomie unique réutilisée du dispatch
const agents = ref([]) // [{ email, name }]
const queues = ref([]) // les 4 files = départements omnichannel (categories.js QUEUES)
const labels = ref({})
const membersMap = reactive({}) // { queue: [emails lowercase] } édité localement, sauvé en bloc
const visibility = reactive({}) // { email: { mode:'personnel'|'department'|'tags', tags:[...] } } réduction du bruit par personne
const MODES = [
{ value: 'personnel', label: 'Perso', icon: 'person', hint: 'Seulement assignés / assistant / suivis — aucun bruit de département' },
{ value: 'department', label: 'Département', icon: 'groups', hint: 'Tous les tickets de ses files' },
{ value: 'tags', label: 'Dépt + compétences', icon: 'sell', hint: 'Ses files, filtrées à ses COMPÉTENCES (taxonomie partagée avec le dispatch)' },
]
const TAG_SUGGESTIONS = ['téléphonie', 'installation', 'fibre', 'television', 'internet', 'dépannage', 'reseau'] // repli si aucune compétence définie côté dispatch
const skillOptions = ref([]) // compétences PARTAGÉES (Dispatch Tag catégorie « Compétence ») alimente le sélecteur ; saisie libre permise en plus
const search = ref('')
const loading = ref(false)
const saving = ref(false)
const dirty = ref(false)
const ini = (n) => staffInitials(n || '?')
const queueLabel = (q) => labels.value[q] || q
const queueCount = (q) => (membersMap[q] || []).length
const isMember = (email, q) => (membersMap[q] || []).includes(String(email).toLowerCase())
const modeOf = (email) => (visibility[String(email).toLowerCase()] || {}).mode || 'department'
function setMode (email, m) { const e = String(email).toLowerCase(); if (!visibility[e]) visibility[e] = { mode: 'department', tags: [] }; visibility[e].mode = m; dirty.value = true }
const tagsOf = (email) => (visibility[String(email).toLowerCase()] || {}).tags || []
function setTags (email, tags) { const e = String(email).toLowerCase(); if (!visibility[e]) visibility[e] = { mode: 'tags', tags: [] }; visibility[e].tags = (tags || []).map(t => String(t).trim().toLowerCase()).filter(Boolean); dirty.value = true }
const filtered = computed(() => {
const s = search.value.trim().toLowerCase()
if (!s) return agents.value
return agents.value.filter(a => a.email.includes(s) || (a.name || '').toLowerCase().includes(s))
})
function toggle (email, q) {
const e = String(email).toLowerCase()
if (!membersMap[q]) membersMap[q] = []
const i = membersMap[q].indexOf(e)
if (i >= 0) membersMap[q].splice(i, 1)
else membersMap[q].push(e)
dirty.value = true
}
async function load () {
loading.value = true
try {
const [aR, mR] = await Promise.all([
fetch(`${HUB_URL}/conversations/agents`).then(r => r.json()),
fetch(`${HUB_URL}/conversations/queue-members`).then(r => r.json()),
])
const names = aR.names || {}
agents.value = (aR.agents || []).map(e => ({ email: e, name: names[e] || e }))
queues.value = mR.queues || []
labels.value = mR.labels || {}
const m = mR.members || {}
for (const q of queues.value) membersMap[q] = (m[q] || []).map(e => String(e).toLowerCase())
const vis = mR.visibility || {}
for (const k of Object.keys(vis)) visibility[String(k).toLowerCase()] = { mode: vis[k].mode || 'department', tags: Array.isArray(vis[k].tags) ? vis[k].tags : [] }
dirty.value = false
} catch (e) { Notify.create({ type: 'negative', message: 'Chargement des départements échoué' }) }
loading.value = false
}
async function save () {
saving.value = true
try {
const members = {}
for (const q of queues.value) members[q] = membersMap[q] || []
const r = await fetch(`${HUB_URL}/conversations/queue-members`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ members, visibility: { ...visibility } }),
})
if (r.ok) { dirty.value = false; Notify.create({ type: 'positive', message: 'Abonnements enregistrés' }) }
else Notify.create({ type: 'negative', message: 'Échec de l\'enregistrement' })
} catch (e) { Notify.create({ type: 'negative', message: e.message }) }
saving.value = false
}
async function loadSkills () {
try {
const tags = await fetchTags()
const skills = (tags || []).filter(t => /comp[ée]tence|skill/i.test(t.category || '')).map(t => t.label || t.name).filter(Boolean)
skillOptions.value = skills.length ? [...new Set(skills)].sort() : TAG_SUGGESTIONS
} catch { skillOptions.value = TAG_SUGGESTIONS }
}
onMounted(() => { load(); loadSkills() })
</script>
<style scoped>
.dept-list { max-height: 52vh; overflow-y: auto; border: 1px solid #eef2f7; border-radius: 8px; }
.dept-row { display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px; border-bottom: 1px solid #f1f5f9; }
.dept-row:last-child { border-bottom: 0; }
.dept-who { display: flex; align-items: center; gap: 8px; min-width: 180px; max-width: 230px; flex: 0 0 auto; padding-top: 3px; }
.dept-who-txt { min-width: 0; }
.dept-ctrl { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; }
.dept-chips { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
.dept-mode { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; justify-content: flex-end; }
.dept-tagsel { min-width: 200px; max-width: 320px; }
@media (max-width: 700px) { .dept-row { flex-direction: column; align-items: stretch; } .dept-who { max-width: none; } .dept-ctrl { align-items: stretch; } .dept-chips, .dept-mode { justify-content: flex-start; } }
</style>

View File

@ -1,10 +1,10 @@
<template>
<div class="ops-card q-mb-md">
<div class="row items-center q-mb-xs">
<q-icon name="inbox" size="20px" color="indigo-6" class="q-mr-sm" />
<q-icon name="inbox" size="20px" color="primary" class="q-mr-sm" />
<span class="text-subtitle1 text-weight-bold">Files (Inbox) & équipes</span>
<q-space />
<q-spinner v-if="loading" size="16px" color="indigo-6" />
<q-spinner v-if="loading" size="16px" color="primary" />
</div>
<div class="text-caption text-grey-6 q-mb-md">
Qui reçoit et traite les demandes de chaque file. À l'arrivée d'une demande, ses membres sont notifiés (courriel + Inbox). Même logique que les groupes de droits on cherche des utilisateurs et on les ajoute.
@ -15,10 +15,10 @@
<div class="col-12 col-sm-6 col-md-3" v-for="q in queues" :key="q">
<div class="queue-card" :class="{ sel: selected === q }" @click="select(q)">
<div class="row items-center no-wrap">
<q-icon name="groups" size="18px" :color="selected === q ? 'indigo-6' : 'grey-6'" class="q-mr-sm" />
<q-icon name="groups" size="18px" :color="selected === q ? 'primary' : 'grey-6'" class="q-mr-sm" />
<span class="text-weight-medium">{{ label(q) }}</span>
<q-space />
<q-badge :color="(members[q] || []).length ? 'indigo-1' : 'grey-3'" :text-color="(members[q] || []).length ? 'indigo-8' : 'grey-6'">
<q-badge :color="(members[q] || []).length ? 'green-1' : 'grey-3'" :text-color="(members[q] || []).length ? 'green-8' : 'grey-6'">
{{ (members[q] || []).length }}
</q-badge>
</div>
@ -30,7 +30,7 @@
<q-slide-transition>
<div v-if="selected" class="queue-detail q-mt-md">
<div class="row items-center q-mb-sm">
<q-icon name="groups" size="22px" color="indigo-6" class="q-mr-sm" />
<q-icon name="groups" size="22px" color="primary" class="q-mr-sm" />
<span class="text-h6">{{ label(selected) }}</span>
<q-space />
<q-btn flat round dense icon="close" @click="selected = null" />
@ -39,17 +39,17 @@
<!-- Nom d'affichage (ex. singulier) le nom réel (clé, = F, souvent au pluriel) reste inchangé -->
<div class="row items-center q-gutter-sm q-mb-md">
<q-input dense outlined v-model="labelDraft" label="Nom affiché" style="min-width:220px" @keyup.enter="saveLabel" @blur="saveLabel">
<template #append><q-icon v-if="(labels[selected] || selected) !== labelDraft" name="edit" size="16px" color="indigo-5" /></template>
<template #append><q-icon v-if="(labels[selected] || selected) !== labelDraft" name="edit" size="16px" color="green-5" /></template>
</q-input>
<span class="text-caption text-grey-6">Nom réel (F) : <b>{{ selected }}</b> inchangé</span>
</div>
<div class="text-subtitle2 q-mb-xs">Membres ({{ (members[selected] || []).length }})</div>
<div v-for="email in (members[selected] || [])" :key="email" class="row items-center q-py-xs q-px-sm qm-member-row">
<q-avatar size="26px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:.7rem">{{ initial(email) }}</q-avatar>
<q-avatar size="26px" color="green-1" text-color="green-8" class="q-mr-sm" style="font-size:.7rem">{{ initial(email) }}</q-avatar>
<span class="text-body2">{{ email }}</span>
<q-space />
<q-btn flat round dense icon="person_remove" size="sm" color="red-5" class="qm-del" @click="removeMember(email)"><q-tooltip>Retirer de la file</q-tooltip></q-btn>
<q-btn flat round dense icon="person_remove" size="sm" color="negative" class="qm-del" @click="removeMember(email)"><q-tooltip>Retirer de la file</q-tooltip></q-btn>
</div>
<div v-if="!(members[selected] || []).length" class="text-caption text-grey-5 q-py-sm">Aucun membre personne n'est notifié pour cette file.</div>
@ -57,13 +57,13 @@
<div class="q-mt-sm" style="position:relative">
<q-input v-model="search" label="Ajouter un membre…" outlined dense autocomplete="off" name="qm-add-nope" @update:model-value="onSearch">
<template #append>
<q-spinner v-if="searching" size="16px" color="indigo-6" />
<q-spinner v-if="searching" size="16px" color="primary" />
<q-icon v-else-if="search" name="close" class="cursor-pointer" @click="search = ''; results = []" />
</template>
</q-input>
<div v-if="results.length" class="qm-dropdown">
<div v-for="u in results" :key="u.email || u.pk" class="qm-item" @click="addMember(u)">
<q-avatar size="24px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:.65rem">{{ initial(u.email || u.name) }}</q-avatar>
<q-avatar size="24px" color="green-1" text-color="green-8" class="q-mr-sm" style="font-size:.65rem">{{ initial(u.email || u.name) }}</q-avatar>
<span class="text-body2">{{ u.name || u.username || u.email }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ u.email }}</span>
<q-badge v-if="(members[selected] || []).includes((u.email || '').toLowerCase())" label="déjà membre" color="grey-3" text-color="grey-6" class="q-ml-auto" />
@ -74,7 +74,7 @@
<!-- Importer tout un groupe Authentik (la table explicite reste la source) -->
<div class="row items-center q-gutter-sm q-mt-sm">
<q-select v-model="importGroupName" :options="groupsList" dense outlined options-dense style="min-width:200px" label="Importer un groupe Authentik" clearable emit-value map-options />
<q-btn dense unelevated color="indigo-5" icon="group_add" label="Importer les membres" :disable="!importGroupName" :loading="importing" no-caps @click="importGroup" />
<q-btn dense unelevated color="green-5" icon="group_add" label="Importer les membres" :disable="!importGroupName" :loading="importing" no-caps @click="importGroup" />
</div>
</div>
</q-slide-transition>

View File

@ -0,0 +1,76 @@
<template>
<!-- Éditeur des délais SLA (réponse / résolution) PAR FILE (type de ticket) + PRIORITÉ. Stocké dans le hub, appliqué aux pastilles SLA. -->
<div class="ops-card q-mb-md">
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
<template #header>
<q-item-section avatar><q-icon name="timer" color="primary" size="24px" /></q-item-section>
<q-item-section>
<div class="text-subtitle1 text-weight-bold">Délais SLA réponse / résolution</div>
<div class="text-caption text-grey-6">Cibles par file (type de ticket) + priorité. « Défaut » s'applique aux types sans politique propre.</div>
</q-item-section>
</template>
<q-card><q-card-section>
<div class="row items-center q-gutter-sm q-mb-md">
<q-select dense outlined v-model="file" :options="fileOptions" label="File (type de ticket)" emit-value map-options style="min-width:260px" @update:model-value="loadFile" />
<q-space />
<q-btn unelevated no-caps color="primary" icon="save" label="Enregistrer" :loading="saving" @click="save" />
</div>
<q-markup-table flat bordered dense>
<thead><tr><th class="text-left">Priorité</th><th>Réponse (h)</th><th>Résolution (h)</th></tr></thead>
<tbody>
<tr v-for="pr in PRIORITIES" :key="pr">
<td class="text-left"><span class="ops-badge" :class="priorityClass(pr)">{{ pr }}</span></td>
<td class="text-center"><q-input dense outlined type="number" min="0" step="0.25" v-model.number="edit[pr].response" input-class="text-center" style="width:96px;margin:0 auto" /></td>
<td class="text-center"><q-input dense outlined type="number" min="0" step="0.25" v-model.number="edit[pr].resolution" input-class="text-center" style="width:96px;margin:0 auto" /></td>
</tr>
</tbody>
</q-markup-table>
<div class="text-caption text-grey-6 q-mt-sm">Valeurs en heures (0.25 = 15 min). Appliquées immédiatement aux pastilles SLA des tickets.</div>
</q-card-section></q-card>
</q-expansion-item>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { listDocs } from 'src/api/erp'
import { useSla } from 'src/composables/useSla'
import { priorityClass } from 'src/composables/useStatusClasses'
const $q = useQuasar()
const { policies, defaults, loadPolicies, savePolicies } = useSla()
const PRIORITIES = ['Urgent', 'High', 'Medium', 'Low']
const file = ref('_default')
const fileOptions = ref([{ label: 'Défaut (toutes files)', value: '_default' }])
const edit = reactive({ Urgent: { response: 0, resolution: 0 }, High: { response: 0, resolution: 0 }, Medium: { response: 0, resolution: 0 }, Low: { response: 0, resolution: 0 } })
const saving = ref(false)
function loadFile () {
const pol = (policies.value && policies.value[file.value]) || {}
const def = (defaults.value && defaults.value._default) || {}
for (const pr of PRIORITIES) {
const cell = pol[pr] || def[pr] || { response: 0, resolution: 0 }
edit[pr].response = +(((cell.response || 0) / 60).toFixed(2))
edit[pr].resolution = +(((cell.resolution || 0) / 60).toFixed(2))
}
}
async function save () {
saving.value = true
try {
const pol = JSON.parse(JSON.stringify(policies.value || {}))
pol[file.value] = {}
for (const pr of PRIORITIES) pol[file.value][pr] = { response: Math.round((edit[pr].response || 0) * 60), resolution: Math.round((edit[pr].resolution || 0) * 60) }
const ok = await savePolicies(pol)
$q.notify({ type: ok ? 'positive' : 'negative', message: ok ? 'SLA enregistrés (' + (file.value === '_default' ? 'Défaut' : file.value) + ')' : 'Échec de l\'enregistrement' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { saving.value = false }
}
onMounted(async () => {
await loadPolicies()
try {
const types = await listDocs('Issue Type', { fields: ['name'], limit: 100, orderBy: 'name asc' })
fileOptions.value = [{ label: 'Défaut (toutes files)', value: '_default' }, ...types.map(t => ({ label: t.name, value: t.name }))]
} catch { /* types optionnels */ }
loadFile()
})
</script>

View File

@ -0,0 +1,123 @@
<template>
<!-- RÉUTILISABLE : ajoute le fil/objet courant (conversation, ticket, projet) à une TÂCHE ERPNext
existante ou nouvelle. Passé un `source` = { type, name, title, customer, url }. -->
<q-dialog v-model="model" @hide="reset">
<q-card style="min-width:440px;max-width:92vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="add_task" color="primary" size="22px" class="q-mr-sm" />
<span class="text-subtitle1 text-weight-bold">Ajouter à une tâche</span>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div class="text-caption text-grey-7 q-mb-sm">
Lier <b>{{ source.title || source.name || 'cet élément' }}</b>
<span v-if="source.type" class="text-grey-5">({{ srcLabel }})</span> à une tâche.
</div>
<q-btn-toggle v-model="tab" spread no-caps dense unelevated toggle-color="primary" color="grey-2" text-color="grey-8" class="q-mb-md"
:options="[{ label: 'Tâche existante', value: 'existing' }, { label: 'Nouvelle tâche', value: 'new' }]" />
<!-- Tâche existante -->
<template v-if="tab === 'existing'">
<q-select dense outlined v-model="picked" use-input input-debounce="300" :options="taskOptions" @filter="searchTasks"
option-label="label" option-value="value" emit-value map-options clearable :loading="searching"
label="Chercher une tâche (sujet ou #)">
<template #no-option><q-item><q-item-section class="text-grey-6 text-caption">Tape pour chercher ou crée une nouvelle tâche.</q-item-section></q-item></template>
</q-select>
</template>
<!-- Nouvelle tâche -->
<template v-else>
<q-input dense outlined v-model="newTask.subject" label="Sujet de la tâche" autofocus />
<div class="row q-col-gutter-sm q-mt-xs">
<q-select class="col-6 col-sm" dense outlined v-model="newTask.priority" :options="['Low', 'Medium', 'High', 'Urgent']" label="Priorité" emit-value map-options />
<q-input class="col-6 col-sm" dense outlined v-model="newTask.exp_end_date" type="date" label="Échéance" />
</div>
<q-input dense outlined v-model="newTask.description" label="Détails (optionnel)" type="textarea" autogrow class="q-mt-xs" />
</template>
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps label="Annuler" v-close-popup />
<q-btn unelevated no-caps color="primary" :icon="tab === 'new' ? 'add_task' : 'link'"
:label="tab === 'new' ? 'Créer + lier' : 'Lier à la tâche'" :loading="busy"
:disable="tab === 'existing' ? !picked : !newTask.subject.trim()" @click="confirm" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, reactive, watch } from 'vue'
import { useQuasar } from 'quasar'
import { listDocs, getDoc, createDoc, updateDoc } from 'src/api/erp'
const props = defineProps({
modelValue: { type: Boolean, default: false },
// Objet/fil courant à lier : { type:'Issue'|'Conversation'|'Customer'|'Project', name, title, customer, url }
source: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:modelValue', 'linked'])
const $q = useQuasar()
const model = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
const SRC_LABEL = { Issue: 'ticket', Conversation: 'conversation', Customer: 'client', Project: 'projet', Task: 'tâche', 'Dispatch Job': 'travail' }
const srcLabel = computed(() => SRC_LABEL[props.source.type] || props.source.type)
const tab = ref('existing')
const busy = ref(false)
const searching = ref(false)
const taskOptions = ref([])
const picked = ref(null)
const newTask = reactive({ subject: '', priority: 'Medium', exp_end_date: '', description: '' })
// Pré-remplit le sujet de la nouvelle tâche depuis le fil courant
watch(model, (open) => { if (open) { newTask.subject = props.source.title || ''; loadRecentTasks() } })
// Ligne de référence ajoutée à la tâche (lien retour vers le fil) réutilisable multi-objets : chaque ajout empile une ligne.
function refLine () {
const s = props.source
const bits = ['↪ ' + (s.title || s.name || 'élément')]
if (s.type && s.name) bits.push('[' + (srcLabel.value || s.type) + ' ' + s.name + ']')
if (s.customer) bits.push('· client ' + s.customer)
if (s.url) bits.push('· ' + s.url)
return bits.join(' ')
}
async function loadRecentTasks () {
searching.value = true
try {
const rows = await listDocs('Task', { filters: { status: ['not in', ['Completed', 'Cancelled']] }, fields: ['name', 'subject', 'status'], limit: 15, orderBy: 'modified desc' })
taskOptions.value = rows.map(t => ({ label: (t.subject || t.name) + ' · ' + t.status, value: t.name }))
} catch { taskOptions.value = [] } finally { searching.value = false }
}
function searchTasks (query, done) {
const q = (query || '').trim()
if (!q) { loadRecentTasks().then(() => done(() => {})); return }
searching.value = true
listDocs('Task', { or_filters: { subject: ['like', '%' + q + '%'], name: ['like', '%' + q + '%'] }, fields: ['name', 'subject', 'status'], limit: 15, orderBy: 'modified desc' })
.then(rows => { done(() => { taskOptions.value = rows.map(t => ({ label: (t.subject || t.name) + ' · ' + t.status, value: t.name })) }) })
.catch(() => done(() => { taskOptions.value = [] }))
.finally(() => { searching.value = false })
}
async function confirm () {
busy.value = true
try {
if (tab.value === 'new') {
const data = { subject: newTask.subject.trim(), status: 'Open', priority: newTask.priority, description: [newTask.description, refLine()].filter(Boolean).join('\n\n') }
if (newTask.exp_end_date) data.exp_end_date = newTask.exp_end_date
const r = await createDoc('Task', data)
$q.notify({ type: 'positive', icon: 'add_task', message: 'Tâche créée et liée' })
emit('linked', { task: r?.name, created: true })
} else {
const cur = await getDoc('Task', picked.value)
const desc = [(cur && cur.description) || '', refLine()].filter(Boolean).join('\n\n')
await updateDoc('Task', picked.value, { description: desc })
$q.notify({ type: 'positive', icon: 'link', message: 'Lié à la tâche ' + picked.value })
emit('linked', { task: picked.value, created: false })
}
model.value = false
} catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + (e.message || e) }) } finally { busy.value = false }
}
function reset () { tab.value = 'existing'; picked.value = null; newTask.subject = ''; newTask.priority = 'Medium'; newTask.exp_end_date = ''; newTask.description = '' }
</script>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
<template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" persistent>
<q-card style="width:680px;max-width:95vw">
<q-card style="width:700px;max-width:95vw">
<!-- Header -->
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="col">
@ -10,41 +10,60 @@
<q-btn flat round dense icon="close" @click="$emit('update:modelValue', false)" />
</q-card-section>
<!-- Form -->
<q-card-section class="q-pt-md q-gutter-sm">
<!-- Dates -->
<div class="row q-gutter-sm">
<q-input v-model="form.posting_date" label="Date de facturation" type="date" dense outlined class="col" />
<q-input v-model="form.due_date" label="Date d'echeance" type="date" dense outlined class="col" />
</div>
<q-card-section class="q-pt-md">
<!-- Line items (the focus) -->
<div class="text-caption text-weight-bold q-mb-xs">Lignes</div>
<div v-for="(line, idx) in form.items" :key="idx" class="q-mb-sm">
<div class="row items-start q-gutter-xs">
<!-- Product autosuggest type to search catalogue, or free description -->
<q-select
dense outlined class="col"
:model-value="line.sel"
:options="itemResults"
option-label="item_name"
use-input fill-input hide-selected input-debounce="250"
new-value-mode="add-unique" hide-bottom-space clearable
label="Produit ou description"
@filter="searchItems"
@update:model-value="opt => applyProduct(line, opt)"
@input-value="txt => onType(line, txt)"
@clear="() => resetLineProduct(line)">
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section>
<q-item-label>{{ scope.opt.item_name }}</q-item-label>
<q-item-label caption>{{ scope.opt.value }} · {{ scope.opt.item_group || '—' }}</q-item-label>
</q-item-section>
<q-item-section side v-if="scope.opt.rate">
<q-item-label class="text-weight-medium">{{ formatMoney(scope.opt.rate) }}</q-item-label>
</q-item-section>
</q-item>
</template>
<template #no-option>
<q-item><q-item-section class="text-grey-6 text-caption">Tapez pour chercher un produit ou saisissez une description libre</q-item-section></q-item>
</template>
</q-select>
<!-- Tax template -->
<q-select v-model="form.taxes_and_charges" :options="taxOptions" label="Taxes"
dense outlined emit-value map-options />
<!-- Income account -->
<q-select v-model="form.income_account" :options="incomeAccountOptions" label="Compte de revenu"
dense outlined emit-value map-options />
<!-- Line items -->
<div class="text-caption text-weight-bold q-mt-sm q-mb-xs">Lignes</div>
<div v-for="(line, idx) in form.items" :key="idx" class="row items-start q-gutter-xs q-mb-xs">
<q-input v-model="line.description" label="Description" dense outlined class="col"
:rules="[v => !!v?.trim() || 'Requis']" />
<q-input v-model.number="line.qty" label="Qte" type="number" dense outlined
style="width:70px" min="1" step="1" />
<q-input v-model.number="line.qty" label="Qté" type="number" dense outlined
style="width:64px" min="1" step="1" hide-bottom-space />
<q-input v-model.number="line.rate" label="Taux" type="number" dense outlined
style="width:110px" min="0" step="0.01" prefix="$" />
<div style="width:90px" class="q-pt-sm text-right text-weight-medium">
{{ formatMoney(line.qty * line.rate) }}
style="width:106px" min="0" step="0.01" prefix="$" hide-bottom-space class="no-spin" />
<div style="width:84px" class="q-pt-sm text-right text-weight-medium">
{{ formatMoney((line.qty || 0) * (line.rate || 0)) }}
</div>
<q-btn flat round dense icon="delete" color="red-5" size="sm" class="q-mt-xs"
<q-btn flat round dense icon="delete" color="negative" size="sm" class="q-mt-xs"
:disable="form.items.length === 1" @click="removeLine(idx)">
<q-tooltip>Supprimer la ligne</q-tooltip>
</q-btn>
</div>
<!-- Resolved income account (transparent; flagged when it fell back to default) -->
<div v-if="line.item_code" class="text-caption q-pl-xs"
:class="line._confident === false ? 'text-warning' : 'text-grey-6'">
<q-icon :name="line._confident === false ? 'help_outline' : 'east'" size="13px" class="q-mr-xs" />{{ accountCaption(line) }}
</div>
</div>
<q-btn flat dense no-caps color="indigo-6" icon="add" label="Ajouter une ligne"
<q-btn flat dense no-caps color="primary" icon="add" label="Ajouter une ligne"
@click="addLine" class="q-mt-xs" />
<!-- Totals -->
@ -67,12 +86,43 @@
<span class="text-weight-bold">{{ formatMoney(grandTotal) }}</span>
</div>
</div>
<!-- Rarely-touched settings, tucked away -->
<q-separator class="q-mt-sm" />
<q-expansion-item dense flat icon="tune" :label="advancedSummary"
header-class="text-grey-7 text-caption q-px-none">
<div class="q-pt-sm q-gutter-sm">
<!-- Due terms -->
<div>
<div class="text-caption text-grey-6 q-mb-xs">Échéance (selon le crédit du client)</div>
<div class="row items-center q-gutter-sm">
<q-btn-toggle v-model="dueTerms" no-caps unelevated dense
toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[{label:'Prépayé', value:0},{label:'15 jours', value:15},{label:'30 jours', value:30}]" />
<span class="text-caption text-grey-6"> {{ form.due_date }}</span>
</div>
</div>
<!-- Dates -->
<div class="row q-gutter-sm">
<q-input v-model="form.posting_date" label="Date de facturation" type="date" dense outlined class="col" hide-bottom-space />
<q-input v-model="form.due_date" label="Date d'échéance" type="date" dense outlined class="col" hide-bottom-space
@update:model-value="dueTerms = 'custom'" />
</div>
<!-- Taxes -->
<q-select v-model="form.taxes_and_charges" :options="taxOptions" label="Taxes"
dense outlined emit-value map-options hide-bottom-space />
<!-- Default income account (for free-text lines without a product) -->
<q-select v-model="form.default_income_account" :options="incomeAccountOptions"
label="Compte de revenu par défaut (lignes sans produit)"
dense outlined emit-value map-options hide-bottom-space />
</div>
</q-expansion-item>
</q-card-section>
<!-- Actions -->
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat label="Annuler" color="grey-7" @click="$emit('update:modelValue', false)" />
<q-btn unelevated label="Creer brouillon" color="indigo-6" icon="save"
<q-btn unelevated label="Créer brouillon" color="primary" icon="save"
:loading="submitting" :disable="!canSubmit" @click="onSubmit" />
</q-card-actions>
</q-card>
@ -82,8 +132,12 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { Notify } from 'quasar'
import { createDoc } from 'src/api/erp'
import { createDoc, listDocs } from 'src/api/erp'
import { formatMoney } from 'src/composables/useFormatters'
import {
DEFAULT_INCOME_ACCOUNT, INCOME_ACCOUNT_OPTIONS,
resolveIncomeAccount, incomeAccountLabel,
} from 'src/data/income-accounts'
const props = defineProps({
modelValue: { type: Boolean, default: false },
@ -101,22 +155,16 @@ const taxOptions = [
{ label: 'Canada GST 5%', value: TAX_GST },
{ label: 'Aucune taxe (Exempt)', value: TAX_NONE },
]
const incomeAccountOptions = [
{ label: '4020 - Mensualite fibre', value: '4020 - Mensualité fibre - T' },
{ label: '4017 - Installation et equipement fibre', value: '4017 - Installation et équipement fibre - T' },
]
const incomeAccountOptions = INCOME_ACCOUNT_OPTIONS
const submitting = ref(false)
const dueTerms = ref(30) // 0 = prépayé · 15 · 30 · 'custom'
const itemResults = ref([]) // product search results (shared by the active dropdown)
function defaultLine () {
return { description: '', qty: 1, rate: 0 }
return { sel: null, description: '', qty: 1, rate: 0, item_code: '', item_group: '', income_account: DEFAULT_INCOME_ACCOUNT, _confident: null }
}
function todayStr () {
return new Date().toISOString().slice(0, 10)
}
function todayStr () { return new Date().toISOString().slice(0, 10) }
function addDays (dateStr, days) {
const d = new Date(dateStr)
d.setDate(d.getDate() + days)
@ -127,59 +175,117 @@ const form = ref({
posting_date: todayStr(),
due_date: addDays(todayStr(), 30),
taxes_and_charges: TAX_QC,
income_account: '4020 - Mensualité fibre - T',
default_income_account: DEFAULT_INCOME_ACCOUNT,
items: [defaultLine()],
})
// Reset form when dialog opens
// Reset when the dialog opens
watch(() => props.modelValue, (open) => {
if (open) {
const isExempt = props.customer?.tax_category_legacy === 'Exempt'
dueTerms.value = 30
itemResults.value = []
form.value = {
posting_date: todayStr(),
due_date: addDays(todayStr(), 30),
taxes_and_charges: isExempt ? TAX_NONE : TAX_QC,
income_account: '4020 - Mensualité fibre - T',
default_income_account: DEFAULT_INCOME_ACCOUNT,
items: [defaultLine()],
}
}
})
function addLine () {
form.value.items.push(defaultLine())
// Recompute due date from the selected term (unless the user set a custom date)
watch([dueTerms, () => form.value.posting_date], () => {
if (dueTerms.value !== 'custom' && form.value.posting_date) {
form.value.due_date = addDays(form.value.posting_date, dueTerms.value)
}
})
// Product search (ERPNext Item)
async function searchItems (txt, update, abort) {
try {
const like = `%${(txt || '').trim()}%`
const items = await listDocs('Item', {
filters: { is_sales_item: 1, disabled: 0 },
or_filters: [['item_name', 'like', like], ['item_code', 'like', like]],
fields: ['name', 'item_name', 'item_group', 'standard_rate'],
limit: 15,
orderBy: 'item_name asc',
})
update(() => {
itemResults.value = items.map(it => ({
item_name: it.item_name || it.name,
value: it.name,
item_group: it.item_group || '',
rate: parseFloat(it.standard_rate || 0),
}))
})
} catch { update(() => { itemResults.value = [] }) }
}
function removeLine (idx) {
if (form.value.items.length > 1) {
form.value.items.splice(idx, 1)
function applyProduct (line, opt) {
if (opt && typeof opt === 'object') {
line.sel = opt
line.description = opt.item_name || opt.value
line.item_code = opt.value
line.item_group = opt.item_group || ''
if (opt.rate) line.rate = opt.rate // don't wipe a manual rate with a $0 item
const r = resolveIncomeAccount(opt.item_group)
line.income_account = r.account
line._confident = r.confident
} else if (typeof opt === 'string') {
line.sel = opt
line.description = opt
line.item_code = ''
line.item_group = ''
line._confident = null
line.income_account = form.value.default_income_account
} else {
resetLineProduct(line)
}
}
function onType (line, txt) { line.description = txt }
function resetLineProduct (line) {
line.sel = null; line.item_code = ''; line.item_group = ''; line._confident = null
line.description = ''; line.income_account = form.value.default_income_account
}
// Computed totals
const subtotal = computed(() =>
form.value.items.reduce((sum, l) => sum + (l.qty || 0) * (l.rate || 0), 0)
)
function accountCaption (line) {
const lbl = incomeAccountLabel(line.income_account)
return line._confident === false ? `${lbl} — compte par défaut, à vérifier` : lbl
}
function addLine () { form.value.items.push(defaultLine()) }
function removeLine (idx) { if (form.value.items.length > 1) form.value.items.splice(idx, 1) }
// Totals
const subtotal = computed(() => form.value.items.reduce((s, l) => s + (l.qty || 0) * (l.rate || 0), 0))
const isTwoPartTax = computed(() => form.value.taxes_and_charges === TAX_QC)
const taxRate = computed(() => {
if (form.value.taxes_and_charges === TAX_QC) return 0.14975
if (form.value.taxes_and_charges === TAX_GST) return 0.05
return 0
})
const taxLabel = computed(() => {
if (form.value.taxes_and_charges === TAX_QC) return '14.975%'
if (form.value.taxes_and_charges === TAX_GST) return '5%'
return ''
})
const taxAmount = computed(() => subtotal.value * taxRate.value)
const grandTotal = computed(() => subtotal.value + taxAmount.value)
const canSubmit = computed(() =>
form.value.items.some(l => l.description?.trim() && l.rate > 0)
)
const termLabel = computed(() => {
if (dueTerms.value === 'custom') return `échéance ${form.value.due_date}`
if (dueTerms.value === 0) return 'prépayé'
return `échéance ${dueTerms.value} j`
})
const advancedSummary = computed(() => {
const tax = taxOptions.find(t => t.value === form.value.taxes_and_charges)?.label || 'Sans taxe'
return `Options — ${tax} · ${termLabel.value}`
})
const canSubmit = computed(() => form.value.items.some(l => l.description?.trim() && l.rate > 0))
async function onSubmit () {
if (!canSubmit.value) return
@ -187,14 +293,17 @@ async function onSubmit () {
try {
const items = form.value.items
.filter(l => l.description?.trim() && l.rate > 0)
.map(l => ({
item_code: 'SVC',
item_name: l.description.trim(),
.map(l => {
const row = {
item_code: l.item_code || 'SVC',
description: l.description.trim(),
qty: l.qty || 1,
rate: l.rate,
income_account: form.value.income_account,
}))
income_account: l.income_account || form.value.default_income_account,
}
if (!l.item_code) row.item_name = l.description.trim().slice(0, 140)
return row
})
const payload = {
doctype: 'Sales Invoice',
@ -207,38 +316,22 @@ async function onSubmit () {
items,
}
// Include tax rows directly (ERPNext tax templates are empty shells)
const taxSel = form.value.taxes_and_charges
if (taxSel === TAX_QC) {
payload.taxes_and_charges = TAX_QC
payload.taxes = [
{
charge_type: 'On Net Total',
account_head: '2300 - TPS perçue - T',
description: 'TPS 5%',
rate: 5,
},
{
charge_type: 'On Net Total',
account_head: '2350 - TVQ perçue - T',
description: 'TVQ 9.975%',
rate: 9.975,
},
{ charge_type: 'On Net Total', account_head: '2300 - TPS perçue - T', description: 'TPS 5%', rate: 5 },
{ charge_type: 'On Net Total', account_head: '2350 - TVQ perçue - T', description: 'TVQ 9.975%', rate: 9.975 },
]
} else if (taxSel === TAX_GST) {
payload.taxes_and_charges = TAX_GST
payload.taxes = [
{
charge_type: 'On Net Total',
account_head: '2300 - TPS perçue - T',
description: 'TPS 5%',
rate: 5,
},
{ charge_type: 'On Net Total', account_head: '2300 - TPS perçue - T', description: 'TPS 5%', rate: 5 },
]
}
const doc = await createDoc('Sales Invoice', payload)
Notify.create({ type: 'positive', message: `Facture ${doc.name} creee (brouillon)`, position: 'top' })
Notify.create({ type: 'positive', message: `Facture ${doc.name} créée (brouillon)`, position: 'top' })
emit('created', doc)
emit('update:modelValue', false)
} catch (err) {
@ -250,8 +343,9 @@ async function onSubmit () {
</script>
<style scoped>
.totals-grid {
max-width: 320px;
margin-left: auto;
}
.totals-grid { max-width: 320px; margin-left: auto; }
/* Hide number-input spinner arrows (useless here) */
.no-spin :deep(input[type=number])::-webkit-outer-spin-button,
.no-spin :deep(input[type=number])::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.no-spin :deep(input[type=number]) { -moz-appearance: textfield; }
</style>

View File

@ -0,0 +1,39 @@
<template>
<!-- q-table qui bascule en mode CARTES sous 1024px (sinon une table large déborde en horizontal sur mobile).
Transmet tous les attrs/slots du q-table. Un slot #item par défaut rend chaque ligne en carte clé/valeur ;
fournir son propre #item pour personnaliser. Usage : <DataTable :rows :columns row-key="name" />. -->
<q-table v-bind="$attrs" :grid="isMobile" flat bordered>
<template v-for="(_, name) in $slots" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
<template v-if="!$slots.item" #item="props">
<div class="col-12 dt-grid-item">
<q-card flat bordered class="dt-card" :class="{ 'dt-card-click': !!$attrs.onRowClick }"
@click="$attrs.onRowClick && $attrs.onRowClick($event, props.row)">
<div v-for="col in props.cols" :key="col.name" class="dt-row">
<span class="dt-k">{{ col.label }}</span>
<span class="dt-v">{{ col.value }}</span>
</div>
</q-card>
</div>
</template>
</q-table>
</template>
<script setup>
import { useIsMobile } from 'src/composables/useMediaQuery'
defineOptions({ inheritAttrs: false })
const isMobile = useIsMobile()
</script>
<style scoped>
.dt-grid-item { padding: 4px; }
.dt-card { padding: 8px 12px; border-radius: 10px; }
.dt-card-click { cursor: pointer; }
.dt-card-click:active { background: var(--ops-bg-light, #f8fafc); }
.dt-row { display: flex; justify-content: space-between; gap: 12px; padding: 4px 0; font-size: 0.85rem; border-bottom: 1px solid var(--ops-border); }
.dt-row:last-child { border-bottom: none; }
.dt-k { color: var(--ops-text-muted); }
.dt-v { font-weight: 600; text-align: right; word-break: break-word; }
</style>

View File

@ -1,157 +0,0 @@
<template>
<div class="dept-wrap">
<div class="dept-head">
<div class="text-caption text-grey-7">{{ totalActive }} active(s) · glisser une carte = classer dans une file · clic = ouvrir le fil</div>
<q-space />
<q-toggle v-model="showResolved" dense size="sm" label="Inclure résolues" color="grey-7" class="q-mr-sm" />
<q-btn flat dense round icon="refresh" :loading="loading" @click="fetchList" />
</div>
<div class="dept-board">
<div v-for="dep in depts" :key="dep" class="dept-col" :class="{ 'drop-hover': dropDept === dep }"
@dragover.prevent="dropDept = dep" @dragleave="dropDept === dep && (dropDept = null)" @drop="onDrop(dep)">
<div class="dept-col-hd" :class="dep === UNASSIGNED ? 'dh-unassigned' : 'dh-' + slug(dep)">
<q-icon :name="deptIcon(dep)" size="16px" class="q-mr-xs" />
<span class="ellipsis col">{{ dep }}</span>
<q-badge color="white" text-color="grey-9" class="q-ml-xs">{{ (columns[dep] || []).length }}</q-badge>
<q-btn flat dense round icon="add" size="xs" color="white" class="q-ml-xs">
<q-tooltip>Composer dans « {{ dep }} »</q-tooltip>
<q-menu auto-close>
<q-list dense style="min-width:160px">
<q-item clickable @click="compose('email', dep)"><q-item-section avatar><q-icon name="mail" color="red-5" /></q-item-section><q-item-section>Nouveau courriel</q-item-section></q-item>
<q-item clickable @click="compose('sms', dep)"><q-item-section avatar><q-icon name="sms" color="teal-6" /></q-item-section><q-item-section>Nouveau texto</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<div class="dept-col-body">
<div v-for="d in (columns[dep] || [])" :key="d.id" class="dept-card" :class="{ 'needs-reply': needsReply(d) }"
draggable="true" @dragstart="onDragStart(d)" @dragend="dragCard = null" @click="openCard(d)">
<div class="dept-card-t">
<q-icon :name="d.channel === 'email' ? 'mail' : 'sms'" size="13px" class="q-mr-xs" :color="d.channel === 'email' ? 'red-5' : 'teal-6'" />
<span class="ellipsis col">{{ d.customerName || d.email || d.phone || 'Inconnu' }}</span>
<q-badge v-if="d.status === 'active'" color="green" class="q-ml-xs" style="font-size:.55rem;padding:1px 4px">actif</q-badge>
</div>
<div class="dept-card-s ellipsis">{{ preview(d) }}</div>
<div class="dept-card-m">
<span class="text-grey-6">{{ relTime(d.lastMessage && d.lastMessage.ts) }}</span>
<q-icon v-if="needsReply(d)" name="reply" size="12px" color="blue-7" class="q-ml-xs"><q-tooltip>En attente de réponse</q-tooltip></q-icon>
<q-space />
<span v-if="d.assignee" class="dept-assignee"><q-icon name="pan_tool" size="11px" /> {{ shortAgent(d.assignee) }}</span>
</div>
</div>
<div v-if="!(columns[dep] || []).length" class="dept-empty"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { useConversations } from 'src/composables/useConversations'
import { shortAgent, relTime } from 'src/composables/useFormatters'
const $q = useQuasar()
const {
discussions, QUEUES, assignQueue, fetchList, loading,
openDiscussion, panelOpen, newDialogOpen, newDialogChannel,
} = useConversations()
const UNASSIGNED = 'Non assigné'
const depts = computed(() => [...QUEUES, UNASSIGNED])
const showResolved = ref(false)
const dragCard = ref(null)
const dropDept = ref(null)
const DEPT_ICONS = {
'Facturations': 'request_quote',
'Service à la clientèle': 'support_agent',
'Support': 'build',
'Supports': 'build',
'Technicien': 'engineering',
}
function deptIcon (dep) { return dep === UNASSIGNED ? 'inbox' : (DEPT_ICONS[dep] || 'groups') }
function slug (s) { return String(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z]+/g, '-').replace(/^-|-$/g, '') }
function needsReply (d) { return d.status === 'active' && d.lastMessage && d.lastMessage.from === 'customer' }
function preview (d) { return d.subject || (d.lastMessage && d.lastMessage.text) || '—' }
function tsOf (d) { const t = d.lastMessage && d.lastMessage.ts; const n = t ? new Date(t).getTime() : 0; return Number.isFinite(n) ? n : 0 }
// shortAgent + relTime (ex-fmtTime, logique identique) viennent de useFormatters (consolidation)
// Regroupe les discussions unifiées (SMS + courriel) par file/département.
const columns = computed(() => {
const map = {}
for (const dep of depts.value) map[dep] = []
for (const d of (discussions.value || [])) {
if (!showResolved.value && d.status === 'closed') continue
const q = (d.queue && QUEUES.includes(d.queue)) ? d.queue : UNASSIGNED
map[q].push(d)
}
for (const dep of depts.value) map[dep].sort((a, b) => tsOf(b) - tsOf(a))
return map
})
const totalActive = computed(() => (discussions.value || []).filter(d => d.status === 'active').length)
// Jeton de conversation à classer (active sinon dernière sinon token de la discussion).
function discToken (d) {
const active = (d.conversations || []).find(c => c.status === 'active')
return (active && active.token) || d.token || (((d.conversations || []).slice(-1)[0]) || {}).token || null
}
function onDragStart (d) { dragCard.value = d }
async function onDrop (dep) {
dropDept.value = null
const d = dragCard.value; dragCard.value = null
if (!d) return
const tok = discToken(d)
if (!tok) { $q.notify({ type: 'warning', message: 'Conversation sans jeton — impossible de classer.' }); return }
const newQueue = dep === UNASSIGNED ? '' : dep
if ((d.queue || '') === newQueue) return
const prev = d.queue
d.queue = newQueue // déplacement optimiste (assignQueue rafraîchit ensuite la liste)
try {
await assignQueue(tok, newQueue)
$q.notify({ type: 'positive', message: `${d.customerName || d.phone || 'Conversation'}${dep}`, timeout: 1200 })
} catch (e) { d.queue = prev; $q.notify({ type: 'negative', message: 'Échec du classement' }) }
}
function openCard (d) { panelOpen.value = true; openDiscussion(d) }
// Envoi depuis une colonne : ouvre le dialogue partagé (rendu dans ConversationPanel, hors du tiroir).
// v1 : la nouvelle conversation atterrit en « Non assigné » l'agent la glisse dans la bonne file (auto-routage à venir).
function compose (channel /* , dep */) {
newDialogChannel.value = channel
newDialogOpen.value = true
}
onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList() })
</script>
<style scoped>
.dept-wrap { padding: 10px 14px; }
.dept-head { display: flex; align-items: center; margin-bottom: 10px; }
.dept-board { display: flex; gap: 12px; overflow-x: auto; align-items: flex-start; padding-bottom: 8px; }
.dept-col { flex: 0 0 270px; background: #f1f5f9; border-radius: 10px; display: flex; flex-direction: column; max-height: calc(100vh - 230px); }
.dept-col.drop-hover { outline: 2px dashed #6366f1; outline-offset: -2px; }
.dept-col-hd { display: flex; align-items: center; font-weight: 700; font-size: .8rem; color: #fff; padding: 7px 10px; border-radius: 10px 10px 0 0; }
.dh-facturations { background: #d97706; }
.dh-service-a-la-clientele { background: #6366f1; }
.dh-support, .dh-supports { background: #0891b2; }
.dh-technicien { background: #16a34a; }
.dh-unassigned { background: #64748b; }
.dept-col-body { padding: 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; }
.dept-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.dept-card:hover { border-color: #6366f1; }
.dept-card.needs-reply { border-left: 3px solid #2563eb; background: #eff6ff; }
.dept-card-t { font-weight: 600; font-size: .82rem; display: flex; align-items: center; }
.dept-card-s { font-size: .75rem; color: #475569; margin-top: 2px; }
.dept-card-m { display: flex; align-items: center; font-size: .68rem; margin-top: 5px; }
.dept-assignee { color: #b45309; font-weight: 600; }
.dept-empty { text-align: center; color: #cbd5e1; padding: 10px; font-size: .8rem; }
.col { min-width: 0; }
@media (max-width: 640px) {
.dept-board { scroll-snap-type: x mandatory; }
.dept-col { flex-basis: 86vw; scroll-snap-align: start; max-height: calc(100vh - 200px); }
}
</style>

View File

@ -20,7 +20,7 @@
<!-- Loading -->
<q-card-section v-if="loading" class="flex flex-center" style="min-height:200px">
<q-spinner size="32px" color="indigo-6" />
<q-spinner size="32px" color="primary" />
</q-card-section>
<!-- Content -->

View File

@ -0,0 +1,25 @@
<template>
<!-- État vide réutilisable (listes, tableaux, rapports). Optionnel : slot #action pour un bouton. -->
<div class="empty-state">
<q-icon :name="icon" size="44px" class="empty-icon" />
<div class="empty-label">{{ label }}</div>
<div v-if="hint" class="empty-hint">{{ hint }}</div>
<div v-if="$slots.action" class="empty-action"><slot name="action" /></div>
</div>
</template>
<script setup>
defineProps({
icon: { type: String, default: 'inbox' },
label: { type: String, default: 'Aucune donnée' },
hint: { type: String, default: '' },
})
</script>
<style scoped>
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; padding: 44px 20px; text-align: center; }
.empty-icon { color: var(--ops-border); }
.empty-label { font-size: 0.95rem; font-weight: 600; color: var(--ops-text); }
.empty-hint { font-size: 0.82rem; color: var(--ops-text-muted); max-width: 44ch; }
.empty-action { margin-top: 12px; }
</style>

View File

@ -15,7 +15,7 @@
{{ displayValue || placeholder }}
</span>
</slot>
<q-spinner v-if="saving" size="12px" color="indigo-6" class="q-ml-xs" />
<q-spinner v-if="saving" size="12px" color="primary" class="q-ml-xs" />
</template>
<!-- Edit mode: text / number -->

View File

@ -2,7 +2,7 @@
<q-dialog v-model="open" @show="onShow">
<q-card style="min-width:480px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="forum" color="indigo-6" size="22px" class="q-mr-sm" />
<q-icon name="forum" color="primary" size="22px" class="q-mr-sm" />
<div>
<div class="text-subtitle1 text-weight-bold">Intervenir{{ title ? ' — ' + title : '' }}</div>
<div class="text-caption text-grey-6">Collaboration d'équipe · <b>n'affecte pas l'assignation</b></div>
@ -30,7 +30,7 @@
<q-input v-model="text" type="textarea" autogrow :rows="3" outlined autofocus
label="Indice, note, contexte… (visible par l'équipe sur le ticket)" />
<div class="row justify-end q-mt-sm">
<q-btn unelevated color="indigo-6" icon="send" label="Publier la note" no-caps :disable="!text.trim() || busy" :loading="busy" @click="submit([])" />
<q-btn unelevated color="primary" icon="send" label="Publier la note" no-caps :disable="!text.trim() || busy" :loading="busy" @click="submit([])" />
</div>
</div>
@ -38,22 +38,22 @@
<div v-else-if="step === 'mention'">
<q-btn flat dense size="sm" icon="arrow_back" label="Retour" no-caps class="q-mb-xs" @click="step = 'menu'" />
<div class="row items-center q-gutter-xs q-mb-xs" v-if="picked.length">
<q-chip v-for="m in picked" :key="m" dense removable color="indigo-1" text-color="indigo-8" @remove="picked = picked.filter(x => x !== m)">{{ shortName(m) }}</q-chip>
<q-chip v-for="m in picked" :key="m" dense removable color="green-1" text-color="green-8" @remove="picked = picked.filter(x => x !== m)">{{ shortName(m) }}</q-chip>
</div>
<div style="position:relative">
<q-input v-model="search" dense outlined label="Mentionner un collègue…" autofocus autocomplete="off" @update:model-value="onSearch">
<template #append><q-spinner v-if="searching" size="16px" color="indigo-6" /></template>
<template #append><q-spinner v-if="searching" size="16px" color="primary" /></template>
</q-input>
<div v-if="results.length" class="iv-dd">
<div v-for="u in results" :key="u.email" class="iv-dd-item" @click="addPick(u.email)">
<q-avatar size="22px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:.6rem">{{ (u.name || u.email).charAt(0).toUpperCase() }}</q-avatar>
<q-avatar size="22px" color="green-1" text-color="green-8" class="q-mr-sm" style="font-size:.6rem">{{ (u.name || u.email).charAt(0).toUpperCase() }}</q-avatar>
{{ u.name || u.email }}<span class="text-caption text-grey-6 q-ml-xs">{{ u.email }}</span>
</div>
</div>
</div>
<q-input v-model="text" type="textarea" autogrow :rows="2" outlined class="q-mt-sm" label="Message au(x) collègue(s)…" />
<div class="row justify-end q-mt-sm">
<q-btn unelevated color="indigo-6" icon="send" :label="`Notifier ${picked.length || ''}`" no-caps :disable="!picked.length || !text.trim() || busy" :loading="busy" @click="submit(picked)" />
<q-btn unelevated color="primary" icon="send" :label="`Notifier ${picked.length || ''}`" no-caps :disable="!picked.length || !text.trim() || busy" :loading="busy" @click="submit(picked)" />
</div>
</div>
@ -94,7 +94,7 @@ let _t = null
const actions = [
{ key: 'note', icon: 'lightbulb', color: 'amber-7', label: 'Indice / note', hint: 'Partager une connaissance' },
{ key: 'mention', icon: 'alternate_email', color: 'indigo-6', label: 'Mentionner', hint: 'Demander à un collègue' },
{ key: 'mention', icon: 'alternate_email', color: 'primary', label: 'Mentionner', hint: 'Demander à un collègue' },
{ key: 'link', icon: 'link', color: 'teal-6', label: 'Lier un élément', hint: 'Ticket, client…', soon: true },
{ key: 'follow', icon: 'visibility', color: 'blue-grey-6', label: 'Suivre', hint: 'Recevoir les suites', soon: true },
]

View File

@ -4,22 +4,53 @@
<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-btn-toggle v-model="chanView" :options="chanOptions" dense unelevated no-caps toggle-color="primary" 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="notifyEnabled ? 'notifications_active' : 'notifications_none'" :color="notifyEnabled ? 'primary' : '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="refreshing || loading" @click="refreshNow"><q-tooltip>Rafraîchir chercher les nouveaux courriels maintenant</q-tooltip></q-btn>
<!-- Admin : voir la Boîte (vue/filtres) comme un autre utilisateur, en lecture seule -->
<q-btn v-if="isAdmin" flat dense round icon="visibility" :color="impersonating ? 'deep-orange-6' : 'grey-6'" size="sm" @click="openViewAs">
<q-tooltip>Voir la Boîte comme un autre utilisateur (admin · lecture seule)</q-tooltip>
<q-menu>
<q-list dense style="min-width:230px">
<q-item-label header class="q-py-xs">Voir comme (lecture seule)</q-item-label>
<q-item v-for="a in agentList" :key="a" clickable v-close-popup @click="viewAs(a)" :active="a === impersonating" active-class="bg-deep-orange-1">
<q-item-section avatar><q-avatar size="22px" color="blue-grey-5" text-color="white" style="font-size:10px">{{ agentInitials(a) }}</q-avatar></q-item-section>
<q-item-section>{{ agentName(a) }}</q-item-section>
</q-item>
<q-item v-if="!agentList.length"><q-item-section class="text-grey-5 text-caption">Chargement</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<!-- Bandeau lecture seule pendant l'impersonation admin -->
<div v-if="impersonating" class="mlist-impersonate row items-center no-wrap">
<q-icon name="visibility" size="15px" class="q-mr-xs" />
<span>Vue de <b>{{ agentName(impersonating) }}</b> lecture seule (vos préférences ne changent pas)</span>
<q-space />
<q-btn flat dense size="sm" no-caps icon="logout" label="Quitter" color="white" @click="exitViewAs" />
</div>
<!-- Case « tout cocher » + filtres OU actions de lot DANS LA MÊME LIGNE (hauteur constante pas de décalage au clic) -->
<div class="mlist-tabs">
<!-- Mode TICKETS : critères de filtre obligatoires (statut + assignés à moi) -->
<template v-if="ticketMode">
<q-icon name="confirmation_number" color="positive" size="16px" class="q-ml-xs q-mr-sm" />
<q-btn-toggle v-model="ticketStatus" :options="TStatusOpts" dense unelevated no-caps toggle-color="primary" color="grey-2" text-color="grey-7" size="sm" />
<q-chip dense clickable size="sm" class="q-ml-sm" :color="ticketMine ? 'primary' : 'grey-3'" :text-color="ticketMine ? 'white' : 'grey-8'" @click="ticketMine = !ticketMine">À moi<span v-if="ticketMineCount" class="mlist-cnt">{{ ticketMineCount }}</span><q-tooltip>Tickets qui me sont assignés (ERPNext)</q-tooltip></q-chip>
<q-space />
<span class="text-caption text-grey-6">{{ shownTickets.length }} ticket(s)</span>
</template>
<template v-else>
<q-checkbox dense size="xs" class="mlist-allcb" :model-value="headState" @update:model-value="toggleAll"><q-tooltip>{{ checked.size ? 'Tout décocher' : 'Tout cocher' }}</q-tooltip></q-checkbox>
<template v-if="checked.size">
<span class="text-weight-medium text-grey-8">{{ checked.size }} sélectionné(s)</span>
<q-space />
<q-btn dense flat no-caps size="sm" color="teal-7" icon="confirmation_number" label="→ Ticket" :loading="batchBusy">
<q-btn dense flat no-caps size="sm" color="positive" icon="confirmation_number" label="→ Ticket" :loading="batchBusy">
<q-menu auto-close>
<q-list dense style="min-width:190px">
<q-item-label header class="q-py-xs">Créer un ticket dans</q-item-label>
@ -31,65 +62,94 @@
</q-menu>
</q-btn>
<q-btn dense flat no-caps size="sm" color="grey-7" icon="archive" label="Archiver" :loading="batchBusy" @click="batchArchive" />
<q-btn dense flat no-caps size="sm" color="red-6" icon="delete" label="Supprimer" :loading="batchBusy" @click="batchDelete" />
<q-btn dense flat no-caps size="sm" color="negative" icon="delete" label="Supprimer" :loading="batchBusy" @click="batchDelete" />
<q-btn dense flat round size="sm" icon="close" @click="checked = new Set()"><q-tooltip>Annuler la sélection</q-tooltip></q-btn>
</template>
<template v-else>
<q-chip dense clickable size="sm" :color="sel.size === 0 ? 'indigo-6' : 'grey-3'" :text-color="sel.size === 0 ? 'white' : 'grey-8'" @click="toggle('all')">
<q-chip dense clickable size="sm" :color="sel.size === 0 ? 'primary' : 'grey-3'" :text-color="sel.size === 0 ? 'white' : 'grey-8'" @click="toggle('all')">
Tous<span class="mlist-cnt">{{ visible.length }}</span>
</q-chip>
<q-chip v-for="f in chips" :key="f.key" dense clickable size="sm"
:color="sel.has(f.key) ? 'indigo-6' : 'grey-3'" :text-color="sel.has(f.key) ? 'white' : 'grey-8'"
:color="sel.has(f.key) ? 'primary' : 'grey-3'" :text-color="sel.has(f.key) ? 'white' : 'grey-8'"
@click="toggle(f.key)">
{{ f.label }}<span v-if="f.count" class="mlist-cnt">{{ f.count }}</span>
</q-chip>
</template>
</template>
</div>
<div class="mlist-body">
<!-- Mode TICKETS : liste des tickets ERPNext (Issues) filtrés -->
<template v-if="ticketMode">
<div v-for="t in shownTickets" :key="t.name" class="mrow mrow-ticket" @click="openTicket(t)">
<q-icon name="confirmation_number" color="positive" size="17px" class="mrow-chan" />
<span class="mrow-name">{{ t.customer || '—' }}</span>
<q-badge class="mrow-tag" :color="t.status === 'Open' ? 'orange-1' : 'blue-1'" :text-color="t.status === 'Open' ? 'orange-9' : 'blue-9'">{{ t.status === 'Open' ? 'Ouvert' : t.status === 'Replied' ? 'Répondu' : t.status }}</q-badge>
<div class="mrow-subject"><span class="mrow-subj-strong">{{ t.subject || t.name }}</span><span class="mrow-snip"> {{ t.name }}</span></div>
<div class="mrow-right">
<q-icon v-if="t.convToken" name="forum" size="13px" color="green-5" class="mrow-rep"><q-tooltip>Fil de conversation lié clic pour l'ouvrir</q-tooltip></q-icon>
<q-badge v-if="t.priority && t.priority !== 'Medium'" class="mrow-tag" :color="['High', 'Urgent'].includes(t.priority) ? 'red-2' : 'grey-3'" :text-color="['High', 'Urgent'].includes(t.priority) ? 'red-9' : 'grey-8'">{{ t.priority }}</q-badge>
<span class="mrow-time">{{ relTime(t.modified) }}</span>
</div>
</div>
<div v-if="!shownTickets.length" class="mlist-empty">Aucun ticket{{ ticketStatus !== 'all' || ticketMine || search ? ' (filtre actif)' : '' }}</div>
</template>
<!-- Mode CONVERSATIONS (défaut) -->
<template v-else>
<!-- RÉSULTATS DE RECHERCHE FLOUE (SQL pg_trgm) : nom/courriel/sujet + CORPS DES MESSAGES, typo-tolérant, sur TOUTES les convs (même archivées) -->
<template v-if="searchResults !== null">
<div v-for="r in searchResults" :key="'s:' + r.token" class="mrow" @click="openSearchResult(r)">
<q-icon v-if="r.channel" :name="channelMeta(r.channel).icon" :color="channelMeta(r.channel).color" size="16px" class="mrow-chan" />
<span class="mrow-name">{{ r.customerName || r.email || 'Inconnu' }}</span>
<q-badge v-if="r.queue" class="mrow-tag" color="blue-grey-1" text-color="blue-grey-8">{{ qLabel(r.queue) }}</q-badge>
<q-badge v-if="r.status && r.status !== 'active'" class="mrow-tag" color="grey-3" text-color="grey-7">archivé</q-badge>
<div class="mrow-subject"><span v-if="r.subject" class="mrow-subj-strong">{{ r.subject }}</span><span class="mrow-snip">{{ r.subject && r.snippet ? ' ' : '' }}{{ r.snippet }}</span></div>
<div class="mrow-right"><span class="mrow-time">{{ relTime(r.lastActivity) }}</span></div>
</div>
<div v-if="searchBusy" class="mlist-empty">Recherche</div>
<div v-else-if="!searchResults.length" class="mlist-empty">Aucun résultat pour « {{ search }} »</div>
</template>
<template v-else>
<div v-for="(d, idx) in shown" :key="d.id" class="mrow" :class="{ 'mrow-unread': isUnread(d), 'mrow-checked': checked.has(d.id) }" @click="open(d)">
<q-checkbox :model-value="checked.has(d.id)" dense size="xs" class="mrow-cb" @click.stop="onRowCheck($event, idx, d.id)" />
<!-- Drapeau de PRIORITÉ (style ClickUp) : coloré si défini, discret sinon ; clic = menu rapide (triage depuis la boîte) -->
<q-btn flat dense round size="xs" class="mrow-flag" :class="{ 'mrow-flag-none': !d.priority }" :icon="priorityMeta(d.priority).icon" :color="priorityMeta(d.priority).color" @click.stop>
<q-tooltip v-if="d.priority">Priorité : {{ priorityMeta(d.priority).label }}</q-tooltip>
<q-menu auto-close anchor="bottom left" self="top left">
<q-list dense style="min-width:168px">
<q-item-label header class="q-py-xs">Priorité</q-item-label>
<q-item v-for="lvl in PRIORITY_LEVELS" :key="lvl" clickable @click="setRowPriority(d, lvl)" :active="d.priority === lvl" active-class="bg-green-1">
<q-item-section avatar><q-icon name="flag" :color="priorityMeta(lvl).color" size="18px" /></q-item-section>
<q-item-section>{{ priorityMeta(lvl).label }}</q-item-section>
</q-item>
<q-separator />
<q-item clickable @click="setRowPriority(d, '')"><q-item-section avatar><q-icon name="outlined_flag" color="grey-5" size="18px" /></q-item-section><q-item-section class="text-grey-7">Aucune</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<!-- icône de canal seulement pour SMS/chat/Facebook ; pour le courriel (cas par défaut) elle est redondante -->
<q-icon v-if="d.channel && d.channel !== 'email'" :name="chanIcon(d.channel)" :color="chanColor(d.channel)" size="17px" class="mrow-chan" />
<q-icon v-if="d.channel" :name="channelMeta(d.channel).icon" :color="channelMeta(d.channel).color" size="16px" class="mrow-chan"><q-tooltip>{{ channelMeta(d.channel).label }}</q-tooltip></q-icon>
<!-- Ligne unique pleine largeur (style Gmail) : Nom · file · Sujet aperçu · indicateurs · heure -->
<span class="mrow-name">{{ d.customerName || d.email || d.phone || 'Inconnu' }}</span>
<span v-if="replyInfo(d).count > 1" class="mrow-count">{{ replyInfo(d).count }}</span>
<q-badge v-if="d.queue" class="mrow-tag" color="blue-grey-1" text-color="blue-grey-8">{{ qLabel(d.queue) }}</q-badge>
<div class="mrow-subject"><span v-if="subj(d)" class="mrow-subj-strong">{{ subj(d) }}</span><span class="mrow-snip">{{ subj(d) && snip(d) ? ' ' : '' }}{{ snip(d) }}</span></div>
<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>
<q-avatar v-if="draftAgentOf(d)" size="19px" color="indigo-5" text-color="white" class="q-mr-xs mrow-draft">
<q-icon v-if="replyInfo(d).client" name="reply" size="14px" color="positive" 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="green-5" class="mrow-rep"><q-tooltip>Un collègue a répondu</q-tooltip></q-icon>
<q-avatar v-if="draftAgentOf(d)" size="19px" color="green-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>
<!-- Actions au survol (style Gmail) : répondre / classer en ticket par dépt / archiver / supprimer -->
<div class="mrow-actions" @click.stop>
<q-spinner v-if="rowBusy === d.id" size="18px" color="indigo-6" />
<template v-else>
<q-btn flat dense round size="sm" icon="reply" color="grey-7" @click.stop="open(d)"><q-tooltip>Répondre (ouvrir le fil)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="confirmation_number" color="teal-7">
<q-tooltip>Classer en ticket</q-tooltip>
<q-menu auto-close anchor="bottom right" self="top right">
<q-list dense style="min-width:190px">
<q-item-label header class="q-py-xs">Créer un ticket dans</q-item-label>
<q-item v-for="dep in TICKET_DEPTS" :key="dep.cat" clickable @click="ticketTo(d, dep)">
<q-item-section avatar><q-icon :name="dep.icon" :color="dep.color" size="18px" /></q-item-section>
<q-item-section>{{ dep.label }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
<q-btn flat dense round size="sm" icon="filter_alt" color="blue-grey-6" @click.stop="openFilterLike(d)"><q-tooltip>Filtrer les messages comme celui-ci (masquer / afficher)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="archive" color="grey-7" @click.stop="quickArchive(d)"><q-tooltip>Archiver (retirer de la boîte)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="delete" color="red-4" @click.stop="quickDelete(d)"><q-tooltip>Supprimer</q-tooltip></q-btn>
</template>
</div>
<!-- Barre d'actions au survol RETIRÉE : elle cachait l'avatar de présence/lecteurs à droite. Répondre/ticket/archiver/supprimer
se font dans le fil ouvert (clic sur la ligne) + la barre de lot (cases à cocher). Le filtre « masquer comme ceci » est dans le détail du fil. -->
</div>
<div v-if="!shown.length" class="mlist-empty">Aucune conversation{{ sel.size || search || chanView !== 'all' ? ' (filtre actif)' : '' }}</div>
</template>
</template>
</div>
<!-- « Filtrer les messages comme celui-ci » : crée une règle masquer/afficher (style Gmail) -->
@ -98,13 +158,13 @@
<q-card-section class="row items-center q-pb-none"><q-icon name="filter_alt" color="blue-grey-6" size="20px" class="q-mr-sm" /><span class="text-subtitle1 text-weight-bold">Filtrer les messages comme celui-ci</span></q-card-section>
<q-card-section>
<div class="text-caption text-grey-7 q-mb-sm">Les messages correspondants (futurs <b>et déjà reçus</b>) seront <b>{{ filterDialog.action === 'mask' ? 'masqués de la boîte' : 'toujours affichés' }}</b>.</div>
<q-option-group :model-value="filterDialog.field" @update:model-value="onFilterField" inline dense color="indigo-6" :options="[{ label: 'Expéditeur', value: 'from' }, { label: 'Sujet', value: 'subject' }]" />
<q-option-group :model-value="filterDialog.field" @update:model-value="onFilterField" inline dense color="primary" :options="[{ label: 'Expéditeur', value: 'from' }, { label: 'Sujet', value: 'subject' }]" />
<q-input dense outlined v-model="filterDialog.contains" class="q-mt-sm" :label="filterDialog.field === 'from' ? 'Expéditeur contient' : 'Sujet contient'" hint="Courriel complet, domaine (ex. targo.ca) ou mot-clé" />
<q-option-group v-model="filterDialog.action" inline dense color="indigo-6" class="q-mt-md" :options="[{ label: 'Masquer', value: 'mask' }, { label: 'Toujours afficher', value: 'allow' }]" />
<q-option-group v-model="filterDialog.action" inline dense color="primary" class="q-mt-md" :options="[{ label: 'Masquer', value: 'mask' }, { label: 'Toujours afficher', value: 'allow' }]" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps label="Annuler" v-close-popup />
<q-btn unelevated no-caps color="indigo-6" icon="check" label="Créer la règle" @click="doSaveFilter" />
<q-btn unelevated no-caps color="primary" icon="check" label="Créer la règle" @click="doSaveFilter" />
</q-card-actions>
</q-card>
</q-dialog>
@ -112,17 +172,22 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useQuasar } from 'quasar'
import { useConversations } from 'src/composables/useConversations'
import { useUserPrefs } from 'src/composables/useUserPrefs'
import { usePermissions } from 'src/composables/usePermissions'
import { HUB_URL } from 'src/config/hub'
import { useAuthStore } from 'src/stores/auth'
import { relTime } from 'src/composables/useFormatters'
import { channelMeta, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay'
import { useRouter } from 'vue-router'
import ReaderStack from './ReaderStack.vue'
const $q = useQuasar()
const router = useRouter()
const { discussions, QUEUES, fetchList, pollNow, loading, openDiscussion, panelOpen, newDialogOpen, newDialogChannel, agentTyping, sharedDraft, bulkDelete, bulkArchive, createTicket, archiveDiscussion, notifyEnabled, requestNotifyPermission, fetchInboxRules, saveInboxRules } = useConversations()
const { discussions, tickets, fetchTickets, QUEUES, fetchList, pollNow, loading, openDiscussion, panelOpen, newDialogOpen, newDialogChannel, agentTyping, sharedDraft, bulkDelete, bulkArchive, createTicket, archiveDiscussion, notifyEnabled, requestNotifyPermission, fetchInboxRules, saveInboxRules, searchConversations, setPriority } = useConversations()
async function setRowPriority (d, p) { try { await setPriority(d.token, p); d.priority = p } catch (e) { /* */ } }
// 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 } }
@ -161,6 +226,20 @@ function agentName (a) { return String(a || '').split('@')[0].replace(/[._-]+/g,
const sel = ref(new Set()) // filtres cumulables (départements OU entre eux ; unread/mine = ET)
const search = ref('')
// Recherche FLOUE omnichannel (SQL pg_trgm côté hub) : nom/courriel/sujet + corps des messages, sur TOUTES les convs.
const searchResults = ref(null) // null = pas de recherche active liste normale ; tableau = résultats SQL
const searchBusy = ref(false)
let _searchSeq = 0
watch(search, async (v) => { // l'input est déjà debounce 200ms
const q = String(v || '').trim()
if (q.length < 2) { searchResults.value = null; searchBusy.value = false; return }
const seq = ++_searchSeq
searchBusy.value = true
const r = await searchConversations(q)
if (seq !== _searchSeq) return // frappe plus récente résultat périmé ignoré
searchResults.value = r; searchBusy.value = false
})
function openSearchResult (r) { if (r && r.token) router.push('/communications/c/' + r.token) } // peut ouvrir une conv archivée non chargée
// 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']
@ -168,12 +247,82 @@ const chanOptions = [
{ value: 'all', label: 'Tous', icon: 'all_inbox' },
{ value: 'email', label: 'Courriel', icon: 'mail' },
{ value: 'chat', label: 'Clavardage', icon: 'forum' },
{ value: 'tickets', label: 'Tickets', icon: 'confirmation_number' },
]
const ticketMode = computed(() => chanView.value === 'tickets')
// 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)
// Vue TICKETS dans la Boîte (ERPNext Issues) affichée seulement en mode « Tickets », avec critères de filtre OBLIGATOIRES (statut + assignés à moi + recherche) pour ne pas noyer la file
const ticketStatus = ref('open') // 'open' | 'replied' | 'all'
const ticketMine = ref(false)
const TStatusOpts = [{ value: 'open', label: 'Ouverts' }, { value: 'replied', label: 'Répondus' }, { value: 'all', label: 'Tous' }]
const ticketMineCount = computed(() => { const me = String(meEmail.value || '').toLowerCase(); return (tickets.value || []).filter(t => (t.assignees || []).map(x => String(x).toLowerCase()).includes(me)).length })
const shownTickets = computed(() => {
const q = search.value.trim().toLowerCase()
const me = String(meEmail.value || '').toLowerCase()
return (tickets.value || []).filter(t => {
if (ticketStatus.value === 'open' && t.status !== 'Open') return false
if (ticketStatus.value === 'replied' && t.status !== 'Replied') return false
if (ticketMine.value && !(t.assignees || []).map(x => String(x).toLowerCase()).includes(me)) return false
if (q && ![t.name, t.subject, t.customer].some(x => String(x || '').toLowerCase().includes(q))) return false
return true
}) // déjà trié « modified desc » par le hub
})
function openTicket (t) {
if (t.convToken) { const d = (discussions.value || []).find(x => (x.conversations || []).some(c => c.token === t.convToken) || x.token === t.convToken); if (d) { open(d); return } router.push('/communications/c/' + t.convToken); return }
if (t.customer) { router.push('/clients/' + encodeURIComponent(t.customer)); return }
$q.notify({ type: 'info', message: t.name + (t.subject ? ' — ' + t.subject : '') })
}
watch(() => chanView.value === 'tickets', (on) => { if (on) fetchTickets() }) // (re)charge les tickets en entrant dans la vue
// MOTEUR DE PRÉFÉRENCES par utilisateur (serveur, suit l'usager entre appareils + lisible par l'admin) : vue/filtres/densité de la Boîte persistés
const impersonating = ref('') // PHASE 3 courriel de l'utilisateur dont on regarde la Boîte ('' = soi-même). Déclaré AVANT les watch (le watch immédiat le lit éviter le TDZ).
const { prefs: inboxPrefs, save: saveInbox } = useUserPrefs('inbox', { chanView: 'all', filters: [], compact: false, ticketStatus: 'open', ticketMine: false })
let _lastSaved = ''
function _prefSig () { return JSON.stringify({ chanView: chanView.value, filters: [...sel.value].sort(), compact: compact.value, ticketStatus: ticketStatus.value, ticketMine: ticketMine.value }) }
function applyInboxPrefs (p) { // applique un jeu de préférences à l'état local (les miennes OU celles d'un autre en mode « voir comme »)
if (!p) return
chanView.value = p.chanView || 'all'
sel.value = new Set(Array.isArray(p.filters) ? p.filters : [])
compact.value = !!p.compact
ticketStatus.value = p.ticketStatus || 'open'
ticketMine.value = !!p.ticketMine
}
watch(inboxPrefs, (p) => { // localStorage (instantané) puis serveur applique à l'état local
if (impersonating.value) return // en mode « voir comme » : ne PAS écraser la vue avec mes propres prefs
applyInboxPrefs(p)
_lastSaved = _prefSig() // évite de re-sauver ce qu'on vient d'appliquer (anti-écho)
}, { immediate: true, deep: true })
watch([chanView, sel, compact, ticketStatus, ticketMine], () => {
if (impersonating.value) return // LECTURE SEULE pendant l'impersonation : on n'enregistre rien
const sig = _prefSig(); if (sig === _lastSaved) return
_lastSaved = sig
saveInbox({ chanView: chanView.value, filters: [...sel.value], compact: compact.value, ticketStatus: ticketStatus.value, ticketMine: ticketMine.value })
}, { deep: true })
// PHASE 3 ADMIN : « Voir comme » un autre utilisateur (lecture seule). Le serveur (/prefs?as=) n'autorise QUE les admins (x-authentik-groups) ; sinon il renvoie nos propres prefs (repli sûr).
const { isAdmin } = usePermissions()
const agentList = ref([])
async function openViewAs () { if (!agentList.value.length) agentList.value = await fetchAgents() }
async function viewAs (email) {
try {
const r = await fetch(`${HUB_URL}/prefs?as=${encodeURIComponent(email)}`).then(x => x.ok ? x.json() : null)
if (!r) throw new Error('chargement échoué')
impersonating.value = email // active la lecture seule AVANT d'appliquer (bloque la sauvegarde)
applyInboxPrefs((r.prefs && r.prefs.inbox) || {})
if (chanView.value === 'tickets') fetchTickets()
$q.notify({ type: 'info', icon: 'visibility', message: 'Vue de ' + agentName(email) + ' — lecture seule' })
} catch (e) { impersonating.value = ''; $q.notify({ type: 'negative', message: 'Voir comme : ' + e.message }) }
}
async function exitViewAs () {
impersonating.value = ''
try { const r = await fetch(`${HUB_URL}/prefs`).then(x => x.ok ? x.json() : null); applyInboxPrefs((r && r.prefs && r.prefs.inbox) || {}); _lastSaved = _prefSig() } catch (e) { /* on garde l'état courant */ }
$q.notify({ type: 'positive', message: 'Retour à vos préférences' })
}
const batchBusy = ref(false)
const QLABELS = { Facturations: 'Facturation', 'Service à la clientèle': 'Service client', Supports: 'Support', Technicien: 'Technicien' }
@ -219,6 +368,8 @@ 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 prioN = visible.value.filter(d => d.priority === 'urgent' || d.priority === 'high').length
if (prioN) base.push({ key: 'prio', label: '🚩 Prioritaires', count: prioN })
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 })
@ -235,7 +386,7 @@ function toggle (k) {
const shown = computed(() => {
const q = search.value.trim().toLowerCase()
const depts = [...sel.value].filter(k => QUEUES.includes(k))
const needU = sel.value.has('unread'); const needM = sel.value.has('mine'); const needD = sel.value.has('draft')
const needU = sel.value.has('unread'); const needM = sel.value.has('mine'); const needD = sel.value.has('draft'); const needPrio = sel.value.has('prio')
return visible.value.filter(d => {
if (depts.length && !depts.includes(d.queue || '')) return false
const isChat = CHAT_CH.includes(d.channel || '')
@ -244,9 +395,11 @@ const shown = computed(() => {
if (needD && !draftAgentOf(d)) return false
if (needU && !isUnread(d)) return false
if (needM && d.assignee !== meEmail.value) return false
if (needPrio && !(d.priority === 'urgent' || d.priority === 'high')) return false
if (q && !matchSearch(d, q)) return false
return true
}).sort((a, b) => String(lastTs(b) || '').localeCompare(String(lastTs(a) || '')))
// Tri : priorité d'abord (Urgent Haute Normale Basse aucune), puis récence dans chaque bande.
}).sort((a, b) => (priorityMeta(b.priority).rank - priorityMeta(a.priority).rank) || String(lastTs(b) || '').localeCompare(String(lastTs(a) || '')))
})
// Sélection par plage (style Gmail) : cocher une case, puis SHIFT+clic sur une
@ -301,7 +454,7 @@ async function batchArchive () {
// Actions au survol (style Gmail) : répondre / classer en ticket par département / archiver / supprimer
const TICKET_DEPTS = [
{ label: 'Support technique', cat: 'Support', icon: 'build', color: 'indigo-6' },
{ label: 'Support technique', cat: 'Support', icon: 'build', color: 'primary' },
{ label: 'Facturation', cat: 'Facturation', icon: 'receipt_long', color: 'green-7' },
{ label: 'Service client', cat: 'Service client', icon: 'support_agent', color: 'blue-6' },
{ label: 'Installation', cat: 'Installation', icon: 'cable', color: 'deep-orange-6' },
@ -322,6 +475,7 @@ function quickDelete (d) {
})
}
function open (d) { const tok = discToken(d); if (tok) router.push('/communications/c/' + tok); else { panelOpen.value = true; openDiscussion(d) } }
function openFiche (d) { if (d && d.customer) router.push('/clients/' + encodeURIComponent(d.customer)) } // lien direct liste fiche client OPS 360
onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList() })
</script>
@ -330,6 +484,8 @@ onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList
fenêtre flottante (plus de volet ancré à droite), donc la boîte occupe tout l'espace. */
.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-impersonate { background: #f4511e; color: #fff; font-size: 0.8rem; padding: 5px 12px; gap: 4px; }
.mlist-impersonate b { font-weight: 700; }
.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; }
@ -337,6 +493,11 @@ onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList
.mlist-tabs { display: flex; flex-wrap: nowrap; align-items: center; gap: 4px; padding: 6px 12px; border-bottom: 1px solid var(--ops-border, #e2e8f0); overflow-x: auto; min-height: 40px; }
.mlist-tabs::-webkit-scrollbar { height: 0; }
.mlist-tabs .q-chip { flex: 0 0 auto; }
/* Mobile : la barre d'outils s'empile — recherche pleine largeur sous Composer + canal (les chips défilent déjà). */
@media (max-width: 599px) {
.mlist-head { flex-wrap: wrap; padding: 8px 8px 4px; }
.mlist-search { flex: 1 1 100%; order: 10; max-width: none; }
}
.mlist-allcb { flex: 0 0 auto; margin-right: 4px; }
.mlist-cnt { margin-left: 5px; font-size: 0.66rem; opacity: 0.8; }
.mlist-batch { display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: #eef2ff; border-bottom: 1px solid #c7d2fe; font-size: 0.82rem; }
@ -350,6 +511,9 @@ onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList
.mrow:hover .mrow-actions { display: flex; }
.mrow-checked { background: #eef2ff; }
.mrow-cb { margin-top: 1px; flex: 0 0 auto; }
.mrow-flag { flex: 0 0 auto; transition: opacity .15s; }
.mrow-flag-none { opacity: .2; } /* aucune priorité → drapeau discret (ne pollue pas la liste) */
.mrow:hover .mrow-flag-none { opacity: .55; } /* au survol → visible pour classer en 1 clic */
.mrow-chan { margin-top: 2px; flex: 0 0 auto; }
.mrow-main { flex: 1; min-width: 0; }
.mrow-line1 { display: flex; align-items: center; gap: 6px; }

View File

@ -2,7 +2,7 @@
<q-dialog v-model="open">
<q-card style="min-width:460px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="confirmation_number" color="teal-7" size="22px" class="q-mr-sm" />
<q-icon name="confirmation_number" color="positive" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Nouveau ticket</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
@ -13,16 +13,16 @@
<q-input dense outlined v-model="title" type="textarea" autogrow :rows="2" autofocus
label="Décris le problème (ex. « Problème wifi 2338 rue Ste-Clotilde » ou un nom / téléphone)"
@update:model-value="onSearch">
<template #append><q-spinner v-if="searching" size="16px" color="teal-6" /></template>
<template #append><q-spinner v-if="searching" size="16px" color="positive" /></template>
</q-input>
<div v-if="results.length && !customer" class="nt-dd">
<div v-for="m in results" :key="m.name" class="nt-item" @click="pick(m)">
<q-icon :name="m.matched === 'adresse' ? 'place' : m.matched === 'téléphone' ? 'call' : 'person'" size="16px" color="teal-6" class="q-mr-sm" />
<q-icon :name="m.matched === 'adresse' ? 'place' : m.matched === 'téléphone' ? 'call' : 'person'" size="16px" color="positive" class="q-mr-sm" />
<div class="col ellipsis">
<div class="text-body2 ellipsis">{{ m.customer_name || m.name }}<q-badge v-if="m.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></div>
<div class="text-caption text-grey-6 ellipsis">{{ m.address || m.territory || m.name }}</div>
</div>
<q-badge color="teal-1" text-color="teal-8" :label="m.matched" />
<q-badge color="green-1" text-color="green-9" :label="m.matched" />
</div>
</div>
</div>
@ -36,9 +36,9 @@
<!-- Adresse de service (compte multi-adresses) on lie la BONNE, pas la facturation -->
<q-select v-if="serviceLocs.length > 1" dense outlined v-model="serviceLoc" :options="serviceLocs" option-label="address" option-value="name" emit-value map-options clearable
label="Adresse de service" class="q-mt-xs">
<template #prepend><q-icon name="place" color="orange-6" /></template>
<template #prepend><q-icon name="place" color="warning" /></template>
</q-select>
<div v-else-if="serviceLoc && serviceLocs.length === 1" class="text-caption text-grey-7 q-mt-xs"><q-icon name="place" size="13px" color="orange-6" /> {{ serviceLocs[0].address }}</div>
<div v-else-if="serviceLoc && serviceLocs.length === 1" class="text-caption text-grey-7 q-mt-xs"><q-icon name="place" size="13px" color="warning" /> {{ serviceLocs[0].address }}</div>
</div>
<div v-else class="text-caption text-grey-5"><q-icon name="info" size="14px" /> Tape un nom, une adresse ou un numéro l'autosuggest lie le bon compte (optionnel).</div>
@ -55,14 +55,14 @@
</div>
<div class="text-caption text-grey-6" v-if="queueFor(category)"> routé à l'équipe <b>{{ queueFor(category) }}</b></div>
<div class="row q-col-gutter-sm items-center">
<q-btn-toggle class="col-auto" v-model="status" dense unelevated size="sm" toggle-color="indigo-6" color="grey-3" text-color="grey-8" no-caps :options="[{ label: 'Ouvert', value: 'Open' }, { label: 'En attente', value: 'On Hold' }]" />
<q-btn-toggle class="col-auto" v-model="status" dense unelevated size="sm" toggle-color="primary" color="grey-3" text-color="grey-8" no-caps :options="[{ label: 'Ouvert', value: 'Open' }, { label: 'En attente', value: 'On Hold' }]" />
<q-input class="col" dense outlined v-model="dueDate" type="date" stack-label :label="status === 'On Hold' ? 'Rappel — réactiver à cette date' : 'Échéance (optionnel)'" />
</div>
<div v-if="status === 'On Hold'" class="text-caption text-amber-9"><q-icon name="pause_circle" size="13px" /> Suspendu (ex. en attente du paiement) réapparaît à la date de rappel.</div>
<div v-if="status === 'On Hold'" class="text-caption text-warning"><q-icon name="pause_circle" size="13px" /> Suspendu (ex. en attente du paiement) réapparaît à la date de rappel.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="teal-7" icon="confirmation_number" label="Créer le ticket" :disable="!title.trim()" :loading="busy" @click="submit" />
<q-btn unelevated color="positive" icon="confirmation_number" label="Créer le ticket" :disable="!title.trim()" :loading="busy" @click="submit" />
</q-card-actions>
</q-card>
</q-dialog>

View File

@ -5,10 +5,10 @@
<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" />
<q-icon name="notifications" color="primary" 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 flat dense round size="sm" icon="tune" :color="showPrefs ? 'primary' : '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>
@ -18,7 +18,17 @@
<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" />
<q-toggle :model-value="prefs[f.key]" @update:model-value="v => setFeed(f.key, v)" dense color="primary" />
</div>
<!-- Mes FILES (self-service) : m'abonner + (dés)activer la notif par file pilote qui SONNE sur appel entrant + le screen-pop. -->
<div v-if="(myQueues.queues || []).length" class="q-mt-sm q-pt-sm" style="border-top:1px solid #eef1f6">
<div class="text-caption text-grey-6 q-mb-xs">Mes files (appels + conversations)&nbsp;:</div>
<div v-for="q in myQueues.queues" :key="q" class="row items-center no-wrap q-py-xs">
<q-icon name="groups" :color="myQueues.mine.includes(q) ? 'primary' : 'grey-5'" size="18px" class="q-mr-sm" />
<span class="text-body2 col" :class="{ 'text-grey-5': !myQueues.mine.includes(q) }">{{ (myQueues.labels && myQueues.labels[q]) || q }}</span>
<q-toggle :model-value="myQueues.mine.includes(q)" @update:model-value="v => setQueueSub(q, v)" dense color="primary"><q-tooltip>M'abonner à cette file</q-tooltip></q-toggle>
<q-btn flat dense round size="sm" :icon="myQueues.mine.includes(q) && (myQueues.notify[q] !== false) ? 'notifications_active' : 'notifications_off'" :color="myQueues.mine.includes(q) && (myQueues.notify[q] !== false) ? 'green-7' : 'grey-5'" :disable="!myQueues.mine.includes(q)" class="q-ml-xs" @click="setQueueNotify(q, myQueues.notify[q] === false)"><q-tooltip>{{ myQueues.notify[q] === false ? 'Activer la notif de cette file' : 'Couper la notif de cette file' }}</q-tooltip></q-btn>
</div>
</div>
<div class="text-caption text-grey-5 q-mt-xs">Préférences propres à ton compte.</div>
</div>
@ -26,7 +36,7 @@
<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-icon :name="n.icon || (n.type === 'comment' ? 'rate_review' : 'star')" :color="n.low ? 'orange-8' : (n.type === 'comment' ? 'primary' : (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>
@ -47,7 +57,7 @@ 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 { notifications, unread, prefs, FEEDS, myQueues, setQueueSub, setQueueNotify, 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() }

View File

@ -26,7 +26,7 @@
<div v-if="diagnoses.length" class="q-mt-md">
<div v-for="(a, i) in diagnoses" :key="'d' + i" class="orch-diag">
<div class="row items-center q-mb-xs">
<q-icon name="network_check" color="indigo-6" size="18px" class="q-mr-sm" />
<q-icon name="network_check" color="primary" size="18px" class="q-mr-sm" />
<span class="text-weight-medium">Vérification</span>
<span class="text-caption text-grey-6 q-ml-sm">« {{ a.customer_query }} »</span>
</div>
@ -43,11 +43,11 @@
<div class="text-caption text-grey-6">{{ a.diagnostic.summary.detail }}<span v-if="a.diagnostic.summary.signal"> · {{ a.diagnostic.summary.signal }}</span></div>
</div>
</div>
<div v-if="a.diagnostic.locations && a.diagnostic.locations.length > 1" class="text-caption text-orange-8 q-mt-xs"><q-icon name="place" size="13px" /> {{ a.diagnostic.locations.length }} adresses affichage de la principale</div>
<div v-if="a.diagnostic.locations && a.diagnostic.locations.length > 1" class="text-caption text-warning q-mt-xs"><q-icon name="place" size="13px" /> {{ a.diagnostic.locations.length }} adresses affichage de la principale</div>
</template>
<!-- plusieurs clients préciser l'adresse -->
<template v-else-if="a.diagnostic && a.diagnostic.ambiguous">
<div class="text-caption text-orange-8 q-mb-xs">Plusieurs clients précise l'adresse :</div>
<div class="text-caption text-warning q-mb-xs">Plusieurs clients précise l'adresse :</div>
<q-list dense bordered class="rounded-borders">
<q-item clickable v-ripple v-for="(m, j) in a.diagnostic.matches" :key="j" @click="pickDiag(a, m)">
<q-item-section><q-item-label>{{ m.customer_name }}</q-item-label><q-item-label caption>{{ m.address || m.phone || m.email }}</q-item-label></q-item-section>
@ -55,7 +55,7 @@
</q-item>
</q-list>
</template>
<div v-else-if="a.diagnostic && !a.diagnostic.found" class="text-caption text-red-7">Client introuvable pour « {{ a.diagnostic.query || a.customer_query }} »</div>
<div v-else-if="a.diagnostic && !a.diagnostic.found" class="text-caption text-negative">Client introuvable pour « {{ a.diagnostic.query || a.customer_query }} »</div>
<div v-else class="text-caption text-grey-6"><q-spinner size="14px" /> diagnostic</div>
</div>
</div>
@ -67,7 +67,7 @@
<div class="row items-center no-wrap q-mb-xs">
<q-icon :name="iconOf(a.type)" :color="colorOf(a.type)" size="18px" class="q-mr-sm" />
<span class="text-weight-medium">{{ labelOf(a.type) }}</span>
<q-badge v-if="a.type === 'create_ticket' && a.status === 'On Hold'" color="amber-3" text-color="amber-9" label="En attente" class="q-ml-sm" />
<q-badge v-if="a.type === 'create_ticket' && a.status === 'On Hold'" color="amber-1" text-color="amber-9" label="En attente" class="q-ml-sm" />
<q-badge v-if="a.category" :label="a.category" color="grey-3" text-color="grey-8" class="q-ml-xs" />
<q-space />
<q-btn flat round dense size="xs" icon="delete" color="grey-5" @click="actions.splice(actions.indexOf(a), 1)" />
@ -83,16 +83,16 @@
</q-item-section></q-item>
</template>
</q-select>
<div v-if="a.type === 'send_sms' && a.resolved && !a.resolved.can_sms" class="text-caption text-orange-8"><q-icon name="warning" size="13px" /> pas de mobile SMS impossible</div>
<div v-if="a.type === 'send_sms' && a.resolved && !a.resolved.can_sms" class="text-caption text-warning"><q-icon name="warning" size="13px" /> pas de mobile SMS impossible</div>
</div>
<div v-else-if="a.phone" class="text-caption text-grey-7 q-mb-xs"><q-icon name="call" size="13px" /> {{ a.phone }}</div>
<div v-else-if="a.customer_query || a.to_query" class="text-caption text-orange-8 q-mb-xs"><q-icon name="info" size="13px" /> « {{ a.customer_query || a.to_query }} » non résolu</div>
<div v-else-if="a.customer_query || a.to_query" class="text-caption text-warning q-mb-xs"><q-icon name="info" size="13px" /> « {{ a.customer_query || a.to_query }} » non résolu</div>
<!-- Adresse de service liée (compte multi-adresses on choisit la bonne, pas la facturation) -->
<div v-if="a.type === 'create_ticket' && a.resolved" class="q-mb-xs">
<q-select v-if="a._locs && a._locs.length > 1" dense outlined v-model="a.service_location" :options="a._locs" option-label="address" option-value="name" emit-value map-options clearable label="Adresse de service">
<template #prepend><q-icon name="place" color="orange-6" /></template>
<template #prepend><q-icon name="place" color="warning" /></template>
</q-select>
<div v-else-if="a.resolved.address || a.service_location" class="text-caption text-grey-7"><q-icon name="place" size="13px" color="orange-6" /> {{ a.resolved.address || locLabel(a) }}</div>
<div v-else-if="a.resolved.address || a.service_location" class="text-caption text-grey-7"><q-icon name="place" size="13px" color="warning" /> {{ a.resolved.address || locLabel(a) }}</div>
</div>
<!-- contenu éditable -->
<q-input v-if="a.type === 'create_ticket'" v-model="a.subject" dense outlined label="Sujet" />
@ -104,7 +104,7 @@
<!-- Résultats -->
<div v-if="results.length" class="q-mt-md">
<div v-for="(r, i) in results" :key="i" class="text-body2" :class="r.ok ? 'text-green-8' : 'text-red-7'">
<div v-for="(r, i) in results" :key="i" class="text-body2" :class="r.ok ? 'text-green-8' : 'text-negative'">
<q-icon :name="r.ok ? 'check_circle' : 'error'" size="15px" /> {{ summaryOf(r) }}
</div>
</div>

View File

@ -1,7 +1,7 @@
<template>
<div v-if="alerts.length" class="outage-alerts-panel">
<div class="outage-alerts-header" @click="expanded = !expanded">
<q-icon name="warning_amber" color="orange-8" size="18px" />
<q-icon name="warning_amber" color="warning" size="18px" />
<span class="text-weight-bold">{{ alerts.length }} alerte{{ alerts.length > 1 ? 's' : '' }} réseau</span>
<q-icon :name="expanded ? 'expand_less' : 'expand_more'" size="18px" class="q-ml-auto" />
</div>

View File

@ -3,18 +3,18 @@
<transition name="ob-slide">
<div v-if="pending.length" class="outbox-panel">
<div class="ob-head row items-center no-wrap">
<q-icon name="schedule_send" color="amber-9" size="20px" class="q-mr-xs" />
<q-icon name="schedule_send" color="warning" size="20px" class="q-mr-xs" />
<div class="text-weight-bold">{{ pending.length }} courriel{{ pending.length > 1 ? 's' : '' }} en attente d'envoi</div>
<q-space />
<q-btn dense flat no-caps size="sm" color="red-7" icon="block" label="Tout annuler" @click="cancelAll" />
<q-btn dense flat no-caps size="sm" color="negative" icon="block" label="Tout annuler" @click="cancelAll" />
</div>
<div v-for="it in pending" :key="it.id" class="ob-item row items-center no-wrap"
:class="{ 'ob-failed': it.status === 'failed' }">
<q-circular-progress v-if="it.status !== 'failed'" :value="pct(it)" size="38px" :thickness="0.18"
color="amber-8" track-color="amber-2" show-value class="q-mr-sm">
color="warning" track-color="warning" show-value class="q-mr-sm">
<span class="text-caption text-weight-bold">{{ secLeft(it) }}</span>
</q-circular-progress>
<q-icon v-else name="error" color="red-7" size="30px" class="q-mr-sm" />
<q-icon v-else name="error" color="negative" size="30px" class="q-mr-sm" />
<div class="col" style="min-width:0">
<div class="text-caption text-grey-7 ellipsis">
<q-badge :color="kindColor(it.kind)" text-color="white" :label="it.label || it.kind" class="q-mr-xs" />
@ -22,7 +22,7 @@
</div>
<div class="text-body2 ellipsis" :title="it.subject">{{ it.subject || '(sans objet)' }}</div>
<div class="text-caption text-grey-6 ellipsis" :title="it.recipients.join(', ')">{{ it.recipients.join(', ') }}</div>
<div v-if="it.status === 'failed'" class="text-caption text-red-7 ellipsis">Échec : {{ it.error }}</div>
<div v-if="it.status === 'failed'" class="text-caption text-negative ellipsis">Échec : {{ it.error }}</div>
</div>
<q-btn dense flat round icon="close" color="grey-7" @click="cancel(it.id)"><q-tooltip>Annuler (ne pas envoyer)</q-tooltip></q-btn>
<q-btn dense flat round :icon="it.status === 'failed' ? 'refresh' : 'send'" color="green-7" @click="sendNow(it.id)">
@ -53,7 +53,7 @@ async function refresh () {
}
function secLeft (it) { return Math.max(0, Math.ceil((it.sendAt - now.value) / 1000)) }
function pct (it) { const total = holdSec.value * 1000; const left = Math.max(0, it.sendAt - now.value); return Math.max(0, Math.min(100, (left / total) * 100)) }
function kindColor (k) { return k === 'queue-notif' ? 'indigo-5' : k === 'ticket-notif' ? 'teal-6' : 'blue-grey-5' }
function kindColor (k) { return k === 'queue-notif' ? 'green-5' : k === 'ticket-notif' ? 'teal-6' : 'blue-grey-5' }
async function cancel (id) {
pending.value = pending.value.filter(x => x.id !== id) // optimiste

View File

@ -0,0 +1,37 @@
<template>
<!-- En-tête de page unifié : bouton retour optionnel + titre (+ compteur) + actions à droite + barre de filtres.
Usage : <PageHeader title="Clients" :count="total"><template #actions></template><template #filters></template></PageHeader> -->
<div class="page-header">
<div class="ph-title-row">
<q-btn v-if="back" flat dense round icon="arrow_back" :to="back" class="ph-back"><q-tooltip>Retour</q-tooltip></q-btn>
<h1 class="ph-title">
{{ title }}
<span v-if="count !== null && count !== undefined" class="ph-count">{{ count }}</span>
</h1>
<q-space />
<div v-if="$slots.actions" class="ph-actions"><slot name="actions" /></div>
</div>
<div v-if="$slots.filters" class="ph-filters"><slot name="filters" /></div>
<div v-if="$slots.default" class="ph-sub"><slot /></div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, default: '' },
count: { type: [Number, String], default: null },
back: { type: [String, Object], default: null },
})
</script>
<style scoped>
.page-header { margin-bottom: 16px; }
.ph-title-row { display: flex; align-items: center; gap: 8px; min-height: 34px; }
.ph-back { margin-left: -6px; }
.ph-title { font-size: 1.4rem; font-weight: 720; letter-spacing: -0.01em; margin: 0; display: flex; align-items: center; gap: 10px; text-wrap: balance; }
.ph-count { font-size: 0.78rem; font-weight: 600; color: var(--ops-text-muted); background: var(--ops-bg-light); border: 1px solid var(--ops-border); border-radius: 20px; padding: 2px 10px; font-variant-numeric: tabular-nums; }
.ph-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.ph-filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; margin-top: 12px; }
.ph-sub { margin-top: 8px; }
@media (max-width: 599px) { .ph-title { font-size: 1.2rem; } }
</style>

View File

@ -4,20 +4,25 @@
<div v-if="!embedded" class="text-h6 text-weight-bold">Pipeline de ventes</div>
<div v-else class="text-subtitle2 text-weight-bold text-grey-8">Leads</div>
<q-space />
<div class="text-caption text-grey-7 q-mr-md">{{ totalLeads }} lead(s) · {{ openLeads }} en cours</div>
<div class="text-caption text-grey-7 q-mr-md">{{ totalLeads }} lead(s) · {{ openLeads }} en cours<span v-if="pipelineValue > 0"> · <b class="text-primary">{{ formatMoney(pipelineValue) }}</b> en pipeline</span></div>
<q-btn flat dense round icon="refresh" :loading="loading" @click="load" />
</div>
<div class="pipe-board">
<div v-for="st in stages" :key="st" class="pipe-col" :class="{ 'drop-hover': dropStage === st }"
@dragover.prevent="dropStage = st" @dragleave="dropStage === st && (dropStage = null)" @drop="onDrop(st)">
<div v-for="st in stages" :key="st" class="pipe-col">
<div class="pipe-col-hd" :class="stageClass(st)">
<span>{{ st }}</span>
<q-badge color="white" text-color="grey-9" class="q-ml-xs">{{ (columns[st] || []).length }}</q-badge>
<q-space />
<span v-if="colTotal(st) > 0" style="font-size:.72rem;opacity:.92;font-weight:600">{{ formatMoney(colTotal(st)) }}</span>
</div>
<div class="pipe-col-body">
<div v-for="c in (columns[st] || [])" :key="c.token" class="pipe-card" draggable="true"
@dragstart="onDragStart(c)" @dragend="dragCard = null" @click="openLead(c)">
<!-- vuedraggable (SortableJS) : glisser-déposer qui marche AU DOIGT appui long 160ms sur tactile
(delay-on-touch-only le défilement reste fluide), instantané à la souris sur desktop. -->
<draggable :list="columns[st]" group="pipeline" item-key="token" class="pipe-col-body"
:animation="150" :delay="160" :delay-on-touch-only="true" :touch-start-threshold="6" ghost-class="pipe-ghost"
@change="(e) => onDragChange(e, st)">
<template #item="{ element: c }">
<div class="pipe-card" @click="openLead(c)">
<div class="pipe-card-t">
<q-icon :name="c.channel === 'email' ? 'mail' : 'sms'" size="13px" class="q-mr-xs" :color="c.channel === 'email' ? 'red-5' : 'teal-6'" />
{{ c.customerName || c.contact || 'Lead' }}
@ -25,13 +30,17 @@
</div>
<div class="pipe-card-s ellipsis">{{ c.subject || '—' }}</div>
<div class="pipe-card-m">
<span v-if="c.contact" class="text-grey-6 ellipsis" style="max-width:130px">{{ c.contact }}</span>
<span v-if="c.value > 0" class="pipe-val">{{ formatMoney(c.value) }}</span>
<span v-else-if="c.contact" class="text-grey-6 ellipsis" style="max-width:130px">{{ c.contact }}</span>
<q-space />
<span v-if="c.assignee" class="pipe-assignee"><q-icon name="pan_tool" size="11px" /> {{ shortAgent(c.assignee) }}</span>
</div>
</div>
<div v-if="!(columns[st] || []).length" class="pipe-empty"></div>
</div>
</template>
<template #footer>
<div v-if="!(columns[st] || []).length" class="pipe-empty">Glissez un lead ici</div>
</template>
</draggable>
</div>
</div>
</div>
@ -40,8 +49,9 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import draggable from 'vuedraggable'
import { useConversations } from 'src/composables/useConversations'
import { shortAgent } from 'src/composables/useFormatters'
import { shortAgent, formatMoney } from 'src/composables/useFormatters'
defineProps({ embedded: Boolean })
@ -51,29 +61,32 @@ const { pipelineBoard, setPipeline, fetchList, discussions, openDiscussion, pane
const stages = ref(['Nouveau', 'Qualifié', 'Devis', 'Gagné', 'Perdu'])
const columns = ref({})
const loading = ref(false)
const dragCard = ref(null)
const dropStage = ref(null)
const totalLeads = computed(() => Object.values(columns.value).reduce((s, a) => s + a.length, 0))
const openLeads = computed(() => ['Nouveau', 'Qualifié', 'Devis'].reduce((s, st) => s + ((columns.value[st] || []).length), 0))
function colTotal (st) { return (columns.value[st] || []).reduce((s, c) => s + (c.value || 0), 0) }
const pipelineValue = computed(() => ['Nouveau', 'Qualifié', 'Devis'].reduce((s, st) => s + colTotal(st), 0)) // valeur ouverte (hors Gagné/Perdu)
// shortAgent vient de useFormatters (consolidation)
function stageClass (st) { return 'st-' + st.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() }
async function load () {
loading.value = true
try { const b = await pipelineBoard(); if (b.stages && b.stages.length) stages.value = b.stages; columns.value = b.columns || {} } catch (e) { /* */ } finally { loading.value = false }
try {
const b = await pipelineBoard()
if (b.stages && b.stages.length) stages.value = b.stages
const cols = b.columns || {}
for (const st of stages.value) if (!Array.isArray(cols[st])) cols[st] = [] // chaque étape = vrai tableau (requis par vuedraggable :list)
columns.value = cols
} catch (e) { /* */ } finally { loading.value = false }
}
function onDragStart (c) { dragCard.value = c }
async function onDrop (stage) {
dropStage.value = null
const c = dragCard.value; dragCard.value = null
// vuedraggable a DÉJÀ déplacé la carte entre les colonnes (optimiste) ; on persiste seulement à l'arrivée (added).
async function onDragChange (evt, stage) {
const c = evt && evt.added && evt.added.element
if (!c || !stage) return
// retire de l'ancienne colonne, ajoute à la nouvelle (optimiste)
for (const st in columns.value) columns.value[st] = columns.value[st].filter(x => x.token !== c.token)
columns.value[stage] = [{ ...c }, ...(columns.value[stage] || [])]
try { await setPipeline(c.token, stage); $q.notify({ type: 'positive', message: `${c.customerName || 'Lead'}${stage}`, timeout: 1500 }) } catch (e) { $q.notify({ type: 'negative', message: 'Échec déplacement' }); load() }
try { await setPipeline(c.token, stage); $q.notify({ type: 'positive', message: `${c.customerName || 'Lead'}${stage}`, timeout: 1500 }) }
catch (e) { $q.notify({ type: 'negative', message: 'Échec déplacement' }); load() }
}
async function openLead (c) {
@ -97,10 +110,12 @@ onMounted(load)
.pipe-col-body { padding: 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; }
.pipe-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.pipe-card:hover { border-color: #6366f1; }
.pipe-ghost { opacity: .55; background: #eef2ff; border: 1px dashed #6366f1; }
.pipe-card-t { font-weight: 600; font-size: .82rem; display: flex; align-items: center; }
.pipe-card-s { font-size: .75rem; color: #475569; margin-top: 2px; }
.pipe-card-m { display: flex; align-items: center; font-size: .68rem; margin-top: 5px; }
.pipe-assignee { color: #b45309; font-weight: 600; }
.pipe-val { color: #4f46e5; font-weight: 700; }
.pipe-empty { text-align: center; color: #cbd5e1; padding: 10px; font-size: .8rem; }
@media (max-width: 640px) {
.pipe-board { scroll-snap-type: x mandatory; }

View File

@ -33,7 +33,7 @@
<div class="quick-sale-title">Vente rapide &mdash; sans installation</div>
<div class="quick-sale-desc">Ajout d'équipement (ex&nbsp;: routeur, amplificateur WiFi) ou d'un service mensuel sur une adresse existante. Aucune étape d'installation.</div>
</div>
<q-icon name="arrow_forward" size="20px" color="indigo-6" />
<q-icon name="arrow_forward" size="20px" color="primary" />
</div>
<div class="row items-center q-mt-md q-mb-xs">
@ -46,7 +46,7 @@
<div class="template-grid">
<div v-for="tpl in templates" :key="tpl.id" class="template-card"
:class="{ selected: selectedTemplate?.id === tpl.id }" @click="selectTemplate(tpl)">
<q-icon :name="tpl.icon" size="28px" :color="selectedTemplate?.id === tpl.id ? 'indigo-6' : 'grey-6'" />
<q-icon :name="tpl.icon" size="28px" :color="selectedTemplate?.id === tpl.id ? 'primary' : 'grey-6'" />
<div class="template-card-name">{{ tpl.name }}</div>
<div class="template-card-desc">{{ tpl.description }}</div>
<q-badge :label="tpl.steps.length + ' étapes'" color="grey-3" text-color="grey-8" class="q-mt-xs" />
@ -62,9 +62,9 @@
<q-card-section v-if="currentStep === 1" class="col q-pt-md" style="overflow-y:auto;min-height:300px;max-height:60vh">
<div v-if="isQuickSale" class="quick-sale-notice">
<q-icon name="info" size="18px" color="indigo-6" class="q-mr-sm" />
<q-icon name="info" size="18px" color="primary" class="q-mr-sm" />
<div class="col">
<div class="text-weight-bold text-indigo-8" style="font-size:0.85rem">Mode Vente rapide &mdash; aucune étape requise</div>
<div class="text-weight-bold text-green-8" style="font-size:0.85rem">Mode Vente rapide &mdash; aucune étape requise</div>
<div class="text-caption text-grey-7">Appuyez sur «&nbsp;Suivant&nbsp;» pour aller directement au catalogue.</div>
</div>
</div>
@ -84,7 +84,7 @@
<div class="step-num">{{ i + 1 }}</div>
<q-input v-model="step.subject" dense outlined class="col q-ml-sm" placeholder="Sujet de l'étape"
:input-style="{ fontSize: '0.85rem', fontWeight: '600' }" />
<q-btn v-if="wizardSteps.length > 1" flat round dense icon="delete" size="sm" color="red-5" class="q-ml-xs"
<q-btn v-if="wizardSteps.length > 1" flat round dense icon="delete" size="sm" color="negative" class="q-ml-xs"
@click="removeStep(i)" />
</div>
<div class="row q-col-gutter-sm q-mb-xs">
@ -129,21 +129,21 @@
</div>
</q-expansion-item>
<div v-if="step.depends_on_step != null" class="step-dep-indicator">
<q-icon name="subdirectory_arrow_right" size="14px" color="orange-7" />
<span class="text-caption text-orange-8">Après: étape {{ step.depends_on_step + 1 }}</span>
<q-icon name="subdirectory_arrow_right" size="14px" color="warning" />
<span class="text-caption text-warning">Après: étape {{ step.depends_on_step + 1 }}</span>
</div>
</div>
</div>
<q-btn flat dense icon="add" label="Ajouter une étape" color="indigo-6" no-caps class="q-mt-sm"
<q-btn flat dense icon="add" label="Ajouter une étape" color="primary" no-caps class="q-mt-sm"
@click="addStep" />
</q-card-section>
<q-card-section v-if="currentStep === 2" class="col q-pt-md cart-scroll-host" style="overflow-y:auto;min-height:300px;max-height:60vh">
<div class="row q-mb-sm q-gutter-sm items-center">
<q-btn-toggle v-model="orderMode" no-caps dense unelevated toggle-color="indigo-6" color="grey-3" text-color="grey-8"
<q-btn-toggle v-model="orderMode" no-caps dense unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
:options="ORDER_MODE_OPTIONS" />
<q-space />
<q-btn unelevated icon="inventory_2" label="Catalogue" color="indigo-6" no-caps size="sm" class="catalog-open-btn"
<q-btn unelevated icon="inventory_2" label="Catalogue" color="primary" no-caps size="sm" class="catalog-open-btn"
@click="openCatalog()" />
</div>
@ -155,8 +155,8 @@
@click="goToSommaire">
<div class="cart-pill-left">
<q-icon name="shopping_cart" size="22px"
:color="orderItems.length ? 'indigo-6' : 'grey-5'" />
<q-badge v-if="orderItems.length" floating color="deep-orange-6" text-color="white"
:color="orderItems.length ? 'primary' : 'grey-5'" />
<q-badge v-if="orderItems.length" floating color="warning" text-color="white"
class="cart-pill-badge">{{ orderItems.length }}</q-badge>
</div>
<div class="cart-pill-body">
@ -168,7 +168,7 @@
</div>
<div class="cart-pill-sub">
<template v-if="orderItems.length">
<span class="text-indigo-7">Voir le sommaire </span>
<span class="text-primary">Voir le sommaire </span>
<span class="text-grey-6"> modifier, réordonner, supprimer</span>
</template>
<template v-else>
@ -187,7 +187,7 @@
+{{ ((onetimeTotal + recurringTotal) * 0.14975).toFixed(2) }}$ tx
</div>
</div>
<q-icon v-if="orderItems.length" name="arrow_forward" size="18px" color="indigo-6" />
<q-icon v-if="orderItems.length" name="arrow_forward" size="18px" color="primary" />
</div>
<!--
@ -206,14 +206,14 @@
<div class="wizard-step-summary">
<template v-if="selectedHeroTier">
<q-icon name="check_circle" size="14px" color="green-6" class="q-mr-xs" />
<strong class="text-indigo-8">{{ selectedHeroTier.label }}</strong>
<strong class="text-green-8">{{ selectedHeroTier.label }}</strong>
<span class="text-grey-7"> · {{ selectedHeroTier.price_effective.toFixed(2) }}$/mois</span>
<span v-if="internetContractMonths" class="text-grey-6"> · contrat {{ internetContractMonths }} mois</span>
<span v-if="selectedHeroTier.speed" class="text-grey-6"> · {{ selectedHeroTier.speed }}</span>
</template>
<template v-else>
<q-icon name="flash_on" size="14px" color="amber-8" class="q-mr-xs" />
<span class="text-amber-8">Obligatoire à partir de 39.95$/mois · cliquez pour choisir un forfait</span>
<q-icon name="flash_on" size="14px" color="warning" class="q-mr-xs" />
<span class="text-warning">Obligatoire à partir de 39.95$/mois · cliquez pour choisir un forfait</span>
</template>
</div>
</div>
@ -235,7 +235,7 @@
@click.stop="onInternetTierClick(t)">
<div class="tier-row-icon">
<q-icon :name="t.icon" size="24px"
:color="selectedHeroTier && selectedHeroTier.code === t.code ? 'green-6' : 'indigo-6'" />
:color="selectedHeroTier && selectedHeroTier.code === t.code ? 'green-6' : 'primary'" />
</div>
<div class="tier-row-body">
<div class="tier-row-head">
@ -378,8 +378,8 @@
</div>
<div class="preset-hero-desc">
<template v-if="phoneApplied && phoneMode === 'keep' && !phoneKeepNumber">
<q-icon name="warning" size="12px" color="amber-8" class="q-mr-xs" />
<span class="text-amber-8">Cliquez pour saisir le numéro à conserver</span>
<q-icon name="warning" size="12px" color="warning" class="q-mr-xs" />
<span class="text-warning">Cliquez pour saisir le numéro à conserver</span>
</template>
<template v-else>Illimitée CA/US · bonification combo</template>
</div>
@ -423,7 +423,7 @@
<q-icon name="swap_calls" size="20px"
:color="phoneApplied && phoneMode === 'keep' ? 'green-6' : 'teal-6'" />
<div class="tier-card-title">Conservation (port-in)</div>
<q-badge color="amber-7" text-color="white" class="tier-card-badge">
<q-badge color="warning" text-color="white" class="tier-card-badge">
Preuve requise
</q-badge>
</div>
@ -452,7 +452,7 @@
:class="[`tier-${p.tier}`, { 'preset-added': usedServiceTypes.has(p.service_type) }]"
@click="applyPreset(p)">
<q-icon :name="p.icon" size="18px"
:color="usedServiceTypes.has(p.service_type) ? 'green-6' : 'indigo-6'" />
:color="usedServiceTypes.has(p.service_type) ? 'green-6' : 'primary'" />
<div class="preset-upsell-body">
<div class="preset-upsell-delta">
+{{ p.price_delta.toFixed(2) }}<span class="preset-upsell-mo">$/mois</span>
@ -507,7 +507,7 @@
<q-input v-model.number="installRegular" dense outlined type="number" step="0.01" min="0"
label="Prix original" :input-style="{ fontSize: '0.78rem' }"
title="Prix barré affiché au client (0 = aucun barré)">
<template v-slot:append><q-icon name="local_offer" size="13px" color="orange-7" /></template>
<template v-slot:append><q-icon name="local_offer" size="13px" color="warning" /></template>
</q-input>
</div>
<div class="col-4">
@ -528,7 +528,7 @@
the sticky cart pill at the top handles navigation to Sommaire
which is where cart details and totals are edited/reviewed. -->
<div class="row q-gutter-sm q-mt-md q-mb-sm">
<q-btn flat dense icon="add" label="Autre item du catalogue" color="indigo-6" no-caps size="sm"
<q-btn flat dense icon="add" label="Autre item du catalogue" color="primary" no-caps size="sm"
@click="openCatalog()" />
<q-btn flat dense icon="edit" label="Item manuel" color="grey-7" no-caps size="sm"
@click="addOrderItem" />
@ -543,7 +543,7 @@
class="q-mt-xs" style="font-size:0.78rem" />
<div v-if="requireAcceptance" class="q-mt-sm q-gutter-sm">
<q-btn-toggle v-model="acceptanceMethod" no-caps dense unelevated size="sm"
toggle-color="indigo-6" color="grey-3" text-color="grey-8" :options="ACCEPTANCE_METHOD_OPTIONS" />
toggle-color="primary" color="grey-3" text-color="grey-8" :options="ACCEPTANCE_METHOD_OPTIONS" />
<div class="row q-gutter-sm q-mt-xs">
<q-input v-model="clientPhone" dense outlined placeholder="+15145551234" label="Téléphone"
class="col" :input-style="{ fontSize: '0.8rem' }" />
@ -568,8 +568,8 @@
reorder item order drives the quote's line order downstream. -->
<q-card-section v-if="currentStep === 3" class="col q-pt-md" style="overflow-y:auto;min-height:300px;max-height:60vh">
<div class="row items-center q-mb-sm">
<q-icon name="shopping_cart" size="18px" color="indigo-6" class="q-mr-xs" />
<div class="text-weight-bold text-indigo-8" style="font-size:0.9rem">
<q-icon name="shopping_cart" size="18px" color="primary" class="q-mr-xs" />
<div class="text-weight-bold text-green-8" style="font-size:0.9rem">
Sommaire &mdash; {{ orderItems.length }} item{{ orderItems.length > 1 ? 's' : '' }}
</div>
<q-space />
@ -580,7 +580,7 @@
<div v-if="!orderItems.length" class="sommaire-empty">
<q-icon name="shopping_cart_checkout" size="40px" color="grey-4" />
<div class="text-grey-6 q-mt-sm" style="font-size:0.85rem">Aucun item dans le panier</div>
<q-btn unelevated color="indigo-6" no-caps icon="add_shopping_cart" size="sm"
<q-btn unelevated color="primary" no-caps icon="add_shopping_cart" size="sm"
label="Retour au catalogue" class="q-mt-sm" @click="currentStep = 2" />
</div>
@ -606,28 +606,28 @@
</div>
<div class="sommaire-badge">{{ i + 1 }}</div>
<q-icon :name="item.billing === 'recurring' ? 'autorenew' : 'shopping_cart'" size="16px"
:color="item.billing === 'recurring' ? 'orange-7' : 'indigo-6'" class="sommaire-type-icon" />
:color="item.billing === 'recurring' ? 'orange-7' : 'primary'" class="sommaire-type-icon" />
<div class="sommaire-main">
<div class="sommaire-name">{{ item.item_name || 'Item sans nom' }}</div>
<div class="sommaire-meta">
<span>Qté {{ item.qty }}</span>
<span class="sommaire-sep">·</span>
<span class="text-weight-bold">{{ (item.qty * item.rate).toFixed(2) }}$</span>
<span v-if="item.billing === 'recurring'" class="text-orange-7">/mois</span>
<span v-if="item.billing === 'recurring'" class="text-warning">/mois</span>
<template v-if="item.billing === 'recurring' && item.contract_months">
<span class="sommaire-sep">·</span>
<span class="text-grey-6">{{ item.contract_months }} mois</span>
</template>
<template v-if="item.regular_price > item.rate">
<span class="sommaire-sep">·</span>
<q-chip dense square color="orange-1" text-color="orange-9"
<q-chip dense square color="amber-1" text-color="amber-9"
icon="celebration" size="sm" style="margin:0">
Promo {{ ((item.regular_price - item.rate) * item.qty).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}
</q-chip>
</template>
</div>
</div>
<q-btn flat round dense size="sm" icon="delete" color="red-5"
<q-btn flat round dense size="sm" icon="delete" color="negative"
@click.stop="deleteSommaireItem(i)" title="Supprimer" />
<q-icon :name="expandedSommaireIdx === i ? 'expand_less' : 'expand_more'"
size="18px" color="grey-6" />
@ -644,12 +644,12 @@
@click="onItemFieldClick(i, $event)">
<template v-slot:prepend>
<q-icon :name="item.billing === 'recurring' ? 'autorenew' : 'shopping_cart'" size="16px"
:color="item.billing === 'recurring' ? 'orange-7' : 'indigo-6'" />
:color="item.billing === 'recurring' ? 'orange-7' : 'primary'" />
</template>
<template v-slot:append>
<q-btn v-if="item.item_code" flat dense round size="sm" icon="swap_horiz" color="indigo-6"
<q-btn v-if="item.item_code" flat dense round size="sm" icon="swap_horiz" color="primary"
@click.stop="openCatalogForLine(i)" title="Changer du catalogue" />
<q-btn v-else flat dense round size="sm" icon="inventory_2" color="indigo-6"
<q-btn v-else flat dense round size="sm" icon="inventory_2" color="primary"
@click.stop="openCatalogForLine(i)" title="Choisir du catalogue" />
<q-btn flat dense round size="xs" icon="edit" color="grey-6"
@click.stop="enableManualEdit(i)"
@ -688,7 +688,7 @@
:input-style="{ fontSize: '0.75rem' }"
title="Prix barré affiché au client. Laisser vide (ou ≤ prix marketing) = aucun prix barré.">
<template v-slot:append>
<q-icon name="local_offer" size="14px" color="orange-7" />
<q-icon name="local_offer" size="14px" color="warning" />
</template>
</q-input>
</div>
@ -699,7 +699,7 @@
<span class="text-grey-6">Aperçu client :</span>
<span style="text-decoration:line-through;color:#9ca3af">{{ Number(item.regular_price).toFixed(2) }}$</span>
<span class="text-weight-bold text-green-8">{{ Number(item.rate).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}</span>
<q-chip dense square color="orange-1" text-color="orange-9" size="sm" style="margin:0">
<q-chip dense square color="amber-1" text-color="amber-9" size="sm" style="margin:0">
{{ ((Number(item.regular_price) - Number(item.rate)) * (item.qty || 1)).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}
</q-chip>
</div>
@ -709,7 +709,7 @@
with it. -->
<div v-if="item.project_template_id" class="item-steps-wrap q-mt-xs">
<template v-if="!templateLoadedFor.has(item.project_template_id)">
<q-btn flat dense size="sm" icon="auto_fix_high" no-caps color="indigo-6"
<q-btn flat dense size="sm" icon="auto_fix_high" no-caps color="primary"
label="Charger les étapes d'installation" @click="loadTemplateFromItem(item)" />
</template>
<template v-else>
@ -735,16 +735,16 @@
<span class="text-weight-bold">{{ onetimeTotal.toFixed(2) }} $</span>
</div>
<div v-if="stepsExtraTotal > 0" class="row justify-between q-pl-md" style="font-size:0.76rem">
<span class="text-grey-6"><q-icon name="add_circle_outline" size="12px" color="orange-6" /> dont extras sur étapes</span>
<span class="text-orange-7">{{ stepsExtraTotal.toFixed(2) }} $</span>
<span class="text-grey-6"><q-icon name="add_circle_outline" size="12px" color="warning" /> dont extras sur étapes</span>
<span class="text-warning">{{ stepsExtraTotal.toFixed(2) }} $</span>
</div>
<div class="row justify-between" style="font-size:0.82rem">
<span class="text-grey-7">Récurrent mensuel</span>
<span class="text-weight-bold text-orange-8">{{ recurringTotal.toFixed(2) }} $/mois</span>
<span class="text-weight-bold text-warning">{{ recurringTotal.toFixed(2) }} $/mois</span>
</div>
<div v-if="promoTotal > 0" class="row justify-between" style="font-size:0.82rem">
<span class="text-orange-8"><q-icon name="celebration" size="14px" /> Valeur des promotions étalées</span>
<span class="text-weight-bold text-orange-8">{{ promoTotal.toFixed(2) }} $</span>
<span class="text-warning"><q-icon name="celebration" size="14px" /> Valeur des promotions étalées</span>
<span class="text-weight-bold text-warning">{{ promoTotal.toFixed(2) }} $</span>
</div>
<q-separator class="q-my-xs" />
<div class="row justify-between" style="font-size:0.82rem">
@ -757,17 +757,17 @@
<q-card-section v-if="currentStep === 4" class="col q-pt-md" style="overflow-y:auto;min-height:300px;max-height:60vh">
<div class="text-caption text-grey-6 q-mb-md">Vérifiez le projet avant publication</div>
<div v-if="orderItems.length" class="ops-card q-mb-md" style="padding:10px 12px;border-color:#c7d2fe">
<div class="text-weight-bold text-indigo-8" style="font-size:0.82rem;margin-bottom:6px;">
<div class="text-weight-bold text-green-8" style="font-size:0.82rem;margin-bottom:6px;">
<q-icon name="receipt_long" size="16px" class="q-mr-xs" />
{{ orderMode === 'quotation' ? 'Devis' : orderMode === 'prepaid' ? 'Facture' : 'Bon de commande' }}
&mdash; {{ orderItems.length }} items
</div>
<div v-for="(item, i) in orderItems" :key="i" style="font-size:0.78rem;color:#475569;display:flex;gap:6px;align-items:center">
<q-icon :name="item.billing === 'recurring' ? 'autorenew' : 'shopping_cart'" size="14px"
:color="item.billing === 'recurring' ? 'orange-7' : 'indigo-6'" />
:color="item.billing === 'recurring' ? 'orange-7' : 'primary'" />
<span class="col">{{ item.qty }}x {{ item.item_name }}</span>
<span v-if="item.regular_price > item.rate"
class="text-caption text-orange-8" style="text-decoration:line-through;opacity:0.7">
class="text-caption text-warning" style="text-decoration:line-through;opacity:0.7">
{{ (item.qty * item.regular_price).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}
</span>
<span class="text-weight-bold">{{ (item.qty * item.rate).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}</span>
@ -777,14 +777,14 @@
<span>Total</span>
<span>{{ onetimeTotal.toFixed(2) }}$ + {{ recurringTotal.toFixed(2) }}$/mois</span>
</div>
<div v-if="promoTotal > 0" style="font-size:0.75rem" class="row justify-between text-orange-8 q-mt-xs">
<div v-if="promoTotal > 0" style="font-size:0.75rem" class="row justify-between text-warning q-mt-xs">
<span><q-icon name="celebration" size="12px" />&nbsp;Valeur promotions étalées sur {{ maxContractMonths }} mois</span>
<span class="text-weight-bold">{{ promoTotal.toFixed(2) }}$</span>
</div>
</div>
<div v-if="willCreateContract" class="ops-card q-mb-md" style="padding:10px 12px;border-color:#fed7aa;background:#fff7ed">
<div class="text-weight-bold text-orange-9" style="font-size:0.82rem;margin-bottom:4px;">
<div class="text-weight-bold text-warning" style="font-size:0.82rem;margin-bottom:4px;">
<q-icon name="handshake" size="16px" class="q-mr-xs" />
Contrat de service &mdash; {{ acceptanceMethod === 'docuseal' ? 'Commercial' : 'Résidentiel' }}
</div>
@ -792,7 +792,7 @@
{{ recurringTotal.toFixed(2) }}$/mois &middot; {{ maxContractMonths }} mois
<template v-if="promoTotal > 0"> &middot; {{ promoTotal.toFixed(2) }}$ en promotions étalées</template>
</div>
<div v-if="acceptanceMethod !== 'docuseal'" class="text-caption text-orange-7 q-mt-xs">
<div v-if="acceptanceMethod !== 'docuseal'" class="text-caption text-warning q-mt-xs">
Récapitulatif envoyé au client &mdash; click-to-accept avec horodatage + IP. Pas de "pénalité"; changement avant {{ maxContractMonths }} mois = portion non étalée au prorata.
</div>
</div>
@ -812,7 +812,7 @@
<span v-if="step.assigned_group"><q-icon name="group" size="12px" /> {{ step.assigned_group }}</span>
<span v-if="step.scheduled_date"><q-icon name="event" size="12px" /> {{ step.scheduled_date }}</span>
<span>{{ step.duration_h }}h</span>
<span v-if="step.on_open_webhook || step.on_close_webhook" class="text-blue-6">
<span v-if="step.on_open_webhook || step.on_close_webhook" class="text-info">
<q-icon name="webhook" size="12px" /> n8n
</span>
</div>
@ -840,18 +840,18 @@
<q-card-section v-if="publishedDone" class="col q-pt-md" style="overflow-y:auto;min-height:250px;max-height:70vh;">
<div style="text-align:center">
<q-icon :name="pendingAcceptance ? 'hourglass_top' : 'check_circle'" :color="pendingAcceptance ? 'orange-6' : 'green-6'" size="56px" />
<div class="text-h6 text-weight-bold q-mt-sm" :class="pendingAcceptance ? 'text-orange-8' : 'text-green-8'">
<div class="text-h6 text-weight-bold q-mt-sm" :class="pendingAcceptance ? 'text-warning' : 'text-green-8'">
{{ pendingAcceptance ? 'En attente d\'acceptation client' : 'Projet créé avec succès' }}
</div>
<div class="text-caption text-grey-6 q-mt-xs">{{ publishedDocType }} <b>{{ publishedDocName }}</b></div>
<div v-if="publishedContractName" class="text-caption text-orange-8 q-mt-xs">
<div v-if="publishedContractName" class="text-caption text-warning q-mt-xs">
<q-icon name="handshake" size="14px" class="q-mr-xs" />Contrat <b>{{ publishedContractName }}</b>
</div>
<div v-if="!pendingAcceptance && publishedJobCount" class="text-caption text-grey-6">{{ publishedJobCount }} tâches créées</div>
</div>
<div v-if="pendingAcceptance && !agentAccepted" class="ops-card q-mx-auto q-my-sm" style="max-width:440px;padding:12px 16px;background:#fff7ed;border-color:#fed7aa;text-align:left">
<div class="text-caption text-orange-9" style="line-height:1.5">
<div class="text-caption text-warning" style="line-height:1.5">
<q-icon name="info" size="14px" class="q-mr-xs" />
Les tâches seront créées automatiquement lorsque le client acceptera le devis.
</div>
@ -868,7 +868,7 @@
</div>
<div v-if="publishedDocType === 'Quotation'" class="ops-card q-mx-auto q-my-sm" style="max-width:440px;padding:12px 16px;border-color:#c7d2fe;text-align:left">
<div class="text-weight-bold text-indigo-8 q-mb-sm" style="font-size:0.82rem">
<div class="text-weight-bold text-green-8 q-mb-sm" style="font-size:0.82rem">
<q-icon name="send" size="16px" class="q-mr-xs" /> Envoyer au client
</div>
<div class="row q-col-gutter-sm items-end q-mb-xs">
@ -877,27 +877,27 @@
:placeholder="sendChannel === 'email' ? 'client@exemple.com' : '+15145551234'"
:label="sendChannel === 'email' ? 'Courriel' : 'Téléphone'" :input-style="{ fontSize: '0.82rem' }">
<template v-slot:prepend>
<q-icon :name="sendChannel === 'email' ? 'email' : 'sms'" size="18px" color="indigo-6" />
<q-icon :name="sendChannel === 'email' ? 'email' : 'sms'" size="18px" color="primary" />
</template>
</q-input>
</div>
<div class="col-auto">
<q-btn-toggle v-model="sendChannel" dense unelevated toggle-color="indigo-6" color="grey-3" text-color="grey-8"
<q-btn-toggle v-model="sendChannel" dense unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
size="sm" no-caps style="margin-bottom:1px"
:options="[{ label: 'Courriel', value: 'email' }, { label: 'SMS', value: 'sms' }]" />
</div>
</div>
<q-btn unelevated :label="sending ? 'Envoi...' : sendChannel === 'email' ? 'Envoyer par courriel' : 'Envoyer par SMS'"
:icon="sendChannel === 'email' ? 'email' : 'sms'"
color="indigo-6" no-caps dense class="full-width q-mt-xs"
color="primary" no-caps dense class="full-width q-mt-xs"
:disable="!sendTo || sending" :loading="sending" @click="sendToClient" />
<div v-if="sendResult" class="text-caption q-mt-xs" :class="sendResult.ok ? 'text-green-7' : 'text-red-6'">
<div v-if="sendResult" class="text-caption q-mt-xs" :class="sendResult.ok ? 'text-green-7' : 'text-negative'">
{{ sendResult.message }}
</div>
</div>
<div class="row justify-center q-gutter-sm q-mt-sm">
<q-btn unelevated icon="picture_as_pdf" label="PDF" color="indigo-6" dense no-caps @click="downloadPdf" />
<q-btn unelevated icon="picture_as_pdf" label="PDF" color="primary" dense no-caps @click="downloadPdf" />
<q-btn outline icon="content_copy" label="Lien PDF" color="grey-7" dense no-caps @click="copyPdfLink" />
<q-btn v-if="acceptanceLinkUrl" outline icon="link" label="Lien acceptation" color="purple-6" dense no-caps
@click="copyAcceptanceLink" />
@ -910,7 +910,7 @@
<template v-else>
<q-btn flat label="Annuler" color="grey-7" @click="cancel" />
<q-btn v-if="currentStep > 0" flat label="Précédent" color="grey-7" icon="arrow_back" @click="goBack" />
<q-btn v-if="currentStep < 4" unelevated label="Suivant" color="indigo-6" icon-right="arrow_forward"
<q-btn v-if="currentStep < 4" unelevated label="Suivant" color="primary" icon-right="arrow_forward"
:disable="!canProceed" @click="goNext" />
<q-btn v-if="currentStep === 4" unelevated :label="publishLabel" color="green-7" icon="rocket_launch"
:loading="publishing" @click="publish" />
@ -922,7 +922,7 @@
<q-card class="catalog-modal column no-wrap">
<div class="catalog-modal-header">
<div class="row items-center no-wrap q-mb-sm">
<q-icon name="inventory_2" size="22px" color="indigo-6" class="q-mr-sm" />
<q-icon name="inventory_2" size="22px" color="primary" class="q-mr-sm" />
<div class="col">
<div class="text-weight-bold" style="font-size:1rem">Catalogue</div>
<div class="text-caption text-grey-6">Ajoutez des produits ou services au devis</div>
@ -946,7 +946,7 @@
<q-scroll-area class="catalog-modal-body col">
<div v-if="catalogLoading" class="catalog-loading">
<q-spinner size="28px" color="indigo-6" />
<q-spinner size="28px" color="primary" />
<div class="text-caption text-grey-6 q-mt-sm">Chargement du catalogue...</div>
</div>
<div v-else-if="!displayedCatalog.length" class="catalog-empty">
@ -974,7 +974,7 @@
{{ p.billing_type === 'Mensuel' ? '/ mois' : p.billing_type === 'Annuel' ? '/ an' : 'unique' }}
</div>
</div>
<q-btn round unelevated color="indigo-6" icon="add" size="sm" class="catalog-card-add" />
<q-btn round unelevated color="primary" icon="add" size="sm" class="catalog-card-add" />
</div>
</div>
</q-scroll-area>
@ -987,7 +987,7 @@
&middot; <span class="text-green-7 text-weight-bold">+{{ catalogRecentlyAdded.size }}</span> ajouté{{ catalogRecentlyAdded.size > 1 ? 's' : '' }}
</span>
</div>
<q-btn unelevated color="indigo-6" label="Terminé" no-caps icon-right="check"
<q-btn unelevated color="primary" label="Terminé" no-caps icon-right="check"
@click="catalogOpen = false" />
</div>
</q-card>
@ -996,7 +996,7 @@
<q-dialog v-model="addStepDialog.open">
<q-card style="min-width: 340px; max-width: 90vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="add_task" size="22px" color="indigo-6" class="q-mr-sm" />
<q-icon name="add_task" size="22px" color="primary" class="q-mr-sm" />
<div class="text-weight-bold">Ajouter une étape</div>
<q-space />
<q-btn flat round dense icon="close" @click="addStepDialog.open = false" />
@ -1019,7 +1019,7 @@
</q-card-section>
<q-card-actions align="right" class="q-pa-md">
<q-btn flat no-caps label="Annuler" @click="addStepDialog.open = false" />
<q-btn unelevated color="indigo-6" no-caps label="Ajouter" icon-right="check"
<q-btn unelevated color="primary" no-caps label="Ajouter" icon-right="check"
:disable="!(addStepDialog.subject && addStepDialog.subject.trim())"
@click="confirmAddStep()" />
</q-card-actions>
@ -1076,7 +1076,7 @@
class="channel-chip"
@remove="toggleChannel(ch)">
<span class="channel-chip-name">{{ ch.name }}</span>
<q-badge v-if="ch.premium_group" color="red-6" text-color="white"
<q-badge v-if="ch.premium_group" color="negative" text-color="white"
class="channel-chip-pastille" rounded>
2
</q-badge>
@ -1096,7 +1096,7 @@
<div class="col" style="min-width:0">
<div class="channel-row-name">
{{ ch.name }}
<q-badge v-if="ch.premium_group" color="red-6" text-color="white"
<q-badge v-if="ch.premium_group" color="negative" text-color="white"
class="q-ml-xs" rounded>2 choix</q-badge>
</div>
<div v-if="ch.sub && ch.sub.length" class="channel-row-sub">

View File

@ -0,0 +1,78 @@
<template>
<!-- Sélecteur de destinataire(s) RÉUTILISABLE : autosuggest flou (pg_trgm via /collab/customer-search) dès la frappe.
Pattern q-input + menu de résultats + puces (le même, éprouvé, que le composeur). v-model = tableau de COURRIELS.
On peut aussi taper un courriel libre + Entrée (clients non fichés). -->
<div>
<div v-if="modelValue.length" class="rs-chips q-mb-xs">
<q-chip v-for="(em, i) in modelValue" :key="em" dense removable size="sm" color="green-1" text-color="green-9" @remove="removeAt(i)">{{ em }}</q-chip>
</div>
<q-input :model-value="query" @update:model-value="onInput" dense outlined :label="label"
autocomplete="off" name="rs-recipient" @keydown.enter.prevent="addTyped" @keydown.esc="menuOpen = false">
<template #prepend><q-icon :name="icon" size="16px" color="grey-6" /></template>
<template #append><q-spinner v-if="busy" size="14px" color="primary" /></template>
<q-menu v-model="menuOpen" no-focus no-parent-event fit anchor="bottom left" self="top left" style="min-width:260px">
<q-list dense>
<q-item v-for="m in results" :key="m.name" clickable v-close-popup @click="pick(m)">
<q-item-section avatar>
<q-icon :name="m.kind === 'équipe' ? 'groups' : 'account_circle'" :color="m.kind === 'équipe' ? 'teal-6' : 'blue-6'" size="20px" />
</q-item-section>
<q-item-section>
<q-item-label>
{{ m.customer_name || m.name }}
<q-badge v-if="m.kind === 'équipe'" color="green-1" text-color="green-9" class="q-ml-xs" label="Équipe" />
</q-item-label>
<q-item-label caption>{{ m.email }}{{ m.matched && m.kind !== 'équipe' ? ' · ' + m.matched : '' }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-input>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { HUB_URL } from 'src/config/hub'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
label: { type: String, default: '' },
icon: { type: String, default: 'person' },
team: { type: Boolean, default: true }, // inclure les membres de l'équipe (en PREMIER) pour choisir un collègue comme destinataire
})
const emit = defineEmits(['update:modelValue'])
const query = ref('')
const results = ref([])
const busy = ref(false)
const menuOpen = ref(false)
let _t = null
function onInput (v) {
query.value = v == null ? '' : v
clearTimeout(_t)
const q = String(v || '').trim()
if (q.length < 2) { results.value = []; menuOpen.value = false; return }
_t = setTimeout(async () => {
busy.value = true
try {
const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}${props.team ? '&team=1' : ''}`)
const d = r.ok ? await r.json() : {}
results.value = (d.matches || []).filter(m => m.email)
menuOpen.value = results.value.length > 0
} catch (e) { results.value = []; menuOpen.value = false } finally { busy.value = false }
}, 250)
}
function add (email) {
const e = String(email || '').trim()
if (e && !props.modelValue.includes(e)) emit('update:modelValue', [...props.modelValue, e])
query.value = ''; results.value = []; menuOpen.value = false; clearTimeout(_t)
}
function pick (m) { add(m.email) }
function addTyped () { if (query.value.trim()) add(query.value.trim()) }
function removeAt (i) { const a = [...props.modelValue]; a.splice(i, 1); emit('update:modelValue', a) }
</script>
<style scoped>
.rs-chips { display: flex; flex-wrap: wrap; gap: 4px; }
</style>

View File

@ -0,0 +1,79 @@
<template>
<!-- Échafaudage commun des pages de rapports : en-tête + tuiles stats + graphique + états (chargement/erreur/vide) + tableau responsive.
La page fournit : filtres (#filters), cartes (:cards), colonnes (:columns), lignes (:rows) et un fetch qui pilote :loading/:error/:has-run.
Les slots body-cell-* / #item sont transmis au DataTable. Voir composables/useCsvExport pour @export. -->
<q-page padding>
<PageHeader :title="title" :back="back">
<template #actions>
<q-input v-if="searchable" v-model="search" dense outlined placeholder="Filtrer…" clearable class="rs-search">
<template #prepend><q-icon name="search" /></template>
</q-input>
<slot name="actions" />
<q-btn v-if="exportable && rows && rows.length" flat dense no-caps icon="download" label="CSV" @click="$emit('export')">
<q-tooltip>Exporter en CSV</q-tooltip>
</q-btn>
</template>
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
</PageHeader>
<div v-if="cards && cards.length" class="row q-col-gutter-sm q-mb-md">
<div v-for="(c, i) in cards" :key="i" class="col-6 col-sm">
<StatCard :label="c.label" :value="c.value" :icon="c.icon" :color="c.color || 'primary'" :value-class="c.valueClass" :hint="c.hint" />
</div>
</div>
<slot name="chart" />
<TableSkeleton v-if="loading" :rows="8" :cols="(columns && columns.length) || 5" />
<div v-else-if="error" class="rs-state">
<q-icon name="error_outline" size="34px" color="negative" />
<div>{{ error }}</div>
<q-btn outline no-caps color="primary" label="Réessayer" @click="$emit('retry')" />
</div>
<EmptyState v-else-if="!rows || !rows.length"
:icon="hasRun ? 'inbox' : 'filter_alt'"
:label="hasRun ? empty : 'Choisissez une période, puis « Générer ».'" />
<DataTable v-else :rows="rows" :columns="columns" :row-key="rowKey" :pagination="pagination" :filter="searchable ? search : undefined">
<template v-for="name in tableSlotNames" #[name]="sp"><slot :name="name" v-bind="sp || {}" /></template>
</DataTable>
<!-- Contenu additionnel sous le tableau (ex. résumé de période) la page gère sa propre visibilité. -->
<div v-if="$slots.below && !loading && !error"><slot name="below" /></div>
</q-page>
</template>
<script setup>
import { useSlots, computed, ref } from 'vue'
import PageHeader from './PageHeader.vue'
import StatCard from './StatCard.vue'
import DataTable from './DataTable.vue'
import TableSkeleton from './TableSkeleton.vue'
import EmptyState from './EmptyState.vue'
defineProps({
title: { type: String, default: '' },
back: { type: [String, Object], default: '/rapports' },
loading: { type: Boolean, default: false },
error: { type: String, default: '' },
hasRun: { type: Boolean, default: true },
cards: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
rows: { type: Array, default: () => [] },
rowKey: { type: String, default: 'name' },
pagination: { type: Object, default: () => ({ rowsPerPage: 50 }) },
empty: { type: String, default: 'Aucune donnée pour cette période.' },
exportable: { type: Boolean, default: true },
searchable: { type: Boolean, default: false },
})
defineEmits(['retry', 'export'])
const search = ref('')
const slots = useSlots()
const RESERVED = ['filters', 'chart', 'actions', 'below']
const tableSlotNames = computed(() => Object.keys(slots).filter(n => !RESERVED.includes(n)))
</script>
<style scoped>
.rs-state { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 40px 20px; text-align: center; color: var(--ops-text-muted); }
.rs-search { width: 200px; max-width: 100%; }
</style>

View File

@ -0,0 +1,32 @@
<template>
<!-- q-dialog qui se MAXIMISE sur téléphone (plein écran) et plafonne à 95vw ailleurs.
Remplace les `style="width:NNNpx"` qui débordent sur mobile.
Usage : <ResponsiveDialog v-model="open" :max-width="560"> contenu </ResponsiveDialog> -->
<q-dialog v-bind="$attrs" :maximized="isPhone">
<q-card v-if="card" class="rdlg-card column no-wrap" :style="cardStyle">
<slot />
</q-card>
<slot v-else />
</q-dialog>
</template>
<script setup>
import { computed } from 'vue'
import { useIsPhone } from 'src/composables/useMediaQuery'
defineOptions({ inheritAttrs: false })
const props = defineProps({
card: { type: Boolean, default: true }, // enveloppe automatiquement dans un q-card
maxWidth: { type: [String, Number], default: 560 },
})
const isPhone = useIsPhone()
const cardStyle = computed(() => {
if (isPhone.value) return 'width:100%;max-width:100%;height:100%'
const mw = typeof props.maxWidth === 'number' ? props.maxWidth + 'px' : props.maxWidth
return `width:100%;max-width:min(95vw, ${mw})`
})
</script>
<style scoped>
.rdlg-card { max-height: 95vh; }
</style>

View File

@ -2,7 +2,7 @@
<q-dialog v-model="open" @hide="reset">
<q-card style="min-width:540px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="wifi_find" color="cyan-8" size="22px" class="q-mr-sm" />
<q-icon name="wifi_find" color="info" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Statut du service</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
@ -14,7 +14,7 @@
label="Adresse, nom, téléphone ou courriel"
@update:model-value="onSearch" @keyup.enter.exact.prevent="onSearch(query, true)">
<template #prepend><q-icon name="search" /></template>
<template #append><q-spinner v-if="searching" size="16px" color="cyan-7" /></template>
<template #append><q-spinner v-if="searching" size="16px" color="info" /></template>
</q-input>
<div class="text-caption text-grey-5 q-mt-xs"><q-icon name="info" size="13px" /> Ex. « 2338 Ste-Clotilde », « Jardins Lefort », un cellulaire ou un courriel comme pour créer un ticket.</div>
@ -23,7 +23,7 @@
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">Plusieurs comptes choisis :</div>
<q-list bordered separator class="rounded-borders">
<q-item v-for="m in candidates" :key="m.name" clickable @click="pickCustomer(m.name, m)">
<q-item-section avatar><q-icon :name="m.matched === 'adresse' ? 'place' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person'" color="cyan-7" /></q-item-section>
<q-item-section avatar><q-icon :name="m.matched === 'adresse' ? 'place' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person'" color="info" /></q-item-section>
<q-item-section>
<q-item-label>{{ 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.phone || m.email || m.name }}</q-item-label>
@ -42,7 +42,7 @@
<div v-if="data" class="q-mt-md">
<!-- En-tête compte -->
<div class="row items-center no-wrap q-mb-sm">
<q-icon name="person" color="blue-7" size="18px" class="q-mr-xs" />
<q-icon name="person" color="info" size="18px" class="q-mr-xs" />
<div class="col">
<div class="text-weight-bold ellipsis">{{ data.customer.customer_name }}</div>
<div class="text-caption text-grey-6 ellipsis">{{ [data.customer.phone, data.customer.email].filter(Boolean).join(' · ') || data.customer.name }}</div>
@ -54,7 +54,7 @@
<!-- Adresse de service (compte multi-adresses re-requête) -->
<q-select v-if="data.locations && data.locations.length > 1" dense outlined v-model="selLoc" :options="data.locations" option-label="address" option-value="name" emit-value map-options
label="Adresse de service" class="q-mb-sm" @update:model-value="pickLocation">
<template #prepend><q-icon name="place" color="orange-6" /></template>
<template #prepend><q-icon name="place" color="warning" /></template>
<template #option="s">
<q-item v-bind="s.itemProps"><q-item-section>
<q-item-label>{{ s.opt.address }}</q-item-label>
@ -62,7 +62,7 @@
</q-item-section></q-item>
</template>
</q-select>
<div v-else-if="data.location" class="text-caption text-grey-7 q-mb-sm"><q-icon name="place" size="13px" color="orange-6" /> {{ data.location.address || data.location.name }}</div>
<div v-else-if="data.location" class="text-caption text-grey-7 q-mb-sm"><q-icon name="place" size="13px" color="warning" /> {{ data.location.address || data.location.name }}</div>
<!-- Abonnement(s) service -->
<div v-if="data.subscriptions && data.subscriptions.length" class="q-mb-sm">
@ -94,7 +94,7 @@
<q-chip v-if="d.wifiClients != null" dense square size="sm" color="grey-3" text-color="grey-8" icon="devices">{{ d.wifiClients }} Wi-Fi</q-chip>
<q-chip v-if="d.oltName" dense square size="sm" color="grey-3" text-color="grey-8" icon="dns">{{ d.oltName }}<span v-if="d.oltPort != null" class="q-ml-xs">· p{{ d.oltPort }}</span></q-chip>
<q-chip v-if="d.ip" dense square size="sm" color="grey-3" text-color="grey-8" icon="lan">{{ d.ip }}</q-chip>
<q-chip v-if="d.offlineCause" dense square size="sm" color="red-2" text-color="red-9" icon="warning">{{ d.offlineCause }}</q-chip>
<q-chip v-if="d.offlineCause" dense square size="sm" color="red-1" text-color="red-9" icon="warning">{{ d.offlineCause }}</q-chip>
</div>
<!-- DoStuff (n8n) : statut LIVE de l'OLT lent (~30 s), à la demande -->
<div v-if="d.serial && d.olt" class="q-mt-xs">
@ -114,8 +114,8 @@
<q-chip v-if="d._live.rxPower != null" dense square size="sm" :color="signalColor(d._live.signal)" text-color="white" icon="network_check">{{ d._live.signal }} · Rx {{ d._live.rxPower }}<span v-if="d._live.txPower != null"> / Tx {{ d._live.txPower }}</span> dBm</q-chip>
<q-chip v-if="d._live.distance != null" dense square size="sm" color="grey-3" text-color="grey-8" icon="straighten">{{ d._live.distance }} m</q-chip>
<q-chip v-if="d._live.uptime" dense square size="sm" color="grey-3" text-color="grey-8" icon="schedule">{{ d._live.uptime }}</q-chip>
<q-chip v-if="d._live.lastDown" dense square size="sm" color="amber-2" text-color="amber-9" icon="history">{{ d._live.lastDown }}</q-chip>
<q-chip v-if="d._live.managementIp" dense square size="sm" clickable color="blue-2" text-color="blue-9" icon="router" @click="openMgmt(d._live.managementIp)">GUI {{ d._live.managementIp }}</q-chip>
<q-chip v-if="d._live.lastDown" dense square size="sm" color="amber-1" text-color="amber-9" icon="history">{{ d._live.lastDown }}</q-chip>
<q-chip v-if="d._live.managementIp" dense square size="sm" clickable color="blue-1" text-color="blue-9" icon="router" @click="openMgmt(d._live.managementIp)">GUI {{ d._live.managementIp }}</q-chip>
<q-chip v-if="d._live.wanIp" dense square size="sm" color="grey-3" text-color="grey-8" icon="public">WAN {{ d._live.wanIp }}</q-chip>
<q-chip v-if="d._live.firmware" dense square size="sm" color="grey-3" text-color="grey-8" icon="memory">{{ d._live.firmware }}</q-chip>
</div>

View File

@ -0,0 +1,29 @@
<template>
<!-- Tuile de statistique réutilisable (rapports + tableau de bord). À placer dans une grille responsive,
p.ex. <div class="col-6 col-sm-auto"> 2 par ligne sur téléphone, en ligne sur ordinateur. -->
<div class="ops-card ops-stat stat-card">
<q-icon v-if="icon" :name="icon" :color="color" size="20px" class="stat-card-icon" />
<div class="ops-stat-value" :class="valueClass">
<slot>{{ value }}</slot>
</div>
<div class="ops-stat-label">{{ label }}</div>
<div v-if="hint" class="stat-card-hint">{{ hint }}</div>
</div>
</template>
<script setup>
defineProps({
label: { type: String, default: '' },
value: { type: [String, Number], default: '' },
icon: { type: String, default: '' },
color: { type: String, default: 'primary' },
hint: { type: String, default: '' },
valueClass: { type: String, default: '' }, // ex. 'text-positive' / 'text-negative'
})
</script>
<style scoped>
.stat-card { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; min-width: 0; height: 100%; }
.stat-card-icon { margin-bottom: 2px; }
.stat-card-hint { font-size: 0.72rem; color: var(--ops-text-muted); margin-top: 2px; }
</style>

View File

@ -0,0 +1,22 @@
<template>
<!-- Squelette de chargement en forme de tableau (préserve la mise en page pendant le fetch au lieu d'un flash vide). -->
<div class="tbl-skel">
<div v-for="r in rows" :key="r" class="tbl-skel-row">
<q-skeleton v-for="c in cols" :key="c" type="text" class="tbl-skel-cell" />
</div>
</div>
</template>
<script setup>
defineProps({
rows: { type: Number, default: 6 },
cols: { type: Number, default: 5 },
})
</script>
<style scoped>
.tbl-skel { padding: 4px 0; }
.tbl-skel-row { display: flex; gap: 16px; padding: 10px 12px; border-bottom: 1px solid var(--ops-border); }
.tbl-skel-cell { flex: 1; height: 14px; border-radius: 5px; }
.tbl-skel-cell:first-child { max-width: 34%; }
</style>

View File

@ -18,12 +18,12 @@
</div>
<div class="text-caption text-grey-6 row items-center q-gutter-x-sm">
<span v-if="job.job_type">{{ job.job_type }}</span>
<span v-if="job.assigned_tech" class="text-indigo-6"><q-icon name="person" size="12px" /> {{ job.assigned_tech }}</span>
<span v-if="job.assigned_tech" class="text-primary"><q-icon name="person" size="12px" /> {{ job.assigned_tech }}</span>
<span v-if="job.scheduled_date"><q-icon name="event" size="12px" /> {{ job.scheduled_date }}</span>
<span v-if="job.depends_on" class="text-orange-8">
<span v-if="job.depends_on" class="text-warning">
<q-icon name="link" size="12px" /> après {{ job.depends_on }}
</span>
<span v-if="job.on_open_webhook || job.on_close_webhook" class="text-blue-6">
<span v-if="job.on_open_webhook || job.on_close_webhook" class="text-info">
<q-icon name="webhook" size="12px" /> n8n
</span>
<span v-if="children.length" class="text-grey-5">
@ -89,7 +89,7 @@
</div>
<div class="task-tags-row">
<q-chip v-for="tag in jobTags" :key="tag" removable dense size="sm"
color="indigo-1" text-color="indigo-8" icon="sell"
color="green-1" text-color="green-8" icon="sell"
@remove="removeTag(tag)">
{{ tag }}
</q-chip>
@ -102,12 +102,12 @@
<!-- Action buttons -->
<div class="row items-center q-gutter-x-sm">
<q-btn flat dense size="sm" icon="open_in_new" label="Dispatch" color="indigo-6" no-caps
<q-btn flat dense size="sm" icon="open_in_new" label="Dispatch" color="primary" no-caps
@click="goToDispatch">
<q-tooltip>Ouvrir dans le tableau dispatch</q-tooltip>
</q-btn>
<q-space />
<q-btn flat dense size="sm" icon="delete" label="Supprimer" color="red-5" no-caps
<q-btn flat dense size="sm" icon="delete" label="Supprimer" color="negative" no-caps
@click="confirmDelete">
<q-tooltip>Supprimer cette tâche</q-tooltip>
</q-btn>

View File

@ -80,7 +80,7 @@
<template v-slot:option="{ opt, itemProps }">
<q-item v-bind="itemProps">
<q-item-section side style="min-width:48px">
<q-badge :color="opt.type === 'Issue' ? 'indigo-3' : 'teal-3'" :label="opt.type === 'Issue' ? 'Ticket' : 'Tâche'" />
<q-badge :color="opt.type === 'Issue' ? 'green-3' : 'teal-3'" :label="opt.type === 'Issue' ? 'Ticket' : 'Tâche'" />
</q-item-section>
<q-item-section>
<q-item-label class="text-caption">{{ opt.label }}</q-item-label>
@ -138,7 +138,7 @@
<!-- Actions -->
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat label="Annuler" color="grey-7" @click="$emit('update:modelValue', false)" />
<q-btn unelevated :label="submitLabel" color="indigo-6"
<q-btn unelevated :label="submitLabel" color="primary"
:loading="submitting" :disable="!form.subject?.trim()" @click="onSubmit" />
</q-card-actions>
</q-card>

View File

@ -0,0 +1,26 @@
<template>
<!-- COURRIEL AGRANDI iframe sandbox plein écran. Viewer présentationnel pur.
Extrait de ConversationPanel (découpage 2026-07-01). -->
<q-dialog :model-value="modelValue" @update:model-value="v => emit('update:modelValue', v)" maximized>
<q-card>
<q-bar class="bg-green-1">
<q-icon name="mail" color="primary" />
<div class="text-weight-medium text-green-9">{{ subject || 'Courriel' }}</div>
<q-space />
<q-btn dense flat icon="close" v-close-popup><q-tooltip>Fermer</q-tooltip></q-btn>
</q-bar>
<iframe :srcdoc="emailSrcdoc(html)" sandbox="allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" style="width:100%;height:calc(100vh - 32px);border:0;background:#fff"></iframe>
</q-card>
</q-dialog>
</template>
<script setup>
import { emailSrcdoc } from 'src/composables/useEmailRender'
defineProps({
modelValue: { type: Boolean, default: false },
html: { type: String, default: '' },
subject: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
</script>

View File

@ -6,9 +6,9 @@
<div class="topo-link" :class="{ ok: status.online }"></div>
<div class="topo-node"><q-icon name="cell_tower" size="22px" color="blue-grey-6" /><div class="topo-cap">OLT</div><div class="topo-sub">{{ oltLabel }}</div></div>
<div class="topo-link" :class="{ ok: status.online }"></div>
<div class="topo-node"><div class="topo-modem"><q-icon name="router" size="22px" color="indigo-6" /><span class="topo-dot" :class="status.online ? 'on' : 'off'"></span></div><div class="topo-cap">Modem</div><div class="topo-sub" v-if="doc.olt_slot != null">{{ doc.olt_slot }}/{{ doc.olt_port }}/{{ doc.olt_ontid }}</div></div>
<div class="topo-node"><div class="topo-modem"><q-icon name="router" size="22px" color="primary" /><span class="topo-dot" :class="status.online ? 'on' : 'off'"></span></div><div class="topo-cap">Modem</div><div class="topo-sub" v-if="doc.olt_slot != null">{{ doc.olt_slot }}/{{ doc.olt_port }}/{{ doc.olt_ontid }}</div></div>
<div class="topo-link" :class="{ ok: clientCount > 0 }"></div>
<div class="topo-node topo-click" @click="openHosts"><div style="position:relative;display:inline-block;"><q-icon name="devices" size="22px" color="indigo-6" /><q-circular-progress v-if="hostsLoading" indeterminate size="15px" :thickness="0.22" color="indigo-5" style="position:absolute;right:-9px;top:-4px;" /></div><div class="topo-cap">{{ clientCount }} app.</div></div>
<div class="topo-node topo-click" @click="openHosts"><div style="position:relative;display:inline-block;"><q-icon name="devices" size="22px" color="primary" /><q-circular-progress v-if="hostsLoading" indeterminate size="15px" :thickness="0.22" color="green-5" style="position:absolute;right:-9px;top:-4px;" /></div><div class="topo-cap">{{ clientCount }} app.</div></div>
</div>
<div class="topo-badges">
<q-badge :color="statusBadgeColor">{{ status.label }}</q-badge>
@ -252,7 +252,7 @@
<span class="text-weight-bold">{{ wifiDiag.wiredEquipment.length }} repeteur(s) mesh detecte(s)</span>
</div>
<div v-for="eq in wifiDiag.wiredEquipment" :key="eq.mac" class="q-ml-md q-mt-xs text-caption">
<q-icon name="router" size="12px" class="q-mr-xs" color="blue-6" />
<q-icon name="router" size="12px" class="q-mr-xs" color="info" />
<span class="text-weight-medium">{{ eq.hostname || eq.model }}</span>
&mdash; {{ eq.ip }} ({{ eq.mac }})
<q-badge :color="eq.type === 'mesh_repeater' ? 'blue-2' : 'orange-2'" :text-color="eq.type === 'mesh_repeater' ? 'blue-9' : 'orange-9'" class="q-ml-xs" style="font-size:0.6rem;">
@ -293,7 +293,7 @@
<td><code class="text-caption">{{ l.mac }}</code></td>
<td class="text-caption">{{ l.expiry != null ? formatLease(l.expiry) : '—' }}</td>
<td>
<q-badge v-if="l.isMeshRepeater" color="blue-2" text-color="blue-9" style="font-size:0.6rem;">Mesh</q-badge>
<q-badge v-if="l.isMeshRepeater" color="blue-1" text-color="blue-9" style="font-size:0.6rem;">Mesh</q-badge>
<q-badge v-else-if="l.isWired" color="grey-3" text-color="grey-8" style="font-size:0.6rem;">Filaire</q-badge>
<q-badge v-else color="green-2" text-color="green-9" style="font-size:0.6rem;">WiFi</q-badge>
</td>
@ -339,7 +339,7 @@
<q-badge :color="node.active ? 'green' : 'red'" style="padding: 2px 5px;" />
<span class="text-weight-bold q-ml-xs">{{ node.hostname }}</span>
<span class="text-caption text-grey-6 q-ml-xs">{{ node.model }}</span>
<q-badge v-if="node.isController" outline color="blue-8" class="q-ml-xs" style="font-size:0.6rem;">Routeur</q-badge>
<q-badge v-if="node.isController" outline color="info" class="q-ml-xs" style="font-size:0.6rem;">Routeur</q-badge>
</div>
<div class="adv-mesh-stats">
<span v-if="!node.isController" class="adv-mesh-stat">

View File

@ -1,7 +1,7 @@
<template>
<!-- View toggle -->
<q-tabs v-model="viewMode" dense align="left" inline-label no-caps
indicator-color="indigo-6" active-color="indigo-7" class="q-mb-sm"
indicator-color="primary" active-color="primary" class="q-mb-sm"
style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<q-tab name="fields" icon="list_alt" label="Détails" />
<q-tab name="client" icon="receipt_long" label="Aperçu client" />
@ -10,9 +10,9 @@
<!-- CLIENT PREVIEW -->
<div v-if="viewMode === 'client'" class="q-mb-md">
<div class="row q-gutter-sm q-mb-sm q-pa-sm" style="background:#f8fafc;border-radius:8px">
<q-btn outline dense no-caps color="red-5" icon="picture_as_pdf" label="Télécharger PDF"
<q-btn outline dense no-caps color="negative" icon="picture_as_pdf" label="Télécharger PDF"
size="sm" :href="pdfDownloadUrl" target="_blank" />
<q-btn outline dense no-caps color="indigo-6" icon="content_copy" label="Copier lien paiement"
<q-btn outline dense no-caps color="primary" icon="content_copy" label="Copier lien paiement"
size="sm" :loading="generatingPayLink" @click="copyPayLink" />
</div>
<iframe :src="clientPreviewUrl" class="invoice-client-preview" />
@ -28,32 +28,32 @@
<q-tooltip>Valider et soumettre cette facture</q-tooltip>
</q-btn>
<!-- Draft: Delete -->
<q-btn v-if="isDraft" outline dense no-caps color="red-6" icon="delete" label="Supprimer"
<q-btn v-if="isDraft" outline dense no-caps color="negative" icon="delete" label="Supprimer"
size="sm" :loading="actionLoading" @click="deleteInvoice">
<q-tooltip>Supprimer ce brouillon</q-tooltip>
</q-btn>
<!-- Submitted: Cancel -->
<q-btn v-if="isSubmitted && !doc.is_return" outline dense no-caps color="orange-8" icon="cancel" label="Annuler"
<q-btn v-if="isSubmitted && !doc.is_return" outline dense no-caps color="warning" icon="cancel" label="Annuler"
size="sm" :loading="actionLoading" @click="cancelInvoice">
<q-tooltip>Annuler cette facture</q-tooltip>
</q-btn>
<!-- Submitted: Credit Note -->
<q-btn v-if="isSubmitted && !doc.is_return" outline dense no-caps color="red-7" icon="replay" label="Note de credit"
<q-btn v-if="isSubmitted && !doc.is_return" outline dense no-caps color="negative" icon="replay" label="Note de credit"
size="sm" :loading="actionLoading" @click="createCreditNote">
<q-tooltip>Creer une note de credit (renversement)</q-tooltip>
</q-btn>
<!-- Submitted unpaid: Send payment link -->
<q-btn v-if="isUnpaid" outline dense no-caps color="indigo-6" icon="send" label="Lien paiement"
<q-btn v-if="isUnpaid" outline dense no-caps color="primary" icon="send" label="Lien paiement"
size="sm" :loading="sendingLink" @click="sendPaymentLink">
<q-tooltip>Envoyer un lien de paiement par SMS et courriel</q-tooltip>
</q-btn>
<!-- Submitted unpaid: Charge saved card -->
<q-btn v-if="isUnpaid" outline dense no-caps color="teal-7" icon="credit_card" label="Charger carte"
<q-btn v-if="isUnpaid" outline dense no-caps color="positive" icon="credit_card" label="Charger carte"
size="sm" :loading="chargingCard" @click="chargeSavedCard">
<q-tooltip>Debiter la carte enregistree du client</q-tooltip>
</q-btn>
<!-- PDF -->
<q-btn outline dense no-caps color="red-5" icon="picture_as_pdf" label="PDF"
<q-btn outline dense no-caps color="negative" icon="picture_as_pdf" label="PDF"
size="sm" @click="$emit('open-pdf', docName)" />
</div>
@ -67,7 +67,7 @@
<div class="mf"><span class="mf-label">Solde du</span><span :class="{'text-red': doc.outstanding_amount > 0}">{{ formatMoney(doc.outstanding_amount) }}</span></div>
<div class="mf"><span class="mf-label">Devise</span>{{ doc.currency || 'CAD' }}</div>
<div class="mf" v-if="doc.is_return"><span class="mf-label">Type</span><span class="text-red text-weight-medium">Note de credit</span></div>
<div class="mf" v-if="doc.return_against"><span class="mf-label">Renversement de</span><a class="text-indigo-6 cursor-pointer" @click="$emit('navigate', 'Sales Invoice', doc.return_against, 'Facture ' + doc.return_against)">{{ doc.return_against }}</a></div>
<div class="mf" v-if="doc.return_against"><span class="mf-label">Renversement de</span><a class="text-primary cursor-pointer" @click="$emit('navigate', 'Sales Invoice', doc.return_against, 'Facture ' + doc.return_against)">{{ doc.return_against }}</a></div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Remarques</div>
@ -76,7 +76,7 @@
</div>
<div v-if="doc.items?.length" class="q-mt-md">
<div class="info-block-title">Articles ({{ doc.items.length }})</div>
<q-table
<DataTable
:rows="doc.items" :columns="invItemCols" row-key="idx"
flat dense class="ops-table" hide-pagination :pagination="{ rowsPerPage: 0 }"
>
@ -86,7 +86,7 @@
<template #body-cell-rate="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.rate) }}</q-td>
</template>
</q-table>
</DataTable>
</div>
<div v-if="doc.taxes?.length" class="q-mt-md">
<div class="info-block-title">Taxes</div>
@ -130,6 +130,7 @@
<script setup>
import { ref, computed } from 'vue'
import DataTable from 'src/components/shared/DataTable.vue'
import { Notify, useQuasar } from 'quasar'
import { formatMoney, formatDateShort } from 'src/composables/useFormatters'
import { invStatusClass } from 'src/composables/useStatusClasses'
@ -336,7 +337,7 @@ async function sendPaymentLink () {
title: 'Envoyer un lien de paiement',
message: `Le client recevra un lien par SMS et courriel pour payer la facture ${props.docName} (${formatMoney(props.doc.outstanding_amount)}).`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'indigo-6', label: 'Envoyer', flat: true },
ok: { color: 'primary', label: 'Envoyer', flat: true },
persistent: true,
}).onOk(async () => {
sendingLink.value = true

View File

@ -5,7 +5,7 @@
icon="check_circle" label="Fermer le ticket" color="green-7" size="sm"
@click="closeTicket" :loading="closingTicket" />
<q-btn v-else dense unelevated no-caps
icon="refresh" label="Réouvrir" color="orange-7" size="sm"
icon="refresh" label="Réouvrir" color="warning" size="sm"
@click="reopenTicket" :loading="closingTicket" />
<FlowQuickButton flat dense size="sm" icon="account_tree" label="Flow"
tooltip="Lancer / modifier un flow pour ce ticket"
@ -55,7 +55,7 @@
:loading="loadingUsers"
@update:model-value="saveAssignees">
<template #selected-item="scope">
<q-chip dense removable size="sm" color="indigo-1" text-color="indigo-8"
<q-chip dense removable size="sm" color="green-1" text-color="green-8"
@remove="scope.removeAtIndex(scope.index)">
{{ scope.opt.label || scope.opt }}
</q-chip>
@ -117,7 +117,7 @@
<input v-else v-model="editingTagValue" class="tag-mgr-edit"
@keyup.enter="saveTagRename(t)" @keyup.escape="editingTagName = null" @blur="saveTagRename(t)" />
<span class="tag-mgr-cat text-caption text-grey-5">{{ t.category }}</span>
<q-btn flat dense round size="xs" icon="delete" color="red-4"
<q-btn flat dense round size="xs" icon="delete" color="negative"
@click="deleteDispatchTag(t)" class="tag-mgr-del">
<q-tooltip>Supprimer ce tag</q-tooltip>
</q-btn>
@ -140,7 +140,7 @@
<q-tooltip>Créer un projet (modèle multi-étapes)</q-tooltip>
</q-btn>
<q-btn dense size="sm" icon="add" label="Tâche"
color="indigo-6" no-caps @click="showCreateDialog = true">
color="primary" no-caps @click="showCreateDialog = true">
<q-tooltip>Ajouter une tâche simple</q-tooltip>
</q-btn>
</q-btn-group>
@ -227,7 +227,7 @@
<!-- Delete -->
<div class="q-mt-lg" style="border-top:1px solid #fee2e2;padding-top:12px">
<q-btn flat dense no-caps size="sm" icon="delete" label="Supprimer ce ticket"
color="red-6" @click="confirmDelete">
color="negative" @click="confirmDelete">
<q-tooltip>Suppression définitive du ticket</q-tooltip>
</q-btn>
</div>
@ -239,7 +239,7 @@
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
<div class="row justify-end q-mt-xs">
<q-btn unelevated dense size="sm" label="Envoyer" color="indigo-6" icon="send"
<q-btn unelevated dense size="sm" label="Envoyer" color="primary" icon="send"
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply" />
</div>
</div>

View File

@ -18,7 +18,7 @@
icon="cancel" label="Résilier le contrat"
:loading="isTerminating" @click="openTerminateDialog" />
<q-btn v-else-if="isTerminated && doc.termination_invoice" flat dense no-caps
icon="receipt" color="red-7" :label="doc.termination_invoice"
icon="receipt" color="negative" :label="doc.termination_invoice"
@click="$emit('navigate', 'Sales Invoice', doc.termination_invoice, 'Facture ' + doc.termination_invoice)" />
</div>
@ -71,7 +71,7 @@
</div>
<div class="mf">
<span class="mf-label">Résiduel</span>
<span :class="Number(doc.total_remaining_value) > 0 ? 'text-red-7 text-weight-bold' : 'text-grey-5'">
<span :class="Number(doc.total_remaining_value) > 0 ? 'text-negative text-weight-bold' : 'text-grey-5'">
{{ formatMoney(doc.total_remaining_value) }}
</span>
</div>
@ -117,13 +117,13 @@
</span>
<span v-else></span>
</td>
<td class="text-right text-red-7 text-weight-bold">{{ formatMoney(b.remaining_value) }}</td>
<td class="text-right text-negative text-weight-bold">{{ formatMoney(b.remaining_value) }}</td>
</tr>
<tr class="totals-row">
<td class="text-right text-weight-bold" colspan="3">Totaux</td>
<td class="text-right text-green-7 text-weight-bold">{{ formatMoney(totalBenefits) }}</td>
<td></td>
<td class="text-right text-red-7 text-weight-bold">{{ formatMoney(totalRemaining) }}</td>
<td class="text-right text-negative text-weight-bold">{{ formatMoney(totalRemaining) }}</td>
</tr>
</tbody>
</q-markup-table>
@ -137,14 +137,14 @@
Shown only when already résilié: date, reason, fee breakdown,
link to generated invoice. -->
<div v-if="isTerminated && (doc.terminated_at || doc.termination_fee_total)" class="q-mt-md term-box">
<div class="info-block-title text-red-7 row items-center">
<div class="info-block-title text-negative row items-center">
<q-icon name="cancel" size="14px" class="q-mr-xs" /> Résiliation
</div>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Date</span>{{ formatDate(doc.terminated_at) || '—' }}</div>
<div class="mf">
<span class="mf-label">Pénalité</span>
<span class="text-red-7 text-weight-bold">{{ formatMoney(doc.termination_fee_total) }}</span>
<span class="text-negative text-weight-bold">{{ formatMoney(doc.termination_fee_total) }}</span>
</div>
<div v-if="doc.termination_fee_benefits > 0" class="mf">
<span class="mf-label">Avantages</span>{{ formatMoney(doc.termination_fee_benefits) }}

View File

@ -1,157 +0,0 @@
// ── Find optimal technician for emergency mid-day job insertion ───────────────
// Scores each tech based on: GPS proximity, current load, skills match, queue fit
// Uses real-time GPS when available, falls back to last job coords or home base
// ─────────────────────────────────────────────────────────────────────────────
import { MAPBOX_TOKEN } from 'src/config/erpnext'
// Euclidean distance approximation in km (Montreal latitude)
function distKm (a, b) {
if (!a || !b || (!a[0] && !a[1]) || (!b[0] && !b[1])) return 999
const dx = (a[0] - b[0]) * 80 // 1° lng ≈ 80 km at 45°N
const dy = (a[1] - b[1]) * 111 // 1° lat ≈ 111 km
return Math.sqrt(dx * dx + dy * dy)
}
// Get tech's effective current position: GPS > last queued job > home
function techCurrentPos (tech, dateStr) {
if (tech.gpsCoords && tech.gpsOnline) return { coords: tech.gpsCoords, source: 'gps' }
const todayJobs = tech.queue.filter(j => j.scheduledDate === dateStr)
// Find last completed or in-progress job
const done = todayJobs.filter(j => j.status === 'completed' || j.status === 'en-route')
if (done.length) {
const last = done[done.length - 1]
if (last.coords && (last.coords[0] || last.coords[1])) return { coords: last.coords, source: 'lastJob' }
}
// Or next job in queue
if (todayJobs.length) {
const next = todayJobs.find(j => j.coords && (j.coords[0] || j.coords[1]))
if (next) return { coords: next.coords, source: 'nextJob' }
}
return { coords: tech.coords, source: 'home' }
}
// Compute load: total hours assigned today
function techDayLoad (tech, dateStr) {
return tech.queue
.filter(j => j.scheduledDate === dateStr)
.reduce((sum, j) => sum + (parseFloat(j.duration) || 1), 0)
}
// Best insertion point in tech's queue (minimizes detour)
function bestInsertionIdx (tech, jobCoords, dateStr) {
const dayJobs = tech.queue.filter(j => j.scheduledDate === dateStr)
if (!dayJobs.length) return 0
if (!jobCoords || (!jobCoords[0] && !jobCoords[1])) return dayJobs.length
// Try each insertion point, pick the one with least total detour
let bestIdx = dayJobs.length, bestDetour = Infinity
for (let i = 0; i <= dayJobs.length; i++) {
const prev = i === 0 ? (tech.gpsCoords || tech.coords) : dayJobs[i - 1].coords
const next = i < dayJobs.length ? dayJobs[i].coords : null
const directDist = next ? distKm(prev, next) : 0
const detour = distKm(prev, jobCoords) + (next ? distKm(jobCoords, next) : 0) - directDist
if (detour < bestDetour) { bestDetour = detour; bestIdx = i }
}
return bestIdx
}
/**
* Score and rank all technicians for an emergency job.
*
* @param {Object} params
* @param {Array} params.technicians - All available techs
* @param {Array} params.jobCoords - [lng, lat] of the emergency job
* @param {number} params.jobDuration - Hours needed
* @param {Array} params.jobTags - Required skill tags
* @param {string} params.dateStr - YYYY-MM-DD (today)
* @returns {Array} Ranked techs: [{ tech, score, distance, load, insertIdx, reasons }]
*/
export function rankTechs ({ technicians, jobCoords, jobDuration = 1, jobTags = [], dateStr }) {
const hasCoords = jobCoords && (jobCoords[0] || jobCoords[1])
const candidates = technicians.filter(t => t.status !== 'off' && t.status !== 'unavailable')
const scored = candidates.map(tech => {
const pos = techCurrentPos(tech, dateStr)
const distance = hasCoords ? distKm(pos.coords, jobCoords) : 999
const load = techDayLoad(tech, dateStr)
const remainingCap = Math.max(0, 8 - load)
const insertIdx = hasCoords ? bestInsertionIdx(tech, jobCoords, dateStr) : tech.queue.filter(j => j.scheduledDate === dateStr).length
// ── Scoring (lower = better) ──
let score = 0
const reasons = []
// 1. Proximity (weight: 40%) — distance in km, normalized to ~0-100
const proxScore = Math.min(distance, 100)
score += proxScore * 4
if (distance < 5) reasons.push(`📍 ${distance.toFixed(1)} km (très proche)`)
else if (distance < 15) reasons.push(`📍 ${distance.toFixed(1)} km`)
else reasons.push(`📍 ${distance.toFixed(1)} km (loin)`)
// 2. Load balance (weight: 30%) — prefer techs with capacity
const loadScore = load * 10 // 0-80 range
score += loadScore * 3
if (remainingCap < jobDuration) {
score += 500 // Heavy penalty: can't fit the job
reasons.push(`⚠ Surchargé (${load.toFixed(1)}h/${8}h)`)
} else if (load < 4) {
reasons.push(`✓ Dispo (${load.toFixed(1)}h/${8}h)`)
} else {
reasons.push(`◐ Chargé (${load.toFixed(1)}h/${8}h)`)
}
// 3. Skills match (weight: 20%)
if (jobTags.length) {
const techTags = tech.tags || []
const missing = jobTags.filter(t => !techTags.includes(t))
score += missing.length * 200
if (missing.length) reasons.push(`⚠ Manque: ${missing.join(', ')}`)
else reasons.push('✓ Skills OK')
}
// 4. GPS freshness bonus (weight: 10%) — trust live GPS more
if (pos.source === 'gps') {
score -= 20 // Bonus for having live GPS
reasons.push('🛰 GPS en direct')
} else {
reasons.push(`📌 Position: ${pos.source === 'home' ? 'domicile' : 'estimée'}`)
}
return { tech, score, distance, load, remainingCap, insertIdx, posSource: pos.source, reasons }
})
return scored.sort((a, b) => a.score - b.score)
}
/**
* Get real driving times from Mapbox for top N candidates (optional refinement).
* Updates the distance/score with actual driving duration.
*/
export async function refineWithDrivingTimes (ranked, jobCoords, topN = 3) {
if (!jobCoords || (!jobCoords[0] && !jobCoords[1])) return ranked
const top = ranked.slice(0, topN)
const rest = ranked.slice(topN)
const refined = await Promise.all(top.map(async (r) => {
const techPos = r.tech.gpsCoords || r.tech.coords
if (!techPos || (!techPos[0] && !techPos[1])) return r
try {
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${techPos[0]},${techPos[1]};${jobCoords[0]},${jobCoords[1]}?overview=false&access_token=${MAPBOX_TOKEN}`
const res = await fetch(url)
const data = await res.json()
if (data.routes?.[0]) {
const mins = Math.round(data.routes[0].duration / 60)
const km = (data.routes[0].distance / 1000).toFixed(1)
r.drivingMins = mins
r.drivingKm = km
// Replace proximity score with actual driving time
r.score = mins * 4 + r.load * 30 + (r.reasons.some(r => r.includes('Manque')) ? 200 : 0)
r.reasons[0] = `🚗 ${mins} min (${km} km)`
}
} catch { /* keep euclidean estimate */ }
return r
}))
return [...refined.sort((a, b) => a.score - b.score), ...rest]
}

View File

@ -22,6 +22,8 @@ export function useClientData (deps) {
const serviceContracts = ref([])
const comments = ref([])
const accountBalance = ref(null)
const refundsByPi = ref({}) // { pi_xxx: { amount_refunded, fully_refunded, status, refunded_at } } — statut remboursement Stripe par PaymentIntent
const stripeCharges = ref([]) // charges Stripe réelles du client (peuvent ne pas être des Payment Entry ERPNext)
const loadingMoreTickets = ref(false)
const loadingMoreInvoices = ref(false)
@ -47,6 +49,8 @@ export function useClientData (deps) {
ticketsExpanded.value = false
invoicesExpanded.value = false
paymentsExpanded.value = false
refundsByPi.value = {}
stripeCharges.value = []
}
function loadLocations (custFilter) {
@ -166,6 +170,21 @@ export function useClientData (deps) {
}).then(r => r.json()).then(d => d.methods || []).catch(() => [])
}
// Statut de remboursement Stripe par PaymentIntent (1 appel hub → 1 appel Stripe) → badges dans le bloc paiements
function loadRefunds (custId) {
return fetch(`${_HUB_URL}/payments/refunds/${encodeURIComponent(custId)}`, {
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()).then(d => d.refunds || {}).catch(() => ({}))
}
// Charges Stripe réelles (auto-PPA / lien de paiement) — comble le trou où un paiement Stripe non synchronisé
// en Payment Entry ERPNext n'apparaissait pas sur la fiche. Lecture seule, non bloquant.
function loadStripeCharges (custId) {
return fetch(`${_HUB_URL}/payments/charges/${encodeURIComponent(custId)}`, {
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()).then(d => d.charges || []).catch(() => ([]))
}
function loadArrangements (custFilter) {
return listDocs('Payment Arrangement', {
filters: custFilter,
@ -248,6 +267,8 @@ export function useClientData (deps) {
tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || ''))
invoices.value = invs
payments.value = pays
loadRefunds(id).then(m => { refundsByPi.value = m || {} }) // remboursements Stripe (non bloquant) → badges dans le bloc paiements
loadStripeCharges(id).then(c => { stripeCharges.value = c || [] }) // charges Stripe réelles (non bloquant) → bloc paiements
voipLines.value = voip
paymentMethods.value = pmethods
arrangements.value = arrgs
@ -311,7 +332,7 @@ export function useClientData (deps) {
return {
loading, customer, contact, locations, subscriptions, tickets,
invoices, payments, voipLines, paymentMethods, arrangements, quotations,
serviceContracts, comments, accountBalance,
serviceContracts, comments, accountBalance, refundsByPi, stripeCharges,
loadingMoreTickets, loadingMoreInvoices, loadingMorePayments,
loadCustomer, loadAllTickets, loadAllInvoices, loadAllPayments,
}

View File

@ -0,0 +1,81 @@
// useConversationDisplay.js — resolvers UNIQUES pour l'affichage des conversations (cohérence Boîte,
// cap « feeling Missive »). Principe : UN calcul par concept, importé par la liste ET le détail.
// (cf docs/design/communications-coherence.md)
// Nos domaines internes — distinguent une adresse de GROUPE interne d'un destinataire externe.
import { staffColor, staffInitials } from './useFormatters'
const OUR_DOMAINS = ['targointernet.com', 'targointernet.ca', 'targo.ca', 'gigafibre.ca']
// Adresse de groupe (local part) → nom d'équipe affiché (sinon on title-case le local part).
const GROUP_LABELS = {
support: 'Support', supports: 'Support',
facturation: 'Facturation', facturations: 'Facturation', billing: 'Facturation',
service: 'Service à la clientèle', clientele: 'Service à la clientèle',
ventes: 'Ventes', sales: 'Ventes',
technique: 'Technicien', tech: 'Technicien', technicien: 'Technicien',
info: 'Info', contact: 'Info', allo: 'Info',
noreply: 'No-reply', 'no-reply': 'No-reply', noresponse: 'No-reply',
}
export function titleCase (s) {
return String(s || '').replace(/[._-]+/g, ' ').replace(/\s+/g, ' ').trim().replace(/\b\w/g, c => c.toUpperCase())
}
// recipientLabel(addr, meEmail) :
// • 'moi' si l'adresse EST celle de l'utilisateur connecté (et SEULEMENT dans ce cas) ;
// • nom d'équipe si c'est une de NOS adresses de groupe (support@ → « Support ») ;
// • '' sinon (externe) → l'appelant utilise le display-name / local part.
// Corrige le bug « à moi » : une boîte de groupe (support@, facturation@…) n'est JAMAIS « moi ».
export function recipientLabel (addr, meEmail) {
const a = String(addr || '').toLowerCase().trim()
if (!a.includes('@')) return ''
const me = String(meEmail || '').toLowerCase().trim()
if (me && a === me) return 'moi'
const [local, domain] = a.split('@')
if (OUR_DOMAINS.includes(domain)) return GROUP_LABELS[local] || titleCase(local)
return ''
}
// email → « Prénom Nom » (réutilise noteAuthorName) ; '' si pas d'email → laisse la chaîne de repli continuer.
function nameFromEmail (email) { const e = String(email || '').trim(); return e.includes('@') ? titleCase(e.split('@')[0]) : '' }
// messageIdentity(m, ctx) → { name, initials, color } COHÉRENTS pour UN message (même résolution liste + détail).
// Nom JAMAIS vide (sauf 'system') ; agents capitalisés « Prénom Nom » (fini le « gilles.drolet » minuscule) ;
// avatar (couleur + initiales) dérivé du MÊME nom/clé → plus de divergence nom↔avatar.
export function messageIdentity (m, ctx = {}) {
if (!m) return { name: '', initials: '', color: '#9e9e9e' }
let name = ''
if (m.from === 'system') name = ''
else if (m.fromName) name = m.fromName
else if (m.from === 'agent') name = (m.via === 'ai') ? 'IA' : (m.agent ? (nameFromEmail(m.agent) || 'Nous') : 'Nous')
else name = ctx.customerName || nameFromEmail(m.fromEmail) || ctx.email || 'Client'
const key = m.agent || m.fromEmail || name || '?'
return { name, initials: name ? staffInitials(name) : '', color: staffColor(key) }
}
// channelMeta(channel) → { icon, color, label } pour un badge de canal COHÉRENT (liste + détail).
// Email = gris discret (cas par défaut, pas de bruit) ; les autres colorés. Messenger = FB Messenger.
const CHANNELS = {
email: { icon: 'mail', color: 'grey-5', label: 'Courriel' },
sms: { icon: 'sms', color: 'teal-6', label: 'SMS' },
webchat: { icon: 'forum', color: 'purple-5', label: 'Chat web' },
facebook: { icon: 'chat', color: 'blue-6', label: 'Messenger' },
messenger: { icon: 'chat', color: 'blue-6', label: 'Messenger' },
}
export function channelMeta (channel) {
return CHANNELS[channel] || { icon: 'chat_bubble', color: 'grey-5', label: channel || 'Message' }
}
// priorityMeta(p) → { icon, color, label, rank } pour le DRAPEAU de priorité (style ClickUp), cohérent liste + détail.
// rank = tri (urgent en haut). '' / 'none' = aucune priorité → drapeau contour discret (invite à classer).
const PRIORITIES = {
urgent: { icon: 'flag', color: 'red-6', label: 'Urgent', rank: 4 },
high: { icon: 'flag', color: 'orange-7', label: 'Haute', rank: 3 },
normal: { icon: 'flag', color: 'blue-6', label: 'Normale', rank: 2 },
low: { icon: 'flag', color: 'blue-grey-5', label: 'Basse', rank: 1 },
}
export const PRIORITY_LEVELS = ['urgent', 'high', 'normal', 'low'] // ordre d'affichage du menu (plus urgent → moins)
export function priorityMeta (p) {
return PRIORITIES[p] || { icon: 'outlined_flag', color: 'grey-5', label: 'Aucune', rank: 0 }
}

View File

@ -2,6 +2,7 @@ import { ref, computed } from 'vue'
import { useAuthStore } from 'src/stores/auth'
import { HUB_URL } from 'src/config/hub'
import { setAgentRoster } from 'src/composables/useFormatters'
function agentHeaders (extra = {}) {
try {
@ -124,8 +125,22 @@ export function useConversations () {
// charge le DÉTAIL (notes, file, assigné, gmailReplyUrl, suggestion) pour la barre de coordination
fetch(`${HUB_URL}/conversations/${tok}`).then(r => r.ok ? r.json() : null).then(d => { if (d && activeToken.value === tok) activeConv.value = d }).catch(() => {})
}
loadDiscussionMessages(disc) // la LISTE est allégée (pas de messages) → on charge/fusionne les messages des tokens ici
connectDiscussionSSE(disc.conversations.map(c => c.token))
}
// Charge + fusionne les messages de tous les tokens d'une discussion (liste légère → messages à la demande).
async function loadDiscussionMessages (disc) {
if (!disc || (Array.isArray(disc.messages) && disc.messages.length)) return // déjà chargés
const toks = (disc.conversations || []).map(c => c.token).filter(Boolean)
if (!toks.length) return
try {
const results = await Promise.all(toks.map(t => fetch(`${HUB_URL}/conversations/${t}`).then(r => r.ok ? r.json() : null).catch(() => null)))
const merged = []
results.forEach((d, i) => { if (d && Array.isArray(d.messages)) for (const m of d.messages) merged.push({ ...m, convToken: toks[i] }) })
merged.sort((a, b) => String(a.ts || '').localeCompare(String(b.ts || '')))
if (activeDiscussion.value && (activeDiscussion.value === disc || activeDiscussion.value.id === disc.id)) { disc.messages = merged; disc.messageCount = merged.length }
} catch {}
}
async function openConversation (token) {
activeToken.value = token
@ -219,8 +234,40 @@ 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, attachments, sendAs) {
const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ text, media, html, attachments, sendAs }) })
// Recherche FLOUE omnichannel (SQL pg_trgm côté hub) : nom client / courriel / sujet + corps des messages, typo-tolérante.
async function searchConversations (q) {
const term = String(q || '').trim()
if (!term) return []
try {
const res = await fetch(`${HUB_URL}/conversations/search?q=${encodeURIComponent(term)}`, { headers: agentHeaders() })
if (!res.ok) return []
const d = await res.json()
return Array.isArray(d.results) ? d.results : []
} catch { return [] }
}
// Priorité (style ClickUp) : urgent | high | normal | low | '' (aucune). Patch optimiste de la liste + du détail.
async function setPriority (token, priority) {
const res = await fetch(`${HUB_URL}/conversations/${token}/priority`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ priority }) })
if (!res.ok) throw new Error('Échec priorité')
const d = await res.json()
if (activeConv.value && activeToken.value === token) activeConv.value.priority = d.priority
const disc = discussions.value.find(x => x.token === token || (x.conversations || []).some(c => c.token === token))
if (disc) disc.priority = d.priority
return d.priority
}
// Lier (interdépendances) : relie un fil à un autre fil / fiche / ticket (ou retire avec remove:true).
async function relateConversation (token, { targetType, targetId, targetLabel, rel, remove } = {}) {
const res = await fetch(`${HUB_URL}/conversations/${token}/relate`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ targetType, targetId, targetLabel, rel, remove: !!remove }) })
if (!res.ok) throw new Error('Échec du lien')
const d = await res.json()
if (activeConv.value && activeToken.value === token) activeConv.value.links = d.links || []
return d.links || []
}
async function sendMessage (token, text, media, html, attachments, sendAs, cc, replyTo) {
const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ text, media, html, attachments, sendAs, cc: cc || '', to: replyTo || '' }) })
if (!res.ok) throw new Error('Failed to send')
return res.json()
}
@ -352,7 +399,7 @@ export function useConversations () {
await fetchList(); return d
}
let _agents = null
async function fetchAgents () { if (_agents) return _agents; try { const r = await fetch(`${HUB_URL}/conversations/agents`, { headers: agentHeaders() }); if (r.ok) { const d = await r.json(); _agents = d.agents || [] } } catch {} return _agents || [] }
async function fetchAgents () { if (_agents && _agents.length) return _agents; try { const r = await fetch(`${HUB_URL}/conversations/agents`, { headers: agentHeaders() }); if (r.ok) { const d = await r.json(); if (d.names) setAgentRoster(d.names); if (d.agents && d.agents.length) _agents = d.agents } } catch {} return _agents || [] } // peuple aussi le roster nom-complet ; ne cache PAS une liste vide → réessaie au prochain appel
// Spam → marque le(s) courriel(s) SPAM côté Gmail + retire la conversation.
async function spamConv (token) { const r = await fetch(`${HUB_URL}/conversations/${token}/spam`, { method: 'POST', headers: agentHeaders() }); const d = await r.json().catch(() => ({})); await fetchList(); return d }
// Suppression par TOKEN (≠ deleteDiscussion par phone/date) → côté hub, met aussi les courriels liés à la corbeille Gmail.
@ -498,13 +545,13 @@ export function useConversations () {
try { await fetch(`${HUB_URL}/conversations/${token}/read`, { method: 'POST', headers: agentHeaders() }) } catch {}
}
// Pousse le brouillon courant aux autres agents (throttle frappe ; l'envoi VIDE passe toujours pour effacer le miroir).
function pushDraft (token, html, text) {
function pushDraft (token, html, text, replyTo) {
if (!token) return
const empty = !String(html || '').replace(/<[^>]+>/g, '').trim() && !String(text || '').trim()
const now = Date.now()
if (!empty && now - _lastDraftSent < 700) return
_lastDraftSent = now
try { fetch(`${HUB_URL}/conversations/${token}/draft?sid=${_tabSid}`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ html: html || '', text: text || '' }) }) } catch {}
try { fetch(`${HUB_URL}/conversations/${token}/draft?sid=${_tabSid}`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ html: html || '', text: text || '', replyTo: replyTo || null }) }) } catch {}
}
const activeCount = computed(() => discussions.value.filter(d => d.status === 'active').length)
@ -512,7 +559,7 @@ export function useConversations () {
return {
conversations, discussions, activeToken, activeDiscussion, activeConv, tickets,
loading, panelOpen, newDialogOpen, newDialogChannel, composePrefill, openComposeDraft, createStandaloneTicket, showAll, selectedIds, selectedDiscussions,
fetchList, fetchTickets, openDiscussion, openConversation, sendMessage, pollNow, startConversation, startEmail, resolvePhone, resolveEmail, linkCustomer,
fetchList, fetchTickets, openDiscussion, openConversation, sendMessage, pollNow, startConversation, startEmail, resolvePhone, resolveEmail, searchConversations, relateConversation, setPriority, linkCustomer,
closeConversation, deleteDiscussion, bulkDelete, archiveDiscussion, bulkArchive, createTicket,
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,

View File

@ -0,0 +1,23 @@
/**
* Export CSV unifié un seul BOM UTF-8 (), échappement de guillemets cohérent, fin de ligne CRLF.
* Remplace les ~6 copies divergentes de downloadCSV/exportCsv des pages de rapports (deux octets BOM différents avant).
*
* exportCsv('releve_juin', ['Date','Montant'], [['2026-06-01','12.00']], { totalRow: ['TOTAL','12.00'] })
*/
export function exportCsv (filename, headers, rows, { totalRow } = {}) {
const esc = (v) => '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"'
const lines = [headers.map(esc).join(',')]
for (const r of rows) lines.push(r.map(esc).join(','))
if (totalRow) lines.push(totalRow.map(esc).join(','))
const csv = '' + lines.join('\r\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename.endsWith('.csv') ? filename : filename + '.csv'
document.body.appendChild(a)
a.click()
setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 0)
}
export function useCsvExport () { return { exportCsv } }

View File

@ -0,0 +1,9 @@
// Rendu sûr d'un corps de courriel HTML dans une iframe sandbox (srcdoc).
// Fonction pure — partagée entre l'aperçu inline (liste des messages) et le viewer plein écran.
// Extrait de ConversationPanel (découpage 2026-07-01).
export function emailSrcdoc (html) {
const safe = String(html || '')
return `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><meta name="viewport" content="width=device-width,initial-scale=1">` +
`<style>html,body{margin:0;padding:10px;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;font-size:13px;color:#1e293b;word-break:break-word}img{max-width:100%;height:auto}a{color:#4f46e5}table{max-width:100%}</style>` +
`</head><body>${safe}</body></html>`
}

View File

@ -12,7 +12,9 @@ const ERP_BASE = 'https://erp.gigafibre.ca'
*/
export function formatDate (d) {
if (!d) return '—'
return new Date(d).toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric' })
const dt = new Date(d)
if (isNaN(dt.getTime())) return '—' // jamais « Invalid Date » : date absente/illisible → tiret
return dt.toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric' })
}
/**
@ -112,8 +114,15 @@ export function decodeHtml (str) {
* @param {string|null} email
* @returns {string}
*/
// Roster d'équipe (courriel → NOM COMPLET réel), peuplé au chargement des agents (useConversations.fetchAgents → /conversations/agents).
// Permet d'AFFICHER et de RECHERCHER « Michel Blais » plutôt que le seul préfixe courriel « michel » dans l'assignation et les @mentions.
const AGENT_ROSTER = {}
export function setAgentRoster (map) { if (map && typeof map === 'object') for (const k of Object.keys(map)) if (map[k]) AGENT_ROSTER[String(k).toLowerCase()] = map[k] }
export function noteAuthorName (email) {
if (!email) return 'Système'
const real = AGENT_ROSTER[String(email).toLowerCase()]
if (real) return real
const local = email.split('@')[0]
return local.charAt(0).toUpperCase() + local.slice(1).replace(/[._-]/g, ' ')
}

View File

@ -0,0 +1,29 @@
import { ref, onMounted, onBeforeUnmount } from 'vue'
// matchMedia réactif (basé CSS) — fiable partout, y compris là où `$q.screen` ne se met pas à jour
// (aperçu headless), et SSR-safe. Renvoie un ref booléen qui suit le media query.
//
// const isMobile = useIsMobile() // true sous 1024px (tablette/téléphone)
// const isPhone = useIsPhone() // true sous 600px
export function useMediaQuery (query) {
const matches = ref(false)
let mql = null
const update = () => { matches.value = !!(mql && mql.matches) }
onMounted(() => {
if (typeof window === 'undefined' || !window.matchMedia) return
mql = window.matchMedia(query)
update()
if (mql.addEventListener) mql.addEventListener('change', update)
else if (mql.addListener) mql.addListener(update) // anciens Safari
})
onBeforeUnmount(() => {
if (!mql) return
if (mql.removeEventListener) mql.removeEventListener('change', update)
else if (mql.removeListener) mql.removeListener(update)
})
return matches
}
// Seuils alignés sur Quasar (xs<600, sm 600-1023, md 1024+).
export const useIsPhone = () => useMediaQuery('(max-width: 599px)')
export const useIsMobile = () => useMediaQuery('(max-width: 1023px)')

View File

@ -12,18 +12,20 @@ import { useAuthStore } from 'src/stores/auth'
export const FEEDS = [
{ key: 'sms', label: 'Textos (SMS)', icon: 'sms', color: 'teal-7' },
{ key: 'webchat', label: 'Clavardage web', icon: 'chat', color: 'indigo-6' },
{ key: 'webchat', label: 'Clavardage web', icon: 'chat', color: 'primary' },
{ key: '3cx', label: 'Appels (3CX)', icon: 'call', color: 'blue-7' },
{ key: 'call', label: 'Appels entrants (ligne principale)', icon: 'phone_in_talk', color: 'green-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 }
// Défaut : canaux TEMPS RÉEL + appels entrants + évaluations ON ; courriel OFF (asynchrone, fort volume → l'Inbox suffit).
const DEFAULTS = { sms: true, webchat: true, '3cx': true, call: 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 })
const myQueues = ref({ queues: [], mine: [], notify: {}, labels: {} }) // abonnement PAR AGENT aux files + notif par file
let es = null
let _seq = 0
@ -41,6 +43,7 @@ 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.type === 'call' && n.customer) return nav('/clients/' + encodeURIComponent(n.customer))
if (n && n.conv) return nav('/communications/c/' + encodeURIComponent(n.conv))
nav('/communications')
}
@ -51,6 +54,16 @@ async function loadPrefs () {
async function savePrefs () {
try { await fetch(`${HUB_URL}/conversations/notif-prefs`, { method: 'PUT', headers: headers(), body: JSON.stringify({ prefs: prefs.value }) }) } catch { /* */ }
}
// Files (self-service) : mes abonnements + notif par file.
async function loadMyQueues () {
try { const r = await fetch(`${HUB_URL}/conversations/my-queues`, { headers: headers() }); if (r.ok) myQueues.value = await r.json() } catch { /* */ }
}
async function setQueueSub (queue, subscribe) {
try { await fetch(`${HUB_URL}/conversations/my-queues`, { method: 'POST', headers: headers(), body: JSON.stringify({ queue, subscribe }) }); await loadMyQueues() } catch { /* */ }
}
async function setQueueNotify (queue, notify) {
try { await fetch(`${HUB_URL}/conversations/my-queues`, { method: 'POST', headers: headers(), body: JSON.stringify({ queue, notify }) }); await loadMyQueues() } catch { /* */ }
}
function onRating (d) {
if (!prefs.value.ratings) return
@ -62,9 +75,9 @@ 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' }) }] })
toast({ message: `Commentaire — ${name}`, caption: (d.comment || '').slice(0, 90), color: 'green-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' } }
const CH_META = { sms: { icon: 'sms', label: 'SMS', color: 'teal-8' }, webchat: { icon: 'chat', label: 'Clavardage', color: 'green-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)
@ -81,9 +94,20 @@ function onConvEmail (d) {
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 }) }] })
}
// Appel entrant (ligne principale Twilio) → screen-pop coin haut-droit : nom du compte lié + numéro + « Ouvrir la fiche ».
function onCallIncoming (d) {
if (!prefs.value.call || !d) return
// Par FILE : si l'appel cible une file, ne notifier que les agents abonnés à cette file avec notif ON.
if (d.queue) { const mq = myQueues.value; if (!(mq.mine || []).includes(d.queue) || (mq.notify || {})[d.queue] === false) return }
const known = !!d.customerId
const name = d.customerName || (known ? 'Client' : 'Appelant inconnu')
const sub = (d.from || '') + (d.territory ? ' · ' + d.territory : '')
add({ type: 'call', feed: 'call', customer: d.customerId || '', title: '📞 ' + name, caption: 'Appel entrant · ' + sub, icon: 'phone_in_talk' })
toast({ message: 'Appel entrant — ' + name, caption: sub + (known ? '' : ' · numéro non reconnu'), color: known ? 'green-8' : 'blue-grey-8', textColor: 'white', icon: 'phone_in_talk', position: 'top-right', timeout: 15000, actions: known ? [{ label: 'Ouvrir la fiche', color: 'white', handler: () => nav('/clients/' + encodeURIComponent(d.customerId)) }] : [] })
}
function initNotifications () {
loadPrefs()
loadPrefs(); loadMyQueues()
if (es) return
try {
es = new EventSource(`${HUB_URL}/sse?topics=outbox,conversations`)
@ -91,8 +115,9 @@ function initNotifications () {
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 {} })
es.addEventListener('call-incoming', e => { try { onCallIncoming(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 } }
export function useNotifications () { return { notifications, unread, prefs, FEEDS, myQueues, loadMyQueues, setQueueSub, setQueueNotify, initNotifications, markAllRead, savePrefs, loadPrefs, go } }

View File

@ -3,7 +3,10 @@ import { ref, computed } from 'vue'
import { HUB_URL } from 'src/config/hub'
// Singleton state — shared across all components
const permissions = ref(null) // { email, username, name, groups, is_superuser, capabilities, overrides }
// DEV local (pas de hub devant pour /auth/permissions) : profil permissif d'emblée pour pouvoir prévisualiser/auditer
// toutes les pages. En PROD : null → chargé depuis le hub (restrictif tant que non chargé). Aucun effet en prod.
const _DEV_LOCAL = typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
const permissions = ref(_DEV_LOCAL ? { email: 'dev@localhost', name: 'Dev (local)', is_superuser: true, capabilities: {}, groups: ['admin'] } : null) // { email, username, name, groups, is_superuser, capabilities, overrides }
const loading = ref(false)
const error = ref(null)
@ -32,8 +35,10 @@ async function loadPermissions (email) {
.catch(e => {
console.error('[usePermissions]', e.message)
error.value = e.message
// Fallback: allow everything for superusers if fetch fails
permissions.value = null
// DEV local (pas de hub devant) : le fetch échoue → on débloque l'app avec un profil permissif
// pour pouvoir prévisualiser/auditer toutes les pages. En PROD on reste null (= restrictif/sûr).
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
permissions.value = isLocal ? { email, is_superuser: true, capabilities: {}, groups: ['admin'] } : null
})
.finally(() => {
loading.value = false

View File

@ -0,0 +1,28 @@
// Garde la session Authentik VIVANTE pour éviter la coupure « il faut recharger pour que les données/recherche reviennent ».
// Cause : la SPA + /api + /hub sont derrière Authentik ; la session (cookie) expire après une durée d'inactivité.
// Les appels /api (authFetch) se rechargent seuls (_reauth), MAIS les appels /hub (fetch nu) échouent en silence → données absentes.
// Solution : un battement régulier qui (a) RAFRAÎCHIT la session (fenêtre glissante) et (b) recharge proprement si elle est morte.
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
let _timer = null
let _started = false
// Ping léger à travers la porte Authentik. authFetch gère _reauth (recharge 1×/20 s si 401/redirection IdP/HTML).
async function ping () {
try { await authFetch(BASE_URL + '/auth/whoami') } catch (e) { /* réseau transitoire → on réessaie au prochain tick */ }
}
// Démarre le battement (prod uniquement — pas d'Authentik en dev local). Idempotent.
export function startSessionKeepAlive ({ minutes = 4 } = {}) {
if (_started) return
const host = window.location.hostname
if (host === 'localhost' || host === '127.0.0.1') return // dev : pas de front Authentik → inutile + éviterait les reloads
_started = true
_timer = setInterval(ping, Math.max(1, minutes) * 60 * 1000) // < timeout Authentik → la session ne meurt jamais tant que l'onglet est ouvert
// Retour sur l'onglet après une pause → vérifie/rafraîchit TOUT DE SUITE (le cas le plus courant de « c'est figé »).
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') ping() })
ping() // amorçage immédiat
}
export function stopSessionKeepAlive () { if (_timer) { clearInterval(_timer); _timer = null } _started = false }

View File

@ -0,0 +1,76 @@
import { ref } from 'vue'
import { HUB_URL } from 'src/config/hub'
// ─────────────────────────────────────────────────────────────────────────────
// SLA côté Ops : charge les politiques (par FILE=issue_type + PRIORITÉ) depuis le hub,
// et calcule l'état d'un ticket (réponse d'abord tant que non répondu, puis résolution).
// const { slaFor, savePolicies, policies, defaults } = useSla()
// slaFor(ticket) → { state:'ok'|'at_risk'|'breached'|'met'|'none', kind, label, remainMins }
// ─────────────────────────────────────────────────────────────────────────────
let _policies = null
let _defaults = {}
let _loadP = null
const policies = ref(null)
const defaults = ref({})
function loadPolicies () {
if (_policies) return Promise.resolve(_policies)
if (!_loadP) {
_loadP = fetch(`${HUB_URL}/sla/policies`).then(r => r.ok ? r.json() : {})
.then(d => { _policies = d.policies || {}; _defaults = d.defaults || {}; policies.value = _policies; defaults.value = _defaults; return _policies })
.catch(() => { _policies = {}; policies.value = {}; return {} })
}
return _loadP
}
async function savePolicies (pol) {
_policies = pol; policies.value = pol
const r = await fetch(`${HUB_URL}/sla/policies`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ policies: pol }) })
return r.ok
}
function policyFor (issueType, priority) {
const p = policies.value || _policies || {} // policies.value = ref réactif → recalcule quand les politiques se chargent
return (p[issueType] && p[issueType][priority]) || (p._default && p._default[priority]) || null
}
function fmt (mins) {
const a = Math.abs(Math.round(mins))
if (a < 60) return a + ' min'
if (a < 1440) { const h = Math.floor(a / 60); const m = a % 60; return h + ' h' + (m ? ' ' + m : '') }
const j = Math.floor(a / 1440); const h = Math.floor((a % 1440) / 60); return j + ' j' + (h ? ' ' + h + ' h' : '')
}
export function useSla () {
loadPolicies()
function slaFor (t) {
if (!t) return { state: 'none' }
const pol = policyFor(t.issue_type, t.priority)
if (!pol) return { state: 'none' }
const od = t.opening_date || t.creation
if (!od) return { state: 'none' }
const opening = new Date(String(od).length <= 10 ? od + 'T00:00:00' : od)
if (isNaN(opening.getTime())) return { state: 'none' }
if (['Resolved', 'Closed'].includes(t.status)) return { state: 'met', label: 'SLA clos' }
const now = Date.now()
const band = (remain, target, kind, verb) => {
if (remain < 0) return { state: 'breached', kind, label: verb + ' en retard ' + fmt(remain), remainMins: remain }
if (remain < target * 0.25) return { state: 'at_risk', kind, label: verb + ' dans ' + fmt(remain), remainMins: remain }
return { state: 'ok', kind, label: verb + ' dans ' + fmt(remain), remainMins: remain }
}
// Réponse (1ʳᵉ réponse) prioritaire tant que pas encore répondu
if (!t.first_responded_on && pol.response) return band((opening.getTime() + pol.response * 60000 - now) / 60000, pol.response, 'response', 'Réponse')
// Sinon résolution
if (pol.resolution) return band((opening.getTime() + pol.resolution * 60000 - now) / 60000, pol.resolution, 'resolution', 'Résolution')
return { state: 'none' }
}
return { slaFor, loadPolicies, savePolicies, policies, defaults }
}
// Couleurs de pastille par état (réutilisable).
export const SLA_BADGE = {
breached: { color: 'red-2', text: 'red-9', icon: 'error' },
at_risk: { color: 'orange-2', text: 'orange-9', icon: 'schedule' },
ok: { color: 'green-2', text: 'green-9', icon: 'schedule' },
met: { color: 'grey-3', text: 'grey-7', icon: 'check' },
}

View File

@ -0,0 +1,43 @@
import { ref } from 'vue'
import { Notify } from 'quasar'
import { createDoc } from 'src/api/erp'
// Softphone PARTAGÉ (Twilio WebRTC + repli SIP) — état singleton pilotable de n'importe où (ConversationPanel, fiche client…).
// Le dialer lui-même = <PhoneModal> monté UNE seule fois globalement (MainLayout) et lié à cet état. Remplace l'ancien
// montage exclusif dans ChatterPanel → l'appel intégré existe maintenant dans le panneau de comms unifié.
const open = ref(false)
const number = ref('')
const customer = ref('') // nom ERPNext (C-…) pour lier le log d'appel à la bonne fiche
const customerName = ref('') // nom affiché dans le dialer
const provider = ref('twilio')
// Ouvre le dialer pré-rempli + (si fournie) la fiche à laquelle lier le log d'appel.
function call (num, ctx = {}) {
number.value = String(num || '').trim()
customer.value = ctx.customer || ''
customerName.value = ctx.customerName || ''
provider.value = ctx.provider || 'twilio'
open.value = true
}
// Log d'appel = créateur UNIQUE (corrige l'ancien double-log PhoneModal+ChatterPanel) : un seul doc Communication par appel.
async function handleCallEnded (info) {
const phone = (info && info.remote) || number.value
if (!customer.value || !phone) return // pas de fiche liée → on ne crée pas de Communication orpheline
const dur = (info && info.duration) || 0
const durationStr = `${Math.floor(dur / 60)}m${String(dur % 60).padStart(2, '0')}s`
const incoming = info && info.direction === 'in'
try {
await createDoc('Communication', {
subject: (incoming ? 'Appel de ' : 'Appel vers ') + phone,
communication_type: 'Communication', communication_medium: 'Phone',
sent_or_received: incoming ? 'Received' : 'Sent',
status: 'Linked', phone_no: phone, sender: 'sms@gigafibre.ca',
sender_full_name: incoming ? ((info && info.remoteName) || phone) : 'Targo Ops',
content: `Appel ${incoming ? 'reçu de' : 'vers'} ${phone} — Durée: ${durationStr}`,
reference_doctype: 'Customer', reference_name: customer.value,
})
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur enregistrement appel : ' + (e.message || e), timeout: 3000 }) }
}
export function useSoftphone () { return { open, number, customer, customerName, provider, call, handleCallEnded } }

View File

@ -0,0 +1,81 @@
// Éditeur de thème : regroupe les couleurs PRINCIPALES de l'app (variables --ops-*) en un seul endroit,
// avec presets (défaut indigo actuel + palette de marque Gigafibre extraite de gigafibre.ca), aperçu live et persistance.
// But : réduire/uniformiser les couleurs. Chaque token pilote une variable CSS --ops-* (+ miroir Quasar --q-* pour que
// les composants Quasar suivent). N.B. les ~2000 hex EN DUR dans les pages ne suivent PAS encore (migration séparée).
import { ref } from 'vue'
export const THEME_GROUPS = [
{ title: 'Marque', hint: "L'accent : boutons, liens, sélections, mises en valeur.", tokens: [
{ var: '--ops-accent', label: 'Accent (couleur de marque)', quasar: '--q-primary' },
] },
{ title: 'Sombre', hint: 'Barre latérale + tableau Dispatch.', tokens: [
{ var: '--ops-sidebar-bg', label: 'Fond sombre', mirror: '--ops-primary' },
] },
{ title: 'Surfaces', hint: 'Fonds, cartes, bordures.', tokens: [
{ var: '--ops-bg', label: 'Fond de page', mirror: '--ops-bg-light' },
{ var: '--ops-surface', label: 'Cartes / surfaces' },
{ var: '--ops-border', label: 'Bordures' },
{ var: '--ops-bg-hover', label: 'Survol / sélection' },
] },
{ title: 'Texte', tokens: [
{ var: '--ops-text', label: 'Texte principal' },
{ var: '--ops-text-muted', label: 'Texte discret' },
] },
{ title: 'États sémantiques', hint: 'Rôles unifiés (badges, boutons, icônes, notifications) — migrés depuis amber/orange/red/blue.', tokens: [
{ var: '--ops-success', label: 'Succès — payé, actif, complété', quasar: '--q-positive' },
{ var: '--ops-warning', label: 'Attention — en attente, priorité moyenne', quasar: '--q-warning' },
{ var: '--ops-danger', label: 'Erreur — destructif, en retard, urgent', quasar: '--q-negative' },
{ var: '--ops-info', label: 'Info — liens externes, en cours', quasar: '--q-info' },
] },
]
// Palettes. `default` = MODERNE (inspirée des refs zentry/marque : vert + near-black + off-white + teinte verte + gris),
// alignée sur css/app.scss. `vif` = vert lime éclatant (#22c11f de la ref — superbe mais texte blanc moins contrasté sur boutons).
// `indigo` = ancienne palette (legacy).
export const PRESETS = {
default: {
'--ops-accent': '#16a34a', '--ops-sidebar-bg': '#0d0d0d',
'--ops-bg': '#f4f5f2', '--ops-surface': '#ffffff', '--ops-border': '#e5e7e2', '--ops-bg-hover': '#dcf5d3',
'--ops-text': '#0d0d0d', '--ops-text-muted': '#6b7280',
'--ops-success': '#16a34a', '--ops-warning': '#f59e0b', '--ops-danger': '#ef4444', '--ops-info': '#2563eb',
},
vif: {
'--ops-accent': '#22c11f', '--ops-sidebar-bg': '#0d0d0d',
'--ops-bg': '#f4f5f2', '--ops-surface': '#ffffff', '--ops-border': '#e5e7e2', '--ops-bg-hover': '#cceecc',
'--ops-text': '#0d0d0d', '--ops-text-muted': '#6b7280',
'--ops-success': '#16a34a', '--ops-warning': '#f59e0b', '--ops-danger': '#ef4444', '--ops-info': '#2563eb',
},
indigo: {
'--ops-accent': '#6366f1', '--ops-sidebar-bg': '#111422',
'--ops-bg': '#f8fafc', '--ops-surface': '#ffffff', '--ops-border': '#e2e8f0', '--ops-bg-hover': '#eef2ff',
'--ops-text': '#1e293b', '--ops-text-muted': '#64748b',
'--ops-success': '#10b981', '--ops-warning': '#f59e0b', '--ops-danger': '#ef4444', '--ops-info': '#3b82f6',
},
}
const KEY = 'ops_theme_v1'
export const themeVars = ref({})
export function allTokens () { return THEME_GROUPS.flatMap(g => g.tokens) }
// Applique un jeu de couleurs en direct : pose les variables --ops-* (+ miroirs Quasar/alias) sur :root.
export function applyTheme (vars) {
const root = document.documentElement
for (const t of allTokens()) {
const v = vars[t.var]; if (!v) continue
root.style.setProperty(t.var, v)
if (t.mirror) root.style.setProperty(t.mirror, v)
if (t.quasar) root.style.setProperty(t.quasar, v)
}
themeVars.value = { ...vars }
}
// Valeurs effectives courantes (override appliqué OU valeur CSS calculée OU défaut).
export function currentValues () {
const cs = getComputedStyle(document.documentElement); const out = {}
for (const t of allTokens()) out[t.var] = (themeVars.value[t.var] || cs.getPropertyValue(t.var).trim() || PRESETS.default[t.var])
return out
}
export function saveTheme (vars) { try { localStorage.setItem(KEY, JSON.stringify(vars)) } catch (e) {} applyTheme(vars) }
export function resetTheme () { try { localStorage.removeItem(KEY) } catch (e) {} applyTheme(PRESETS.default) }
export function loadSavedTheme () { try { const s = localStorage.getItem(KEY); if (s) applyTheme(JSON.parse(s)) } catch (e) {} }

View File

@ -19,6 +19,15 @@ function nextWeekday () {
return d.toISOString().slice(0, 10)
}
// Numéro de job standardisé YYYYMMDD-XXX, frappé côté hub (compteur par jour). Repli local si le hub ne répond pas.
async function fetchNextRef () {
try {
const r = await fetch(`${HUB_URL}/dispatch/next-ref`)
if (r.ok) { const d = await r.json(); if (d && d.ref) return d.ref }
} catch (e) { /* repli ci-dessous */ }
return 'DJ-' + Date.now().toString(36).toUpperCase()
}
export function useUnifiedCreate (mode, opts = {}) {
// ── Form state ────────────────────────────────────────────────────────────
const form = reactive({
@ -257,7 +266,7 @@ export function useUnifiedCreate (mode, opts = {}) {
Notify.create({ type: 'positive', message: 'Tâche créée' })
} else if (m === 'work-order') {
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase()
const ticketId = await fetchNextRef() // numéro standardisé YYYYMMDD-XXX (hub)
const payload = {
ticket_id: ticketId,
subject: form.subject.trim(),

View File

@ -0,0 +1,51 @@
import { ref } from 'vue'
import { HUB_URL } from 'src/config/hub'
// ─────────────────────────────────────────────────────────────────────────────
// Préférences d'affichage PAR UTILISATEUR (filtres, tri, densité, vues…), par
// namespace ('inbox', 'planif', 'tickets', …). Serveur (hub /prefs, keyé par
// courriel Authentik) → suit l'usager entre appareils + lisible par l'admin.
// Cache localStorage = rendu INSTANTANÉ au montage, puis écrasé par le serveur.
//
// const { prefs, save } = useUserPrefs('planif', { view: 'grid' })
// save({ view: 'kanban' }) // maj réactive + localStorage + PUT serveur (debounce)
// ─────────────────────────────────────────────────────────────────────────────
const _cache = {} // namespace → ref partagé (un seul état par namespace dans l'app)
let _serverAll = null // toutes les prefs serveur (chargées une fois)
let _loadingP = null
function _loadServer () {
if (_serverAll) return Promise.resolve(_serverAll)
if (!_loadingP) {
_loadingP = fetch(`${HUB_URL}/prefs`).then(r => r.ok ? r.json() : { prefs: {} })
.then(d => { _serverAll = d.prefs || {}; return _serverAll })
.catch(() => { _serverAll = {}; return _serverAll })
}
return _loadingP
}
export function useUserPrefs (namespace, defaults = {}) {
if (!_cache[namespace]) {
let local = {}
try { local = JSON.parse(localStorage.getItem('uprefs:' + namespace) || '{}') } catch { /* */ }
const state = ref({ ...defaults, ...local }) // instantané (localStorage), avant le serveur
_loadServer().then(all => { if (all && all[namespace]) state.value = { ...defaults, ...all[namespace] } })
let timer = null
_cache[namespace] = {
prefs: state,
save (patch) {
state.value = { ...state.value, ...(patch || {}) }
try { localStorage.setItem('uprefs:' + namespace, JSON.stringify(state.value)) } catch { /* */ }
if (_serverAll) _serverAll[namespace] = state.value
clearTimeout(timer)
timer = setTimeout(() => {
fetch(`${HUB_URL}/prefs`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespace, value: state.value }),
}).catch(() => { /* non bloquant : le localStorage garde l'état localement */ })
}, 500)
},
}
}
return _cache[namespace]
}

View File

@ -197,9 +197,12 @@ export function useWizardPublish ({ props, emit, state }) {
// Create Dispatch Jobs (tasks) — ONLY if NOT waiting for acceptance
const createdJobs = []
if (!needsAcceptance) {
let ref = ''
try { const rr = await fetch(`${HUB_URL}/dispatch/next-ref`); if (rr.ok) { const dd = await rr.json(); ref = dd.ref || '' } } catch (e) { /* repli ci-dessous */ }
if (!ref) ref = 'DJ-' + Date.now().toString(36).toUpperCase()
for (let i = 0; i < wizardSteps.value.length; i++) {
const step = wizardSteps.value[i]
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() + '-' + i
const ticketId = `${ref}-s${i + 1}` // numéro standardisé YYYYMMDD-XXX-sN (remplace DJ-<base36>)
let dependsOn = ''
if (step.depends_on_step != null) {

View File

@ -8,7 +8,7 @@
* Éditer ici pour ajuster les mots-clés une seule source.
*/
export const DEPARTMENTS = [
{ category: 'Support', queue: 'Supports', icon: 'wifi', color: 'indigo-6',
{ category: 'Support', queue: 'Supports', icon: 'wifi', color: 'primary',
re: /\b(wi-?fi|internet|lent[e]?|ralenti|panne|coup[ée]+|connexion|connecter?|signal|modem|routeur|red[ée]marr|d[ée]connect|d[ée]branch|intermittent|latence|ping|ne marche|ne fonctionne|pas d['e]internet|hors service|bug)\b/i },
{ category: 'Facturation', queue: 'Facturations', icon: 'receipt_long', color: 'green-7',
re: /\b(factur|paiement|payer|pay[ée]|solde|rembours|pr[ée]l[èe]v|carte de cr[ée]dit|montant|frais|cr[ée]dit|impay[ée]|retard de paiement|re[çc]u|virement|interac|trop per[çc]u)\b/i },

View File

@ -1,26 +1,41 @@
// Ops sidebar navigation + search filter options
// `requires` = capability needed to see this nav item (null = always visible)
// `group: 'admin'` → regroupé sous une section repliée « Administration » (épuration nav)
// `section` → regroupe l'item sous une SECTION repliable (menu trop long → sous-menus). Sans `section` = épinglé en haut.
// L'ordre + le libellé des sections sont définis dans `navSections`.
export const navSections = [
{ key: 'service', label: 'Service client', icon: 'MessagesSquare' },
{ key: 'terrain', label: 'Terrain', icon: 'Truck' },
{ key: 'admin', label: 'Administration', icon: 'Settings' },
]
export const navItems = [
// ── Épinglés (haut, toujours visibles) ──
{ path: '/', icon: 'LayoutDashboard', label: 'Tableau de bord', requires: 'view_dashboard_kpi' },
{ path: '/communications', icon: 'MessagesSquare', label: 'Communications', requires: 'view_clients' },
{ path: '/clients', icon: 'Users', label: 'Clients', requires: 'view_clients' },
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' },
{ path: '/dispatch', icon: 'Truck', label: 'Dispatch', requires: 'view_all_jobs' },
{ path: '/planification', icon: 'CalendarRange', label: 'Planification', requires: 'view_all_jobs' },
{ path: '/rdv', icon: 'CalendarClock', label: 'Rendez-vous', requires: 'view_all_jobs' },
{ path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs' },
{ path: '/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' },
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users', group: 'admin' },
{ path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings', group: 'admin' },
{ path: '/sync-legacy', icon: 'RefreshCw', label: 'Sync F↔ERPNext', requires: 'view_settings', group: 'admin' },
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings', group: 'admin' },
{ path: '/factures-fournisseurs', icon: 'ReceiptText', label: 'Factures fournisseurs', requires: 'view_settings', group: 'admin' },
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings', group: 'admin' },
// ── Service client ──
{ path: '/communications', icon: 'MessagesSquare', label: 'Communications', requires: 'view_clients', section: 'service' },
{ path: '/clients', icon: 'Users', label: 'Clients', requires: 'view_clients', section: 'service' },
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets', section: 'service' },
{ path: '/evaluations', icon: 'Star', label: 'Évaluations', requires: 'view_clients', section: 'service' },
{ path: '/pipeline', icon: 'Filter', label: 'Pipeline (ventes)', requires: 'view_clients', section: 'service' },
// ── Terrain (effectifs / interventions) ──
{ path: '/dispatch', icon: 'Truck', label: 'Dispatch', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/planification', icon: 'CalendarRange', label: 'Planification', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/rdv', icon: 'CalendarClock', label: 'Rendez-vous', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/historique', icon: 'History', label: 'Historique', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/taches-pilote', icon: 'Workflow', label: 'Orchestration (tâches)', requires: 'view_all_jobs', section: 'terrain' },
// ── Administration ──
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', section: 'admin' },
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users', section: 'admin' },
{ path: '/campaigns/pool', icon: 'Award', label: 'Récompenses', requires: 'manage_users', section: 'admin' },
{ path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings', section: 'admin' },
{ path: '/sync-legacy', icon: 'RefreshCw', label: 'Sync F↔ERPNext', requires: 'view_settings', section: 'admin' },
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings', section: 'admin' },
{ path: '/factures-fournisseurs', icon: 'ReceiptText', label: 'Factures fournisseurs', requires: 'view_settings', section: 'admin' },
{ path: '/sous-traitants', icon: 'HardHat', label: 'Sous-traitants', requires: 'view_settings', section: 'admin' },
{ path: '/facturation/approbations', icon: 'ClipboardCheck', label: 'Approb. facturation', requires: 'view_all_jobs', section: 'admin' },
{ path: '/theme', icon: 'Palette', label: 'Thème & couleurs', requires: 'view_settings', section: 'admin' },
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings', section: 'admin' },
]
export const territoryOptions = [

View File

@ -11,22 +11,13 @@ export const invoiceCols = [
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
]
function fmtDateTime (row) {
if (!row.creation) return row.posting_date || ''
// ERPNext creation is UTC — display in Eastern time
const d = new Date(row.creation + 'Z')
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Toronto',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(d)
const g = Object.fromEntries(parts.filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
return `${g.year}-${g.month}-${g.day} ${g.hour}:${g.minute}`
}
// Les paiements affichent posting_date (date de transaction réelle, standard comptable),
// PAS `creation` (horodatage d'import ERPNext) qui faisait apparaître des paiements rétro-importés
// à la date du batch (ex. débits avril/mai affichés « 12 juin »).
export const paymentCols = [
{ name: 'name', label: 'N°', field: 'name', align: 'left' },
{ name: 'posting_date', label: 'Date', field: fmtDateTime, align: 'left', sortable: true, sort: (a, b, rowA, rowB) => (rowA.creation || '').localeCompare(rowB.creation || '') },
{ name: 'posting_date', label: 'Date', field: 'posting_date', align: 'left', sortable: true },
{ name: 'paid_amount', label: 'Montant', field: 'paid_amount', align: 'right' },
{ name: 'mode_of_payment', label: 'Mode', field: 'mode_of_payment', align: 'left' },
{ name: 'reference_no', label: 'Référence', field: 'reference_no', align: 'left' },

View File

@ -22,10 +22,11 @@ export const columns = [
{ name: 'issue_type', label: 'Type', field: 'issue_type', align: 'left' },
{ name: 'opening_date', label: 'Date', field: 'opening_date', align: 'left', sortable: true },
{ name: 'priority', label: 'Priorite', field: 'priority', align: 'center', sortable: true },
{ name: 'sla', label: 'SLA', align: 'left' },
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
]
export function buildFilters ({ statusFilter, typeFilter, priorityFilter, search, ownerFilter, me }) {
export function buildFilters ({ statusFilter, typeFilter, priorityFilter, search, ownerFilter, me, deptIssueTypes }) {
const filters = {}
if (statusFilter === 'not_closed') {
filters.status = ['!=', 'Closed']
@ -43,13 +44,17 @@ export function buildFilters ({ statusFilter, typeFilter, priorityFilter, search
}
}
if (ownerFilter === 'mine' && me) {
filters.owner = me // « Mes tickets » = créés/possédés par l'agent connecté (champ owner, toujours requêtable)
filters._assign = ['like', '%' + me + '%'] // « Mes tickets » = ASSIGNÉS à moi (comme assign_to de F), pas seulement créés (owner)
} else if (ownerFilter === 'depts' && !typeFilter && deptIssueTypes && deptIssueTypes.length) {
filters.issue_type = ['in', deptIssueTypes] // « Mes départements » = issue_types des FILES dont je suis membre (un type explicite, lui, a priorité)
}
return filters
}
export function getSortField (col) {
if (col === 'opening_date') return 'creation'
// La colonne « Date » AFFICHE opening_date (vraie date du ticket) → on TRIE sur opening_date,
// pas sur creation (= date d'import ERPNext, regroupée à la migration → dates affichées en désordre).
if (col === 'opening_date') return 'opening_date'
if (col === 'legacy_id') return 'legacy_ticket_id'
return col || 'creation'
return col || 'opening_date'
}

View File

@ -1,30 +1,34 @@
// Targo Ops Global styles
:root {
// Shared dark palette (sidebar + dispatch)
--ops-sidebar-bg: #111422;
// Palette MODERNE (défaut) vert de marque + near-black + off-white + teinte verte + gris.
// Inspirée de gigafibre.ca / dashboards modernes. Éditable via Administration « Thème & couleurs »
// (composables/useTheme.js PRESETS.default doit rester aligné sur ces valeurs).
// Palette sombre partagée (sidebar + dispatch)
--ops-sidebar-bg: #0d0d0d;
--ops-sidebar-hover: rgba(255,255,255,0.06);
--ops-sidebar-border: rgba(255,255,255,0.06);
--ops-sidebar-text: rgba(255,255,255,0.55);
--ops-sidebar-text-active: #ffffff;
// Accent & semantic
--ops-accent: #6366f1;
--ops-success: #10b981;
--ops-warning: #f59e0b;
--ops-danger: #ef4444;
// Rôles sémantiques (badges, boutons, icônes) miroités par Quasar primary/positive/negative/warning/info
--ops-accent: #16a34a; // MARQUE actions principales, liens, états actifs
--ops-success: #16a34a; // SUCCÈS payé, actif, complété
--ops-warning: #f59e0b; // ATTENTION en attente, priorité moyenne, brouillon (ex-amber/orange)
--ops-danger: #ef4444; // ERREUR destructif, en retard, priorité haute (ex-red)
--ops-info: #2563eb; // INFO liens externes, mentions neutres, en cours (ex-blue/cyan)
// Light content area
--ops-bg-hover: #eef2ff;
--ops-bg-light: #f8fafc;
--ops-bg: #f8fafc;
--ops-surface: #ffffff;
--ops-border: #e2e8f0;
--ops-text: #1e293b;
--ops-text-muted: #64748b;
// Zone de contenu claire
--ops-bg-hover: #dcf5d3; // survol de ligne / élément sélectionné (teinte verte)
--ops-bg-light: #f4f5f2; // alias de fond
--ops-bg: #f4f5f2; // fond de page
--ops-surface: #ffffff; // cartes, panneaux, modales
--ops-border: #e5e7e2; // bordures, séparateurs
--ops-text: #0d0d0d; // texte principal
--ops-text-muted: #6b7280; // texte secondaire, libellés
// Legacy alias
--ops-primary: #111422;
// Alias legacy
--ops-primary: #0d0d0d;
}
body {
@ -38,10 +42,16 @@ body {
border-right: 1px solid var(--ops-sidebar-border) !important;
transition: width 0.2s ease;
// .ops-sidebar EST le .q-drawer__content (Quasar y pose la classe) flex colonne : la liste de nav défile
// dans SON espace, le pied (.ops-sidebar-bottom) reste ANCRÉ en bas et ne se superpose jamais aux liens
// (fin du chevauchement Réduire / déconnexion quand le menu est long).
display: flex; flex-direction: column;
// Kill Quasar's default white border
&.q-drawer--bordered { border-right-color: var(--ops-sidebar-border) !important; }
.q-list { padding-top: 0; }
> .q-list { flex: 1 1 auto; overflow-y: auto; min-height: 0; }
.q-item {
color: var(--ops-sidebar-text);
@ -71,7 +81,8 @@ body {
// Sidebar bottom section
.ops-sidebar-bottom {
position: absolute; bottom: 0; left: 0; right: 0;
flex: 0 0 auto; // ancré en bas via le flex du parent (plus de position:absolute qui se superposait)
background: var(--ops-sidebar-bg); // fond opaque : les liens qui défilent passent DERRIÈRE, jamais au travers
padding: 8px 0;
border-top: 1px solid var(--ops-sidebar-border);
.q-item {
@ -210,14 +221,52 @@ body {
overflow: hidden;
display: flex;
flex-direction: column;
.chatter-panel {
display: flex;
flex-direction: column;
max-height: calc(100vh - 32px);
overflow: hidden;
.chatter-timeline {
flex: 1;
overflow-y: auto;
}
}
// Sur tablette/téléphone, la colonne droite « collante » est empilée sous le contenu : on la dé-colle
// et on retire le plafond 100vh (sinon elle mange tout un écran une fois empilée). Voir audit ergonomie.
@media (max-width: 1023px) {
.convos-sticky {
position: static;
max-height: none;
overflow: visible;
}
}
/*
Durcissement RESPONSIVE mobile ( 599px = xs) toute l'app Ops.
Empêche les dialogues / pop-up à largeur fixe (style inline min-width:NNNpx :
480/540/680px) de déborder sur un petit écran. Audit responsive 2026-06-21.
*/
@media (max-width: 599px) {
/* TOUT dialogue (centré OU positionné top/bottom/left/right) remplit l'écran ;
on neutralise les min-width inline pour qu'aucune carte ne dépasse.
On exclut --maximized (déjà plein écran, garde sa mise en page bord-à-bord). */
.q-dialog__inner:not(.q-dialog__inner--maximized) { padding: 10px !important; }
.q-dialog__inner > * { min-width: 0 !important; max-width: 100% !important; }
.q-dialog__inner:not(.q-dialog__inner--maximized) > * { width: 100%; }
/* Menus / listes déroulantes (q-select, création rapide) bornés à l'écran. */
.q-menu { max-width: calc(100vw - 16px) !important; }
/* Panneaux flottants custom (position:fixed) sans garde mobile :
softphone (PhoneModal) + panneau d'affectation (Planification). */
.phone-modal { width: auto !important; left: 12px !important; right: 12px !important; max-width: calc(100vw - 24px) !important; }
.assign-panel { max-width: calc(100vw - 20px) !important; }
}
/*
Plancher d'accessibilité (audit UI 2026-06-30). Anneau de focus CLAVIER
visible partout (:focus-visible rien au clic souris) + respect de
« réduire les animations ». Additif, sans régression visuelle.
*/
:focus-visible {
outline: 2px solid var(--ops-accent);
outline-offset: 2px;
border-radius: 3px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@ -16,6 +16,18 @@ export const phoneLabelMap = { cell_phone: 'Cell', tel_home: 'Maison', tel_offic
export const defaultSectionsOpen = { locations: true, tickets: true, invoices: false, payments: false, voip: false, paymentMethods: false, arrangements: false, quotations: false, serviceContracts: false, notes: false }
// Modules réordonnables de la fiche client 360 (colonne principale). L'ordre de ce tableau = ordre PAR DÉFAUT ;
// chaque utilisateur peut réordonner/masquer (persisté via useUserPrefs 'client360'). key = clé sectionsOpen.
export const CLIENT_360_MODULES = [
{ key: 'locations', label: 'Lieux de service', icon: 'location_on' },
{ key: 'tickets', label: 'Tickets', icon: 'confirmation_number' },
{ key: 'invoices', label: 'Factures', icon: 'receipt_long' },
{ key: 'payments', label: 'Paiements', icon: 'payments' },
{ key: 'voip', label: 'Lignes VoIP', icon: 'call' },
{ key: 'serviceContracts', label: 'Contrats de service', icon: 'description' },
{ key: 'quotations', label: 'Soumissions', icon: 'request_quote' },
]
export const defaultNewEquip = () => ({
equipment_type: 'ONT', serial_number: '', brand: '', model: '', mac_address: '', ip_address: '', status: 'Actif',
})

View File

@ -0,0 +1,82 @@
// Comptes de revenu ERPNext + correspondance item_group → compte.
// Les Item Groups ERPNext n'ont PAS de compte de revenu configuré (item_group_defaults vide),
// donc on résout ici, côté client, par correspondance de NOM (item_group ↔ nom de compte 4xxx).
// N'inclut que les correspondances À HAUTE CONFIANCE (le nom du groupe = le nom du compte).
// Les groupes ambigus (Quincaillerie, Câbles, Accessoires, Internet générique…) ne sont PAS mappés :
// ils retombent sur le compte par défaut, signalé « à vérifier » dans l'UI.
// À terme : configurer item_group_defaults dans ERPNext pour que TOUT (wizard, sync F) résolve pareil.
export const DEFAULT_INCOME_ACCOUNT = '4020 - Mensualité fibre - T'
// item_group (valeur exacte ERPNext) → compte de revenu (name ERPNext)
export const INCOME_ACCOUNT_BY_GROUP = {
'Mensualités fibre': '4020 - Mensualité fibre - T',
'Mensualités sans fil': '4021 - Mensualité Internet Haute-Vitesse sans fil - T',
'Mensualités télévision': '4019 - Mensualité télévision - T',
'Installation et équipement fibre': '4017 - Installation et équipement fibre - T',
'Equipement internet fibre': '4017 - Installation et équipement fibre - T',
'Equipement internet sans fil': '4023 - Équipement Internet Haute-Vitesse sans fil - T',
'Installation et équipement internet sans fil': '4022 - Installation Internet Haute-Vitesse sans fil - T',
'Installation et équipement télé': '4018 - Équipement télé - T',
Téléphonie: '4015 - Téléphonie - T',
'Adresse IP Fixe': '4027 - IP Fixe - T',
'Téléchargement supplémentaire': '4024 - Téléchargement supplémentaire - T',
'Frais divers taxables': '4260 - Frais divers taxables - T',
Frais: '4260 - Frais divers taxables - T',
'Intérêts et frais divers': '4250 - Intérêts et frais divers - T',
"Frais d'activation": "4028 - Frais d'activation - T",
'Garantie prolongée': '4025 - Garantie prolongée - T',
'Pièces informatique': '4054 - Pièces Informatiques - T',
'Services informatique': '4052 - Revenu - Service Informatique - T',
Hébergement: '4042 - Hébergement - T',
'Nom de domaine': '4041 - Nom de Domaine - T',
Technicien: '4106 - Déplacement/temps technicien - T',
Honoraires: '4010 - Honoraires - T',
'Création de site Internet': '4031 - Création de site Internet - T',
'Location espace cloud': '4001 - Location espace cloud - T',
'Section de Tour': '4026 - Section de Tour - T',
'Saisonniers (coupons-maraîchers)': '4016 - Saisonniers (coupons-maraîchers) - T',
}
// Options pour le sélecteur « compte de revenu » (avancé) — principaux comptes 4xxx.
export const INCOME_ACCOUNT_OPTIONS = [
{ label: '4020 · Mensualité fibre', value: '4020 - Mensualité fibre - T' },
{ label: '4021 · Mensualité HV sans fil', value: '4021 - Mensualité Internet Haute-Vitesse sans fil - T' },
{ label: '4019 · Mensualité télévision', value: '4019 - Mensualité télévision - T' },
{ label: '4015 · Téléphonie', value: '4015 - Téléphonie - T' },
{ label: '4017 · Installation et équipement fibre', value: '4017 - Installation et équipement fibre - T' },
{ label: '4022 · Installation HV sans fil', value: '4022 - Installation Internet Haute-Vitesse sans fil - T' },
{ label: '4023 · Équipement HV sans fil', value: '4023 - Équipement Internet Haute-Vitesse sans fil - T' },
{ label: '4018 · Équipement télé', value: '4018 - Équipement télé - T' },
{ label: '4027 · IP Fixe', value: '4027 - IP Fixe - T' },
{ label: '4024 · Téléchargement supplémentaire', value: '4024 - Téléchargement supplémentaire - T' },
{ label: "4028 · Frais d'activation", value: "4028 - Frais d'activation - T" },
{ label: '4025 · Garantie prolongée', value: '4025 - Garantie prolongée - T' },
{ label: '4260 · Frais divers taxables', value: '4260 - Frais divers taxables - T' },
{ label: '4250 · Intérêts et frais divers', value: '4250 - Intérêts et frais divers - T' },
{ label: '4054 · Pièces informatiques', value: '4054 - Pièces Informatiques - T' },
{ label: '4052 · Service informatique', value: '4052 - Revenu - Service Informatique - T' },
{ label: '4042 · Hébergement', value: '4042 - Hébergement - T' },
{ label: '4041 · Nom de domaine', value: '4041 - Nom de Domaine - T' },
{ label: '4106 · Déplacement/temps technicien', value: '4106 - Déplacement/temps technicien - T' },
{ label: '4010 · Honoraires', value: '4010 - Honoraires - T' },
{ label: '4031 · Création de site Internet', value: '4031 - Création de site Internet - T' },
{ label: '4001 · Location espace cloud', value: '4001 - Location espace cloud - T' },
{ label: '4000 · Revenus autres', value: '4000 - Revenus autres - T' },
]
// Libellé court d'un compte (« 4020 · Mensualité fibre ») pour l'affichage.
export function incomeAccountLabel (account) {
if (!account) return ''
const opt = INCOME_ACCOUNT_OPTIONS.find(o => o.value === account)
if (opt) return opt.label
// repli : « 4020 - Mensualité fibre - T » → « 4020 · Mensualité fibre »
return account.replace(/ - T$/, '').replace(' - ', ' · ')
}
// Résout le compte de revenu d'un produit d'après son item_group.
// Renvoie { account, confident } — confident=false ⇒ repli sur le défaut (à vérifier).
export function resolveIncomeAccount (itemGroup) {
const hit = INCOME_ACCOUNT_BY_GROUP[(itemGroup || '').trim()]
return hit ? { account: hit, confident: true } : { account: DEFAULT_INCOME_ACCOUNT, confident: false }
}

View File

@ -9,18 +9,38 @@
<q-item-section v-if="!collapsed"><q-item-label style="color:#fff;font-size:1.1rem;font-weight:700">Targo Ops</q-item-label></q-item-section>
</q-item>
<template v-for="(nav, i) in navItems" :key="nav.path">
<q-item-label v-if="!collapsed && nav.group === 'admin' && (i === 0 || navItems[i - 1].group !== 'admin')" header
style="color:rgba(255,255,255,.45);font-size:.68rem;letter-spacing:.05em;text-transform:uppercase;padding:14px 16px 4px">Administration</q-item-label>
<q-item clickable :to="nav.path"
:class="{ 'active-link': isActive(nav.path), 'justify-center': collapsed }"
:title="collapsed ? nav.label : undefined">
<!-- RÉDUIT (rail d'icônes) : liste à plat, sans sections -->
<template v-if="collapsed">
<q-item v-for="nav in navItems" :key="nav.path" clickable :to="nav.path" class="justify-center"
:class="{ 'active-link': isActive(nav.path) }">
<q-item-section avatar><component :is="icons[nav.icon]" :size="20" /></q-item-section>
<q-item-section v-if="!collapsed"><q-item-label>{{ nav.label }}</q-item-label></q-item-section>
<q-item-section side v-if="nav.badge && !collapsed"><q-badge color="red" :label="nav.badge" rounded /></q-item-section>
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">{{ nav.label }}</q-tooltip>
<q-tooltip anchor="center right" self="center left" :offset="[8, 0]">{{ nav.label }}</q-tooltip>
</q-item>
</template>
<!-- DÉPLOYÉ : épinglés en haut + sections repliables (menu plus court) -->
<template v-else>
<q-item v-for="nav in topItems" :key="nav.path" clickable :to="nav.path" :class="{ 'active-link': isActive(nav.path) }">
<q-item-section avatar><component :is="icons[nav.icon]" :size="20" /></q-item-section>
<q-item-section><q-item-label>{{ nav.label }}</q-item-label></q-item-section>
<q-item-section side v-if="nav.badge"><q-badge color="red" :label="nav.badge" rounded /></q-item-section>
</q-item>
<q-expansion-item v-for="sec in shownSections" :key="sec.key" dense expand-separator
:model-value="!!openSections[sec.key]" @update:model-value="v => setSection(sec.key, v)"
header-class="ops-sec-header">
<template #header>
<q-item-section avatar><component :is="icons[sec.icon]" :size="18" /></q-item-section>
<q-item-section><q-item-label>{{ sec.label }}</q-item-label></q-item-section>
</template>
<q-item v-for="nav in itemsOf(sec.key)" :key="nav.path" clickable :to="nav.path" class="ops-sub-item"
:class="{ 'active-link': isActive(nav.path) }">
<q-item-section avatar><component :is="icons[nav.icon]" :size="18" /></q-item-section>
<q-item-section><q-item-label>{{ nav.label }}</q-item-label></q-item-section>
<q-item-section side v-if="nav.badge"><q-badge color="red" :label="nav.badge" rounded /></q-item-section>
</q-item>
</q-expansion-item>
</template>
</q-list>
<div class="ops-sidebar-bottom">
@ -56,7 +76,7 @@
</q-input>
<div v-if="searchResults.length" class="ops-search-results">
<div v-for="r in searchResults" :key="r.id" class="ops-search-result" @mousedown="goToResult(r)">
<q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'indigo-5' : 'teal-5'" class="q-mr-sm" />
<q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'green-5' : 'teal-5'" class="q-mr-sm" />
<div style="flex:1;min-width:0">
<div class="ops-search-title">{{ r.title }}</div>
<div class="ops-search-sub">{{ r.sub }}</div>
@ -86,7 +106,7 @@
<div v-for="(r, i) in searchResults" :key="r.id" class="ops-search-result"
:class="{ 'ops-search-highlighted': i === highlightIdx }"
@mousedown="goToResult(r)">
<q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'indigo-5' : 'teal-5'" class="q-mr-sm" />
<q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'green-5' : 'teal-5'" class="q-mr-sm" />
<div style="flex:1;min-width:0">
<div class="ops-search-title">{{ r.title }}</div>
<div class="ops-search-sub">{{ r.sub }}</div>
@ -105,6 +125,8 @@
<!-- Conversation panel (right drawer) masqué sur la route plein écran (sinon double affichage du fil) -->
<ConversationPanel v-if="can('view_clients') && !route.path.startsWith('/communications/c/')" />
<!-- Softphone GLOBAL (Twilio WebRTC + repli SIP) : monté UNE fois, piloté par useSoftphone depuis ConversationPanel / la fiche. Remplace le montage exclusif dans ChatterPanel. -->
<PhoneModal v-if="can('view_clients')" v-model="spOpen" :initial-number="spNumber" :customer-name="spCustomerName" :customer-erp-name="spCustomer" :provider="spProvider" @call-ended="spHandleCallEnded" />
<!-- FAB messagerie : clic « forum » = ouvrir les communications (action directe et fiable) ; bouton « + » (clic) = menu créer rapide. -->
<q-page-sticky v-if="can('view_clients')" position="bottom-right" :offset="[18, 18]">
@ -112,17 +134,17 @@
<q-slide-transition>
<div v-show="quickOpen" class="column items-end q-gutter-xs">
<q-btn rounded unelevated color="deep-purple-6" icon="bolt" label="Dicter une commande" no-caps size="sm" @click="quickOpen = false; orchestratorOpen = true" />
<q-btn rounded unelevated color="cyan-8" icon="wifi_find" label="Statut du service" no-caps size="sm" @click="quickOpen = false; serviceStatusOpen = true" />
<q-btn rounded unelevated color="teal-7" icon="confirmation_number" label="Créer un ticket" no-caps size="sm" @click="quickOpen = false; newTicketOpen = true" />
<q-btn rounded unelevated color="info" icon="wifi_find" label="Statut du service" no-caps size="sm" @click="quickOpen = false; serviceStatusOpen = true" />
<q-btn rounded unelevated color="positive" icon="confirmation_number" label="Créer un ticket" no-caps size="sm" @click="quickOpen = false; newTicketOpen = true" />
<q-btn rounded unelevated color="green-6" icon="sms" label="Nouveau texto" no-caps size="sm" @click="quickOpen = false; openNew('sms')" />
<q-btn rounded unelevated color="red-5" icon="mail" label="Nouveau courriel" no-caps size="sm" @click="quickOpen = false; openNew('email')" />
<q-btn rounded unelevated color="negative" icon="mail" label="Nouveau courriel" no-caps size="sm" @click="quickOpen = false; openNew('email')" />
</div>
</q-slide-transition>
<div class="row items-center q-gutter-xs">
<q-btn round unelevated :color="quickOpen ? 'blue-grey-7' : 'blue-grey-4'" :icon="quickOpen ? 'close' : 'add'" size="sm" @click="quickOpen = !quickOpen">
<q-tooltip>Créer rapidement (ticket · texto · courriel · dicter)</q-tooltip>
</q-btn>
<q-btn fab icon="forum" color="indigo-6" @click="toggleConvPanel">
<q-btn fab icon="forum" color="primary" @click="toggleConvPanel">
<q-badge v-if="convCount > 0" color="red" floating rounded :label="convCount" />
<q-tooltip>Voir les communications</q-tooltip>
</q-btn>
@ -146,18 +168,21 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, nextTick, watch } from 'vue'
import { useQuasar } from 'quasar'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'src/stores/auth'
import { usePermissions } from 'src/composables/usePermissions'
import { listDocs } from 'src/api/erp'
import { navItems as allNavItems } from 'src/config/nav'
import { HUB_URL } from 'src/config/hub'
import { navItems as allNavItems, navSections } from 'src/config/nav'
import {
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History,
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat,
} from 'lucide-vue-next'
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
import PhoneModal from 'src/components/customer/PhoneModal.vue'
import { useSoftphone } from 'src/composables/useSoftphone'
import NotificationBell from 'src/components/shared/NotificationBell.vue'
import { useConversations } from 'src/composables/useConversations'
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
@ -166,9 +191,10 @@ import OrchestratorDialog from 'src/components/shared/OrchestratorDialog.vue'
import ServiceStatusDialog from 'src/components/shared/ServiceStatusDialog.vue'
import OutboxPanel from 'src/components/shared/OutboxPanel.vue'
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History }
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat }
const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount } = useConversations()
const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount, openComposeDraft } = useConversations()
const { open: spOpen, number: spNumber, customer: spCustomer, customerName: spCustomerName, provider: spProvider, handleCallEnded: spHandleCallEnded } = useSoftphone()
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
// FAB messagerie : clic « forum » = ouvrir les communications ; bouton « + » (clic, pas survol) = menu créer rapide.
const quickOpen = ref(false)
@ -186,7 +212,25 @@ const navItems = computed(() =>
)
const route = useRoute()
const router = useRouter()
const drawer = ref(true)
const $q = useQuasar()
// Menu en sous-menus (trop long sections repliables) : items épinglés (sans `section`) + sections
const topItems = computed(() => navItems.value.filter(n => !n.section))
const shownSections = computed(() => navSections.filter(s => navItems.value.some(n => n.section === s.key)))
function itemsOf (key) { return navItems.value.filter(n => n.section === key) }
const openSections = ref((() => {
let saved = {}; try { saved = JSON.parse(localStorage.getItem('ops-nav-sections') || '{}') } catch (e) { /* */ }
const act = navItems.value.find(n => n.section && isActive(n.path))
if (act) saved[act.section] = true // ouvre toujours la section de la page courante
else if (!Object.keys(saved).length) saved.service = true // défaut 1re visite
return saved
})())
function setSection (key, v) { openSections.value = { ...openSections.value, [key]: v }; try { localStorage.setItem('ops-nav-sections', JSON.stringify(openSections.value)) } catch (e) { /* */ } }
// Sur desktop ( breakpoint 1024) le tiroir est persistant/ouvert ; sur mobile il démarre FERMÉ (overlay via hamburger).
const drawer = ref($q.screen.width >= 1024)
// Mobile : refermer le tiroir après navigation pour libérer l'écran.
watch(() => route.path, () => { if ($q.screen.width < 1024) drawer.value = false })
const collapsed = ref(localStorage.getItem('ops-sidebar-collapsed') !== 'false')
const sidebarW = computed(() => collapsed.value ? 64 : 220)
@ -231,29 +275,21 @@ function onSearchInput (val) {
async function runSearch (q) {
if (!q || q.length < 2) { searchLoading.value = false; return }
try {
const cf = ['name', 'customer_name', 'customer_type', 'territory', 'disabled']
const lf = ['name', 'address_line', 'city', 'customer', 'customer_name', 'status']
const timeout = new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 4000))
const [cName, cId, lAddr, lCity] = await Promise.race([
Promise.all([
listDocs('Customer', { filters: { customer_name: ['like', '%' + q + '%'] }, fields: cf, limit: 6, orderBy: 'customer_name asc' }).catch(() => []),
listDocs('Customer', { filters: { name: ['like', '%' + q + '%'] }, fields: cf, limit: 4, orderBy: 'name asc' }).catch(() => []),
listDocs('Service Location', { filters: { address_line: ['like', '%' + q + '%'] }, fields: lf, limit: 6, orderBy: 'address_line asc' }).catch(() => []),
listDocs('Service Location', { filters: { city: ['like', '%' + q + '%'] }, fields: lf, limit: 4, orderBy: 'city asc' }).catch(() => []),
]),
timeout,
])
const seen = new Set()
const out = []
for (const c of [...cName, ...cId]) {
if (seen.has(c.name)) continue; seen.add(c.name)
out.push({ id: 'c-' + c.name, type: 'customer', typeLabel: 'Client', icon: 'person', title: c.customer_name, sub: c.name + (c.territory ? ' · ' + c.territory : ''), route: '/clients/' + c.name })
}
for (const l of [...lAddr, ...lCity]) {
if (seen.has(l.name)) continue; seen.add(l.name)
out.push({ id: 'l-' + l.name, type: 'location', typeLabel: 'Adresse', icon: 'location_on', title: l.address_line + (l.city ? ', ' + l.city : ''), sub: (l.customer_name || l.customer) + ' · ' + l.status, route: '/clients/' + l.customer })
}
searchResults.value = out.slice(0, 12)
// Recherche FLOUE (pg_trgm, typo-tolérante) + multi-champs (nom / courriel / téléphone / adresse) via le hub.
// Remplace les 4 requêtes listDocs sous-chaîne cohérent avec les autres sélecteurs de contact.
// team=1 membres de l'équipe (System Users targo) EN PREMIER : « michel » sort Michel Blais sans préciser. Clic collègue = nouveau courriel.
const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}&team=1`)
const matches = r.ok ? ((await r.json()).matches || []) : []
searchResults.value = matches.slice(0, 12).map(m => m.kind === 'équipe' ? ({
id: 't-' + m.name, type: 'team', typeLabel: 'Équipe', icon: 'groups',
title: m.customer_name || m.name, sub: 'Membre de léquipe · ' + m.email, email: m.email, route: null,
}) : ({
id: 'c-' + m.name, type: 'customer', typeLabel: 'Client',
icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person',
title: m.customer_name || m.name,
sub: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name,
route: '/clients/' + m.name,
}))
} catch {
searchResults.value = []
}
@ -286,7 +322,14 @@ async function doSearch () {
}
}
function goToResult (r) {
async function goToResult (r) {
if (r.type === 'team') { // collègue nouveau courriel pré-rempli (le panneau de compo est monté dans ce layout)
clearSearch()
// Sur la page pleine conv le panneau n'est pas monté : on y navigue + attend le montage pour que le watch consomme le pré-remplissage.
if (route.path.startsWith('/communications/c/')) { await router.push('/communications'); await nextTick() }
openComposeDraft({ to: r.email, channel: 'email' })
return
}
router.push(r.route)
clearSearch()
}
@ -310,3 +353,14 @@ function onSearchBlur () {
setTimeout(() => { searchDropdownOpen.value = false }, 200)
}
</script>
<style>
/* Menu en sous-menus (sidebar foncée) : entêtes de section + sous-items lisibles sur fond sombre */
.ops-sidebar .ops-sec-header { min-height: 38px; }
.ops-sidebar .ops-sec-header .q-item__label { color: rgba(255,255,255,.55); font-size: .72rem; letter-spacing: .03em; text-transform: uppercase; font-weight: 700; }
.ops-sidebar .ops-sec-header .q-item__section--avatar { color: rgba(255,255,255,.6); min-width: 34px; }
.ops-sidebar .q-expansion-item__toggle-icon { color: rgba(255,255,255,.45); }
.ops-sidebar .ops-sub-item { padding-left: 24px; min-height: 38px; }
.ops-sidebar .ops-sub-item .q-item__section--avatar { color: rgba(255,255,255,.7); min-width: 30px; }
.ops-sidebar .ops-sub-item .q-item__label { color: rgba(255,255,255,.82); }
</style>

View File

@ -12,11 +12,11 @@
>
<q-tooltip>Télécharger le rapport (shortlinks Giftbit, emails, statuts d'envoi)</q-tooltip>
</q-btn>
<q-btn v-if="campaign?.recipients?.length" flat dense icon="visibility" color="indigo-6"
<q-btn v-if="campaign?.recipients?.length" flat dense icon="visibility" color="primary"
label="Aperçu" class="q-mr-sm" @click="openPreview()">
<q-tooltip>Voir le courriel tel qu'envoyé (rendu du template avec un destinataire réel) conservé avec la campagne</q-tooltip>
</q-btn>
<q-btn v-if="canCreateReminder" flat dense icon="schedule" color="orange-9"
<q-btn v-if="canCreateReminder" flat dense icon="schedule" color="warning"
label="Créer une relance" class="q-mr-sm"
:loading="creatingReminder" @click="confirmCreateReminder">
<q-tooltip>Cloner cette campagne pour les destinataires qui n'ont PAS cliqué le cadeau encore</q-tooltip>
@ -51,7 +51,7 @@
the operator can hop between them quickly without leaving the
single-glance click view. -->
<q-banner v-if="campaign?.reminder_of"
class="bg-blue-1 text-blue-9 q-mb-md" rounded dense>
class="bg-blue-1 text-info q-mb-md" rounded dense>
<template v-slot:avatar><q-icon name="reply" /></template>
Cette campagne est une <strong>relance</strong> de
<a :href="`/ops/#/campaigns/${campaign.reminder_of}`" class="text-primary">
@ -121,7 +121,7 @@
:value="sentRatio" :color="campaign.counters?.failed ? 'orange' : 'positive'" size="8px" class="q-mb-md"
/>
<q-table
<DataTable
v-if="campaign"
:rows="campaign.recipients || []"
:columns="columns" row-key="email"
@ -138,7 +138,7 @@
<span v-if="props.row.gift_clicked_via_reminder"><br> via la relance <strong>{{ props.row.gift_clicked_via_reminder }}</strong></span>
</q-tooltip>
</q-icon>
<q-icon v-if="props.row.gift_clicked_via_reminder" name="autorenew" color="orange-9" size="14px" class="q-ml-xs">
<q-icon v-if="props.row.gift_clicked_via_reminder" name="autorenew" color="warning" size="14px" class="q-ml-xs">
<q-tooltip>Ce clic a été remonté depuis la campagne de relance, pas le 1er courriel</q-tooltip>
</q-icon>
</q-td>
@ -178,7 +178,7 @@
</div>
</q-td>
</template>
</q-table>
</DataTable>
<div v-if="!campaign && !loading" class="text-center q-pa-xl text-grey-7">
<q-icon name="error_outline" size="48px" />
@ -288,8 +288,8 @@
<q-item-label caption>Destinataires</q-item-label>
<q-item-label>
<strong>{{ campaign?.recipients?.length || 0 }}</strong>
<q-badge color="blue-7" class="q-ml-sm" v-if="langBreakdown.fr">{{ langBreakdown.fr }} FR</q-badge>
<q-badge color="teal-7" class="q-ml-xs" v-if="langBreakdown.en">{{ langBreakdown.en }} EN</q-badge>
<q-badge color="info" class="q-ml-sm" v-if="langBreakdown.fr">{{ langBreakdown.fr }} FR</q-badge>
<q-badge color="positive" class="q-ml-xs" v-if="langBreakdown.en">{{ langBreakdown.en }} EN</q-badge>
<q-badge color="grey-6" class="q-ml-xs" v-if="langBreakdown.other">{{ langBreakdown.other }} autre</q-badge>
</q-item-label>
</q-item-section>
@ -333,7 +333,7 @@
<q-dialog v-model="previewOpen">
<q-card style="width:680px;max-width:95vw;height:82vh;display:flex;flex-direction:column">
<q-card-section class="row items-center q-py-sm">
<q-icon name="visibility" color="indigo-6" size="sm" class="q-mr-sm" />
<q-icon name="visibility" color="primary" size="sm" class="q-mr-sm" />
<div class="text-h6">Aperçu de l'envoi</div>
<q-space />
<q-btn-toggle :model-value="previewLang" @update:model-value="openPreview" dense no-caps unelevated size="sm"
@ -354,6 +354,7 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import DataTable from 'src/components/shared/DataTable.vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { getCampaign, sendCampaign, cloneCampaign, campaignSseUrl, campaignReportCsvUrl, retryRecipient, createReminderCampaign, updateCampaign, listTemplates, previewTemplate } from 'src/api/campaigns'

View File

@ -216,14 +216,14 @@
</q-card>
<q-card flat bordered class="col-6 col-md" v-if="namesNeedingReview">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-amber-9">{{ namesNeedingReview }}</div>
<div class="text-h5 text-warning">{{ namesNeedingReview }}</div>
<div class="text-caption text-grey-7">Noms à vérifier</div>
</q-card-section>
</q-card>
</div>
<!-- Auto-clean summary informational, not blocking -->
<q-banner v-if="namesAutoCorrected || namesNeedingReview" class="bg-blue-1 text-blue-9 q-mb-sm" rounded dense>
<q-banner v-if="namesAutoCorrected || namesNeedingReview" class="bg-blue-1 text-info q-mb-sm" rounded dense>
<template v-slot:avatar><q-icon name="auto_fix_high" /></template>
<span v-if="namesAutoCorrected">
<strong>{{ namesAutoCorrected }} nom(s) auto-corrigés</strong> (Title Case, accents québécois,
@ -236,7 +236,7 @@
</q-banner>
<!-- Imbalance banner: explicit explanation of what the imbalance means -->
<q-banner v-if="unpairedContacts.length || unusedGifts.length" class="bg-orange-1 text-orange-9 q-mb-md" rounded>
<q-banner v-if="unpairedContacts.length || unusedGifts.length" class="bg-orange-1 text-warning q-mb-md" rounded>
<template v-slot:avatar><q-icon name="warning" /></template>
<div v-if="unpairedContacts.length">
<strong>{{ unpairedContacts.length }} contact(s) sans lien-cadeau</strong>
@ -302,7 +302,7 @@
(Title Case, accent restoration, compound split) -->
<template v-slot:body-cell-firstname="props">
<q-td :props="props" style="cursor:pointer">
<q-icon v-if="props.row.name_warnings?.firstname" name="warning" color="amber-9" size="14px" class="q-mr-xs">
<q-icon v-if="props.row.name_warnings?.firstname" name="warning" color="warning" size="14px" class="q-mr-xs">
<q-tooltip>{{ props.row.name_warnings.firstname }}</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.cleaned_changed && props.row.firstname !== props.row.firstname_raw"
@ -318,7 +318,7 @@
</template>
<template v-slot:body-cell-lastname="props">
<q-td :props="props" style="cursor:pointer">
<q-icon v-if="props.row.name_warnings?.lastname" name="warning" color="amber-9" size="14px" class="q-mr-xs">
<q-icon v-if="props.row.name_warnings?.lastname" name="warning" color="warning" size="14px" class="q-mr-xs">
<q-tooltip>{{ props.row.name_warnings.lastname }}</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.cleaned_changed && props.row.lastname !== props.row.lastname_raw"
@ -356,7 +356,7 @@
</template>
<template v-slot:body-cell-match="props">
<q-td :props="props">
<q-chip v-if="props.row.manual" dense color="indigo-5" text-color="white" size="sm" icon="edit" label="manuel" />
<q-chip v-if="props.row.manual" dense color="green-5" text-color="white" size="sm" icon="edit" label="manuel" />
<q-chip v-else-if="props.row.customer_id" dense color="positive" text-color="white" size="sm" :label="props.row.match_method" />
<q-chip v-else dense color="warning" text-color="white" size="sm" label="non lié" />
</q-td>
@ -375,7 +375,7 @@
<!-- Contacts that have NO gift-url (won't be sent) -->
<q-expansion-item v-if="unpairedContacts.length" expand-separator
icon="person_off" :label="`${unpairedContacts.length} contact(s) sans lien-cadeau (ne recevront pas)`"
header-class="bg-red-1 text-red-9">
header-class="bg-red-1 text-negative">
<q-table
:rows="unpairedContacts" :columns="unpairedColumns" row-key="row_index"
flat dense hide-bottom :pagination="{ rowsPerPage: 0 }"
@ -390,7 +390,7 @@
<q-expansion-item v-if="unusedGifts.length" expand-separator
icon="card_giftcard"
:label="`${unusedGifts.length} lien(s) Giftbit non utilisés`"
header-class="bg-orange-1 text-orange-9"
header-class="bg-orange-1 text-warning"
class="q-mt-sm">
<q-list dense>
<q-item v-for="g in unusedGifts" :key="g.gift_url">
@ -406,7 +406,7 @@
<q-expansion-item v-if="parseSkippedRows.length" expand-separator
icon="filter_alt_off"
:label="`${parseSkippedRows.length} ligne(s) du Map CSV non importée(s)`"
header-class="bg-red-1 text-red-9"
header-class="bg-red-1 text-negative"
class="q-mt-sm">
<div class="q-pa-sm text-caption text-grey-7">
Chaque ligne ci-dessous a été <strong>droppée au parsing</strong> et n'apparaît pas
@ -480,7 +480,7 @@
<q-item><q-item-section side>Durée estimée</q-item-section><q-item-section> {{ estimatedMinutes }} min</q-item-section></q-item>
</q-list>
</q-card-section>
<q-card-section class="bg-red-1 text-red-9">
<q-card-section class="bg-red-1 text-negative">
<q-icon name="warning" /> <strong>Confirmation finale.</strong>
L'envoi démarre dès le clic sur <em>"Lancer l'envoi maintenant"</em>.
Tu seras redirigé vers la page de progression temps réel.
@ -583,7 +583,7 @@
</q-btn>
<q-btn flat dense round icon="close" @click="previewOpen = false" />
</q-toolbar>
<q-banner v-if="previewLoading" class="bg-blue-1 text-blue-9">
<q-banner v-if="previewLoading" class="bg-blue-1 text-info">
<q-spinner size="sm" /> Rendu en cours
</q-banner>
<q-card-section class="q-pa-md" style="height: calc(100vh - 60px); overflow:hidden">

View File

@ -20,7 +20,7 @@
<q-btn class="q-mt-lg" color="primary" icon="add" label="Créer la première" :to="'/campaigns/new'" />
</q-card>
<q-table
<DataTable
v-else
:rows="campaigns"
:columns="columns"
@ -62,12 +62,13 @@
</q-btn>
</q-td>
</template>
</q-table>
</DataTable>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import DataTable from 'src/components/shared/DataTable.vue'
import { useQuasar } from 'quasar'
import { listCampaigns, deleteCampaign } from 'src/api/campaigns'

View File

@ -31,7 +31,7 @@
</q-card>
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'expired'" style="cursor:pointer">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-orange-9">{{ counts.expired }}</div>
<div class="text-h5 text-warning">{{ counts.expired }}</div>
<div class="text-caption text-grey-7">Expirés</div>
</q-card-section>
</q-card>
@ -44,7 +44,7 @@
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'reassignable'" style="cursor:pointer"
:class="{ 'bg-amber-1': counts.reassignable > 0 }">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-amber-9">{{ counts.reassignable }}</div>
<div class="text-h5 text-warning">{{ counts.reassignable }}</div>
<div class="text-caption text-grey-7">Réassignables</div>
</q-card-section>
</q-card>
@ -68,7 +68,7 @@
</q-card-section>
</q-card>
<q-table
<DataTable
:rows="filtered" :columns="columns" row-key="gift_token"
flat bordered dense :pagination="{ rowsPerPage: 50, sortBy: 'expires', descending: false }"
:rows-per-page-options="[25, 50, 100, 0]"
@ -109,7 +109,7 @@
</template>
<template v-slot:body-cell-expires="props">
<q-td :props="props">
<span v-if="props.row.gift_expires_at" :class="{ 'text-orange-9': props.row.status === 'expired' }">
<span v-if="props.row.gift_expires_at" :class="{ 'text-warning': props.row.status === 'expired' }">
{{ formatDate(props.row.gift_expires_at) }}
</span>
<span v-else class="text-grey-5"></span>
@ -133,18 +133,20 @@
<q-tooltip>Révoquer ce lien (libère le cadeau Giftbit pour réassignation)</q-tooltip>
</q-btn>
<q-btn v-if="props.row.status === 'expired' || props.row.status === 'revoked'"
flat dense color="amber-9" icon="autorenew" size="sm"
flat dense color="warning" icon="autorenew" size="sm"
@click="copyForReassign(props.row.gift_url)">
<q-tooltip>Copier l'URL pour réassigner à un autre destinataire</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</DataTable>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import DataTable from 'src/components/shared/DataTable.vue'
import { useQuasar } from 'quasar'
import { listGifts, revokeGift } from 'src/api/campaigns'
@ -310,5 +312,11 @@ async function load () {
loading.value = false
}
}
onMounted(load)
// Ancrage profond depuis la fiche client : ?q=<gift_token|courriel> pré-filtre le tableau sur la bonne récompense.
const route = useRoute()
onMounted(() => {
if (route.query.q) search.value = String(route.query.q)
if (route.query.status) filterStatus.value = String(route.query.status)
load()
})
</script>

View File

@ -0,0 +1,177 @@
<template>
<q-page class="q-pa-md">
<div class="row items-center q-mb-md">
<div>
<div class="text-h6">Pool de récompenses</div>
<div class="text-caption text-grey-7">Liens-cadeaux récupérés (jamais ouverts) réattribuables à d'autres clients via notre lien intermédiaire.</div>
</div>
<q-space />
<q-btn unelevated color="primary" icon="card_giftcard" label="Envoyer une récompense" :disable="pool.available === 0" @click="openSend" />
</div>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-sm-4">
<q-card flat bordered><q-card-section><div class="text-caption text-grey-7">Disponibles</div><div class="text-h4 text-green-8">{{ pool.available }}</div></q-card-section></q-card>
</div>
<div class="col-12 col-sm-4">
<q-card flat bordered><q-card-section><div class="text-caption text-grey-7">Réattribués</div><div class="text-h4">{{ pool.total - pool.available }}</div></q-card-section></q-card>
</div>
<div class="col-12 col-sm-4">
<q-card flat bordered><q-card-section><div class="text-caption text-grey-7">Total récupérés</div><div class="text-h4">{{ pool.total }}</div></q-card-section></q-card>
</div>
</div>
<DataTable flat bordered dense :rows="pool.items" :columns="cols" row-key="gift_url" :loading="loading" :pagination="{ rowsPerPage: 15 }">
<template #body-cell-status="props">
<q-td :props="props"><q-badge :color="props.row.used ? 'grey-6' : 'green'">{{ props.row.used ? 'Réattribué' : 'Disponible' }}</q-badge></q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn v-if="props.row.used" flat dense size="sm" color="negative" icon="undo" no-caps label="Révoquer & remettre au pool" :loading="revoking === props.row.gift_url" @click="revokeRow(props.row)" />
</q-td>
</template>
<template #no-data><div class="full-width text-center q-pa-md text-grey-7">Pool vide. Récupère des liens depuis l'inventaire des cadeaux.</div></template>
</DataTable>
<q-dialog v-model="send.open">
<q-card style="min-width: 440px; max-width: 96vw">
<q-card-section class="text-h6 row items-center"><q-icon name="card_giftcard" color="primary" class="q-mr-sm" />Envoyer une récompense</q-card-section>
<q-separator />
<q-card-section class="q-gutter-md">
<q-select outlined dense use-input input-debounce="300" clearable
label="Client — nom, courriel, téléphone…" v-model="send.customer"
:options="custOptions" option-label="label" :loading="custSearching" @filter="searchCust"
hint="Recherche auto-suggest">
<template #no-option><q-item><q-item-section class="text-grey">Tapez au moins 2 caractères</q-item-section></q-item></template>
</q-select>
<q-btn-toggle v-model="send.channel" dense unelevated toggle-color="primary" :options="[
{ label: 'Courriel', value: 'email', icon: 'mail' },
{ label: 'Texto (bientôt)', value: 'sms', icon: 'sms', disable: true }
]" />
<q-banner v-if="send.preview" dense class="bg-blue-1 text-body2 rounded-borders">
Aperçu <b>{{ send.preview.would_send && send.preview.would_send.firstname }}</b>
({{ send.preview.would_send && send.preview.would_send.email }})<br>
<span class="text-green-9"> Le client reçoit <b>notre</b> lien : <code style="font-size:11px">{{ send.preview.client_link }}</code></span><br>
<span class="text-grey-7">Le lien Giftbit n'est jamais dans le courriel<span v-if="send.preview.raw_link_in_email === false"> (vérifié au rendu)</span>. Lien interne du pool, <b>non envoyé</b> : <code style="font-size:10px">{{ send.preview.pool_link_internal }}</code></span>
· {{ send.preview.pool_available }} restant(s)
</q-banner>
<q-banner v-if="send.result" dense class="rounded-borders" :class="send.result.sent ? 'bg-green-1' : 'bg-red-1'">
<span v-if="send.result.sent"> Envoyé. Lien suivi : <a :href="send.result.wrapper_url" target="_blank">{{ send.result.wrapper_url }}</a></span>
<span v-else>{{ send.result.error || ('Statut : ' + send.result.status) }}</span>
</q-banner>
<div class="text-caption text-grey-7">Le courriel standard « récompense » est envoyé (prénom + lien préremplis). Le clic est suivi sur la fiche du client.</div>
</q-card-section>
<q-separator />
<q-card-actions align="right">
<q-btn flat label="Fermer" v-close-popup />
<q-btn flat color="primary" label="Aperçu" :loading="send.busy" :disable="!send.customer" @click="doSend(true)" />
<q-btn flat color="primary" icon="edit" label="Éditer dans la messagerie" :loading="send.busy" :disable="!send.customer" @click="doDraft" />
<q-btn unelevated color="primary" label="Envoyer directement" :loading="send.busy" :disable="!send.customer" @click="doSend(false)" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import DataTable from 'src/components/shared/DataTable.vue'
import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router'
import { HUB_URL } from 'src/config/hub'
import { getGiftPool, rewardSend, revokeReward } from 'src/api/campaigns'
const $q = useQuasar()
const router = useRouter()
const loading = ref(false)
const revoking = ref('')
const pool = reactive({ total: 0, available: 0, items: [] })
const cols = [
{ name: 'status', label: 'Statut', field: 'used', align: 'left' },
{ name: 'value', label: 'Valeur', field: r => (r.gift_value_cents ? (r.gift_value_cents / 100).toFixed(2) + ' $' : '—'), align: 'left' },
{ name: 'source', label: 'Source', field: 'source_campaign', align: 'left' },
{ name: 'reclaimed', label: 'Récupéré', field: r => (r.reclaimed_at || '').slice(0, 10), align: 'left' },
{ name: 'used_by', label: 'Réattribué à', field: r => r.used_by || '—', align: 'left' },
{ name: 'actions', label: '', field: 'gift_url', align: 'right' }
]
async function load () {
loading.value = true
try {
const d = await getGiftPool()
pool.total = d.total || 0
pool.available = d.available || 0
pool.items = d.items || []
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { loading.value = false }
}
const custOptions = ref([])
const custSearching = ref(false)
function searchCust (val, update) {
const q = String(val || '').trim()
if (q.length < 2) { update(() => { custOptions.value = [] }); return }
custSearching.value = true
fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}`)
.then(r => (r.ok ? r.json() : {}))
.then(d => {
const opts = (d.matches || []).map(m => ({ label: (m.customer_name || m.name) + (m.email ? ' · ' + m.email : ''), id: m.name, email: m.email || '', name: m.customer_name || m.name }))
update(() => { custOptions.value = opts })
})
.catch(() => update(() => { custOptions.value = [] }))
.finally(() => { custSearching.value = false })
}
const send = reactive({ open: false, customer: null, channel: 'email', busy: false, preview: null, result: null })
function openSend () { send.open = true; send.customer = null; send.preview = null; send.result = null }
async function doSend (dryRun) {
if (!send.customer) return
send.busy = true
send.preview = null
if (!dryRun) send.result = null
try {
const r = await rewardSend({ customer_id: send.customer.id, email: send.customer.email, channel: send.channel, dry_run: dryRun })
if (dryRun) {
send.preview = r
if (r.error) $q.notify({ type: 'warning', message: r.error })
} else {
send.result = r
if (r.sent) { $q.notify({ type: 'positive', message: 'Récompense envoyée à ' + send.customer.name }); await load() } else { $q.notify({ type: 'negative', message: r.error || ('Échec : ' + r.status) }) }
}
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { send.busy = false }
}
// Génère un brouillon (lien /g/ + prénom préremplis) et l'ouvre dans la messagerie centralisée pour édition + envoi.
async function doDraft () {
if (!send.customer) return
send.busy = true
try {
const r = await rewardSend({ customer_id: send.customer.id, email: send.customer.email, mode: 'draft' })
if (r.error) { $q.notify({ type: 'negative', message: r.error }); return }
if (r.conversation_token) {
try {
if (r.design) sessionStorage.setItem('reward_prefill_design_' + r.conversation_token, JSON.stringify(r.design))
sessionStorage.setItem('reward_prefill_' + r.conversation_token, r.body_text || '')
} catch (e) { /* */ }
send.open = false
$q.notify({ type: 'positive', message: 'Brouillon prêt — ouverture dans la messagerie' })
await load()
router.push('/communications/c/' + r.conversation_token)
} else { $q.notify({ type: 'warning', message: 'Brouillon créé sans conversation' }) }
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { send.busy = false }
}
// Révoque une récompense envoyée + remet le lien au pool (refusé côté hub si déjà cliqué/redirigé).
async function revokeRow (row) {
if (!window.confirm('Révoquer ce lien envoyé et le remettre au pool disponible ? Il cessera de fonctionner pour le client.')) return
revoking.value = row.gift_url
try {
const r = await revokeReward(row.gift_url)
if (r.error) $q.notify({ type: 'negative', message: r.error })
else { $q.notify({ type: 'positive', message: 'Révoqué — lien remis au pool (' + r.available + ' disponibles)' }); await load() }
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { revoking.value = '' }
}
onMounted(load)
</script>

View File

@ -25,7 +25,7 @@
:disable="!aiTranslateTargetName" @click="aiTranslateOpen = true">
<q-tooltip>{{ aiTranslateTargetName ? `Traduire vers ${aiTranslateTargetName} via Gemini` : 'Disponible pour les templates avec suffixe -fr ou -en' }}</q-tooltip>
</q-btn>
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm" @click="testSendOpen = true">
<q-btn flat color="warning" icon="send" label="Envoyer un test" class="q-mr-sm" @click="testSendOpen = true">
<q-tooltip>Envoyer un courriel réel à une adresse de test</q-tooltip>
</q-btn>
<q-btn unelevated color="primary" icon="save" label="Enregistrer"
@ -57,7 +57,7 @@
<q-toolbar-title>Aperçu inbox · {{ currentName }}</q-toolbar-title>
<q-btn flat dense round icon="close" @click="previewOpen = false" />
</q-toolbar>
<q-banner v-if="previewLoading" class="bg-blue-1 text-blue-9"><q-spinner size="sm" /> Rendu en cours</q-banner>
<q-banner v-if="previewLoading" class="bg-blue-1 text-info"><q-spinner size="sm" /> Rendu en cours</q-banner>
<q-card-section style="height: calc(100vh - 60px); overflow:hidden">
<iframe :srcdoc="previewHtmlContent" style="width:100%; height:100%; border:1px solid #e5e7eb; background:#fff;" />
</q-card-section>
@ -67,7 +67,7 @@
<!-- New template dialog name + starter -->
<q-dialog v-model="newTemplateOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw">
<q-card-section class="row items-center bg-blue-1 text-blue-9">
<q-card-section class="row items-center bg-blue-1 text-info">
<q-icon name="add" class="q-mr-sm" />
<div class="text-h6">Nouveau template</div>
<q-space />
@ -122,7 +122,7 @@
Il traduit seulement le texte visible.
</span>
</q-banner>
<q-banner v-if="targetTemplateExists" class="bg-amber-1 text-amber-9 q-mb-md" rounded dense>
<q-banner v-if="targetTemplateExists" class="bg-amber-1 text-warning q-mb-md" rounded dense>
<q-icon name="warning" class="q-mr-xs" />
Le template <strong>{{ aiTranslateTargetName }}</strong> existe déjà.
La traduction va l'écraser (backup automatique avant).
@ -142,7 +142,7 @@
<!-- Test-send dialog -->
<q-dialog v-model="testSendOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw">
<q-card-section class="row items-center bg-orange-1 text-orange-9">
<q-card-section class="row items-center bg-orange-1 text-warning">
<q-icon name="send" class="q-mr-sm" />
<div class="text-h6">Envoyer un test du courriel</div>
<q-space />
@ -223,7 +223,7 @@
</q-item>
</q-list>
</div>
<q-banner class="bg-blue-1 text-blue-9 q-mt-md" rounded dense>
<q-banner class="bg-blue-1 text-info q-mt-md" rounded dense>
<template v-slot:avatar><q-icon name="lightbulb" /></template>
<span v-pre><strong>Bientôt :</strong> à l'importation CSV, on pourra mapper chaque colonne
comme variable additionnelle (ex. <code>{{plan_name}}</code>,

Some files were not shown because too many files have changed in this diff Show More