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>
194 lines
9.7 KiB
Vue
194 lines
9.7 KiB
Vue
<template>
|
||
<q-page class="q-pa-md" style="max-width:900px;margin:0 auto">
|
||
<div class="text-h6 q-mb-xs">🤖 Copilote dispatch</div>
|
||
<div class="text-caption text-grey-7 q-mb-md">
|
||
Demande en langage naturel (texte ou voix) l'impact d'une absence et des idées de réassignation.
|
||
Ex : « Kadi est malade le 16 juin, quel impact et quelles réassignations ? »
|
||
</div>
|
||
|
||
<!-- Politique de reprise -->
|
||
<q-card flat bordered class="q-pa-md q-mb-md">
|
||
<div class="text-subtitle2 q-mb-sm">Politique de reprise (technicien indisponible)</div>
|
||
<q-select dense outlined emit-value map-options
|
||
v-model="policy.reschedule" :options="opts.reschedule"
|
||
label="Quand un tech ne peut se présenter" :loading="loadingPolicy" />
|
||
<div class="row items-center q-gutter-md q-mt-sm">
|
||
<q-toggle v-model="policy.sms_enabled" label="SMS client activé" />
|
||
<q-toggle v-model="policy.sms_quiet_hours" label="Pas la nuit (21h–8h)" />
|
||
<q-select dense outlined emit-value map-options style="min-width:260px"
|
||
v-model="policy.escalation" :options="opts.escalation" label="Escalade superviseur" />
|
||
<q-space />
|
||
<q-btn unelevated color="primary" label="Enregistrer" :loading="savingPolicy" @click="doSavePolicy" />
|
||
</div>
|
||
<div v-if="policySaved" class="text-positive text-caption q-mt-xs">✓ Politique enregistrée</div>
|
||
</q-card>
|
||
|
||
<!-- #56 Créneaux offerts à la prise de RDV -->
|
||
<q-card flat bordered class="q-pa-md q-mb-md">
|
||
<div class="text-subtitle2 q-mb-xs">Créneaux offerts à la prise de RDV</div>
|
||
<div class="text-caption text-grey-7 q-mb-sm">
|
||
Contrôle ce qui est proposé sur la page client et dans la vue agent. Les créneaux restent
|
||
dérivés des shifts du roster — ces réglages les <b>bornent</b> (délai, heures, jours, plafond).
|
||
</div>
|
||
<div class="row q-col-gutter-sm">
|
||
<div v-for="f in bookingFields" :key="f.key" class="col-6 col-sm-4">
|
||
<q-input dense outlined type="number" :min="f.min" :max="f.max"
|
||
v-model.number="booking[f.key]" :label="f.label" :suffix="f.unit" :hint="f.hint" />
|
||
</div>
|
||
</div>
|
||
<div class="q-mt-md">
|
||
<div class="text-caption text-grey-7 q-mb-xs">Jours offerts</div>
|
||
<q-chip v-for="d in weekdays" :key="d.v" clickable
|
||
:color="(booking.days_offered || []).includes(d.v) ? 'primary' : 'grey-4'"
|
||
:text-color="(booking.days_offered || []).includes(d.v) ? 'white' : 'grey-8'"
|
||
@click="toggleDay(d.v)">{{ d.l }}</q-chip>
|
||
</div>
|
||
<q-separator class="q-my-md" />
|
||
<div class="text-caption text-weight-medium q-mb-xs">Compétence requise par type de job</div>
|
||
<div class="text-caption text-grey-7 q-mb-sm">
|
||
Le client ne voit que les créneaux des techs ayant le <b>tag</b> correspondant. Laisser vide = aucun tag requis
|
||
(ex. ajout TV / borne WiFi → n'importe qui ; installation ou réparation → « installateur »).
|
||
</div>
|
||
<div v-if="!meta.service_types.length" class="text-caption text-grey-6 q-mb-sm">
|
||
Aucun type de job détecté (champ <code>service_type</code> vide sur les jobs). Renseigne‑le sur les Dispatch Jobs.
|
||
</div>
|
||
<div v-for="st in meta.service_types" :key="st" class="row items-center q-col-gutter-sm q-mb-xs">
|
||
<div class="col-12 col-sm-5 text-body2">{{ st }}</div>
|
||
<q-select class="col" dense outlined clearable use-input new-value-mode="add-unique" input-debounce="0"
|
||
:options="meta.skills" v-model="skillMap[st]" label="Compétence requise (tag)" placeholder="(aucune)" />
|
||
</div>
|
||
<div v-if="!meta.skills.length" class="text-caption text-warning q-mt-xs">
|
||
⚠️ Aucun technicien n'a de compétence saisie — renseigne les tags dans Planification › Outils › Cadence équipe, sinon le filtre exclura tout le monde.
|
||
</div>
|
||
<div class="row items-center q-mt-sm">
|
||
<q-space />
|
||
<q-btn unelevated color="primary" label="Enregistrer" :loading="savingBooking" @click="doSaveBooking" />
|
||
</div>
|
||
<div v-if="bookingSaved" class="text-positive text-caption q-mt-xs">✓ Réglages enregistrés</div>
|
||
</q-card>
|
||
|
||
<!-- Chat -->
|
||
<q-card flat bordered class="q-pa-md">
|
||
<div ref="scrollEl" style="max-height:52vh;overflow:auto" class="q-pb-sm">
|
||
<div v-if="!messages.length" class="text-grey-6 text-center q-pa-lg">
|
||
Pose ta question — j'analyse l'équipe et les rendez-vous réels avant de répondre.
|
||
</div>
|
||
<q-chat-message v-for="(m, i) in messages" :key="i"
|
||
:sent="m.role === 'user'"
|
||
:bg-color="m.role === 'user' ? 'primary' : 'grey-2'"
|
||
:text-color="m.role === 'user' ? 'white' : 'grey-10'">
|
||
<div style="white-space:pre-wrap">{{ m.content }}</div>
|
||
</q-chat-message>
|
||
<q-chat-message v-if="loading" :text="['…']" bg-color="grey-2" />
|
||
</div>
|
||
<q-separator class="q-my-sm" />
|
||
<div class="row items-end q-gutter-sm">
|
||
<q-input class="col" outlined dense autogrow v-model="input"
|
||
placeholder="Ex : Simon Clo est malade aujourd'hui, quel impact ?"
|
||
@keyup.enter.exact.prevent="send" />
|
||
<q-btn v-if="voiceOk" round flat :color="listening ? 'red' : 'grey-7'"
|
||
:icon="listening ? 'mic' : 'mic_none'" @click="toggleVoice" :title="listening ? 'Arrêter' : 'Dicter'" />
|
||
<q-btn round unelevated color="primary" icon="send" :loading="loading"
|
||
:disable="!input.trim()" @click="send" />
|
||
</div>
|
||
</q-card>
|
||
</q-page>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||
import { askAssistant, getPolicy, savePolicy, bookMeta } from 'src/api/roster'
|
||
|
||
const messages = ref([])
|
||
const input = ref('')
|
||
const loading = ref(false)
|
||
const scrollEl = ref(null)
|
||
|
||
const policy = reactive({ reschedule: 'auto_then_sms_then_super', sms_enabled: true, sms_quiet_hours: true, escalation: 'queue_sms' })
|
||
const opts = reactive({ reschedule: [], escalation: [] })
|
||
const loadingPolicy = ref(false)
|
||
const savingPolicy = ref(false)
|
||
const policySaved = ref(false)
|
||
|
||
// #56 — réglages des créneaux offerts
|
||
const booking = reactive({ lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10 })
|
||
const bookingFields = ref([])
|
||
const weekdays = ref([])
|
||
const savingBooking = ref(false)
|
||
const bookingSaved = ref(false)
|
||
// Table type de job → compétence requise (le client ne voit que les techs qualifiés)
|
||
const meta = reactive({ service_types: [], skills: [] })
|
||
const skillMap = reactive({})
|
||
|
||
onMounted(async () => {
|
||
loadingPolicy.value = true
|
||
try {
|
||
const d = await getPolicy()
|
||
Object.assign(policy, d.policy || {})
|
||
if (d.policy?.booking) Object.assign(booking, d.policy.booking)
|
||
opts.reschedule = d.options?.reschedule || []
|
||
opts.escalation = d.options?.escalation || []
|
||
bookingFields.value = d.options?.booking_fields || []
|
||
weekdays.value = d.options?.weekdays || []
|
||
} catch (e) { /* defaults */ }
|
||
try { const m = await bookMeta(); meta.service_types = m.service_types || []; meta.skills = m.skills || []; Object.assign(skillMap, m.skill_by_type || {}) } catch (e) { /* non bloquant */ }
|
||
loadingPolicy.value = false
|
||
})
|
||
|
||
async function doSavePolicy () {
|
||
savingPolicy.value = true; policySaved.value = false
|
||
try { await savePolicy({ reschedule: policy.reschedule, sms_enabled: policy.sms_enabled, sms_quiet_hours: policy.sms_quiet_hours, escalation: policy.escalation }); policySaved.value = true; setTimeout(() => { policySaved.value = false }, 2500) }
|
||
catch (e) { /* ignore */ }
|
||
savingPolicy.value = false
|
||
}
|
||
|
||
function toggleDay (v) {
|
||
if (!Array.isArray(booking.days_offered)) booking.days_offered = []
|
||
const i = booking.days_offered.indexOf(v)
|
||
if (i >= 0) booking.days_offered.splice(i, 1); else booking.days_offered.push(v)
|
||
}
|
||
async function doSaveBooking () {
|
||
savingBooking.value = true; bookingSaved.value = false
|
||
const sbt = {}; for (const k in skillMap) { if (skillMap[k]) sbt[k] = String(skillMap[k]).trim() } // n'enregistre que les types tagués
|
||
try { await savePolicy({ booking: { ...booking, days_offered: [...(booking.days_offered || [])], skill_by_type: sbt } }); bookingSaved.value = true; setTimeout(() => { bookingSaved.value = false }, 2500) }
|
||
catch (e) { /* ignore */ }
|
||
savingBooking.value = false
|
||
}
|
||
|
||
async function scrollDown () { await nextTick(); if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight }
|
||
|
||
async function send () {
|
||
const text = input.value.trim()
|
||
if (!text || loading.value) return
|
||
messages.value.push({ role: 'user', content: text })
|
||
input.value = ''
|
||
loading.value = true
|
||
await scrollDown()
|
||
try {
|
||
const hist = messages.value.slice(-8).map(m => ({ role: m.role, content: m.content }))
|
||
const d = await askAssistant(text, hist)
|
||
messages.value.push({ role: 'assistant', content: d.reply || 'Aucune réponse.' })
|
||
} catch (e) {
|
||
messages.value.push({ role: 'assistant', content: 'Erreur : ' + (e.message || e) })
|
||
}
|
||
loading.value = false
|
||
await scrollDown()
|
||
}
|
||
|
||
// ── Voix (Web Speech API, navigateur) ──
|
||
const SR = (typeof window !== 'undefined') && (window.SpeechRecognition || window.webkitSpeechRecognition)
|
||
const voiceOk = !!SR
|
||
const listening = ref(false)
|
||
let rec = null
|
||
function toggleVoice () {
|
||
if (!SR) return
|
||
if (listening.value) { try { rec && rec.stop() } catch {} ; return }
|
||
rec = new SR(); rec.lang = 'fr-CA'; rec.interimResults = false; rec.maxAlternatives = 1
|
||
rec.onresult = (e) => { const t = e.results[0][0].transcript; input.value = (input.value ? input.value + ' ' : '') + t }
|
||
rec.onend = () => { listening.value = false }
|
||
rec.onerror = () => { listening.value = false }
|
||
listening.value = true
|
||
try { rec.start() } catch { listening.value = false }
|
||
}
|
||
</script>
|