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:
parent
788d969090
commit
c4c4cc6575
|
|
@ -100,7 +100,7 @@ onMounted(() => { load(); refreshTimer = setInterval(load, 60000); document.addE
|
||||||
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); document.removeEventListener('visibilitychange', onVisible) })
|
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); document.removeEventListener('visibilitychange', onVisible) })
|
||||||
|
|
||||||
const dash = (v) => (loading.value && v == null) ? '…' : (v == null ? '—' : v)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,21 @@
|
||||||
<q-tooltip>{{ micSupported ? (listening ? 'Arrêter la dictée' : 'Dicter à voix') : 'Dictée non supportée ici' }}</q-tooltip>
|
<q-tooltip>{{ micSupported ? (listening ? 'Arrêter la dictée' : 'Dicter à voix') : 'Dictée non supportée ici' }}</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</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">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
@ -143,12 +158,41 @@ const props = defineProps({
|
||||||
planEndpoint: { type: String, default: '/collab/orchestrate' },
|
planEndpoint: { type: String, default: '/collab/orchestrate' },
|
||||||
runEndpoint: { type: String, default: '/collab/orchestrate/run' },
|
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é)
|
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 emit = defineEmits(['update:modelValue', 'done'])
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const text = ref('')
|
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 actions = ref([])
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
const reply = ref('')
|
const reply = ref('')
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@
|
||||||
<!-- ⌘K langage naturel → copilote STAFF dispatch/planification (function-calling ; plan → confirmer → exécuter).
|
<!-- ⌘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). -->
|
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"
|
<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 -->
|
<!-- Statut service+modem par identifiant libre (adresse / nom / téléphone / courriel) — lecture -->
|
||||||
<ServiceStatusDialog v-if="can('view_clients')" v-model="serviceStatusOpen" />
|
<ServiceStatusDialog v-if="can('view_clients')" v-model="serviceStatusOpen" />
|
||||||
<!-- Tampon d'envoi : courriels multi-destinataires retenus + compte à rebours + annuler (anti-spam) -->
|
<!-- Tampon d'envoi : courriels multi-destinataires retenus + compte à rebours + annuler (anti-spam) -->
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user