Ops: page Copilote dispatch (chat + voix + sélecteur de politique)
Nouvelle page /copilote : chat texte/voix (Web Speech API fr-CA) vers le copilote Gemini Flash (impact d'absence + propositions de réassignation), + sélecteur de politique de reprise (réassign/SMS/escalade) persistée. Route + nav (icône Sparkles ; ajout CalendarRange/CalendarClock manquantes dans la map d'icônes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d1bd268a32
commit
79d160b9f1
|
|
@ -37,6 +37,11 @@ export const generate = (start, days = 7, weights) => jpost('/roster/generate',
|
|||
export const publish = (assignments) => jpost('/roster/publish', { assignments })
|
||||
export const publishWeek = (start, days, assignments, notify) => jpost('/roster/publish-week', { start, days, assignments, notify })
|
||||
export const updateTemplate = (name, patch) => jput('/roster/template/' + encodeURIComponent(name), patch)
|
||||
|
||||
// ── Copilote (Gemini Flash) + politique de reprise ──
|
||||
export const askAssistant = (message, history) => jpost('/roster/assistant', { message, history })
|
||||
export const getPolicy = () => jget('/roster/policy')
|
||||
export const savePolicy = (policy) => jpost('/roster/policy', policy)
|
||||
export async function deleteShiftTemplate (name) {
|
||||
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const navItems = [
|
|||
{ 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: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' },
|
||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
|
||||
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
||||
|
|
|
|||
|
|
@ -124,12 +124,13 @@ import { navItems as allNavItems } from 'src/config/nav'
|
|||
import {
|
||||
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
||||
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
|
||||
CalendarRange, CalendarClock, Sparkles,
|
||||
} from 'lucide-vue-next'
|
||||
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
||||
import { useConversations } from 'src/composables/useConversations'
|
||||
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
||||
|
||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail }
|
||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles }
|
||||
|
||||
const { panelOpen, activeCount: convCount } = useConversations()
|
||||
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
|
||||
|
|
|
|||
122
apps/ops/src/pages/CopilotePage.vue
Normal file
122
apps/ops/src/pages/CopilotePage.vue
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<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>
|
||||
|
||||
<!-- 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 } 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)
|
||||
|
||||
onMounted(async () => {
|
||||
loadingPolicy.value = true
|
||||
try {
|
||||
const d = await getPolicy()
|
||||
Object.assign(policy, d.policy || {})
|
||||
opts.reschedule = d.options?.reschedule || []
|
||||
opts.escalation = d.options?.escalation || []
|
||||
} catch (e) { /* defaults */ }
|
||||
loadingPolicy.value = false
|
||||
})
|
||||
|
||||
async function doSavePolicy () {
|
||||
savingPolicy.value = true; policySaved.value = false
|
||||
try { await savePolicy({ ...policy }); policySaved.value = true; setTimeout(() => { policySaved.value = false }, 2500) }
|
||||
catch (e) { /* ignore */ }
|
||||
savingPolicy.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>
|
||||
|
|
@ -40,6 +40,7 @@ const routes = [
|
|||
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
||||
{ path: 'planification', component: () => import('src/pages/PlanificationPage.vue') },
|
||||
{ path: 'rdv', component: () => import('src/pages/RendezVousPage.vue') },
|
||||
{ path: 'copilote', component: () => import('src/pages/CopilotePage.vue') },
|
||||
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
||||
{ path: 'network', component: () => import('src/pages/NetworkPage.vue') },
|
||||
// Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user