feat(dispatch): review job-move menu ranks techs by proximity + occupancy

Each job's move menu in the optimize review now reuses techsForJob()
(capable → with-shift → nearest → least-loaded) instead of alphabetical,
showing per tech: distance (home→job km) and load (h/cap). Lets you drop
a "non casé" job onto the best nearby, least-busy capable tech — the
manual version of "move a job to free a tech's capacity". Works on
placeholder/unassigned entries too (same row).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 15:05:05 -04:00
parent f5a7547c1f
commit 2566976191

View File

@ -1080,13 +1080,13 @@
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'pm' }" @click="toggleJobAmpm(e.jobName, 'pm')"><q-tooltip>Après-midi (12-16) -optimiser pour appliquer</q-tooltip>PM</span>
<span class="jt-chip jt-early" :class="{ on: jobTimeOf(e.jobName).early }" @click="toggleJobEarly(e.jobName)"><q-tooltip>Urgent servir en DÉBUT de journée (-optimiser)</q-tooltip></span>
<q-space />
<q-btn flat dense round size="sm" icon="swap_horiz" color="grey-7"><q-tooltip>Déplacer vers un autre tech</q-tooltip>
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:220px;max-height:42vh;overflow:auto">
<q-item-label v-if="e.skill" header class="q-py-xs">Compétence « {{ e.skill }} »</q-item-label>
<q-item v-for="t in capableTechsFirst([e.skill])" :key="t.id" clickable v-close-popup @click="moveSuggestEntry(e, t.id, t.name)" :active="t.id === e.techId" :disable="!covers(t, [e.skill]) && anyCovers([e.skill])">
<q-item-section avatar style="min-width:30px"><q-avatar size="22px" :color="covers(t, [e.skill]) ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(t.name) }}</q-avatar></q-item-section>
<q-item-section>{{ t.name }}</q-item-section>
<q-item-section side v-if="!covers(t, [e.skill])"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence</q-tooltip></q-icon></q-item-section>
<q-btn flat dense round size="sm" icon="swap_horiz" color="grey-7"><q-tooltip>Déplacer vers un autre tech (classés par proximité + charge)</q-tooltip>
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:46vh;overflow:auto">
<q-item-label header class="q-py-xs">Techs par <b>proximité</b> + <b>charge</b><span v-if="e.skill" class="text-grey-6"> · {{ e.skill }}</span></q-item-label>
<q-item v-for="r in techsForEntry(e)" :key="r.id" clickable v-close-popup @click="moveSuggestEntry(e, r.id, r.name)" :active="r.id === e.techId" :disable="!r.capable && anyCovers([e.skill])">
<q-item-section avatar style="min-width:30px"><q-avatar size="22px" :color="r.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(r.name) }}</q-avatar></q-item-section>
<q-item-section>{{ r.name }}<div class="text-grey-6" style="font-size:10px">{{ r.distKm != null ? '📍 ≈' + (Math.round(r.distKm * 10) / 10) + ' km' : 'sans domicile' }} · {{ r.h }}/{{ r.cap }}h<span v-if="r.noShift"> · sans quart</span></div></q-item-section>
<q-item-section side v-if="!r.capable"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence</q-tooltip></q-icon></q-item-section>
</q-item>
</q-list></q-menu>
</q-btn>
@ -4227,6 +4227,8 @@ const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skill
const anyCovers = (skills) => !((skills || []).filter(Boolean).length) || (visibleTechs.value || []).some(t => covers(t, skills))
// Liste de techs triée CAPABLES d'abord (couvrent la/les compétence(s)), puis alphabétique pour les menus d'assignation.
function capableTechsFirst (skills) { return [...(visibleTechs.value || [])].sort((a, b) => (covers(b, skills) - covers(a, skills)) || String(a.name || '').localeCompare(String(b.name || ''))) }
// Une entrée de la revue objet job-like pour réutiliser techsForJob (classement proximité + occupation).
function techsForEntry (e) { return techsForJob({ required_skill: e.skill, lat: e.lat, lon: e.lon, scheduled_date: e.iso }) }
const techById = computed(() => { const m = {}; for (const t of (visibleTechs.value || [])) m[t.id] = t; return m })
// Réassigne UNE entrée : quand la cible est un VRAI tech, on sort du placeholder + on (re)calcule quart manquant ET compétence.
function reassignEntry (e, techId, techName) {