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>
208 lines
13 KiB
Vue
208 lines
13 KiB
Vue
<template>
|
|
<q-dialog v-model="open" @hide="reset">
|
|
<q-card style="min-width:540px;max-width:96vw">
|
|
<q-card-section class="row items-center q-pb-none">
|
|
<q-icon name="wifi_find" color="info" size="22px" class="q-mr-sm" />
|
|
<div class="text-subtitle1 text-weight-bold">Statut du service</div>
|
|
<q-space />
|
|
<q-btn flat round dense icon="close" v-close-popup />
|
|
</q-card-section>
|
|
|
|
<q-card-section>
|
|
<!-- Recherche par n'importe quel identifiant -->
|
|
<q-input dense outlined v-model="query" autofocus clearable
|
|
label="Adresse, nom, téléphone ou courriel"
|
|
@update:model-value="onSearch" @keyup.enter.exact.prevent="onSearch(query, true)">
|
|
<template #prepend><q-icon name="search" /></template>
|
|
<template #append><q-spinner v-if="searching" size="16px" color="info" /></template>
|
|
</q-input>
|
|
<div class="text-caption text-grey-5 q-mt-xs"><q-icon name="info" size="13px" /> Ex. « 2338 Ste-Clotilde », « Jardins Lefort », un cellulaire ou un courriel — comme pour créer un ticket.</div>
|
|
|
|
<!-- Plusieurs comptes → on choisit -->
|
|
<div v-if="candidates.length" class="q-mt-md">
|
|
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">Plusieurs comptes — choisis :</div>
|
|
<q-list bordered separator class="rounded-borders">
|
|
<q-item v-for="m in candidates" :key="m.name" clickable @click="pickCustomer(m.name, m)">
|
|
<q-item-section avatar><q-icon :name="m.matched === 'adresse' ? 'place' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person'" color="info" /></q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>{{ m.customer_name || m.name }}<q-badge v-if="m.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></q-item-label>
|
|
<q-item-label caption>{{ m.address || m.phone || m.email || m.name }}</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side><q-icon name="chevron_right" color="grey-5" /></q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</div>
|
|
|
|
<!-- Aucun résultat -->
|
|
<div v-else-if="searched && !data" class="q-mt-md text-grey-6 text-center q-pa-md">
|
|
<q-icon name="search_off" size="28px" color="grey-4" /><div class="text-caption q-mt-xs">Aucun compte trouvé pour « {{ lastQuery }} ».</div>
|
|
</div>
|
|
|
|
<!-- Carte statut -->
|
|
<div v-if="data" class="q-mt-md">
|
|
<!-- En-tête compte -->
|
|
<div class="row items-center no-wrap q-mb-sm">
|
|
<q-icon name="person" color="info" size="18px" class="q-mr-xs" />
|
|
<div class="col">
|
|
<div class="text-weight-bold ellipsis">{{ data.customer.customer_name }}</div>
|
|
<div class="text-caption text-grey-6 ellipsis">{{ [data.customer.phone, data.customer.email].filter(Boolean).join(' · ') || data.customer.name }}</div>
|
|
</div>
|
|
<!-- Bandeau global -->
|
|
<q-chip dense :color="statusColor(data.summary.online)" text-color="white" :icon="statusIcon(data.summary.online)" class="text-weight-medium">{{ data.summary.label }}</q-chip>
|
|
</div>
|
|
|
|
<!-- Adresse de service (compte multi-adresses → re-requête) -->
|
|
<q-select v-if="data.locations && data.locations.length > 1" dense outlined v-model="selLoc" :options="data.locations" option-label="address" option-value="name" emit-value map-options
|
|
label="Adresse de service" class="q-mb-sm" @update:model-value="pickLocation">
|
|
<template #prepend><q-icon name="place" color="warning" /></template>
|
|
<template #option="s">
|
|
<q-item v-bind="s.itemProps"><q-item-section>
|
|
<q-item-label>{{ s.opt.address }}</q-item-label>
|
|
<q-item-label caption>{{ s.opt.active ? '● service actif' : s.opt.status || 'inactif' }}</q-item-label>
|
|
</q-item-section></q-item>
|
|
</template>
|
|
</q-select>
|
|
<div v-else-if="data.location" class="text-caption text-grey-7 q-mb-sm"><q-icon name="place" size="13px" color="warning" /> {{ data.location.address || data.location.name }}</div>
|
|
|
|
<!-- Abonnement(s) service -->
|
|
<div v-if="data.subscriptions && data.subscriptions.length" class="q-mb-sm">
|
|
<div v-for="s in data.subscriptions" :key="s.name" class="row items-center q-gutter-xs q-mb-xs">
|
|
<q-icon name="router" size="15px" color="grey-7" />
|
|
<span class="text-body2">{{ s.plan || s.name }}</span>
|
|
<q-badge :color="subColor(s.status)" text-color="white" :label="s.status || '—'" />
|
|
<span v-if="s.start" class="text-caption text-grey-5">depuis {{ s.start }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Équipement(s) : statut fibre + TR-069 -->
|
|
<div v-if="data.devices && data.devices.length">
|
|
<div v-for="d in data.devices" :key="d.serial || d.mac" class="ss-dev q-mb-xs">
|
|
<div class="row items-center no-wrap">
|
|
<q-icon :name="statusIcon(d.online)" :color="statusColor(d.online)" size="18px" class="q-mr-sm" />
|
|
<div class="col">
|
|
<div class="text-body2 text-weight-medium ellipsis">{{ d.model || d.brand || d.serial || 'Équipement' }}</div>
|
|
<div class="text-caption text-grey-6 ellipsis">{{ d.serial }}<span v-if="d.detail"> · {{ d.detail }}</span></div>
|
|
</div>
|
|
<div class="text-right">
|
|
<q-chip dense :color="statusColor(d.online)" text-color="white" class="q-ma-none">{{ d.label }}</q-chip>
|
|
<div v-if="d.minutesAgo != null" class="text-caption text-grey-5">{{ fmtAgo(d.minutesAgo) }}</div>
|
|
</div>
|
|
</div>
|
|
<!-- métriques -->
|
|
<div class="row q-gutter-xs q-mt-xs">
|
|
<q-chip v-if="d.signal" dense square size="sm" :color="signalColor(d.signal)" text-color="white" icon="network_check">{{ d.signal }}<span v-if="d.rxPower != null" class="q-ml-xs">{{ d.rxPower }} dBm</span></q-chip>
|
|
<q-chip v-if="d.wifiClients != null" dense square size="sm" color="grey-3" text-color="grey-8" icon="devices">{{ d.wifiClients }} Wi-Fi</q-chip>
|
|
<q-chip v-if="d.oltName" dense square size="sm" color="grey-3" text-color="grey-8" icon="dns">{{ d.oltName }}<span v-if="d.oltPort != null" class="q-ml-xs">· p{{ d.oltPort }}</span></q-chip>
|
|
<q-chip v-if="d.ip" dense square size="sm" color="grey-3" text-color="grey-8" icon="lan">{{ d.ip }}</q-chip>
|
|
<q-chip v-if="d.offlineCause" dense square size="sm" color="red-1" text-color="red-9" icon="warning">{{ d.offlineCause }}</q-chip>
|
|
</div>
|
|
<!-- DoStuff (n8n) : statut LIVE de l'OLT — lent (~30 s), à la demande -->
|
|
<div v-if="d.serial && d.olt" class="q-mt-xs">
|
|
<q-btn v-if="!d._live && !d._liveLoading" dense flat size="sm" no-caps icon="bolt" color="deep-purple-6" label="Vérifier en direct (F)" @click="dostuff(d)">
|
|
<q-tooltip>Interroge l'OLT en direct via n8n (comme F) — ~30 s</q-tooltip>
|
|
</q-btn>
|
|
<div v-if="d._liveLoading" class="text-caption text-deep-purple-6 q-py-xs"><q-spinner-dots size="18px" /> Interrogation de l'OLT… (~30 s)</div>
|
|
<div v-if="d._live" class="ss-live">
|
|
<div class="row items-center no-wrap">
|
|
<q-icon :name="d._live.ok ? (d._live.online ? 'check_circle' : 'cancel') : 'info'" :color="d._live.ok ? statusColor(d._live.online) : 'grey-5'" size="15px" class="q-mr-xs" />
|
|
<b class="text-caption">Direct (F)</b>
|
|
<span class="text-caption q-ml-xs" :class="d._live.ok ? '' : 'text-grey-6'">{{ d._live.ok ? d._live.label : d._live.error }}</span>
|
|
<q-space />
|
|
<q-btn dense flat round size="xs" icon="refresh" color="grey-6" @click="dostuff(d)"><q-tooltip>Réinterroger</q-tooltip></q-btn>
|
|
</div>
|
|
<div v-if="d._live.ok" class="row q-gutter-xs q-mt-xs">
|
|
<q-chip v-if="d._live.rxPower != null" dense square size="sm" :color="signalColor(d._live.signal)" text-color="white" icon="network_check">{{ d._live.signal }} · Rx {{ d._live.rxPower }}<span v-if="d._live.txPower != null"> / Tx {{ d._live.txPower }}</span> dBm</q-chip>
|
|
<q-chip v-if="d._live.distance != null" dense square size="sm" color="grey-3" text-color="grey-8" icon="straighten">{{ d._live.distance }} m</q-chip>
|
|
<q-chip v-if="d._live.uptime" dense square size="sm" color="grey-3" text-color="grey-8" icon="schedule">{{ d._live.uptime }}</q-chip>
|
|
<q-chip v-if="d._live.lastDown" dense square size="sm" color="amber-1" text-color="amber-9" icon="history">{{ d._live.lastDown }}</q-chip>
|
|
<q-chip v-if="d._live.managementIp" dense square size="sm" clickable color="blue-1" text-color="blue-9" icon="router" @click="openMgmt(d._live.managementIp)">GUI {{ d._live.managementIp }}</q-chip>
|
|
<q-chip v-if="d._live.wanIp" dense square size="sm" color="grey-3" text-color="grey-8" icon="public">WAN {{ d._live.wanIp }}</q-chip>
|
|
<q-chip v-if="d._live.firmware" dense square size="sm" color="grey-3" text-color="grey-8" icon="memory">{{ d._live.firmware }}</q-chip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-caption text-grey-6 q-pa-sm bg-grey-2 rounded-borders"><q-icon name="info" size="14px" /> {{ data.summary.detail }}</div>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch } from 'vue'
|
|
import { useQuasar } from 'quasar'
|
|
import { HUB_URL } from 'src/config/hub'
|
|
|
|
const props = defineProps({ modelValue: Boolean })
|
|
const emit = defineEmits(['update:modelValue'])
|
|
const $q = useQuasar()
|
|
|
|
const open = ref(false)
|
|
const query = ref('')
|
|
const lastQuery = ref('')
|
|
const searching = ref(false)
|
|
const searched = ref(false)
|
|
const candidates = ref([])
|
|
const data = ref(null)
|
|
const selLoc = ref('')
|
|
let _t = null
|
|
|
|
watch(() => props.modelValue, v => { open.value = v; if (v) reset() })
|
|
watch(open, v => emit('update:modelValue', v))
|
|
function reset () { query.value = ''; lastQuery.value = ''; candidates.value = []; data.value = null; searched.value = false; selLoc.value = ''; clearTimeout(_t) }
|
|
|
|
function onSearch (v, now = false) {
|
|
clearTimeout(_t)
|
|
const q = String(v || '').trim()
|
|
candidates.value = []; data.value = null; searched.value = false
|
|
if (q.length < 3) return
|
|
_t = setTimeout(() => fetchStatus({ q }), now ? 0 : 400)
|
|
}
|
|
|
|
async function fetchStatus (params) {
|
|
searching.value = true
|
|
lastQuery.value = params.q || lastQuery.value
|
|
try {
|
|
const usp = new URLSearchParams()
|
|
for (const [k, val] of Object.entries(params)) if (val) usp.set(k, val)
|
|
const r = await fetch(`${HUB_URL}/collab/service-status?${usp}`)
|
|
const d = await r.json()
|
|
searched.value = true
|
|
if (!d.ok) { $q.notify({ type: 'negative', message: d.error || 'Échec' }); return }
|
|
if (d.ambiguous) { candidates.value = d.matches || []; data.value = null; return }
|
|
if (!d.found) { candidates.value = []; data.value = null; return }
|
|
candidates.value = []
|
|
data.value = d
|
|
selLoc.value = d.location ? d.location.name : ''
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { searching.value = false }
|
|
}
|
|
|
|
function pickCustomer (name, m) { lastQuery.value = (m && m.customer_name) || name; fetchStatus({ customer: name }) }
|
|
function pickLocation (loc) { if (data.value) fetchStatus({ customer: data.value.customer.name, location: loc }) }
|
|
|
|
function fmtAgo (min) { if (min == null) return ''; if (min < 1) return "à l'instant"; if (min < 60) return `il y a ${min} min`; const h = Math.floor(min / 60); return h < 24 ? `il y a ${h} h` : `il y a ${Math.floor(h / 24)} j` }
|
|
function statusColor (o) { return o === true ? 'green-6' : o === false ? 'red-6' : 'grey-5' }
|
|
function statusIcon (o) { return o === true ? 'check_circle' : o === false ? 'cancel' : 'help' }
|
|
function signalColor (s) { return s === 'excellent' || s === 'bon' ? 'green-6' : s === 'faible' ? 'orange-7' : s === 'critique' ? 'red-6' : 'grey-5' }
|
|
function subColor (s) { const x = String(s || '').toLowerCase(); return x.includes('actif') ? 'green-6' : x.includes('suspend') ? 'orange-7' : 'grey-5' }
|
|
|
|
// DoStuff (n8n) : statut LIVE de l'OLT — ~30 s, à la demande (réservé OLT tech=3 ; sinon « OLT non reconnue » → le statut ci-dessus suffit).
|
|
async function dostuff (d) {
|
|
if (!d.serial || !d.olt) return
|
|
d._live = null; d._liveLoading = true
|
|
try {
|
|
const r = await fetch(`${HUB_URL}/collab/dostuff?serial=${encodeURIComponent(d.serial)}&olt=${encodeURIComponent(d.olt)}`)
|
|
const res = await r.json()
|
|
if (!res.ok && res.recognized === false) res.error = 'OLT suivie par notre sonde (statut ci-dessus) — direct réservé aux OLT XGS-PON'
|
|
d._live = res
|
|
} catch (e) { d._live = { ok: false, error: e.message } } finally { d._liveLoading = false }
|
|
}
|
|
function openMgmt (ip) { if (ip) window.open('https://' + ip + '/', '_blank', 'noopener') }
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ss-dev { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; background: #f7fafc; }
|
|
</style>
|