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>
167 lines
6.4 KiB
Vue
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>
|