feat(ops): pickups = ~5min jobs + generic visit icon (handyman, not bolt)

Retours terrain :
1. « Ramassage d'équipement » = simple collecte chez le client → job court.
   Détection (isPickup : titre/type ~ /ramm?ass|récupération/) → durée
   plafonnée à 5 min : jobDur(pickup)=5/60 (maths d'occupation/Suggérer) +
   capPickups() sur les 3 chargements du pool (est_min affiché=5). Évite de
   surallouer le temps même si l'est_min du ticket dit plus long.
2. Icône de visite GÉNÉRIQUE (type non reconnu) : `bolt` (éclair, peu
   parlant) → `handyman` (technicien + outils). Fallback de skillIcon →
   s'applique partout (carte markerIcon + listes skillSym).

Vérifié live (données réelles) : ticket « Rammassage Équipements #253602 »
(St-Anicet) affiche ≈ 5min ; carrousel : plus de bolt, handyman présent
pour le job générique ; 0 nouvelle erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-05 17:09:22 -04:00
parent cfc721e598
commit c05a6272b7

View File

@ -2093,7 +2093,7 @@ async function fetchKbMatrix (techId) {
}
let _kbMatT = null // le watch déclencheur est enregistré PLUS BAS (après la déclaration de occByTechDay) pour éviter la zone morte temporelle
const kbColor = (j) => j.skill ? getTagColor(j.skill) : (j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae'))
async function reloadPool () { try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { /* non bloquant */ } }
async function reloadPool () { try { assignPanel.jobs = capPickups((await roster.unassignedJobs()).jobs) } catch (e) { /* non bloquant */ } }
// Création de job NATIVE OPS (#17) Dispatch déprécié tout depuis Planification. Réutilise UnifiedCreateModal (work-order).
// Job OPS-natif = PAS de legacy_ticket_id coexiste avec les jobs synchronisés de F (la sync F ne touche que les jobs à legacy_ticket_id). « Sync F transitoire » = coexistence, pas de push F pour l'instant.
const createJobOpen = ref(false)
@ -2517,7 +2517,7 @@ function skillIcon (sk) {
if (/info|ordinateur|\bpc\b|cam[ée]ra/.test(s)) return 'computer'
if (/vente|sales|soumission/.test(s)) return 'sell'
if (/factur|paiement|compta/.test(s)) return 'receipt_long'
return 'bolt'
return 'handyman' // visite générique (type non reconnu) : technicien avec outils, plutôt que l'éclair peu parlant
}
// Icône de compétence pour un MARQUEUR carte (DOM, police material-icons) : UNIQUEMENT des ligatures material (pas les SVG
// custom de skillSym) sinon le glyphe ne s'affiche pas dans un <span class="material-icons"> hors q-icon.
@ -2587,7 +2587,7 @@ const draggingJobName = ref(null); const dropCell = ref(null); const dragHours =
const selectedJobs = reactive({}) // jobName true
const dropPreview = reactive({ key: null, addH: 0 })
const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours de glissé (source estompée) + fantôme custom
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { err(e) } finally { assignPanel.loading = false } }
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = capPickups((await roster.unassignedJobs()).jobs) } catch (e) { err(e) } finally { assignPanel.loading = false } }
// Write-back legacy : aperçu (dryRun, 0 écriture) confirmation écrit ticket.assign_to dans osTicket.
const legacyPush = reactive({ open: false, loading: false, applying: false, preview: null, notify: true })
@ -4324,7 +4324,14 @@ function setEntryPriority (name, lvl) { // écrit la priorité ERPNext du job (l
const solverOpts = reactive({ rankWeight: 2, overtimeCoef: 20, speedKmh: 45, maxSeconds: 8 }) // un COÛT par critère (pas de slider)
try { const s = JSON.parse(localStorage.getItem('ops.solverOpts') || 'null'); if (s && typeof s === 'object') Object.assign(solverOpts, s) } catch (e) { /* défauts */ }
watch(solverOpts, v => { try { localStorage.setItem('ops.solverOpts', JSON.stringify(v)) } catch (e) {} }, { deep: true })
function jobDur (j) { return (j && (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1))) || 1 }
// « Ramassage d'équipement » (récupération chez le client) = simple collecte job court (~5 min).
// On ne surallocue pas le temps même si la classification/est_min du ticket dit plus long.
const PICKUP_RE = /ramm?ass|r[ée]cup[ée]ration/i
const PICKUP_MIN = 5
function isPickup (j) { return !!j && PICKUP_RE.test(String(j.subject || j.service_type || j.job_type || '')) }
function jobDur (j) { if (isPickup(j)) return PICKUP_MIN / 60; return (j && (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1))) || 1 }
// Cap la durée AFFICHÉE des ramassages à 5 min (le temps affiché suit alors jobDur partout : pool, carte, occupation).
function capPickups (jobs) { return (jobs || []).map(j => isPickup(j) ? { ...j, est_min: PICKUP_MIN } : j) }
// Construit un plan jobtech×jour SANS rien écrire (proposition). Chaque job garde sa date si elle est dans la fenêtre.
function buildSuggestion () {
const useSel = selectedNames.value.length > 0
@ -5628,7 +5635,7 @@ let _legacyRefreshT = null
function scheduleLegacyRefresh () {
clearTimeout(_legacyRefreshT)
_legacyRefreshT = setTimeout(async () => {
try { await reloadOccupancy(); if (assignPanel.open) { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } } catch (e) { /* non bloquant */ }
try { await reloadOccupancy(); if (assignPanel.open) { assignPanel.jobs = capPickups((await roster.unassignedJobs()).jobs) } } catch (e) { /* non bloquant */ }
}, 700)
}
function onLegacyUpdate (data) {