gigafibre-fsm/apps/ops/src/pages/CopilotePage.vue
louispaulb e33dd8e83a roster(planif): assignation drag-drop + timeline ressource + occupation + nettoyage
Panneau « jobs à assigner » v2 : multi-sélection (cases), groupes parent-enfant
surlignés, heuristique terrain/à distance (activation/netadmin pré-décochés),
pré-total d'heures, aperçu d'occupation PROJETÉE au survol (barre fantôme + badge).

Fix barre d'occupation figée après drop : /roster/assign-job pose désormais un
start_time (premier-trou-libre dans le shift) + garantit duration_h, sinon le job
compte dans les heures mais n'affiche aucun bloc. Nouvel endpoint
/roster/backfill-start-times (idempotent) pour rattraper l'historique.

Infobulle de cellule : nb de jobs + liste triée par priorité (occupancyByTechDay
renvoie jobs[]). Timeline contextuelle par ressource (dialogue, 0 appel réseau).

Lisibilité du drag : fantôme compact semi-transparent décalé sous le curseur
(ne masque plus l'aperçu) + source estompée.

Scoring de priorité : hook proximité (neutre — secteur géré manuellement),
réservé à 20% du score quand la géoloc arrivera.

Refactor hub : helper partagé firstFitStart (assign-job + backfill).
Nettoyage : retrait code mort (onDeleteRosterTag, projUsedH), carte des sections
en tête de PlanificationPage. Doc : docs/features/roster.md + index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:50:17 -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-orange-9 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>