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

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>