feat(ops/planif): add JobPool + useJobPool + RouteMap route/explode

Completes the jobs-to-assign pool unification whose PlanificationPage wiring
landed in 9c49054 (which referenced these files before they were committed):
- JobPool.vue: one shared component (floating/docked/mobile) replacing the 3
  divergent pool implementations
- useJobPool.js: shared filter/sort/group/badge composable
- RouteMap.vue: live tech icons join the explode/fan; single-tech real Traccar
  day-route layer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-09 20:30:36 -04:00
parent 9c49054b8d
commit cfcd7c04cc
3 changed files with 494 additions and 7 deletions

View File

@ -0,0 +1,312 @@
<script setup>
/**
* JobPool liste UNIFIÉE « Jobs à assigner » (source unique, remplace les 3 réimplémentations divergentes).
* variant='floating' panneau flottant (desktop) : cases à cocher, menu d'assignation, niveau, fil, badges.
* variant='docked' colonne « Jour »/kanban : recherche + tri, cartes glissables (drag-only).
* variant='mobile' liste tactile : chips compétence, tri, swipe = sélectionner, tap = feuille.
*
* Le composant est PRÉSENTATIONNEL : l'état/dérivés viennent du prop `pool` (instance useJobPool, détenue par la page,
* la carte du panneau garde sa source de vérité) et TOUS les handlers/formatters de `inject('jobPoolCtx')` (fournis
* une seule fois par la page selectedJobs/draggingSet gardent la MÊME identité, donc le bulk-assign reste cohérent).
*/
import { reactive, computed, inject } from 'vue'
import { skillSym } from 'src/composables/useSkillIcons' // icône de compétence (source unique)
const props = defineProps({
variant: { type: String, default: 'floating' }, // floating | docked | mobile
pool: { type: Object, required: true }, // instance useJobPool
selectedDay: { type: String, default: '' }, // mobile : jour sélectionné (en-têtes secteur + tri « pertinence »)
})
const ctx = inject('jobPoolCtx') // handlers + état partagé + formatters (fournis par la page)
const MOBILE_SORTS = [{ value: 'smart', label: 'Pertinence' }, { value: 'sector', label: 'Secteur' }, { value: 'date', label: 'Date prévue' }, { value: 'prio', label: 'Priorité' }, { value: 'dur', label: 'Durée' }]
const DOCKED_SORTS = [{ label: 'Priorité', value: 'prio' }, { label: 'Ville', value: 'city' }, { label: 'Distance (dépôt)', value: 'dist' }, { label: 'Compétence', value: 'skill' }, { label: 'Durée', value: 'dur' }]
// `pool` est un objet PLAIN de refs (instance useJobPool) les refs imbriquées ne se déballent PAS dans le template.
// On les ré-expose ici en bindings locaux (déballés + réactifs) ; les tris/recherche/filtres sont writables (get/set).
const P = props.pool
const filteredJobs = computed(() => P.filteredJobs.value)
const groups = computed(() => P.groups.value)
const skillChips = computed(() => P.skillChips.value)
const dateChips = computed(() => P.dateChips.value)
const allCollapsed = computed(() => P.allCollapsed.value)
const collapsed = computed(() => P.collapsed.value)
const skillFilter = computed(() => P.skillFilter.value)
const dateFilter = computed(() => P.dateFilter.value)
const sort = computed({ get: () => P.sort.value, set: v => { P.sort.value = v } })
const sortDir = computed({ get: () => P.sortDir.value, set: v => { P.sortDir.value = v } })
const search = computed({ get: () => P.search.value, set: v => { P.search.value = v } })
// Couleur de bordure d'une carte kanban : compétence, sinon département legacy, sinon gris.
function kbColor (j) { return j.skill ? ctx.getTagColor(j.skill) : (j.required_skill ? ctx.getTagColor(j.required_skill) : (ctx.legacyDeptColor(j.legacy_dept) || '#90a4ae')) }
// Swipe mobile (pointer events) : tap = ouvrir la feuille (onJobClick) · glissé horizontal franc = sélectionner
const swipe = reactive({ name: null, x0: 0, y0: 0, dx: 0, active: false })
let swipeGuard = false
function swipeStart (e, j) { if (e.pointerType === 'mouse' && e.button !== 0) return; swipe.name = j.name; swipe.x0 = e.clientX; swipe.y0 = e.clientY; swipe.dx = 0; swipe.active = false }
function swipeMove (e) {
if (swipe.name == null) return
const dx = e.clientX - swipe.x0; const dy = e.clientY - swipe.y0
if (!swipe.active) {
if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.4) { swipe.active = true; try { e.currentTarget.setPointerCapture(e.pointerId) } catch (_) {} }
else if (Math.abs(dy) > 10) { swipe.name = null; return }
}
if (swipe.active) { e.preventDefault(); swipe.dx = Math.max(-120, Math.min(120, dx)) }
}
function swipeEnd (e, j) {
if (swipe.name == null) return
const active = swipe.active; const dx = swipe.dx
swipe.name = null; swipe.active = false; swipe.dx = 0
if (!active) return
swipeGuard = true; setTimeout(() => { swipeGuard = false }, 380)
if (Math.abs(dx) > 55) ctx.toggleSel(j)
}
function swipeReset () { swipe.name = null; swipe.active = false; swipe.dx = 0 }
function onJobClick (j) { if (swipeGuard) { swipeGuard = false; return } ctx.openJobSheet(j) }
function swipeStyle (j) { return (swipe.active && swipe.name === j.name) ? { transform: 'translateX(' + swipe.dx + 'px)' } : {} }
</script>
<template>
<template v-if="variant === 'mobile'">
<div class="pm-pool-hd"><q-icon name="inbox" size="16px" class="q-mr-xs" />À assigner <span class="pm-count">{{ filteredJobs.length }}</span><q-space /><span class="text-grey-5 q-mr-sm" style="font-size:10px;display:inline-flex;align-items:center;gap:1px">prio<i class="pm-leg" style="background:#ef4444"></i><i class="pm-leg" style="background:#f59e0b"></i><i class="pm-leg" style="background:#9e9e9e"></i></span>
<q-select :model-value="sort" @update:model-value="v => sort = v" :options="MOBILE_SORTS" dense options-dense borderless emit-value map-options class="pm-sort"><template #prepend><q-icon name="sort" size="15px" color="grey-6" /></template></q-select>
</div>
<div v-if="skillChips.length" class="pm-chips">
<button v-for="s in skillChips" :key="s.k" type="button" class="pm-chip" :class="{ on: skillFilter.includes(s.k) }" :style="skillFilter.includes(s.k) ? { background: ctx.getTagColor(s.k), borderColor: ctx.getTagColor(s.k), color: '#fff' } : {}" @click="pool.toggleSkill(s.k)">{{ s.k }} {{ s.n }}</button>
<button v-if="skillFilter.length" type="button" class="pm-chip pm-chip-x" @click="pool.clearSkills()"> tout</button>
</div>
<div class="pm-pool-list">
<template v-for="grp in groups" :key="grp.key">
<div v-if="grp.label" class="pm-sector-hd" @click="ctx.selectAllSector(grp.key)"><q-icon name="place" size="13px" color="green-5" />{{ grp.key }}<q-icon name="done_all" size="14px" color="primary" class="q-ml-xs"><q-tooltip class="bg-grey-9">Sélectionner / désélectionner tout ce secteur (puis « Assigner »)</q-tooltip></q-icon><span style="margin-left:auto;font-weight:400;color:#94a3b8">{{ grp.n }} · {{ ctx.fmtMin(grp.mins || 0) }}</span></div>
<div v-for="j in grp.jobs" :key="j.name" class="pm-swipe">
<div class="pm-swipe-bg"><span class="pm-swipe-hint l"><q-icon name="check_circle" size="17px" /> Sélectionner</span><span class="pm-swipe-hint r">Sélectionner <q-icon name="check_circle" size="17px" /></span></div>
<button type="button" class="pm-job" :class="{ sel: ctx.selectedJobs[j.name], hold: j.status === 'On Hold', swiping: swipe.active && swipe.name === j.name }" :style="{ borderLeft: '4px solid ' + ctx.prioColor(j.priority), ...swipeStyle(j) }"
@click="onJobClick(j)" @pointerdown="swipeStart($event, j)" @pointermove="swipeMove($event)" @pointerup="swipeEnd($event, j)" @pointercancel="swipeReset">
<q-icon :name="ctx.selectedJobs[j.name] ? 'check_circle' : (ctx.jobIsOnsite(j) ? 'home_repair_service' : 'cloud')" size="16px" :color="ctx.selectedJobs[j.name] ? 'primary' : (ctx.jobIsOnsite(j) ? 'teal' : 'grey-5')" class="q-mr-xs" />
<span class="pm-job-main">
<span class="pm-job-t ellipsis">{{ j.subject || j.service_type || j.name }}</span>
<span class="pm-job-s ellipsis">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template></span>
<span v-if="pool.jobAddrBadge(j)" class="pm-addr" :class="{ multi: pool.jobAddrBadge(j).multi }"><q-icon :name="pool.jobAddrBadge(j).multi ? 'workspaces' : 'place'" size="11px" />{{ pool.jobAddrBadge(j).n }} jobs<q-tooltip class="bg-grey-9">{{ pool.jobAddrBadge(j).n }} job(s) à cette adresse<template v-if="pool.jobAddrBadge(j).multi"> nécessite un tech avec <b>{{ pool.jobAddrBadge(j).skills.join(' + ') }}</b></template>.</q-tooltip></span>
<span v-if="j.scheduled_date || j.creation" class="pm-job-d">
<span v-if="j.scheduled_date" :class="ctx.isOverdue(j.scheduled_date) ? 'text-warning text-weight-medium' : 'text-grey-6'">📅 {{ ctx.fmtDueLabel(j.scheduled_date) }}</span>
<span v-if="j.creation" class="text-grey-5"> · créé {{ String(j.creation).slice(0, 10) }}</span>
</span>
<span v-if="j.notes" class="pm-job-note ellipsis"><q-icon name="sticky_note_2" size="12px" color="warning" /> {{ j.notes }}</span>
</span>
<span class="pm-job-icons">
<q-icon :name="j.priority === 'high' ? 'star' : 'star_border'" :color="j.priority === 'high' ? 'amber-7' : 'grey-5'" size="21px" @click.stop="ctx.toggleUrgent(j)"><q-tooltip>{{ j.priority === 'high' ? 'Urgent — toucher pour retirer' : 'Marquer urgent (priorité, heure fixée / SLA)' }}</q-tooltip></q-icon>
<q-icon :name="j.notes ? 'sticky_note_2' : 'note_add'" :color="j.notes ? 'deep-orange-6' : 'grey-4'" size="19px" @click.stop="ctx.openJobSheet(j, { note: true })"><q-tooltip>Note importante / détails</q-tooltip></q-icon>
<q-icon name="more_vert" color="grey-6" size="19px" @click.stop="ctx.openJobSheet(j)"><q-tooltip>Options (priorité, statut, compétence, report)</q-tooltip></q-icon>
</span>
<span class="pm-job-h">{{ j.est_min ? ctx.fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}</span>
</button>
</div>
</template>
<div v-if="!filteredJobs.length" class="pm-empty">Rien à assigner 🎉</div>
</div>
</template>
<template v-else-if="variant === 'docked'">
<div class="kbb-pool-hd"><q-icon name="inbox" size="15px" />&nbsp;À assigner <span class="kb-count">{{ filteredJobs.length }}</span></div>
<div class="kbb-pool-tools">
<q-input dense outlined :model-value="search" @update:model-value="v => search = v || ''" placeholder="Rechercher" clearable><template #prepend><q-icon name="search" size="16px" /></template></q-input>
<q-select dense outlined :model-value="sort" @update:model-value="v => sort = v" emit-value map-options :options="DOCKED_SORTS"><template #prepend><q-icon name="sort" size="16px" /></template></q-select>
<div v-if="skillChips.length > 1" class="kb-chips">
<button v-for="s in skillChips" :key="s.k" type="button" class="kb-chip" :class="{ on: skillFilter.includes(s.k) }" :style="skillFilter.includes(s.k) ? { background: ctx.getTagColor(s.k), borderColor: ctx.getTagColor(s.k), color: '#fff' } : {}" @click="pool.toggleSkill(s.k)">{{ s.k }} {{ s.n }}</button>
<button v-if="skillFilter.length" type="button" class="kb-chip kb-chip-x" @click="pool.clearSkills()"></button>
</div>
</div>
<div class="kbb-pool-body">
<template v-for="grp in groups" :key="grp.key">
<div v-if="grp.label" class="kb-grp-hd">{{ grp.label }} <span style="opacity:.6">({{ grp.n }})</span></div>
<div v-for="j in grp.jobs" :key="j.name" class="kb-card" :class="{ hold: j.status === 'On Hold', dragging: ctx.draggingSet.has(j.name) }" :style="{ borderLeftColor: kbColor(j) }" :draggable="j.status !== 'On Hold'" @dragstart="ctx.onJobDragStart($event, j)" @dragend="ctx.onJobDragEnd">
<div class="kb-card-t"><q-icon :name="skillSym(j.required_skill)" size="13px" :style="{ color: kbColor(j) }" />&nbsp;{{ j.subject || j.service_type || j.name }}</div>
<div class="kb-card-m"><span class="kb-dot" :style="{ background: ctx.prioColor(j.priority) }"></span>{{ j.est_min ? ctx.fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}<template v-if="ctx.jobCity(j)"> · {{ ctx.jobCity(j) }}</template><span v-if="pool.jobAddrBadge(j)" class="kb-addr" :class="{ multi: pool.jobAddrBadge(j).multi }"><q-icon :name="pool.jobAddrBadge(j).multi ? 'workspaces' : 'place'" size="10px" />{{ pool.jobAddrBadge(j).n }}</span></div>
<q-tooltip class="bg-grey-9" :delay="350" style="font-size:11px">{{ j.subject || j.service_type }}<template v-if="j.customer_name"><br>{{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template><template v-if="j.required_skill"><br>🧩 {{ j.required_skill }}</template><template v-if="pool.jobAddrBadge(j) && pool.jobAddrBadge(j).multi"><br>👥 {{ pool.jobAddrBadge(j).n }} jobs à cette adresse — tech avec {{ pool.jobAddrBadge(j).skills.join(' + ') }}</template><template v-if="j.status === 'On Hold'"><br>🔒 en attente non assignable</template></q-tooltip>
</div>
</template>
<div v-if="!filteredJobs.length" class="kb-empty"> rien à assigner </div>
</div>
</template>
<template v-else>
<div class="assign-sortbar" @mousedown.stop>
<span>Trier :</span>
<select :value="sort" @change="e => sort = e.target.value" @mousedown.stop>
<option value="group">Groupe (parent-enfant)</option>
<option value="skill">Compétence</option>
<option value="date">Date</option>
<option value="city">Ville</option>
<option value="priority">Priorité</option>
</select>
<q-btn dense flat size="sm" :icon="sortDir === 'asc' ? 'arrow_upward' : 'arrow_downward'" @click="sortDir = sortDir === 'asc' ? 'desc' : 'asc'" @mousedown.stop><q-tooltip>{{ sortDir === 'asc' ? 'Croissant (ancien → récent)' : 'Décroissant (récent → ancien)' }}</q-tooltip></q-btn>
<q-btn dense flat size="sm" :icon="allCollapsed ? 'unfold_more' : 'unfold_less'" no-caps @click="pool.toggleCollapseAll()" @mousedown.stop><q-tooltip>{{ allCollapsed ? 'Tout déplier' : 'Tout replier (voir un seul groupe)' }}</q-tooltip></q-btn>
<q-space />
<q-btn v-if="ctx.selectedNames.value.length" dense flat size="sm" icon="deselect" :label="'✕ ' + ctx.selectedNames.value.length" no-caps color="grey-7" @click="ctx.clearSel()" @mousedown.stop><q-tooltip>Tout désélectionner</q-tooltip></q-btn>
</div>
<div v-if="skillChips.length > 1" class="assign-chips" @mousedown.stop>
<span v-for="t in skillChips" :key="t.k" class="assign-chip-f" :style="skillFilter.includes(t.k) ? { background: ctx.getTagColor(t.k), color: '#fff', borderColor: ctx.getTagColor(t.k) } : { borderColor: ctx.getTagColor(t.k) }" @click="pool.toggleSkill(t.k)">{{ t.k }} <b>{{ t.n }}</b></span>
<span v-if="skillFilter.length" class="assign-chip-f clear" @click="pool.clearSkills()"><q-tooltip>Tout afficher</q-tooltip></span>
</div>
<div v-if="dateChips.length > 1" class="assign-chips" @mousedown.stop>
<q-icon name="event" size="14px" color="grey-6" class="q-mr-xs" />
<span v-for="d in dateChips" :key="d.iso" class="assign-chip-f" :style="dateFilter.includes(d.iso) ? { background: d.overdue ? '#f59e0b' : '#6366f1', color: '#fff', borderColor: d.overdue ? '#f59e0b' : '#6366f1' } : (d.overdue ? { borderColor: '#f59e0b', color: '#b45309' } : {})" @click="pool.toggleDate(d.iso)">{{ d.label }} <b>{{ d.n }}</b></span>
<span v-if="dateFilter.length" class="assign-chip-f clear" @click="pool.clearDates()"><q-tooltip>Toutes les dates</q-tooltip></span>
</div>
<div class="assign-body">
<div v-if="!filteredJobs.length" class="text-grey-6 q-pa-md text-center">Aucun job à assigner 🎉</div>
<div v-for="grp in groups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': ctx.groupSelected(grp) }">
<div v-if="grp.label" class="assign-grp-lbl" style="cursor:pointer;display:flex;align-items:center;gap:3px" @click="pool.toggleCollapse(grp.key)"><q-icon :name="collapsed.has(grp.key) ? 'chevron_right' : 'expand_more'" size="15px" /><span>{{ grp.label }} <span style="opacity:.6">({{ grp.jobs.length }})</span></span><q-space /><q-icon name="done_all" size="16px" :color="ctx.groupSelected(grp) ? 'primary' : 'grey-5'" @click.stop="ctx.toggleGroupSelAll(grp)"><q-tooltip>Sélectionner / désélectionner tout ce groupe</q-tooltip></q-icon></div>
<template v-if="!(grp.label && collapsed.has(grp.key))">
<div v-if="sort === 'group' && grp.jobs.length > 1" class="assign-grp-hdr" @click="ctx.toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) tout sélectionner (terrain)</div>
<div v-for="(j, idx) in grp.jobs" :key="j.name" :data-jobname="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: sort === 'group' && grp.jobs.length > 1 && idx > 0, sel: !!ctx.selectedJobs[j.name], dragging: ctx.draggingSet.has(j.name), 'job-flash': ctx.flashJob.value === j.name, assoc: pool.jobAssocOnly(j) }" :style="{ borderLeft: '5px solid ' + ctx.panelJobColor(j) }" draggable="true" @dragstart="ctx.onJobDragStart($event, j)" @dragend="ctx.onJobDragEnd">
<div class="row items-center no-wrap">
<q-checkbox dense size="xs" :model-value="!!ctx.selectedJobs[j.name]" @update:model-value="v => { if (v) ctx.selectedJobs[j.name] = true; else delete ctx.selectedJobs[j.name] }" @click.stop @mousedown.stop class="q-mr-xs" />
<q-icon :name="ctx.jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="ctx.jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ ctx.jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
<span v-if="ctx.groupLabel(j)" class="assign-grp-badge q-mr-xs"><q-tooltip class="bg-grey-9">Même adresse de service (groupe {{ ctx.groupLabel(j)[0] }}) simple repère de proximité</q-tooltip>{{ ctx.groupLabel(j) }}</span>
<q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
<span class="ellipsis text-weight-medium" :style="ctx.showMap.value ? 'cursor:pointer' : ''" @click="ctx.showMap.value && ctx.focusAssignJob(j)">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
<span v-if="ctx.jobSlaBadge(j)" class="assign-sla" :class="'sla-' + ctx.jobSlaBadge(j).state"><q-icon :name="ctx.jobSlaBadge(j).icon" size="11px" />{{ ctx.jobSlaBadge(j).short }}<q-tooltip class="bg-grey-9">{{ ctx.jobSlaBadge(j).label }}</q-tooltip></span>
<q-space />
<q-btn v-if="j.status !== 'On Hold'" flat dense round size="sm" icon="person_add" color="primary" class="q-ml-xs" @click.stop @mousedown.stop>
<q-tooltip>Assigner à un technicien (classés par pertinence : compétence · dispo · distance · charge)</q-tooltip>
<q-menu anchor="bottom right" self="top right" @click.stop>
<div class="assign-pick-hd">Assigner « {{ (j.subject || j.name).slice(0, 30) }} » · 📅 {{ ctx.fmtDueLabel(ctx.jobTargetDay(j)) }}</div>
<q-list dense style="min-width:270px;max-height:44vh;overflow:auto">
<q-item v-for="tk in ctx.techsForJob(j)" :key="tk.id" clickable v-close-popup @click="ctx.quickAssign(j, tk)" :class="{ 'assign-pick-incap': !tk.capable }">
<q-item-section avatar style="min-width:32px"><q-avatar size="26px" :color="tk.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:10px;font-weight:700">{{ ctx.initials(tk.name) }}</q-avatar></q-item-section>
<q-item-section>
<q-item-label>{{ tk.name }}<q-icon v-if="!tk.capable && j.required_skill" name="warning" size="12px" color="orange" class="q-ml-xs"><q-tooltip>Compétence « {{ j.required_skill }} » absente</q-tooltip></q-icon></q-item-label>
<q-item-label caption><span v-if="tk.distKm != null">📍 {{ Math.round(tk.distKm) }} km</span><span v-else class="text-grey-5">dist. ?</span> · {{ tk.h }}/{{ tk.cap }}h<span v-if="tk.noShift" class="text-warning"> · ▲ sans quart</span><span v-else-if="tk.over" class="text-negative"> · surchargé</span></q-item-label>
</q-item-section>
<q-item-section side><q-icon name="chevron_right" size="16px" color="grey-5" /></q-item-section>
</q-item>
<q-item v-if="!ctx.techsForJob(j).length"><q-item-section class="text-grey-6 text-caption">Aucun technicien visible.</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<q-icon v-if="j.legacy_ticket_id" name="forum" size="14px" :color="j._showThread ? 'indigo' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop="ctx.toggleAssignThread(j)" @mousedown.stop><q-tooltip>Voir le fil du ticket #{{ j.legacy_ticket_id }}</q-tooltip></q-icon>
<q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon>
</div>
<div class="assign-sub">
<span v-if="j.required_skill" class="assign-skill" :style="{ background: ctx.getTagColor(j.required_skill) }">{{ j.required_skill }}</span>
<span v-if="pool.jobAssocOnly(j)" class="assign-linked"><q-icon name="link" size="11px" />lié<q-tooltip class="bg-grey-9">Affiché car un autre job filtré est à la MÊME adresse à dispatcher avec lui (même tech).</q-tooltip></span>
<span v-if="pool.jobAddrBadge(j)" class="assign-addr" :class="{ multi: pool.jobAddrBadge(j).multi }"><q-icon :name="pool.jobAddrBadge(j).multi ? 'workspaces' : 'place'" size="11px" />{{ pool.jobAddrBadge(j).n }} jobs<q-tooltip class="bg-grey-9">{{ pool.jobAddrBadge(j).n }} job(s) à cette adresse<template v-if="pool.jobAddrBadge(j).multi"> nécessite un tech avec <b>{{ pool.jobAddrBadge(j).skills.join(' + ') }}</b></template>.</q-tooltip></span>
<span v-if="j.required_skill" class="assign-lvl" :class="{ set: j.required_level > 1 }" @click.stop @mousedown.stop><q-icon name="military_tech" size="11px" />niv&nbsp;{{ j.required_level || 1 }}<q-tooltip class="bg-grey-9">Niveau min requis pour ce job (persistant · mode « juste ce qu'il faut » → réserve les experts)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:150px"><q-item v-for="n in [1,2,3,4,5]" :key="n" clickable v-close-popup @click="ctx.setJobReqLevel(j, n)" :active="(j.required_level||1)===n"><q-item-section>Niveau {{ n }}{{ n===1 ? ' — de base' : (n===5 ? ' — expert' : '') }}</q-item-section></q-item></q-list></q-menu></span>
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · <span class="text-positive text-weight-medium"> {{ j.est_min ? ctx.fmtMin(j.est_min) : (j.duration_h || 1) + 'h' }}<q-tooltip v-if="j.est_labels && j.est_labels.length" class="bg-grey-9" style="font-size:11px">Estimé (auto) : {{ j.est_labels.join(' + ') }}</q-tooltip></span><span v-if="j.scheduled_date"> · 📅 <span :class="ctx.isOverdue(j.scheduled_date) ? 'text-warning text-weight-bold' : 'text-grey-7'">{{ ctx.fmtDueLabel(j.scheduled_date) }}</span><q-icon name="edit_calendar" size="14px" :color="ctx.isOverdue(j.scheduled_date) ? 'warning' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop @mousedown.stop><q-tooltip>Replanifier (aujourd'hui / demain / date)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:160px"><q-item clickable v-close-popup @click="ctx.setJobDate(j, ctx.todayISO())"><q-item-section avatar style="min-width:28px"><q-icon name="today" size="18px" color="primary" /></q-item-section><q-item-section>Aujourd'hui</q-item-section></q-item><q-item clickable v-close-popup @click="ctx.setJobDate(j, ctx.tomorrowISO())"><q-item-section avatar style="min-width:28px"><q-icon name="event" size="18px" color="primary" /></q-item-section><q-item-section>Demain</q-item-section></q-item><q-separator /><q-date :model-value="j.scheduled_date" @update:model-value="v => ctx.setJobDate(j, v)" mask="YYYY-MM-DD" minimal today-btn /></q-list></q-menu></q-icon></span>
</div>
<div v-if="j._showThread" class="assign-thread" @mousedown.stop @click.stop>
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="12px" class="q-mr-xs" />Chargement du fil</div>
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="assign-msg"><b>{{ m.author }}</b><span v-if="m.at" class="text-grey-5"> · {{ ctx.fmtDT(m.at) }}</span><div class="assign-msg-txt">{{ m.text }}</div></div>
</template>
<div v-else class="text-grey-6" style="font-size:11px;white-space:pre-wrap">{{ j.legacy_detail || 'Aucun fil pour ce ticket.' }}</div>
<div class="text-right q-mt-xs"><q-btn dense flat size="sm" color="negative" icon="block" label="Fermer ce ticket (legacy)" @click="ctx.closeLegacyTicket(j)"><q-tooltip>Ticket inutile status closed dans osTicket + sort du dispatch</q-tooltip></q-btn></div>
</div>
</div>
</template>
</div>
</div>
</template>
</template>
<!-- Styles NON scopés (préfixes assign-/kb-/pm- = pas de collision) le composant est monté DANS la page dont le <style scoped> ne l'atteint pas. -->
<style>
/* ── FLOATING ── */
.assign-sortbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; color: #555; background: #f3f0fa; border-bottom: 1px solid #e0e0e0; }
.assign-sortbar select { font-size: 11px; border: 1px solid #cfc4e8; border-radius: 5px; padding: 1px 4px; background: #fff; color: #333; flex: 1; }
.assign-body { overflow: auto; padding: 5px; flex: 1 1 auto; min-height: 60px; }
.assign-chips { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 6px; border-bottom: 1px solid #eee; flex: 0 0 auto; }
.assign-chip-f { font-size: 10px; border: 1.5px solid #cfd8dc; border-radius: 11px; padding: 1px 8px; cursor: pointer; user-select: none; line-height: 1.6; background: #fff; color: #455a64; }
.assign-chip-f.clear { border-color: #b0bec5; color: #607d8b; font-weight: 700; }
.assign-chip-f:hover { filter: brightness(0.95); }
.assign-grp-badge { display: inline-block; background: #cfd8dc; color: #455a64; font-weight: 700; border-radius: 4px; padding: 0 5px; font-size: 10px; line-height: 16px; }
.assign-grp { margin-bottom: 6px; border-radius: 7px; padding: 2px; }
.assign-grp-lbl { font-size: 11px; font-weight: 700; color: #37474f; padding: 3px 6px 2px; border-bottom: 1px solid #eee; margin-bottom: 2px; position: sticky; top: 0; background: #fff; z-index: 1; }
.assign-grp.grp-hl { background: #ede7f6; box-shadow: inset 0 0 0 1px #b39ddb; }
.assign-grp-hdr { font-size: 10px; font-weight: 700; color: #5e35b1; padding: 2px 6px; cursor: pointer; display: flex; align-items: center; gap: 3px; }
.assign-grp-hdr:hover { text-decoration: underline; }
.assign-job { border: 1px solid #e0e0e0; border-radius: 6px; padding: 3px 7px; margin: 3px 0; cursor: grab; background: #fafafa; font-size: 12px; }
.assign-job:hover { border-color: #5e35b1; background: #f3e9fb; }
.assign-job:active { cursor: grabbing; }
.assign-job.sel { border-color: #00897b; background: #e0f2f1; box-shadow: inset 0 0 0 1px #00897b; }
.assign-job.child { margin-left: 14px; border-left: 3px solid #b39ddb; }
.assign-job.blocked { opacity: .65; }
.assign-job.assoc { background: #fbfbfd; border-style: dashed; }
.assign-linked { display: inline-flex; align-items: center; gap: 1px; color: #6d4c9f; background: #ede7f6; border-radius: 6px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-right: 3px; }
.assign-addr { display: inline-flex; align-items: center; gap: 1px; color: #37474f; background: #eceff1; border-radius: 6px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-right: 3px; }
.assign-addr.multi { color: #fff; background: #7e57c2; }
.assign-job.job-flash { animation: jobflash 1.7s ease-out; }
@keyframes jobflash { 0% { background: #fff59d; box-shadow: inset 0 0 0 2px #fbc02d; } 100% { background: #fafafa; box-shadow: none; } }
.assign-sub { font-size: 10px; color: #888; margin-top: 1px; }
.assign-pick-hd { font-size: 11px; font-weight: 700; color: #455a64; padding: 6px 10px; border-bottom: 1px solid #eef2f7; background: #f7f5fc; }
.assign-pick-incap { opacity: 0.62; }
.assign-skill { display: inline-block; color: #fff; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; margin-right: 3px; }
.assign-lvl { display: inline-flex; align-items: center; gap: 1px; border: 1px solid #cfd8dc; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; color: #607d8b; margin-right: 3px; cursor: pointer; }
.assign-lvl.set { border-color: #6366f1; color: #4338ca; background: #eef2ff; }
.assign-sla { display: inline-flex; align-items: center; gap: 2px; font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 8px; white-space: nowrap; margin-left: 4px; }
.assign-sla.sla-breached { background: #fee2e2; color: #b91c1c; }
.assign-sla.sla-at_risk { background: #ffedd5; color: #c2410c; }
.assign-thread { background: #f5f5f5; border-radius: 5px; padding: 4px 7px; margin-top: 4px; max-height: 220px; overflow: auto; }
.assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; }
.assign-msg:first-child { border-top: none; }
.assign-msg-txt { white-space: pre-wrap; color: #37474f; }
/* ── DOCKED (kanban) ── */
.kbb-pool-hd { display: flex; align-items: center; font-size: 12px; font-weight: 700; color: #2b3445; padding: 7px 9px 4px; }
.kbb-pool-tools { display: flex; flex-direction: column; gap: 5px; padding: 0 7px 7px; border-bottom: 1px solid #e0e6ef; }
.kbb-pool-body { flex: 1 1 auto; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 5px; }
.kb-count { margin-left: auto; background: #d7deea; color: #3a4658; border-radius: 9px; padding: 0 7px; font-size: 11px; }
.kb-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.kb-chip { font-size: 10px; border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 999px; padding: 1px 8px; cursor: pointer; white-space: nowrap; }
.kb-chip.on { color: #fff; border-color: transparent; }
.kb-chip-x { border-color: #fca5a5; color: #dc2626; font-weight: 700; }
.kb-grp-hd { font-size: 10.5px; font-weight: 700; color: #475569; padding: 3px 4px 1px; position: sticky; top: 0; background: #eef2f7; z-index: 1; }
.kb-card { background: #fff; border: 1px solid #e6e9ef; border-left: 4px solid #90a4ae; border-radius: 6px; padding: 5px 7px; cursor: grab; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
.kb-card:hover { box-shadow: 0 2px 6px rgba(0,0,0,.13); }
.kb-card:active { cursor: grabbing; }
.kb-card.dragging { opacity: .4; }
.kb-card.hold { opacity: .62; cursor: not-allowed; background: repeating-linear-gradient(45deg, #fafafa 0 6px, #f0f0f0 6px 12px); }
.kb-card-t { font-size: 12px; font-weight: 600; color: #222; line-height: 1.25; display: flex; align-items: center; }
.kb-card-m { font-size: 10.5px; color: #67707e; display: flex; align-items: center; gap: 4px; margin-top: 2px; }
.kb-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; }
.kb-addr { display: inline-flex; align-items: center; gap: 1px; color: #37474f; background: #eceff1; border-radius: 5px; padding: 0 3px; font-size: 9px; font-weight: 700; }
.kb-addr.multi { color: #fff; background: #7e57c2; }
.kb-empty { font-size: 11px; color: #9aa3b0; text-align: center; padding: 10px 4px; border: 1px dashed #d7deea; border-radius: 6px; }
/* ── MOBILE ── */
.pm-pool-hd { display: flex; align-items: center; font-size: 12px; font-weight: 700; color: #475569; margin-bottom: 4px; }
.pm-count { margin-left: 6px; background: #e2e8f0; color: #475569; border-radius: 9px; font-size: 11px; padding: 0 7px; font-weight: 700; }
.pm-leg { width: 7px; height: 7px; border-radius: 2px; display: inline-block; margin-left: 2px; }
.pm-sort { font-size: 11px; min-height: 0; max-width: 120px; }
.pm-sort .q-field__control { min-height: 26px; }
.pm-sort .q-field__native { font-size: 11px; font-weight: 600; color: #475569; padding: 0; min-height: 26px; }
.pm-sort .q-field__prepend { padding-right: 2px; height: 26px; }
.pm-sort .q-field__marginal { height: 26px; }
.pm-chips { display: flex; gap: 5px; overflow-x: auto; padding-bottom: 6px; margin-bottom: 2px; }
.pm-chips::-webkit-scrollbar { height: 0; }
.pm-chip { flex: 0 0 auto; border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 999px; font-size: 11px; font-weight: 600; padding: 3px 10px; cursor: pointer; white-space: nowrap; }
.pm-chip.on { color: #fff; border-color: transparent; }
.pm-chip-x { border-color: #fca5a5; color: #dc2626; }
.pm-pool-list { max-height: 46vh; overflow-y: auto; }
.pm-sector-hd { display: flex; align-items: center; gap: 4px; padding: 5px 8px; margin: 6px 0 2px; background: #eef2f7; border-radius: 6px; font-size: 11px; font-weight: 600; color: #475569; position: sticky; top: 0; z-index: 2; cursor: pointer; }
.pm-swipe { position: relative; margin-bottom: 6px; border-radius: 8px; overflow: hidden; }
.pm-swipe-bg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: space-between; border-radius: 8px; background: linear-gradient(90deg, #bbf7d0 0 50%, #c7d2fe 50% 100%); }
.pm-swipe-hint { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 800; padding: 0 14px; }
.pm-swipe-hint.l { color: #166534; }
.pm-swipe-hint.r { color: #3730a3; }
.pm-swipe .pm-job { margin-bottom: 0; position: relative; touch-action: pan-y; transition: transform .18s ease; }
.pm-job { display: flex; align-items: center; gap: 6px; width: 100%; text-align: left; border: 1px solid #e2e8f0; background: #fff; border-radius: 8px; padding: 8px; margin-bottom: 6px; cursor: pointer; }
.pm-job.sel { border-color: #6366f1; background: #eef2ff; box-shadow: 0 0 0 2px rgba(99, 102, 241, .18); }
.pm-job.hold { background: repeating-linear-gradient(45deg, #f8fafc 0 7px, #eef2f7 7px 14px); cursor: not-allowed; }
.pm-job.hold .pm-job-t { color: #64748b; }
.pm-job.swiping { transition: none; }
.pm-job-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.pm-job-t { font-size: 13px; font-weight: 600; color: #1e293b; }
.pm-job-s { font-size: 11px; color: #64748b; }
.pm-job-d { font-size: 10.5px; margin-top: 1px; }
.pm-job-note { font-size: 10.5px; margin-top: 1px; color: #c2410c; font-style: italic; max-width: 100%; }
.pm-job-icons { flex: 0 0 auto; display: flex; align-items: center; gap: 1px; }
.pm-job-icons .q-icon { padding: 2px; cursor: pointer; }
.pm-job-h { flex: 0 0 auto; font-size: 12px; font-weight: 700; color: #0d9488; min-width: 30px; text-align: right; }
.pm-addr { display: inline-flex; align-items: center; gap: 1px; align-self: flex-start; color: #37474f; background: #eceff1; border-radius: 5px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-top: 1px; }
.pm-addr.multi { color: #fff; background: #7e57c2; }
.pm-empty { color: #94a3b8; font-size: 13px; text-align: center; padding: 12px; }
</style>

View File

@ -18,6 +18,7 @@ const props = defineProps({
routes: { type: Array, default: () => [] }, routes: { type: Array, default: () => [] },
live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }] live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }]
pins: { type: Array, default: () => [] }, // jobs NON assignés à situer : [{ lat, lon, subject, name, color(priorité), city }] pins: { type: Array, default: () => [] }, // jobs NON assignés à situer : [{ lat, lon, subject, name, color(priorité), city }]
track: { type: Object, default: null }, // tracé GPS RÉEL (Traccar) d'UN tech sur la journée : { coords:[[lon,lat]] } couche orange au 1er plan (vs tournées prévues)
height: { type: String, default: '440px' }, height: { type: String, default: '440px' },
}) })
const emit = defineEmits(['metrics', 'stop-click', 'pin-click']) const emit = defineEmits(['metrics', 'stop-click', 'pin-click'])
@ -66,6 +67,12 @@ function clearMarkers () { for (const m of stopMk) m.mk.remove(); for (const m o
// Positions GPS LIVE des techs (marqueur distinct des arrêts : pastille ronde à initiales + halo pulsé) // Positions GPS LIVE des techs (marqueur distinct des arrêts : pastille ronde à initiales + halo pulsé)
// initials composables/useFormatters (source unique) // initials composables/useFormatters (source unique)
function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] } function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] }
function renderTrack (tk) { // tracé GPS réel (couche GL orange) vidé si absent
if (!map || !ready) return
const src = map.getSource('rm-track'); if (!src) return
const coords = (tk && Array.isArray(tk.coords)) ? tk.coords : []
src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
}
function clearPins () { for (const m of pinMk) m.mk.remove(); pinMk = [] } function clearPins () { for (const m of pinMk) m.mk.remove(); pinMk = [] }
function renderLive (list) { function renderLive (list) {
if (!map || !ready) return if (!map || !ready) return
@ -77,14 +84,23 @@ function renderLive (list) {
const stale = ageMin != null && ageMin > 10 // > 10 min sans fix = position vieillie (pas de halo, grisée) const stale = ageMin != null && ageMin > 10 // > 10 min sans fix = position vieillie (pas de halo, grisée)
const color = p.color || '#1e88e5' const color = p.color || '#1e88e5'
const wrap = document.createElement('div'); wrap.className = 'rm-live' + (stale ? ' stale' : '') const wrap = document.createElement('div'); wrap.className = 'rm-live' + (stale ? ' stale' : '')
wrap.innerHTML = '<span class="rm-live-pulse" style="background:' + color + '"></span>' + // Élément INTERNE fannable : Mapbox pose sa transform de positionnement sur `wrap` (racine du marqueur) on translate `inner`,
// pas `wrap` (comme les arrêts translatent .rm-pill, enfant de .rm-mk), sinon on casserait le placement GPS.
const inner = document.createElement('div'); inner.className = 'rm-live-in'
inner.innerHTML = '<span class="rm-live-pulse" style="background:' + color + '"></span>' +
'<span class="rm-live-dot" style="background:' + color + '">' + initials(p.name || p.techName) + '</span>' '<span class="rm-live-dot" style="background:' + color + '">' + initials(p.name || p.techName) + '</span>'
wrap.appendChild(inner)
const spd = (p.speed != null && p.speed > 1) ? Math.round(p.speed * 1.852) + ' km/h' : 'arrêté' // Traccar speed = nœuds km/h const spd = (p.speed != null && p.speed > 1) ? Math.round(p.speed * 1.852) + ' km/h' : 'arrêté' // Traccar speed = nœuds km/h
const when = ageMin == null ? 'position' : (ageMin <= 1 ? "à l'instant" : 'il y a ' + ageMin + ' min') const when = ageMin == null ? 'position' : (ageMin <= 1 ? "à l'instant" : 'il y a ' + ageMin + ' min')
wrap.title = (p.name || p.techName || '') + ' — ' + when + ' · ' + spd wrap.title = (p.name || p.techName || '') + ' — ' + when + ' · ' + spd
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map) const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map)
liveMk.push({ mk, el: wrap }) // Rejoint l'ÉCLATEMENT en éventail : quand une position GPS chevauche des arrêts/gouttes, le groupe se sépare au survol.
const rec = { mk, el: wrap, disc: inner, rid: p.techId, lngLat: [+p.lon, +p.lat], fan: null, members: null, live: true }
wrap.addEventListener('mouseenter', () => onEnter(rec))
wrap.addEventListener('mouseleave', () => onLeave(rec))
liveMk.push(rec)
} }
recluster()
} }
function esc (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) } function esc (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) }
// Jobs NON assignés du jour, GROUPÉS PAR ADRESSE : pastille round-rect avec les icônes des types de jobs + le temps // Jobs NON assignés du jour, GROUPÉS PAR ADRESSE : pastille round-rect avec les icônes des types de jobs + le temps
@ -132,7 +148,11 @@ function renderMarkers (routes) {
const d = document.createElement('div'); d.className = 'rm-home'; d.style.borderColor = r.color; wrap.appendChild(d) const d = document.createElement('div'); d.className = 'rm-home'; d.style.borderColor = r.color; wrap.appendChild(d)
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+r.home.lon, +r.home.lat]).addTo(map) const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+r.home.lon, +r.home.lat]).addTo(map)
wrap.title = r.name + ' — départ' wrap.title = r.name + ' — départ'
homeMk.push({ mk, el: wrap, rid: r.id }) // Le point de DÉPART (domicile/dépôt) rejoint aussi l'éclatement : disc = pastille interne (mapbox transforme `wrap`) + survol fan.
const rec = { mk, el: wrap, disc: d, rid: r.id, lngLat: [+r.home.lon, +r.home.lat], fan: null, members: null, home: true }
wrap.addEventListener('mouseenter', () => onEnter(rec))
wrap.addEventListener('mouseleave', () => onLeave(rec))
homeMk.push(rec)
} }
for (const s of (r.stops || [])) { for (const s of (r.stops || [])) {
// Pastille = ROUND-RECT (pill) : icône du type de job + numéro d'ordre (best practice quand 2 infos ; cf. outils de tournée). // Pastille = ROUND-RECT (pill) : icône du type de job + numéro d'ordre (best practice quand 2 infos ; cf. outils de tournée).
@ -153,8 +173,8 @@ function renderMarkers (routes) {
} }
// Regroupe les pastilles qui se CHEVAUCHENT à l'écran (distance pixel) calcule pour chacune un décalage en éventail. // Regroupe les pastilles qui se CHEVAUCHENT à l'écran (distance pixel) calcule pour chacune un décalage en éventail.
function recluster () { function recluster () {
// Arrêts assignés ET gouttes non assignées (par adresse) partagent l'éclatement en éventail au chevauchement. // TOUS les types d'icônes partagent l'éclatement au chevauchement : arrêts, gouttes (non assignés), positions GPS live ET départs (domicile/dépôt).
const all = stopMk.concat(pinMk) const all = stopMk.concat(pinMk, liveMk, homeMk)
if (!map || !all.length) return if (!map || !all.length) return
const px = all.map(s => map.project(s.lngLat)) const px = all.map(s => map.project(s.lngLat))
const parent = all.map((_, i) => i) const parent = all.map((_, i) => i)
@ -208,6 +228,7 @@ async function draw () {
renderMarkers(routes) renderMarkers(routes)
renderLive(props.live) renderLive(props.live)
renderPins(props.pins) renderPins(props.pins)
renderTrack(props.track)
const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|') + '#' + (props.pins || []).length const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|') + '#' + (props.pins || []).length
if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); for (const p of (props.pins || [])) if (okLL(p.lon, p.lat)) b.extend([+p.lon, +p.lat]); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} } if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); for (const p of (props.pins || [])) if (okLL(p.lon, p.lat)) b.extend([+p.lon, +p.lat]); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} }
// Routes RÉELLES en asynchrone : remplace les segments droits + émet km/min réels. Ignoré si un draw plus récent est parti. // Routes RÉELLES en asynchrone : remplace les segments droits + émet km/min réels. Ignoré si un draw plus récent est parti.
@ -250,6 +271,10 @@ onMounted(async () => {
map.addSource('rm-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) map.addSource('rm-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({ id: 'rm-line', type: 'line', source: 'rm-line', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.7 } }) map.addLayer({ id: 'rm-line', type: 'line', source: 'rm-line', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.7 } })
map.addLayer({ id: 'rm-line-a', type: 'line', source: 'rm-line', filter: ['==', ['get', 'rid'], '___aucun___'], layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 5.5, 'line-opacity': 0.95 } }) map.addLayer({ id: 'rm-line-a', type: 'line', source: 'rm-line', filter: ['==', ['get', 'rid'], '___aucun___'], layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 5.5, 'line-opacity': 0.95 } })
// Tracé GPS RÉEL (Traccar) d'un seul tech orange, AU-DESSUS des tournées prévues (halo + trait) « où il est réellement passé ».
map.addSource('rm-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({ id: 'rm-track-halo', type: 'line', source: 'rm-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 9, 'line-opacity': 0.22 } })
map.addLayer({ id: 'rm-track-l', type: 'line', source: 'rm-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3.5, 'line-opacity': 0.85 } })
map.on('mousemove', 'rm-line', (e) => { const f = e.features && e.features[0]; if (f) setActive(f.properties.rid) }) map.on('mousemove', 'rm-line', (e) => { const f = e.features && e.features[0]; if (f) setActive(f.properties.rid) })
map.on('mouseleave', 'rm-line', () => setActive(null)) map.on('mouseleave', 'rm-line', () => setActive(null))
map.on('movestart', () => { if (openMembers) { fan(openMembers, false); openMembers = null } }) // décalages px périmés au déplacement map.on('movestart', () => { if (openMembers) { fan(openMembers, false); openMembers = null } }) // décalages px périmés au déplacement
@ -260,8 +285,9 @@ onMounted(async () => {
}) })
onBeforeUnmount(() => { clearMarkers(); clearLive(); clearPins(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false }) onBeforeUnmount(() => { clearMarkers(); clearLive(); clearPins(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false })
watch(() => props.routes, () => { draw() }, { deep: true }) watch(() => props.routes, () => { draw() }, { deep: true })
watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live sans redessiner les tournées watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live (+ recluster interne) sans redessiner les tournées
watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // jobs non assignés (secteur) sans redessiner les tournées watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // jobs non assignés (secteur) sans redessiner les tournées
watch(() => props.track, () => { renderTrack(props.track) }, { deep: true }) // tracé GPS réel (1 tech) sans redessiner les tournées
// Satellite : bouton local + préférence PARTAGÉE (satellitePref) toutes les cartes suivent. // Satellite : bouton local + préférence PARTAGÉE (satellitePref) toutes les cartes suivent.
function toggleSat () { setSatellitePref(!satellitePref.value); if (map) setSatelliteVisible(map, satellitePref.value) } function toggleSat () { setSatellitePref(!satellitePref.value); if (map) setSatelliteVisible(map, satellitePref.value) }
watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) }) watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) })
@ -299,7 +325,7 @@ watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) })
.rm-pop-row:hover { background: #eef2ff; } .rm-pop-row:hover { background: #eef2ff; }
.rm-pop-s { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-pop-s { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rm-pop-est { color: #64748b; font-variant-numeric: tabular-nums; flex-shrink: 0; } .rm-pop-est { color: #64748b; font-variant-numeric: tabular-nums; flex-shrink: 0; }
.rm-mk .rm-home { width: 15px; height: 15px; border-radius: 50%; box-sizing: border-box; background: #fff; border: 3px solid #888; box-shadow: 0 1px 3px rgba(0,0,0,.4); } .rm-mk .rm-home { width: 15px; height: 15px; border-radius: 50%; box-sizing: border-box; background: #fff; border: 3px solid #888; box-shadow: 0 1px 3px rgba(0,0,0,.4); transition: transform .18s cubic-bezier(.2,.8,.3,1); }
.rm-mk.hot { z-index: 7; } .rm-mk.hot { z-index: 7; }
.rm-mk.hot .rm-pill { transform: scale(1.15); box-shadow: 0 3px 9px rgba(0,0,0,.55); } .rm-mk.hot .rm-pill { transform: scale(1.15); box-shadow: 0 3px 9px rgba(0,0,0,.55); }
.rm-mk.route-hot { z-index: 5; } .rm-mk.route-hot { z-index: 5; }
@ -307,6 +333,8 @@ watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) })
.rm-mk.fanned .rm-pill { box-shadow: 0 3px 10px rgba(0,0,0,.55); } .rm-mk.fanned .rm-pill { box-shadow: 0 3px 10px rgba(0,0,0,.55); }
/* Position GPS LIVE d'un tech : pastille ronde à initiales + halo pulsé (distincte des pastilles d'arrêt). */ /* Position GPS LIVE d'un tech : pastille ronde à initiales + halo pulsé (distincte des pastilles d'arrêt). */
.rm-live { position: relative; width: 0; height: 0; z-index: 8; pointer-events: auto; } .rm-live { position: relative; width: 0; height: 0; z-index: 8; pointer-events: auto; }
.rm-live.fanned { z-index: 9; } /* éclatée au chevauchement → passe devant arrêts/gouttes */
.rm-live-in { position: absolute; width: 0; height: 0; transition: transform .18s cubic-bezier(.2,.8,.3,1); } /* élément translaté par l'éventail */
.rm-live .rm-live-dot { position: absolute; transform: translate(-50%, -50%); width: 28px; height: 28px; border-radius: 50%; border: 2.5px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,.5); color: #fff; font-size: 10.5px; font-weight: 800; display: flex; align-items: center; justify-content: center; z-index: 2; cursor: default; } .rm-live .rm-live-dot { position: absolute; transform: translate(-50%, -50%); width: 28px; height: 28px; border-radius: 50%; border: 2.5px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,.5); color: #fff; font-size: 10.5px; font-weight: 800; display: flex; align-items: center; justify-content: center; z-index: 2; cursor: default; }
.rm-live .rm-live-pulse { position: absolute; width: 28px; height: 28px; border-radius: 50%; opacity: .45; z-index: 1; animation: rm-live-pulse 1.8s ease-out infinite; } .rm-live .rm-live-pulse { position: absolute; width: 28px; height: 28px; border-radius: 50%; opacity: .45; z-index: 1; animation: rm-live-pulse 1.8s ease-out infinite; }
@keyframes rm-live-pulse { 0% { transform: translate(-50%,-50%) scale(1); opacity: .45; } 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } } @keyframes rm-live-pulse { 0% { transform: translate(-50%,-50%) scale(1); opacity: .45; } 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } }

View File

@ -0,0 +1,147 @@
import { ref, computed } from 'vue'
/**
* useJobPool cerveau UNIQUE des pools « Jobs à assigner » (panneau flottant, colonne Jour/kanban, liste mobile).
*
* Historique : trois réimplémentations divergentes (assignJobsFiltered/assignGroups, kanbanPoolView, poolJobs) filtraient
* et triaient le MÊME tableau (assignPanel.jobs). Ce composable les fusionne : une instance PAR surface (créée et détenue
* par la page la carte du panneau flottant garde sa source de vérité en lisant `filteredJobs`). Le composant JobPool.vue
* ne fait que rendre `groups`/`filteredJobs` + les badges. Toute la logique d'assignation/drag reste dans la page.
*
* @param {Function} jobsGetter () => Array source réactive (ex. () => assignPanel.jobs)
* @param {Object} deps { jobCity, jobLocKey, todayISO, fmtDueLabel, distOf? } helpers PURS de la page (source unique)
* @param {Object} opts { variant:'floating'|'docked'|'mobile', defaultSort?, selectedDay?:()=>iso }
*/
export function useJobPool (jobsGetter, deps, opts = {}) {
const { jobCity, jobLocKey, todayISO, fmtDueLabel } = deps
const distOf = deps.distOf || null // (j) => km depuis le dépôt — seulement le variant « docked » l'utilise
const variant = opts.variant || 'floating'
const selectedDay = opts.selectedDay || (() => null)
const PRIO_RANK = { urgent: 0, high: 1, 'élevée': 1, elevee: 1, medium: 2, moyenne: 2, normal: 2, low: 3, basse: 3 }
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
const pr = j => (PRIO_RANK[String(j.priority || '').toLowerCase()] ?? 2)
const durMin = j => (+j.est_min || (+j.duration_h || 1) * 60)
// État de vue (PAR instance, vit dans la page → survit au démontage du composant lors d'un changement de vue).
const skillFilter = ref([]) // compétences cochées (vide = toutes)
const dateFilter = ref([]) // isos de date DUE cochés ('__overdue__' = toutes passées)
const sort = ref(opts.defaultSort || (variant === 'mobile' ? 'smart' : variant === 'docked' ? 'prio' : 'date'))
const sortDir = ref('asc')
const search = ref('')
const collapsed = ref(new Set())
const jobs = computed(() => jobsGetter() || [])
// Chips compétence : {k,n} par required_skill (le plus fréquent d'abord).
const skillChips = computed(() => {
const m = {}
for (const j of jobs.value) { const k = j.required_skill || 'autre'; m[k] = (m[k] || 0) + 1 }
return Object.entries(m).map(([k, n]) => ({ k, n })).sort((a, b) => b.n - a.n)
})
// Chips date DUE : dates passées regroupées en « ⏰ en retard », futures + « Sans date » séparées.
const dateChips = computed(() => {
const t = todayISO(); const m = {}
for (const j of jobs.value) { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; m[d] = (m[d] || 0) + 1 }
const out = []; let overdueN = 0
for (const [iso, n] of Object.entries(m)) {
if (iso !== 'Sans date' && iso < t) { overdueN += n; continue }
out.push({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: false })
}
out.sort((a, b) => String(a.iso).localeCompare(String(b.iso)))
if (overdueN) out.unshift({ iso: '__overdue__', n: overdueN, label: '⏰ en retard', overdue: true })
return out
})
function jobMatchesDateChips (j) {
const df = dateFilter.value; if (!df.length) return true
const ds = new Set(df); const t = todayISO()
const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'
return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t)
}
// Filtre : date d'abord (borne le périmètre) → compétence PAR ADRESSE (garde les jobs co-localisés d'un job filtré) → recherche texte.
const filteredJobs = computed(() => {
let arr = jobs.value
if (dateFilter.value.length) arr = arr.filter(jobMatchesDateChips)
const f = skillFilter.value
if (f.length) {
const set = new Set(f); const matchAddr = new Set()
for (const j of arr) if (set.has(j.required_skill || 'autre')) { const k = jobLocKey(j); if (k) matchAddr.add(k) }
arr = arr.filter(j => set.has(j.required_skill || 'autre') || matchAddr.has(jobLocKey(j)))
}
const q = search.value.trim().toLowerCase()
if (q) arr = arr.filter(j => [j.subject, j.service_type, j.customer_name, j.address, j.required_skill].some(x => String(x || '').toLowerCase().includes(q)))
return arr
})
// Info d'adresse (périmètre du jour, AVANT filtre compétence) : nb de jobs + compétences distinctes → badges.
const addrInfo = computed(() => {
const base = dateFilter.value.length ? jobs.value.filter(jobMatchesDateChips) : jobs.value
const m = {}
for (const j of base) { const k = jobLocKey(j); if (!k) continue; const g = m[k] || (m[k] = { n: 0, skills: new Set() }); g.n++; if (j.required_skill) g.skills.add(j.required_skill) }
return m
})
function jobAddrBadge (j) { const k = jobLocKey(j); const g = k && addrInfo.value[k]; if (!g || g.n < 2) return null; const skills = [...g.skills]; return { n: g.n, skills, multi: skills.length > 1 } }
function jobAssocOnly (j) { const f = skillFilter.value; return f.length ? !new Set(f).has(j.required_skill || 'autre') : false }
// Modes de tri qui PRODUISENT des en-têtes de groupe (par variant) ; les autres = une seule liste à plat.
const GROUPING = new Set(opts.groupingModes || (variant === 'mobile' ? ['sector'] : variant === 'docked' ? ['city', 'skill'] : ['group', 'skill', 'date', 'city', 'sector', 'priority']))
const secOf = j => jobCity(j) || 'Sans secteur'
const byDate = (a, b) => { const da = a.scheduled_date && a.scheduled_date !== 'Sans date' ? a.scheduled_date : '9999-99-99'; const db = b.scheduled_date && b.scheduled_date !== 'Sans date' ? b.scheduled_date : '9999-99-99'; return String(da).localeCompare(String(db)) }
const dueRank = j => { const t = todayISO(); const sel = selectedDay(); const d = j.scheduled_date; if (d && d === sel) return 0; if (!d || d === 'Sans date') return 4; if (d < t) return 1; if (d === t) return 2; return 3 }
const groups = computed(() => {
const list = filteredJobs.value; const mode = sort.value
// Groupe parent-enfant (installation → activation…), ordonné par step_order.
if (mode === 'group') {
const g = {}; for (const j of list) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
return Object.keys(g).map(k => ({ key: k, label: null, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
}
// Tris À PLAT (une seule liste, pas d'en-tête) : smart / prio / dist / dur.
if (!GROUPING.has(mode)) {
const cmp = mode === 'prio' ? (a, b) => pr(a) - pr(b) || byDate(a, b)
: mode === 'dist' && distOf ? (a, b) => (distOf(a) ?? 9e9) - (distOf(b) ?? 9e9)
: mode === 'dur' ? (a, b) => durMin(b) - durMin(a) || dueRank(a) - dueRank(b)
: (a, b) => dueRank(a) - dueRank(b) || pr(a) - pr(b) || byDate(a, b) // 'smart'
return [{ key: 'all', label: null, jobs: [...list].sort(cmp) }]
}
// Tris GROUPÉS : skill / date / city / sector / priority.
const keyOf = j => mode === 'skill' ? (j.required_skill || 'Sans compétence')
: (mode === 'city' || mode === 'sector') ? secOf(j)
: mode === 'priority' ? (j.priority || 'low')
: (j.scheduled_date || 'Sans date')
const labelOf = k => mode === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k)
: mode === 'date' ? '📅 ' + fmtDueLabel(k) : k
const g = {}; for (const j of list) { const k = keyOf(j); (g[k] = g[k] || []).push(j) }
const dir = sortDir.value === 'desc' ? -1 : 1
const keys = Object.keys(g).sort((a, b) => (mode === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b)) * dir)
return keys.map(k => {
const gj = g[k]
const mins = gj.reduce((s, j) => s + durMin(j), 0)
return { key: k, label: labelOf(k), jobs: gj, n: gj.length, mins }
})
})
const allCollapsed = computed(() => { const gs = groups.value; return gs.length > 0 && gs.every(g => g.label != null && collapsed.value.has(g.key)) })
// Actions sur l'état de vue
function toggleSkill (k) { const i = skillFilter.value.indexOf(k); if (i >= 0) skillFilter.value.splice(i, 1); else skillFilter.value.push(k) }
function clearSkills () { skillFilter.value = [] }
function toggleDate (iso) { const s = new Set(dateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); dateFilter.value = [...s] }
function clearDates () { dateFilter.value = [] }
function toggleCollapse (key) { const s = new Set(collapsed.value); s.has(key) ? s.delete(key) : s.add(key); collapsed.value = s }
function toggleCollapseAll () { collapsed.value = allCollapsed.value ? new Set() : new Set(groups.value.filter(g => g.label != null).map(g => g.key)) }
return {
variant,
// état (writable)
skillFilter, dateFilter, sort, sortDir, search, collapsed,
// dérivés
jobs, skillChips, dateChips, filteredJobs, groups, addrInfo, allCollapsed,
// helpers
jobMatchesDateChips, jobAddrBadge, jobAssocOnly,
toggleSkill, clearSkills, toggleDate, clearDates, toggleCollapse, toggleCollapseAll,
}
}