diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 1916c3c..3c083de 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1019,11 +1019,31 @@
Rien à répartir (aucun job éligible).
-
+
- + {{ g.techName }} - {{ g.jobs }} job(s) · {{ Math.round(g.hours * 10) / 10 }}h · 🚗 ≈{{ g.km }} km / {{ g.mins }} min · à assigner quand le quart week-end existe + {{ g.jobs }} job(s) · {{ Math.round(g.hours * 10) / 10 }}h · 🚗 ≈{{ g.km }} km / {{ g.mins }} min · déjà {{ g.existing }}h · file à assigner + +
+
+ {{ g.projected }}/{{ g.cap }}h +
+ Surcharge : {{ g.projected }}h projetées > {{ g.cap }}h de capacité (inclut le déjà-assigné) + + + {{ g.techName }} n'a pas de quart {{ g.noShiftDays.length > 1 ? 'sur ' + g.noShiftDays.length + ' jour(s)' : 'ce jour' }} — créer un quart 8-16 + + {{ g.placeholder ? 'Assigner cette tournée à un tech' : ('Déplacer / fusionner tous les jobs de ' + g.techName) }} + + {{ g.placeholder ? 'Assigner les' : 'Déplacer les' }} {{ g.jobs }} job(s) à… (compétence {{ g.skills.join(', ') }}) + + {{ initials(t.name) }} + {{ t.name }} · fusionner ({{ planCountByTech[t.id] }}) + Sans la compétence requise + + +
{{ d.label }} · {{ Math.round(d.hours * 10) / 10 }}h · 🚗 ≈{{ d.km }} km / {{ d.mins }} min
@@ -1034,14 +1054,16 @@ Tech rapide sur ce type ({{ e.eff }}) {{ e.subject }} · {{ e.customer }} {{ Math.round(e.dur * 10) / 10 }}h - Compétence « {{ e.skill }} » absente — dernier recours + {{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner Aucun quart publié ce jour (assignable, mais ⚠ hors quart) Déplacer vers un autre tech - - - {{ initials(t.name) }} + + Compétence « {{ e.skill }} » + + {{ initials(t.name) }} {{ t.name }} + Sans la compétence @@ -1764,6 +1786,8 @@ const dayList = computed(() => { }) function dowOf (iso) { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() } const isWeekendIso = iso => { const d = dowOf(iso); return d === 0 || d === 6 } // samedi/dimanche → PAS de quart auto (techs de fin de semaine dédiés) +// File PLACEHOLDER (dispatch auto) : pas un vrai tech → « à assigner ». `__wknd__` (legacy) + `__hold__||` (tournées sans quart, tout jour). +const isHoldId = id => id === '__wknd__' || (typeof id === 'string' && id.startsWith('__hold__')) // ── MODE JOUR (style Gaiia) : 1 seule colonne pleine largeur → timelines hautes + règle d'heures, idéal pour disposer // les jobs (glisser depuis le panneau « jobs du jour » vers un tech, réordonner via l'éditeur de jour). ───────────── const dayMode = computed(() => dayList.value.length === 1) @@ -2610,6 +2634,7 @@ async function saveLocPicker () { } else { await roster.setJobLocation(locPicker.job, locPicker.lat, locPicker.lon, locPicker.address) const j = dayEditor.list.find(x => x.name === locPicker.job); if (j) { j.lat = locPicker.lat; j.lon = locPicker.lon; if (locPicker.address) j.address = locPicker.address } + const pj = (assignPanel.jobs || []).find(x => x.name === locPicker.job); if (pj) { pj.latitude = locPicker.lat; pj.longitude = locPicker.lon; if (locPicker.address) pj.address = locPicker.address } // pool « à assigner » : latitude/longitude → rafraîchit le badge sans coords locPicker.open = false await reloadOccupancy(); if (_dayMap) refreshDayMap() $q.notify({ type: 'positive', icon: 'place', message: 'Emplacement enregistré sur le job' }) @@ -2698,8 +2723,15 @@ const assignNoCoord = computed(() => assignJobsFiltered.value.filter(j => !_hasJ // LOT (pool « à assigner ») : faire correspondre l'adresse de chaque job sans coords à la base RQA → GPS, écrit // sur le Dispatch Job + en mémoire (j.latitude/longitude) → les jobs apparaissent sur la carte du pool. Ambigus = à la main. async function batchLocatePool () { - const targets = assignPanel.jobs.filter(j => !_hasJobLL(j) && String(j.address || j.location_label || '').trim().length >= 3) - if (!targets.length) { $q.notify({ type: 'info', message: 'Tous les jobs à assigner ont déjà des coordonnées 🎉', timeout: 2000 }); return } + // MÊME périmètre que le badge assignNoCoord (jobs sans coords du filtre courant) → plus d'incohérence badge/notification. + const coordless = assignJobsFiltered.value.filter(j => !_hasJobLL(j)) + if (!coordless.length) { $q.notify({ type: 'positive', message: 'Tous les jobs à assigner ont déjà des coordonnées 🎉', timeout: 2000 }); return } + const targets = coordless.filter(j => String(j.address || j.location_label || '').trim().length >= 3) + if (!targets.length) { // sans coords MAIS sans adresse exploitable → pas d'auto-localisation possible : on ouvre le sélecteur manuel sur le 1er + $q.notify({ type: 'warning', icon: 'wrong_location', timeout: 4000, message: `${coordless.length} job(s) sans adresse exploitable — à situer à la main sur la carte` }) + openLocPicker(coordless[0]); return + } + const noAddr = coordless.length - targets.length assignPanel.locating = true let ok = 0, ambiguous = 0 try { @@ -2719,8 +2751,8 @@ async function batchLocatePool () { } catch (e) { ambiguous++ } } if (_assignMap) refreshAssignMap() - $q.notify({ type: ok ? 'positive' : 'warning', icon: 'place', timeout: 3500, - message: `${ok} job(s) localisé(s)` + (ambiguous ? ` · ${ambiguous} adresse(s) ambiguë(s) — situer à la main (📍)` : '') }) + $q.notify({ type: ok ? 'positive' : 'warning', icon: 'place', timeout: 4000, + message: `${ok} job(s) localisé(s)` + (ambiguous ? ` · ${ambiguous} adresse(s) ambiguë(s) — situer à la main (📍)` : '') + (noAddr ? ` · ${noAddr} sans adresse (📍)` : '') }) } catch (e) { err(e) } finally { assignPanel.locating = false } } function assignStops () { return assignLocations.value.stops } @@ -3870,7 +3902,7 @@ function buildSuggestion () { const isOver = j => (j.scheduled_date && j.scheduled_date !== 'Sans date' && j.scheduled_date < today) ? 0 : 1 const sorted = [...jobs].sort((a, b) => pr(a) - pr(b) || isOver(a) - isOver(b) || String(a.scheduled_date || '9').localeCompare(String(b.scheduled_date || '9')) || jobDur(b) - jobDur(a)) const proj = {} // techId|iso → { h, cap, pts:[[lat,lon]] } — charge PROJETÉE, initialisée depuis l'occupation actuelle - const cell = (tid, iso) => { const k = tid + '|' + iso; if (!proj[k]) { const load = techLoad(techDayOcc(tid, iso), hasShiftDay(tid, iso)); proj[k] = { h: load.h || 0, cap: load.cap || 8, pts: [] } } return proj[k] } + const cell = (tid, iso) => { const k = tid + '|' + iso; if (!proj[k]) { const load = techLoad(techDayOcc(tid, iso), hasShiftDay(tid, iso)); proj[k] = { h: load.h || 0, cap: load.cap || 8, pts: [], cities: new Set() } } return proj[k] } // Poids selon la STRATÉGIE : smart · best (meilleurs d'abord, cascade) · balance (round-robin) · enough (juste ce qu'il faut → réserve les experts) const strat = suggestDlg.strategy || 'smart' const W = strat === 'best' ? { dist: 0.4, skill: 15, eff: 65, cost: 20, load: -0.25, overqual: 0 } @@ -3879,10 +3911,12 @@ function buildSuggestion () { : { dist: 1.6, skill: 6, eff: 30, cost: 7, load: 0.6, overqual: 0 } const costs = techs.map(t => Number(t.cost_h) || 0); const cMin = Math.min(...costs); const cRange = (Math.max(...costs) - cMin) || 1 // normalisation coût 0..1 const plan = [] + const holdByDay = {} // jobs SANS tech de quart → regroupés par jour puis découpés en tournées PLACEHOLDER (pass 2) for (const j of sorted) { const reqSkill = j.required_skill || skillByType.value[j.service_type] || '' // compétence explicite, sinon déduite du TYPE de job const reqLevel = Number(j.required_level) || 1 // niveau imposé PAR LE JOB (persistant) — plus de défaut global par compétence - const jlat = +j.lat, jlon = +j.lon; const hasLL = isFinite(jlat) && isFinite(jlon) && (jlat || jlon) + const jlat = +(j.lat != null ? j.lat : j.latitude), jlon = +(j.lon != null ? j.lon : j.longitude); const hasLL = isFinite(jlat) && isFinite(jlon) && (jlat || jlon) // pool hub = latitude/longitude (bruts ERP) ; grille = lat/lon + const jcity = jobCity(j) || '' // municipalité — sert de repli de proximité quand le job n'a pas de coordonnées const dur = jobDur(j) const sched = (j.scheduled_date && j.scheduled_date !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(j.scheduled_date)) ? j.scheduled_date : null let cand @@ -3893,7 +3927,9 @@ function buildSuggestion () { if (!cand.length) continue let best = null for (const t of techs) { - const capable = !reqSkill || (t.skills || []).includes(reqSkill) + // SKILL = filtre DUR : un tech sans la compétence requise n'est JAMAIS candidat (ex. Josée-Anne ne fait ni réparation ni installation). + if (reqSkill && !(t.skills || []).includes(reqSkill)) continue + const capable = true const lvl = reqSkill ? (skillLevelOf(t, reqSkill) || 0) : 3 // maîtrise 0-5 (3 = neutre sans compétence requise) const eff = reqSkill ? skillEffOf(t, reqSkill) : (Number(t.efficiency) || 1) // vitesse : <1 = plus rapide pour cette compétence const effDur = dur * (eff > 0 ? eff : 1) // durée EFFECTIVE = un tech rapide prend moins de capacité pour le même job @@ -3902,31 +3938,66 @@ function buildSuggestion () { for (const iso of cand) { const c = cell(t.id, iso) const noShift = !hasShiftDay(t.id, iso) - if (isWeekendIso(iso) && noShift) continue // WEEK-END : jamais de quart auto → seuls les techs DÉJÀ de quart le week-end sont candidats const over = Math.max(0, (c.h + effDur) - c.cap) - let dist = null - if (hasLL) { - if (c.pts.length) dist = Math.min(...c.pts.map(p => haversineKm(p[0], p[1], jlat, jlon))) // proche des jobs déjà planifiés (route serrée) - else if (home && isFinite(+home.lat) && isFinite(+home.lon)) dist = haversineKm(+home.lat, +home.lon, jlat, jlon) - } - const under = Math.max(0, reqLevel - lvl) // sous le niveau requis pour ce job - let score = over * 120 + (noShift ? 60 : 0) + (capable ? 0 : 1000) + under * 200 // surcharge / hors-quart / incompétence / SOUS-qualifié - score += (dist == null ? 20 : dist) * W.dist // proximité + // PROXIMITÉ : vraie distance km si géolocalisé ; SINON repli par VILLE (regroupe la même municipalité, pénalise le mélange lointain type Ste-Barbe + Napierville) + let prox + if (hasLL && c.pts.length) prox = Math.min(...c.pts.map(p => haversineKm(p[0], p[1], jlat, jlon))) // proche des jobs déjà planifiés (route serrée) + else if (hasLL && home && isFinite(+home.lat) && isFinite(+home.lon)) prox = haversineKm(+home.lat, +home.lon, jlat, jlon) // 1er job → depuis le domicile + else if (jcity && c.cities.has(jcity)) prox = 0 // même ville qu'un job déjà planifié ce jour = même tournée + else if (c.cities.size) prox = 40 // le tech a déjà une AUTRE ville ce jour → forte pénalité (évite de mélanger des villes éloignées) + else prox = 15 // 1er job du tech ce jour, sans coords → neutre + const under = Math.max(0, reqLevel - lvl) // sous le niveau requis pour ce job (maîtrise insuffisante — compétence déjà garantie par le filtre dur) + let score = over * 120 + (noShift ? 60 : 0) + under * 200 // surcharge / hors-quart / SOUS-qualifié + score += prox * W.dist // proximité (km réels OU regroupement par ville) score += c.h * W.load // load>0 = étale (round-robin) · load<0 = remplit les 1ers (cascade) score += (5 - lvl) * W.skill // (best/smart) maîtrise faible pénalisée → avantage aux experts score += Math.max(0, lvl - reqLevel) * (W.overqual || 0) // (« juste ce qu'il faut ») sur-qualification pénalisée → garde les experts pour les jobs exigeants score += (eff - 1) * W.eff // rapide (eff<1) récompensé · lent (eff>1) pénalisé score += costN * W.cost // moins cher préféré - if (!best || score < best.score) best = { t, iso, score, dist, noShift, capable, over, lvl, eff, effDur } + if (!best || score < best.score) best = { t, iso, score, noShift, capable, over, lvl, eff, effDur } } } - if (!best) { - // Aucun tech valide. Job de WEEK-END sans quart publié → groupe PLACEHOLDER « en attente du quart » (ne se perd pas). - if (cand.length && cand.every(isWeekendIso)) plan.push({ jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '', techId: '__wknd__', techName: '⏳ Week-end — en attente du quart', placeholder: true, iso: cand[0], dur, skill: reqSkill || '', capable: true, noShift: false, over: false, lat: hasLL ? jlat : null, lon: hasLL ? jlon : null, lvl: 0, eff: 1, reqLevel }) - continue + // Commit sur un VRAI tech seulement s'il a un quart ce jour-là. Sinon → réservé pour une file PLACEHOLDER (pass 2), à assigner ensuite à un vrai tech. + if (best && !best.noShift) { + const c = cell(best.t.id, best.iso); c.h += best.effDur; if (hasLL) c.pts.push([jlat, jlon]); if (jcity) c.cities.add(jcity) // capacité + ville pour la proximité des suivants + plan.push({ jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '', techId: best.t.id, techName: best.t.name, iso: best.iso, dur, skill: reqSkill || '', capable: best.capable, noShift: false, over: best.over > 0, lat: hasLL ? jlat : null, lon: hasLL ? jlon : null, lvl: best.lvl, eff: best.eff, reqLevel }) + } else { + const holdIso = best ? best.iso : (sched && winSet.has(sched) ? sched : (cand.find(d => !isWeekendIso(d)) || cand[0])) + ;(holdByDay[holdIso] = holdByDay[holdIso] || []).push({ j, dur, reqSkill, reqLevel, jlat, jlon, hasLL, jcity, capable: !best || best.capable }) + } + } + // PASS 2 — jobs sans tech de quart → files PLACEHOLDER par jour, groupées PAR COMPÉTENCE puis découpées en tournées ~8 h par PROXIMITÉ + // (km réels, sinon même ville ; >60 km = nouvelle tournée). Une tournée = 1 compétence + 1 secteur → assignable à un tech qui a CETTE compétence. + const HOLD_CAP = 8 + for (const iso of Object.keys(holdByDay)) { + const bySkill = {}; for (const it of holdByDay[iso]) { const k = it.reqSkill || '__any__'; (bySkill[k] = bySkill[k] || []).push(it) } // 1 groupe par compétence + const dLbl = (FR_DOW[dowOf(iso)] || '') + ' ' + iso.slice(8) // ex. « ven 03 » + let ti = 0 + for (const sk of Object.keys(bySkill)) { + const items = bySkill[sk].slice().sort((a, b) => (a.jcity || '').localeCompare(b.jcity || '')) // départ groupé par ville → tournées cohérentes + const queues = [] + for (const it of items) { + let bq = null, bd = Infinity + for (const q of queues) { + if (q.h + it.dur > HOLD_CAP + 0.01) continue // tournée pleine (~8 h) + let d + if (it.hasLL && q.pts.length) d = Math.min(...q.pts.map(p => haversineKm(p[0], p[1], it.jlat, it.jlon))) + else if (it.jcity && q.cities.has(it.jcity)) d = 0 + else if (q.cities.size || q.pts.length) d = 40 + else d = 15 + if (d < bd) { bd = d; bq = q } + } + if (!bq || bd > 60) { bq = { h: 0, pts: [], cities: new Set(), jobs: [] }; queues.push(bq) } // trop loin ou tout plein → nouvelle tournée + bq.h += it.dur; if (it.hasLL) bq.pts.push([it.jlat, it.jlon]); if (it.jcity) bq.cities.add(it.jcity); bq.jobs.push(it) + } + const skLbl = sk === '__any__' ? '' : ' ' + sk + queues.forEach((q) => { + ti++ + const hid = `__hold__|${iso}|${ti}` + const label = `⏳ ${dLbl}${skLbl} — tournée ${ti} (à assigner)` + for (const it of q.jobs) plan.push({ jobName: it.j.name, subject: it.j.subject || it.j.service_type || it.j.name, customer: it.j.customer_name || it.j.location_label || '', techId: hid, techName: label, placeholder: true, iso, dur: it.dur, skill: it.reqSkill || '', capable: it.capable, noShift: false, over: false, lat: it.hasLL ? it.jlat : null, lon: it.hasLL ? it.jlon : null, lvl: 0, eff: 1, reqLevel: it.reqLevel }) + }) } - const c = cell(best.t.id, best.iso); c.h += best.effDur; if (hasLL) c.pts.push([jlat, jlon]) // capacité consommée = durée EFFECTIVE (vitesse du tech) - plan.push({ jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '', techId: best.t.id, techName: best.t.name, iso: best.iso, dur, skill: reqSkill || '', capable: best.capable, noShift: best.noShift, over: best.over > 0, lat: hasLL ? jlat : null, lon: hasLL ? jlon : null, lvl: best.lvl, eff: best.eff, reqLevel }) } suggestDlg.plan = plan } @@ -4014,7 +4085,17 @@ const suggestGroups = computed(() => { 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) } }) - return { ...g, dayList: days, km: Math.round(tkm * 10) / 10, mins: estTravelMin(tkm) } + // Occupation : capacité (quarts réels ou 8h) + charge DÉJÀ présente sur ces jours, + les heures proposées → barre projetée. + let cap = 0, existing = 0, placeholder = isHoldId(g.techId) + if (!placeholder) for (const d of days) { const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso)); cap += (l.cap || 8); existing += (l.h || 0) } + const projected = existing + g.hours + const noShiftDays = placeholder ? [] : days.filter(d => !isWeekendIso(d.iso) && !hasShiftDay(g.techId, d.iso)).map(d => d.iso) // jours (hors WE) sans quart → alerte + créer quart + const skills = [...new Set(days.flatMap(d => d.entries).map(e => e.skill).filter(Boolean))] // compétences requises par cette file → cible d'assignation doit les couvrir + return { + ...g, dayList: days, km: Math.round(tkm * 10) / 10, mins: estTravelMin(tkm), + cap: Math.round(cap * 10) / 10, existing: Math.round(existing * 10) / 10, projected: Math.round(projected * 10) / 10, + fillPct: cap ? Math.min(100, Math.round(projected / cap * 100)) : 0, over: !placeholder && projected > cap + 0.1, placeholder, noShiftDays, skills + } }).sort((a, b) => b.jobs - a.jobs) }) // Carte des tournées (grande carte dans la revue) : 1 couleur par tech, domicile + arrêts ordonnés, POUR UN SEUL JOUR. @@ -4024,7 +4105,7 @@ const suggestRoutes = computed(() => { const day = suggestMapDay.value || (suggestWindow.value[0] || '') const out = []; let ci = 0 for (const g of suggestGroups.value) { - if (g.techId === '__wknd__') continue + if (g.placeholder) continue const d = (g.dayList || []).find(x => x.iso === day); if (!d || !d.entries.length) continue const stops = d.entries.filter(e => e.lat != null && e.lon != null).map((e, i) => ({ lat: +e.lat, lon: +e.lon, seq: i + 1, subject: e.subject })) if (!stops.length) continue @@ -4038,7 +4119,38 @@ const suggestRoutes = computed(() => { watch(() => suggestDlg.view, (v) => { if (v === 'map') { if (!suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || ''; nextTick(() => { _suggestMap ? refreshSuggestMap() : initSuggestMap() }) } }) watch([suggestMapDay, suggestGroups], () => { if (suggestDlg.view === 'map' && _suggestMap) refreshSuggestMap() }) watch(() => suggestDlg.open, (o) => { if (!o) destroySuggestMap() }) -function moveSuggestEntry (entry, techId, techName) { entry.techId = techId; entry.techName = techName } // réassignation manuelle dans la revue (recalc réactif) +// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées). +const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s)) +// Existe-t-il AU MOINS un tech capable ? Sinon (données de compétences incomplètes) on ne bloque personne (évite de piéger une file inassignable). +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 || ''))) } +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) { + e.techId = techId; e.techName = techName + const hold = isHoldId(techId) + e.placeholder = hold + e.noShift = hold ? false : !hasShiftDay(techId, e.iso) + e.capable = hold ? true : covers(techById.value[techId] || {}, [e.skill]) // hors compétence → marqué (icône ⚠) +} +function moveSuggestEntry (entry, techId, techName) { reassignEntry(entry, techId, techName) } // déplacer 1 job vers un autre tech (ou une file placeholder) +function moveSuggestGroup (g, techId, techName) { // déplace/fusionne TOUTE la file d'un tech (ou d'un placeholder) vers un autre tech + if (!techId || techId === g.techId) return + const targetHad = suggestDlg.plan.some(e => e.techId === techId) // la cible avait déjà une file → c'est une FUSION + let n = 0; for (const e of suggestDlg.plan) if (e.techId === g.techId) { reassignEntry(e, techId, techName); n++ } + $q.notify({ type: 'info', icon: targetHad ? 'merge' : 'drive_file_move', message: `${n} job(s) ${targetHad ? 'fusionnés dans' : '→'} ${techName}`, timeout: 2800 }) +} +// Files déjà présentes dans le plan (pour signaler « fusionner » dans le menu de déplacement). +const planCountByTech = computed(() => { const m = {}; for (const e of suggestDlg.plan) m[e.techId] = (m[e.techId] || 0) + 1; return m }) +// Crée LOCALEMENT le quart 8-16 pour les jours sans quart de cette file (persisté via Publier, comme l'option makeShifts). PAS d'écriture immédiate. +async function createShiftForGroup (g) { + const days = g.noShiftDays || []; if (!days.length) return + const tpl = await ensureWindowTpl(8, 16); if (!tpl) { $q.notify({ type: 'negative', message: 'Modèle de quart 8-16 introuvable' }); return } + pushHistory() + for (const iso of days) setCellReplace(g.techId, g.techName, iso, tpl) // local → recalcule hasShiftDay/occupation ; Publier pour enregistrer + $q.notify({ type: 'positive', icon: 'event_available', message: `${days.length} quart(s) 8-16 créé(s) pour ${g.techName} — Publier pour enregistrer`, timeout: 3500 }) +} function removeSuggestEntry (entry) { suggestDlg.plan = suggestDlg.plan.filter(e => e !== entry) } // retiré du plan → reste au pool // Applique : regroupe par tech×jour → réutilise assignNames (assignation serveur + occupation optimiste + retrait du pool). async function applySuggestion () { @@ -4049,7 +4161,7 @@ async function applySuggestion () { let shiftsMade = 0 if (suggestDlg.makeShifts) { const tpl = await ensureWindowTpl(8, 16) - if (tpl) { let first = true; for (const k of Object.keys(groups)) { const g = groups[k]; if (g.techId === '__wknd__' || isWeekendIso(g.iso)) continue; if (!hasShiftDay(g.techId, g.iso)) { const t = (visibleTechs.value || []).find(x => x.id === g.techId); if (t) { if (first) { pushHistory(); first = false } setCellReplace(g.techId, t.name, g.iso, tpl); shiftsMade++ } } } } // JAMAIS de quart auto le week-end + if (tpl) { let first = true; for (const k of Object.keys(groups)) { const g = groups[k]; if (isHoldId(g.techId) || isWeekendIso(g.iso)) continue; if (!hasShiftDay(g.techId, g.iso)) { const t = (visibleTechs.value || []).find(x => x.id === g.techId); if (t) { if (first) { pushHistory(); first = false } setCellReplace(g.techId, t.name, g.iso, tpl); shiftsMade++ } } } } // JAMAIS de quart auto le week-end } let total = 0 for (const k of Object.keys(groups)) { @@ -4058,8 +4170,8 @@ async function applySuggestion () { try { const done = await assignNames(g.names, t, d); total += (done || []).length } catch (e) { err(e) } } suggestDlg.applying = false; suggestDlg.open = false - const pending = suggestDlg.plan.filter(e => e.placeholder).length // jobs week-end laissés EN ATTENTE (pas assignés) - if (total || pending) $q.notify({ type: pending ? 'warning' : 'positive', message: (total ? `${total} job(s) répartis` : '') + (shiftsMade ? ` · ${shiftsMade} quart(s) 8–16 créé(s)` : '') + (pending ? ` · ⏳ ${pending} job(s) WEEK-END en attente — crée le quart samedi/dimanche puis relance` : (total ? ' — Publier pour enregistrer' : '')), timeout: 7000 }) + const pending = suggestDlg.plan.filter(e => e.placeholder).length // jobs en file PLACEHOLDER (pas assignés — à confier à un vrai tech avant d'appliquer) + if (total || pending) $q.notify({ type: pending ? 'warning' : 'positive', message: (total ? `${total} job(s) répartis` : '') + (shiftsMade ? ` · ${shiftsMade} quart(s) 8–16 créé(s)` : '') + (pending ? ` · ⏳ ${pending} job(s) en file à assigner — assigne les tournées placeholder à un tech (bouton 👤+) puis applique` : (total ? ' — Publier pour enregistrer' : '')), timeout: 7000 }) await reloadPool() } // Mobile : assignation AU TOUCHER (le glisser HTML5 ne marche pas au doigt) + MULTI-sélection. @@ -4869,6 +4981,12 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return .suggest-leg { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: #37474f; } .suggest-leg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; flex-shrink: 0; } .suggest-tech-hd { display: flex; align-items: center; padding: 6px 10px; background: #f7f5fc; border-bottom: 1px solid #eef2f7; } +.suggest-occ { display: inline-flex; align-items: center; gap: 6px; margin-left: 12px; min-width: 130px; } +.suggest-occ-bar { flex: 1; height: 6px; border-radius: 3px; background: #e2e8f0; overflow: hidden; min-width: 60px; } +.suggest-occ-fill { height: 100%; background: #16a34a; border-radius: 3px; transition: width .2s; } +.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-day { padding: 2px 8px 6px; } .suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; } .suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; }