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:
parent
6628eaaa5b
commit
512c4a5f1b
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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
14
apps/ops/knip.json
Normal 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": []
|
||||||
|
}
|
||||||
760
apps/ops/package-lock.json
generated
760
apps/ops/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
35
apps/ops/src/api/billing.js
Normal file
35
apps/ops/src/api/billing.js
Normal 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 } })
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
31
apps/ops/src/api/subcontractor.js
Normal file
31
apps/ops/src/api/subcontractor.js
Normal 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
|
||||||
26
apps/ops/src/boot/net-timeout.js
Normal file
26
apps/ops/src/boot/net-timeout.js
Normal 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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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"> · {{ 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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
149
apps/ops/src/components/customer/RewardDialog.vue
Normal file
149
apps/ops/src/components/customer/RewardDialog.vue
Normal 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>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
82
apps/ops/src/components/flow-editor/FlowCanvas.vue
Normal file
82
apps/ops/src/components/flow-editor/FlowCanvas.vue
Normal 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 (profondeur→X, ordre→Y).
|
||||||
|
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>
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
105
apps/ops/src/components/flow-editor/TaskDependencyGraph.vue
Normal file
105
apps/ops/src/components/flow-editor/TaskDependencyGraph.vue
Normal 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>
|
||||||
62
apps/ops/src/components/flow-editor/TaskGantt.vue
Normal file
62
apps/ops/src/components/flow-editor/TaskGantt.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
163
apps/ops/src/components/settings/DepartmentSubscriptions.vue
Normal file
163
apps/ops/src/components/settings/DepartmentSubscriptions.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
76
apps/ops/src/components/settings/SlaSettings.vue
Normal file
76
apps/ops/src/components/settings/SlaSettings.vue
Normal 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>
|
||||||
123
apps/ops/src/components/shared/AddToTaskDialog.vue
Normal file
123
apps/ops/src/components/shared/AddToTaskDialog.vue
Normal 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
|
|
@ -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>
|
||||||
|
|
|
||||||
39
apps/ops/src/components/shared/DataTable.vue
Normal file
39
apps/ops/src/components/shared/DataTable.vue
Normal 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>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
25
apps/ops/src/components/shared/EmptyState.vue
Normal file
25
apps/ops/src/components/shared/EmptyState.vue
Normal 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>
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) :</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() }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
37
apps/ops/src/components/shared/PageHeader.vue
Normal file
37
apps/ops/src/components/shared/PageHeader.vue
Normal 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>
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
<div class="quick-sale-title">Vente rapide — sans installation</div>
|
<div class="quick-sale-title">Vente rapide — sans installation</div>
|
||||||
<div class="quick-sale-desc">Ajout d'équipement (ex : 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 : 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 — aucune étape requise</div>
|
<div class="text-weight-bold text-green-8" style="font-size:0.85rem">Mode Vente rapide — aucune étape requise</div>
|
||||||
<div class="text-caption text-grey-7">Appuyez sur « Suivant » pour aller directement au catalogue.</div>
|
<div class="text-caption text-grey-7">Appuyez sur « Suivant » 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 — {{ orderItems.length }} item{{ orderItems.length > 1 ? 's' : '' }}
|
Sommaire — {{ 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' }}
|
||||||
— {{ orderItems.length }} items
|
— {{ 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" /> Valeur promotions étalées sur {{ maxContractMonths }} mois</span>
|
<span><q-icon name="celebration" size="12px" /> 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 — {{ acceptanceMethod === 'docuseal' ? 'Commercial' : 'Résidentiel' }}
|
Contrat de service — {{ acceptanceMethod === 'docuseal' ? 'Commercial' : 'Résidentiel' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -792,7 +792,7 @@
|
||||||
{{ recurringTotal.toFixed(2) }}$/mois · {{ maxContractMonths }} mois
|
{{ recurringTotal.toFixed(2) }}$/mois · {{ maxContractMonths }} mois
|
||||||
<template v-if="promoTotal > 0"> · {{ promoTotal.toFixed(2) }}$ en promotions étalées</template>
|
<template v-if="promoTotal > 0"> · {{ 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 — 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 — 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 @@
|
||||||
· <span class="text-green-7 text-weight-bold">+{{ catalogRecentlyAdded.size }}</span> ajouté{{ catalogRecentlyAdded.size > 1 ? 's' : '' }}
|
· <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">
|
||||||
|
|
|
||||||
78
apps/ops/src/components/shared/RecipientSelect.vue
Normal file
78
apps/ops/src/components/shared/RecipientSelect.vue
Normal 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>
|
||||||
79
apps/ops/src/components/shared/ReportScaffold.vue
Normal file
79
apps/ops/src/components/shared/ReportScaffold.vue
Normal 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>
|
||||||
32
apps/ops/src/components/shared/ResponsiveDialog.vue
Normal file
32
apps/ops/src/components/shared/ResponsiveDialog.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
29
apps/ops/src/components/shared/StatCard.vue
Normal file
29
apps/ops/src/components/shared/StatCard.vue
Normal 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>
|
||||||
22
apps/ops/src/components/shared/TableSkeleton.vue
Normal file
22
apps/ops/src/components/shared/TableSkeleton.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
— {{ eq.ip }} ({{ eq.mac }})
|
— {{ 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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) }}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
apps/ops/src/composables/useConversationDisplay.js
Normal file
81
apps/ops/src/composables/useConversationDisplay.js
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
23
apps/ops/src/composables/useCsvExport.js
Normal file
23
apps/ops/src/composables/useCsvExport.js
Normal 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 } }
|
||||||
9
apps/ops/src/composables/useEmailRender.js
Normal file
9
apps/ops/src/composables/useEmailRender.js
Normal 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>`
|
||||||
|
}
|
||||||
|
|
@ -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, ' ')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
apps/ops/src/composables/useMediaQuery.js
Normal file
29
apps/ops/src/composables/useMediaQuery.js
Normal 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)')
|
||||||
|
|
@ -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 } }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
28
apps/ops/src/composables/useSessionKeepAlive.js
Normal file
28
apps/ops/src/composables/useSessionKeepAlive.js
Normal 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 }
|
||||||
76
apps/ops/src/composables/useSla.js
Normal file
76
apps/ops/src/composables/useSla.js
Normal 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' },
|
||||||
|
}
|
||||||
43
apps/ops/src/composables/useSoftphone.js
Normal file
43
apps/ops/src/composables/useSoftphone.js
Normal 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 } }
|
||||||
81
apps/ops/src/composables/useTheme.js
Normal file
81
apps/ops/src/composables/useTheme.js
Normal 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) {} }
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
51
apps/ops/src/composables/useUserPrefs.js
Normal file
51
apps/ops/src/composables/useUserPrefs.js
Normal 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]
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
})
|
})
|
||||||
|
|
|
||||||
82
apps/ops/src/data/income-accounts.js
Normal file
82
apps/ops/src/data/income-accounts.js
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
177
apps/ops/src/modules/campaigns/pages/RewardsPoolPage.vue
Normal file
177
apps/ops/src/modules/campaigns/pages/RewardsPoolPage.vue
Normal 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>
|
||||||
|
|
@ -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
Loading…
Reference in New Issue
Block a user