diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 209cf2c..a72bf9a 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1033,7 +1033,13 @@
{{ g.techName }} - {{ g.jobs }} job(s) · {{ Math.round(g.hours * 10) / 10 }}h · 🚗 ≈{{ g.km }} km / {{ g.mins }} min · déjà {{ g.existing }}h · file à assigner + + + {{ si === 0 ? '★ ' : '' }}{{ sk }} + +{{ (techById[g.techId].skills || []).length - 2 }} + Ordre de priorité : {{ (techById[g.techId].skills || []).join(' → ') || 'aucune compétence' }} — clic pour éditer/réordonner + + {{ g.jobs }} job(s) · {{ Math.round(g.hours * 10) / 10 }}h · 🚗 ≈{{ g.km }} km / {{ g.mins }} min · déjà {{ g.existing }}h · 📍 {{ g.sectors.slice(0, 2).join(', ') }} · file à assigner
@@ -1058,7 +1064,22 @@
-
{{ d.label }} · {{ Math.round(d.hours * 10) / 10 }}h · 🚗 ≈{{ d.km }} km / {{ d.mins }} min
+
{{ d.label }} · {{ Math.round(d.hours * 10) / 10 }}h · 🚗 ≈{{ d.km }} km / {{ d.mins }} min + + + 🔒 {{ d.prevJobs.length }} déjà assigné(s) + + +
+
{{ ei + 1 }} {{ e.skill }} @@ -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; }