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:
parent
48faeba72b
commit
5dd38eb79b
|
|
@ -236,3 +236,7 @@ export const geofenceStates = (names) => jget('/roster/geofence-states?jobs=' +
|
||||||
export const listTraccarDevices = () => jget('/traccar/devices')
|
export const listTraccarDevices = () => jget('/traccar/devices')
|
||||||
// Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif.
|
// 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 })
|
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 || '' })
|
||||||
|
|
|
||||||
101
apps/ops/src/components/shared/GpsDevicesDialog.vue
Normal file
101
apps/ops/src/components/shared/GpsDevicesDialog.vue
Normal 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>
|
||||||
|
|
@ -2239,6 +2239,28 @@ async function handle (req, res, method, path, url) {
|
||||||
const u = new URL(req.url, 'http://localhost')
|
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' }))
|
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.
|
// 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).
|
// device_id = id numérique Traccar (ou '' pour dissocier).
|
||||||
const mTrk = path.match(/^\/roster\/technician\/(.+)\/traccar-device$/)
|
const mTrk = path.match(/^\/roster\/technician\/(.+)\/traccar-device$/)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user