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:
parent
28f544a777
commit
2e6eb6dd01
|
|
@ -1033,7 +1033,13 @@
|
||||||
<div class="suggest-tech-hd">
|
<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>
|
<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>
|
<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) -->
|
<!-- 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 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>
|
<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>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="d in g.dayList" :key="d.iso" class="suggest-day">
|
<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">
|
<div v-for="(e, ei) in d.entries" :key="e.jobName" class="suggest-entry">
|
||||||
<span class="suggest-step">{{ ei + 1 }}</span>
|
<span class="suggest-step">{{ ei + 1 }}</span>
|
||||||
<span v-if="e.skill" class="assign-skill" :style="{ background: getTagColor(e.skill) }">{{ e.skill }}</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 ──
|
// ── 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 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).
|
// 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)
|
const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge)
|
||||||
function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} }
|
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++
|
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) }
|
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 => {
|
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 days = Object.values(g.days).sort((a, b) => a.iso.localeCompare(b.iso)).map(d => {
|
||||||
const r = nnOrder(d.entries, home); tkm += r.km
|
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 : 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).
|
// 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
|
let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false
|
||||||
if (!placeholder) for (const d of days) {
|
if (!placeholder) for (const d of days) {
|
||||||
const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso))
|
const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso))
|
||||||
|
|
@ -4218,7 +4262,7 @@ const suggestGroups = computed(() => {
|
||||||
return {
|
return {
|
||||||
...g, dayList: days, km: Math.round(tkm * 10) / 10, mins: estTravelMin(tkm),
|
...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,
|
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)
|
}).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.high { background: #f59e0b; }
|
||||||
.suggest-occ-fill.over { background: #ef4444; }
|
.suggest-occ-fill.over { background: #ef4444; }
|
||||||
.suggest-occ-txt { font-size: 11px; font-weight: 700; color: #475569; white-space: nowrap; }
|
.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 { 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:hover { border-color: #6366f1; }
|
||||||
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
|
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user