gigafibre-fsm/apps/ops/src/pages/CopilotePage.vue
louispaulb 512c4a5f1b 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>
2026-07-02 08:49:35 -04:00

194 lines
9.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 (21h8h)" />
<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). Renseignele 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>