feat(ops): « Allouer un technicien » = chat IA (Gemini) avec typeahead client/adresse

L'action ouvrait déjà le copilote Gemini (staff-agent) mais sans autocomplétion → il fallait taper le client exactement.
Ajout d'un typeahead d'ENTITÉ dans le chat (OrchestratorDialog, prop entity-search) : on tape → suggestions live
(nom · adresse · téléphone · courriel via /collab/customer-search) → le choix INJECTE l'entité RÉSOLUE « client <id>
(<nom> — <adresse>) » dans la demande, que Gemini extrait pour agir (proposer_rdv_client / assign_tech). Activé sur le
copilote STAFF (MainLayout entity-search) ; graine « Allouer un technicien : » (MonAujourdhui). Séparateur propre après « : ».
Vérifié en dev : « tremblay » → 8 suggestions → choix → prompt « Allouer un technicien : client C-SYLVT… (Tremblay…) » ;
endpoint /collab/customer-search OK (12 matches) ; 0 erreur console. Build OK, leak-clean, déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 20:30:48 -04:00
parent 788d969090
commit c4c4cc6575
3 changed files with 46 additions and 2 deletions

View File

@ -100,7 +100,7 @@ onMounted(() => { load(); refreshTimer = setInterval(load, 60000); document.addE
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); document.removeEventListener('visibilitychange', onVisible) })
const dash = (v) => (loading.value && v == null) ? '…' : (v == null ? '—' : v)
function allocate () { window.dispatchEvent(new CustomEvent('ops:assistant', { detail: { text: 'Allouer un technicien pour le client ' } })) }
function allocate () { window.dispatchEvent(new CustomEvent('ops:assistant', { detail: { text: 'Allouer un technicien : ' } })) }
</script>
<template>

View File

@ -18,6 +18,21 @@
<q-tooltip>{{ micSupported ? (listening ? 'Arrêter la dictée' : 'Dicter à voix') : 'Dictée non supportée ici' }}</q-tooltip>
</q-btn>
</div>
<!-- Typeahead client / adresse : tape suggestions (nom, adresse, tél., courriel) insère l'entité dans la demande -->
<q-select v-if="entitySearch" dense outlined use-input hide-dropdown-icon input-debounce="200"
v-model="entityPick" :options="entityOptions" @filter="filterEntities" @update:model-value="onPickEntity"
option-label="label" clearable class="q-mt-sm"
label="Ajouter un client / une adresse — recherche (nom · adresse · téléphone · courriel)">
<template #prepend><q-icon name="person_search" color="deep-purple-5" /></template>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar><q-icon :name="scope.opt.icon" size="18px" color="deep-purple-5" /></q-item-section>
<q-item-section><q-item-label>{{ scope.opt.label }}</q-item-label><q-item-label caption>{{ scope.opt.caption }}</q-item-label></q-item-section>
</q-item>
</template>
<template #no-option><q-item><q-item-section class="text-grey-6">Tape au moins 2 lettres</q-item-section></q-item></template>
</q-select>
<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>
@ -143,12 +158,41 @@ const props = defineProps({
planEndpoint: { type: String, default: '/collab/orchestrate' },
runEndpoint: { type: String, default: '/collab/orchestrate/run' },
sendIdentity: { type: Boolean, default: false }, // joindre X-Authentik-Email (routes STAFF : agissent au nom de l'agent connecté)
entitySearch: { type: Boolean, default: false }, // typeahead client/adresse injecte l'entité résolue dans la demande (ex. « Allouer un technicien »)
})
const emit = defineEmits(['update:modelValue', 'done'])
const $q = useQuasar()
const open = ref(false)
const text = ref('')
// Typeahead entité (client/adresse) recherche floue hub (nom/adresse/téléphone/courriel) ; le choix INJECTE l'entité
// RÉSOLUE (id + nom + adresse) dans la demande l'assistant Gemini en extrait l'ID pour agir (proposer_rdv_client, etc.).
const entityPick = ref(null)
const entityOptions = ref([])
function filterEntities (val, update) {
const q = (val || '').trim()
if (q.length < 2) { update(() => { entityOptions.value = [] }); return }
update(async () => {
try {
const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}&team=1`, { headers: reqHeaders() })
const matches = r.ok ? ((await r.json()).matches || []) : []
entityOptions.value = matches.filter(m => m.kind !== 'équipe').slice(0, 8).map(m => ({
id: m.name, name: m.customer_name || m.name, address: m.address || '',
icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person',
label: m.customer_name || m.name,
caption: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name,
}))
} catch { entityOptions.value = [] }
})
}
function onPickEntity (opt) {
if (!opt) return
const frag = `client ${opt.id} (${opt.name}${opt.address ? ' — ' + opt.address : ''})`
const t = text.value.trim()
const sep = !t ? '' : (/[:,-]$/.test(t) ? ' ' : ' · ') // pas de « · » après un « : » ou une virgule
text.value = (t ? t + sep : '') + frag + ' '
nextTick(() => { entityPick.value = null; entityOptions.value = [] })
}
const actions = ref([])
const results = ref([])
const reply = ref('')

View File

@ -170,7 +170,7 @@
<!-- K langage naturel copilote STAFF dispatch/planification (function-calling ; plan confirmer exécuter).
MÊME composant réutilisé, branché sur le endpoint STAFF avec l'identité de l'agent (permissions serveur). -->
<OrchestratorDialog v-if="can('view_clients')" v-model="staffCmdOpen" :initial-text="staffCmdText"
title="Assistant Ops" plan-endpoint="/staff-agent" run-endpoint="/staff-agent/run" send-identity />
title="Assistant Ops" plan-endpoint="/staff-agent" run-endpoint="/staff-agent/run" send-identity entity-search />
<!-- Statut service+modem par identifiant libre (adresse / nom / téléphone / courriel) lecture -->
<ServiceStatusDialog v-if="can('view_clients')" v-model="serviceStatusOpen" />
<!-- Tampon d'envoi : courriels multi-destinataires retenus + compte à rebours + annuler (anti-spam) -->