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>
219 lines
14 KiB
Vue
219 lines
14 KiB
Vue
<template>
|
|
<q-dialog v-model="open" @hide="reset">
|
|
<q-card style="min-width:540px;max-width:96vw">
|
|
<q-card-section class="row items-center q-pb-none">
|
|
<q-icon name="bolt" color="deep-purple-6" size="22px" class="q-mr-sm" />
|
|
<div class="text-subtitle1 text-weight-bold">Commande dictée</div>
|
|
<q-space />
|
|
<q-btn flat round dense icon="close" v-close-popup />
|
|
</q-card-section>
|
|
|
|
<q-card-section>
|
|
<!-- Dictée / texte -->
|
|
<div class="row items-end q-gutter-sm">
|
|
<q-input v-model="text" type="textarea" autogrow :rows="2" outlined class="col" autofocus
|
|
label="Dicte ou écris (ex. « Crée un ticket de facture non payée pour Louis-Paul, texto au client qu'on attend le paiement »)"
|
|
@keyup.enter.exact.prevent="plan" />
|
|
<q-btn round :color="listening ? 'red-6' : 'deep-purple-5'" :icon="listening ? 'mic' : 'mic_none'" :loading="planning" @click="toggleMic">
|
|
<q-tooltip>{{ micSupported ? (listening ? 'Arrêter la dictée' : 'Dicter à voix') : 'Dictée non supportée ici' }}</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
<div class="row justify-end q-mt-xs">
|
|
<q-btn unelevated color="deep-purple-6" icon="auto_awesome" label="Préparer le plan" no-caps :disable="!text.trim() || planning" :loading="planning" @click="plan" />
|
|
</div>
|
|
|
|
<!-- Vérifications (lecture immédiate — pas de confirmation) -->
|
|
<div v-if="diagnoses.length" class="q-mt-md">
|
|
<div v-for="(a, i) in diagnoses" :key="'d' + i" class="orch-diag">
|
|
<div class="row items-center q-mb-xs">
|
|
<q-icon name="network_check" color="primary" size="18px" class="q-mr-sm" />
|
|
<span class="text-weight-medium">Vérification</span>
|
|
<span class="text-caption text-grey-6 q-ml-sm">« {{ a.customer_query }} »</span>
|
|
</div>
|
|
<!-- 1 client → diagnostic -->
|
|
<template v-if="a.diagnostic && a.diagnostic.found">
|
|
<div class="row items-center no-wrap">
|
|
<span class="diag-dot" :style="{ background: dotColor(a.diagnostic.summary) }"></span>
|
|
<div class="col">
|
|
<div class="text-weight-medium">{{ a.diagnostic.customer.customer_name }}</div>
|
|
<div class="text-caption text-grey-7">{{ a.diagnostic.location ? a.diagnostic.location.address : '—' }}</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-weight-medium" :style="{ color: dotColor(a.diagnostic.summary) }">{{ a.diagnostic.summary.label }}</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 v-if="a.diagnostic.locations && a.diagnostic.locations.length > 1" class="text-caption text-warning q-mt-xs"><q-icon name="place" size="13px" /> {{ a.diagnostic.locations.length }} adresses — affichage de la principale</div>
|
|
</template>
|
|
<!-- plusieurs clients → préciser l'adresse -->
|
|
<template v-else-if="a.diagnostic && a.diagnostic.ambiguous">
|
|
<div class="text-caption text-warning q-mb-xs">Plusieurs clients — précise l'adresse :</div>
|
|
<q-list dense bordered class="rounded-borders">
|
|
<q-item clickable v-ripple v-for="(m, j) in a.diagnostic.matches" :key="j" @click="pickDiag(a, m)">
|
|
<q-item-section><q-item-label>{{ m.customer_name }}</q-item-label><q-item-label caption>{{ m.address || m.phone || m.email }}</q-item-label></q-item-section>
|
|
<q-item-section side><q-icon name="chevron_right" color="grey-5" /></q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</template>
|
|
<div v-else-if="a.diagnostic && !a.diagnostic.found" class="text-caption text-negative">Client introuvable pour « {{ a.diagnostic.query || a.customer_query }} »</div>
|
|
<div v-else class="text-caption text-grey-6"><q-spinner size="14px" /> diagnostic…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plan d'actions à confirmer -->
|
|
<div v-if="writeActions.length" class="q-mt-md">
|
|
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">Plan ({{ writeActions.length }} action{{ writeActions.length > 1 ? 's' : '' }}) — vérifie puis confirme :</div>
|
|
<div v-for="(a, i) in writeActions" :key="i" class="orch-act">
|
|
<div class="row items-center no-wrap q-mb-xs">
|
|
<q-icon :name="iconOf(a.type)" :color="colorOf(a.type)" size="18px" class="q-mr-sm" />
|
|
<span class="text-weight-medium">{{ labelOf(a.type) }}</span>
|
|
<q-badge v-if="a.type === 'create_ticket' && a.status === 'On Hold'" color="amber-1" text-color="amber-9" label="En attente" class="q-ml-sm" />
|
|
<q-badge v-if="a.category" :label="a.category" color="grey-3" text-color="grey-8" class="q-ml-xs" />
|
|
<q-space />
|
|
<q-btn flat round dense size="xs" icon="delete" color="grey-5" @click="actions.splice(actions.indexOf(a), 1)" />
|
|
</div>
|
|
<!-- destinataire / client résolu -->
|
|
<div v-if="a.candidates && a.candidates.length" class="q-mb-xs">
|
|
<q-select dense outlined v-model="a.resolved" :options="a.candidates" option-label="customer_name" map-options emit-value
|
|
:display-value="a.resolved ? a.resolved.customer_name : '— aucun —'" label="Client / destinataire" @update:model-value="onResolved(a)">
|
|
<template #option="s">
|
|
<q-item v-bind="s.itemProps"><q-item-section>
|
|
<q-item-label>{{ s.opt.customer_name }}<q-badge v-if="s.opt.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></q-item-label>
|
|
<q-item-label caption>{{ s.opt.kind === 'technicien' ? '🔧 technicien · ' : '' }}{{ s.opt.address || s.opt.phone || s.opt.email }}{{ s.opt.can_sms ? ' · SMS ✓' : '' }}</q-item-label>
|
|
</q-item-section></q-item>
|
|
</template>
|
|
</q-select>
|
|
<div v-if="a.type === 'send_sms' && a.resolved && !a.resolved.can_sms" class="text-caption text-warning"><q-icon name="warning" size="13px" /> pas de mobile — SMS impossible</div>
|
|
</div>
|
|
<div v-else-if="a.phone" class="text-caption text-grey-7 q-mb-xs"><q-icon name="call" size="13px" /> {{ a.phone }}</div>
|
|
<div v-else-if="a.customer_query || a.to_query" class="text-caption text-warning q-mb-xs"><q-icon name="info" size="13px" /> « {{ a.customer_query || a.to_query }} » — non résolu</div>
|
|
<!-- Adresse de service liée (compte multi-adresses → on choisit la bonne, pas la facturation) -->
|
|
<div v-if="a.type === 'create_ticket' && a.resolved" class="q-mb-xs">
|
|
<q-select v-if="a._locs && a._locs.length > 1" dense outlined v-model="a.service_location" :options="a._locs" option-label="address" option-value="name" emit-value map-options clearable label="Adresse de service">
|
|
<template #prepend><q-icon name="place" color="warning" /></template>
|
|
</q-select>
|
|
<div v-else-if="a.resolved.address || a.service_location" class="text-caption text-grey-7"><q-icon name="place" size="13px" color="warning" /> {{ a.resolved.address || locLabel(a) }}</div>
|
|
</div>
|
|
<!-- contenu éditable -->
|
|
<q-input v-if="a.type === 'create_ticket'" v-model="a.subject" dense outlined label="Sujet" />
|
|
<q-input v-if="a.type === 'send_sms'" v-model="a.message" type="textarea" autogrow :rows="2" dense outlined label="Texto" />
|
|
<template v-if="a.type === 'send_email'"><q-input v-model="a.subject" dense outlined label="Sujet" class="q-mb-xs" /><q-input v-model="a.body" type="textarea" autogrow :rows="2" dense outlined label="Courriel" /></template>
|
|
<q-input v-if="a.type === 'note'" v-model="a.text" type="textarea" autogrow :rows="2" dense outlined label="Note" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Résultats -->
|
|
<div v-if="results.length" class="q-mt-md">
|
|
<div v-for="(r, i) in results" :key="i" class="text-body2" :class="r.ok ? 'text-green-8' : 'text-negative'">
|
|
<q-icon :name="r.ok ? 'check_circle' : 'error'" size="15px" /> {{ summaryOf(r) }}
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<q-card-actions v-if="writeActions.length && !results.length" align="right">
|
|
<q-btn flat label="Annuler" @click="actions = []" />
|
|
<q-btn unelevated color="green-7" icon="send" label="Confirmer et exécuter" no-caps :loading="running" @click="run" />
|
|
</q-card-actions>
|
|
<q-card-actions v-else-if="results.length" align="right">
|
|
<q-btn unelevated color="grey-7" label="Fermer" v-close-popup />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch } from 'vue'
|
|
import { useQuasar } from 'quasar'
|
|
import { HUB_URL } from 'src/config/hub'
|
|
|
|
const props = defineProps({ modelValue: Boolean })
|
|
const emit = defineEmits(['update:modelValue', 'done'])
|
|
const $q = useQuasar()
|
|
|
|
const open = ref(false)
|
|
const text = ref('')
|
|
const actions = ref([])
|
|
const results = ref([])
|
|
const planning = ref(false)
|
|
const running = ref(false)
|
|
const listening = ref(false)
|
|
const micSupported = !!(window.SpeechRecognition || window.webkitSpeechRecognition)
|
|
let recog = null
|
|
|
|
// Les vérifications (lecture) sont déjà exécutées dans le plan → séparées des actions à confirmer.
|
|
const diagnoses = computed(() => actions.value.filter(a => a.type === 'diagnose'))
|
|
const writeActions = computed(() => actions.value.filter(a => a.type !== 'diagnose'))
|
|
function dotColor (s) { return (s && s.online === true) ? '#16a34a' : (s && s.online === false) ? '#dc2626' : '#94a3b8' }
|
|
async function pickDiag (a, m) {
|
|
a.diagnostic = null
|
|
try { const r = await fetch(`${HUB_URL}/collab/service-status?customer=${encodeURIComponent(m.name)}`); if (r.ok) a.diagnostic = await r.json() } catch (e) { a.diagnostic = { ok: false, error: e.message } }
|
|
}
|
|
|
|
watch(() => props.modelValue, v => { open.value = v; if (v) reset() })
|
|
watch(open, v => emit('update:modelValue', v))
|
|
function reset () { text.value = ''; actions.value = []; results.value = []; if (recog) { try { recog.stop() } catch (e) {} } listening.value = false }
|
|
|
|
const iconOf = t => ({ create_ticket: 'confirmation_number', send_sms: 'sms', send_email: 'mail', note: 'sticky_note_2' }[t] || 'bolt')
|
|
const colorOf = t => ({ create_ticket: 'teal-7', send_sms: 'green-6', send_email: 'red-5', note: 'amber-7' }[t] || 'grey-7')
|
|
const labelOf = t => ({ create_ticket: 'Créer un ticket', send_sms: 'Envoyer un texto', send_email: 'Envoyer un courriel', note: 'Note interne' }[t] || t)
|
|
function summaryOf (r) {
|
|
if (r.type === 'create_ticket') return r.ok ? `Ticket ${r.name} créé (${r.label || ''})` : 'Ticket : ' + (r.error || 'échec')
|
|
if (r.type === 'send_sms') return r.ok ? `Texto envoyé à ${r.to} (${r.via})` : 'Texto : ' + (r.error || 'échec')
|
|
if (r.type === 'send_email') return r.ok ? `Courriel envoyé à ${r.to}` : 'Courriel : ' + (r.error || 'échec')
|
|
if (r.type === 'note') return 'Note : ' + (r.text || '')
|
|
return JSON.stringify(r)
|
|
}
|
|
|
|
function toggleMic () {
|
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
|
|
if (!SR) { $q.notify({ type: 'warning', message: 'Dictée non supportée par ce navigateur' }); return }
|
|
if (listening.value) { try { recog && recog.stop() } catch (e) {} return }
|
|
recog = new SR(); recog.lang = 'fr-CA'; recog.interimResults = true; recog.continuous = false
|
|
recog.onresult = e => { let t = ''; for (const r of e.results) t += r[0].transcript; text.value = t }
|
|
recog.onend = () => { listening.value = false }
|
|
recog.onerror = () => { listening.value = false }
|
|
try { recog.start(); listening.value = true } catch (e) { listening.value = false }
|
|
}
|
|
|
|
async function plan () {
|
|
if (!text.value.trim()) return
|
|
planning.value = true; results.value = []
|
|
try {
|
|
const r = await fetch(`${HUB_URL}/collab/orchestrate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text.value.trim() }) })
|
|
const d = await r.json()
|
|
if (d.ok) {
|
|
actions.value = d.actions || []
|
|
for (const a of actions.value) if (a.type === 'create_ticket' && a.resolved) onResolved(a) // pré-charge les adresses de service
|
|
if (!actions.value.length) $q.notify({ type: 'info', message: 'Aucune action détectée — reformule.' })
|
|
} else $q.notify({ type: 'negative', message: d.error || 'Échec' })
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { planning.value = false }
|
|
}
|
|
// Quand le client résolu change (ou au chargement) : récupère ses adresses de service et présélectionne celle qui matche.
|
|
async function onResolved (a) {
|
|
if (a.type !== 'create_ticket' || !a.resolved || !a.resolved.name) { a._locs = []; return }
|
|
a.service_location = a.resolved.service_location || ''
|
|
try {
|
|
const r = await fetch(`${HUB_URL}/collab/service-locations?customer=${encodeURIComponent(a.resolved.name)}`)
|
|
if (r.ok) { const d = await r.json(); a._locs = d.locations || []; if (!a.service_location && a._locs.length) a.service_location = a._locs[0].name } // adresse de service active la plus récente (triée 1re)
|
|
} catch (e) { a._locs = [] }
|
|
}
|
|
function locLabel (a) { const l = (a._locs || []).find(x => x.name === a.service_location); return l ? l.address : a.service_location }
|
|
async function run () {
|
|
running.value = true
|
|
try {
|
|
const r = await fetch(`${HUB_URL}/collab/orchestrate/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ actions: writeActions.value }) })
|
|
const d = await r.json()
|
|
results.value = d.results || []
|
|
const ok = results.value.filter(x => x.ok).length
|
|
$q.notify({ type: ok ? 'positive' : 'negative', message: `${ok}/${results.value.length} action(s) exécutée(s)` })
|
|
emit('done')
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { running.value = false }
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.orch-act { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; margin-bottom: 8px; background: #faf9ff; }
|
|
.orch-diag { border: 1px solid #e0e7ff; border-radius: 8px; padding: 10px; margin-bottom: 8px; background: #f5f7ff; }
|
|
.diag-dot { width: 11px; height: 11px; border-radius: 50%; margin-right: 10px; flex: 0 0 auto; }
|
|
</style>
|