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 ]([ return Promise[ method ]([
import('boot/pinia') import('boot/net-timeout'),
import('boot/pinia'),
import('boot/tooltip-ux')
]).then(bootFiles => { ]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function') 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": { "scripts": {
"dev": "quasar dev", "dev": "quasar dev",
"build": "quasar build -m pwa", "build": "quasar build -m pwa",
"lint": "eslint --ext .js,.vue ./src" "lint": "eslint --ext .js,.vue ./src",
"knip": "npx --yes knip"
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "^1.16.12", "@quasar/extras": "^1.16.12",
"@twilio/voice-sdk": "^2.18.1", "@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", "chart.js": "^4.5.1",
"cytoscape": "^3.33.2", "cytoscape": "^3.33.2",
"grapesjs": "^0.22.16", "grapesjs": "^0.22.16",
"grapesjs-mjml": "^1.0.8", "grapesjs-mjml": "^1.0.8",
"grapesjs-preset-newsletter": "^1.0.2", "grapesjs-preset-newsletter": "^1.0.2",
"hy-vue-gantt": "^5.2.1",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0", "lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",

View File

@ -1,9 +1,17 @@
/* eslint-env node */ /* eslint-env node */
const { configure } = require('quasar/wrappers') 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 () { module.exports = configure(function () {
return { return {
boot: ['pinia', 'tooltip-ux'], boot: ['net-timeout', 'pinia', 'tooltip-ux'],
css: ['app.scss', 'tech.scss'], css: ['app.scss', 'tech.scss'],
@ -34,11 +42,20 @@ module.exports = configure(function () {
target: 'https://erp.gigafibre.ca', target: 'https://erp.gigafibre.ca',
changeOrigin: true, 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: { 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'], plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog'],
}, },

View File

@ -5,7 +5,13 @@
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useAuthStore } from 'src/stores/auth' 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() 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> </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). // Reconnexion auto sur session Authentik expirée, anti-boucle (1 reload / 20 s max).
let _lastReload = 0 let _lastReload = 0
function _reauth (status) { 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() const now = Date.now()
if (now - _lastReload > 20000) { if (now - _lastReload > 20000) {
_lastReload = now _lastReload = now
@ -24,18 +29,23 @@ export async function authFetch (url, opts = {}) {
try { try {
res = await fetch(url, opts) res = await fetch(url, opts)
} catch (e) { } 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)) await new Promise(r => setTimeout(r, 800))
try {
res = await fetch(url, opts) 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") : // Détection « session Authentik expirée » (la cause du "il faut recharger") :
// - 401/403 explicites // - 401/403 explicites
// - réponse redirigée vers l'IdP (auth.targo.ca / /if/flow/) // - 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 ct = (res.headers && res.headers.get && res.headers.get('content-type')) || ''
const redirectedToIdp = res.redirected && /auth\.targo\.ca|\/if\/flow\//.test(res.url || '') 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) { if (res.status === 401 || res.status === 403 || redirectedToIdp || htmlForApi) {
return _reauth(res.status) return _reauth(res.status)
} }
@ -43,6 +53,8 @@ export async function authFetch (url, opts = {}) {
} }
export async function getLoggedUser () { 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) // First try nginx whoami endpoint (returns Authentik email header)
try { try {
const res = await fetch(BASE_URL + '/auth/whoami') 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 || []) 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 // Kill switch — manually expire a single recipient's wrapper token so
// the underlying Giftbit URL becomes reassignable before the natural // the underlying Giftbit URL becomes reassignable before the natural
// expiry date. // expiry date.

View File

@ -91,3 +91,23 @@ export function overpricedInternetCsvUrl ({ threshold = 90, segment = 'residenti
}) })
return `${HUB}/reports/legacy/overpriced-internet.csv?${qs}` 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 listTechnicians = () => jget('/roster/technicians')
export const listTemplates = () => jget('/roster/templates') export const listTemplates = () => jget('/roster/templates')
export const createTemplate = (t) => jpost('/roster/templates', t) 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 listRequirements = (start, days = 7) => jget(`/roster/requirements?start=${start}&days=${days}`)
export const createRequirement = (r) => jpost('/roster/requirements', r) export const createRequirement = (r) => jpost('/roster/requirements', r)
export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`) 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'), {}) 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 // 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)) 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) // 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), {}) export const closeLegacyTicket = (ticket) => jpost('/dispatch/legacy-sync/close-ticket?ticket=' + encodeURIComponent(ticket), {})
// Fermeture EN LOT (1 appel pour N tickets) — efficace // 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 }) export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte // 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 }) 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. // É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 getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job))
export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a }) 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) 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). // 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)) 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-section q-mt-sm">
<div class="notify-header" @click="notifyExpanded = !notifyExpanded"> <div class="notify-header" @click="notifyExpanded = !notifyExpanded">
<q-icon :name="notifyExpanded ? 'expand_more' : 'chevron_right'" size="16px" color="grey-5" /> <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> <span class="text-caption text-weight-medium text-grey-7">Envoyer notification</span>
<q-space /> <q-space />
<q-badge v-if="lastSentLabel" color="green-6" class="text-caption">{{ lastSentLabel }}</q-badge> <q-badge v-if="lastSentLabel" color="green-6" class="text-caption">{{ lastSentLabel }}</q-badge>
</div> </div>
<div v-show="notifyExpanded" class="notify-body"> <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" <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="[ :options="[
{ label: 'SMS', value: 'sms', icon: 'sms' }, { label: 'SMS', value: 'sms', icon: 'sms' },
{ label: 'Email', value: 'email', icon: 'email' }, { label: 'Email', value: 'email', icon: 'email' },
@ -48,7 +48,7 @@
<span class="text-caption text-grey-5">{{ notifyMessage.length }} car.</span> <span class="text-caption text-grey-5">{{ notifyMessage.length }} car.</span>
<q-space /> <q-space />
<q-btn unelevated dense size="sm" :label="channel === 'sms' ? 'Envoyer SMS' : 'Envoyer Email'" <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" /> :disable="!canSend" :loading="sending" @click="sendNotification" />
</div> </div>
</div> </div>

View File

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

View File

@ -24,13 +24,13 @@
<q-toggle v-model="customer.is_bad_payer" dense size="xs" color="red" <q-toggle v-model="customer.is_bad_payer" dense size="xs" color="red"
@update:model-value="save('is_bad_payer')" /> @update:model-value="save('is_bad_payer')" />
<q-icon name="warning" size="16px" :color="customer.is_bad_payer ? 'red-6' : 'grey-4'" /> <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>
<div class="info-row"> <div class="info-row">
<q-toggle v-model="customer.exclude_fees" dense size="xs" color="orange" <q-toggle v-model="customer.exclude_fees" dense size="xs" color="orange"
@update:model-value="save('exclude_fees')" /> @update:model-value="save('exclude_fees')" />
<q-icon name="money_off" size="16px" :color="customer.exclude_fees ? 'orange-6' : 'grey-4'" /> <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>
<div class="info-row"> <div class="info-row">
<q-toggle v-model="customer.ppa_enabled" dense size="xs" color="green" <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 v-else class="phone-panel">
<div class="phone-header"> <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> <span class="text-weight-bold" style="font-size:1rem">Telephone</span>
<q-space /> <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-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-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" /> <q-btn flat dense round size="xs" icon="minimize" color="grey-6" @click="minimized = true" />
@ -23,7 +23,7 @@
</div> </div>
<div v-if="customerName" class="phone-contact"> <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> <span class="q-ml-xs text-weight-medium">{{ customerName }}</span>
</div> </div>
@ -84,12 +84,12 @@
<q-icon name="check_circle" size="18px" color="green" class="q-mr-xs" /> <q-icon name="check_circle" size="18px" color="green" class="q-mr-xs" />
<span class="text-caption">Appel termine {{ formattedDuration }}</span> <span class="text-caption">Appel termine {{ formattedDuration }}</span>
<q-space /> <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>
<div class="phone-footer"> <div class="phone-footer">
<q-icon name="circle" size="8px" :color="deviceReady ? 'green' : 'grey-4'" class="q-mr-xs" /> <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> </div>
</div> </div>
@ -100,12 +100,9 @@
<script setup> <script setup>
import { ref, watch, computed, onMounted, onUnmounted } from 'vue' import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import { createDoc } from 'src/api/erp'
import { Device } from '@twilio/voice-sdk' 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 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({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
@ -137,16 +134,15 @@ const callDurationSec = ref(0)
let durationTimer = null let durationTimer = null
let device = null let device = null
let activeCall = null let activeCall = null
let sipUA = null
let sipRegisterer = null
let audioElement = null let audioElement = null
let heartbeatTimer = null
const dialpadKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'] const dialpadKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']
const callControls = computed(() => [ const callControls = computed(() => [
{ label: muted.value ? 'Unmute' : 'Mute', icon: muted.value ? 'mic_off' : 'mic', color: muted.value ? 'red' : 'grey-7', action: toggleMute }, { 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: '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 }) watch(() => props.modelValue, v => { show.value = v })
@ -161,8 +157,7 @@ const formattedDuration = computed(() => {
}) })
async function initDevice () { async function initDevice () {
if (props.provider === 'sip') { if (!sipUA) await initSipDevice() } if (!device) await initTwilioDevice()
else { if (!device) await initTwilioDevice() }
} }
async function initTwilioDevice () { async function initTwilioDevice () {
@ -174,25 +169,33 @@ async function initTwilioDevice () {
identity.value = data.identity identity.value = data.identity
device = new Device(data.token, { edge: 'ashburn', logLevel: 'warn', codecPreferences: ['opus', 'pcmu'] }) 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('error', err => { deviceError.value = err.message || 'Device error' })
device.on('tokenWillExpire', async () => { device.on('tokenWillExpire', async () => {
try { const r = await fetch(HUB_URL + '/voice/token'); const d = await r.json(); device.updateToken(d.token) } catch {} try { const r = await fetch(HUB_URL + '/voice/token'); const d = await r.json(); device.updateToken(d.token) } catch {}
}) })
await device.register() await device.register()
deviceReady.value = true deviceReady.value = true; startHeartbeat()
device.on('incoming', call => { 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 show.value = true; minimized.value = false
dialNumber.value = call.parameters.From || 'Inconnu' dialNumber.value = from
Notify.create({ 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: [ actions: [
{ label: 'Repondre', color: 'green', handler: () => answerIncoming(call) }, { label: 'Répondre', color: 'white', handler: () => answerIncoming(call) },
{ label: 'Refuser', color: 'red', handler: () => call.reject() }, { 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) { } 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 () { function cleanupDevices () {
deviceReady.value = false; deviceError.value = '' deviceReady.value = false; deviceError.value = ''
if (device) { try { device.unregister(); device.destroy() } catch {} device = null } 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 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 () { function getAudioElement () {
if (!audioElement) { if (!audioElement) {
audioElement = document.createElement('audio') audioElement = document.createElement('audio')
@ -267,31 +236,6 @@ function answerIncoming (call) {
inCall.value = true; connected.value = true; callEnded.value = false; startDurationTimer() 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 () { async function startCall () {
if (!dialNumber.value.trim() || !deviceReady.value) return if (!dialNumber.value.trim() || !deviceReady.value) return
connecting.value = true; callEnded.value = false; callStatusText.value = 'Connexion...' 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 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) { 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) { function setupCallListeners (call) {
call.on('ringing', () => { callStatusText.value = 'Sonnerie...'; connecting.value = false }) call.on('ringing', () => { callStatusText.value = 'Sonnerie...'; connecting.value = false })
call.on('accept', () => { connected.value = true; connecting.value = false; callStatusText.value = ''; startDurationTimer() }) call.on('accept', () => { connected.value = true; connecting.value = false; callStatusText.value = ''; startDurationTimer() })
@ -339,18 +270,13 @@ function setupCallListeners (call) {
function endCall () { function endCall () {
if (!activeCall) return if (!activeCall) return
if (props.provider === 'sip') { activeCall.disconnect()
try { activeCall.state === SessionState.Established ? activeCall.bye() : (activeCall.cancel?.() || activeCall.reject?.()) } catch {}
} else { activeCall.disconnect() }
} }
function toggleMute () { function toggleMute () {
if (!activeCall) return if (!activeCall) return
muted.value = !muted.value muted.value = !muted.value
if (props.provider === 'sip') { activeCall.mute(muted.value)
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) }
} }
function toggleSpeaker () { speakerOn.value = !speakerOn.value } function toggleSpeaker () { speakerOn.value = !speakerOn.value }
@ -358,12 +284,8 @@ function toggleDialpad () { showDtmf.value = !showDtmf.value }
function sendDtmf (digit) { function sendDtmf (digit) {
if (!activeCall) return if (!activeCall) return
if (props.provider === 'sip') {
try { activeCall.sessionDescriptionHandler?.sendDtmf(digit) } catch {}
} else {
activeCall.sendDigits(digit) activeCall.sendDigits(digit)
} }
}
function pressKey (key) { dialNumber.value += key } function pressKey (key) { dialNumber.value += key }
@ -378,22 +300,11 @@ async function logCall () {
loggingCall.value = true loggingCall.value = true
const phone = dialNumber.value.trim() const phone = dialNumber.value.trim()
const duration = callDurationSec.value const duration = callDurationSec.value
const durationStr = `${Math.floor(duration / 60)}m${String(duration % 60).padStart(2, '0')}s`
try { try {
await createDoc('Communication', { // 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.
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 })
emit('call-ended', { direction: 'out', remote: phone, duration }) emit('call-ended', { direction: 'out', remote: phone, duration })
Notify.create({ type: 'positive', message: 'Appel enregistré', timeout: 2000 })
callEnded.value = false callEnded.value = false
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
} finally { loggingCall.value = false } } finally { loggingCall.value = false }
} }
@ -403,8 +314,9 @@ function close () {
callEnded.value = false; callStartTime.value = null; callDurationSec.value = 0 callEnded.value = false; callStartTime.value = null; callDurationSec.value = 0
} }
onMounted(() => { if (show.value) initDevice() }) // Enregistrement EN ARRIÈRE-PLAN dès le montage (panneau caché) l'agent reçoit les appels entrants sans ouvrir le clavier.
onUnmounted(() => { stopDurationTimer(); cleanupDevices() }) onMounted(() => { initDevice() })
onUnmounted(() => { stopDurationTimer(); stopHeartbeat(); cleanupDevices() })
</script> </script>
<style scoped> <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> <template>
<div class="nlp-input-bar" :class="{ 'nlp-expanded': showResult }"> <div class="nlp-input-bar" :class="{ 'nlp-expanded': showResult }">
<div class="nlp-row"> <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 <input
ref="inputRef" ref="inputRef"
v-model="text" v-model="text"
@ -11,8 +11,8 @@
@keydown.escape="clear" @keydown.escape="clear"
:disabled="loading" :disabled="loading"
/> />
<q-spinner v-if="loading" size="16px" color="indigo-5" class="q-mx-xs" /> <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="indigo-6" @click="submit" /> <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" /> <q-btn v-if="showResult" flat dense round size="xs" icon="close" color="grey-6" @click="clear" />
</div> </div>
<transition name="nlp-slide"> <transition name="nlp-slide">
@ -45,7 +45,7 @@
<div class="nlp-actions q-mt-sm"> <div class="nlp-actions q-mt-sm">
<q-btn v-if="result.action !== 'query' && result.action !== 'unknown'" dense no-caps unelevated <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" /> <q-btn flat dense no-caps size="sm" label="Ignorer" color="grey-6" @click="clear" />
</div> </div>
</div> </div>
@ -84,7 +84,7 @@ const ACTION_ICONS = {
unknown: 'help_outline', unknown: 'help_outline',
} }
const ACTION_COLORS = { const ACTION_COLORS = {
create_job: 'indigo-6', create_job: 'primary',
move_job: 'blue-6', move_job: 'blue-6',
redistribute: 'orange-8', redistribute: 'orange-8',
cancel_job: 'red', 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"> <div v-if="!steps.length" class="flow-editor-empty">
<q-icon name="account_tree" size="32px" color="grey-5" /> <q-icon name="account_tree" size="32px" color="grey-5" />
<div class="text-caption text-grey-7 q-mt-sm">Flow vide</div> <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" /> icon="add" @click="openNew()" class="q-mt-sm" />
</div> </div>

View File

@ -23,7 +23,7 @@
<!-- Header --> <!-- Header -->
<q-card-section class="fe-header"> <q-card-section class="fe-header">
<div class="row items-center no-wrap"> <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="col">
<div class="text-subtitle1 text-weight-bold"> <div class="text-subtitle1 text-weight-bold">
{{ mode === 'new' ? 'Nouveau template de flow' : (template?.template_name || templateName || '') }} {{ mode === 'new' ? 'Nouveau template de flow' : (template?.template_name || templateName || '') }}
@ -31,11 +31,11 @@
<div class="text-caption text-grey-7"> <div class="text-caption text-grey-7">
<span v-if="templateName">{{ templateName }}</span> <span v-if="templateName">{{ templateName }}</span>
<span v-if="template?.version" class="q-ml-sm">v{{ template.version }}</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" /> class="q-ml-sm" />
<q-badge v-if="template && !template.is_active" color="grey-3" text-color="grey-7" <q-badge v-if="template && !template.is_active" color="grey-3" text-color="grey-7"
label="inactif" class="q-ml-sm" /> 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" /> label="non sauvegardé" class="q-ml-sm" />
</div> </div>
</div> </div>
@ -46,7 +46,7 @@
<q-separator /> <q-separator />
<q-card-section v-if="loading" class="flex flex-center q-pa-xl"> <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>
<q-card-section v-else-if="error" class="text-negative q-pa-md"> <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="text-grey-7 q-mb-xs">{{ ex.desc }}</div>
<div class="row items-start no-wrap"> <div class="row items-start no-wrap">
<div class="condition-code col">{{ ex.code }}</div> <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)"> class="q-ml-xs" @click="useExample(ex.code)">
<q-tooltip>Utiliser cet exemple</q-tooltip> <q-tooltip>Utiliser cet exemple</q-tooltip>
</q-btn> </q-btn>
@ -163,7 +163,7 @@
<div class="row items-center q-mb-sm"> <div class="row items-center q-mb-sm">
<div class="text-caption text-weight-bold text-grey-7">Étapes du flow</div> <div class="text-caption text-weight-bold text-grey-7">Étapes du flow</div>
<q-space /> <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> </div>
<FlowEditor :model-value="template.flow_definition" <FlowEditor :model-value="template.flow_definition"
:kind-catalog="PROJECT_KINDS" :kind-catalog="PROJECT_KINDS"
@ -178,14 +178,14 @@
<!-- Footer actions --> <!-- Footer actions -->
<q-card-actions class="fe-footer"> <q-card-actions class="fe-footer">
<q-btn v-if="mode === 'edit' && !template?.is_system" <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" /> @click="onDelete" />
<q-btn v-if="mode === 'edit'" <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" /> @click="onDuplicate" />
<q-space /> <q-space />
<q-btn flat color="grey-7" label="Fermer" no-caps @click="onClose" /> <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 :label="mode === 'new' ? 'Créer' : 'Enregistrer'" no-caps
:loading="saving" :disable="!canSave" :loading="saving" :disable="!canSave"
@click="onSave" /> @click="onSave" />

View File

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

View File

@ -28,9 +28,9 @@
:options="CATEGORY_OPTIONS" label="Catégorie" style="min-width:160px" /> :options="CATEGORY_OPTIONS" label="Catégorie" style="min-width:160px" />
<q-select v-model="appliesToFilter" dense outlined emit-value map-options clearable <q-select v-model="appliesToFilter" dense outlined emit-value map-options clearable
:options="APPLIES_TO_OPTIONS" label="Applique à" style="min-width:200px" /> :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-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" /> @click="onCreate" />
<q-btn flat dense icon="refresh" @click="reload" :loading="loading"> <q-btn flat dense icon="refresh" @click="reload" :loading="loading">
<q-tooltip>Rafraîchir</q-tooltip> <q-tooltip>Rafraîchir</q-tooltip>
@ -39,12 +39,12 @@
<!-- Loading --> <!-- Loading -->
<div v-if="loading && !rows.length" class="flex flex-center q-pa-xl"> <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> </div>
<!-- Error --> <!-- Error -->
<q-banner v-else-if="error" class="bg-red-1 text-red-9 q-mb-md"> <q-banner v-else-if="error" class="bg-red-1 text-negative q-mb-md">
<template #avatar><q-icon name="error" color="red-7" /></template> <template #avatar><q-icon name="error" color="negative" /></template>
{{ error }} {{ error }}
</q-banner> </q-banner>
@ -52,7 +52,7 @@
<div v-else-if="!filtered.length" class="text-center text-grey-6 q-py-lg"> <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" /> <q-icon name="account_tree" size="32px" color="grey-4" />
<div class="text-caption q-mt-sm">Aucun template trouvé</div> <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" /> class="q-mt-sm" @click="onCreate" />
</div> </div>
@ -74,12 +74,12 @@
<tbody> <tbody>
<tr v-for="r in filtered" :key="r.name" class="ft-row" <tr v-for="r in filtered" :key="r.name" class="ft-row"
@click="onEdit(r)"> @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> <td>
<div class="text-weight-medium">{{ r.template_name }}</div> <div class="text-weight-medium">{{ r.template_name }}</div>
<div class="text-caption text-grey-6"> <div class="text-caption text-grey-6">
{{ r.name }} {{ 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> <span v-if="r.version" class="q-ml-xs">v{{ r.version }}</span>
</div> </div>
</td> </td>
@ -92,11 +92,11 @@
@update:model-value="v => onToggleActive(r, v)" /> @update:model-value="v => onToggleActive(r, v)" />
</td> </td>
<td class="text-center" @click.stop> <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)"> @click="onDuplicate(r)">
<q-tooltip>Dupliquer</q-tooltip> <q-tooltip>Dupliquer</q-tooltip>
</q-btn> </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)"> @click="onDelete(r)">
<q-tooltip>Supprimer</q-tooltip> <q-tooltip>Supprimer</q-tooltip>
</q-btn> </q-btn>

View File

@ -103,7 +103,7 @@
<q-card-actions class="step-editor-ftr"> <q-card-actions class="step-editor-ftr">
<q-btn flat color="grey-7" label="Annuler" @click="close" no-caps /> <q-btn flat color="grey-7" label="Annuler" @click="close" no-caps />
<q-space /> <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" /> icon="check" />
</q-card-actions> </q-card-actions>
</q-card> </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> <template>
<q-btn-dropdown flat dense no-caps size="sm" :icon="icon" :label="label" <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-list dense class="vp-list">
<q-item-label header class="vp-header"> <q-item-label header class="vp-header">
Variables disponibles Variables disponibles
@ -55,7 +55,7 @@
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <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-section>
</q-item> </q-item>
</q-list> </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.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" /> <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>
<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. <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>
<div class="row items-center q-gutter-sm q-mt-sm"> <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" /> <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" /> <q-icon :name="testResult.ok ? 'check_circle' : 'error'" size="14px" />
{{ testResult.ok ? `OK · ${testResult.model} · ${testResult.latency_ms} ms` : ('Échec : ' + testResult.error) }} {{ testResult.ok ? `OK · ${testResult.model} · ${testResult.latency_ms} ms` : ('Échec : ' + testResult.error) }}
</span> </span>
@ -54,7 +54,7 @@
<q-icon :name="taskIcon(t)" size="18px" color="grey-7" class="q-mr-sm" /> <q-icon :name="taskIcon(t)" size="18px" color="grey-7" class="q-mr-sm" />
<div class="col"> <div class="col">
<div class="text-body2">{{ taskLabel(t) }}</div> <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> </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" /> <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> </div>

View File

@ -1,12 +1,12 @@
<template> <template>
<div class="ops-card q-mb-md"> <div class="ops-card q-mb-md">
<div class="row items-center q-mb-xs cursor-pointer" @click="expanded = !expanded"> <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> <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-space />
<q-spinner v-if="loading" size="16px" color="indigo-6" class="q-mr-sm" /> <q-spinner v-if="loading" size="16px" color="primary" class="q-mr-sm" />
<q-btn v-if="expanded" dense unelevated color="indigo-5" icon="add" label="Nouvelle" no-caps @click.stop="addNew" /> <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" /> <q-icon :name="expanded ? 'keyboard_arrow_up' : 'keyboard_arrow_down'" size="24px" color="grey-7" class="q-ml-sm" />
</div> </div>
<div v-if="expanded"> <div v-if="expanded">
@ -18,13 +18,13 @@
<div class="row items-center q-gutter-sm q-mb-xs"> <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-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-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> </div>
<q-editor v-model="c.html" min-height="7rem" class="canned-editor" <q-editor v-model="c.html" min-height="7rem" class="canned-editor"
:toolbar="[['bold','italic','underline'],['unordered','ordered'],['link','removeFormat'],['undo','redo']]" :toolbar="[['bold','italic','underline'],['unordered','ordered'],['link','removeFormat'],['undo','redo']]"
placeholder="Texte / signature… (image via le bouton ci-dessous, ou collez depuis Gmail)" /> placeholder="Texte / signature… (image via le bouton ci-dessous, ou collez depuis Gmail)" />
<div class="row items-center q-mt-xs"> <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> <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>
</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" /> <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"> <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> </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> <template>
<div class="ops-card q-mb-md"> <div class="ops-card q-mb-md">
<div class="row items-center q-mb-xs"> <div class="row items-center q-mb-xs">
<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> <span class="text-subtitle1 text-weight-bold">Files (Inbox) & équipes</span>
<q-space /> <q-space />
<q-spinner v-if="loading" size="16px" color="indigo-6" /> <q-spinner v-if="loading" size="16px" color="primary" />
</div> </div>
<div class="text-caption text-grey-6 q-mb-md"> <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. 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="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="queue-card" :class="{ sel: selected === q }" @click="select(q)">
<div class="row items-center no-wrap"> <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> <span class="text-weight-medium">{{ label(q) }}</span>
<q-space /> <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 }} {{ (members[q] || []).length }}
</q-badge> </q-badge>
</div> </div>
@ -30,7 +30,7 @@
<q-slide-transition> <q-slide-transition>
<div v-if="selected" class="queue-detail q-mt-md"> <div v-if="selected" class="queue-detail q-mt-md">
<div class="row items-center q-mb-sm"> <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> <span class="text-h6">{{ label(selected) }}</span>
<q-space /> <q-space />
<q-btn flat round dense icon="close" @click="selected = null" /> <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é --> <!-- 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"> <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"> <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> </q-input>
<span class="text-caption text-grey-6">Nom réel (F) : <b>{{ selected }}</b> inchangé</span> <span class="text-caption text-grey-6">Nom réel (F) : <b>{{ selected }}</b> inchangé</span>
</div> </div>
<div class="text-subtitle2 q-mb-xs">Membres ({{ (members[selected] || []).length }})</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"> <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> <span class="text-body2">{{ email }}</span>
<q-space /> <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>
<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> <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"> <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"> <q-input v-model="search" label="Ajouter un membre…" outlined dense autocomplete="off" name="qm-add-nope" @update:model-value="onSearch">
<template #append> <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 = []" /> <q-icon v-else-if="search" name="close" class="cursor-pointer" @click="search = ''; results = []" />
</template> </template>
</q-input> </q-input>
<div v-if="results.length" class="qm-dropdown"> <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)"> <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-body2">{{ u.name || u.username || u.email }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ 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" /> <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) --> <!-- Importer tout un groupe Authentik (la table explicite reste la source) -->
<div class="row items-center q-gutter-sm q-mt-sm"> <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-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>
</div> </div>
</q-slide-transition> </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> <template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" persistent> <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 --> <!-- Header -->
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)"> <q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="col"> <div class="col">
@ -10,41 +10,60 @@
<q-btn flat round dense icon="close" @click="$emit('update:modelValue', false)" /> <q-btn flat round dense icon="close" @click="$emit('update:modelValue', false)" />
</q-card-section> </q-card-section>
<!-- Form --> <q-card-section class="q-pt-md">
<q-card-section class="q-pt-md q-gutter-sm"> <!-- Line items (the focus) -->
<!-- Dates --> <div class="text-caption text-weight-bold q-mb-xs">Lignes</div>
<div class="row q-gutter-sm"> <div v-for="(line, idx) in form.items" :key="idx" class="q-mb-sm">
<q-input v-model="form.posting_date" label="Date de facturation" type="date" dense outlined class="col" /> <div class="row items-start q-gutter-xs">
<q-input v-model="form.due_date" label="Date d'echeance" type="date" dense outlined class="col" /> <!-- Product autosuggest type to search catalogue, or free description -->
</div> <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-input v-model.number="line.qty" label="Qté" type="number" dense outlined
<q-select v-model="form.taxes_and_charges" :options="taxOptions" label="Taxes" style="width:64px" min="1" step="1" hide-bottom-space />
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.rate" label="Taux" type="number" dense outlined <q-input v-model.number="line.rate" label="Taux" type="number" dense outlined
style="width:110px" min="0" step="0.01" prefix="$" /> style="width:106px" min="0" step="0.01" prefix="$" hide-bottom-space class="no-spin" />
<div style="width:90px" class="q-pt-sm text-right text-weight-medium"> <div style="width:84px" class="q-pt-sm text-right text-weight-medium">
{{ formatMoney(line.qty * line.rate) }} {{ formatMoney((line.qty || 0) * (line.rate || 0)) }}
</div> </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)"> :disable="form.items.length === 1" @click="removeLine(idx)">
<q-tooltip>Supprimer la ligne</q-tooltip> <q-tooltip>Supprimer la ligne</q-tooltip>
</q-btn> </q-btn>
</div> </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" /> @click="addLine" class="q-mt-xs" />
<!-- Totals --> <!-- Totals -->
@ -67,12 +86,43 @@
<span class="text-weight-bold">{{ formatMoney(grandTotal) }}</span> <span class="text-weight-bold">{{ formatMoney(grandTotal) }}</span>
</div> </div>
</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> </q-card-section>
<!-- Actions --> <!-- Actions -->
<q-card-actions align="right" class="q-px-md q-pb-md"> <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 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" /> :loading="submitting" :disable="!canSubmit" @click="onSubmit" />
</q-card-actions> </q-card-actions>
</q-card> </q-card>
@ -82,8 +132,12 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import { createDoc } from 'src/api/erp' import { createDoc, listDocs } from 'src/api/erp'
import { formatMoney } from 'src/composables/useFormatters' import { formatMoney } from 'src/composables/useFormatters'
import {
DEFAULT_INCOME_ACCOUNT, INCOME_ACCOUNT_OPTIONS,
resolveIncomeAccount, incomeAccountLabel,
} from 'src/data/income-accounts'
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
@ -101,22 +155,16 @@ const taxOptions = [
{ label: 'Canada GST 5%', value: TAX_GST }, { label: 'Canada GST 5%', value: TAX_GST },
{ label: 'Aucune taxe (Exempt)', value: TAX_NONE }, { label: 'Aucune taxe (Exempt)', value: TAX_NONE },
] ]
const incomeAccountOptions = INCOME_ACCOUNT_OPTIONS
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 submitting = ref(false) 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 () { 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) { function addDays (dateStr, days) {
const d = new Date(dateStr) const d = new Date(dateStr)
d.setDate(d.getDate() + days) d.setDate(d.getDate() + days)
@ -127,59 +175,117 @@ const form = ref({
posting_date: todayStr(), posting_date: todayStr(),
due_date: addDays(todayStr(), 30), due_date: addDays(todayStr(), 30),
taxes_and_charges: TAX_QC, taxes_and_charges: TAX_QC,
income_account: '4020 - Mensualité fibre - T', default_income_account: DEFAULT_INCOME_ACCOUNT,
items: [defaultLine()], items: [defaultLine()],
}) })
// Reset form when dialog opens // Reset when the dialog opens
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, (open) => {
if (open) { if (open) {
const isExempt = props.customer?.tax_category_legacy === 'Exempt' const isExempt = props.customer?.tax_category_legacy === 'Exempt'
dueTerms.value = 30
itemResults.value = []
form.value = { form.value = {
posting_date: todayStr(), posting_date: todayStr(),
due_date: addDays(todayStr(), 30), due_date: addDays(todayStr(), 30),
taxes_and_charges: isExempt ? TAX_NONE : TAX_QC, taxes_and_charges: isExempt ? TAX_NONE : TAX_QC,
income_account: '4020 - Mensualité fibre - T', default_income_account: DEFAULT_INCOME_ACCOUNT,
items: [defaultLine()], items: [defaultLine()],
} }
} }
}) })
function addLine () { // Recompute due date from the selected term (unless the user set a custom date)
form.value.items.push(defaultLine()) 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) { function applyProduct (line, opt) {
if (form.value.items.length > 1) { if (opt && typeof opt === 'object') {
form.value.items.splice(idx, 1) 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 function accountCaption (line) {
const subtotal = computed(() => const lbl = incomeAccountLabel(line.income_account)
form.value.items.reduce((sum, l) => sum + (l.qty || 0) * (l.rate || 0), 0) 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 isTwoPartTax = computed(() => form.value.taxes_and_charges === TAX_QC)
const taxRate = computed(() => { const taxRate = computed(() => {
if (form.value.taxes_and_charges === TAX_QC) return 0.14975 if (form.value.taxes_and_charges === TAX_QC) return 0.14975
if (form.value.taxes_and_charges === TAX_GST) return 0.05 if (form.value.taxes_and_charges === TAX_GST) return 0.05
return 0 return 0
}) })
const taxLabel = computed(() => { const taxLabel = computed(() => {
if (form.value.taxes_and_charges === TAX_QC) return '14.975%' if (form.value.taxes_and_charges === TAX_QC) return '14.975%'
if (form.value.taxes_and_charges === TAX_GST) return '5%' if (form.value.taxes_and_charges === TAX_GST) return '5%'
return '' return ''
}) })
const taxAmount = computed(() => subtotal.value * taxRate.value) const taxAmount = computed(() => subtotal.value * taxRate.value)
const grandTotal = computed(() => subtotal.value + taxAmount.value) const grandTotal = computed(() => subtotal.value + taxAmount.value)
const canSubmit = computed(() => const termLabel = computed(() => {
form.value.items.some(l => l.description?.trim() && l.rate > 0) 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 () { async function onSubmit () {
if (!canSubmit.value) return if (!canSubmit.value) return
@ -187,14 +293,17 @@ async function onSubmit () {
try { try {
const items = form.value.items const items = form.value.items
.filter(l => l.description?.trim() && l.rate > 0) .filter(l => l.description?.trim() && l.rate > 0)
.map(l => ({ .map(l => {
item_code: 'SVC', const row = {
item_name: l.description.trim(), item_code: l.item_code || 'SVC',
description: l.description.trim(), description: l.description.trim(),
qty: l.qty || 1, qty: l.qty || 1,
rate: l.rate, 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 = { const payload = {
doctype: 'Sales Invoice', doctype: 'Sales Invoice',
@ -207,38 +316,22 @@ async function onSubmit () {
items, items,
} }
// Include tax rows directly (ERPNext tax templates are empty shells)
const taxSel = form.value.taxes_and_charges const taxSel = form.value.taxes_and_charges
if (taxSel === TAX_QC) { if (taxSel === TAX_QC) {
payload.taxes_and_charges = TAX_QC payload.taxes_and_charges = TAX_QC
payload.taxes = [ payload.taxes = [
{ { charge_type: 'On Net Total', account_head: '2300 - TPS perçue - T', description: 'TPS 5%', rate: 5 },
charge_type: 'On Net Total', { charge_type: 'On Net Total', account_head: '2350 - TVQ perçue - T', description: 'TVQ 9.975%', rate: 9.975 },
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) { } else if (taxSel === TAX_GST) {
payload.taxes_and_charges = TAX_GST payload.taxes_and_charges = TAX_GST
payload.taxes = [ 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) 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('created', doc)
emit('update:modelValue', false) emit('update:modelValue', false)
} catch (err) { } catch (err) {
@ -250,8 +343,9 @@ async function onSubmit () {
</script> </script>
<style scoped> <style scoped>
.totals-grid { .totals-grid { max-width: 320px; margin-left: auto; }
max-width: 320px; /* Hide number-input spinner arrows (useless here) */
margin-left: auto; .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> </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 --> <!-- Loading -->
<q-card-section v-if="loading" class="flex flex-center" style="min-height:200px"> <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> </q-card-section>
<!-- Content --> <!-- 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 }} {{ displayValue || placeholder }}
</span> </span>
</slot> </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> </template>
<!-- Edit mode: text / number --> <!-- Edit mode: text / number -->

View File

@ -2,7 +2,7 @@
<q-dialog v-model="open" @show="onShow"> <q-dialog v-model="open" @show="onShow">
<q-card style="min-width:480px;max-width:96vw"> <q-card style="min-width:480px;max-width:96vw">
<q-card-section class="row items-center q-pb-none"> <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>
<div class="text-subtitle1 text-weight-bold">Intervenir{{ title ? ' — ' + title : '' }}</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> <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 <q-input v-model="text" type="textarea" autogrow :rows="3" outlined autofocus
label="Indice, note, contexte… (visible par l'équipe sur le ticket)" /> label="Indice, note, contexte… (visible par l'équipe sur le ticket)" />
<div class="row justify-end q-mt-sm"> <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>
</div> </div>
@ -38,22 +38,22 @@
<div v-else-if="step === 'mention'"> <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'" /> <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"> <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>
<div style="position:relative"> <div style="position:relative">
<q-input v-model="search" dense outlined label="Mentionner un collègue…" autofocus autocomplete="off" @update:model-value="onSearch"> <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> </q-input>
<div v-if="results.length" class="iv-dd"> <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)"> <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> {{ u.name || u.email }}<span class="text-caption text-grey-6 q-ml-xs">{{ u.email }}</span>
</div> </div>
</div> </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)…" /> <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"> <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>
</div> </div>
@ -94,7 +94,7 @@ let _t = null
const actions = [ const actions = [
{ key: 'note', icon: 'lightbulb', color: 'amber-7', label: 'Indice / note', hint: 'Partager une connaissance' }, { 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: '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 }, { 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-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-tooltip>Nouveau message (courriel par défaut, ou texto)</q-tooltip>
</q-btn> </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"> <q-input v-model="search" dense outlined clearable debounce="200" placeholder="Rechercher (nom, courriel, sujet…)" class="mlist-search">
<template #prepend><q-icon name="search" size="18px" /></template> <template #prepend><q-icon name="search" size="18px" /></template>
</q-input> </q-input>
<q-btn flat dense round :icon="compact ? 'density_small' : 'density_medium'" size="sm" @click="toggleDensity"><q-tooltip>{{ compact ? 'Affichage confortable' : 'Affichage compact' }}</q-tooltip></q-btn> <q-btn flat dense round :icon="compact ? 'density_small' : 'density_medium'" size="sm" @click="toggleDensity"><q-tooltip>{{ compact ? 'Affichage confortable' : 'Affichage compact' }}</q-tooltip></q-btn>
<q-btn flat dense round :icon="notifyEnabled ? 'notifications_active' : 'notifications_none'" :color="notifyEnabled ? 'indigo-6' : 'grey-6'" size="sm" @click="toggleNotify"><q-tooltip>{{ notifyEnabled ? 'Notifications navigateur activées' : 'Activer les notifications de nouveaux courriels' }}</q-tooltip></q-btn> <q-btn flat dense round :icon="notifyEnabled ? 'notifications_active' : 'notifications_none'" :color="notifyEnabled ? '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> <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> </div>
<!-- Case « tout cocher » + filtres OU actions de lot DANS LA MÊME LIGNE (hauteur constante pas de décalage au clic) --> <!-- Case « tout cocher » + filtres OU actions de lot DANS LA MÊME LIGNE (hauteur constante pas de décalage au clic) -->
<div class="mlist-tabs"> <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> <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"> <template v-if="checked.size">
<span class="text-weight-medium text-grey-8">{{ checked.size }} sélectionné(s)</span> <span class="text-weight-medium text-grey-8">{{ checked.size }} sélectionné(s)</span>
<q-space /> <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-menu auto-close>
<q-list dense style="min-width:190px"> <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-label header class="q-py-xs">Créer un ticket dans</q-item-label>
@ -31,65 +62,94 @@
</q-menu> </q-menu>
</q-btn> </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="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> <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>
<template v-else> <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> Tous<span class="mlist-cnt">{{ visible.length }}</span>
</q-chip> </q-chip>
<q-chip v-for="f in chips" :key="f.key" dense clickable size="sm" <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)"> @click="toggle(f.key)">
{{ f.label }}<span v-if="f.count" class="mlist-cnt">{{ f.count }}</span> {{ f.label }}<span v-if="f.count" class="mlist-cnt">{{ f.count }}</span>
</q-chip> </q-chip>
</template> </template>
</template>
</div> </div>
<div class="mlist-body"> <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)"> <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)" /> <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 --> <!-- 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 --> <!-- 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 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> <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> <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-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"> <div class="mrow-right">
<q-icon v-if="replyInfo(d).client" name="reply" size="14px" color="teal-6" class="mrow-rep"><q-tooltip>Le client a répondu</q-tooltip></q-icon> <q-icon v-if="replyInfo(d).client" name="reply" size="14px" color="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="indigo-5" class="mrow-rep"><q-tooltip>Un collègue a répondu</q-tooltip></q-icon> <q-icon v-if="replyInfo(d).team" name="forum" size="13px" color="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="indigo-5" text-color="white" class="q-mr-xs mrow-draft"> <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> <span style="font-size:9px;font-weight:700">{{ agentInitials(draftAgentOf(d)) }}</span>
<q-tooltip> Brouillon en cours {{ agentName(draftAgentOf(d)) }}</q-tooltip> <q-tooltip> Brouillon en cours {{ agentName(draftAgentOf(d)) }}</q-tooltip>
</q-avatar> </q-avatar>
<ReaderStack :readers="readersOf(d)" :me="meEmail" :max="3" :size="17" /> <ReaderStack :readers="readersOf(d)" :me="meEmail" :max="3" :size="17" />
<span class="mrow-time">{{ relTime(lastTs(d)) }}</span> <span class="mrow-time">{{ relTime(lastTs(d)) }}</span>
</div> </div>
<!-- Actions au survol (style Gmail) : répondre / classer en ticket par dépt / archiver / supprimer --> <!-- Barre d'actions au survol RETIRÉE : elle cachait l'avatar de présence/lecteurs à droite. Répondre/ticket/archiver/supprimer
<div class="mrow-actions" @click.stop> 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. -->
<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>
</div> </div>
<div v-if="!shown.length" class="mlist-empty">Aucune conversation{{ sel.size || search || chanView !== 'all' ? ' (filtre actif)' : '' }}</div> <div v-if="!shown.length" class="mlist-empty">Aucune conversation{{ sel.size || search || chanView !== 'all' ? ' (filtre actif)' : '' }}</div>
</template>
</template>
</div> </div>
<!-- « Filtrer les messages comme celui-ci » : crée une règle masquer/afficher (style Gmail) --> <!-- « Filtrer les messages comme celui-ci » : crée une règle masquer/afficher (style Gmail) -->
@ -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 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> <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> <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-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-section>
<q-card-actions align="right"> <q-card-actions align="right">
<q-btn flat no-caps label="Annuler" v-close-popup /> <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-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
@ -112,17 +172,22 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useConversations } from 'src/composables/useConversations' 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 { useAuthStore } from 'src/stores/auth'
import { relTime } from 'src/composables/useFormatters' import { relTime } from 'src/composables/useFormatters'
import { channelMeta, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ReaderStack from './ReaderStack.vue' import ReaderStack from './ReaderStack.vue'
const $q = useQuasar() const $q = useQuasar()
const router = useRouter() 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). // Rafraîchir à la demande = forcer un pull Gmail immédiat puis recharger la liste ( simple relecture).
const refreshing = ref(false) const refreshing = ref(false)
async function refreshNow () { refreshing.value = true; try { await pollNow() } finally { await fetchList(); refreshing.value = 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 sel = ref(new Set()) // filtres cumulables (départements OU entre eux ; unread/mine = ET)
const search = ref('') 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) // Vue par canal (style Gmail « Mail / Chat ») : Tous (défaut) · Courriel · Clavardage (SMS + chat web temps réel)
const chanView = ref('all') const chanView = ref('all')
const CHAT_CH = ['sms', 'webchat', '3cx', 'facebook'] const CHAT_CH = ['sms', 'webchat', '3cx', 'facebook']
@ -168,12 +247,82 @@ const chanOptions = [
{ value: 'all', label: 'Tous', icon: 'all_inbox' }, { value: 'all', label: 'Tous', icon: 'all_inbox' },
{ value: 'email', label: 'Courriel', icon: 'mail' }, { value: 'email', label: 'Courriel', icon: 'mail' },
{ value: 'chat', label: 'Clavardage', icon: 'forum' }, { 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). // 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 function openCompose () { newDialogChannel.value = 'email'; newDialogOpen.value = true } // Compose = courriel par défaut ; SMS = choix secondaire dans la fenêtre
const compact = ref(false); try { compact.value = localStorage.getItem('mlist-density') === 'compact' } catch (e) { /* */ } const compact = ref(false); try { compact.value = localStorage.getItem('mlist-density') === 'compact' } catch (e) { /* */ }
function toggleDensity () { compact.value = !compact.value; try { localStorage.setItem('mlist-density', compact.value ? 'compact' : '') } catch (e) { /* */ } } function toggleDensity () { compact.value = !compact.value; try { localStorage.setItem('mlist-density', compact.value ? 'compact' : '') } catch (e) { /* */ } }
const checked = ref(new Set()) // ids de discussions cochées (batch) const checked = ref(new Set()) // ids de discussions cochées (batch)
// 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 batchBusy = ref(false)
const QLABELS = { Facturations: 'Facturation', 'Service à la clientèle': 'Service client', Supports: 'Support', Technicien: 'Technicien' } 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: 'unread', label: 'Non lus', count: visible.value.filter(isUnread).length },
{ key: 'mine', label: 'À moi', count: visible.value.filter(d => d.assignee === meEmail.value).length }, { key: 'mine', label: 'À moi', count: visible.value.filter(d => d.assignee === meEmail.value).length },
] ]
const 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 const draftN = visible.value.filter(draftAgentOf).length
if (draftN) base.push({ key: 'draft', label: '✎ Brouillons', count: draftN }) if (draftN) base.push({ key: 'draft', label: '✎ Brouillons', count: draftN })
for (const q of QUEUES) base.push({ key: q, label: qLabel(q), count: visible.value.filter(d => (d.queue || '') === q).length }) for (const q of QUEUES) base.push({ key: q, label: qLabel(q), count: visible.value.filter(d => (d.queue || '') === q).length })
@ -235,7 +386,7 @@ function toggle (k) {
const shown = computed(() => { const shown = computed(() => {
const q = search.value.trim().toLowerCase() const q = search.value.trim().toLowerCase()
const depts = [...sel.value].filter(k => QUEUES.includes(k)) const depts = [...sel.value].filter(k => QUEUES.includes(k))
const 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 => { return visible.value.filter(d => {
if (depts.length && !depts.includes(d.queue || '')) return false if (depts.length && !depts.includes(d.queue || '')) return false
const isChat = CHAT_CH.includes(d.channel || '') const isChat = CHAT_CH.includes(d.channel || '')
@ -244,9 +395,11 @@ const shown = computed(() => {
if (needD && !draftAgentOf(d)) return false if (needD && !draftAgentOf(d)) return false
if (needU && !isUnread(d)) return false if (needU && !isUnread(d)) return false
if (needM && d.assignee !== meEmail.value) return false if (needM && d.assignee !== meEmail.value) return false
if (needPrio && !(d.priority === 'urgent' || d.priority === 'high')) return false
if (q && !matchSearch(d, q)) return false if (q && !matchSearch(d, q)) return false
return true 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 // 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 // Actions au survol (style Gmail) : répondre / classer en ticket par département / archiver / supprimer
const TICKET_DEPTS = [ 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: 'Facturation', cat: 'Facturation', icon: 'receipt_long', color: 'green-7' },
{ label: 'Service client', cat: 'Service client', icon: 'support_agent', color: 'blue-6' }, { label: 'Service client', cat: 'Service client', icon: 'support_agent', color: 'blue-6' },
{ label: 'Installation', cat: 'Installation', icon: 'cable', color: 'deep-orange-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 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() }) onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList() })
</script> </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. */ 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 { display: flex; flex-direction: column; height: calc(100vh - 50px); width: 100%; }
.mlist-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px 4px; } .mlist-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px 4px; }
.mlist-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-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-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; } .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 { display: flex; flex-wrap: nowrap; align-items: center; gap: 4px; padding: 6px 12px; border-bottom: 1px solid var(--ops-border, #e2e8f0); overflow-x: auto; min-height: 40px; }
.mlist-tabs::-webkit-scrollbar { height: 0; } .mlist-tabs::-webkit-scrollbar { height: 0; }
.mlist-tabs .q-chip { flex: 0 0 auto; } .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-allcb { flex: 0 0 auto; margin-right: 4px; }
.mlist-cnt { margin-left: 5px; font-size: 0.66rem; opacity: 0.8; } .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; } .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:hover .mrow-actions { display: flex; }
.mrow-checked { background: #eef2ff; } .mrow-checked { background: #eef2ff; }
.mrow-cb { margin-top: 1px; flex: 0 0 auto; } .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-chan { margin-top: 2px; flex: 0 0 auto; }
.mrow-main { flex: 1; min-width: 0; } .mrow-main { flex: 1; min-width: 0; }
.mrow-line1 { display: flex; align-items: center; gap: 6px; } .mrow-line1 { display: flex; align-items: center; gap: 6px; }

View File

@ -2,7 +2,7 @@
<q-dialog v-model="open"> <q-dialog v-model="open">
<q-card style="min-width:460px;max-width:96vw"> <q-card style="min-width:460px;max-width:96vw">
<q-card-section class="row items-center q-pb-none"> <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> <div class="text-subtitle1 text-weight-bold">Nouveau ticket</div>
<q-space /> <q-space />
<q-btn flat round dense icon="close" v-close-popup /> <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 <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)" label="Décris le problème (ex. « Problème wifi 2338 rue Ste-Clotilde » ou un nom / téléphone)"
@update:model-value="onSearch"> @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> </q-input>
<div v-if="results.length && !customer" class="nt-dd"> <div v-if="results.length && !customer" class="nt-dd">
<div v-for="m in results" :key="m.name" class="nt-item" @click="pick(m)"> <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="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-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 class="text-caption text-grey-6 ellipsis">{{ m.address || m.territory || m.name }}</div>
</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> </div>
</div> </div>
@ -36,9 +36,9 @@
<!-- Adresse de service (compte multi-adresses) on lie la BONNE, pas la facturation --> <!-- 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 <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"> 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> </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>
<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> <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>
<div class="text-caption text-grey-6" v-if="queueFor(category)"> routé à l'équipe <b>{{ queueFor(category) }}</b></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"> <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)'" /> <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>
<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-section>
<q-card-actions align="right"> <q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup /> <q-btn flat label="Annuler" v-close-popup />
<q-btn 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-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>

View File

@ -5,10 +5,10 @@
<q-menu anchor="bottom right" self="top right" @show="onShow"> <q-menu anchor="bottom right" self="top right" @show="onShow">
<div style="min-width:330px;max-width:390px"> <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"> <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> <span class="text-weight-bold">Notifications</span>
<q-space /> <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" /> <q-btn v-if="notifications.length" flat dense size="sm" no-caps label="Tout lu" color="grey-7" @click="markAllRead" />
</div> </div>
@ -18,7 +18,17 @@
<div v-for="f in FEEDS" :key="f.key" class="row items-center no-wrap q-py-xs"> <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" /> <q-icon :name="f.icon" :color="f.color" size="18px" class="q-mr-sm" />
<span class="text-body2 col">{{ f.label }}</span> <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>
<div class="text-caption text-grey-5 q-mt-xs">Préférences propres à ton compte.</div> <div class="text-caption text-grey-5 q-mt-xs">Préférences propres à ton compte.</div>
</div> </div>
@ -26,7 +36,7 @@
<q-list v-if="notifications.length" style="max-height:60vh;overflow:auto"> <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 v-for="n in notifications" :key="n.id" clickable v-close-popup @click="go(n)">
<q-item-section avatar> <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-section> <q-item-section>
<q-item-label lines="1">{{ n.title }}</q-item-label> <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' import { relTime } from 'src/composables/useFormatters'
defineProps({ dark: Boolean }) 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) const showPrefs = ref(false)
function onShow () { markAllRead() } // ouvrir = marquer lu (pastille à zéro) function onShow () { markAllRead() } // ouvrir = marquer lu (pastille à zéro)
function setFeed (key, v) { prefs.value[key] = v; savePrefs() } 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-if="diagnoses.length" class="q-mt-md">
<div v-for="(a, i) in diagnoses" :key="'d' + i" class="orch-diag"> <div v-for="(a, i) in diagnoses" :key="'d' + i" class="orch-diag">
<div class="row items-center q-mb-xs"> <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-weight-medium">Vérification</span>
<span class="text-caption text-grey-6 q-ml-sm">« {{ a.customer_query }} »</span> <span class="text-caption text-grey-6 q-ml-sm">« {{ a.customer_query }} »</span>
</div> </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 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> </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> </template>
<!-- plusieurs clients préciser l'adresse --> <!-- plusieurs clients préciser l'adresse -->
<template v-else-if="a.diagnostic && a.diagnostic.ambiguous"> <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-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 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> <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-item>
</q-list> </q-list>
</template> </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 v-else class="text-caption text-grey-6"><q-spinner size="14px" /> diagnostic</div>
</div> </div>
</div> </div>
@ -67,7 +67,7 @@
<div class="row items-center no-wrap q-mb-xs"> <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" /> <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> <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-badge v-if="a.category" :label="a.category" color="grey-3" text-color="grey-8" class="q-ml-xs" />
<q-space /> <q-space />
<q-btn flat round dense size="xs" icon="delete" color="grey-5" @click="actions.splice(actions.indexOf(a), 1)" /> <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> </q-item-section></q-item>
</template> </template>
</q-select> </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>
<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.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) --> <!-- 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"> <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"> <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> </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> </div>
<!-- contenu éditable --> <!-- contenu éditable -->
<q-input v-if="a.type === 'create_ticket'" v-model="a.subject" dense outlined label="Sujet" /> <q-input v-if="a.type === 'create_ticket'" v-model="a.subject" dense outlined label="Sujet" />
@ -104,7 +104,7 @@
<!-- Résultats --> <!-- Résultats -->
<div v-if="results.length" class="q-mt-md"> <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) }} <q-icon :name="r.ok ? 'check_circle' : 'error'" size="15px" /> {{ summaryOf(r) }}
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
<template> <template>
<div v-if="alerts.length" class="outage-alerts-panel"> <div v-if="alerts.length" class="outage-alerts-panel">
<div class="outage-alerts-header" @click="expanded = !expanded"> <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> <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" /> <q-icon :name="expanded ? 'expand_less' : 'expand_more'" size="18px" class="q-ml-auto" />
</div> </div>

View File

@ -3,18 +3,18 @@
<transition name="ob-slide"> <transition name="ob-slide">
<div v-if="pending.length" class="outbox-panel"> <div v-if="pending.length" class="outbox-panel">
<div class="ob-head row items-center no-wrap"> <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> <div class="text-weight-bold">{{ pending.length }} courriel{{ pending.length > 1 ? 's' : '' }} en attente d'envoi</div>
<q-space /> <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>
<div v-for="it in pending" :key="it.id" class="ob-item row items-center no-wrap" <div v-for="it in pending" :key="it.id" class="ob-item row items-center no-wrap"
:class="{ 'ob-failed': it.status === 'failed' }"> :class="{ 'ob-failed': it.status === 'failed' }">
<q-circular-progress v-if="it.status !== 'failed'" :value="pct(it)" size="38px" :thickness="0.18" <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> <span class="text-caption text-weight-bold">{{ secLeft(it) }}</span>
</q-circular-progress> </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="col" style="min-width:0">
<div class="text-caption text-grey-7 ellipsis"> <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" /> <q-badge :color="kindColor(it.kind)" text-color="white" :label="it.label || it.kind" class="q-mr-xs" />
@ -22,7 +22,7 @@
</div> </div>
<div class="text-body2 ellipsis" :title="it.subject">{{ it.subject || '(sans objet)' }}</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 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> </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="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)"> <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 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 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) { async function cancel (id) {
pending.value = pending.value.filter(x => x.id !== id) // optimiste 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-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> <div v-else class="text-subtitle2 text-weight-bold text-grey-8">Leads</div>
<q-space /> <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" /> <q-btn flat dense round icon="refresh" :loading="loading" @click="load" />
</div> </div>
<div class="pipe-board"> <div class="pipe-board">
<div v-for="st in stages" :key="st" class="pipe-col" :class="{ 'drop-hover': dropStage === st }" <div v-for="st in stages" :key="st" class="pipe-col">
@dragover.prevent="dropStage = st" @dragleave="dropStage === st && (dropStage = null)" @drop="onDrop(st)">
<div class="pipe-col-hd" :class="stageClass(st)"> <div class="pipe-col-hd" :class="stageClass(st)">
<span>{{ st }}</span> <span>{{ st }}</span>
<q-badge color="white" text-color="grey-9" class="q-ml-xs">{{ (columns[st] || []).length }}</q-badge> <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>
<div class="pipe-col-body"> <!-- vuedraggable (SortableJS) : glisser-déposer qui marche AU DOIGT appui long 160ms sur tactile
<div v-for="c in (columns[st] || [])" :key="c.token" class="pipe-card" draggable="true" (delay-on-touch-only le défilement reste fluide), instantané à la souris sur desktop. -->
@dragstart="onDragStart(c)" @dragend="dragCard = null" @click="openLead(c)"> <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"> <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'" /> <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' }} {{ c.customerName || c.contact || 'Lead' }}
@ -25,13 +30,17 @@
</div> </div>
<div class="pipe-card-s ellipsis">{{ c.subject || '—' }}</div> <div class="pipe-card-s ellipsis">{{ c.subject || '—' }}</div>
<div class="pipe-card-m"> <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 /> <q-space />
<span v-if="c.assignee" class="pipe-assignee"><q-icon name="pan_tool" size="11px" /> {{ shortAgent(c.assignee) }}</span> <span v-if="c.assignee" class="pipe-assignee"><q-icon name="pan_tool" size="11px" /> {{ shortAgent(c.assignee) }}</span>
</div> </div>
</div> </div>
<div v-if="!(columns[st] || []).length" class="pipe-empty"></div> </template>
</div> <template #footer>
<div v-if="!(columns[st] || []).length" class="pipe-empty">Glissez un lead ici</div>
</template>
</draggable>
</div> </div>
</div> </div>
</div> </div>
@ -40,8 +49,9 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import draggable from 'vuedraggable'
import { useConversations } from 'src/composables/useConversations' import { useConversations } from 'src/composables/useConversations'
import { shortAgent } from 'src/composables/useFormatters' import { shortAgent, formatMoney } from 'src/composables/useFormatters'
defineProps({ embedded: Boolean }) defineProps({ embedded: Boolean })
@ -51,29 +61,32 @@ const { pipelineBoard, setPipeline, fetchList, discussions, openDiscussion, pane
const stages = ref(['Nouveau', 'Qualifié', 'Devis', 'Gagné', 'Perdu']) const stages = ref(['Nouveau', 'Qualifié', 'Devis', 'Gagné', 'Perdu'])
const columns = ref({}) const columns = ref({})
const loading = ref(false) 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 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)) 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) // shortAgent vient de useFormatters (consolidation)
function stageClass (st) { return 'st-' + st.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() } function stageClass (st) { return 'st-' + st.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() }
async function load () { async function load () {
loading.value = true 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 } // vuedraggable a DÉJÀ déplacé la carte entre les colonnes (optimiste) ; on persiste seulement à l'arrivée (added).
async function onDrop (stage) { async function onDragChange (evt, stage) {
dropStage.value = null const c = evt && evt.added && evt.added.element
const c = dragCard.value; dragCard.value = null
if (!c || !stage) return if (!c || !stage) return
// retire de l'ancienne colonne, ajoute à la nouvelle (optimiste) try { await setPipeline(c.token, stage); $q.notify({ type: 'positive', message: `${c.customerName || 'Lead'}${stage}`, timeout: 1500 }) }
for (const st in columns.value) columns.value[st] = columns.value[st].filter(x => x.token !== c.token) catch (e) { $q.notify({ type: 'negative', message: 'Échec déplacement' }); load() }
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() }
} }
async function openLead (c) { 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-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 { 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-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-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-s { font-size: .75rem; color: #475569; margin-top: 2px; }
.pipe-card-m { display: flex; align-items: center; font-size: .68rem; margin-top: 5px; } .pipe-card-m { display: flex; align-items: center; font-size: .68rem; margin-top: 5px; }
.pipe-assignee { color: #b45309; font-weight: 600; } .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; } .pipe-empty { text-align: center; color: #cbd5e1; padding: 10px; font-size: .8rem; }
@media (max-width: 640px) { @media (max-width: 640px) {
.pipe-board { scroll-snap-type: x mandatory; } .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-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 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> </div>
<q-icon name="arrow_forward" size="20px" color="indigo-6" /> <q-icon name="arrow_forward" size="20px" color="primary" />
</div> </div>
<div class="row items-center q-mt-md q-mb-xs"> <div class="row items-center q-mt-md q-mb-xs">
@ -46,7 +46,7 @@
<div class="template-grid"> <div class="template-grid">
<div v-for="tpl in templates" :key="tpl.id" class="template-card" <div v-for="tpl in templates" :key="tpl.id" class="template-card"
:class="{ selected: selectedTemplate?.id === tpl.id }" @click="selectTemplate(tpl)"> :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-name">{{ tpl.name }}</div>
<div class="template-card-desc">{{ tpl.description }}</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" /> <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"> <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"> <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="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 class="text-caption text-grey-7">Appuyez sur «&nbsp;Suivant&nbsp;» pour aller directement au catalogue.</div>
</div> </div>
</div> </div>
@ -84,7 +84,7 @@
<div class="step-num">{{ i + 1 }}</div> <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" <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' }" /> :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)" /> @click="removeStep(i)" />
</div> </div>
<div class="row q-col-gutter-sm q-mb-xs"> <div class="row q-col-gutter-sm q-mb-xs">
@ -129,21 +129,21 @@
</div> </div>
</q-expansion-item> </q-expansion-item>
<div v-if="step.depends_on_step != null" class="step-dep-indicator"> <div v-if="step.depends_on_step != null" class="step-dep-indicator">
<q-icon name="subdirectory_arrow_right" size="14px" color="orange-7" /> <q-icon name="subdirectory_arrow_right" size="14px" color="warning" />
<span class="text-caption text-orange-8">Après: étape {{ step.depends_on_step + 1 }}</span> <span class="text-caption text-warning">Après: étape {{ step.depends_on_step + 1 }}</span>
</div> </div>
</div> </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" /> @click="addStep" />
</q-card-section> </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"> <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"> <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" /> :options="ORDER_MODE_OPTIONS" />
<q-space /> <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()" /> @click="openCatalog()" />
</div> </div>
@ -155,8 +155,8 @@
@click="goToSommaire"> @click="goToSommaire">
<div class="cart-pill-left"> <div class="cart-pill-left">
<q-icon name="shopping_cart" size="22px" <q-icon name="shopping_cart" size="22px"
:color="orderItems.length ? 'indigo-6' : 'grey-5'" /> :color="orderItems.length ? 'primary' : 'grey-5'" />
<q-badge v-if="orderItems.length" floating color="deep-orange-6" text-color="white" <q-badge v-if="orderItems.length" floating color="warning" text-color="white"
class="cart-pill-badge">{{ orderItems.length }}</q-badge> class="cart-pill-badge">{{ orderItems.length }}</q-badge>
</div> </div>
<div class="cart-pill-body"> <div class="cart-pill-body">
@ -168,7 +168,7 @@
</div> </div>
<div class="cart-pill-sub"> <div class="cart-pill-sub">
<template v-if="orderItems.length"> <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> <span class="text-grey-6"> modifier, réordonner, supprimer</span>
</template> </template>
<template v-else> <template v-else>
@ -187,7 +187,7 @@
+{{ ((onetimeTotal + recurringTotal) * 0.14975).toFixed(2) }}$ tx +{{ ((onetimeTotal + recurringTotal) * 0.14975).toFixed(2) }}$ tx
</div> </div>
</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> </div>
<!-- <!--
@ -206,14 +206,14 @@
<div class="wizard-step-summary"> <div class="wizard-step-summary">
<template v-if="selectedHeroTier"> <template v-if="selectedHeroTier">
<q-icon name="check_circle" size="14px" color="green-6" class="q-mr-xs" /> <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 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="internetContractMonths" class="text-grey-6"> · contrat {{ internetContractMonths }} mois</span>
<span v-if="selectedHeroTier.speed" class="text-grey-6"> · {{ selectedHeroTier.speed }}</span> <span v-if="selectedHeroTier.speed" class="text-grey-6"> · {{ selectedHeroTier.speed }}</span>
</template> </template>
<template v-else> <template v-else>
<q-icon name="flash_on" size="14px" color="amber-8" class="q-mr-xs" /> <q-icon name="flash_on" size="14px" color="warning" class="q-mr-xs" />
<span class="text-amber-8">Obligatoire à partir de 39.95$/mois · cliquez pour choisir un forfait</span> <span class="text-warning">Obligatoire à partir de 39.95$/mois · cliquez pour choisir un forfait</span>
</template> </template>
</div> </div>
</div> </div>
@ -235,7 +235,7 @@
@click.stop="onInternetTierClick(t)"> @click.stop="onInternetTierClick(t)">
<div class="tier-row-icon"> <div class="tier-row-icon">
<q-icon :name="t.icon" size="24px" <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>
<div class="tier-row-body"> <div class="tier-row-body">
<div class="tier-row-head"> <div class="tier-row-head">
@ -378,8 +378,8 @@
</div> </div>
<div class="preset-hero-desc"> <div class="preset-hero-desc">
<template v-if="phoneApplied && phoneMode === 'keep' && !phoneKeepNumber"> <template v-if="phoneApplied && phoneMode === 'keep' && !phoneKeepNumber">
<q-icon name="warning" size="12px" color="amber-8" class="q-mr-xs" /> <q-icon name="warning" size="12px" color="warning" class="q-mr-xs" />
<span class="text-amber-8">Cliquez pour saisir le numéro à conserver</span> <span class="text-warning">Cliquez pour saisir le numéro à conserver</span>
</template> </template>
<template v-else>Illimitée CA/US · bonification combo</template> <template v-else>Illimitée CA/US · bonification combo</template>
</div> </div>
@ -423,7 +423,7 @@
<q-icon name="swap_calls" size="20px" <q-icon name="swap_calls" size="20px"
:color="phoneApplied && phoneMode === 'keep' ? 'green-6' : 'teal-6'" /> :color="phoneApplied && phoneMode === 'keep' ? 'green-6' : 'teal-6'" />
<div class="tier-card-title">Conservation (port-in)</div> <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 Preuve requise
</q-badge> </q-badge>
</div> </div>
@ -452,7 +452,7 @@
:class="[`tier-${p.tier}`, { 'preset-added': usedServiceTypes.has(p.service_type) }]" :class="[`tier-${p.tier}`, { 'preset-added': usedServiceTypes.has(p.service_type) }]"
@click="applyPreset(p)"> @click="applyPreset(p)">
<q-icon :name="p.icon" size="18px" <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-body">
<div class="preset-upsell-delta"> <div class="preset-upsell-delta">
+{{ p.price_delta.toFixed(2) }}<span class="preset-upsell-mo">$/mois</span> +{{ 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" <q-input v-model.number="installRegular" dense outlined type="number" step="0.01" min="0"
label="Prix original" :input-style="{ fontSize: '0.78rem' }" label="Prix original" :input-style="{ fontSize: '0.78rem' }"
title="Prix barré affiché au client (0 = aucun barré)"> 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> </q-input>
</div> </div>
<div class="col-4"> <div class="col-4">
@ -528,7 +528,7 @@
the sticky cart pill at the top handles navigation to Sommaire the sticky cart pill at the top handles navigation to Sommaire
which is where cart details and totals are edited/reviewed. --> which is where cart details and totals are edited/reviewed. -->
<div class="row q-gutter-sm q-mt-md q-mb-sm"> <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()" /> @click="openCatalog()" />
<q-btn flat dense icon="edit" label="Item manuel" color="grey-7" no-caps size="sm" <q-btn flat dense icon="edit" label="Item manuel" color="grey-7" no-caps size="sm"
@click="addOrderItem" /> @click="addOrderItem" />
@ -543,7 +543,7 @@
class="q-mt-xs" style="font-size:0.78rem" /> class="q-mt-xs" style="font-size:0.78rem" />
<div v-if="requireAcceptance" class="q-mt-sm q-gutter-sm"> <div v-if="requireAcceptance" class="q-mt-sm q-gutter-sm">
<q-btn-toggle v-model="acceptanceMethod" no-caps dense unelevated size="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"> <div class="row q-gutter-sm q-mt-xs">
<q-input v-model="clientPhone" dense outlined placeholder="+15145551234" label="Téléphone" <q-input v-model="clientPhone" dense outlined placeholder="+15145551234" label="Téléphone"
class="col" :input-style="{ fontSize: '0.8rem' }" /> class="col" :input-style="{ fontSize: '0.8rem' }" />
@ -568,8 +568,8 @@
reorder item order drives the quote's line order downstream. --> 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"> <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"> <div class="row items-center q-mb-sm">
<q-icon name="shopping_cart" size="18px" color="indigo-6" class="q-mr-xs" /> <q-icon name="shopping_cart" size="18px" color="primary" class="q-mr-xs" />
<div class="text-weight-bold text-indigo-8" style="font-size:0.9rem"> <div class="text-weight-bold text-green-8" style="font-size:0.9rem">
Sommaire &mdash; {{ orderItems.length }} item{{ orderItems.length > 1 ? 's' : '' }} Sommaire &mdash; {{ orderItems.length }} item{{ orderItems.length > 1 ? 's' : '' }}
</div> </div>
<q-space /> <q-space />
@ -580,7 +580,7 @@
<div v-if="!orderItems.length" class="sommaire-empty"> <div v-if="!orderItems.length" class="sommaire-empty">
<q-icon name="shopping_cart_checkout" size="40px" color="grey-4" /> <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> <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" /> label="Retour au catalogue" class="q-mt-sm" @click="currentStep = 2" />
</div> </div>
@ -606,28 +606,28 @@
</div> </div>
<div class="sommaire-badge">{{ i + 1 }}</div> <div class="sommaire-badge">{{ i + 1 }}</div>
<q-icon :name="item.billing === 'recurring' ? 'autorenew' : 'shopping_cart'" size="16px" <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-main">
<div class="sommaire-name">{{ item.item_name || 'Item sans nom' }}</div> <div class="sommaire-name">{{ item.item_name || 'Item sans nom' }}</div>
<div class="sommaire-meta"> <div class="sommaire-meta">
<span>Qté {{ item.qty }}</span> <span>Qté {{ item.qty }}</span>
<span class="sommaire-sep">·</span> <span class="sommaire-sep">·</span>
<span class="text-weight-bold">{{ (item.qty * item.rate).toFixed(2) }}$</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"> <template v-if="item.billing === 'recurring' && item.contract_months">
<span class="sommaire-sep">·</span> <span class="sommaire-sep">·</span>
<span class="text-grey-6">{{ item.contract_months }} mois</span> <span class="text-grey-6">{{ item.contract_months }} mois</span>
</template> </template>
<template v-if="item.regular_price > item.rate"> <template v-if="item.regular_price > item.rate">
<span class="sommaire-sep">·</span> <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"> icon="celebration" size="sm" style="margin:0">
Promo {{ ((item.regular_price - item.rate) * item.qty).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }} Promo {{ ((item.regular_price - item.rate) * item.qty).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}
</q-chip> </q-chip>
</template> </template>
</div> </div>
</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" /> @click.stop="deleteSommaireItem(i)" title="Supprimer" />
<q-icon :name="expandedSommaireIdx === i ? 'expand_less' : 'expand_more'" <q-icon :name="expandedSommaireIdx === i ? 'expand_less' : 'expand_more'"
size="18px" color="grey-6" /> size="18px" color="grey-6" />
@ -644,12 +644,12 @@
@click="onItemFieldClick(i, $event)"> @click="onItemFieldClick(i, $event)">
<template v-slot:prepend> <template v-slot:prepend>
<q-icon :name="item.billing === 'recurring' ? 'autorenew' : 'shopping_cart'" size="16px" <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>
<template v-slot:append> <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" /> @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" /> @click.stop="openCatalogForLine(i)" title="Choisir du catalogue" />
<q-btn flat dense round size="xs" icon="edit" color="grey-6" <q-btn flat dense round size="xs" icon="edit" color="grey-6"
@click.stop="enableManualEdit(i)" @click.stop="enableManualEdit(i)"
@ -688,7 +688,7 @@
:input-style="{ fontSize: '0.75rem' }" :input-style="{ fontSize: '0.75rem' }"
title="Prix barré affiché au client. Laisser vide (ou ≤ prix marketing) = aucun prix barré."> title="Prix barré affiché au client. Laisser vide (ou ≤ prix marketing) = aucun prix barré.">
<template v-slot:append> <template v-slot:append>
<q-icon name="local_offer" size="14px" color="orange-7" /> <q-icon name="local_offer" size="14px" color="warning" />
</template> </template>
</q-input> </q-input>
</div> </div>
@ -699,7 +699,7 @@
<span class="text-grey-6">Aperçu client :</span> <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 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> <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' : '' }} {{ ((Number(item.regular_price) - Number(item.rate)) * (item.qty || 1)).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}
</q-chip> </q-chip>
</div> </div>
@ -709,7 +709,7 @@
with it. --> with it. -->
<div v-if="item.project_template_id" class="item-steps-wrap q-mt-xs"> <div v-if="item.project_template_id" class="item-steps-wrap q-mt-xs">
<template v-if="!templateLoadedFor.has(item.project_template_id)"> <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)" /> label="Charger les étapes d'installation" @click="loadTemplateFromItem(item)" />
</template> </template>
<template v-else> <template v-else>
@ -735,16 +735,16 @@
<span class="text-weight-bold">{{ onetimeTotal.toFixed(2) }} $</span> <span class="text-weight-bold">{{ onetimeTotal.toFixed(2) }} $</span>
</div> </div>
<div v-if="stepsExtraTotal > 0" class="row justify-between q-pl-md" style="font-size:0.76rem"> <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-grey-6"><q-icon name="add_circle_outline" size="12px" color="warning" /> dont extras sur étapes</span>
<span class="text-orange-7">{{ stepsExtraTotal.toFixed(2) }} $</span> <span class="text-warning">{{ stepsExtraTotal.toFixed(2) }} $</span>
</div> </div>
<div class="row justify-between" style="font-size:0.82rem"> <div class="row justify-between" style="font-size:0.82rem">
<span class="text-grey-7">Récurrent mensuel</span> <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>
<div v-if="promoTotal > 0" class="row justify-between" style="font-size:0.82rem"> <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-warning"><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-weight-bold text-warning">{{ promoTotal.toFixed(2) }} $</span>
</div> </div>
<q-separator class="q-my-xs" /> <q-separator class="q-my-xs" />
<div class="row justify-between" style="font-size:0.82rem"> <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"> <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 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 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" /> <q-icon name="receipt_long" size="16px" class="q-mr-xs" />
{{ orderMode === 'quotation' ? 'Devis' : orderMode === 'prepaid' ? 'Facture' : 'Bon de commande' }} {{ orderMode === 'quotation' ? 'Devis' : orderMode === 'prepaid' ? 'Facture' : 'Bon de commande' }}
&mdash; {{ orderItems.length }} items &mdash; {{ orderItems.length }} items
</div> </div>
<div v-for="(item, i) in orderItems" :key="i" style="font-size:0.78rem;color:#475569;display:flex;gap:6px;align-items:center"> <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" <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 class="col">{{ item.qty }}x {{ item.item_name }}</span>
<span v-if="item.regular_price > item.rate" <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' : '' }} {{ (item.qty * item.regular_price).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}
</span> </span>
<span class="text-weight-bold">{{ (item.qty * item.rate).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>Total</span>
<span>{{ onetimeTotal.toFixed(2) }}$ + {{ recurringTotal.toFixed(2) }}$/mois</span> <span>{{ onetimeTotal.toFixed(2) }}$ + {{ recurringTotal.toFixed(2) }}$/mois</span>
</div> </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><q-icon name="celebration" size="12px" />&nbsp;Valeur promotions étalées sur {{ maxContractMonths }} mois</span>
<span class="text-weight-bold">{{ promoTotal.toFixed(2) }}$</span> <span class="text-weight-bold">{{ promoTotal.toFixed(2) }}$</span>
</div> </div>
</div> </div>
<div v-if="willCreateContract" class="ops-card q-mb-md" style="padding:10px 12px;border-color:#fed7aa;background:#fff7ed"> <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" /> <q-icon name="handshake" size="16px" class="q-mr-xs" />
Contrat de service &mdash; {{ acceptanceMethod === 'docuseal' ? 'Commercial' : 'Résidentiel' }} Contrat de service &mdash; {{ acceptanceMethod === 'docuseal' ? 'Commercial' : 'Résidentiel' }}
</div> </div>
@ -792,7 +792,7 @@
{{ recurringTotal.toFixed(2) }}$/mois &middot; {{ maxContractMonths }} mois {{ recurringTotal.toFixed(2) }}$/mois &middot; {{ maxContractMonths }} mois
<template v-if="promoTotal > 0"> &middot; {{ promoTotal.toFixed(2) }}$ en promotions étalées</template> <template v-if="promoTotal > 0"> &middot; {{ promoTotal.toFixed(2) }}$ en promotions étalées</template>
</div> </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. 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>
</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.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 v-if="step.scheduled_date"><q-icon name="event" size="12px" /> {{ step.scheduled_date }}</span>
<span>{{ step.duration_h }}h</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 <q-icon name="webhook" size="12px" /> n8n
</span> </span>
</div> </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;"> <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"> <div style="text-align:center">
<q-icon :name="pendingAcceptance ? 'hourglass_top' : 'check_circle'" :color="pendingAcceptance ? 'orange-6' : 'green-6'" size="56px" /> <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' }} {{ pendingAcceptance ? 'En attente d\'acceptation client' : 'Projet créé avec succès' }}
</div> </div>
<div class="text-caption text-grey-6 q-mt-xs">{{ publishedDocType }} <b>{{ publishedDocName }}</b></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> <q-icon name="handshake" size="14px" class="q-mr-xs" />Contrat <b>{{ publishedContractName }}</b>
</div> </div>
<div v-if="!pendingAcceptance && publishedJobCount" class="text-caption text-grey-6">{{ publishedJobCount }} tâches créées</div> <div v-if="!pendingAcceptance && publishedJobCount" class="text-caption text-grey-6">{{ publishedJobCount }} tâches créées</div>
</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 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" /> <q-icon name="info" size="14px" class="q-mr-xs" />
Les tâches seront créées automatiquement lorsque le client acceptera le devis. Les tâches seront créées automatiquement lorsque le client acceptera le devis.
</div> </div>
@ -868,7 +868,7 @@
</div> </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 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 <q-icon name="send" size="16px" class="q-mr-xs" /> Envoyer au client
</div> </div>
<div class="row q-col-gutter-sm items-end q-mb-xs"> <div class="row q-col-gutter-sm items-end q-mb-xs">
@ -877,27 +877,27 @@
:placeholder="sendChannel === 'email' ? 'client@exemple.com' : '+15145551234'" :placeholder="sendChannel === 'email' ? 'client@exemple.com' : '+15145551234'"
:label="sendChannel === 'email' ? 'Courriel' : 'Téléphone'" :input-style="{ fontSize: '0.82rem' }"> :label="sendChannel === 'email' ? 'Courriel' : 'Téléphone'" :input-style="{ fontSize: '0.82rem' }">
<template v-slot:prepend> <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> </template>
</q-input> </q-input>
</div> </div>
<div class="col-auto"> <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" size="sm" no-caps style="margin-bottom:1px"
:options="[{ label: 'Courriel', value: 'email' }, { label: 'SMS', value: 'sms' }]" /> :options="[{ label: 'Courriel', value: 'email' }, { label: 'SMS', value: 'sms' }]" />
</div> </div>
</div> </div>
<q-btn unelevated :label="sending ? 'Envoi...' : sendChannel === 'email' ? 'Envoyer par courriel' : 'Envoyer par SMS'" <q-btn unelevated :label="sending ? 'Envoi...' : sendChannel === 'email' ? 'Envoyer par courriel' : 'Envoyer par SMS'"
:icon="sendChannel === 'email' ? 'email' : '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" /> :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 }} {{ sendResult.message }}
</div> </div>
</div> </div>
<div class="row justify-center q-gutter-sm q-mt-sm"> <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 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 <q-btn v-if="acceptanceLinkUrl" outline icon="link" label="Lien acceptation" color="purple-6" dense no-caps
@click="copyAcceptanceLink" /> @click="copyAcceptanceLink" />
@ -910,7 +910,7 @@
<template v-else> <template v-else>
<q-btn flat label="Annuler" color="grey-7" @click="cancel" /> <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 > 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" /> :disable="!canProceed" @click="goNext" />
<q-btn v-if="currentStep === 4" unelevated :label="publishLabel" color="green-7" icon="rocket_launch" <q-btn v-if="currentStep === 4" unelevated :label="publishLabel" color="green-7" icon="rocket_launch"
:loading="publishing" @click="publish" /> :loading="publishing" @click="publish" />
@ -922,7 +922,7 @@
<q-card class="catalog-modal column no-wrap"> <q-card class="catalog-modal column no-wrap">
<div class="catalog-modal-header"> <div class="catalog-modal-header">
<div class="row items-center no-wrap q-mb-sm"> <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="col">
<div class="text-weight-bold" style="font-size:1rem">Catalogue</div> <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> <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"> <q-scroll-area class="catalog-modal-body col">
<div v-if="catalogLoading" class="catalog-loading"> <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 class="text-caption text-grey-6 q-mt-sm">Chargement du catalogue...</div>
</div> </div>
<div v-else-if="!displayedCatalog.length" class="catalog-empty"> <div v-else-if="!displayedCatalog.length" class="catalog-empty">
@ -974,7 +974,7 @@
{{ p.billing_type === 'Mensuel' ? '/ mois' : p.billing_type === 'Annuel' ? '/ an' : 'unique' }} {{ p.billing_type === 'Mensuel' ? '/ mois' : p.billing_type === 'Annuel' ? '/ an' : 'unique' }}
</div> </div>
</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>
</div> </div>
</q-scroll-area> </q-scroll-area>
@ -987,7 +987,7 @@
&middot; <span class="text-green-7 text-weight-bold">+{{ catalogRecentlyAdded.size }}</span> ajouté{{ catalogRecentlyAdded.size > 1 ? 's' : '' }} &middot; <span class="text-green-7 text-weight-bold">+{{ catalogRecentlyAdded.size }}</span> ajouté{{ catalogRecentlyAdded.size > 1 ? 's' : '' }}
</span> </span>
</div> </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" /> @click="catalogOpen = false" />
</div> </div>
</q-card> </q-card>
@ -996,7 +996,7 @@
<q-dialog v-model="addStepDialog.open"> <q-dialog v-model="addStepDialog.open">
<q-card style="min-width: 340px; max-width: 90vw"> <q-card style="min-width: 340px; max-width: 90vw">
<q-card-section class="row items-center q-pb-none"> <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> <div class="text-weight-bold">Ajouter une étape</div>
<q-space /> <q-space />
<q-btn flat round dense icon="close" @click="addStepDialog.open = false" /> <q-btn flat round dense icon="close" @click="addStepDialog.open = false" />
@ -1019,7 +1019,7 @@
</q-card-section> </q-card-section>
<q-card-actions align="right" class="q-pa-md"> <q-card-actions align="right" class="q-pa-md">
<q-btn flat no-caps label="Annuler" @click="addStepDialog.open = false" /> <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())" :disable="!(addStepDialog.subject && addStepDialog.subject.trim())"
@click="confirmAddStep()" /> @click="confirmAddStep()" />
</q-card-actions> </q-card-actions>
@ -1076,7 +1076,7 @@
class="channel-chip" class="channel-chip"
@remove="toggleChannel(ch)"> @remove="toggleChannel(ch)">
<span class="channel-chip-name">{{ ch.name }}</span> <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> class="channel-chip-pastille" rounded>
2 2
</q-badge> </q-badge>
@ -1096,7 +1096,7 @@
<div class="col" style="min-width:0"> <div class="col" style="min-width:0">
<div class="channel-row-name"> <div class="channel-row-name">
{{ ch.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> class="q-ml-xs" rounded>2 choix</q-badge>
</div> </div>
<div v-if="ch.sub && ch.sub.length" class="channel-row-sub"> <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-dialog v-model="open" @hide="reset">
<q-card style="min-width:540px;max-width:96vw"> <q-card style="min-width:540px;max-width:96vw">
<q-card-section class="row items-center q-pb-none"> <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> <div class="text-subtitle1 text-weight-bold">Statut du service</div>
<q-space /> <q-space />
<q-btn flat round dense icon="close" v-close-popup /> <q-btn flat round dense icon="close" v-close-popup />
@ -14,7 +14,7 @@
label="Adresse, nom, téléphone ou courriel" label="Adresse, nom, téléphone ou courriel"
@update:model-value="onSearch" @keyup.enter.exact.prevent="onSearch(query, true)"> @update:model-value="onSearch" @keyup.enter.exact.prevent="onSearch(query, true)">
<template #prepend><q-icon name="search" /></template> <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> </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> <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> <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-list bordered separator class="rounded-borders">
<q-item v-for="m in candidates" :key="m.name" clickable @click="pickCustomer(m.name, m)"> <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-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>{{ 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> <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"> <div v-if="data" class="q-mt-md">
<!-- En-tête compte --> <!-- En-tête compte -->
<div class="row items-center no-wrap q-mb-sm"> <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="col">
<div class="text-weight-bold ellipsis">{{ data.customer.customer_name }}</div> <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> <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) --> <!-- 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 <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"> 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"> <template #option="s">
<q-item v-bind="s.itemProps"><q-item-section> <q-item v-bind="s.itemProps"><q-item-section>
<q-item-label>{{ s.opt.address }}</q-item-label> <q-item-label>{{ s.opt.address }}</q-item-label>
@ -62,7 +62,7 @@
</q-item-section></q-item> </q-item-section></q-item>
</template> </template>
</q-select> </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 --> <!-- Abonnement(s) service -->
<div v-if="data.subscriptions && data.subscriptions.length" class="q-mb-sm"> <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.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.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.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> </div>
<!-- DoStuff (n8n) : statut LIVE de l'OLT lent (~30 s), à la demande --> <!-- DoStuff (n8n) : statut LIVE de l'OLT lent (~30 s), à la demande -->
<div v-if="d.serial && d.olt" class="q-mt-xs"> <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.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.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.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.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-2" text-color="blue-9" icon="router" @click="openMgmt(d._live.managementIp)">GUI {{ d._live.managementIp }}</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.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> <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> </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>
<div class="text-caption text-grey-6 row items-center q-gutter-x-sm"> <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.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.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 }} <q-icon name="link" size="12px" /> après {{ job.depends_on }}
</span> </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 <q-icon name="webhook" size="12px" /> n8n
</span> </span>
<span v-if="children.length" class="text-grey-5"> <span v-if="children.length" class="text-grey-5">
@ -89,7 +89,7 @@
</div> </div>
<div class="task-tags-row"> <div class="task-tags-row">
<q-chip v-for="tag in jobTags" :key="tag" removable dense size="sm" <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)"> @remove="removeTag(tag)">
{{ tag }} {{ tag }}
</q-chip> </q-chip>
@ -102,12 +102,12 @@
<!-- Action buttons --> <!-- Action buttons -->
<div class="row items-center q-gutter-x-sm"> <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"> @click="goToDispatch">
<q-tooltip>Ouvrir dans le tableau dispatch</q-tooltip> <q-tooltip>Ouvrir dans le tableau dispatch</q-tooltip>
</q-btn> </q-btn>
<q-space /> <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"> @click="confirmDelete">
<q-tooltip>Supprimer cette tâche</q-tooltip> <q-tooltip>Supprimer cette tâche</q-tooltip>
</q-btn> </q-btn>

View File

@ -80,7 +80,7 @@
<template v-slot:option="{ opt, itemProps }"> <template v-slot:option="{ opt, itemProps }">
<q-item v-bind="itemProps"> <q-item v-bind="itemProps">
<q-item-section side style="min-width:48px"> <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-section> <q-item-section>
<q-item-label class="text-caption">{{ opt.label }}</q-item-label> <q-item-label class="text-caption">{{ opt.label }}</q-item-label>
@ -138,7 +138,7 @@
<!-- Actions --> <!-- Actions -->
<q-card-actions align="right" class="q-px-md q-pb-md"> <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 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" /> :loading="submitting" :disable="!form.subject?.trim()" @click="onSubmit" />
</q-card-actions> </q-card-actions>
</q-card> </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-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-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-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-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>
<div class="topo-badges"> <div class="topo-badges">
<q-badge :color="statusBadgeColor">{{ status.label }}</q-badge> <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> <span class="text-weight-bold">{{ wifiDiag.wiredEquipment.length }} repeteur(s) mesh detecte(s)</span>
</div> </div>
<div v-for="eq in wifiDiag.wiredEquipment" :key="eq.mac" class="q-ml-md q-mt-xs text-caption"> <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> <span class="text-weight-medium">{{ eq.hostname || eq.model }}</span>
&mdash; {{ eq.ip }} ({{ eq.mac }}) &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;"> <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><code class="text-caption">{{ l.mac }}</code></td>
<td class="text-caption">{{ l.expiry != null ? formatLease(l.expiry) : '—' }}</td> <td class="text-caption">{{ l.expiry != null ? formatLease(l.expiry) : '—' }}</td>
<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-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> <q-badge v-else color="green-2" text-color="green-9" style="font-size:0.6rem;">WiFi</q-badge>
</td> </td>
@ -339,7 +339,7 @@
<q-badge :color="node.active ? 'green' : 'red'" style="padding: 2px 5px;" /> <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-weight-bold q-ml-xs">{{ node.hostname }}</span>
<span class="text-caption text-grey-6 q-ml-xs">{{ node.model }}</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>
<div class="adv-mesh-stats"> <div class="adv-mesh-stats">
<span v-if="!node.isController" class="adv-mesh-stat"> <span v-if="!node.isController" class="adv-mesh-stat">

View File

@ -1,7 +1,7 @@
<template> <template>
<!-- View toggle --> <!-- View toggle -->
<q-tabs v-model="viewMode" dense align="left" inline-label no-caps <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)"> style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<q-tab name="fields" icon="list_alt" label="Détails" /> <q-tab name="fields" icon="list_alt" label="Détails" />
<q-tab name="client" icon="receipt_long" label="Aperçu client" /> <q-tab name="client" icon="receipt_long" label="Aperçu client" />
@ -10,9 +10,9 @@
<!-- CLIENT PREVIEW --> <!-- CLIENT PREVIEW -->
<div v-if="viewMode === 'client'" class="q-mb-md"> <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"> <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" /> 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" /> size="sm" :loading="generatingPayLink" @click="copyPayLink" />
</div> </div>
<iframe :src="clientPreviewUrl" class="invoice-client-preview" /> <iframe :src="clientPreviewUrl" class="invoice-client-preview" />
@ -28,32 +28,32 @@
<q-tooltip>Valider et soumettre cette facture</q-tooltip> <q-tooltip>Valider et soumettre cette facture</q-tooltip>
</q-btn> </q-btn>
<!-- Draft: Delete --> <!-- 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"> size="sm" :loading="actionLoading" @click="deleteInvoice">
<q-tooltip>Supprimer ce brouillon</q-tooltip> <q-tooltip>Supprimer ce brouillon</q-tooltip>
</q-btn> </q-btn>
<!-- Submitted: Cancel --> <!-- 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"> size="sm" :loading="actionLoading" @click="cancelInvoice">
<q-tooltip>Annuler cette facture</q-tooltip> <q-tooltip>Annuler cette facture</q-tooltip>
</q-btn> </q-btn>
<!-- Submitted: Credit Note --> <!-- 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"> size="sm" :loading="actionLoading" @click="createCreditNote">
<q-tooltip>Creer une note de credit (renversement)</q-tooltip> <q-tooltip>Creer une note de credit (renversement)</q-tooltip>
</q-btn> </q-btn>
<!-- Submitted unpaid: Send payment link --> <!-- 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"> size="sm" :loading="sendingLink" @click="sendPaymentLink">
<q-tooltip>Envoyer un lien de paiement par SMS et courriel</q-tooltip> <q-tooltip>Envoyer un lien de paiement par SMS et courriel</q-tooltip>
</q-btn> </q-btn>
<!-- Submitted unpaid: Charge saved card --> <!-- 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"> size="sm" :loading="chargingCard" @click="chargeSavedCard">
<q-tooltip>Debiter la carte enregistree du client</q-tooltip> <q-tooltip>Debiter la carte enregistree du client</q-tooltip>
</q-btn> </q-btn>
<!-- PDF --> <!-- 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)" /> size="sm" @click="$emit('open-pdf', docName)" />
</div> </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">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"><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.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>
<div class="q-mt-md"> <div class="q-mt-md">
<div class="info-block-title">Remarques</div> <div class="info-block-title">Remarques</div>
@ -76,7 +76,7 @@
</div> </div>
<div v-if="doc.items?.length" class="q-mt-md"> <div v-if="doc.items?.length" class="q-mt-md">
<div class="info-block-title">Articles ({{ doc.items.length }})</div> <div class="info-block-title">Articles ({{ doc.items.length }})</div>
<q-table <DataTable
:rows="doc.items" :columns="invItemCols" row-key="idx" :rows="doc.items" :columns="invItemCols" row-key="idx"
flat dense class="ops-table" hide-pagination :pagination="{ rowsPerPage: 0 }" flat dense class="ops-table" hide-pagination :pagination="{ rowsPerPage: 0 }"
> >
@ -86,7 +86,7 @@
<template #body-cell-rate="p"> <template #body-cell-rate="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.rate) }}</q-td> <q-td :props="p" class="text-right">{{ formatMoney(p.row.rate) }}</q-td>
</template> </template>
</q-table> </DataTable>
</div> </div>
<div v-if="doc.taxes?.length" class="q-mt-md"> <div v-if="doc.taxes?.length" class="q-mt-md">
<div class="info-block-title">Taxes</div> <div class="info-block-title">Taxes</div>
@ -130,6 +130,7 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import DataTable from 'src/components/shared/DataTable.vue'
import { Notify, useQuasar } from 'quasar' import { Notify, useQuasar } from 'quasar'
import { formatMoney, formatDateShort } from 'src/composables/useFormatters' import { formatMoney, formatDateShort } from 'src/composables/useFormatters'
import { invStatusClass } from 'src/composables/useStatusClasses' import { invStatusClass } from 'src/composables/useStatusClasses'
@ -336,7 +337,7 @@ async function sendPaymentLink () {
title: 'Envoyer un lien de paiement', 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)}).`, 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' }, cancel: { flat: true, label: 'Annuler' },
ok: { color: 'indigo-6', label: 'Envoyer', flat: true }, ok: { color: 'primary', label: 'Envoyer', flat: true },
persistent: true, persistent: true,
}).onOk(async () => { }).onOk(async () => {
sendingLink.value = true sendingLink.value = true

View File

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

View File

@ -18,7 +18,7 @@
icon="cancel" label="Résilier le contrat" icon="cancel" label="Résilier le contrat"
:loading="isTerminating" @click="openTerminateDialog" /> :loading="isTerminating" @click="openTerminateDialog" />
<q-btn v-else-if="isTerminated && doc.termination_invoice" flat dense no-caps <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)" /> @click="$emit('navigate', 'Sales Invoice', doc.termination_invoice, 'Facture ' + doc.termination_invoice)" />
</div> </div>
@ -71,7 +71,7 @@
</div> </div>
<div class="mf"> <div class="mf">
<span class="mf-label">Résiduel</span> <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) }} {{ formatMoney(doc.total_remaining_value) }}
</span> </span>
</div> </div>
@ -117,13 +117,13 @@
</span> </span>
<span v-else></span> <span v-else></span>
</td> </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>
<tr class="totals-row"> <tr class="totals-row">
<td class="text-right text-weight-bold" colspan="3">Totaux</td> <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 class="text-right text-green-7 text-weight-bold">{{ formatMoney(totalBenefits) }}</td>
<td></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> </tr>
</tbody> </tbody>
</q-markup-table> </q-markup-table>
@ -137,14 +137,14 @@
Shown only when already résilié: date, reason, fee breakdown, Shown only when already résilié: date, reason, fee breakdown,
link to generated invoice. --> link to generated invoice. -->
<div v-if="isTerminated && (doc.terminated_at || doc.termination_fee_total)" class="q-mt-md term-box"> <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 <q-icon name="cancel" size="14px" class="q-mr-xs" /> Résiliation
</div> </div>
<div class="modal-field-grid"> <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">Date</span>{{ formatDate(doc.terminated_at) || '—' }}</div>
<div class="mf"> <div class="mf">
<span class="mf-label">Pénalité</span> <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>
<div v-if="doc.termination_fee_benefits > 0" class="mf"> <div v-if="doc.termination_fee_benefits > 0" class="mf">
<span class="mf-label">Avantages</span>{{ formatMoney(doc.termination_fee_benefits) }} <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 serviceContracts = ref([])
const comments = ref([]) const comments = ref([])
const accountBalance = ref(null) 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 loadingMoreTickets = ref(false)
const loadingMoreInvoices = ref(false) const loadingMoreInvoices = ref(false)
@ -47,6 +49,8 @@ export function useClientData (deps) {
ticketsExpanded.value = false ticketsExpanded.value = false
invoicesExpanded.value = false invoicesExpanded.value = false
paymentsExpanded.value = false paymentsExpanded.value = false
refundsByPi.value = {}
stripeCharges.value = []
} }
function loadLocations (custFilter) { function loadLocations (custFilter) {
@ -166,6 +170,21 @@ export function useClientData (deps) {
}).then(r => r.json()).then(d => d.methods || []).catch(() => []) }).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) { function loadArrangements (custFilter) {
return listDocs('Payment Arrangement', { return listDocs('Payment Arrangement', {
filters: custFilter, 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 || '')) tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || ''))
invoices.value = invs invoices.value = invs
payments.value = pays 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 voipLines.value = voip
paymentMethods.value = pmethods paymentMethods.value = pmethods
arrangements.value = arrgs arrangements.value = arrgs
@ -311,7 +332,7 @@ export function useClientData (deps) {
return { return {
loading, customer, contact, locations, subscriptions, tickets, loading, customer, contact, locations, subscriptions, tickets,
invoices, payments, voipLines, paymentMethods, arrangements, quotations, invoices, payments, voipLines, paymentMethods, arrangements, quotations,
serviceContracts, comments, accountBalance, serviceContracts, comments, accountBalance, refundsByPi, stripeCharges,
loadingMoreTickets, loadingMoreInvoices, loadingMorePayments, loadingMoreTickets, loadingMoreInvoices, loadingMorePayments,
loadCustomer, loadAllTickets, loadAllInvoices, loadAllPayments, 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 { useAuthStore } from 'src/stores/auth'
import { HUB_URL } from 'src/config/hub' import { HUB_URL } from 'src/config/hub'
import { setAgentRoster } from 'src/composables/useFormatters'
function agentHeaders (extra = {}) { function agentHeaders (extra = {}) {
try { try {
@ -124,8 +125,22 @@ export function useConversations () {
// charge le DÉTAIL (notes, file, assigné, gmailReplyUrl, suggestion) pour la barre de coordination // 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(() => {}) 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)) 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) { async function openConversation (token) {
activeToken.value = 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 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) { // Recherche FLOUE omnichannel (SQL pg_trgm côté hub) : nom client / courriel / sujet + corps des messages, typo-tolérante.
const res = await fetch(`${HUB_URL}/conversations/${token}/messages`, { method: 'POST', headers: agentHeaders(), body: JSON.stringify({ text, media, html, attachments, sendAs }) }) 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') if (!res.ok) throw new Error('Failed to send')
return res.json() return res.json()
} }
@ -352,7 +399,7 @@ export function useConversations () {
await fetchList(); return d await fetchList(); return d
} }
let _agents = null 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. // 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 } 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. // 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 {} 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). // 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 if (!token) return
const empty = !String(html || '').replace(/<[^>]+>/g, '').trim() && !String(text || '').trim() const empty = !String(html || '').replace(/<[^>]+>/g, '').trim() && !String(text || '').trim()
const now = Date.now() const now = Date.now()
if (!empty && now - _lastDraftSent < 700) return if (!empty && now - _lastDraftSent < 700) return
_lastDraftSent = now _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) const activeCount = computed(() => discussions.value.filter(d => d.status === 'active').length)
@ -512,7 +559,7 @@ export function useConversations () {
return { return {
conversations, discussions, activeToken, activeDiscussion, activeConv, tickets, conversations, discussions, activeToken, activeDiscussion, activeConv, tickets,
loading, panelOpen, newDialogOpen, newDialogChannel, composePrefill, openComposeDraft, createStandaloneTicket, showAll, selectedIds, selectedDiscussions, 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, closeConversation, deleteDiscussion, bulkDelete, archiveDiscussion, bulkArchive, createTicket,
QUEUES, assignQueue, claimConv, releaseConv, assignConv, setConvState, fetchAgents, spamConv, deleteConvByToken, addNote, editNote, deleteNote, fetchAttachments, fetchForCustomer, nlCommand, pipelineBoard, setPipeline, QUEUES, assignQueue, claimConv, releaseConv, assignConv, setConvState, fetchAgents, spamConv, deleteConvByToken, addNote, editNote, deleteNote, fetchAttachments, fetchForCustomer, nlCommand, pipelineBoard, setPipeline,
splitPreview, splitConversation, mergeConversation, fetchCanned, saveCanned, analyzePayment, summarize, serviceability, fetchInboxRules, saveInboxRules, splitPreview, splitConversation, mergeConversation, fetchCanned, saveCanned, analyzePayment, summarize, serviceability, fetchInboxRules, saveInboxRules,

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) { export function formatDate (d) {
if (!d) return '—' 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 * @param {string|null} email
* @returns {string} * @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) { export function noteAuthorName (email) {
if (!email) return 'Système' if (!email) return 'Système'
const real = AGENT_ROSTER[String(email).toLowerCase()]
if (real) return real
const local = email.split('@')[0] const local = email.split('@')[0]
return local.charAt(0).toUpperCase() + local.slice(1).replace(/[._-]/g, ' ') 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 = [ export const FEEDS = [
{ key: 'sms', label: 'Textos (SMS)', icon: 'sms', color: 'teal-7' }, { 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: '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: 'facebook', label: 'Messenger', icon: 'facebook', color: 'blue-9' },
{ key: 'email', label: 'Courriels', icon: 'mail', color: 'deep-purple-6' }, { key: 'email', label: 'Courriels', icon: 'mail', color: 'deep-purple-6' },
{ key: 'ratings', label: 'Évaluations', icon: 'star', color: 'amber-7' }, { 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). // 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, facebook: true, email: false, ratings: true } 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 notifications = ref([]) // { id, type, feed, title, caption, ts, customer, conv, icon, stars, low }
const unread = ref(0) const unread = ref(0)
const prefs = ref({ ...DEFAULTS }) const prefs = ref({ ...DEFAULTS })
const myQueues = ref({ queues: [], mine: [], notify: {}, labels: {} }) // abonnement PAR AGENT aux files + notif par file
let es = null let es = null
let _seq = 0 let _seq = 0
@ -41,6 +43,7 @@ function nav (path) { window.location.hash = '#' + path }
function go (n) { function go (n) {
markAllRead() markAllRead()
if (n && (n.type === 'rating' || n.type === 'comment')) return nav('/evaluations') 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)) if (n && n.conv) return nav('/communications/c/' + encodeURIComponent(n.conv))
nav('/communications') nav('/communications')
} }
@ -51,6 +54,16 @@ async function loadPrefs () {
async function savePrefs () { async function savePrefs () {
try { await fetch(`${HUB_URL}/conversations/notif-prefs`, { method: 'PUT', headers: headers(), body: JSON.stringify({ prefs: prefs.value }) }) } catch { /* */ } 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) { function onRating (d) {
if (!prefs.value.ratings) return if (!prefs.value.ratings) return
@ -62,9 +75,9 @@ function onComment (d) {
if (!prefs.value.ratings) return if (!prefs.value.ratings) return
const name = d.name || d.customer || 'Client' 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) }) 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) { function onConvMessage (d) {
if (!d || !d.message || d.message.from !== 'customer') return // UNIQUEMENT les messages entrants client (pas nos réponses) 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) 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' }) 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 }) }] }) 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 () { function initNotifications () {
loadPrefs() loadPrefs(); loadMyQueues()
if (es) return if (es) return
try { try {
es = new EventSource(`${HUB_URL}/sse?topics=outbox,conversations`) 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('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-message', e => { try { onConvMessage(JSON.parse(e.data)) } catch {} })
es.addEventListener('conv-email', e => { try { onConvEmail(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 */ } } catch (e) { /* SSE indisponible */ }
} }
function markAllRead () { unread.value = 0 } 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' import { HUB_URL } from 'src/config/hub'
// Singleton state — shared across all components // 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 loading = ref(false)
const error = ref(null) const error = ref(null)
@ -32,8 +35,10 @@ async function loadPermissions (email) {
.catch(e => { .catch(e => {
console.error('[usePermissions]', e.message) console.error('[usePermissions]', e.message)
error.value = e.message error.value = e.message
// Fallback: allow everything for superusers if fetch fails // DEV local (pas de hub devant) : le fetch échoue → on débloque l'app avec un profil permissif
permissions.value = null // 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(() => { .finally(() => {
loading.value = false 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) 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 = {}) { export function useUnifiedCreate (mode, opts = {}) {
// ── Form state ──────────────────────────────────────────────────────────── // ── Form state ────────────────────────────────────────────────────────────
const form = reactive({ const form = reactive({
@ -257,7 +266,7 @@ export function useUnifiedCreate (mode, opts = {}) {
Notify.create({ type: 'positive', message: 'Tâche créée' }) Notify.create({ type: 'positive', message: 'Tâche créée' })
} else if (m === 'work-order') { } 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 = { const payload = {
ticket_id: ticketId, ticket_id: ticketId,
subject: form.subject.trim(), 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 // Create Dispatch Jobs (tasks) — ONLY if NOT waiting for acceptance
const createdJobs = [] const createdJobs = []
if (!needsAcceptance) { 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++) { for (let i = 0; i < wizardSteps.value.length; i++) {
const step = wizardSteps.value[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 = '' let dependsOn = ''
if (step.depends_on_step != null) { if (step.depends_on_step != null) {

View File

@ -8,7 +8,7 @@
* Éditer ici pour ajuster les mots-clés une seule source. * Éditer ici pour ajuster les mots-clés une seule source.
*/ */
export const DEPARTMENTS = [ 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 }, 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', { 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 }, 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 // Ops sidebar navigation + search filter options
// `requires` = capability needed to see this nav item (null = always visible) // `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 = [ export const navItems = [
// ── Épinglés (haut, toujours visibles) ──
{ path: '/', icon: 'LayoutDashboard', label: 'Tableau de bord', requires: 'view_dashboard_kpi' }, { 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' }, { path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
// ── Administration (replié) ── // ── Service client ──
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', group: 'admin' }, { path: '/communications', icon: 'MessagesSquare', label: 'Communications', requires: 'view_clients', section: 'service' },
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users', group: 'admin' }, { path: '/clients', icon: 'Users', label: 'Clients', requires: 'view_clients', section: 'service' },
{ path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings', group: 'admin' }, { path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets', section: 'service' },
{ path: '/sync-legacy', icon: 'RefreshCw', label: 'Sync F↔ERPNext', requires: 'view_settings', group: 'admin' }, { path: '/evaluations', icon: 'Star', label: 'Évaluations', requires: 'view_clients', section: 'service' },
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings', group: 'admin' }, { path: '/pipeline', icon: 'Filter', label: 'Pipeline (ventes)', requires: 'view_clients', section: 'service' },
{ path: '/factures-fournisseurs', icon: 'ReceiptText', label: 'Factures fournisseurs', requires: 'view_settings', group: 'admin' }, // ── Terrain (effectifs / interventions) ──
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings', group: 'admin' }, { 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 = [ export const territoryOptions = [

View File

@ -11,22 +11,13 @@ export const invoiceCols = [
{ name: 'status', label: 'Statut', field: 'status', align: 'center' }, { name: 'status', label: 'Statut', field: 'status', align: 'center' },
] ]
function fmtDateTime (row) { // Les paiements affichent posting_date (date de transaction réelle, standard comptable),
if (!row.creation) return row.posting_date || '' // PAS `creation` (horodatage d'import ERPNext) qui faisait apparaître des paiements rétro-importés
// ERPNext creation is UTC — display in Eastern time // à la date du batch (ex. débits avril/mai affichés « 12 juin »).
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}`
}
export const paymentCols = [ export const paymentCols = [
{ name: 'name', label: 'N°', field: 'name', align: 'left' }, { 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: 'paid_amount', label: 'Montant', field: 'paid_amount', align: 'right' },
{ name: 'mode_of_payment', label: 'Mode', field: 'mode_of_payment', align: 'left' }, { name: 'mode_of_payment', label: 'Mode', field: 'mode_of_payment', align: 'left' },
{ name: 'reference_no', label: 'Référence', field: 'reference_no', 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: 'issue_type', label: 'Type', field: 'issue_type', align: 'left' },
{ name: 'opening_date', label: 'Date', field: 'opening_date', align: 'left', sortable: true }, { name: 'opening_date', label: 'Date', field: 'opening_date', align: 'left', sortable: true },
{ name: 'priority', label: 'Priorite', field: 'priority', align: 'center', 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' }, { 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 = {} const filters = {}
if (statusFilter === 'not_closed') { if (statusFilter === 'not_closed') {
filters.status = ['!=', 'Closed'] filters.status = ['!=', 'Closed']
@ -43,13 +44,17 @@ export function buildFilters ({ statusFilter, typeFilter, priorityFilter, search
} }
} }
if (ownerFilter === 'mine' && me) { 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 return filters
} }
export function getSortField (col) { 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' 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 // Targo Ops Global styles
:root { :root {
// Shared dark palette (sidebar + dispatch) // Palette MODERNE (défaut) vert de marque + near-black + off-white + teinte verte + gris.
--ops-sidebar-bg: #111422; // 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-hover: rgba(255,255,255,0.06);
--ops-sidebar-border: 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: rgba(255,255,255,0.55);
--ops-sidebar-text-active: #ffffff; --ops-sidebar-text-active: #ffffff;
// Accent & semantic // Rôles sémantiques (badges, boutons, icônes) miroités par Quasar primary/positive/negative/warning/info
--ops-accent: #6366f1; --ops-accent: #16a34a; // MARQUE actions principales, liens, états actifs
--ops-success: #10b981; --ops-success: #16a34a; // SUCCÈS payé, actif, complété
--ops-warning: #f59e0b; --ops-warning: #f59e0b; // ATTENTION en attente, priorité moyenne, brouillon (ex-amber/orange)
--ops-danger: #ef4444; --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 // Zone de contenu claire
--ops-bg-hover: #eef2ff; --ops-bg-hover: #dcf5d3; // survol de ligne / élément sélectionné (teinte verte)
--ops-bg-light: #f8fafc; --ops-bg-light: #f4f5f2; // alias de fond
--ops-bg: #f8fafc; --ops-bg: #f4f5f2; // fond de page
--ops-surface: #ffffff; --ops-surface: #ffffff; // cartes, panneaux, modales
--ops-border: #e2e8f0; --ops-border: #e5e7e2; // bordures, séparateurs
--ops-text: #1e293b; --ops-text: #0d0d0d; // texte principal
--ops-text-muted: #64748b; --ops-text-muted: #6b7280; // texte secondaire, libellés
// Legacy alias // Alias legacy
--ops-primary: #111422; --ops-primary: #0d0d0d;
} }
body { body {
@ -38,10 +42,16 @@ body {
border-right: 1px solid var(--ops-sidebar-border) !important; border-right: 1px solid var(--ops-sidebar-border) !important;
transition: width 0.2s ease; 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 // Kill Quasar's default white border
&.q-drawer--bordered { border-right-color: var(--ops-sidebar-border) !important; } &.q-drawer--bordered { border-right-color: var(--ops-sidebar-border) !important; }
.q-list { padding-top: 0; } .q-list { padding-top: 0; }
> .q-list { flex: 1 1 auto; overflow-y: auto; min-height: 0; }
.q-item { .q-item {
color: var(--ops-sidebar-text); color: var(--ops-sidebar-text);
@ -71,7 +81,8 @@ body {
// Sidebar bottom section // Sidebar bottom section
.ops-sidebar-bottom { .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; padding: 8px 0;
border-top: 1px solid var(--ops-sidebar-border); border-top: 1px solid var(--ops-sidebar-border);
.q-item { .q-item {
@ -210,14 +221,52 @@ body {
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.chatter-panel { }
display: flex; // Sur tablette/téléphone, la colonne droite « collante » est empilée sous le contenu : on la dé-colle
flex-direction: column; // et on retire le plafond 100vh (sinon elle mange tout un écran une fois empilée). Voir audit ergonomie.
max-height: calc(100vh - 32px); @media (max-width: 1023px) {
overflow: hidden; .convos-sticky {
.chatter-timeline { position: static;
flex: 1; max-height: none;
overflow-y: auto; 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 } 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 = () => ({ export const defaultNewEquip = () => ({
equipment_type: 'ONT', serial_number: '', brand: '', model: '', mac_address: '', ip_address: '', status: 'Actif', 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-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> </q-item>
<template v-for="(nav, i) in navItems" :key="nav.path"> <!-- RÉDUIT (rail d'icônes) : liste à plat, sans sections -->
<q-item-label v-if="!collapsed && nav.group === 'admin' && (i === 0 || navItems[i - 1].group !== 'admin')" header <template v-if="collapsed">
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 v-for="nav in navItems" :key="nav.path" clickable :to="nav.path" class="justify-center"
<q-item clickable :to="nav.path" :class="{ 'active-link': isActive(nav.path) }">
:class="{ 'active-link': isActive(nav.path), 'justify-center': collapsed }"
:title="collapsed ? nav.label : undefined">
<q-item-section avatar><component :is="icons[nav.icon]" :size="20" /></q-item-section> <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-tooltip anchor="center right" self="center left" :offset="[8, 0]">{{ nav.label }}</q-tooltip>
<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-item> </q-item>
</template> </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> </q-list>
<div class="ops-sidebar-bottom"> <div class="ops-sidebar-bottom">
@ -56,7 +76,7 @@
</q-input> </q-input>
<div v-if="searchResults.length" class="ops-search-results"> <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)"> <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 style="flex:1;min-width:0">
<div class="ops-search-title">{{ r.title }}</div> <div class="ops-search-title">{{ r.title }}</div>
<div class="ops-search-sub">{{ r.sub }}</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" <div v-for="(r, i) in searchResults" :key="r.id" class="ops-search-result"
:class="{ 'ops-search-highlighted': i === highlightIdx }" :class="{ 'ops-search-highlighted': i === highlightIdx }"
@mousedown="goToResult(r)"> @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 style="flex:1;min-width:0">
<div class="ops-search-title">{{ r.title }}</div> <div class="ops-search-title">{{ r.title }}</div>
<div class="ops-search-sub">{{ r.sub }}</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) --> <!-- 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/')" /> <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. --> <!-- 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]"> <q-page-sticky v-if="can('view_clients')" position="bottom-right" :offset="[18, 18]">
@ -112,17 +134,17 @@
<q-slide-transition> <q-slide-transition>
<div v-show="quickOpen" class="column items-end q-gutter-xs"> <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="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="info" 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="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="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> </div>
</q-slide-transition> </q-slide-transition>
<div class="row items-center q-gutter-xs"> <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-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-tooltip>Créer rapidement (ticket · texto · courriel · dicter)</q-tooltip>
</q-btn> </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-badge v-if="convCount > 0" color="red" floating rounded :label="convCount" />
<q-tooltip>Voir les communications</q-tooltip> <q-tooltip>Voir les communications</q-tooltip>
</q-btn> </q-btn>
@ -146,18 +168,21 @@
</template> </template>
<script setup> <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 { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'src/stores/auth' import { useAuthStore } from 'src/stores/auth'
import { usePermissions } from 'src/composables/usePermissions' import { usePermissions } from 'src/composables/usePermissions'
import { listDocs } from 'src/api/erp' import { HUB_URL } from 'src/config/hub'
import { navItems as allNavItems } from 'src/config/nav' import { navItems as allNavItems, navSections } from 'src/config/nav'
import { import {
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, 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' } from 'lucide-vue-next'
import ConversationPanel from 'src/components/shared/ConversationPanel.vue' 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 NotificationBell from 'src/components/shared/NotificationBell.vue'
import { useConversations } from 'src/composables/useConversations' import { useConversations } from 'src/composables/useConversations'
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue' import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
@ -166,9 +191,10 @@ import OrchestratorDialog from 'src/components/shared/OrchestratorDialog.vue'
import ServiceStatusDialog from 'src/components/shared/ServiceStatusDialog.vue' import ServiceStatusDialog from 'src/components/shared/ServiceStatusDialog.vue'
import OutboxPanel from 'src/components/shared/OutboxPanel.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 } function toggleConvPanel () { panelOpen.value = !panelOpen.value }
// FAB messagerie : clic « forum » = ouvrir les communications ; bouton « + » (clic, pas survol) = menu créer rapide. // FAB messagerie : clic « forum » = ouvrir les communications ; bouton « + » (clic, pas survol) = menu créer rapide.
const quickOpen = ref(false) const quickOpen = ref(false)
@ -186,7 +212,25 @@ const navItems = computed(() =>
) )
const route = useRoute() const route = useRoute()
const router = useRouter() 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 collapsed = ref(localStorage.getItem('ops-sidebar-collapsed') !== 'false')
const sidebarW = computed(() => collapsed.value ? 64 : 220) const sidebarW = computed(() => collapsed.value ? 64 : 220)
@ -231,29 +275,21 @@ function onSearchInput (val) {
async function runSearch (q) { async function runSearch (q) {
if (!q || q.length < 2) { searchLoading.value = false; return } if (!q || q.length < 2) { searchLoading.value = false; return }
try { try {
const cf = ['name', 'customer_name', 'customer_type', 'territory', 'disabled'] // Recherche FLOUE (pg_trgm, typo-tolérante) + multi-champs (nom / courriel / téléphone / adresse) via le hub.
const lf = ['name', 'address_line', 'city', 'customer', 'customer_name', 'status'] // Remplace les 4 requêtes listDocs sous-chaîne cohérent avec les autres sélecteurs de contact.
const timeout = new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 4000)) // team=1 membres de l'équipe (System Users targo) EN PREMIER : « michel » sort Michel Blais sans préciser. Clic collègue = nouveau courriel.
const [cName, cId, lAddr, lCity] = await Promise.race([ const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}&team=1`)
Promise.all([ const matches = r.ok ? ((await r.json()).matches || []) : []
listDocs('Customer', { filters: { customer_name: ['like', '%' + q + '%'] }, fields: cf, limit: 6, orderBy: 'customer_name asc' }).catch(() => []), searchResults.value = matches.slice(0, 12).map(m => m.kind === 'équipe' ? ({
listDocs('Customer', { filters: { name: ['like', '%' + q + '%'] }, fields: cf, limit: 4, orderBy: 'name asc' }).catch(() => []), id: 't-' + m.name, type: 'team', typeLabel: 'Équipe', icon: 'groups',
listDocs('Service Location', { filters: { address_line: ['like', '%' + q + '%'] }, fields: lf, limit: 6, orderBy: 'address_line asc' }).catch(() => []), title: m.customer_name || m.name, sub: 'Membre de léquipe · ' + m.email, email: m.email, route: null,
listDocs('Service Location', { filters: { city: ['like', '%' + q + '%'] }, fields: lf, limit: 4, orderBy: 'city asc' }).catch(() => []), }) : ({
]), id: 'c-' + m.name, type: 'customer', typeLabel: 'Client',
timeout, icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person',
]) title: m.customer_name || m.name,
const seen = new Set() sub: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name,
const out = [] route: '/clients/' + m.name,
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)
} catch { } catch {
searchResults.value = [] 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) router.push(r.route)
clearSearch() clearSearch()
} }
@ -310,3 +353,14 @@ function onSearchBlur () {
setTimeout(() => { searchDropdownOpen.value = false }, 200) setTimeout(() => { searchDropdownOpen.value = false }, 200)
} }
</script> </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-tooltip>Télécharger le rapport (shortlinks Giftbit, emails, statuts d'envoi)</q-tooltip>
</q-btn> </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()"> 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-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>
<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" label="Créer une relance" class="q-mr-sm"
:loading="creatingReminder" @click="confirmCreateReminder"> :loading="creatingReminder" @click="confirmCreateReminder">
<q-tooltip>Cloner cette campagne pour les destinataires qui n'ont PAS cliqué le cadeau encore</q-tooltip> <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 the operator can hop between them quickly without leaving the
single-glance click view. --> single-glance click view. -->
<q-banner v-if="campaign?.reminder_of" <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> <template v-slot:avatar><q-icon name="reply" /></template>
Cette campagne est une <strong>relance</strong> de Cette campagne est une <strong>relance</strong> de
<a :href="`/ops/#/campaigns/${campaign.reminder_of}`" class="text-primary"> <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" :value="sentRatio" :color="campaign.counters?.failed ? 'orange' : 'positive'" size="8px" class="q-mb-md"
/> />
<q-table <DataTable
v-if="campaign" v-if="campaign"
:rows="campaign.recipients || []" :rows="campaign.recipients || []"
:columns="columns" row-key="email" :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> <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-tooltip>
</q-icon> </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-tooltip>Ce clic a été remonté depuis la campagne de relance, pas le 1er courriel</q-tooltip>
</q-icon> </q-icon>
</q-td> </q-td>
@ -178,7 +178,7 @@
</div> </div>
</q-td> </q-td>
</template> </template>
</q-table> </DataTable>
<div v-if="!campaign && !loading" class="text-center q-pa-xl text-grey-7"> <div v-if="!campaign && !loading" class="text-center q-pa-xl text-grey-7">
<q-icon name="error_outline" size="48px" /> <q-icon name="error_outline" size="48px" />
@ -288,8 +288,8 @@
<q-item-label caption>Destinataires</q-item-label> <q-item-label caption>Destinataires</q-item-label>
<q-item-label> <q-item-label>
<strong>{{ campaign?.recipients?.length || 0 }}</strong> <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="info" 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="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-badge color="grey-6" class="q-ml-xs" v-if="langBreakdown.other">{{ langBreakdown.other }} autre</q-badge>
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
@ -333,7 +333,7 @@
<q-dialog v-model="previewOpen"> <q-dialog v-model="previewOpen">
<q-card style="width:680px;max-width:95vw;height:82vh;display:flex;flex-direction:column"> <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-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> <div class="text-h6">Aperçu de l'envoi</div>
<q-space /> <q-space />
<q-btn-toggle :model-value="previewLang" @update:model-value="openPreview" dense no-caps unelevated size="sm" <q-btn-toggle :model-value="previewLang" @update:model-value="openPreview" dense no-caps unelevated size="sm"
@ -354,6 +354,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import DataTable from 'src/components/shared/DataTable.vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { getCampaign, sendCampaign, cloneCampaign, campaignSseUrl, campaignReportCsvUrl, retryRecipient, createReminderCampaign, updateCampaign, listTemplates, previewTemplate } from 'src/api/campaigns' 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>
<q-card flat bordered class="col-6 col-md" v-if="namesNeedingReview"> <q-card flat bordered class="col-6 col-md" v-if="namesNeedingReview">
<q-card-section class="text-center q-pa-sm"> <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> <div class="text-caption text-grey-7">Noms à vérifier</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<!-- Auto-clean summary informational, not blocking --> <!-- 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> <template v-slot:avatar><q-icon name="auto_fix_high" /></template>
<span v-if="namesAutoCorrected"> <span v-if="namesAutoCorrected">
<strong>{{ namesAutoCorrected }} nom(s) auto-corrigés</strong> (Title Case, accents québécois, <strong>{{ namesAutoCorrected }} nom(s) auto-corrigés</strong> (Title Case, accents québécois,
@ -236,7 +236,7 @@
</q-banner> </q-banner>
<!-- Imbalance banner: explicit explanation of what the imbalance means --> <!-- 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> <template v-slot:avatar><q-icon name="warning" /></template>
<div v-if="unpairedContacts.length"> <div v-if="unpairedContacts.length">
<strong>{{ unpairedContacts.length }} contact(s) sans lien-cadeau</strong> <strong>{{ unpairedContacts.length }} contact(s) sans lien-cadeau</strong>
@ -302,7 +302,7 @@
(Title Case, accent restoration, compound split) --> (Title Case, accent restoration, compound split) -->
<template v-slot:body-cell-firstname="props"> <template v-slot:body-cell-firstname="props">
<q-td :props="props" style="cursor:pointer"> <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-tooltip>{{ props.row.name_warnings.firstname }}</q-tooltip>
</q-icon> </q-icon>
<q-icon v-else-if="props.row.cleaned_changed && props.row.firstname !== props.row.firstname_raw" <q-icon v-else-if="props.row.cleaned_changed && props.row.firstname !== props.row.firstname_raw"
@ -318,7 +318,7 @@
</template> </template>
<template v-slot:body-cell-lastname="props"> <template v-slot:body-cell-lastname="props">
<q-td :props="props" style="cursor:pointer"> <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-tooltip>{{ props.row.name_warnings.lastname }}</q-tooltip>
</q-icon> </q-icon>
<q-icon v-else-if="props.row.cleaned_changed && props.row.lastname !== props.row.lastname_raw" <q-icon v-else-if="props.row.cleaned_changed && props.row.lastname !== props.row.lastname_raw"
@ -356,7 +356,7 @@
</template> </template>
<template v-slot:body-cell-match="props"> <template v-slot:body-cell-match="props">
<q-td :props="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-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-chip v-else dense color="warning" text-color="white" size="sm" label="non lié" />
</q-td> </q-td>
@ -375,7 +375,7 @@
<!-- Contacts that have NO gift-url (won't be sent) --> <!-- Contacts that have NO gift-url (won't be sent) -->
<q-expansion-item v-if="unpairedContacts.length" expand-separator <q-expansion-item v-if="unpairedContacts.length" expand-separator
icon="person_off" :label="`${unpairedContacts.length} contact(s) sans lien-cadeau (ne recevront pas)`" 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 <q-table
:rows="unpairedContacts" :columns="unpairedColumns" row-key="row_index" :rows="unpairedContacts" :columns="unpairedColumns" row-key="row_index"
flat dense hide-bottom :pagination="{ rowsPerPage: 0 }" flat dense hide-bottom :pagination="{ rowsPerPage: 0 }"
@ -390,7 +390,7 @@
<q-expansion-item v-if="unusedGifts.length" expand-separator <q-expansion-item v-if="unusedGifts.length" expand-separator
icon="card_giftcard" icon="card_giftcard"
:label="`${unusedGifts.length} lien(s) Giftbit non utilisés`" :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"> class="q-mt-sm">
<q-list dense> <q-list dense>
<q-item v-for="g in unusedGifts" :key="g.gift_url"> <q-item v-for="g in unusedGifts" :key="g.gift_url">
@ -406,7 +406,7 @@
<q-expansion-item v-if="parseSkippedRows.length" expand-separator <q-expansion-item v-if="parseSkippedRows.length" expand-separator
icon="filter_alt_off" icon="filter_alt_off"
:label="`${parseSkippedRows.length} ligne(s) du Map CSV non importée(s)`" :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"> class="q-mt-sm">
<div class="q-pa-sm text-caption text-grey-7"> <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 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-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-list>
</q-card-section> </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> <q-icon name="warning" /> <strong>Confirmation finale.</strong>
L'envoi démarre dès le clic sur <em>"Lancer l'envoi maintenant"</em>. 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. Tu seras redirigé vers la page de progression temps réel.
@ -583,7 +583,7 @@
</q-btn> </q-btn>
<q-btn flat dense round icon="close" @click="previewOpen = false" /> <q-btn flat dense round icon="close" @click="previewOpen = false" />
</q-toolbar> </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-spinner size="sm" /> Rendu en cours
</q-banner> </q-banner>
<q-card-section class="q-pa-md" style="height: calc(100vh - 60px); overflow:hidden"> <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-btn class="q-mt-lg" color="primary" icon="add" label="Créer la première" :to="'/campaigns/new'" />
</q-card> </q-card>
<q-table <DataTable
v-else v-else
:rows="campaigns" :rows="campaigns"
:columns="columns" :columns="columns"
@ -62,12 +62,13 @@
</q-btn> </q-btn>
</q-td> </q-td>
</template> </template>
</q-table> </DataTable>
</q-page> </q-page>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import DataTable from 'src/components/shared/DataTable.vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { listCampaigns, deleteCampaign } from 'src/api/campaigns' import { listCampaigns, deleteCampaign } from 'src/api/campaigns'

View File

@ -31,7 +31,7 @@
</q-card> </q-card>
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'expired'" style="cursor:pointer"> <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"> <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> <div class="text-caption text-grey-7">Expirés</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -44,7 +44,7 @@
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'reassignable'" style="cursor:pointer" <q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'reassignable'" style="cursor:pointer"
:class="{ 'bg-amber-1': counts.reassignable > 0 }"> :class="{ 'bg-amber-1': counts.reassignable > 0 }">
<q-card-section class="text-center q-pa-sm"> <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> <div class="text-caption text-grey-7">Réassignables</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -68,7 +68,7 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
<q-table <DataTable
:rows="filtered" :columns="columns" row-key="gift_token" :rows="filtered" :columns="columns" row-key="gift_token"
flat bordered dense :pagination="{ rowsPerPage: 50, sortBy: 'expires', descending: false }" flat bordered dense :pagination="{ rowsPerPage: 50, sortBy: 'expires', descending: false }"
:rows-per-page-options="[25, 50, 100, 0]" :rows-per-page-options="[25, 50, 100, 0]"
@ -109,7 +109,7 @@
</template> </template>
<template v-slot:body-cell-expires="props"> <template v-slot:body-cell-expires="props">
<q-td :props="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) }} {{ formatDate(props.row.gift_expires_at) }}
</span> </span>
<span v-else class="text-grey-5"></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-tooltip>Révoquer ce lien (libère le cadeau Giftbit pour réassignation)</q-tooltip>
</q-btn> </q-btn>
<q-btn v-if="props.row.status === 'expired' || props.row.status === 'revoked'" <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)"> @click="copyForReassign(props.row.gift_url)">
<q-tooltip>Copier l'URL pour réassigner à un autre destinataire</q-tooltip> <q-tooltip>Copier l'URL pour réassigner à un autre destinataire</q-tooltip>
</q-btn> </q-btn>
</q-td> </q-td>
</template> </template>
</q-table> </DataTable>
</q-page> </q-page>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import DataTable from 'src/components/shared/DataTable.vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { listGifts, revokeGift } from 'src/api/campaigns' import { listGifts, revokeGift } from 'src/api/campaigns'
@ -310,5 +312,11 @@ async function load () {
loading.value = false 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> </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"> :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-tooltip>{{ aiTranslateTargetName ? `Traduire vers ${aiTranslateTargetName} via Gemini` : 'Disponible pour les templates avec suffixe -fr ou -en' }}</q-tooltip>
</q-btn> </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-tooltip>Envoyer un courriel réel à une adresse de test</q-tooltip>
</q-btn> </q-btn>
<q-btn unelevated color="primary" icon="save" label="Enregistrer" <q-btn unelevated color="primary" icon="save" label="Enregistrer"
@ -57,7 +57,7 @@
<q-toolbar-title>Aperçu inbox · {{ currentName }}</q-toolbar-title> <q-toolbar-title>Aperçu inbox · {{ currentName }}</q-toolbar-title>
<q-btn flat dense round icon="close" @click="previewOpen = false" /> <q-btn flat dense round icon="close" @click="previewOpen = false" />
</q-toolbar> </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"> <q-card-section style="height: calc(100vh - 60px); overflow:hidden">
<iframe :srcdoc="previewHtmlContent" style="width:100%; height:100%; border:1px solid #e5e7eb; background:#fff;" /> <iframe :srcdoc="previewHtmlContent" style="width:100%; height:100%; border:1px solid #e5e7eb; background:#fff;" />
</q-card-section> </q-card-section>
@ -67,7 +67,7 @@
<!-- New template dialog name + starter --> <!-- New template dialog name + starter -->
<q-dialog v-model="newTemplateOpen" persistent> <q-dialog v-model="newTemplateOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw"> <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" /> <q-icon name="add" class="q-mr-sm" />
<div class="text-h6">Nouveau template</div> <div class="text-h6">Nouveau template</div>
<q-space /> <q-space />
@ -122,7 +122,7 @@
Il traduit seulement le texte visible. Il traduit seulement le texte visible.
</span> </span>
</q-banner> </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" /> <q-icon name="warning" class="q-mr-xs" />
Le template <strong>{{ aiTranslateTargetName }}</strong> existe déjà. Le template <strong>{{ aiTranslateTargetName }}</strong> existe déjà.
La traduction va l'écraser (backup automatique avant). La traduction va l'écraser (backup automatique avant).
@ -142,7 +142,7 @@
<!-- Test-send dialog --> <!-- Test-send dialog -->
<q-dialog v-model="testSendOpen" persistent> <q-dialog v-model="testSendOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw"> <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" /> <q-icon name="send" class="q-mr-sm" />
<div class="text-h6">Envoyer un test du courriel</div> <div class="text-h6">Envoyer un test du courriel</div>
<q-space /> <q-space />
@ -223,7 +223,7 @@
</q-item> </q-item>
</q-list> </q-list>
</div> </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> <template v-slot:avatar><q-icon name="lightbulb" /></template>
<span v-pre><strong>Bientôt :</strong> à l'importation CSV, on pourra mapper chaque colonne <span v-pre><strong>Bientôt :</strong> à l'importation CSV, on pourra mapper chaque colonne
comme variable additionnelle (ex. <code>{{plan_name}}</code>, comme variable additionnelle (ex. <code>{{plan_name}}</code>,

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