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>
146 lines
9.5 KiB
Vue
146 lines
9.5 KiB
Vue
<template>
|
|
<q-dialog v-model="open">
|
|
<q-card style="min-width:460px;max-width:96vw">
|
|
<q-card-section class="row items-center q-pb-none">
|
|
<q-icon name="confirmation_number" color="positive" size="22px" class="q-mr-sm" />
|
|
<div class="text-subtitle1 text-weight-bold">Nouveau ticket</div>
|
|
<q-space />
|
|
<q-btn flat round dense icon="close" v-close-popup />
|
|
</q-card-section>
|
|
<q-card-section class="q-gutter-sm">
|
|
<!-- Sujet en langage naturel : tape le problème + nom / adresse / téléphone -->
|
|
<div style="position:relative">
|
|
<q-input dense outlined v-model="title" type="textarea" autogrow :rows="2" autofocus
|
|
label="Décris le problème (ex. « Problème wifi 2338 rue Ste-Clotilde » ou un nom / téléphone)"
|
|
@update:model-value="onSearch">
|
|
<template #append><q-spinner v-if="searching" size="16px" color="positive" /></template>
|
|
</q-input>
|
|
<div v-if="results.length && !customer" class="nt-dd">
|
|
<div v-for="m in results" :key="m.name" class="nt-item" @click="pick(m)">
|
|
<q-icon :name="m.matched === 'adresse' ? 'place' : m.matched === 'téléphone' ? 'call' : 'person'" size="16px" color="positive" class="q-mr-sm" />
|
|
<div class="col ellipsis">
|
|
<div class="text-body2 ellipsis">{{ m.customer_name || m.name }}<q-badge v-if="m.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></div>
|
|
<div class="text-caption text-grey-6 ellipsis">{{ m.address || m.territory || m.name }}</div>
|
|
</div>
|
|
<q-badge color="green-1" text-color="green-9" :label="m.matched" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Client résolu -->
|
|
<div v-if="customer">
|
|
<div class="row items-center">
|
|
<q-chip dense color="blue-1" text-color="blue-9" icon="person" removable @remove="clearCustomer">{{ customerName }}</q-chip>
|
|
<span class="text-caption text-grey-6">compte lié</span>
|
|
</div>
|
|
<!-- Adresse de service (compte multi-adresses) → on lie la BONNE, pas la facturation -->
|
|
<q-select v-if="serviceLocs.length > 1" dense outlined v-model="serviceLoc" :options="serviceLocs" option-label="address" option-value="name" emit-value map-options clearable
|
|
label="Adresse de service" class="q-mt-xs">
|
|
<template #prepend><q-icon name="place" color="warning" /></template>
|
|
</q-select>
|
|
<div v-else-if="serviceLoc && serviceLocs.length === 1" class="text-caption text-grey-7 q-mt-xs"><q-icon name="place" size="13px" color="warning" /> {{ serviceLocs[0].address }}</div>
|
|
</div>
|
|
<div v-else class="text-caption text-grey-5"><q-icon name="info" size="14px" /> Tape un nom, une adresse ou un numéro → l'autosuggest lie le bon compte (optionnel).</div>
|
|
|
|
<!-- Départements probables par mots-clés (pas besoin de fouiller la liste) -->
|
|
<div v-if="suggestions.length" class="row items-center q-gutter-xs">
|
|
<span class="text-caption text-grey-6">Probable :</span>
|
|
<q-chip v-for="s in suggestions" :key="s.category" dense clickable :icon="s.icon"
|
|
:color="category === s.category ? s.color : 'grey-3'" :text-color="category === s.category ? 'white' : 'grey-8'"
|
|
@click="setCategory(s.category)">{{ s.category }}<q-tooltip>détecté : « {{ s.keyword }} »</q-tooltip></q-chip>
|
|
</div>
|
|
<div class="row q-col-gutter-sm">
|
|
<q-select class="col" dense outlined v-model="category" :options="categories" label="Catégorie" @update:model-value="touched = true" />
|
|
<q-select class="col" dense outlined v-model="priority" :options="['Low','Medium','High','Urgent']" label="Priorité" />
|
|
</div>
|
|
<div class="text-caption text-grey-6" v-if="queueFor(category)">→ routé à l'équipe <b>{{ queueFor(category) }}</b></div>
|
|
<div class="row q-col-gutter-sm items-center">
|
|
<q-btn-toggle class="col-auto" v-model="status" dense unelevated size="sm" toggle-color="primary" color="grey-3" text-color="grey-8" no-caps :options="[{ label: 'Ouvert', value: 'Open' }, { label: 'En attente', value: 'On Hold' }]" />
|
|
<q-input class="col" dense outlined v-model="dueDate" type="date" stack-label :label="status === 'On Hold' ? 'Rappel — réactiver à cette date' : 'Échéance (optionnel)'" />
|
|
</div>
|
|
<div v-if="status === 'On Hold'" class="text-caption text-warning"><q-icon name="pause_circle" size="13px" /> Suspendu (ex. en attente du paiement) — réapparaît à la date de rappel.</div>
|
|
</q-card-section>
|
|
<q-card-actions align="right">
|
|
<q-btn flat label="Annuler" v-close-popup />
|
|
<q-btn unelevated color="positive" icon="confirmation_number" label="Créer le ticket" :disable="!title.trim()" :loading="busy" @click="submit" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch, computed } from 'vue'
|
|
import { useQuasar } from 'quasar'
|
|
import { HUB_URL } from 'src/config/hub'
|
|
import { useConversations } from 'src/composables/useConversations'
|
|
import { suggestDepartments } from 'src/config/departments'
|
|
|
|
const props = defineProps({ modelValue: Boolean })
|
|
const emit = defineEmits(['update:modelValue', 'created'])
|
|
const $q = useQuasar()
|
|
const { createStandaloneTicket } = useConversations()
|
|
|
|
const open = ref(false)
|
|
const title = ref('')
|
|
const category = ref('Support')
|
|
const priority = ref('Medium')
|
|
const customer = ref('')
|
|
const customerName = ref('')
|
|
const results = ref([])
|
|
const searching = ref(false)
|
|
const busy = ref(false)
|
|
const status = ref('Open')
|
|
const dueDate = ref('')
|
|
const touched = ref(false) // l'agent a-t-il choisi la catégorie manuellement ?
|
|
let _t = null
|
|
const categories = ['Support', 'Facturation', 'Installation', 'Fibre', 'Télévision', 'Téléphonie', 'Commercial', 'Autre']
|
|
const QUEUE_OF = { Support: 'Supports', Facturation: 'Facturations', Commercial: 'Service à la clientèle', Installation: 'Technicien', Fibre: 'Technicien', Télévision: 'Technicien', Téléphonie: 'Technicien' }
|
|
function queueFor (c) { return QUEUE_OF[c] || '' }
|
|
// Départements probables (mots-clés, live) → pré-sélection du plus probable tant que l'agent n'a pas choisi.
|
|
const suggestions = computed(() => suggestDepartments(title.value).slice(0, 3))
|
|
watch(suggestions, list => { if (!touched.value && list.length) category.value = list[0].category })
|
|
function setCategory (c) { category.value = c; touched.value = true }
|
|
|
|
watch(() => props.modelValue, v => { open.value = v; if (v) reset() })
|
|
watch(open, v => emit('update:modelValue', v))
|
|
const serviceLoc = ref('') // adresse de service liée (Service Location)
|
|
const serviceLocs = ref([]) // adresses du compte (multi-adresses)
|
|
function reset () { title.value = ''; category.value = 'Support'; priority.value = 'Medium'; customer.value = ''; customerName.value = ''; results.value = []; touched.value = false; status.value = 'Open'; dueDate.value = ''; serviceLoc.value = ''; serviceLocs.value = [] }
|
|
function clearCustomer () { customer.value = ''; customerName.value = ''; serviceLoc.value = ''; serviceLocs.value = [] }
|
|
function pick (m) {
|
|
customer.value = m.name; customerName.value = m.customer_name || m.name; results.value = []
|
|
serviceLoc.value = m.service_location || '' // adresse matchée → présélection
|
|
fetch(`${HUB_URL}/collab/service-locations?customer=${encodeURIComponent(m.name)}`).then(r => r.ok ? r.json() : { locations: [] }).then(d => {
|
|
serviceLocs.value = d.locations || []
|
|
if (!serviceLoc.value && serviceLocs.value.length) serviceLoc.value = serviceLocs.value[0].name // pré-sélectionne l'adresse de service active la plus récente (triée en 1re par le hub)
|
|
}).catch(() => { serviceLocs.value = [] })
|
|
}
|
|
|
|
function onSearch (v) {
|
|
clearTimeout(_t)
|
|
if (customer.value) return // déjà lié
|
|
const q = String(v || '').trim()
|
|
if (q.length < 3) { results.value = []; return }
|
|
_t = setTimeout(async () => {
|
|
searching.value = true
|
|
try { const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}`); if (r.ok) { const d = await r.json(); results.value = d.matches || [] } } catch (e) { results.value = [] } finally { searching.value = false }
|
|
}, 350)
|
|
}
|
|
|
|
async function submit () {
|
|
if (!title.value.trim()) return
|
|
busy.value = true
|
|
try {
|
|
const d = await createStandaloneTicket({ title: title.value.trim(), category: category.value, priority: priority.value, customer: customer.value || undefined, customer_name: customerName.value || undefined, service_location: serviceLoc.value || undefined, queue: queueFor(category.value) || undefined, status: status.value, due_date: dueDate.value || undefined })
|
|
if (d.ok) { $q.notify({ type: 'positive', message: `Ticket ${d.name} créé${customerName.value ? ' · ' + customerName.value : ''}${queueFor(category.value) ? ' → ' + queueFor(category.value) : ''}` }); emit('created', d); open.value = false }
|
|
else $q.notify({ type: 'negative', message: d.error || 'Échec' })
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { busy.value = false }
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.nt-dd { position: absolute; z-index: 30; left: 0; right: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.12); margin-top: 2px; max-height: 240px; overflow-y: auto; }
|
|
.nt-item { display: flex; align-items: center; padding: 8px 10px; cursor: pointer; border-bottom: 1px solid #f5f7fa; }
|
|
.nt-item:hover { background: #f0fdfa; }
|
|
</style>
|