feat(dispatch): skill-driven auto-dispatch — route grouping, placeholder queues, shift check
Overhaul of the Planification « Suggérer » (auto-dispatch) flow: - fix: read latitude/longitude (hub pool field), not lat/lon — the distance scoring was inert (NaN) so jobs scattered; they now cluster by real route (haversine) with a same-city fallback - skill = HARD filter: incapable techs are no longer candidates (Josée-Anne no longer gets réparation/installation). Assign/move menus list capable techs first and block the rest, unless no capable alternative exists (avoids trapping an unassignable queue) - placeholder queues for any no-shift day (not just weekend), grouped by skill then geography into ~8h tournées; the owner is swappable to a real tech, and two queues can be merged - per-tech occupation bar in the review (existing load + suggested / capacity, red on overload); "Créer quart 8-16" button when the assigned tech has no shift that day (local, saved on Publier) - fix: "N jobs sans coordonnées" badge vs locate mismatch — honest message + open the manual map picker for address-less jobs; also sync pool jobs (latitude/longitude) on manual save so the badge refreshes Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
72e84d6b8d
commit
f6e5128859
|
|
@ -1019,11 +1019,31 @@
|
||||||
</div>
|
</div>
|
||||||
<div v-show="suggestDlg.view !== 'map'">
|
<div v-show="suggestDlg.view !== 'map'">
|
||||||
<div v-if="!suggestDlg.plan.length" class="text-grey-6 text-center q-pa-lg">Rien à répartir (aucun job éligible).</div>
|
<div v-if="!suggestDlg.plan.length" class="text-grey-6 text-center q-pa-lg">Rien à répartir (aucun job éligible).</div>
|
||||||
<div v-for="g in suggestGroups" :key="g.techId" class="suggest-tech" :class="{ 'suggest-wknd': g.techId === '__wknd__' }">
|
<div v-for="g in suggestGroups" :key="g.techId" class="suggest-tech" :class="{ 'suggest-wknd': g.placeholder }">
|
||||||
<div class="suggest-tech-hd">
|
<div class="suggest-tech-hd">
|
||||||
<q-avatar size="24px" :color="g.techId === '__wknd__' ? 'amber-2' : 'blue-grey-1'" :text-color="g.techId === '__wknd__' ? 'amber-9' : 'blue-grey-8'" style="font-size:10px;font-weight:700"><q-icon v-if="g.techId === '__wknd__'" 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.techId !== '__wknd__'"> · 🚗 ≈{{ g.km }} km / {{ g.mins }} min</span><span v-if="g.techId === '__wknd__'" class="text-amber-9"> · à assigner quand le quart week-end existe</span></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.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>
|
||||||
|
<span class="suggest-occ-txt" :class="{ 'text-negative': g.over }">{{ g.projected }}/{{ g.cap }}h</span>
|
||||||
|
</div>
|
||||||
|
<q-icon v-if="g.over" name="error" color="negative" size="16px" class="q-ml-xs"><q-tooltip>Surcharge : {{ g.projected }}h projetées > {{ g.cap }}h de capacité (inclut le déjà-assigné)</q-tooltip></q-icon>
|
||||||
|
<q-space />
|
||||||
|
<!-- Quart manquant (Q2 §1) : alerte + créer le quart 8-16 (local, enregistré au Publier) -->
|
||||||
|
<q-btn v-if="g.noShiftDays && g.noShiftDays.length" flat dense no-caps size="sm" color="amber-8" icon="event_busy" :label="'Créer quart' + (g.noShiftDays.length > 1 ? ' ×' + g.noShiftDays.length : '')" @click="createShiftForGroup(g)"><q-tooltip>{{ 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</q-tooltip></q-btn>
|
||||||
|
<!-- Assigner / fusionner TOUTE la file (marche depuis un placeholder → vrai tech, ou pour fusionner 2 files) -->
|
||||||
|
<q-btn flat dense round size="sm" :icon="g.placeholder ? 'person_add' : 'drive_file_move'" :color="g.placeholder ? 'amber-9' : 'grey-7'"><q-tooltip>{{ g.placeholder ? 'Assigner cette tournée à un tech' : ('Déplacer / fusionner tous les jobs de ' + g.techName) }}</q-tooltip>
|
||||||
|
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:42vh;overflow:auto">
|
||||||
|
<q-item-label header class="q-py-xs">{{ g.placeholder ? 'Assigner les' : 'Déplacer les' }} {{ g.jobs }} job(s) à…<span v-if="g.skills && g.skills.length" class="text-grey-6"> (compétence {{ g.skills.join(', ') }})</span></q-item-label>
|
||||||
|
<q-item v-for="t in capableTechsFirst(g.skills)" :key="t.id" clickable v-close-popup @click="moveSuggestGroup(g, t.id, t.name)" :disable="t.id === g.techId || (!covers(t, g.skills) && anyCovers(g.skills))">
|
||||||
|
<q-item-section avatar style="min-width:30px"><q-avatar size="22px" :color="covers(t, g.skills) ? '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 }}<span v-if="planCountByTech[t.id] && t.id !== g.techId" class="text-teal-7" style="font-size:11px"> · fusionner ({{ planCountByTech[t.id] }})</span></q-item-section>
|
||||||
|
<q-item-section side v-if="!covers(t, g.skills)"><q-icon name="block" color="grey-5" size="15px"><q-tooltip>Sans la compétence requise</q-tooltip></q-icon></q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list></q-menu>
|
||||||
|
</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></div>
|
||||||
|
|
@ -1034,14 +1054,16 @@
|
||||||
<q-icon v-if="e.eff && e.eff < 0.95" name="bolt" size="12px" color="green-6" class="q-mr-xs"><q-tooltip>Tech rapide sur ce type ({{ e.eff }})</q-tooltip></q-icon>
|
<q-icon v-if="e.eff && e.eff < 0.95" name="bolt" size="12px" color="green-6" class="q-mr-xs"><q-tooltip>Tech rapide sur ce type ({{ e.eff }})</q-tooltip></q-icon>
|
||||||
<span class="suggest-entry-t ellipsis">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span>
|
<span class="suggest-entry-t ellipsis">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span>
|
||||||
<span class="text-grey-6 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round(e.dur * 10) / 10 }}h</span>
|
<span class="text-grey-6 q-mx-xs" style="font-size:11px;white-space:nowrap">{{ Math.round(e.dur * 10) / 10 }}h</span>
|
||||||
<q-icon v-if="!e.capable" name="warning" size="14px" color="orange" class="q-mr-xs"><q-tooltip>Compétence « {{ e.skill }} » absente — dernier recours</q-tooltip></q-icon>
|
<q-icon v-if="!e.capable" name="warning" size="14px" color="deep-orange" class="q-mr-xs"><q-tooltip>{{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner</q-tooltip></q-icon>
|
||||||
<q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais ⚠ hors quart)</q-tooltip></q-icon>
|
<q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais ⚠ hors quart)</q-tooltip></q-icon>
|
||||||
<q-space />
|
<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-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:210px;max-height:42vh;overflow:auto">
|
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:220px;max-height:42vh;overflow:auto">
|
||||||
<q-item v-for="t in visibleTechs" :key="t.id" clickable v-close-popup @click="moveSuggestEntry(e, t.id, t.name)" :active="t.id === e.techId">
|
<q-item-label v-if="e.skill" header class="q-py-xs">Compétence « {{ e.skill }} »</q-item-label>
|
||||||
<q-item-section avatar style="min-width:30px"><q-avatar size="22px" color="blue-grey-1" text-color="blue-grey-8" style="font-size:9px;font-weight:700">{{ initials(t.name) }}</q-avatar></q-item-section>
|
<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>{{ 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-item>
|
</q-item>
|
||||||
</q-list></q-menu>
|
</q-list></q-menu>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
|
@ -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() }
|
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)
|
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__|<iso>|<n>` (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
|
// ── 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). ─────────────
|
// 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)
|
const dayMode = computed(() => dayList.value.length === 1)
|
||||||
|
|
@ -2610,6 +2634,7 @@ async function saveLocPicker () {
|
||||||
} else {
|
} else {
|
||||||
await roster.setJobLocation(locPicker.job, locPicker.lat, locPicker.lon, locPicker.address)
|
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 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
|
locPicker.open = false
|
||||||
await reloadOccupancy(); if (_dayMap) refreshDayMap()
|
await reloadOccupancy(); if (_dayMap) refreshDayMap()
|
||||||
$q.notify({ type: 'positive', icon: 'place', message: 'Emplacement enregistré sur le job' })
|
$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
|
// 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.
|
// sur le Dispatch Job + en mémoire (j.latitude/longitude) → les jobs apparaissent sur la carte du pool. Ambigus = à la main.
|
||||||
async function batchLocatePool () {
|
async function batchLocatePool () {
|
||||||
const targets = assignPanel.jobs.filter(j => !_hasJobLL(j) && String(j.address || j.location_label || '').trim().length >= 3)
|
// MÊME périmètre que le badge assignNoCoord (jobs sans coords du filtre courant) → plus d'incohérence badge/notification.
|
||||||
if (!targets.length) { $q.notify({ type: 'info', message: 'Tous les jobs à assigner ont déjà des coordonnées 🎉', timeout: 2000 }); return }
|
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
|
assignPanel.locating = true
|
||||||
let ok = 0, ambiguous = 0
|
let ok = 0, ambiguous = 0
|
||||||
try {
|
try {
|
||||||
|
|
@ -2719,8 +2751,8 @@ async function batchLocatePool () {
|
||||||
} catch (e) { ambiguous++ }
|
} catch (e) { ambiguous++ }
|
||||||
}
|
}
|
||||||
if (_assignMap) refreshAssignMap()
|
if (_assignMap) refreshAssignMap()
|
||||||
$q.notify({ type: ok ? 'positive' : 'warning', icon: 'place', timeout: 3500,
|
$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 (📍)` : '') })
|
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 }
|
} catch (e) { err(e) } finally { assignPanel.locating = false }
|
||||||
}
|
}
|
||||||
function assignStops () { return assignLocations.value.stops }
|
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 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 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 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)
|
// 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 strat = suggestDlg.strategy || 'smart'
|
||||||
const W = strat === 'best' ? { dist: 0.4, skill: 15, eff: 65, cost: 20, load: -0.25, overqual: 0 }
|
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 }
|
: { 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 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 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) {
|
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 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 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 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
|
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
|
let cand
|
||||||
|
|
@ -3893,7 +3927,9 @@ function buildSuggestion () {
|
||||||
if (!cand.length) continue
|
if (!cand.length) continue
|
||||||
let best = null
|
let best = null
|
||||||
for (const t of techs) {
|
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 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 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
|
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) {
|
for (const iso of cand) {
|
||||||
const c = cell(t.id, iso)
|
const c = cell(t.id, iso)
|
||||||
const noShift = !hasShiftDay(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)
|
const over = Math.max(0, (c.h + effDur) - c.cap)
|
||||||
let dist = null
|
// 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)
|
||||||
if (hasLL) {
|
let prox
|
||||||
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)
|
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 (home && isFinite(+home.lat) && isFinite(+home.lon)) dist = haversineKm(+home.lat, +home.lon, jlat, jlon)
|
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
|
||||||
const under = Math.max(0, reqLevel - lvl) // sous le niveau requis pour ce job
|
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)
|
||||||
let score = over * 120 + (noShift ? 60 : 0) + (capable ? 0 : 1000) + under * 200 // surcharge / hors-quart / incompétence / SOUS-qualifié
|
else prox = 15 // 1er job du tech ce jour, sans coords → neutre
|
||||||
score += (dist == null ? 20 : dist) * W.dist // proximité
|
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 += 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 += (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 += 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 += (eff - 1) * W.eff // rapide (eff<1) récompensé · lent (eff>1) pénalisé
|
||||||
score += costN * W.cost // moins cher préféré
|
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) {
|
// 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.
|
||||||
// Aucun tech valide. Job de WEEK-END sans quart publié → groupe PLACEHOLDER « en attente du quart » (ne se perd pas).
|
if (best && !best.noShift) {
|
||||||
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 })
|
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
|
||||||
continue
|
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
|
suggestDlg.plan = plan
|
||||||
}
|
}
|
||||||
|
|
@ -4014,7 +4085,17 @@ const suggestGroups = computed(() => {
|
||||||
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) }
|
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)
|
}).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.
|
// 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 day = suggestMapDay.value || (suggestWindow.value[0] || '')
|
||||||
const out = []; let ci = 0
|
const out = []; let ci = 0
|
||||||
for (const g of suggestGroups.value) {
|
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 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 }))
|
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
|
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(() => 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([suggestMapDay, suggestGroups], () => { if (suggestDlg.view === 'map' && _suggestMap) refreshSuggestMap() })
|
||||||
watch(() => suggestDlg.open, (o) => { if (!o) destroySuggestMap() })
|
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
|
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).
|
// Applique : regroupe par tech×jour → réutilise assignNames (assignation serveur + occupation optimiste + retrait du pool).
|
||||||
async function applySuggestion () {
|
async function applySuggestion () {
|
||||||
|
|
@ -4049,7 +4161,7 @@ async function applySuggestion () {
|
||||||
let shiftsMade = 0
|
let shiftsMade = 0
|
||||||
if (suggestDlg.makeShifts) {
|
if (suggestDlg.makeShifts) {
|
||||||
const tpl = await ensureWindowTpl(8, 16)
|
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
|
let total = 0
|
||||||
for (const k of Object.keys(groups)) {
|
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) }
|
try { const done = await assignNames(g.names, t, d); total += (done || []).length } catch (e) { err(e) }
|
||||||
}
|
}
|
||||||
suggestDlg.applying = false; suggestDlg.open = false
|
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)
|
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) WEEK-END en attente — crée le quart samedi/dimanche puis relance` : (total ? ' — Publier pour enregistrer' : '')), timeout: 7000 })
|
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()
|
await reloadPool()
|
||||||
}
|
}
|
||||||
// Mobile : assignation AU TOUCHER (le glisser HTML5 ne marche pas au doigt) + MULTI-sélection.
|
// 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 { 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-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-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 { padding: 2px 8px 6px; }
|
||||||
.suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; }
|
.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; }
|
.suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user