@@ -908,7 +1145,8 @@
- Glisser/flèches = ordre (heures recalculées) · 🔒 = RDV fixe · clic = détails · total {{ dayTotalH() }}h
+ Glisser/flèches = ordre · 🔒 = RDV fixe · clic = détails · total {{ dayTotalH() }}h
+ Réordonne par plus proche voisin depuis {{ dayOrigin ? (dayOrigin.kind === 'home' ? 'le domicile du tech' : 'le dépôt') : 'le 1er arrêt' }} → la 1re job devient la plus proche. Puis « Enregistrer ».
@@ -957,36 +1195,90 @@ const techs = ref([])
const templates = ref([])
const assignments = ref([])
const coverageData = ref([])
-// Dispo restante par jour × segment (AM/PM/Soir), calculée CLIENT-SIDE sur les ressources AFFICHÉES (visibleTechs)
-// → suit le filtre par compétence : cliquer « installation » ⇒ visibleTechs = techs installation ⇒ capacité = leurs shifts.
-// Soir (17-21h) RETIRÉ des blocs offerts : c'est une capacité de secours/urgence/garde uniquement, jamais proposée au client.
-const DAY_SEGS = [{ key: 'am', label: 'AM', from: 8, to: 12 }, { key: 'pm', label: 'PM', from: 12, to: 17 }]
+// Capacité OFFRABLE par jour (journée 8h–17h, AM+PM COMBINÉS — on ne gère que des heures/jour),
+// calculée CLIENT-SIDE sur les ressources AFFICHÉES (visibleTechs) → suit le filtre par compétence
+// (cliquer « installation » ⇒ visibleTechs = techs installation ⇒ capacité = leurs shifts).
+// Le soir (17h+) reste une réserve garde/urgence, jamais proposée au client → exclu de la fenêtre.
+const DAY_FROM = 8, DAY_TO = 17
const _segOv = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
const _r1 = (x) => Math.round(x * 10) / 10
const _toH = (t) => { if (t == null) return 0; if (typeof t === 'number') return t; const p = String(t).split(':'); return (Number(p[0]) || 0) + (Number(p[1]) || 0) / 60 }
const capByDay = computed(() => {
const out = {}; const tids = visibleTechs.value.map(t => t.id); const cbtd = cellsByTechDay.value; const tpl = tplByName.value
for (const d of dayList.value) {
- const segs = DAY_SEGS.map(s => ({ key: s.key, label: s.label, cap: 0, used: 0, free: 0 }))
+ let cap = 0, used = 0
for (const tid of tids) {
const asgs = (cbtd[tid] && cbtd[tid][d.iso]) || [] // quarts publiés/proposés du tech ce jour
- for (const a of asgs) { const t = tpl[a.shift]; if (!t || t.on_call) continue; const s = _toH(t.start_time), e = _toH(t.end_time); if (!(e > s)) continue; for (let i = 0; i < DAY_SEGS.length; i++) segs[i].cap += _segOv(s, e, DAY_SEGS[i].from, DAY_SEGS[i].to) }
+ for (const a of asgs) { const t = tpl[a.shift]; if (!t || t.on_call) continue; const s = _toH(t.start_time), e = _toH(t.end_time); if (e > s) cap += _segOv(s, e, DAY_FROM, DAY_TO) }
const occ = occByTechDay.value[tid + '|' + d.iso] // jobs placés (blocs horaires)
- if (occ && occ.blocks) for (const b of occ.blocks) for (let i = 0; i < DAY_SEGS.length; i++) segs[i].used += _segOv(b.s, b.e, DAY_SEGS[i].from, DAY_SEGS[i].to)
+ if (occ && occ.blocks) for (const b of occ.blocks) used += _segOv(b.s, b.e, DAY_FROM, DAY_TO)
}
let due_h = 0, jobs_due = 0 // charge DUE = pool non assigné ce jour (filtré par compétence si actif)
for (const j of (assignPanel.jobs || [])) { if (j.scheduled_date !== d.iso) continue; if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue; due_h += (j.est_min ? j.est_min / 60 : Number(j.duration_h || j.dur || 0)); jobs_due++ }
- segs.forEach(s => { s.cap = _r1(s.cap); s.used = _r1(s.used); s.free = _r1(s.cap - s.used) })
- const cap_h = _r1(segs.reduce((x, s) => x + s.cap, 0)); const used_h = _r1(segs.reduce((x, s) => x + s.used, 0))
- out[d.iso] = { segments: segs, cap_h, used_h, free_h: _r1(cap_h - used_h), due_h: _r1(due_h), jobs_due, overbooked: cap_h > 0 && due_h > cap_h }
+ cap = _r1(cap); used = _r1(used)
+ out[d.iso] = { cap_h: cap, used_h: used, free_h: _r1(cap - used), due_h: _r1(due_h), jobs_due, overbooked: cap > 0 && due_h > cap }
}
return out
})
+// Total des heures (et effectif) sur les RESSOURCES AFFICHÉES uniquement — pied de tableau.
+// Source = assignments (cohérent avec hoursOf par tech), garde exclue.
+const visStatByDay = computed(() => {
+ const vis = new Set(visibleTechs.value.map(t => t.id)); const tpl = tplByName.value; const agg = {}
+ for (const a of assignments.value) {
+ if (!vis.has(a.tech)) continue
+ const t = tpl[a.shift]; if (t && t.on_call) continue // garde = mise en dispo, pas travaillée
+ const o = agg[a.date] || (agg[a.date] = { hours: 0, staff: new Set() })
+ o.hours += Number(a.hours) || 0; o.staff.add(a.tech)
+ }
+ const out = {}; for (const k in agg) out[k] = { hours: _r1(agg[k].hours), staff: agg[k].staff.size }
+ return out
+})
+function visStat (iso) { return visStatByDay.value[iso] || { hours: 0, staff: 0 } }
+// CHARGE estimée par jour = Σ heures estimées des jobs de cette date (assignés aux techs AFFICHÉS + pool non assigné,
+// filtré compétence) → c'est le NUMÉRATEUR ; le dénominateur = heures dispo (visStat). Ratio = taux d'occupation prévu.
+const estLoadByDay = computed(() => {
+ const vis = new Set(visibleTechs.value.map(t => t.id)); const out = {}
+ for (const key in occByTechDay.value) { // jobs déjà assignés (occupancy), seulement sur les techs affichés
+ const [tid, iso] = key.split('|'); if (!vis.has(tid)) continue
+ const blocks = (occByTechDay.value[key] || {}).blocks || []
+ out[iso] = (out[iso] || 0) + blocks.reduce((s, b) => s + Math.max(0, (b.e - b.s)), 0)
+ }
+ for (const j of (assignPanel.jobs || [])) { // + pool non assigné de cette date (même filtre compétence que la capacité)
+ if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue
+ const iso = j.scheduled_date; if (!iso) continue
+ out[iso] = (out[iso] || 0) + (j.est_min ? j.est_min / 60 : Number(j.duration_h || j.dur || 0))
+ }
+ for (const k in out) out[k] = _r1(out[k]); return out
+})
+function estLoad (iso) { return estLoadByDay.value[iso] || 0 }
+function loadClass (iso) { const est = estLoad(iso), dispo = visStat(iso).hours || 0; if (!dispo) return est > 0 ? 'cap-full' : 'cap-none'; const r = est / dispo; return r > 1 ? 'cap-full' : (r > 0.85 ? 'cap-low' : 'cap-ok') } // vert/orange/rouge selon est/dispo
+// Vue « jobs prévus en en-tête de jour » : jobs NON assignés ayant une date, groupés par jour, glissables sur une case
+// tech×jour (réutilise onJobDragStart/onCellDrop → assigne + replanifie à la date de la case). Suit le filtre compétence.
+const showDayJobs = ref(false)
+// Charge LEGACY appliquée aux timelines : durées estimées des jobs assignés dans osTicket (datés dans la fenêtre affichée).
+const showLegacyLoad = ref((() => { try { return localStorage.getItem('plan_legacy_load') !== '0' } catch (e) { return true } })()) // défaut ON
+const legacyLoad = ref({}) // 'technician_id|iso' → { h, n, jobs } (durées estimées des jobs legacy datés, fenêtre affichée)
+async function loadLegacyWindow () {
+ if (!showLegacyLoad.value) { legacyLoad.value = {}; return }
+ try { const r = await roster.legacyWindowLoad(start.value, days.value); legacyLoad.value = (r && r.load) || {} } catch (e) { /* non bloquant — legacy indispo */ }
+}
+watch(showLegacyLoad, (v) => { try { localStorage.setItem('plan_legacy_load', v ? '1' : '0') } catch (e) {} loadLegacyWindow() })
+const unassignedByDay = computed(() => {
+ const out = {}
+ const pr = j => ({ urgent: 0, high: 1, moyenne: 2, medium: 2, normal: 2, low: 3, basse: 3 }[String(j.priority || '').toLowerCase()] ?? 2)
+ for (const j of (assignPanel.jobs || [])) {
+ if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue
+ const iso = j.scheduled_date; if (!iso) continue
+ ;(out[iso] = out[iso] || []).push(j)
+ }
+ for (const k in out) out[k].sort((a, b) => pr(a) - pr(b) || (b.est_min || 0) - (a.est_min || 0))
+ return out
+})
const dailyStats = ref([])
const solverStats = ref(null)
const loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = ref(false)
const days = ref(7)
-const start = ref(upcomingMonday())
+const start = ref(thisMonday()) // défaut = semaine COURANTE (cohérent avec « Auj. ») — pas la semaine suivante
const lastWeek = reactive({ start: start.value, days: days.value })
const showDemand = ref(false)
const drag = reactive({ on: false, ti: 0, di: 0, moved: false, base: [] })
@@ -1020,8 +1312,7 @@ const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const
// Date LOCALE du jour (PAS toISOString, qui bascule au lendemain le soir en UTC− → cause des « jours décalés »).
function todayISO () { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') }
-function upcomingMonday () { const t = todayISO(); return addDaysISO(t, (1 - dowOf(t) + 7) % 7) } // prochain lundi (aujourd'hui si lundi), calcul UTC-cohérent
-function thisMonday () { return mondayISO(todayISO()) } // lundi de la semaine COURANTE (pour « Auj. »)
+function thisMonday () { return mondayISO(todayISO()) } // lundi de la semaine COURANTE — défaut au chargement ET bouton « Auj. »
function addDaysISO (iso, n) { const [y, m, d] = iso.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + n); return dt.toISOString().slice(0, 10) }
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
const dayList = computed(() => {
@@ -1030,6 +1321,307 @@ const dayList = computed(() => {
return out
})
function dowOf (iso) { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() }
+// ── 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)
+watch(dayMode, (on) => { if (on) showDayJobs.value = true }) // en mode Jour, affiche d'office le panneau de jobs à disposer
+
+// ── VUE KANBAN (mode de vue, sur le socle hub) : colonne « À assigner » + 1 colonne/tech, cartes glissables entre lanes.
+// Réutilise tout : occupation (avec équipes via job.assist), pool unassignedJobs, onCellDrop (= roster.assignJob hub). ──
+const boardView = ref(localStorage.getItem('plan_view') === 'kanban' ? 'kanban' : 'grid') // 'grid' | 'kanban'
+watch(boardView, (v) => { localStorage.setItem('plan_view', v); if (v === 'kanban') { kbSelIso.value = nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = thisMonday(); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } })
+const kbSelIso = ref('') // mode Jour : jour explicitement choisi ('' = auto → aujourd'hui)
+const kanbanDay = computed(() => { // Jour : jour choisi, sinon AUJOURD'HUI (ET) si dans la fenêtre, sinon le 1er jour
+ for (const c of [kbSelIso.value, nowET.value.iso]) { if (!c) continue; const d = dayList.value.find(x => x.iso === c); if (d) return d }
+ return dayList.value[0] || null
+})
+// Sélecteur de date de la barre d'outils : en mode JOUR = le jour affiché (éditable) ; en SEMAINE = début de fenêtre.
+const boardDate = computed({
+ get: () => (boardView.value === 'kanban' ? ((kanbanDay.value && kanbanDay.value.iso) || start.value) : start.value),
+ set: (v) => {
+ if (!v) return
+ if (boardView.value === 'kanban') { kbSelIso.value = v; if (!dayList.value.find(d => d.iso === v)) { start.value = mondayISO(v); loadWeek() } }
+ else if (v !== start.value) { start.value = v; onWeekChange() }
+ },
+})
+const kanbanPool = computed(() => { const f = skillFilter.value; return (assignPanel.jobs || []).filter(j => !f.length || f.includes(j.required_skill)) })
+// Pool : recherche plein-texte + tri (priorité / ville / distance dépôt / compétence / durée), comme la fenêtre flottante.
+const kbSearch = ref(''); const kbSort = ref('prio')
+function kbCity (j) { if (j.municipalite) return j.municipalite; const m = String(j.address || '').match(/,\s*([^,]+?)\s*(?:,|$|\s[A-Z]\d[A-Z])/); return (m ? m[1] : '').trim() }
+const kanbanPoolView = computed(() => {
+ let list = kanbanPool.value
+ const q = kbSearch.value.trim().toLowerCase()
+ if (q) list = list.filter(j => [j.subject, j.service_type, j.customer_name, j.address, j.required_skill].some(x => String(x || '').toLowerCase().includes(q)))
+ const pr = (j) => ({ urgent: 0, high: 1, medium: 2, low: 3 }[String(j.priority || '').toLowerCase()] ?? 2)
+ const dep = depot.value; const dist = (j) => (dep && dep.lat != null && j.latitude != null) ? (haversineKm(dep.lat, dep.lon, +j.latitude, +j.longitude) ?? 9e9) : 9e9
+ const s = kbSort.value
+ return [...list].sort((a, b) =>
+ s === 'city' ? (kbCity(a) || 'zz').localeCompare(kbCity(b) || 'zz') :
+ s === 'dist' ? dist(a) - dist(b) :
+ s === 'skill' ? String(a.required_skill || 'zz').localeCompare(String(b.required_skill || 'zz')) :
+ s === 'dur' ? (b.est_min || 0) - (a.est_min || 0) :
+ (pr(a) - pr(b) || (b.est_min || 0) - (a.est_min || 0)))
+})
+// Blocs d'une lane : depuis l'occupation BRUTE (occByTechDay = TOUS les jobs assignés ; robuste vs shift-gating qui
+// faisait « disparaître » la carte). Timés (start_h) à leur heure ; non-timés en flow ; inclut les miroirs « assist ».
+const KB_DAY_START = 8 // 8h AM (style Gaiia) : départ par défaut de la tournée
+const kbMatrix = ref({}); const _kbMatPending = new Set() // cache matrice Mapbox par tournée (tech|jour) → 1 call/tournée
+function kanbanLaneBlocks (techId) {
+ const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
+ const o = occByTechDay.value[techId + '|' + iso] || { jobs: [] }
+ const dur = (j) => Number(j.dur) || 1
+ // Jobs réels (occupation) + jobs TERRAIN legacy (synthétique F, comme la grille semaine) : non draggables, clic = ouvre le ticket.
+ const base = (o.jobs || []).slice()
+ const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null
+ if (lg && Array.isArray(lg.jobs)) { for (const lj of lg.jobs) { if (!lj.field) continue; const d = Number(lj.est_h) || 0; if (d <= 0) continue; base.push({ name: 'LEG:' + lj.id, legacy: true, lid: lj.id, subject: lj.subject, dept: lj.dept, skill: lj.skill, dur: d, start_h: null, route_order: 9999 }) } }
+ if (!base.length) return []
+ // Ordre de tournée : route_order puis heure.
+ const jobs = base.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
+ // RDV CONFIRMÉS + miroirs ASSISTANT = verrouillés à leur heure (le renfort suit l'heure du lead) ; tout le reste COULE depuis 8h.
+ const fixed = jobs.filter(j => (j.booking_status === 'Confirmé' || j.assist) && j.start_h != null)
+ const out = fixed.map(j => ({ ...j, s: j.start_h, e: j.start_h + dur(j), fixed: true }))
+ let cursor = KB_DAY_START
+ const mat = (kbMatrix.value[techId + '|' + iso] || {}).map || null // matrice Mapbox RÉELLE (1 call/tournée) si chargée
+ const dep = depot.value
+ let prev = (dep && dep.lat != null) ? { lat: +dep.lat, lon: +dep.lon } : null
+ let prevName = (dep && dep.lat != null) ? 'DEPOT' : null
+ for (const j of jobs) {
+ if (j.assist) continue // miroir assistant : déjà placé (fixed) ; ne coule pas et ne compte pas dans le trajet de l'assistant
+ if (j.booking_status === 'Confirmé' && j.start_h != null) { if (j.lat != null) { prev = { lat: +j.lat, lon: +j.lon }; prevName = j.name } continue }
+ const d = dur(j); const blk = { ...j }
+ // Trajet d'approche : temps ROUTIER RÉEL (matrice Mapbox) si dispo, sinon repli à vol d'oiseau (~55 km/h).
+ let legMin = 0, legKm = 0
+ const real = (mat && prevName && j.name) ? mat[prevName + '>' + j.name] : null
+ if (real) { legMin = Math.min(120, real.min); legKm = real.km != null ? real.km : 0 }
+ else if (prev && j.lat != null && j.lon != null) { const km = haversineKm(prev.lat, prev.lon, +j.lat, +j.lon); if (km != null && km > 0.05) { legKm = km; legMin = Math.min(60, Math.round(km * 1.1)) } }
+ if (legMin >= 3) { blk.legS = cursor; blk.legMin = legMin; blk.legKm = Math.round(legKm * 10) / 10; blk.legReal = !!real; cursor += legMin / 60; blk.legE = cursor }
+ let guard = 0; while (guard++ < 50) { const c = fixed.find(f => cursor < (f.start_h + dur(f)) && (cursor + d) > f.start_h); if (c) cursor = c.start_h + dur(c); else break }
+ blk.s = cursor; blk.e = cursor + d; out.push(blk); cursor += d
+ if (j.lat != null && j.lon != null) { prev = { lat: +j.lat, lon: +j.lon }; prevName = j.name }
+ }
+ return out
+}
+// Matrice Mapbox RÉELLE par tournée (tech×jour) : 1 SEUL appel /tournée (≤25 coords) → temps de route réels pour tous les legs. Cache par signature (set de jobs) → pas de refetch inutile.
+async function fetchKbMatrix (techId) {
+ const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso || !MAPBOX_TOKEN) return
+ const key = techId + '|' + iso
+ const o = occByTechDay.value[key]
+ const jobs = (o && o.jobs ? o.jobs : []).filter(j => isFinite(+j.lat) && isFinite(+j.lon) && (+j.lat || +j.lon))
+ const sig = jobs.map(j => j.name).join(',')
+ if (jobs.length < 1) return
+ const cached = kbMatrix.value[key]; if ((cached && cached.sig === sig) || _kbMatPending.has(key)) return
+ const dep = depot.value; const pts = []
+ if (dep && isFinite(+dep.lat) && isFinite(+dep.lon)) pts.push({ name: 'DEPOT', lat: +dep.lat, lon: +dep.lon })
+ for (const j of jobs) pts.push({ name: j.name, lat: +j.lat, lon: +j.lon })
+ if (pts.length < 2) return
+ const use = pts.slice(0, 25) // Matrix = 25 coords max
+ _kbMatPending.add(key)
+ try {
+ const coords = use.map(p => `${p.lon.toFixed(6)},${p.lat.toFixed(6)}`).join(';')
+ const r = await fetch(`https://api.mapbox.com/directions-matrix/v1/mapbox/driving/${coords}?annotations=duration,distance&access_token=${MAPBOX_TOKEN}`)
+ if (!r.ok) throw new Error('matrix ' + r.status)
+ const d = await r.json(); const dur = d.durations || [], dist = d.distances || []; const map = {}
+ for (let i = 0; i < use.length; i++) for (let k = 0; k < use.length; k++) { if (i === k) continue; const sec = dur[i] && dur[i][k]; if (sec == null) continue; const m = dist[i] && dist[i][k]; map[use[i].name + '>' + use[k].name] = { min: Math.max(2, Math.round(sec / 60)), km: m != null ? Math.round(m / 100) / 10 : null } }
+ kbMatrix.value = { ...kbMatrix.value, [key]: { sig, map } } // réactif → les legs se recalculent (temps routiers réels)
+ } catch (e) { /* repli haversine (géré dans kanbanLaneBlocks) */ } finally { _kbMatPending.delete(key) }
+}
+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 */ } }
+// Drop sur la colonne « À assigner » = renvoyer au pool (désassigner, ERPNext only).
+async function onKanbanUnassign (ev) {
+ ev.preventDefault()
+ const raw = (ev.dataTransfer && ev.dataTransfer.getData('text/plain')) || draggingJobName.value; draggingJobName.value = null
+ const names = (raw || '').split(',').filter(Boolean); onJobDragEnd(); if (!names.length) return
+ let ok = 0; for (const n of names) { try { await roster.unassignJobRoster(n); ok++ } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
+ if (ok) { $q.notify({ type: 'info', message: ok + ' job(s) renvoyé(s) au pool', timeout: 2200 }); await reloadOccupancy(); await reloadPool() }
+}
+// Clic droit sur une carte job → désassigner directement (retour au pool), sans devoir la glisser sur un autre tech.
+async function unassignOne (b) {
+ const jn = b && (b.name || b.job); if (!jn || b.assist || b.legacy) return // Kanban=name, grille=job ; legacy/assist non désassignables
+ try { await roster.unassignJobRoster(jn); await reloadOccupancy(); await reloadPool(); $q.notify({ type: 'info', message: (b.subject || b.skill || 'Job') + ' renvoyé au pool', timeout: 2200 }) } catch (e) { err(e) }
+}
+// Ligne-curseur « maintenant » (Eastern Time, America/Toronto), rafraîchie chaque minute — style Gaiia.
+const nowTick = ref(0); let _nowTimer = null
+const nowET = computed(() => {
+ nowTick.value // dépendance réactive → recalcule à chaque tick (minute)
+ try {
+ const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Toronto', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date())
+ const o = {}; for (const p of parts) o[p.type] = p.value
+ const hh = o.hour === '24' ? 0 : Number(o.hour)
+ return { iso: `${o.year}-${o.month}-${o.day}`, h: hh + Number(o.minute) / 60 }
+ } catch (e) { return { iso: '', h: null } }
+})
+const nowHHMM = computed(() => { const n = nowET.value; if (n.h == null) return ''; const h = Math.floor(n.h); return String(h).padStart(2, '0') + ':' + String(Math.round((n.h - h) * 60)).padStart(2, '0') })
+// ── Échelle d'heures du board en PIXELS (style Gaiia/dispatch) : 7h–18h REMPLIT la largeur dispo (mesurée),
+// scroll horizontal pour les heures hors plage ; lanes hautes + rangées serrées = plus de place pour les détails. ──
+const KB_VIS_FROM = 6; const KB_VIS_TO = 18; const KB_NAME_W = 150 // fenêtre de REMPLISSAGE 6–18h ; 0–6 et 18–24 restent SCROLLABLES (continuité)
+const kbBoardEl = ref(null); const kbBoardW = ref(1100); let _kbRO = null
+const kbScrollEl = ref(null); let _kbDidScroll = false
+function kbScrollToDefault () { if (kbScrollEl.value) { kbScrollEl.value.scrollLeft = Math.max(0, (KB_VIS_FROM - kbAxis.value.min) * kbPxH.value); _kbDidScroll = true } } // cale la vue sur 6h (0–6 reste accessible en scrollant à gauche)
+watch(kbBoardEl, (el) => { if (_kbRO) { _kbRO.disconnect(); _kbRO = null } if (el) { _kbRO = new ResizeObserver(es => { kbBoardW.value = es[0].contentRect.width; if (!_kbDidScroll) nextTick(kbScrollToDefault) }); _kbRO.observe(el); kbBoardW.value = el.clientWidth || 1100 } })
+// Plage RENDUE : couvre au moins 7–18, étendue aux heures réelles des shifts (scroll pour le hors-plage).
+// Vue calée sur 7–18h : on NE rend PAS 00–06h (ni la nuit/garde). Min fixe à 7 ; max étendu aux jobs réels mais plafonné à 21h.
+const kbAxis = computed(() => ({ min: 0, max: 24 })) // rendu PLEINE JOURNÉE → scroll continu (0–6 à gauche, 18–24 à droite) ; 6–18 remplit la largeur
+const kbPxH = computed(() => Math.max(46, (kbBoardW.value - KB_NAME_W - 4) / (KB_VIS_TO - KB_VIS_FROM))) // px/heure : 7–18 = largeur dispo
+const kbInnerW = computed(() => Math.round((kbAxis.value.max - kbAxis.value.min) * kbPxH.value))
+// Lane : fond BLANC + lignes verticales pâles toutes les 0,5 h (alignées sur l'axe : 0 = kbAxis.min).
+const kbLaneStyle = computed(() => { const half = Math.max(8, kbPxH.value / 2); return { width: kbInnerW.value + 'px', backgroundColor: '#eef1f4', backgroundImage: 'repeating-linear-gradient(90deg, rgba(0,0,0,.05) 0 1px, transparent 1px ' + half + 'px)' } }) // gris pâle = indispo ; le quart (.kbb-shift) y crée une zone blanche
+function kbPos (s, e) { const m = kbAxis.value.min, p = kbPxH.value; const L = (Math.max(m, s) - m) * p; const W = (Math.min(e, kbAxis.value.max) - Math.max(m, s)) * p; return { left: Math.round(L) + 'px', width: Math.max(6, Math.round(W)) + 'px' } }
+const kbTicks = computed(() => { const a = kbAxis.value; const out = []; for (let h = a.min; h <= a.max; h++) out.push({ h, left: Math.round((h - a.min) * kbPxH.value) + 'px' }); return out })
+// Bandes de quart (px) : réguliers + garde (brut depuis cellsOf/templates, sans pos %).
+function kbShiftBands (techId) {
+ const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
+ let s = Infinity, e = -Infinity
+ for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; const ss = hToNum(t.start_time), ee = hToNum(t.end_time); if (ss != null) s = Math.min(s, ss); if (ee != null) e = Math.max(e, ee <= ss ? 24 : ee) }
+ // Allonge la zone blanche du quart pour COUVRIR les blocs qui débordent (jobs + trajets) ; garde = bande séparée.
+ const blks = kanbanLaneBlocks(techId)
+ if (!isFinite(s)) { if (!blks.length) return []; s = Math.min(...blks.map(b => b.s)); e = Math.max(...blks.map(b => b.e)) }
+ else for (const b of blks) { if (b.s < s) s = b.s; if (b.e > e) e = b.e }
+ return [{ s, e }]
+}
+const kbOnGarde = (techId) => kanbanDay.value ? onGarde(techId, kanbanDay.value.iso) : false
+// Garde = shift SUR APPEL : bande jaune pâle sur la timeline (heures de garde) + téléphone rouge (pas une icône dans le nom).
+function kbGardeBands (techId) {
+ const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return []
+ const g = gardeEffective.value[techId + '|' + iso]; if (!g) return []
+ const t = tplByName.value[g]; if (!t) return []
+ const s = hToNum(t.start_time), e = hToNum(t.end_time); if (s == null || e == null) return []
+ return [{ s, e: e <= s ? 24 : e }]
+}
+// Ligne « maintenant » (px) — seulement si la colonne = aujourd'hui (ET) et dans la plage rendue.
+const nowLeftPx = computed(() => { const n = nowET.value; if (n.h == null || !kanbanDay.value || kanbanDay.value.iso !== n.iso) return null; const a = kbAxis.value; if (n.h < a.min || n.h > a.max) return null; return (n.h - a.min) * kbPxH.value })
+const nowLineStyle = computed(() => { const x = nowLeftPx.value; return x == null ? null : { left: (KB_NAME_W + Math.round(x)) + 'px' } }) // trait continu (inclut la colonne noms)
+const nowLblStyle = computed(() => { const x = nowLeftPx.value; return x == null ? null : { left: Math.round(x) + 'px' } }) // étiquette dans la règle (après les noms)
+// Export GPX du parcours GPS (jour courant) via Traccar — si le tech a un appareil associé.
+const kbIsToday = computed(() => { const n = nowET.value; return !!(kanbanDay.value && n.iso && kanbanDay.value.iso === n.iso) })
+function exportGpx (techId) {
+ const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso) return
+ const from = new Date(iso + 'T00:00:00').toISOString(); const to = new Date(iso + 'T23:59:59').toISOString()
+ window.open(roster.gpxUrl(techId, from, to), '_blank')
+}
+// ── Détails d'un job : double-clic sur un bloc → grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour. ──
+const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', time: '', detail: '', lid: null, dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null })
+async function openJobDetail (b, t) {
+ if (!b) return
+ jdShowTrack.value = false
+ Object.assign(jobDetail, { open: true, name: b.name || '', subject: b.subject || b.name || 'Job', customer: b.customer || '', address: b.address || '', skill: b.skill || '', time: (b.start || (b.s != null ? fmtH(b.s) : '')) + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : ''), detail: b.detail || '', lid: b.legacy_id || null, dept: b.dept || '', techId: (t && t.id) || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : null, lon: b.lon != null ? +b.lon : null, loading: !!b.legacy_id, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null })
+ if (b.legacy_id) { try { jobDetail.thread = await roster.ticketThread(b.legacy_id) } catch (e) { jobDetail.thread = { error: true, messages: [] } } finally { jobDetail.loading = false } }
+ if (jobDetail.canTeam) { jobDetail.teamLoading = true; try { const r = await roster.getJobTeam(b.name); jobDetail.team = r.assistants || [] } catch (e) { jobDetail.team = [] } finally { jobDetail.teamLoading = false } }
+}
+// Options du sélecteur d'assistant : tous les techs sauf le lead courant + ceux déjà dans l'équipe.
+const jdTeamOptions = computed(() => { const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)]); return (techs.value || []).filter(t => !taken.has(t.id)).map(t => ({ label: t.name, value: { id: t.id, name: t.name } })) })
+async function jdAddAssistant () {
+ const t = jobDetail.teamAdd; if (!t || !jobDetail.name) return
+ try {
+ await roster.addAssistant(jobDetail.name, { tech_id: t.id, tech_name: t.name, duration_h: 0, pinned: 1 })
+ jobDetail.teamAdd = null
+ const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []
+ await reloadOccupancy()
+ $q.notify({ type: 'positive', icon: 'group_add', message: t.name + ' ajouté en renfort · bloc hachuré réservé dans son horaire', timeout: 2600 })
+ } catch (e) { err(e) }
+}
+async function jdRemoveAssistant (techId) {
+ if (!jobDetail.name) return
+ try { await roster.removeAssistant(jobDetail.name, techId); const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []; await reloadOccupancy(); $q.notify({ type: 'info', message: 'Assistant retiré', timeout: 1600 }) } catch (e) { err(e) }
+}
+let _kbClickT = null
+function kbBlockClick (t) { clearTimeout(_kbClickT); _kbClickT = setTimeout(() => { if (kanbanDay.value) openDayFromCell(t, kanbanDay.value) }, 230) } // simple clic (différé) = éditeur de jour
+function kbBlockDbl (b, t) { clearTimeout(_kbClickT); openJobDetail(b, t) } // double-clic = volet détails
+// Clic (ou clic droit) sur le FOND d'une lane (hors job) → MÊME menu que la grille (Jour/Soir/Garde/Absent/heures).
+function onKbLaneMenu (t, ev) { if (!kanbanDay.value) return; openShiftMenu(t, kanbanDay.value, ev, -1, -1) }
+// Drop d'une job sur une lane : assigne (onCellDrop → hub) PUIS crée un quart 8–16 auto si le tech n'a aucun quart régulier ce jour.
+async function onKbDrop (ev, t) {
+ if (!kanbanDay.value) return
+ await onCellDrop(ev, t, kanbanDay.value) // si pas de quart → onCellDrop ouvre le dialogue (créer quart / absence) ; sinon assigne + toast Annuler
+}
+// ── Drop sur une lane SANS quart : demander quoi faire (créer un quart OU marquer absent) au lieu d'assigner en douce. ──
+const dropAsk = reactive({ open: false, tech: null, day: null, names: [], mode: 'choice', preset: '8-16', cStart: '08:00', cEnd: '16:00', scope: 'day', from: '', to: '', absType: 'Congé' })
+const shiftPresets = [{ label: 'Journée · 8 h–16 h', value: '8-16' }, { label: 'Avant-midi · 8 h–12 h', value: '8-12' }, { label: 'Après-midi · 12 h–16 h', value: '12-16' }, { label: 'Soir · 13 h–21 h', value: '13-21' }, { label: 'Personnalisé…', value: 'custom' }]
+const absTypes = ['Congé', 'Maladie', 'Personnel', 'Formation', 'Absent']
+const dropAskReturn = computed(() => { if (!dropAsk.open || dropAsk.mode !== 'absence') return ''; const days = dropAskDays(); return days.length ? addDaysISO(days[days.length - 1], 1) : '' })
+function openDropAsk (names, t, d) { Object.assign(dropAsk, { open: true, tech: t, day: d, names: names.slice(), mode: 'choice', preset: '8-16', cStart: '08:00', cEnd: '16:00', scope: 'day', from: d.iso, to: d.iso, absType: 'Congé' }) }
+function dropAskWindow () { if (dropAsk.preset === 'custom') return [hToNum(dropAsk.cStart), hToNum(dropAsk.cEnd)]; const [a, b] = dropAsk.preset.split('-').map(Number); return [a, b] }
+async function dropAskCreateShift () {
+ const t = dropAsk.tech, d = dropAsk.day; const [min, max] = dropAskWindow()
+ if (!(max > min)) { $q.notify({ type: 'warning', message: 'La fin doit être après le début.' }); return }
+ const tpl = await ensureWindowTpl(min, max)
+ if (tpl) { pushHistory(); addShift(t.id, t.name, d.iso, tpl) }
+ const done = await assignNames(dropAsk.names, t, d)
+ dropAsk.open = false
+ notifyAssignUndo(done, t, d, tpl ? tpl.name : null) // « Annuler » → désassigne + retire le quart créé
+}
+function dropAskDays () {
+ if (dropAsk.scope === 'day') return [dropAsk.day.iso]
+ if (dropAsk.scope === 'week') { const m = mondayISO(dropAsk.day.iso); return Array.from({ length: 7 }, (_, i) => addDaysISO(m, i)) }
+ const out = []; let dd = dropAsk.from; let g = 0; while (dd && dropAsk.to && dd <= dropAsk.to && g++ < 400) { out.push(dd); dd = addDaysISO(dd, 1) }; return out
+}
+async function dropAskAbsence () {
+ const t = dropAsk.tech; const days = dropAskDays(); if (!days.length) { $q.notify({ type: 'warning', message: 'Plage de dates invalide.' }); return }
+ for (const dd of days) { try { await roster.setAbsence(t.id, dd, dropAsk.absType || 'Congé', false) } catch (e) { err(e) } } // séquentiel (frappe_pg)
+ await reloadAbsences(); await reloadOccupancy()
+ const retour = addDaysISO(days[days.length - 1], 1)
+ const noms = dropAsk.names.slice(); dropAsk.open = false
+ $q.notify({ type: 'warning', icon: 'event_busy', message: `${t.name} absent ${days.length} j (${dropAsk.absType}) · retour le ${retour}. ${noms.length} job(s) laissé(s) au pool.`, timeout: 8000, actions: [{ label: 'Assigner à un autre tech', color: 'white', handler: () => openAssignPanel() }] })
+ await checkAbsenceImpact(days.map(dd => t.id + '|' + dd)) // redistribue les jobs DÉJÀ assignés sur ces jours (dialogue existant)
+}
+// ── Absence sur PLAGE (comme l'ancien dispatch) : défaut = jour affiché ; options « ce jour / cette semaine / plage du..au ». ──
+const absDialog = reactive({ open: false, techId: '', techName: '', from: '', to: '' })
+function openAbsDialog () {
+ if (!menu.tech || !menu.day) return
+ absDialog.techId = menu.tech.id; absDialog.techName = menu.tech.name
+ absDialog.from = menu.day.iso; absDialog.to = menu.day.iso; absDialog.open = true; menu.show = false
+}
+function absWeek () { const m = mondayISO(absDialog.from || todayISO()); absDialog.from = m; absDialog.to = addDaysISO(m, 6) }
+function absDays () { const out = []; let d = absDialog.from; if (!d || !absDialog.to || absDialog.to < d) return d ? [d] : out; let g = 0; while (d <= absDialog.to && g++ < 400) { out.push(d); d = addDaysISO(d, 1) }; return out }
+async function applyAbs (remove) {
+ const days = absDays(); if (!days.length || !absDialog.techId) return
+ for (const d of days) { try { await roster.setAbsence(absDialog.techId, d, 'Congé', remove) } catch (e) { err(e) } } // séquentiel (frappe_pg)
+ await reloadAbsences(); await reloadOccupancy()
+ $q.notify({ type: 'info', message: remove ? ('Absence retirée (' + days.length + ' j)') : (days.length + ' jour(s) marqué(s) absent') })
+ absDialog.open = false
+ if (!remove) await checkAbsenceImpact(days.map(d => absDialog.techId + '|' + d))
+}
+
+// ── Mini-carte du volet détails : zoome sur le job + layer « tracé GPS réel » (Traccar) en option ──
+const jdMapEl = ref(null); let _jdMap = null
+const jdShowTrack = ref(false); const jdTrackMsg = ref('')
+// Toggle Traccar disponible UNIQUEMENT aujourd'hui ou hier (ET) — sinon Traccar trop lourd/lent.
+const kbCanTrack = computed(() => {
+ const iso = kanbanDay.value && kanbanDay.value.iso; const n = nowET.value.iso; if (!iso || !n) return false
+ const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
+ return iso === n || iso === yest
+})
+async function initJdMap () {
+ if (!MAPBOX_TOKEN || !jdMapEl.value) return
+ const mapboxgl = await ensureMapbox(); if (!mapboxgl || !jdMapEl.value) return
+ if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null }
+ const lat = jobDetail.lat, lon = jobDetail.lon; const hasLL = isFinite(lat) && isFinite(lon) && (lat || lon)
+ _jdMap = new mapboxgl.Map({ container: jdMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: hasLL ? [lon, lat] : [-73.6756, 45.1599], zoom: hasLL ? 14 : 9 })
+ _jdMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
+ _jdMap.on('load', () => {
+ _jdMap.resize()
+ if (hasLL) new mapboxgl.Marker({ color: '#1976d2' }).setLngLat([lon, lat]).addTo(_jdMap) // le point du job
+ _jdMap.addSource('jd-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
+ _jdMap.addLayer({ id: 'jd-track-halo', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 8, 'line-opacity': 0.22 } })
+ _jdMap.addLayer({ id: 'jd-track-l', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3, 'line-opacity': 0.78 } })
+ if (jdShowTrack.value) loadJdTrack()
+ })
+}
+async function loadJdTrack () {
+ jdTrackMsg.value = '…'
+ const iso = kanbanDay.value && kanbanDay.value.iso; if (!iso || !jobDetail.techId || !_jdMap) { jdTrackMsg.value = ''; return }
+ const from = new Date(iso + 'T00:00:00').toISOString()
+ const to = (iso === nowET.value.iso) ? new Date().toISOString() : new Date(iso + 'T23:59:59').toISOString()
+ try {
+ const r = await roster.traccarTrack(jobDetail.techId, from, to); const coords = (r && r.coords) || []
+ const src = _jdMap.getSource('jd-track')
+ if (src) src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
+ jdTrackMsg.value = coords.length >= 2 ? (coords.length + ' points GPS') : 'Aucun tracé ce jour (ou pas d\'appareil).'
+ } catch (e) { jdTrackMsg.value = 'Tracé indisponible' }
+}
+watch(jdShowTrack, (on) => { if (!_jdMap) return; if (on) loadJdTrack(); else { const s = _jdMap.getSource('jd-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); jdTrackMsg.value = '' } })
+watch(() => jobDetail.open, (open) => { if (open) nextTick(() => setTimeout(initJdMap, 280)); else if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } })
const tplOptions = computed(() => templates.value.map(t => ({ label: t.template_name, value: t.name })))
const techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id })))
@@ -1108,6 +1700,30 @@ function setSkillLevel (t, sk, v) { if (!t.skill_levels) t.skill_levels = {}; t.
// Couleur du cercle (vitesse PAR compétence) : vite → vert · normal → bleu-gris · lent → rouge. Intensité ∝ écart.
function effColor (factor) { const p = effPctOf(factor); if (!p) return '#607d8b'; const a = Math.min(Math.abs(p), 30) / 30; const hue = p > 0 ? 122 : 4; return 'hsl(' + hue + ',' + Math.round(50 + a * 28) + '%,' + (44 - Math.round(a * 8)) + '%)' }
function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e == null || e === '') ? '#607d8b' : effColor(Number(e)) } // neutre si pas d'override (pure par-compétence)
+// Icône Material (offline, jeu Quasar par défaut) représentant une compétence/type — par mots-clés, repli 'bolt'.
+// Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support,
+// outils=réparation ; sinon ligature classique unique (skillIcon). NB: les pins carte (HTML brut) gardent skillIcon.
+function skillSym (sk) {
+ const s = String(sk || '').toLowerCase()
+ if (/install/.test(s)) return symOutlinedToolsLadder
+ if (/support|service.?client|t[ée]l[ée]assist|\baide\b/.test(s)) return symOutlinedHeadsetMic
+ return skillIcon(sk) // réparation → 'build' (clé simple / wrench) via skillIcon ; autres → ligature unique
+}
+function skillIcon (sk) {
+ const s = String(sk || '').toLowerCase()
+ if (/install/.test(s)) return 'construction'
+ if (/r[ée]par|d[ée]pann|bris/.test(s)) return 'build'
+ if (/fibre|fusion|soud|épissure|epissure/.test(s)) return 'cable'
+ if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv'
+ if (/t[ée]l[ée]phon|voip|\b3cx\b/.test(s)) return 'call'
+ if (/r[ée]seau|net\s?admin|router|bgp|olt|acs/.test(s)) return 'dns'
+ if (/monteur|poteau|tour|hauteur|nacelle|a[ée]rien/.test(s)) return 'cell_tower'
+ if (/d[ée]sinstall|retrait|ramass|d[ée]mant/.test(s)) return 'delete_sweep'
+ 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'
+}
function onTagsChange (t, items) {
const newLabels = (items || []).map(x => typeof x === 'string' ? x : x.tag).filter(Boolean)
const removed = (t.skills || []).filter(s => !newLabels.includes(s)) // compétences retirées → vérifier l'impact sur les jobs assignés
@@ -1187,6 +1803,7 @@ const assignSort = ref('date') // défaut = date DUE (facilite le dispatch : auj
const assignSortDir = ref('asc') // sens du tri (asc/desc) — surtout pour la date
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
function jobCity (j) {
+ if (j.municipalite) return j.municipalite // municipalité CANONIQUE résolue côté hub (regroupe Ste-Clotilde / ste-clotilde / … en 1 groupe)
const a = String(j.location_label || j.service_location || '')
const parts = a.split(',').map(s => s.trim()).filter(Boolean)
if (parts.length >= 2) return parts[parts.length - 1] // dernier segment d'adresse = ville
@@ -1248,19 +1865,40 @@ function onJobDragStart (ev, job) {
}
function onJobDragEnd () { dropCell.value = null; dropPreview.key = null; draggingSet.clear(); if (_dragGhost) { _dragGhost.remove(); _dragGhost = null } }
function onCellDragOver (t, d) { dropCell.value = t.id + '|' + d.iso; dropPreview.key = t.id + '|' + d.iso; dropPreview.addH = dragHours.value }
-async function onCellDrop (ev, t, d) {
+async function onCellDrop (ev, t, d, opts = {}) {
dropCell.value = null; dropPreview.key = null
const raw = (ev.dataTransfer && ev.dataTransfer.getData('text/plain')) || draggingJobName.value; draggingJobName.value = null
- const names = (raw || '').split(',').filter(Boolean); if (!names.length) return
+ const names = (raw || '').split(',').filter(Boolean); if (!names.length) return []
// Garde-fou : un job « On Hold » attend une tâche précédente → on REFUSE de l'assigner (≠ simple 🔒 visuel).
const statusBy = Object.fromEntries(assignPanel.jobs.map(j => [j.name, j.status]))
const blocked = names.filter(n => statusBy[n] === 'On Hold'); const assignable = names.filter(n => statusBy[n] !== 'On Hold')
if (blocked.length) $q.notify({ type: 'warning', message: blocked.length + ' job(s) en attente d\'une tâche précédente — non assigné(s). Termine d\'abord l\'étape requise.', timeout: 4000 })
- if (!assignable.length) return
- let ok = 0
- for (const jn of assignable) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
- assignPanel.jobs = assignPanel.jobs.filter(j => !assignable.includes(j.name)) // les bloqués restent dans le panneau
- $q.notify({ type: 'positive', message: ok + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum, timeout: 2800 }); await reloadOccupancy() // refresh rapide du timeline (blocs) sans loadWeek complet
+ if (!assignable.length) return []
+ // Pas de quart RÉGULIER ce jour → on N'ASSIGNE PAS en douce : on demande quoi faire (créer un quart OU marquer absent).
+ const hasShift = cellsOf(t.id, d.iso).some(a => { const tp = tplByName.value[a.shift]; return tp && !tp.on_call })
+ if (!hasShift && !opts.forceAssign) { openDropAsk(assignable, t, d); return [] }
+ const done = await assignNames(assignable, t, d)
+ if (!opts.silent) notifyAssignUndo(done, t, d, null) // toast avec « Annuler » (désassigne)
+ return done
+}
+// Boucle d'assignation serveur (séquentielle, frappe_pg) — partagée par le drop direct et le dialogue « créer un quart ».
+async function assignNames (names, t, d) {
+ const done = []
+ for (const jn of names) { try { await roster.assignJob(jn, t.id, d.iso); done.push(jn); delete selectedJobs[jn] } catch (e) { err(e) } }
+ assignPanel.jobs = assignPanel.jobs.filter(j => !names.includes(j.name))
+ await reloadOccupancy()
+ return done
+}
+// Toast d'assignation AVEC bouton « Annuler » → désassigne le(s) job(s) + retire le quart auto-créé. Corrige : l'undo local ne touchait que les quarts, pas l'assignation serveur.
+function notifyAssignUndo (names, t, d, autoShiftName) {
+ if (!names || !names.length) return
+ $q.notify({ type: 'positive', message: names.length + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum + (autoShiftName ? ' · quart 8–16 créé' : ''), timeout: 6500, actions: [{ label: 'Annuler', color: 'white', handler: () => undoDrop(names, t, d, autoShiftName) }] })
+}
+async function undoDrop (names, t, d, autoShiftName) {
+ let ok = 0; for (const n of (names || [])) { try { await roster.unassignJobRoster(n); ok++ } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
+ if (autoShiftName) { pushHistory(); removeShift(t.id, d.iso, autoShiftName) } // retire aussi le quart auto-créé par le drop
+ await reloadOccupancy(); await reloadPool()
+ $q.notify({ type: 'info', message: 'Annulé — ' + ok + ' job(s) renvoyé(s) au pool' + (autoShiftName ? ' + quart retiré' : ''), timeout: 2600 })
}
let _panelDrag = null // déplacement du panneau via son en-tête
function panelHeaderDown (ev) { _panelDrag = { dx: ev.clientX - assignPanel.x, dy: ev.clientY - assignPanel.y }; document.addEventListener('mousemove', panelMove); document.addEventListener('mouseup', panelUp) }
@@ -1321,13 +1959,27 @@ function openStreetView (lng, lat) { window.open(`https://www.google.com/maps/@?
// frontière US). Clic / glisser le repère OU recherche d'adresse RQA → coords + adresse, écrits sur le Dispatch Job. ──
const locMapEl = ref(null); let _locMap = null, _locMarker = null
const TERR_BOUNDS = [[-74.95, 44.95], [-73.0, 45.55]] // SW (Valleyfield/frontière) → NE
-const locPicker = reactive({ open: false, job: null, subject: '', lat: null, lon: null, address: '', search: '', results: [], searching: false, saving: false })
+const locPicker = reactive({ open: false, mode: 'job', job: null, tech: null, techName: '', subject: '', lat: null, lon: null, address: '', search: '', results: [], searching: false, saving: false })
function openLocPicker (j) {
- locPicker.job = j.name; locPicker.subject = j.subject || j.name
+ locPicker.mode = 'job'; locPicker.job = j.name; locPicker.tech = null; locPicker.subject = j.subject || j.name
locPicker.lat = hasLL(j) ? +j.lat : null; locPicker.lon = hasLL(j) ? +j.lon : null
locPicker.address = j.address || ''; locPicker.search = j.address || ''; locPicker.results = []
locPicker.open = true
}
+// Définir le DÉPÔT (point de départ par défaut des tournées) via le même sélecteur de carte.
+function openDepotPicker () {
+ const d = depot.value || {}; locPicker.mode = 'depot'; locPicker.job = null; locPicker.tech = null; locPicker.subject = 'Point de départ (dépôt)'
+ locPicker.lat = hasLL(d) ? +d.lat : null; locPicker.lon = hasLL(d) ? +d.lon : null
+ locPicker.address = d.address || ''; locPicker.search = d.address || ''; locPicker.results = []
+ locPicker.open = true
+}
+// Définir le DOMICILE d'un tech (origine si plus proche du travail que le dépôt).
+function openHomePicker (t) {
+ const h = (techHomes.value[t.id]) || {}; locPicker.mode = 'home'; locPicker.job = null; locPicker.tech = t.id; locPicker.techName = t.name || t.id; locPicker.subject = 'Domicile — ' + (t.name || t.id)
+ locPicker.lat = hasLL(h) ? +h.lat : null; locPicker.lon = hasLL(h) ? +h.lon : null
+ locPicker.address = h.address || ''; locPicker.search = h.address || ''; locPicker.results = []
+ locPicker.open = true
+}
async function initLocMap () {
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !locMapEl.value) return
if (_locMap) { _locMap.resize(); return }
@@ -1373,11 +2025,23 @@ async function saveLocPicker () {
if (locPicker.lat == null || locPicker.lon == null) { $q.notify({ type: 'warning', message: 'Choisis un point sur la carte' }); return }
locPicker.saving = true
try {
- 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 }
- locPicker.open = false
- await reloadOccupancy(); if (_dayMap) refreshDayMap()
- $q.notify({ type: 'positive', icon: 'place', message: 'Emplacement enregistré sur le job' })
+ if (locPicker.mode === 'depot') {
+ const obj = { label: 'Dépôt', address: locPicker.address || '', lat: locPicker.lat, lon: locPicker.lon }
+ await roster.savePolicy({ depot: obj }); depot.value = obj
+ locPicker.open = false; if (_dayMap) refreshDayMap()
+ $q.notify({ type: 'positive', icon: 'warehouse', message: 'Dépôt (point de départ) enregistré' })
+ } else if (locPicker.mode === 'home') {
+ const homes = { ...techHomes.value, [locPicker.tech]: { address: locPicker.address || '', lat: locPicker.lat, lon: locPicker.lon } }
+ await roster.savePolicy({ tech_homes: homes }); techHomes.value = homes
+ locPicker.open = false; if (_dayMap) refreshDayMap()
+ $q.notify({ type: 'positive', icon: 'home', message: 'Domicile enregistré — ' + locPicker.techName })
+ } 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 }
+ locPicker.open = false
+ await reloadOccupancy(); if (_dayMap) refreshDayMap()
+ $q.notify({ type: 'positive', icon: 'place', message: 'Emplacement enregistré sur le job' })
+ }
} catch (e) { err(e) } finally { locPicker.saving = false }
}
function destroyLocMap () { if (_locMarker) { try { _locMarker.remove() } catch (e) {} _locMarker = null } if (_locMap) { try { _locMap.remove() } catch (e) {} _locMap = null } }
@@ -1400,6 +2064,14 @@ async function techAppLinkMenu (t) {
} catch (e) { err(e) }
}
+// ── Bloc Legacy sur la timeline : clic → détail du ticket (fil osTicket ingéré in-app, via ticketThread) ──
+const legacyDetail = reactive({ open: false, lid: null, subject: '', dept: '', loading: false, thread: null })
+async function openLegacyBlock (b) {
+ if (!b || !b.lid) return
+ legacyDetail.lid = b.lid; legacyDetail.subject = b.subject || ('#' + b.lid); legacyDetail.dept = b.dept || ''; legacyDetail.thread = null; legacyDetail.loading = true; legacyDetail.open = true
+ try { legacyDetail.thread = await roster.ticketThread(b.lid) } catch (e) { err(e); legacyDetail.thread = { error: true, messages: [] } } finally { legacyDetail.loading = false }
+}
+
// ── Synchroniser les techniciens : rapport de réconciliation (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) ──
const techSync = reactive({ open: false, loading: false, applying: false, report: null, sel: {} })
const techSyncSelCount = computed(() => Object.values(techSync.sel).filter(Boolean).length)
@@ -1492,15 +2164,39 @@ function openDayEditor (t, d) {
dayEditor.tech = t; dayEditor.day = d
// RDV confirmé (ou heure légacy précise) = heure FIXE → verrouillé ; sinon flexible (replanifiable par la tournée).
dayEditor.list = cellJobs(t.id, d.iso).map(j => ({ ...j, locked: j.booking_status === 'Confirmé', showDetail: false }))
- dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true
+ dayEditor.dragIdx = null; dayEditor.travelMap = {}; dayEditor.routeReady = false; dayEditor.open = true; dayShowTrack.value = false
loadDayRoute() // charge la matrice de temps routiers RÉELS (Mapbox) → packedDay les utilise dès l'arrivée (réactif)
}
+// ── Tracé GPS réel (Traccar) SUPERPOSÉ sur la carte de l'éditeur de jour (jobs + itinéraire planifié déjà présents) ──
+// → permet de voir si le tech est réellement passé par ses jobs. Toggle dispo AUJOURD'HUI/HIER seulement (Traccar lourd).
+const dayShowTrack = ref(false); const dayTrackMsg = ref('')
+const dayCanTrack = computed(() => {
+ const iso = dayEditor.day && dayEditor.day.iso; const n = nowET.value.iso; if (!iso || !n) return false
+ const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
+ return iso === n || iso === yest
+})
+async function loadDayTrack () {
+ dayTrackMsg.value = '…'
+ const iso = dayEditor.day && dayEditor.day.iso; const tech = dayEditor.tech && dayEditor.tech.id
+ const src = _dayMap && _dayMap.getSource('day-track')
+ if (!iso || !tech || !src) { dayTrackMsg.value = ''; return }
+ const from = new Date(iso + 'T00:00:00').toISOString()
+ const to = (iso === nowET.value.iso) ? new Date().toISOString() : new Date(iso + 'T23:59:59').toISOString()
+ try {
+ const r = await roster.traccarTrack(tech, from, to); const coords = (r && r.coords) || []
+ src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
+ dayTrackMsg.value = coords.length >= 2 ? (coords.length + ' pts GPS') : 'aucun tracé / appareil'
+ } catch (e) { dayTrackMsg.value = 'indispo' }
+}
+watch(dayShowTrack, (on) => { if (!_dayMap) return; if (on) loadDayTrack(); else { const s = _dayMap.getSource('day-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); dayTrackMsg.value = '' } })
// Matrice des temps de trajet ROUTIERS RÉELS entre tous les jobs du jour (Mapbox Matrix, 1 requête).
// Indépendante de l'ordre → le réordonnancement réutilise la matrice SANS nouvelle requête (recalcul instantané).
// Repli silencieux sur l'haversine si Mapbox indispo ou coords manquantes.
async function loadDayRoute () {
const key = (dayEditor.tech && dayEditor.tech.id) + '|' + (dayEditor.day && dayEditor.day.iso)
- const pts = dayEditor.list.filter(j => j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon)).slice(0, 25) // Matrix = 25 coords max
+ const jobsLL = dayEditor.list.filter(j => j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon))
+ const origin = dayOrigin.value // l'origine (dépôt/domicile) entre dans la matrice → trajet origine→1er job RÉEL
+ const pts = ((origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [origin, ...jobsLL] : jobsLL).slice(0, 25) // Matrix = 25 coords max
if (pts.length < 2 || !MAPBOX_TOKEN) { dayEditor.travelMap = {}; dayEditor.routeReady = false; return }
const coords = pts.map(j => `${(+j.lon).toFixed(6)},${(+j.lat).toFixed(6)}`).join(';')
const url = `https://api.mapbox.com/directions-matrix/v1/mapbox/driving/${coords}?annotations=duration,distance&access_token=${MAPBOX_TOKEN}`
@@ -1539,13 +2235,15 @@ function travelBetween (a, b) {
const km = haversineKm(a.lat, a.lon, b.lat, b.lon); if (km == null) return null
return { km: Math.round(km * 10) / 10, min: Math.max(5, Math.round(km / 40 * 60) + 5), real: false } // repli : 40 km/h + 5 min tampon (vol d'oiseau)
}
-function dayLeg (i) { return i > 0 ? travelBetween(dayEditor.list[i - 1], dayEditor.list[i]) : null } // trajet vers le job i depuis le précédent
+function dayLeg (i) { if (i === 0) { const o = dayOrigin.value; return (o && dayEditor.list[0]) ? travelBetween(o, dayEditor.list[0]) : null } return travelBetween(dayEditor.list[i - 1], dayEditor.list[i]) } // i=0 = trajet origine (dépôt/domicile) → 1er job ; sinon job précédent → courant
const fmtHM = (h) => { if (h == null) return '—'; const m = Math.round(h * 60); const hh = Math.floor(m / 60), mm = m % 60; return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } // heure décimale → HH:MM (padded, pour start_time)
function dayShiftStartH () { const t = dayEditor.tech, d = dayEditor.day; if (!t || !d) return 8; const w = winOf(t.id, d.iso, false); return w ? w.s : 8 }
// PLANIFICATEUR DE TOURNÉE : recalcule les heures depuis l'ordre de la liste + durées + transport.
// Job verrouillé (RDV fixe) → garde son heure ; flexible → enchaîné après le précédent (+ transport). Plus d'overlap.
const packedDay = computed(() => {
const list = dayEditor.list; const out = []; let cursor = dayShiftStartH()
+ const origin = dayOrigin.value
+ if (origin && list.length) { const l0 = (travelBetween(origin, list[0]) || {}).min || 0; cursor += l0 / 60 } // départ origine → 1er job (le tech quitte l'origine à l'heure du shift)
for (let i = 0; i < list.length; i++) {
const j = list[i]; const dur = Number(j.dur) || 1
const start = (j.locked && j.start_h != null) ? j.start_h : cursor
@@ -1560,10 +2258,27 @@ const packedDay = computed(() => {
const hasLL = (j) => j && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && (Math.abs(+j.lat) > 0.01 || Math.abs(+j.lon) > 0.01)
const dayNoCoord = computed(() => dayEditor.list.filter(j => !hasLL(j)).length)
+// ── Origine de tournée : point de départ = DÉPÔT par défaut, OU DOMICILE du tech s'il est plus proche du travail ──
+// (politique dispatch, lue côté client). La 1re job devient la plus proche de cette origine via « Optimiser depuis le départ ».
+const depot = ref(null) // { label, address, lat, lon }
+const techHomes = ref({}) // { techId: { address, lat, lon } }
+async function loadDispatchPolicy () {
+ try { const d = await roster.getPolicy(); depot.value = (d.policy && d.policy.depot) || null; techHomes.value = (d.policy && d.policy.tech_homes) || {} } catch (e) { /* non bloquant */ }
+}
+const dayOrigin = computed(() => {
+ const t = dayEditor.tech; if (!t) return null
+ const dp = depot.value; const dep = (dp && hasLL(dp)) ? { name: '__origin__', lat: +dp.lat, lon: +dp.lon, label: dp.label || 'Dépôt', address: dp.address || '', kind: 'depot' } : null
+ const hm = techHomes.value[t.id]; const home = (hm && hasLL(hm)) ? { name: '__origin__', lat: +hm.lat, lon: +hm.lon, label: 'Domicile', address: hm.address || '', kind: 'home' } : null
+ const jobs = dayEditor.list.filter(hasLL)
+ if (!jobs.length) return dep || home || null
+ if (dep && home) { const nd = (o) => Math.min(...jobs.map(j => haversineKm(o.lat, o.lon, +j.lat, +j.lon) ?? 9e9)); return nd(home) < nd(dep) ? home : dep } // domicile gagne s'il est plus proche
+ return dep || home || null
+})
+
// ── Carte INTERACTIVE de la journée (Mapbox GL) : itinéraire ROUTIER réel (API Directions) + pins
// numérotés dans l'ordre de tournée. Navigable : zoom molette + boutons (NavigationControl), déplacement. ──
const dayMapEl = ref(null)
-let _dayMap = null; let _dayMapRO = null; let _dirTimer = null
+let _dayMap = null; let _dayMapRO = null; let _dirTimer = null; let _dayStopMarkers = []
function ensureMapbox () {
return new Promise((resolve) => {
if (!document.getElementById('mapbox-css')) { const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'; l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) }
@@ -1577,12 +2292,12 @@ function ensureMapbox () {
const _esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup
return packedDay.value.filter(hasLL).map((j, i) => ({
- lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2',
+ lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2', skill: j.skill || '',
subject: j.subject || '', customer: j.customer || '', address: j.address || '', time: fmtHM(j.startMin) + '–' + fmtHM(j.endMin),
}))
}
// Segments de DÉPLACEMENT (pointillés) = l'espace entre 2 jobs dans la barre timeline.
-const dayTravelSegs = () => { const p = packedDay.value; const out = []; for (let i = 0; i < p.length - 1; i++) { const s = p[i].endMin, e = p[i + 1].startMin; if (e - s > 0.02) out.push({ s, e }) } return out }
+const dayTravelSegs = () => { const p = packedDay.value; const out = []; const o = dayOrigin.value; if (o && p.length) { const s = dayShiftStartH(), e = p[0].startMin; if (e - s > 0.02) out.push({ s, e }) } for (let i = 0; i < p.length - 1; i++) { const s = p[i].endMin, e = p[i + 1].startMin; if (e - s > 0.02) out.push({ s, e }) } return out }
// Centre la carte sur un job (clic sur la ligne de la liste).
function focusDayJob (j) { if (_dayMap && hasLL(j)) _dayMap.easeTo({ center: [+j.lon, +j.lat], zoom: 14, duration: 500 }) }
// Fil legacy (messages + réponses du ticket osTicket) chargé à la demande, mis en cache sur le job (j._thread).
@@ -1636,8 +2351,12 @@ async function initDayMap () {
_dayMap.addSource('day-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_dayMap.addLayer({ id: 'day-route-halo', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 9, 'line-opacity': 0.2 } })
_dayMap.addLayer({ id: 'day-route', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 4, 'line-opacity': 0.85 } })
+ // Tracé GPS RÉEL (Traccar) — orange semi-transparent, par-dessus l'itinéraire planifié (bleu) ; sous les pins.
+ _dayMap.addSource('day-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
+ _dayMap.addLayer({ id: 'day-track-halo', type: 'line', source: 'day-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 9, 'line-opacity': 0.2 } })
+ _dayMap.addLayer({ id: 'day-track-l', type: 'line', source: 'day-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3.5, 'line-opacity': 0.8 } })
_dayMap.addSource('day-stops', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
- _dayMap.addLayer({ id: 'day-stops-c', type: 'circle', source: 'day-stops', paint: { 'circle-radius': 13, 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } })
+ _dayMap.addLayer({ id: 'day-stops-c', type: 'circle', source: 'day-stops', paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } }) // groupe même adresse = un peu plus gros ; rayon réduit pour moins de chevauchement
_dayMap.addLayer({ id: 'day-stops-l', type: 'symbol', source: 'day-stops', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 12, 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
// Clic sur un pin → popup avec les détails du job ; curseur main au survol.
_dayMap.on('mouseenter', 'day-stops-c', () => { _dayMap.getCanvas().style.cursor = 'pointer' })
@@ -1645,7 +2364,7 @@ async function initDayMap () {
_dayMap.on('click', 'day-stops-c', (e) => {
const f = e.features[0]; const p = f.properties
new window.mapboxgl.Popup({ offset: 14 }).setLngLat(f.geometry.coordinates)
- .setHTML(`
${_esc(p.label)}. ${_esc(p.subject)}
🕒 ${_esc(p.time)}${p.customer ? '
👤 ' + _esc(p.customer) : ''}${p.address ? '
📍 ' + _esc(p.address) : ''}
`)
+ .setHTML(`
${p.popup || ('' + _esc(p.label) + '')}${p.address ? '
📍 ' + _esc(p.address) + '' : ''}
`)
.addTo(_dayMap)
})
refreshDayMap()
@@ -1654,22 +2373,49 @@ async function initDayMap () {
function refreshDayMap () {
if (!_dayMap || !_dayMap.isStyleLoaded()) { setTimeout(refreshDayMap, 200); return }
const stops = dayStops()
- const sSrc = _dayMap.getSource('day-stops')
- if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: stops.map(s => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.label, color: s.color, subject: s.subject, customer: s.customer, address: s.address, time: s.time } })) })
- if (stops.length === 1) _dayMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 13, duration: 400 })
- else if (stops.length > 1) {
- const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat])
- stops.forEach(s => b.extend([s.lon, s.lat]))
+ const origin = dayOrigin.value
+ // Regroupe les arrêts à la MÊME adresse (coords arrondies) → 1 pin « style Gaiia » : icône vectorielle du TYPE de la job
+ // PRINCIPALE (1re en ordre de tournée) + son n°, et — si plusieurs jobs à l'adresse — une mini barre segmentée au-dessus
+ // du n° (1 segment coloré par job) qui distingue la principale (l'icône) des secondaires. Le popup liste tous les jobs.
+ const grp = {}; const gord = []
+ for (const s of stops) { const k = s.lon.toFixed(5) + ',' + s.lat.toFixed(5); if (!grp[k]) { grp[k] = { lon: s.lon, lat: s.lat, items: [] }; gord.push(k) } grp[k].items.push(s) }
+ // Marqueurs DOM custom (styles inline car ils sortent du scope CSS du composant) ; les couches circle/symbol restent vides.
+ _dayStopMarkers.forEach(m => { try { m.remove() } catch (e) {} }); _dayStopMarkers = []
+ const sSrc = _dayMap.getSource('day-stops'); if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: [] })
+ const seg = (items) => items.length > 1 ? `
${items.map(i => ``).join('')}
` : ''
+ const pinHtml = (color, icon, num, items, round) => `${seg(items || [])}
${icon}${num ? `${num}` : ''}
`
+ const addPin = (lon, lat, html, popup, addr) => {
+ const el = document.createElement('div'); el.style.cssText = 'display:flex;flex-direction:column;align-items:center;cursor:pointer;line-height:0'; el.innerHTML = html
+ el.addEventListener('click', (ev) => { ev.stopPropagation(); new window.mapboxgl.Popup({ offset: 16 }).setLngLat([lon, lat]).setHTML(`
${popup}${addr ? '
📍 ' + _esc(addr) + '' : ''}
`).addTo(_dayMap) })
+ _dayStopMarkers.push(new window.mapboxgl.Marker({ element: el, anchor: 'bottom' }).setLngLat([lon, lat]).addTo(_dayMap))
+ }
+ for (const k of gord) {
+ const g = grp[k]; const f = g.items[0]
+ const popup = g.items.map(i => `
${_esc(i.label)}. ${_esc(i.subject)}${i.time ? ' · ' + _esc(i.time) : ''}${i.customer ? ' · ' + _esc(i.customer) : ''}`).join('
')
+ addPin(g.lon, g.lat, pinHtml(f.color, skillIcon(f.skill), f.label, g.items, false), popup, f.address) // n° = job principale ; barre = N jobs à l'adresse
+ }
+ if (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) { // pin de départ (dépôt/domicile) : icône maison/entrepôt
+ const oc = origin.kind === 'home' ? '#00897b' : '#37474f'
+ addPin(+origin.lon, +origin.lat, pinHtml(oc, origin.kind === 'home' ? 'home' : 'warehouse', '', null, true), '
Départ — ' + _esc(origin.label || ''), origin.address || '')
+ }
+ const all = (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [...stops, { lon: +origin.lon, lat: +origin.lat }] : stops
+ if (all.length === 1) _dayMap.easeTo({ center: [all[0].lon, all[0].lat], zoom: 13, duration: 400 })
+ else if (all.length > 1) {
+ const b = new window.mapboxgl.LngLatBounds([all[0].lon, all[0].lat], [all[0].lon, all[0].lat])
+ all.forEach(s => b.extend([s.lon, s.lat]))
_dayMap.fitBounds(b, { padding: 45, maxZoom: 14, duration: 400 })
}
- fetchDayRouteGeom(stops)
+ fetchDayRouteGeom(stops, origin)
}
-async function fetchDayRouteGeom (stops) { // itinéraire ROUTIER réel (Directions) → tracé sur la carte
+async function fetchDayRouteGeom (stops, origin) { // itinéraire ROUTIER réel (Directions) → tracé sur la carte, DEPUIS l'origine
const rSrc = _dayMap && _dayMap.getSource('day-route'); if (!rSrc) return
- if (!stops || stops.length < 2) { rSrc.setData({ type: 'FeatureCollection', features: [] }); return }
+ const ordered = (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? [{ lon: +origin.lon, lat: +origin.lat }, ...(stops || [])] : (stops || [])
+ if (ordered.length < 2) { rSrc.setData({ type: 'FeatureCollection', features: [] }); return }
try {
- const pts = stops.slice(0, 25).map(s => `${s.lon.toFixed(6)},${s.lat.toFixed(6)}`).join(';')
- const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${pts}?overview=full&geometries=geojson&access_token=${MAPBOX_TOKEN}`
+ const pts = ordered.slice(0, 25).map(s => `${(+s.lon).toFixed(6)},${(+s.lat).toFixed(6)}`).join(';')
+ // continue_straight=false : autorise le demi-tour aux points intermédiaires (sinon Mapbox interdit le U-turn au
+ // waypoint → grande boucle parasite quand le job suivant est « derrière » ; un tech PEUT faire demi-tour).
+ const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${pts}?overview=full&geometries=geojson&continue_straight=false&access_token=${MAPBOX_TOKEN}`
const r = await fetch(url); if (!r.ok) throw new Error('dir ' + r.status)
const d = await r.json(); const geom = d.routes && d.routes[0] && d.routes[0].geometry
const src = _dayMap && _dayMap.getSource('day-route')
@@ -1679,6 +2425,7 @@ async function fetchDayRouteGeom (stops) { // itinéraire ROUTIER réel (Directi
function destroyDayMap () {
if (_dirTimer) { clearTimeout(_dirTimer); _dirTimer = null }
if (_dayMapRO) { _dayMapRO.disconnect(); _dayMapRO = null }
+ _dayStopMarkers.forEach(m => { try { m.remove() } catch (e) {} }); _dayStopMarkers = []
if (_dayMap) { try { _dayMap.remove() } catch (e) {} _dayMap = null }
}
// (ré)init à l'ouverture du dialogue (après l'anim) ; refresh débouncé au réordonnancement ; destruction à la fermeture.
@@ -1689,13 +2436,32 @@ const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j
async function startJobChrono (j) { try { const r = await roster.startJob(j.name); j.actual_start = r.actual_start || '1'; j.actual_end = ''; $q.notify({ type: 'positive', icon: 'play_arrow', message: 'Job démarré (chrono)', timeout: 1500 }) } catch (e) { err(e) } }
async function finishJobChrono (j) { try { const r = await roster.finishJob(j.name); j.actual_end = '1'; j.actual_min = r.actual_minutes; $q.notify({ type: 'positive', icon: 'check', message: `Terminé · ${r.actual_minutes} min réels`, timeout: 2500 }); reloadOccupancy() } catch (e) { err(e) } }
async function removeFromDay (j) {
- // Action EXPLICITE → renvoie aussi le ticket au pool « Tech Targo » (3301) DANS Legacy (write-back symétrique).
+ // TAMPON : on désassigne SEULEMENT dans OPS (ERPNext), comme le clic-droit « Renvoyer au pool ». AUCUNE écriture dans F ici.
+ // Le retour au pool dans F (legacy) ne part qu'à « Publier au legacy » (action explicite). Évite tout write-back surprise.
try {
- const r = await roster.returnJobToPool(j.name)
+ await roster.unassignJobRoster(j.name)
dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek()
- $q.notify({ type: 'info', message: (r && r.returned_to_pool) ? 'Retiré du tech — ticket renvoyé au pool « Tech Targo » dans Legacy' : 'Retiré du tech (retour au pool « à assigner »)', timeout: 2600 })
+ $q.notify({ type: 'info', message: 'Retiré du tech (retour au pool OPS). À publier dans F via « Publier au legacy ».', timeout: 2800 })
} catch (e) { err(e) }
}
+// « Optimiser depuis le départ » : ordonne par PLUS-PROCHE-VOISIN à partir de l'origine (dépôt/domicile)
+// → la 1re job devient la plus proche de l'origine. Non destructif : l'utilisateur Enregistre ensuite.
+function optimizeFromOrigin () {
+ const origin = dayOrigin.value
+ const withLL = dayEditor.list.filter(hasLL)
+ const noLL = dayEditor.list.filter(j => !hasLL(j))
+ if (withLL.length < 2) { $q.notify({ type: 'info', message: 'Pas assez d’arrêts géolocalisés à optimiser' }); return }
+ const remaining = withLL.slice(); const ordered = []
+ let cur = (origin && isFinite(+origin.lat) && isFinite(+origin.lon)) ? origin : remaining.shift()
+ if (cur && cur !== origin) ordered.push(cur) // pas d'origine définie : on garde le 1er arrêt comme point de départ
+ while (remaining.length) {
+ let bi = 0, bd = Infinity
+ for (let i = 0; i < remaining.length; i++) { const lg = travelBetween(cur, remaining[i]); const d = lg ? lg.min : 9e9; if (d < bd) { bd = d; bi = i } }
+ cur = remaining[bi]; ordered.push(remaining.splice(bi, 1)[0])
+ }
+ dayEditor.list = [...ordered, ...noLL]
+ $q.notify({ type: 'positive', icon: 'route', message: 'Tournée optimisée' + (origin ? ' depuis ' + (origin.kind === 'home' ? 'le domicile' : 'le dépôt') : ''), timeout: 1800 })
+}
async function saveDayOrder () {
dayEditor.saving = true
const packed = packedDay.value // heures recalculées par la tournée → on les persiste (start_time)
@@ -1736,6 +2502,16 @@ function hashColor (label) { let h = 0; for (const c of String(label)) h = (h *
const customTags = ref([]) // [{label,color}] créés à la volée (localStorage)
function saveCustomTags () { localStorage.setItem('roster-skill-tags-v1', JSON.stringify(customTags.value)) }
function getTagColor (label) { const ct = customTags.value.find(x => x.label === label); return (ct && ct.color) || hashColor(label) }
+// Teinte PASTEL (≈80 % blanc + 20 % couleur) → fond doux des blocs (moins chargé), avec contour/icône en couleur pleine.
+function pastelColor (hex) {
+ const h = String(hex || '').replace('#', ''); if (h.length < 6) return '#eef1f4'
+ const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16)
+ const mix = (c) => Math.round(255 * 0.80 + (isNaN(c) ? 200 : c) * 0.20)
+ return `rgb(${mix(r)},${mix(g)},${mix(b)})`
+}
+function hexA (hex, a) { const h = String(hex || '').replace('#', ''); if (h.length < 6) return `rgba(120,130,145,${a})`; return `rgba(${parseInt(h.slice(0, 2), 16)},${parseInt(h.slice(2, 4), 16)},${parseInt(h.slice(4, 6), 16)},${a})` }
+// Fond HACHURÉ (renfort/assistant) : rayures diagonales de la couleur de compétence sur blanc → « réservé sur une autre job ».
+function hatchBg (hex) { const c = hexA(hex, 0.22); return `repeating-linear-gradient(45deg, ${c} 0, ${c} 5px, #ffffff 5px, #ffffff 11px)` }
// Couleur d'une carte job = COULEUR DE SA COMPÉTENCE (éditable via le gestionnaire de tags → cohérent + simple).
// required_skill est renseigné côté hub (skill explicite, sinon déduit du type legacy). Repli : couleur du type.
function panelJobColor (j) { return j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae') }
@@ -1855,6 +2631,11 @@ const hasOnCall = computed(() => Object.keys(gardeEffective.value).length > 0 ||
// Micro-timeline 24 h par cellule : fenêtre(s) du shift = bande neutre, jobs pris = trait coloré.
const occByTechDay = ref({})
+// Déclencheur matrice Mapbox du Kanban (défini ICI, après occByTechDay/visibleTechs, pour éviter la zone morte temporelle).
+watch([boardView, kanbanDay, occByTechDay], () => {
+ if (boardView.value !== 'kanban') return
+ clearTimeout(_kbMatT); _kbMatT = setTimeout(() => { for (const t of visibleTechs.value) fetchKbMatrix(t.id) }, 350) // 1 call/tournée visible, débouncé + caché
+})
const absByTechDay = ref({}) // tech|date → type d'absence (En pause / Congé / Maladie…) → hachuré
function isAbsent (techId, iso) { return !!absByTechDay.value[techId + '|' + iso] }
function absenceLabel (techId, iso) { return absByTechDay.value[techId + '|' + iso] || 'Absent' }
@@ -1890,9 +2671,9 @@ const axisBounds = computed(() => {
let lo = Infinity; let hi = -Infinity
const grow = (t) => { if (!t) return; const s = hToNum(t.start_time); const e = hToNum(t.end_time); if (s != null) lo = Math.min(lo, s); if (e != null) hi = Math.max(hi, e <= s ? 24 : e) }
for (const techId in cellsByTechDay.value) { const day = cellsByTechDay.value[techId]; for (const iso in day) for (const a of day[iso]) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; grow(t) } }
- for (const sh of new Set(Object.values(gardeEffective.value))) grow(tplByName.value[sh]) // garde visible (calque + manuel)
- if (!isFinite(lo) || !isFinite(hi)) return { min: 7, max: 19 }
- lo = Math.max(0, Math.floor(lo)); hi = Math.min(24, Math.ceil(hi)); if (hi - lo < 4) hi = Math.min(24, lo + 4)
+ // Garde NON incluse dans l'axe (rendue comme bande « sur appel ») → l'axe reste FOCALISÉ sur les heures de travail.
+ if (!isFinite(lo) || !isFinite(hi)) return { min: 6, max: 19 }
+ lo = Math.max(0, Math.min(6, Math.floor(lo))); hi = Math.min(24, Math.max(19, Math.ceil(hi))) // focus 6–19, étendu seulement si un quart régulier déborde
return { min: lo, max: hi }
})
// Graduations horaires pour la règle d'en-tête (alignées sur l'axe adaptatif)
@@ -1923,7 +2704,19 @@ function cellBands (techId, iso) {
// Barre de statut OPAQUE selon l'occupation : vert (peu) → orange (plein) → rouge (surbooké).
function occColor (pct) { if (pct == null) return '#9e9e9e'; if (pct >= 100) return '#e53935'; const t = Math.max(0, Math.min(1, pct / 100)); return 'hsl(' + Math.round(122 - t * 90) + ',68%,44%)' }
// Bloc = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable). Repli : couleur d'occupation.
-function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: blk && blk.skill ? getTagColor(blk.skill) : occColor(pct) } }
+// OPS-only = job dispatché dans OPS mais PAS encore dans F (autoritaire) : aucun legacy_id et pas un bloc synthétique F.
+function blkOpsOnly (b) { return !!(b && !b.legacy && !b.legacy_id) }
+// Couleur d'un bloc : dépt F (legacyDeptColor) si connu, sinon compétence OPS. PARTAGÉ grille + kanban (bordure, icône, remplissage).
+function blkColor (b) { return (b && (b.dept || b.legacy_dept) ? legacyDeptColor(b.dept || b.legacy_dept) : null) || (b && b.skill ? getTagColor(b.skill) : '#90a4ae') }
+// Remplissage d'un bloc. F autoritaire (synthétique legacy OU job lié à un ticket F) = couleur PLEINE du dépt.
+// OPS-only (pas encore publié dans F) = PÂLE + contour POINTILLÉ. assist = hachuré.
+function blkFill (blk) {
+ const c = blkColor(blk)
+ if (blk && blk.assist) return { background: hatchBg(c), borderColor: c, borderStyle: 'dashed' }
+ if (blkOpsOnly(blk)) return { background: pastelColor(c), borderColor: c, borderStyle: 'dotted', borderWidth: '2px', opacity: '.72' }
+ return { background: pastelColor(c), borderColor: c }
+}
+function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), ...blkFill(blk) } } // F = couleur dépt pleine ; OPS-non-publié = pâle pointillé ; assist = hachuré
// Fenêtre des shifts (garde=true → seulement les quarts de garde ; garde=false → réguliers)
function winOf (techId, iso, garde) { let s = Infinity; let e = -Infinity; for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || (!!t.on_call) !== garde) continue; const st = hToNum(t.start_time); const en = hToNum(t.end_time); if (st != null) s = Math.min(s, st); if (en != null) e = Math.max(e, en) } return isFinite(s) ? { s, e } : null }
const occCells = computed(() => {
@@ -1933,14 +2726,83 @@ const occCells = computed(() => {
let bookableH = 0; let hasGarde = false; let hasReg = false
for (const a of cells) { const t = tplByName.value[a.shift]; if (t && t.on_call) hasGarde = true; else { hasReg = true; bookableH += Number(a.hours) || 0 } }
const o = occByTechDay.value[techId + '|' + iso] || { h: 0, blocks: [], jobs: [] }
- m[techId + '|' + iso] = { bookableH, usedH: Math.round((o.h || 0) * 10) / 10, hasGarde, hasReg, pct: bookableH > 0 ? Math.round((o.h || 0) / bookableH * 100) : null, blocks: o.blocks || [], jobs: o.jobs || [] }
+ let usedH = o.h || 0; const jobs = o.jobs || []
+ // Fenêtre du quart (début/fin). Les jobs HORODATÉS (o.blocks) restent à leur heure = « lock ». Les jobs sans heure
+ // (ingérés du legacy) + legacy résiduel coulent EN ORDRE (route_order, o.jobs déjà trié) depuis la fin des horodatés,
+ // MIS À L'ÉCHELLE pour TENIR dans la fenêtre du quart → plus de débordement ; la SURCHARGE reste signalée par le %.
+ let winS = Infinity, winE = -Infinity
+ for (const a of cells) { const t = tplByName.value[a.shift]; if (t && !t.on_call) { const s = hToNum(t.start_time), e = hToNum(t.end_time); if (s != null) winS = Math.min(winS, s); if (e != null) winE = Math.max(winE, e <= s ? 24 : e) } }
+ if (!isFinite(winS)) { winS = 8; winE = 17 } else if (!(winE > winS)) winE = winS + 8
+ // VERROUILLÉ = RDV CONFIRMÉS + miroirs assistant (à leur heure réelle). Tout le reste COULE — même un job
+ // « à planifier » qui porte une heure incidente (héritée du legacy) — pour remplir le quart sans trou.
+ // (Cohérent avec la vue Jour/Kanban : avant, la grille verrouillait TOUT job ayant un start_time → trous + faux débordements.)
+ const dur1 = (j) => Number(j.dur) || (j.est_min ? j.est_min / 60 : 1)
+ const ordered = (jobs || []).slice().sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
+ const locked = []
+ for (const j of ordered) { if ((j.booking_status === 'Confirmé' || j.assist) && j.start_h != null) { const d = dur1(j); locked.push({ s: j.start_h, e: j.start_h + d, skill: j.skill, job: j.name, assist: !!j.assist, subject: j.subject, legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }) } }
+ const flow = [] // jobs « à planifier » (heure ignorée) + legacy résiduel, dans l'ordre de tournée
+ for (const j of ordered) { if ((j.booking_status === 'Confirmé' && j.start_h != null) || j.assist) continue; const d = dur1(j); if (d > 0) flow.push({ dur: d, skill: j.skill, job: j.name, subject: j.subject, legacy_id: j.legacy_id, legacy_dept: j.legacy_dept }) }
+ const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null
+ // Seuls les jobs TERRAIN (j.field) deviennent des blocs horaires + comptent dans la charge. L'admin/bureau (officeN) est listé en pastille, pas en bloc → pas de fausse surcharge.
+ if (lg) { for (const j of (lg.jobs || [])) { if (!j.field) continue; const d = Number(j.est_h) || 0; if (d > 0) flow.push({ dur: d, legacy: true, subject: j.subject, dept: j.dept, skill: j.skill, lid: j.id }) }; usedH += (lg.h || 0) }
+ const scale = 1 // PAS de compression : les blocs gardent leur taille réelle et on ALLONGE la zone (shiftE) pour les couvrir ; surcharge = triangle
+ const synth = []
+ let cursor = winS // les jobs souples coulent depuis le DÉBUT du quart et remplissent les trous AUTOUR des RDV confirmés
+ for (const f of flow) {
+ const w = f.dur * scale
+ let guard = 0; while (guard++ < 80) { const c = locked.find(b => cursor < b.e - 0.001 && (cursor + w) > b.s + 0.001); if (c) cursor = c.e; else break } // saute par-dessus un RDV qu'il chevaucherait
+ synth.push({ s: cursor, e: cursor + w, skill: f.skill, legacy: f.legacy, subject: f.subject, dept: f.dept, lid: f.lid, job: f.job, legacy_id: f.legacy_id, legacy_dept: f.legacy_dept }); cursor += w
+ }
+ const blocks = [...locked, ...synth]
+ usedH = Math.round(usedH * 10) / 10
+ // Zone de quart ÉTENDUE (blanche) pour COUVRIR les blocs qui débordent ; surcharge = blocs hors quart OU usedH > dispo.
+ const _maxE = blocks.length ? Math.max(...blocks.map(b => b.e)) : winE
+ const _minS = blocks.length ? Math.min(...blocks.map(b => b.s)) : winS
+ const shiftS = Math.min(winS, _minS); const shiftE = Math.max(winE, _maxE)
+ const over = (_maxE > winE + 0.01) || (_minS < winS - 0.01) || (bookableH > 0 && usedH > bookableH + 0.01)
+ m[techId + '|' + iso] = { bookableH, usedH, hasGarde, hasReg, pct: bookableH > 0 ? Math.round(usedH / bookableH * 100) : null, blocks, jobs, legacyH: lg ? lg.h : 0, officeN: lg ? (lg.officeN || 0) : 0, shiftS, shiftE, winS, winE, over }
+ }
+ // Tickets assignés dans F à des techs SANS quart planifié ce jour → on les affiche quand même (sinon invisibles).
+ if (showLegacyLoad.value) {
+ for (const key in legacyLoad.value) {
+ if (m[key]) continue // déjà couvert par un quart
+ const lg = legacyLoad.value[key]; if (!lg || (!(lg.h > 0) && !(lg.officeN > 0))) continue
+ const winS = 8, winE = 17; const synth = []; let cursor = winS
+ for (const j of (lg.jobs || [])) { if (!j.field) continue; const d = Number(j.est_h) || 0; if (d <= 0) continue; synth.push({ s: cursor, e: cursor + d, skill: j.skill, legacy: true, subject: j.subject, dept: j.dept, lid: j.id }); cursor += d }
+ const maxE = synth.length ? Math.max(...synth.map(b => b.e)) : winE
+ // noShift (amber) seulement s'il y a du TERRAIN sans quart (autoritaire pour dispatcher). Office-only → pas d'amber (aucun quart requis), juste la pastille admin.
+ m[key] = { bookableH: 0, usedH: Math.round((lg.h || 0) * 10) / 10, hasGarde: false, hasReg: false, pct: null, blocks: synth, jobs: [], legacyH: lg.h || 0, noShift: synth.length > 0, officeN: lg.officeN || 0, shiftS: winS, shiftE: Math.max(winE, maxE), winS, winE, over: false }
+ }
}
return m
})
function cellOcc (techId, iso) { return occCells.value[techId + '|' + iso] || null }
function hasReg (techId, iso) { return cellsOf(techId, iso).some(a => { const t = tplByName.value[a.shift]; return t && !t.on_call }) } // a au moins un shift régulier (garde exclue)
function cellBlocks (techId, iso) { const o = cellOcc(techId, iso); return o ? o.blocks : [] }
+function cellOfficeN (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.officeN || 0) : 0 } // nb de tickets admin/bureau (non-terrain) assignés à ce tech ce jour
+function officeJobs (techId, iso) { const lg = showLegacyLoad.value ? legacyLoad.value[techId + '|' + iso] : null; return lg ? (lg.jobs || []).filter(j => !j.field) : [] } // détail des tickets admin (pour la pastille)
+function cellGardeBands (techId, iso) { return cellBands(techId, iso).filter(b => b.oncall) } // garde seulement (la bande blanche du quart régulier vient de occCells.shiftS/E étendu)
function cellPct (techId, iso) { const o = cellOcc(techId, iso); return o ? o.pct : null }
+// Explique PRÉCISÉMENT pourquoi le triangle de surcharge apparaît (heures vs quart + débordements) — pour le tooltip.
+function overWhy (techId, iso) {
+ const o = cellOcc(techId, iso); if (!o || !o.over) return ''
+ const p = []
+ if (o.bookableH > 0 && o.usedH > o.bookableH + 0.01) p.push(`${o.usedH} h de travail prévu pour ${o.bookableH} h de quart${o.pct != null ? ' (' + o.pct + '%)' : ''}`)
+ if (o.winE != null && o.shiftE > o.winE + 0.01) p.push(`des jobs finissent après le quart (${fmtH(o.winE)} → ${fmtH(o.shiftE)})`)
+ if (o.winS != null && o.shiftS < o.winS - 0.01) p.push(`des jobs commencent avant le quart (dès ${fmtH(o.shiftS)})`)
+ if (!o.bookableH) p.push('jobs assignés sans quart régulier publié')
+ return p.length ? p.join(' · ') : 'jobs hors du quart prévu'
+}
+// Charge AGRÉGÉE par tech sur les JOURNÉES VISIBLES (dayList) : Σ occupé / Σ offrable → % d'occupation de la semaine affichée.
+const techLoads = computed(() => {
+ const out = {}
+ for (const t of visibleTechs.value) {
+ let used = 0, book = 0, leg = 0
+ for (const d of dayList.value) { const o = cellOcc(t.id, d.iso); if (o) { used += o.usedH || 0; book += o.bookableH || 0; leg += o.legacyH || 0 } }
+ out[t.id] = { used: Math.round(used * 10) / 10, book: Math.round(book * 10) / 10, leg: Math.round(leg * 10) / 10, pct: book > 0 ? Math.round(used / book * 100) : null }
+ }
+ return out
+})
function cellJobs (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.jobs || []) : [] } // jobs du jour, déjà triés priorité→heure côté hub
function rawCellJobs (techId, iso) { const o = occByTechDay.value[techId + '|' + iso]; return o ? (o.jobs || []) : [] } // jobs BRUTS (inclut les jours SANS quart publié)
function offShiftJobs (techId, iso) { return (hasReg(techId, iso) || onGarde(techId, iso)) ? [] : rawCellJobs(techId, iso) } // jobs assignés un jour où le tech n'a AUCUN quart publié
@@ -1987,8 +2849,14 @@ function guard (fn) { if (dirty.value && !window.confirm(DIRTY_MSG)) return; fn(
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
-function navDay (dir) { guard(() => { start.value = addDaysISO(start.value, dir); loadWeek() }) } // décale la fenêtre d'UN jour (flèche simple)
-function navToday () { guard(() => { start.value = thisMonday(); loadWeek() }) }
+function navDay (dir) { guard(() => {
+ if (boardView.value === 'kanban') { // mode Jour : on déplace le JOUR sélectionné (pas la fenêtre)
+ const cur = (kanbanDay.value && kanbanDay.value.iso) || nowET.value.iso
+ const next = addDaysISO(cur, dir); kbSelIso.value = next
+ if (!dayList.value.find(d => d.iso === next)) { start.value = next; loadWeek() } // hors fenêtre → recentre
+ } else { start.value = addDaysISO(start.value, dir); loadWeek() }
+}) }
+function navToday () { guard(() => { kbSelIso.value = nowET.value.iso; start.value = dayMode.value ? todayISO() : thisMonday(); loadWeek() }) } // mode Jour → aujourd'hui ; sinon lundi courant
// chargement
async function loadBase () { const tr = await roster.listTechnicians(); techs.value = tr.technicians || []; const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
@@ -2045,6 +2913,7 @@ async function loadStats () {
try { const s = await roster.getStats(start.value, days.value); dailyStats.value = s.stats || [] } catch (e) { /* non bloquant */ }
try { const o = await roster.getOccupancy(start.value, days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ }
try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) { /* non bloquant */ }
+ loadLegacyWindow() // charge legacy datée de la fenêtre (durées estimées appliquées aux timelines) — non bloquant
}
// Refresh CIBLÉ + rapide de l'occupation (recharge les blocs/jobs des cellules → timeline à jour) après une assignation,
// sans le loadWeek complet (assignations + couverture + stats), bien plus lent.
@@ -2231,7 +3100,8 @@ function applyTemplate (tm) {
}
// édition + sélection
-const menu = reactive({ show: false, target: null, tech: null, day: null })
+const menu = reactive({ show: false, target: null, tech: null, day: null, x: 0, y: 0 })
+const menuAnchorEl = ref(null) // ancre 1px positionnée au CURSEUR → le menu s'ouvre là où on clique
const menuRange = ref({ min: 8, max: 16 }); const quickEntry = ref('')
function rect (sti, sdi, eti, edi) {
const t0 = Math.min(sti, eti), t1 = Math.max(sti, eti), d0 = Math.min(sdi, edi), d1 = Math.max(sdi, edi)
@@ -2248,7 +3118,8 @@ function removeShift (techId, iso, shift) { assignments.value = assignments.valu
function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)) }
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
function openShiftMenu (t, d, ev, ti, di) {
- selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d; menu.target = ev.currentTarget
+ selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d
+ if (ev && ev.clientX != null) { menu.x = ev.clientX; menu.y = ev.clientY; menu.target = menuAnchorEl.value } else { menu.target = ev && ev.currentTarget } // ancre au curseur si dispo
const wr = winOf(t.id, d.iso, false); quickEntry.value = ''
menuRange.value = wr ? { min: wr.s, max: wr.e } : { min: 8, max: 16 }
menu.show = true
@@ -2385,8 +3256,8 @@ function onLegacyUpdate (data) {
}
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
-onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || [] } catch (e) { /* catégories de job pour suggestions */ } openAssignPanel(); /* panneau « Jobs à assigner » ouvert par défaut (fermable) */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ })
-onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload) })
+onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); loadDispatchPolicy(); /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || [] } catch (e) { /* catégories de job pour suggestions */ } openAssignPanel(); /* panneau « Jobs à assigner » ouvert par défaut (fermable) */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
+onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); if (_kbRO) _kbRO.disconnect() })
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
@@ -2414,6 +3285,19 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.cap-low { background: #ffe0b2; color: #e65100; }
.cap-full { background: #ffcdd2; color: #b71c1c; }
.cap-none { background: #eceff1; color: #b0bec5; }
+.load-cell { min-width: 38px; display: inline-block; } /* pastille « est/dispo » au pied de tableau */
+/* Liste des jobs prévus (non assignés) en en-tête de jour — glissables sur une case tech */
+.dayjobs-hdr { width: 234px; max-width: 234px; margin: 3px auto 0; max-height: 240px; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; gap: 2px; text-align: left; }
+.dayjob { width: 100%; box-sizing: border-box; font-size: 10px; line-height: 1.2; background: #fff; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 5px; cursor: grab; overflow: hidden; display: flex; align-items: center; gap: 4px; box-shadow: 0 1px 1px rgba(0,0,0,.05); }
+.dayjob:hover { border-color: #90a4ae; background: #f5f7fa; }
+.dayjob.dragging { opacity: .4; }
+.dayjob.hold { opacity: .6; font-style: italic; }
+.dj-est { font-weight: 700; color: #00695c; flex: 0 0 auto; font-size: 9px; }
+.dj-sub { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* min-width:0 = indispensable pour l'ellipsis dans un flex */
+.dayjobs-empty { font-size: 9px; color: #cfd8dc; text-align: center; padding: 2px 0; }
+/* bloc LEGACY sur la timeline : coloré par type (comme les jobs importés) + anneau blanc pointillé pour le distinguer */
+.tl-blk-legacy { box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.9); } /* legacy = anneau blanc inset */
+.ovl-warn { position: absolute; top: 0; right: 1px; z-index: 3; pointer-events: auto; filter: drop-shadow(0 0 1.5px #fff) drop-shadow(0 0 1px #fff); } /* triangle orange = surcharge ; halo blanc → ressort sur n'importe quel fond */
.cap-warn { margin-left: 1px; }
.de-actual { font-size: 11px; font-weight: 700; color: #00695c; background: #e0f2f1; border-radius: 4px; padding: 2px 5px; white-space: nowrap; }
.chrono-run { animation: chronopulse 1.4s ease-in-out infinite; }
@@ -2464,7 +3348,8 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.drop-badge.over { background: #e53935; } /* projection ≥ 100% = surbooké */
.grid-wrap { overflow-x: auto; border: 1px solid #e0e0e0; border-radius: 6px; max-width: 100%; }
.roster-grid { border-collapse: collapse; font-size: 12px; width: 100%; user-select: none; -webkit-user-select: none; }
-.roster-grid th, .roster-grid td { border: 1px solid #eee; text-align: center; padding: 2px; }
+.roster-grid th, .roster-grid td { border: 1px solid #c7d0dc; text-align: center; padding: 2px; } /* séparateurs plus contrastés (était #eee, invisible sur le gris des cellules) */
+.roster-grid th + th, .roster-grid td + td { border-left-color: #aab6c6; } /* séparateur de COLONNE (jour) plus marqué que les lignes — en-tête ET rangées */
.roster-grid thead th { position: sticky; top: 0; background: #fafafa; z-index: 1; }
.tech-col { position: sticky; left: 0; background: #fff; text-align: left !important; white-space: nowrap; padding: 2px 8px !important; min-width: 240px; max-width: 300px; z-index: 2; }
.roster-grid thead .tech-col { z-index: 3; }
@@ -2486,13 +3371,27 @@ th.clk, td.clk { cursor: pointer; }
.skill-chip { font-size: 10px; line-height: 15px; height: 15px; padding: 0 5px; border-radius: 8px; color: #fff; font-weight: 600; white-space: nowrap; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
.chip-lvl { display: inline-flex; align-items: center; justify-content: center; min-width: 13px; height: 13px; padding: 0 1px; border-radius: 50%; background: rgba(0,0,0,.32); font-size: 8px; font-weight: 800; box-shadow: 0 0 0 1.5px #fff; margin-left: 1px; } /* niveau 1–5 · contour blanc pour détacher la couleur (vitesse) */
.add-skill-hint { font-size: 10px; color: #9e9e9e; font-style: italic; } /* invite quand aucune compétence */
+/* Compétence compacte = cercle de la couleur du skill + chiffre du niveau ; survol → nom complet + niveau + vitesse */
+/* Compétence : cercle de couleur avec ICÔNE vectorielle (Material, offline) ; devient un rectangle arrondi (pill) icône+chiffre si un niveau est défini */
+.skill-dot { display: inline-flex; align-items: center; justify-content: center; gap: 1px; min-width: 16px; height: 16px; padding: 0 1px; border-radius: 50%; color: #fff; flex-shrink: 0; cursor: pointer; box-shadow: inset 0 0 0 1px rgba(0,0,0,.16); }
+.skill-dot.has-lvl { border-radius: 8px; padding: 0 4px 0 2px; }
+.skill-dot .q-icon { color: #fff; }
+.sd-lvl { font-size: 9px; font-weight: 800; line-height: 1; color: #fff; text-shadow: 0 1px 1px rgba(0,0,0,.4); }
+/* Barre de charge AGRÉGÉE par tech sur les journées visibles (occupé / offrable) */
+.tech-load { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px; flex-shrink: 0; cursor: default; }
+.tech-load-bar { width: 44px; height: 6px; border-radius: 3px; background: #e3e7ea; overflow: hidden; display: inline-block; }
+.tech-load-fill { display: block; height: 100%; border-radius: 3px; }
+.tech-load-pct { font-size: 9px; font-weight: 700; color: #607d8b; }
+.tech-load-pct.over { color: #c62828; }
.hide-eye { flex-shrink: 0; opacity: .45; } .tech-row:hover .hide-eye { opacity: 1; } /* œil masquer (discret, visible au survol) */
tr.res-hidden { opacity: .5; background: repeating-linear-gradient(45deg, #fafafa, #fafafa 6px, #f0f0f0 6px, #f0f0f0 12px); } /* ressource masquée affichée en grisé */
tr.res-hidden .hide-eye { opacity: 1; }
.tech-name.clk:hover { text-decoration: underline; }
.hol-toggle { font-size: 9px; color: #ccc; cursor: pointer; border: 1px solid #eee; border-radius: 3px; width: 14px; margin: 1px auto 0; line-height: 12px; }
.hol-toggle.on { background: #ff9800; color: #fff; border-color: #ff9800; }
-.cell { cursor: pointer; min-height: 24px; position: relative; }
+.cell { cursor: pointer; min-height: 24px; position: relative; background: #eef1f4; } /* gris pâle = INDISPONIBLE par défaut ; un quart crée une zone blanche (.tl-shift) */
+.cell.over { background: #fbe2cf; } /* SURCHARGE : la cellule entière se teinte orange pâle (le triangle reste dans le coin) */
+.cell.noshift { background: #fff3e0; box-shadow: inset 0 0 0 1.5px #ffb74d; } /* WARNING : ticket F assigné mais AUCUN quart planifié ce jour → cellule ambre */
.cell:hover { outline: 2px solid #1976d2; outline-offset: -2px; }
.cell.sel { outline: 2px solid #00897b; outline-offset: -2px; background: #e0f2f1; }
.cell.dirty { box-shadow: inset 0 0 0 2px #ff9800; }
@@ -2502,10 +3401,77 @@ tr.res-hidden .hide-eye { opacity: 1; }
.ch-h { opacity: .7; font-weight: 400; font-size: 9px; margin-left: 1px; }
.free { color: #ccc; }
.offshift-warn { display: inline-flex; align-items: center; gap: 1px; font-size: 10px; font-weight: 700; color: #ef6c00; cursor: pointer; line-height: 1; } /* job assigné un jour sans quart publié */
+.office-dots { position: absolute; right: 2px; top: 2px; bottom: 2px; z-index: 2; display: flex; flex-direction: column; flex-wrap: wrap; align-content: flex-end; gap: 1.5px; cursor: pointer; } /* tickets SANS déplacement (admin/support/NP) = points ronds empilés (≈3-4 de haut puis nouvelle colonne vers la gauche), colorés par dept ; clic = liste cliquable, survol = aperçu ; n'occupent pas la charge */
+.office-dot { width: 5px; height: 5px; min-width: 5px; border-radius: 50%; opacity: .9; display: inline-block; box-shadow: 0 0 0 .5px rgba(255,255,255,.5); }
.hdr-ruler { position: relative; height: 11px; margin-top: 3px; }
.hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; }
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }
-.tl { position: relative; height: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; cursor: pointer; } /* fond cliquable → ajouter/modifier un quart */
+.tl { position: relative; height: 24px; min-width: 110px; background: transparent; border-radius: 4px; margin: 2px 0; overflow: hidden; cursor: pointer; } /* track TRANSPARENT : toute la cellule est blanche (pas d'élément blanc séparé) */
+/* ── Mode JOUR (style Gaiia) : 1 colonne pleine largeur → timelines hautes + règle d'heures alignée → dispo des jobs facile ── */
+.roster-grid.day-mode .tl { height: 30px; }
+.roster-grid.day-mode .cell { min-height: 34px; }
+.grid-axis { position: relative; width: 100%; height: 13px; margin: 2px 0 1px; border-bottom: 1px dashed #e3e7ee; }
+.grid-axis-tick { position: absolute; top: 0; transform: translateX(-50%); font-size: 9px; line-height: 13px; color: #90a4ae; font-weight: 700; white-space: nowrap; }
+.grid-axis-tick::after { content: 'h'; font-weight: 400; opacity: .65; }
+/* ── Vue BOARD (Kanban horizontal) : pool vertical (recherche/tri) + techs en lanes horizontales à échelle d'heures ── */
+.kbb-wrap { display: flex; gap: 10px; align-items: flex-start; padding: 2px 2px 12px; }
+.kbb-pool { flex: 0 0 268px; width: 268px; background: #eef2f7; border: 1px solid #d7deea; border-radius: 8px; display: flex; flex-direction: column; max-height: calc(100vh - 205px); }
+.kbb-pool.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
+.kbb-pool-hd { display: flex; align-items: center; font-size: 12px; font-weight: 700; color: #2b3445; padding: 7px 9px 4px; }
+.kbb-pool-tools { display: flex; flex-direction: column; gap: 5px; padding: 0 7px 7px; border-bottom: 1px solid #e0e6ef; }
+.kbb-pool-body { flex: 1 1 auto; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 5px; }
+.kbb-board { flex: 1 1 auto; min-width: 0; }
+.kbb-daybar { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #455; padding: 2px 4px 6px; flex-wrap: wrap; }
+.kbb-scroll { overflow-x: auto; padding-bottom: 8px; }
+.kbb-axis-row, .kbb-row { display: flex; align-items: stretch; width: max-content; min-width: 100%; }
+.kbb-name-sp, .kbb-name { flex: 0 0 150px; width: 150px; box-sizing: border-box; position: sticky; left: 0; z-index: 5; background: #fff; }
+.kbb-name { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; color: #2b3445; padding: 0 4px 0 6px; border-right: 1px solid #e3e7ee; }
+.kbb-name .ellipsis { flex: 1 1 auto; min-width: 0; }
+.kbb-axis { position: relative; flex: 0 0 auto; height: 16px; border-bottom: 1px dashed #e3e7ee; }
+.kbb-row { border-bottom: 1px solid #e2e6ed; } /* hairline très mince, centrée entre 2 lanes (marges symétriques) */
+.kbb-lane { position: relative; flex: 0 0 auto; height: 60px; margin: 2px 0; background: #fff; border-radius: 4px; overflow: hidden; }
+.kbb-lane.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
+.kbb-lane-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 10.5px; color: #aab2bd; pointer-events: none; }
+/* trajet d'approche (km/min) avant le job : zone pâle hachurée, plus courte que le bloc */
+.kbb-leg { position: absolute; top: 15px; bottom: 15px; background: repeating-linear-gradient(45deg, rgba(120,144,176,.10) 0 4px, rgba(120,144,176,.18) 4px 8px); border: 1px dashed rgba(120,144,176,.5); border-radius: 3px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
+.kbb-leg-t { font-size: 9px; font-weight: 700; color: #5b6b80; white-space: nowrap; }
+.kbb-blk { position: absolute; top: 7px; bottom: 7px; border-radius: 5px; background: #fff; color: #1c2533; border: 1.5px solid #cfd6e0; border-left-width: 4px; display: flex; flex-direction: column; justify-content: center; gap: 1px; padding: 2px 7px; cursor: grab; box-shadow: 0 1px 3px rgba(0,0,0,.12); overflow: hidden; } /* carte BLANCHE, contour + icône colorés (skill), texte noir — style Gaiia */
+.kbb-blk:hover { box-shadow: 0 2px 8px rgba(0,0,0,.22); z-index: 2; }
+.kbb-blk.assist { border-style: dashed; background: #faf8ff; cursor: default; }
+.kbb-blk-l1 { display: flex; align-items: center; gap: 3px; font-size: 11px; font-weight: 700; line-height: 1.2; }
+.kbb-blk-l2 { font-size: 9.5px; font-weight: 500; color: #6a7686; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.kbb-blk-t { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+/* Volet détails (double-clic) : grand panneau droit */
+.jd-card { width: min(900px, 84vw); max-width: 94vw; height: 100vh; display: flex; flex-direction: column; }
+.jd-body { flex: 1 1 auto; overflow: auto; }
+.jd-meta { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #455; }
+.jd-meta > div { display: flex; align-items: center; gap: 6px; }
+.jd-detail { margin-top: 10px; padding: 8px 10px; background: #f6f8fb; border-radius: 6px; white-space: pre-wrap; font-size: 12.5px; color: #333; }
+.jd-team { margin-top: 12px; padding: 10px; border: 1px solid #e6e2f2; background: #faf9fe; border-radius: 8px; }
+.jd-map { width: 100%; height: 240px; border-radius: 8px; overflow: hidden; margin-bottom: 8px; background: #eceff3; }
+.jd-track-row { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; }
+.dt-toggle { cursor: pointer; margin-left: 8px; padding: 0 7px; border-radius: 10px; border: 1px solid #ef6c00; color: #ef6c00; font-weight: 600; white-space: nowrap; }
+.dt-toggle.on { background: #ef6c00; color: #fff; }
+.kbb-rows { position: relative; width: max-content; min-width: 100%; }
+.kbb-now-full { position: absolute; top: 0; bottom: 0; width: 2px; background: #e53935; z-index: 4; pointer-events: none; box-shadow: 0 0 3px rgba(229,57,53,.5); }
+.kbb-shift { position: absolute; top: 2px; bottom: 2px; border: 1px solid #dfe4ea; border-radius: 4px; background: #fff; pointer-events: none; } /* quart = zone BLANCHE (lumière = dispo) sur le fond gris */
+.kbb-shift.oncall { border-style: dotted; border-color: #f9a825; background: rgba(249,168,37,.06); }
+/* garde = shift SUR APPEL : bande jaune pâle + téléphone rouge */
+.kbb-garde { position: absolute; top: 7px; bottom: 7px; background: rgba(255,213,79,.32); border: 1px solid rgba(245,166,35,.6); border-radius: 5px; display: flex; align-items: center; padding: 0 5px; overflow: hidden; }
+.kbb-now-lbl { position: absolute; top: 0; transform: translateX(-50%); background: #e53935; color: #fff; font-size: 9px; font-weight: 800; line-height: 13px; padding: 0 4px; border-radius: 3px; white-space: nowrap; z-index: 6; pointer-events: none; }
+/* Cartes du pool (réutilisées) */
+.kb-card { background: #fff; border: 1px solid #e6e9ef; border-left: 4px solid #90a4ae; border-radius: 6px; padding: 5px 7px; cursor: grab; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
+.kb-card:hover { box-shadow: 0 2px 6px rgba(0,0,0,.13); }
+.kb-card:active { cursor: grabbing; }
+.kb-card.dragging { opacity: .4; }
+.kb-card.hold { opacity: .62; cursor: not-allowed; background: repeating-linear-gradient(45deg, #fafafa 0 6px, #f0f0f0 6px 12px); }
+.kb-card-t { font-size: 12px; font-weight: 600; color: #222; line-height: 1.25; display: flex; align-items: center; }
+.kb-card-m { font-size: 10.5px; color: #67707e; display: flex; align-items: center; gap: 4px; margin-top: 2px; }
+.kb-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; }
+.kb-count { margin-left: auto; background: #d7deea; color: #3a4658; border-radius: 9px; padding: 0 7px; font-size: 11px; }
+.kb-load { font-size: 11px; font-weight: 700; color: #2e7d32; }
+.kb-load.over { color: #e53935; }
+.kb-empty { font-size: 11px; color: #9aa3b0; text-align: center; padding: 10px 4px; border: 1px dashed #d7deea; border-radius: 6px; }
.tl-click { cursor: pointer; } /* clic sur le progressbar → menu jobs (détail + réordonner) */
.tl-click:hover { outline: 1px solid #1976d2; outline-offset: 1px; }
/* Éditeur de journée (clic progressbar) — lignes draggables */
@@ -2522,6 +3488,7 @@ tr.res-hidden .hide-eye { opacity: 1; }
.de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }
.de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; }
.de-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */
+.de-depart { color: #3949ab; font-weight: 600; opacity: 1; } /* trajet origine (dépôt/domicile) → 1er job */
.de-detail { font-size: 11px; line-height: 1.4; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 280px; overflow: auto; }
.de-msg { border-top: 1px solid #e6e0f0; padding: 4px 0; }
.de-msg:first-of-type { border-top: none; }
@@ -2531,10 +3498,11 @@ tr.res-hidden .hide-eye { opacity: 1; }
.assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; }
.assign-msg:first-child { border-top: none; }
.assign-msg-txt { white-space: pre-wrap; color: #37474f; }
-.tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 2px; border: 1px solid rgba(55,65,120,.5); box-sizing: border-box; } /* fenêtre dispo (contour foncé pour la distinguer du fond) */
-.tl-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */
+.tl-shift { position: absolute; top: 0; bottom: 0; background: #fff; border-radius: 4px; border: 1px solid #dfe4ea; box-sizing: border-box; } /* quart = zone BLANCHE (lumière = dispo) sur le fond gris de la cellule */
+.tl-shift.oncall { background: rgba(255,213,79,.30); border: 1px solid rgba(245,166,35,.55); } /* garde = sur appel (jaune pâle, harmonisé Kanban) */
+.tl-shift.over { background: #fff1e0; border-color: #ffc183; } /* zone de quart en surcharge = teinte orange (au lieu du blanc) */
.tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */
-.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; } /* occupé = barre de statut opaque */
+.tl-blk { position: absolute; top: 2px; bottom: 2px; border-radius: 4px; border: 1px solid; display: flex; align-items: center; justify-content: center; overflow: hidden; } /* fond PASTEL + contour couleur (via inline) + icône couleur centrée si elle rentre */
.tl-travel { position: absolute; top: 0; bottom: 0; background-image: repeating-linear-gradient(90deg, #78909c 0 3px, transparent 3px 7px); background-size: 100% 3px; background-repeat: no-repeat; background-position: 0 center; opacity: .85; } /* déplacement = pointillés */
.tl-shift-click { cursor: pointer; } /* bande de quart/garde → clic ouvre la tournée (voir les jobs) */
.tl-shift-click:hover { filter: brightness(0.92); outline: 1px solid rgba(55,71,79,.4); outline-offset: -1px; }
diff --git a/apps/ops/src/pages/RapportsPage.vue b/apps/ops/src/pages/RapportsPage.vue
index df99212..237488e 100644
--- a/apps/ops/src/pages/RapportsPage.vue
+++ b/apps/ops/src/pages/RapportsPage.vue
@@ -70,6 +70,20 @@ const financeReports = [
color: 'deep-orange-6',
route: '/rapports/internet-cher',
},
+ {
+ title: 'Factures récurrentes négatives',
+ description: 'Comptes dont le total récurrent mensuel est négatif (rabais > frais) — anomalies de données à corriger. F reste autoritaire.',
+ icon: 'error',
+ color: 'red-7',
+ route: '/rapports/factures-negatives',
+ },
+ {
+ title: 'Services actifs sur comptes résiliés',
+ description: 'Comptes terminés dans F qui gardent des services récurrents actifs (charges + crédits). Liste de ménage — désactiver au niveau service (sauf employés à internet fourni).',
+ icon: 'cleaning_services',
+ color: 'blue-7',
+ route: '/rapports/resilies-actifs',
+ },
]
const opsReports = [
diff --git a/apps/ops/src/pages/ReportNegativeBillingPage.vue b/apps/ops/src/pages/ReportNegativeBillingPage.vue
new file mode 100644
index 0000000..813b6b2
--- /dev/null
+++ b/apps/ops/src/pages/ReportNegativeBillingPage.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
Factures récurrentes négatives
+
+
+
+
+
+
+
+ Un abonnement récurrent ne peut pas être négatif. La facturation F (gestionclient) est autoritaire et correcte :
+ ces écarts sont des anomalies de données côté ERPNext/OPS (rabais ajoutés manuellement ou artefacts d'import) à corriger.
+
+
+
+
+
Calcul en cours — scan des abonnements actifs…
+
+
+ {{ error }}
+
+
+
+
+
+
Comptes négatifs
+
{{ rows.length }}
+
+
+
+
+
Abonnements scannés
+
{{ scanned.toLocaleString('fr-CA') }}
+
+
+
+
+ {{ rows.length }} compte{{ rows.length > 1 ? 's' : '' }} avec total récurrent négatif
+ · généré {{ fmtTime(generated) }}
+
+
+
+
+
+
+
+ {{ props.row.customer_name }}
+
+
+
+ {{ props.row.service_location }}
+ Frais{{ money(props.row.charges) }}
+ Rabais{{ money(props.row.rebate) }}
+ Total adresse{{ money(props.row.total) }}
+
+
+
+
+
+
+ {{ props.row.customer_name }}
+
+ {{ props.row.customer }} · {{ props.row.service_location }}
+
+
+
+
+
+ Statut du compte GLOBAL dans F (legacy) — l'autorité. Résilié = compte terminé (rabais résiduel = ménage).
+
+
+
+
+ {{ money(props.row.total) }}
+
+
+ {{ money(props.row.customer_total) }}
+
+
+
+ Rabais et frais sur des adresses différentes — le compte n'est pas négatif.
+
+
+ Une charge active dans F est Suspendue/Annulée dans ERPNext → le rabais reste seul. Désync de statut.
+
+ {{ c.status }} : {{ c.plan }} {{ money(c.price) }} · F #{{ c.legacy }}
+
+
+
+
+
+
+
+
+
+ {{ money(r.price) }} · {{ r.plan || r.sku }}
+
+ · fin {{ r.end_date }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/ops/src/pages/ReportTerminatedActivePage.vue b/apps/ops/src/pages/ReportTerminatedActivePage.vue
new file mode 100644
index 0000000..d4b65a7
--- /dev/null
+++ b/apps/ops/src/pages/ReportTerminatedActivePage.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
Services actifs sur comptes résiliés
+
+
+
+
+
+
+
+ Comptes résiliés dans F (status 3/4/5) qui gardent des services récurrents actifs (charges et crédits) —
+ F ne les désactive pas à la résiliation. Liste de ménage : désactiver les services au besoin. ⚠️ Les employés (internet fourni) sont des exceptions à conserver.
+
+
+
+
Calcul en cours — comptes résiliés × services actifs…
+
+ {{ error }}
+
+
+
+
+
+
Comptes résiliés
+
{{ count }}
+
+
+
+
+
Services actifs
+
{{ services }}
+
+
+
+
+ {{ count }} compte{{ count > 1 ? 's' : '' }} résilié{{ count > 1 ? 's' : '' }} avec services actifs
+ · généré {{ fmtTime(generated) }}
+
+
+
+
+
+
+
+ {{ props.row.customer_name }}
+
+
+
+ compte F #{{ props.row.account_id }} · {{ props.row.active_services }} service(s)
+ Frais{{ money(props.row.charges) }}
+ Crédits{{ money(props.row.credits) }}
+ Total mensuel{{ money(props.row.total) }}
+
+
+
+
+
+ {{ props.row.customer_name }}
+
+ {{ props.row.customer }} · compte F #{{ props.row.account_id }}
+
+
+
+ {{ money(props.row.total) }}
+
+
+
+
+ {{ money(s.price) }} · {{ s.plan || s.sku }}
+ · F #{{ s.legacy }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/ops/src/pages/SettingsPage.vue b/apps/ops/src/pages/SettingsPage.vue
index 69a872a..42e257f 100644
--- a/apps/ops/src/pages/SettingsPage.vue
+++ b/apps/ops/src/pages/SettingsPage.vue
@@ -380,15 +380,14 @@
Chargement...