feat(dispatch): review shows tech's existing jobs + sector, and ordered skills inline

- Each tech group day now exposes the jobs ALREADY assigned (OPS
  occupancy + legacy F field tickets) behind a "🔒 N déjà assigné(s) ·
  cities" toggle — locked greyed rows (time, skill, subject, duration).
  Header shows 📍 sector(s) where the tech already works, so the
  dispatcher can adjust accordingly.
- Header shows the tech's skills IN PRIORITY ORDER (★ first + "+N"),
  clickable → same drag-reorder skill editor (visible answer to
  "ordonner les compétences depuis le résultat").
- cityOfOcc: heterogeneous legacy subjects ("Type | Ville | Client" vs
  "Ville | Client") → picks the first city-looking pipe segment (no
  digits, ≤4 words, no job stop-words), else address tail, else
  " - Ville" suffix. Avoids garbage like "Bris de fibre" as a sector.

Verified: Simon Clot-Gagnon → 📍 Sherrington, 2 locked repair rows;
Anthony Dion → ★ installation · réparation · +4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 18:22:57 -04:00
parent 28f544a777
commit 2e6eb6dd01

View File

@ -1033,7 +1033,13 @@
<div class="suggest-tech-hd">
<q-avatar size="24px" :color="g.placeholder ? 'amber-2' : 'blue-grey-1'" :text-color="g.placeholder ? 'amber-9' : 'blue-grey-8'" style="font-size:10px;font-weight:700"><q-icon v-if="g.placeholder" name="hourglass_top" size="15px" /><template v-else>{{ initials(g.techName) }}</template></q-avatar>
<b class="q-ml-sm">{{ g.techName }}</b>
<span class="text-grey-6 q-ml-sm" style="font-size:12px">{{ g.jobs }} job(s) · {{ Math.round(g.hours * 10) / 10 }}h<span v-if="g.km"> · 🚗 {{ g.km }} km / {{ g.mins }} min</span><span v-if="!g.placeholder && g.existing > 0" class="text-orange-8" title="Heures déjà assignées à ce tech ces jours-là"> · déjà {{ g.existing }}h</span><span v-if="g.placeholder" class="text-amber-9"> · file à assigner</span></span>
<!-- Compétences du tech DANS L'ORDRE de priorité (1re = principale) clic = éditer/réordonner (même éditeur) -->
<span v-if="!g.placeholder && techById[g.techId]" class="suggest-hd-skills q-ml-xs" @click.stop="openSkillEditorFor(g.techId, $event)">
<span v-for="(sk, si) in (techById[g.techId].skills || []).slice(0, 2)" :key="sk" class="assign-skill" :style="{ background: getTagColor(sk), opacity: si === 0 ? 1 : 0.75 }">{{ si === 0 ? ' ' : '' }}{{ sk }}</span>
<span v-if="(techById[g.techId].skills || []).length > 2" class="suggest-hd-more">+{{ (techById[g.techId].skills || []).length - 2 }}</span>
<q-tooltip>Ordre de priorité : {{ (techById[g.techId].skills || []).join(' → ') || 'aucune compétence' }} clic pour éditer/réordonner</q-tooltip>
</span>
<span class="text-grey-6 q-ml-sm" style="font-size:12px">{{ g.jobs }} job(s) · {{ Math.round(g.hours * 10) / 10 }}h<span v-if="g.km"> · 🚗 {{ g.km }} km / {{ g.mins }} min</span><span v-if="!g.placeholder && g.existing > 0" class="text-orange-8" title="Heures déjà assignées à ce tech ces jours-là"> · déjà {{ g.existing }}h</span><span v-if="g.sectors && g.sectors.length" class="text-teal-8" title="Secteur(s) où ce tech a DÉJÀ des jobs assignés"> · 📍 {{ g.sectors.slice(0, 2).join(', ') }}<template v-if="g.sectors.length > 2"> +{{ g.sectors.length - 2 }}</template></span><span v-if="g.placeholder" class="text-amber-9"> · file à assigner</span></span>
<!-- Occupation projetée = charge déjà assignée + heures suggérées / capacité (quarts réels ou 8h) -->
<div v-if="!g.placeholder && g.cap" class="suggest-occ" :title="'Occupation projetée ' + g.projected + 'h / ' + g.cap + 'h (déjà ' + g.existing + 'h assigné + ' + (Math.round(g.hours*10)/10) + 'h proposé)'">
<div class="suggest-occ-bar"><div class="suggest-occ-fill" :class="{ over: g.over, high: !g.over && g.fillPct >= 85 }" :style="{ width: g.fillPct + '%' }"></div></div>
@ -1058,7 +1064,22 @@
</q-btn>
</div>
<div v-for="d in g.dayList" :key="d.iso" class="suggest-day">
<div class="suggest-day-lbl">{{ d.label }} <span class="text-grey-6">· {{ Math.round(d.hours * 10) / 10 }}h<span v-if="d.km"> · 🚗 {{ d.km }} km / {{ d.mins }} min</span></span></div>
<div class="suggest-day-lbl">{{ d.label }} <span class="text-grey-6">· {{ Math.round(d.hours * 10) / 10 }}h<span v-if="d.km"> · 🚗 {{ d.km }} km / {{ d.mins }} min</span></span>
<!-- Jobs DÉJÀ assignés (occupation) : cliquer pour voir permet de s'ajuster au secteur existant du tech -->
<span v-if="d.prevJobs && d.prevJobs.length" class="suggest-prev-toggle" @click="prevOpen[g.techId + '|' + d.iso] = !prevOpen[g.techId + '|' + d.iso]">
🔒 {{ d.prevJobs.length }} déjà assigné(s)<template v-if="d.prevCities.length"> · {{ d.prevCities.slice(0, 2).join(', ') }}</template>
<q-icon :name="prevOpen[g.techId + '|' + d.iso] ? 'expand_less' : 'expand_more'" size="13px" />
</span>
</div>
<template v-if="prevOpen[g.techId + '|' + d.iso]">
<div v-for="pj in d.prevJobs" :key="'prev' + pj.name" class="suggest-entry suggest-prev">
<q-icon name="lock" size="11px" color="grey-5" class="q-mr-xs" />
<span v-if="pj.start" class="text-grey-6 q-mr-xs" style="font-size:11px">{{ pj.start }}</span>
<span v-if="pj.skill" class="assign-skill" :style="{ background: getTagColor(pj.skill) }">{{ pj.skill }}</span>
<span class="suggest-entry-t ellipsis text-grey-7">{{ pj.subject }}<span v-if="pj.customer" class="text-grey-5"> · {{ pj.customer }}</span></span>
<span class="text-grey-5 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round((pj.dur || 0) * 10) / 10 }}h</span>
</div>
</template>
<div v-for="(e, ei) in d.entries" :key="e.jobName" class="suggest-entry">
<span class="suggest-step">{{ ei + 1 }}</span>
<span v-if="e.skill" class="assign-skill" :style="{ background: getTagColor(e.skill) }">{{ e.skill }}</span>
@ -3915,6 +3936,7 @@ function selectAllSector (city) {
}
// D : SUGGESTION de répartition (glouton proximité + charge + compétence), REVUE en surcouche avant d'appliquer
const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: false, applying: false, fromSel: false, techSel: {}, makeShifts: true, strategy: 'optimize', view: 'list', jobTime: {} }) // défaut = solveur VRP (1re génération optimise déjà, plus de placeholders) · strategy: optimize|smart|best|balance|enough
const prevOpen = reactive({}) // revue : sections « déjà assigné(s) » dépliées (clé techId|iso)
// Contraintes horaires par job ( Optimiser) : AM/PM (fenêtre dure) + « tôt » (urgence début de journée). Session (clé = jobName, survit aux ré-optimisations).
const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge)
function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} }
@ -4196,15 +4218,37 @@ const suggestGroups = computed(() => {
d.entries.push(e); d.hours += e.dur; g.hours += e.dur; g.jobs++
}
const dnumOf = iso => { const d = (dayList.value || []).find(x => x.iso === iso); return d ? (d.dow + ' ' + d.dnum) : iso.slice(5) }
// Ville d'un job d'occupation/legacy : sujets hétérogènes (« Ville | Client » OU « Type | Ville | Client ») on prend le 1er segment
// qui RESSEMBLE à une ville (4 mots, sans chiffres, sans mots-métier), sinon la fin d'adresse, sinon le suffixe « - Ville ».
const OCC_STOP = /bris|fibre|install|r[ée]par|modem|internet|panne|coupure|remplac|ramass|retrait|d[ée]mant|note|t[ée]l[ée]|\btv\b|wifi|borne|activation|transfert|[ée]quipement|technicien|est\.|time|urgent|\bsca\b|drop|bo[iî]tier|d[ée]roulage|registre|camping/i
const cityLike = s => { s = String(s || '').trim(); if (!s || /\d/.test(s)) return ''; if (s.split(/\s+/).length > 4) return ''; if (OCC_STOP.test(s)) return ''; return s }
const cityOfOcc = j => {
const parts = String(j.address || '').split(',').map(x => x.trim()).filter(Boolean)
if (parts.length >= 2) { const c = cityLike(parts[parts.length - 1]); if (c) return c }
for (const seg of String(j.subject || '').split('|')) {
const c = cityLike(seg); if (c) return c
if (seg.includes(' - ')) { const c2 = cityLike(seg.split(' - ').pop()); if (c2) return c2 }
}
return ''
}
return Object.values(byTech).map(g => {
const home = techHomes.value[g.techId]; let tkm = 0
const home = techHomes.value[g.techId]; const isPh = isHoldId(g.techId); let tkm = 0
const days = Object.values(g.days).sort((a, b) => a.iso.localeCompare(b.iso)).map(d => {
const r = nnOrder(d.entries, home); tkm += r.km
return { ...d, entries: r.ordered, km: r.km, mins: estTravelMin(r.km), label: dnumOf(d.iso) }
// Jobs DÉJÀ assignés à ce tech ce jour (occupation OPS + tickets legacy F terrain) visibles + secteur où il est déjà
let prevJobs = []
if (!isPh) {
prevJobs = (((techDayOcc(g.techId, d.iso) || {}).jobs) || []).filter(j => !j.cancelled)
const lg = legacyLoad.value[g.techId + '|' + d.iso]
for (const j of ((lg && lg.jobs) || [])) if (j.field) prevJobs.push({ name: 'LEG:' + j.id, subject: j.subject, skill: j.skill || '', dur: Number(j.est_h) || 0, legacy: true })
}
const prevCities = [...new Set(prevJobs.map(cityOfOcc).filter(c => c && c !== 'Sans ville'))]
return { ...d, entries: r.ordered, km: r.km, mins: estTravelMin(r.km), label: dnumOf(d.iso), prevJobs, prevCities }
})
const sectors = [...new Set(days.flatMap(d => d.prevCities))] // secteur(s) où le tech a DÉJÀ des jobs s'ajuster en conséquence
// Occupation : capacité (quarts réels ou 8h) + charge DÉJÀ présente sur ces jours, + les heures proposées barre projetée.
// Occupation PAR JOUR (le vrai plafond = 8h/jour, PAS la somme des 2 jours) : on affiche le JOUR le plus chargé (contrainte qui lie).
const placeholder = isHoldId(g.techId)
const placeholder = isPh
let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false
if (!placeholder) for (const d of days) {
const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso))
@ -4218,7 +4262,7 @@ const suggestGroups = computed(() => {
return {
...g, dayList: days, km: Math.round(tkm * 10) / 10, mins: estTravelMin(tkm),
cap: Math.round(worstCap * 10) / 10, existing: Math.round(existing * 10) / 10, projected: Math.round(worstProj * 10) / 10,
fillPct: Math.min(100, Math.round(worstPct)), over: !placeholder && over, placeholder, noShiftDays, skills
fillPct: Math.min(100, Math.round(worstPct)), over: !placeholder && over, placeholder, noShiftDays, skills, sectors
}
}).sort((a, b) => b.jobs - a.jobs)
})
@ -5113,6 +5157,12 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.suggest-occ-fill.high { background: #f59e0b; }
.suggest-occ-fill.over { background: #ef4444; }
.suggest-occ-txt { font-size: 11px; font-weight: 700; color: #475569; white-space: nowrap; }
.suggest-hd-skills { display: inline-flex; align-items: center; gap: 3px; cursor: pointer; }
.suggest-hd-skills:hover .assign-skill { filter: brightness(1.12); }
.suggest-hd-more { font-size: 10px; font-weight: 700; color: #64748b; background: #e2e8f0; border-radius: 8px; padding: 0 5px; line-height: 15px; }
.suggest-prev-toggle { display: inline-flex; align-items: center; gap: 2px; margin-left: 8px; font-size: 10.5px; font-weight: 600; color: #64748b; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 8px; padding: 0 6px; cursor: pointer; user-select: none; }
.suggest-prev-toggle:hover { border-color: #94a3b8; }
.suggest-prev { opacity: 0.75; background: #f8fafc; border-radius: 6px; }
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
.jt-chip:hover { border-color: #6366f1; }
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }