fix(dispatch): GPS device row was misleading — clear linked-device chip + easy relink

The tech WAS associated (Stéphane → traccar_device_id "33" = device
"24 - Stephane M"), but the q-select's use-input placeholder ("non
associé — choisir l'appareil Traccar") kept showing in the filter input
even with a value selected, so it read as unassociated.

Redesigned the inline GPS row:
- Linked: a clear green chip with a 📍 pin + online dot showing the real
  device name (resolves traccar_device_id → Traccar name), a "Changer"
  menu (filter + list) to relink to ANOTHER vehicle, and a dissociate
  button. jdDevice computed; missing id → "#id — introuvable" (not a
  silent blank).
- Not linked: "non associé" + an auto-suggested device (jdDeviceSuggest:
  Traccar device whose name/uniqueId contains a word of the tech's name)
  one-click "Associer …", plus a "Choisir" picker menu.

Removed the confusing q-select + its filterDevices. Verified: Stéphane's
row now shows "📍 24 - Stephane M" (no "non associé"); Changer menu lists
all vehicles with a filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 20:56:27 -04:00
parent fc1f61eecf
commit 2177212a67

View File

@ -1620,19 +1620,29 @@
<q-btn dense flat no-caps size="sm" color="teal" icon="map" label="Carte" @click="openHomePicker(dayEditor.tech)"><q-tooltip>Choisir le domicile (point de départ) sur la carte</q-tooltip></q-btn>
<q-btn dense flat no-caps size="sm" color="indigo" icon="my_location" label="GPS actuel" :loading="liveGpsBusy" @click="setHomeFromLiveGps(dayEditor.tech)"><q-tooltip>Fixer le départ à la position GPS live actuelle du tech (Traccar)</q-tooltip></q-btn>
</div>
<!-- Appareil GPS (Traccar) du tech INLINE ; prérequis du « GPS actuel » ci-dessus + du tracé réel. (migré de Dispatch) -->
<!-- Appareil GPS (Traccar) du tech INLINE : chip clair de l'appareil relié (📍 + pastille en-ligne) OU « non associé » + relier facilement -->
<div v-if="dayEditor.tech" class="row items-center no-wrap q-px-sm q-pb-xs" style="gap:6px;font-size:12px;border-bottom:1px solid #eef2f7">
<q-icon name="gps_fixed" size="16px" :color="dayEditor.tech.traccar_device_id ? 'primary' : 'grey-4'" />
<q-icon name="gps_fixed" size="16px" :color="jdDevice ? 'primary' : 'grey-4'" />
<span class="text-grey-8 text-weight-medium">Appareil GPS&nbsp;:</span>
<q-select dense options-dense borderless class="col" style="min-width:0" :model-value="dayEditor.tech.traccar_device_id ? String(dayEditor.tech.traccar_device_id) : null" :options="deviceOptions" emit-value map-options use-input input-debounce="0" @filter="filterDevices" :loading="devicesLoading" clearable placeholder="non associé — choisir l'appareil Traccar" popup-content-style="min-width:260px" @update:model-value="v => onPickDevice(dayEditor.tech, v)">
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="scope.opt.online ? 'positive' : 'grey-4'" /></q-item-section>
<q-item-section><q-item-label>{{ scope.opt.label }}</q-item-label><q-item-label v-if="scope.opt.uniqueId" caption>{{ scope.opt.uniqueId }}</q-item-label></q-item-section>
</q-item>
<!-- RELIÉ : appareil clairement affiché + changer de véhicule + dissocier -->
<template v-if="jdDevice">
<q-chip dense :color="jdDevice.missing ? 'orange-1' : 'green-1'" :text-color="jdDevice.missing ? 'orange-9' : 'green-9'" icon="place" class="text-weight-medium">
<q-icon name="circle" size="8px" :color="jdDevice.online ? 'positive' : 'grey-5'" class="q-mr-xs" />{{ jdDevice.label }}
<q-tooltip>{{ jdDevice.online ? 'En ligne' : 'Hors ligne' }}{{ jdDevice.uniqueId ? ' · ' + jdDevice.uniqueId : '' }}</q-tooltip>
</q-chip>
<q-btn flat dense no-caps size="sm" color="indigo" icon="swap_horiz" label="Changer"><q-tooltip>Relier à un autre véhicule</q-tooltip>
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:270px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:44vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(dayEditor.tech, o.value)" :active="jdDevice && jdDevice.value === o.value" active-class="bg-green-1"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
</q-btn>
<q-btn flat dense round size="sm" icon="link_off" color="grey-6" @click="onPickDevice(dayEditor.tech, null)"><q-tooltip>Dissocier l'appareil GPS</q-tooltip></q-btn>
</template>
<!-- NON RELIÉ : suggestion auto (nom similaire) + choisir dans la liste -->
<template v-else>
<span class="text-grey-6">non associé</span>
<q-btn v-if="jdDeviceSuggest" dense unelevated size="sm" no-caps color="primary" icon="link" :label="'Associer ' + jdDeviceSuggest.label" @click="onPickDevice(dayEditor.tech, jdDeviceSuggest.value)"><q-tooltip>Appareil probable (nom similaire au tech) cliquer pour relier</q-tooltip></q-btn>
<q-btn flat dense no-caps size="sm" color="indigo" icon="add_location_alt" label="Choisir"><q-tooltip>Relier un véhicule Traccar</q-tooltip>
<q-menu anchor="bottom left" self="top left"><div class="q-pa-xs" style="min-width:270px"><q-input dense outlined v-model="deviceFilter" placeholder="Filtrer les véhicules…" clearable autofocus class="q-mb-xs" /><q-list dense style="max-height:44vh;overflow:auto"><q-item v-for="o in deviceOptions" :key="o.value" clickable v-close-popup @click="onPickDevice(dayEditor.tech, o.value)"><q-item-section avatar style="min-width:22px"><q-icon name="circle" size="9px" :color="o.online ? 'positive' : 'grey-4'" /></q-item-section><q-item-section><q-item-label>{{ o.label }}</q-item-label><q-item-label v-if="o.uniqueId" caption>{{ o.uniqueId }}</q-item-label></q-item-section></q-item><q-item v-if="!deviceOptions.length"><q-item-section class="text-grey-6">{{ devicesLoading ? 'Chargement' : 'Aucun appareil' }}</q-item-section></q-item></q-list></div></q-menu>
</q-btn>
</template>
<template #no-option><q-item><q-item-section class="text-grey-6">Aucun appareil Traccar</q-item-section></q-item></template>
</q-select>
</div>
<div v-if="!dayEditor.list.length" class="text-grey-6 q-pa-md text-center">Aucun job ce jour.</div>
<!-- liste éditable : glisser (souris) · (tactile) · 📌 figer 1er/dernier · durée en min · retirer -->
@ -2676,14 +2686,37 @@ async function loadTraccarDevices () {
devicesLoading.value = true
try { const d = await roster.listTraccarDevices(); traccarDevices.value = Array.isArray(d) ? d : (d && d.devices) || []; _devicesLoaded = true } catch (e) { /* non bloquant */ } finally { devicesLoading.value = false }
}
function filterDevices (val, update) { update(() => { deviceFilter.value = (val || '').toLowerCase() }) }
const deviceOptions = computed(() => {
const q = deviceFilter.value
const q = deviceFilter.value.toLowerCase().trim()
return traccarDevices.value
.map(d => ({ label: d.name || d.uniqueId || ('#' + d.id), value: String(d.id), uniqueId: d.uniqueId || '', online: d.status === 'online' }))
.filter(o => !q || o.label.toLowerCase().includes(q) || (o.uniqueId && o.uniqueId.toLowerCase().includes(q)))
.sort((a, b) => (b.online ? 1 : 0) - (a.online ? 1 : 0) || a.label.localeCompare(b.label))
})
// Appareil ACTUELLEMENT relié au tech de l'éditeur (résout l'id nom réel, ex. « 24 - Stephane M »). missing = id enregistré mais introuvable dans Traccar.
const jdDevice = computed(() => {
const id = dayEditor.tech && dayEditor.tech.traccar_device_id
if (!id) return null
const d = traccarDevices.value.find(x => String(x.id) === String(id))
if (d) return { value: String(d.id), label: d.name || d.uniqueId || ('#' + d.id), online: d.status === 'online', uniqueId: d.uniqueId || '' }
return { value: String(id), label: devicesLoading.value ? ('#' + id + '…') : ('#' + id + ' — introuvable'), online: false, missing: !devicesLoading.value }
})
// Suggestion AUTO quand non relié : appareil dont le nom/uniqueId contient un mot du nom du tech (3 lettres). Facilite « relier ».
const _normDev = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
const jdDeviceSuggest = computed(() => {
if (jdDevice.value) return null
const t = dayEditor.tech; if (!t) return null
const parts = _normDev(t.name).split(/[^a-z]+/).filter(p => p.length >= 3)
if (!parts.length) return null
let best = null
for (const d of traccarDevices.value) {
const dn = _normDev(d.name) + ' ' + _normDev(d.uniqueId)
const hit = parts.filter(p => dn.includes(p)).length
if (hit > (best ? best.hit : 0)) best = { hit, d }
}
if (best && best.hit >= 1) { const d = best.d; return { value: String(d.id), label: d.name || ('#' + d.id), online: d.status === 'online' } }
return null
})
async function onPickDevice (tech, deviceId) {
if (!tech || !tech.id) return
const prev = tech.traccar_device_id || ''; const v = deviceId == null ? '' : String(deviceId)