feat(gps): vue « appareil GPS → technicien » (assignation inverse) + non-associés

Avant : on associait un GPS à un tech UNIQUEMENT depuis le popover du tech (vue Semaine). Ajout du sens inverse demandé :
partir de l'APPAREIL. Hub roster.js :
- GET /roster/traccar-devices : roster enrichi (appareil + tech(s) assigné(s) + unassigned + duplicate + dernier signal),
  croise Traccar getDevices × Dispatch Technician.traccar_device_id. Vérifié live (45 appareils, ex. #4 → Benjamin Djanpou).
- POST /roster/traccar-device-assign {device_id,tech_id} : assigne un appareil→tech en LIBÉRANT d'abord tout autre tech qui
  le détient (le champ vit sur le tech → sinon 2 techs pointeraient le même device ; le doublon est aussi signalé dans la vue).
SPA : GpsDevicesDialog.vue (q-table statut/appareil/dernier signal/technicien via TechSelect + filtre « non associés »),
ouvert par un bouton « Appareils GPS » dans la barre Tournées de Planification. api/roster : listTraccarDevicesRoster + assignTraccarDevice.
PlanificationPage (bouton + mount) = co-édité → déployé, non commité.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 08:42:32 -04:00
parent 48faeba72b
commit 5dd38eb79b
3 changed files with 127 additions and 0 deletions

View File

@ -236,3 +236,7 @@ export const geofenceStates = (names) => jget('/roster/geofence-states?jobs=' +
export const listTraccarDevices = () => jget('/traccar/devices')
// Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif.
export const setTechTraccarDevice = (id, deviceId) => jpost('/roster/technician/' + encodeURIComponent(id) + '/traccar-device', { device_id: deviceId })
// Roster appareils GPS enrichi (device + tech(s) assigné(s) + non-associé + dernier signal) — vue « appareil → tech ».
export const listTraccarDevicesRoster = () => jget('/roster/traccar-devices')
// Assigner un appareil → un tech (SENS INVERSE) ; libère l'ancien détenteur. tech_id vide = dissocier de tous.
export const assignTraccarDevice = (deviceId, techId) => jpost('/roster/traccar-device-assign', { device_id: deviceId, tech_id: techId || '' })

View File

@ -0,0 +1,101 @@
<!--
GpsDevicesDialog vue « appareil GPS technicien » (sens INVERSE de l'assignation du popover tech en vue Semaine).
On voit tous les appareils Traccar, lesquels sont NON ASSOCIÉS, et on assigne un appareil à un tech en un clic.
Autonome : charge son roster (roster.listTraccarDevicesRoster) + les techs à l'ouverture. Réutilise TechSelect.
-->
<template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :maximized="$q.screen.lt.sm">
<q-card style="width:720px;max-width:96vw" class="column no-wrap">
<q-card-section class="row items-center q-pb-sm">
<q-icon name="gps_fixed" size="22px" color="teal-7" class="q-mr-sm" />
<div class="col">
<div class="text-subtitle1 text-weight-bold">Appareils GPS</div>
<div class="text-caption text-grey-6">{{ devices.length }} appareil(s) · {{ unassignedCount }} non associé(s)<span v-if="dupCount"> · <span class="text-orange-8">{{ dupCount }} en double</span></span></div>
</div>
<q-toggle v-model="onlyUnassigned" dense size="sm" label="Non associés" color="teal-7" class="q-mr-sm" />
<q-btn flat round dense icon="refresh" :loading="loading" @click="load"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-separator />
<q-card-section class="col scroll q-pa-none">
<q-table flat dense :rows="shown" :columns="cols" row-key="id" :pagination="{ rowsPerPage: 0 }"
hide-bottom :loading="loading" no-data-label="Aucun appareil Traccar">
<template #body-cell-status="p">
<q-td :props="p"><q-icon name="circle" size="10px" :color="p.row.status === 'online' ? 'green-6' : (p.row.status === 'offline' ? 'grey-5' : 'orange-6')" /><q-tooltip>{{ p.row.status }}</q-tooltip></q-td>
</template>
<template #body-cell-device="p">
<q-td :props="p"><div class="text-weight-medium">{{ p.row.name }}</div><div class="text-caption text-grey-6">{{ p.row.unique_id }}</div></q-td>
</template>
<template #body-cell-seen="p">
<q-td :props="p"><span class="text-caption">{{ relTime(p.row.last_update) }}</span></q-td>
</template>
<template #body-cell-tech="p">
<q-td :props="p" style="min-width:220px">
<TechSelect :model-value="p.row.assigned[0] ? p.row.assigned[0].id : null" :options="techOpts"
:label="p.row.unassigned ? 'Associer à…' : 'Changer…'" style="min-width:200px"
@update:model-value="v => assign(p.row, v)" />
<div v-if="p.row.duplicate" class="text-caption text-orange-8"> {{ p.row.assigned.map(a => a.name).join(', ') }}</div>
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import TechSelect from './TechSelect.vue'
const props = defineProps({ modelValue: { type: Boolean, default: false } })
defineEmits(['update:modelValue'])
const $q = useQuasar()
const devices = ref([])
const techs = ref([])
const loading = ref(false)
const onlyUnassigned = ref(false)
const busy = ref(null)
const cols = [
{ name: 'status', label: '', field: 'status', align: 'center' },
{ name: 'device', label: 'Appareil', field: 'name', align: 'left' },
{ name: 'seen', label: 'Dernier signal', field: 'last_update', align: 'left' },
{ name: 'tech', label: 'Technicien', field: 'tech', align: 'left' },
]
const techOpts = computed(() => (techs.value || []).map(t => ({ label: t.name + ((t.skills || []).length ? ' · ' + t.skills.slice(0, 3).join(', ') : ''), value: t.id })))
const shown = computed(() => onlyUnassigned.value ? devices.value.filter(d => d.unassigned) : devices.value)
const unassignedCount = computed(() => devices.value.filter(d => d.unassigned).length)
const dupCount = computed(() => devices.value.filter(d => d.duplicate).length)
function relTime (iso) {
if (!iso) return 'jamais'
const ms = Date.now() - Date.parse(iso); if (!isFinite(ms)) return '—'
const m = Math.round(ms / 60000); if (m < 1) return "à l'instant"; if (m < 60) return 'il y a ' + m + ' min'
const h = Math.round(m / 60); if (h < 24) return 'il y a ' + h + ' h'
return 'il y a ' + Math.round(h / 24) + ' j'
}
async function load () {
loading.value = true
try {
const [dr, tr] = await Promise.all([roster.listTraccarDevicesRoster(), roster.listTechnicians().catch(() => [])])
devices.value = (dr && dr.devices) || []
techs.value = Array.isArray(tr) ? tr : ((tr && tr.technicians) || [])
} catch (e) { $q.notify({ type: 'negative', message: 'Chargement GPS : ' + (e.message || e), timeout: 3000 }) } finally { loading.value = false }
}
async function assign (row, techId) {
if (busy.value) return
busy.value = row.id
try {
await roster.assignTraccarDevice(row.id, techId || '')
const t = (techs.value || []).find(x => x.id === techId)
$q.notify({ type: 'positive', message: techId ? ('« ' + row.name +' » → ' + ((t && t.name) || techId)) : ('« ' + row.name + ' » dissocié'), timeout: 1800 })
await load()
} catch (e) { $q.notify({ type: 'negative', message: 'Assignation : ' + (e.message || e), timeout: 3000 }) } finally { busy.value = null }
}
watch(() => props.modelValue, (o) => { if (o) load() })
</script>

View File

@ -2239,6 +2239,28 @@ async function handle (req, res, method, path, url) {
const u = new URL(req.url, 'http://localhost')
return json(res, 200, await require('./geofence').reconcileFromTrack({ date: u.searchParams.get('date') || undefined, dryRun: u.searchParams.get('dry') === '1' }))
}
// Roster des appareils GPS Traccar ENRICHI (appareil + tech(s) assigné(s) + non-associé + dernier signal) → vue « appareil → tech ».
if (path === '/roster/traccar-devices' && method === 'GET') {
const [devs, techs] = await Promise.all([require('./traccar').getDevices().catch(() => []), fetchTechnicians().catch(() => [])])
const byDev = new Map() // deviceId(String) → [{id,name}] (peut être >1 = doublon à signaler)
for (const t of (techs || [])) { const d = t.traccar_device_id; if (!d) continue; const k = String(d); if (!byDev.has(k)) byDev.set(k, []); byDev.get(k).push({ id: t.id, name: t.name }) }
const out = (devs || []).map(d => { const holders = byDev.get(String(d.id)) || []; return { id: d.id, name: d.name || ('#' + d.id), unique_id: d.uniqueId || '', status: d.status || 'unknown', last_update: d.lastUpdate || null, assigned: holders, unassigned: holders.length === 0, duplicate: holders.length > 1 } })
return json(res, 200, { devices: out, count: out.length, unassigned: out.filter(x => x.unassigned).length, duplicates: out.filter(x => x.duplicate).length })
}
// Assigner un appareil → un tech (SENS INVERSE). Libère d'abord tout AUTRE tech détenant l'appareil (le champ vit sur le tech →
// sans ça 2 techs pointeraient le même device). tech_id vide = simplement dissocier l'appareil de tous.
if (path === '/roster/traccar-device-assign' && method === 'POST') {
const b = await parseBody(req); const deviceId = String(b.device_id == null ? '' : b.device_id).trim(); const techId = String(b.tech_id || '').trim()
if (!deviceId) return json(res, 400, { ok: false, error: 'device_id requis' })
const techs = await fetchTechnicians().catch(() => [])
const holders = (techs || []).filter(t => String(t.traccar_device_id) === deviceId && t.id !== techId)
const released = []
for (const h of holders) { try { const nm = await resolveTechName(h.id); if (nm) { await retryWrite(() => erp.update('Dispatch Technician', nm, { traccar_device_id: '' })); released.push(h.id) } } catch (e) {} }
let assigned = null
if (techId) { const nm = await resolveTechName(techId); if (!nm) return json(res, 404, { ok: false, error: 'tech introuvable: ' + techId }); const r = await retryWrite(() => erp.update('Dispatch Technician', nm, { traccar_device_id: deviceId })); if (!(r && r.ok)) return json(res, 500, { ok: false, error: (r && r.error) || 'écriture échouée' }); assigned = techId }
invalidateTechCache()
return json(res, 200, { ok: true, device_id: deviceId, tech_id: techId || null, assigned, released })
}
// Associer / changer (ou retirer) l'appareil Traccar d'un tech (GPS live + tracé) — éditable INLINE depuis Planif.
// device_id = id numérique Traccar (ou '' pour dissocier).
const mTrk = path.match(/^\/roster\/technician\/(.+)\/traccar-device$/)