gigafibre-fsm/apps/ops/src/components/dispatch/NlpInput.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

167 lines
6.4 KiB
Vue

<template>
<div class="nlp-input-bar" :class="{ 'nlp-expanded': showResult }">
<div class="nlp-row">
<q-icon name="auto_awesome" size="18px" color="green-5" class="q-mr-xs" />
<input
ref="inputRef"
v-model="text"
class="nlp-text"
:placeholder="placeholder"
@keydown.enter="submit"
@keydown.escape="clear"
:disabled="loading"
/>
<q-spinner v-if="loading" size="16px" color="green-5" class="q-mx-xs" />
<q-btn v-else-if="text.trim()" flat dense round size="sm" icon="send" color="primary" @click="submit" />
<q-btn v-if="showResult" flat dense round size="xs" icon="close" color="grey-6" @click="clear" />
</div>
<transition name="nlp-slide">
<div v-if="showResult && result" class="nlp-result">
<div class="nlp-result-action">
<q-icon :name="actionIcon" :color="actionColor" size="16px" class="q-mr-xs" />
<span class="text-weight-bold">{{ result.action_label || result.action }}</span>
<q-badge v-if="result.confidence" :color="result.confidence > 0.8 ? 'green' : result.confidence > 0.5 ? 'orange' : 'red'" class="q-ml-sm">
{{ Math.round(result.confidence * 100) }}%
</q-badge>
</div>
<div v-if="result.explanation" class="text-caption text-grey-7 q-mt-xs">{{ result.explanation }}</div>
<!-- Action-specific details -->
<div v-if="result.action === 'create_job'" class="nlp-details q-mt-sm">
<div v-if="result.subject" class="nlp-detail"><span class="nlp-label">Sujet:</span> {{ result.subject }}</div>
<div v-if="result.tech" class="nlp-detail"><span class="nlp-label">Tech:</span> {{ result.tech }}</div>
<div v-if="result.date" class="nlp-detail"><span class="nlp-label">Date:</span> {{ result.date }}</div>
<div v-if="result.time" class="nlp-detail"><span class="nlp-label">Heure:</span> {{ result.time }}</div>
<div v-if="result.duration" class="nlp-detail"><span class="nlp-label">Durée:</span> {{ result.duration }}h</div>
<div v-if="result.address" class="nlp-detail"><span class="nlp-label">Adresse:</span> {{ result.address }}</div>
</div>
<div v-if="result.action === 'move_job'" class="nlp-details q-mt-sm">
<div v-if="result.from_tech" class="nlp-detail"><span class="nlp-label">De:</span> {{ result.from_tech }}</div>
<div v-if="result.to_tech" class="nlp-detail"><span class="nlp-label">Vers:</span> {{ result.to_tech }}</div>
</div>
<div v-if="result.action === 'redistribute'" class="nlp-details q-mt-sm">
<div v-if="result.absent_tech" class="nlp-detail"><span class="nlp-label">Absent:</span> {{ result.absent_tech }}</div>
</div>
<div class="nlp-actions q-mt-sm">
<q-btn v-if="result.action !== 'query' && result.action !== 'unknown'" dense no-caps unelevated
color="primary" size="sm" icon="check" label="Appliquer" @click="apply" :loading="applying" />
<q-btn flat dense no-caps size="sm" label="Ignorer" color="grey-6" @click="clear" />
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useQuasar } from 'quasar'
const props = defineProps({
techNames: { type: Array, default: () => [] },
})
const emit = defineEmits(['action', 'applied'])
const $q = useQuasar()
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
const inputRef = ref(null)
const text = ref('')
const loading = ref(false)
const applying = ref(false)
const result = ref(null)
const showResult = computed(() => !!result.value)
const placeholder = 'Ex: "Mets une install chez 123 rue Laval demain 9h pour Marc" ou "Marc est malade, redistribue"...'
const ACTION_ICONS = {
create_job: 'add_task',
move_job: 'swap_horiz',
redistribute: 'group_work',
cancel_job: 'cancel',
query: 'search',
unknown: 'help_outline',
}
const ACTION_COLORS = {
create_job: 'primary',
move_job: 'blue-6',
redistribute: 'orange-8',
cancel_job: 'red',
query: 'grey-7',
unknown: 'grey-5',
}
const actionIcon = computed(() => ACTION_ICONS[result.value?.action] || 'help_outline')
const actionColor = computed(() => ACTION_COLORS[result.value?.action] || 'grey-5')
async function submit () {
const input = text.value.trim()
if (!input || loading.value) return
loading.value = true
result.value = null
try {
const res = await fetch(`${HUB_URL}/ai/dispatch-nlp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: input, tech_names: props.techNames }),
})
if (!res.ok) throw new Error('Erreur AI')
const data = await res.json()
result.value = data
emit('action', data)
} catch (e) {
$q.notify({ type: 'negative', message: `NLP: ${e.message}` })
} finally {
loading.value = false
}
}
function apply () {
if (!result.value) return
applying.value = true
emit('applied', result.value)
// Parent handles the actual dispatch action
setTimeout(() => {
applying.value = false
clear()
}, 500)
}
function clear () {
result.value = null
text.value = ''
inputRef.value?.focus()
}
defineExpose({ focus: () => inputRef.value?.focus() })
</script>
<style scoped>
.nlp-input-bar {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
transition: all 0.2s ease;
}
.nlp-input-bar:focus-within { border-color: #818cf8; box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.15); }
.nlp-expanded { background: #fff; }
.nlp-row { display: flex; align-items: center; padding: 6px 10px; }
.nlp-text {
flex: 1; border: none; outline: none; background: transparent;
font-size: 0.84rem; color: #1e293b; font-family: inherit;
}
.nlp-text::placeholder { color: #94a3b8; }
.nlp-result { padding: 8px 12px; border-top: 1px solid #e2e8f0; }
.nlp-result-action { display: flex; align-items: center; }
.nlp-details { display: flex; flex-wrap: wrap; gap: 4px 12px; }
.nlp-detail { font-size: 0.78rem; color: #475569; }
.nlp-label { font-weight: 600; color: #94a3b8; font-size: 0.72rem; }
.nlp-actions { display: flex; gap: 8px; }
.nlp-slide-enter-active, .nlp-slide-leave-active { transition: max-height 0.2s ease, opacity 0.15s ease; overflow: hidden; }
.nlp-slide-enter-from, .nlp-slide-leave-to { max-height: 0; opacity: 0; }
.nlp-slide-enter-to, .nlp-slide-leave-from { max-height: 300px; opacity: 1; }
</style>