Compare commits

..

No commits in common. "80c8005f0cb7d4fabaa9a209955567923a2304b3" and "c267f9a3ddd8a0c9175f32a4456d8965bc416b2c" have entirely different histories.

32 changed files with 2339 additions and 1042 deletions

View File

@ -1,58 +1,17 @@
<template> <template>
<router-view /> <router-view />
<!-- « FAIL-LOUD » : les permissions OPS n'ont pas chargé (mauvais compte / identité Authentik non transmise).
On affiche un message CLAIR avec le compte réellement reçu, au lieu d'une coquille vide (menu blanc) qu'on
prenait à tort pour un bug de token/session. Inerte en dev local (profil permissif). -->
<div v-if="showAccessError" class="ops-access-error">
<div class="ops-access-card">
<q-icon name="person_off" size="44px" color="negative" />
<div class="text-h6 q-mt-sm">Accès OPS indisponible</div>
<div class="text-body2 text-grey-7 q-mt-sm" style="max-width:400px;line-height:1.5">
<template v-if="authIdentity">
OPS n'a pas pu charger les permissions de <b>{{ authIdentity }}</b> (le compte y a pourtant droit).
<div class="q-mt-sm">Souvent une <b>extension</b> (bloqueur de pub/vie privée) ou un proxy qui bloque la requête
<code>/auth/permissions</code> : essaie une <b>fenêtre privée</b> ou un <b>autre navigateur</b>.</div>
</template>
<template v-else>
Authentik n'a pas transmis ton identité (session incomplète). Reconnecte-toi via
<b>erp.gigafibre.ca/ops</b>.
</template>
<div v-if="permsError" class="text-caption text-negative q-mt-sm" style="font-family:monospace">Détail : {{ permsError }}</div>
</div>
<div class="row q-gutter-sm q-mt-md justify-center">
<q-btn outline no-caps color="grey-8" icon="logout" label="Changer de compte" @click="auth.doLogout()" />
<q-btn unelevated no-caps color="primary" icon="refresh" label="Recharger" @click="reload" />
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { computed, onMounted } from 'vue' import { onMounted } from 'vue'
import { useAuthStore } from 'src/stores/auth' import { useAuthStore } from 'src/stores/auth'
import { usePermissions } from 'src/composables/usePermissions'
import { loadSavedTheme } from 'src/composables/useTheme' import { loadSavedTheme } from 'src/composables/useTheme'
import { startSessionKeepAlive } from 'src/composables/useSessionKeepAlive' import { startSessionKeepAlive } from 'src/composables/useSessionKeepAlive'
loadSavedTheme() // applique le thème enregistré (couleurs --ops-*) dès le démarrage loadSavedTheme() // applique le thème enregistré (couleurs --ops-*) dès le démarrage
const auth = useAuthStore() const auth = useAuthStore()
const { isLoaded, loading: permsLoading, error: permsError } = usePermissions()
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
// PROD uniquement : une fois le contrôle de session ET le chargement des permissions terminés, si aucune
// permission n'a chargé accès indisponible (mauvais compte / identité vide) overlay clair.
const showAccessError = computed(() => !isLocal && !auth.loading && !permsLoading.value && !isLoaded.value)
// Le compte réellement transmis à OPS (courriel whoami) clé du diagnostic : « mauvais compte ? ».
const authIdentity = computed(() => (auth.user && auth.user !== 'authenticated') ? auth.user : '')
function reload () { window.location.reload() }
onMounted(() => { onMounted(() => {
auth.checkSession() auth.checkSession()
startSessionKeepAlive() // garde la session Authentik vivante (ping /auth/whoami /4 min + au retour d'onglet) startSessionKeepAlive() // garde la session Authentik vivante plus besoin de recharger pour que les données/recherche reviennent
}) })
</script> </script>
<style>
.ops-access-error { position: fixed; inset: 0; z-index: 99999; display: flex; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.55); backdrop-filter: blur(2px); }
.ops-access-card { background: #fff; border-radius: 14px; padding: 28px 32px; text-align: center; box-shadow: 0 12px 40px rgba(0,0,0,0.25); display: flex; flex-direction: column; align-items: center; max-width: 92vw; }
</style>

View File

@ -4,13 +4,6 @@ import { BASE_URL } from 'src/config/erpnext'
// Only needed for local dev (VITE_ERP_TOKEN in .env). // Only needed for local dev (VITE_ERP_TOKEN in .env).
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || '' const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || ''
const isLocalHost = () => window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
// Nom d'utilisateur Authentik renvoyé par le dernier whoami — repli de recherche de permissions côté hub
// quand le courriel ne correspond à aucun compte OPS provisionné (voir stores/auth.js → loadPermissions).
let _authUsername = ''
export function getAuthUsername () { return _authUsername }
// Reconnexion auto sur session Authentik expirée, anti-boucle (1 reload / 20 s max). // Reconnexion auto sur session Authentik expirée, anti-boucle (1 reload / 20 s max).
let _lastReload = 0 let _lastReload = 0
function _reauth (status) { function _reauth (status) {
@ -60,19 +53,14 @@ export async function authFetch (url, opts = {}) {
} }
export async function getLoggedUser () { export async function getLoggedUser () {
// ⚠️ APERÇU LOCAL UNIQUEMENT : identité forcée via VITE_DEV_USER (gardé localhost + env). `import.meta.env.DEV` // ⚠️ APERÇU LOCAL UNIQUEMENT : identité forcée via VITE_DEV_USER (gardé localhost + env). Inerte en prod. À RETIRER avec le harnais.
// vaut false en build prod → esbuild élimine toute cette branche (et la valeur inlinée) du bundle livré. if ((window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && import.meta.env.VITE_DEV_USER) return import.meta.env.VITE_DEV_USER
if (import.meta.env.DEV && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && import.meta.env.VITE_DEV_USER) return import.meta.env.VITE_DEV_USER
// First try nginx whoami endpoint (returns Authentik email header) // First try nginx whoami endpoint (returns Authentik email header)
try { try {
const res = await fetch(BASE_URL + '/auth/whoami') const res = await fetch(BASE_URL + '/auth/whoami')
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
_authUsername = data.username || '' // mémorisé pour le repli par username (recherche de permissions)
if (data.email && data.email !== '') return data.email if (data.email && data.email !== '') return data.email
// whoami joignable mais courriel VIDE en prod = identité Authentik non transmise → renvoyer '' (pas de repli
// trompeur sur le propriétaire du jeton ERP « Administrator »). App.vue affiche alors un message clair « fail-loud ».
if (!isLocalHost()) return ''
} }
} catch {} } catch {}
// Fallback: ask ERPNext (returns API token owner, usually "Administrator") // Fallback: ask ERPNext (returns API token owner, usually "Administrator")
@ -88,9 +76,5 @@ export async function getLoggedUser () {
} }
export async function logout () { export async function logout () {
// Déconnexion Authentik PUIS retour sur OPS : le flow d'invalidation honore `?next=` → il renvoie vers OPS, window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/'
// qui (non authentifié) redéclenche le login Authentik AVEC chemin de retour → après login on revient sur OPS,
// au lieu d'atterrir sur la page par défaut d'Authentik (/if/user/#/library).
const back = window.location.origin + (import.meta.env.BASE_URL || '/') // ex. https://erp.gigafibre.ca/ops/
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/?next=' + encodeURIComponent(back)
} }

View File

@ -145,9 +145,6 @@ export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set
export const updateJob = (job, patch) => jpost('/roster/job/update', { job, patch }) export const updateJob = (job, patch) => jpost('/roster/job/update', { job, patch })
// Persister UN quart immédiatement (sinon addShift reste local/Proposé et disparaît au rechargement). Idempotent côté hub. // Persister UN quart immédiatement (sinon addShift reste local/Proposé et disparaît au rechargement). Idempotent côté hub.
export const createShift = (s) => jpost('/roster/shift', s) export const createShift = (s) => jpost('/roster/shift', s)
// Hybride : patron récurrent (source) + matérialisation (fériés/vacances sautés, manuels préservés)
export const setWeeklySchedule = (techId, schedule) => jpost('/roster/technician/' + encodeURIComponent(techId) + '/weekly-schedule', { schedule })
export const materializeShifts = (payload) => jpost('/roster/materialize-shifts', payload || {})
// ÉQUIPE d'un job (assistants en renfort) — le lead reste inchangé ; l'assistant épinglé voit un bloc hachuré dans son horaire. // ÉQUIPE d'un job (assistants en renfort) — le lead reste inchangé ; l'assistant épinglé voit un bloc hachuré dans son horaire.
export const getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job)) export const getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job))
export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a }) export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a })
@ -182,12 +179,6 @@ export const jobServiceStatus = (job) => jget('/roster/job-service-status?job='
export const onsiteCross = (days = 30) => jget('/traccar/onsite?days=' + days) export const onsiteCross = (days = 30) => jget('/traccar/onsite?days=' + days)
// Position GPS LIVE (dernière connue) d'un tech → { ok, lat, lon, time, speed } ; pour fixer son point de départ (domicile). // Position GPS LIVE (dernière connue) d'un tech → { ok, lat, lon, time, speed } ; pour fixer son point de départ (domicile).
export const techLivePosition = (tech) => jget('/traccar/live?tech=' + encodeURIComponent(tech)) export const techLivePosition = (tech) => jget('/traccar/live?tech=' + encodeURIComponent(tech))
// Positions GPS LIVE de TOUS les techs (appareil Traccar associé) → { positions:[{techId,techName,lat,lon,time,speed}] }.
export const techPositions = () => jget('/roster/tech-positions')
// Géofencing : timeline d'un job → { state, events:[{status,at,dist}] } (status: en_route|on_site|departed).
export const jobGeofence = (name) => jget('/roster/job/' + encodeURIComponent(name) + '/geofence')
// Géofencing : états live pour une liste de jobs → { states: { jobName: state } }.
export const geofenceStates = (names) => jget('/roster/geofence-states?jobs=' + encodeURIComponent((names || []).join(',')))
// Liste des appareils Traccar (pour associer un tech à son GPS) — { id, name, uniqueId, status, lastUpdate }. // Liste des appareils Traccar (pour associer un tech à son GPS) — { id, name, uniqueId, status, lastUpdate }.
export const listTraccarDevices = () => jget('/traccar/devices') export const listTraccarDevices = () => jget('/traccar/devices')
// Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif. // Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif.

View File

@ -1,69 +0,0 @@
<template>
<!-- Bande d'occupation réutilisable : 1 cellule par jour, barre verticale « chaleur » (vert rouge).
Extraite de la bande mobile Planif (.pm-strip). Utilisée : tableau de bord (charge de la semaine),
et partout un aperçu de charge par jour est utile. Clic @select(iso). -->
<div class="os-strip">
<button
v-for="d in days" :key="d.iso" type="button" class="os-day"
:class="{ active: d.iso === selected, today: d.today, weekend: d.weekend, holiday: d.holiday }"
:title="dayTitle(d)"
@click="$emit('select', d.iso)"
>
<span v-if="d.holiday" class="os-hol"></span>
<span v-if="d.noShift && !d.unassigned" class="os-warn" title="Aucun quart planifié — capacité estimée"></span>
<span v-if="d.unassigned" class="os-unassigned" :title="d.unassigned + ' job(s) dû(s) ce jour, non assigné(s) — à répartir'">{{ d.unassigned }}</span>
<span class="os-dow">{{ d.dow }}</span>
<span class="os-num">{{ d.dnum }}</span>
<span class="os-bar"><span class="os-fill" :style="{ height: fillH(d) + '%', background: heatColor(d.ratio) }"></span></span>
<span class="os-h">{{ d.cap > 0 ? Math.round(d.h) + '/' + Math.round(d.cap) : (Math.round(d.h) + 'h') }}<span v-if="d.over" class="os-over"></span></span>
</button>
</div>
</template>
<script setup>
import { heatColor } from 'src/composables/useHelpers'
defineProps({
days: { type: Array, default: () => [] }, // [{ iso, dow, dnum, h, cap, ratio, over, weekend, holiday, noShift, today }]
selected: { type: String, default: '' },
})
defineEmits(['select'])
// Barre : min 12 % quand il y a de la charge (visible), 0 si rien ; plafonnée à 100 %.
function fillH (d) { return d.h > 0 ? Math.max(12, Math.min(100, Math.round((d.ratio || 0) * 100))) : 0 }
function dayTitle (d) {
const parts = [Math.round(d.h) + 'h à faire']
if (d.cap > 0) parts.push('/ ' + Math.round(d.cap) + 'h de capacité')
if (d.over) parts.push('· surchargé ⚠')
if (d.noShift) parts.push('· aucun quart (estimé)')
if (d.holiday) parts.unshift('★ férié —')
return parts.join(' ')
}
</script>
<style scoped>
.os-strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
.os-strip::-webkit-scrollbar { height: 0; }
.os-day {
position: relative; flex: 0 0 auto; width: 52px; border: 1px solid var(--ops-border, #e2e8f0);
border-radius: 10px; background: #fff; display: flex; flex-direction: column; align-items: center;
gap: 2px; padding: 7px 2px; cursor: pointer; transition: border-color .15s, box-shadow .15s;
}
.os-day:hover { border-color: #c7d2fe; }
.os-day.active { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, .18); }
.os-day.today .os-num { color: #6366f1; font-weight: 700; }
/* Différenciateur « jour courant » : liseré indigo en haut de la cellule (n'écrase pas la bague .active) */
.os-day.today::before { content: ''; position: absolute; top: -1px; left: 22%; right: 22%; height: 3px; border-radius: 0 0 3px 3px; background: #6366f1; }
.os-day.weekend { background: #f8fafc; }
.os-day.holiday { background: #fff7ed; border-color: #fdba74; }
.os-hol { position: absolute; top: 1px; left: 3px; color: #ea580c; font-size: 9px; line-height: 1; }
.os-warn { position: absolute; top: 2px; right: 3px; color: #ef4444; font-size: 9px; line-height: 1; pointer-events: none; }
/* Badge « dus non assignés ce jour » (flottant coin haut-droit) */
.os-unassigned { position: absolute; top: -5px; right: -5px; min-width: 16px; height: 16px; padding: 0 3px; border-radius: 8px; background: #ef4444; color: #fff; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; box-shadow: 0 1px 3px rgba(2, 6, 23, .3); pointer-events: none; }
.os-dow { font-size: 10px; color: var(--ops-text-secondary, #64748b); text-transform: capitalize; }
.os-num { font-size: 13px; font-weight: 600; color: var(--ops-text, #1e293b); }
.os-bar { width: 10px; height: 44px; background: #eef2f7; border-radius: 5px; overflow: hidden; display: flex; align-items: flex-end; margin: 3px 0; }
.os-fill { width: 100%; border-radius: 5px; transition: height .3s; }
.os-h { font-size: 10px; color: var(--ops-text-secondary, #64748b); white-space: nowrap; }
.os-over { color: #ef4444; margin-left: 1px; }
</style>

View File

@ -0,0 +1,32 @@
<template>
<!-- q-dialog qui se MAXIMISE sur téléphone (plein écran) et plafonne à 95vw ailleurs.
Remplace les `style="width:NNNpx"` qui débordent sur mobile.
Usage : <ResponsiveDialog v-model="open" :max-width="560"> contenu </ResponsiveDialog> -->
<q-dialog v-bind="$attrs" :maximized="isPhone">
<q-card v-if="card" class="rdlg-card column no-wrap" :style="cardStyle">
<slot />
</q-card>
<slot v-else />
</q-dialog>
</template>
<script setup>
import { computed } from 'vue'
import { useIsPhone } from 'src/composables/useMediaQuery'
defineOptions({ inheritAttrs: false })
const props = defineProps({
card: { type: Boolean, default: true }, // enveloppe automatiquement dans un q-card
maxWidth: { type: [String, Number], default: 560 },
})
const isPhone = useIsPhone()
const cardStyle = computed(() => {
if (isPhone.value) return 'width:100%;max-width:100%;height:100%'
const mw = typeof props.maxWidth === 'number' ? props.maxWidth + 'px' : props.maxWidth
return `width:100%;max-width:min(95vw, ${mw})`
})
</script>
<style scoped>
.rdlg-card { max-height: 95vh; }
</style>

View File

@ -15,14 +15,12 @@ import * as roster from 'src/api/roster'
const props = defineProps({ const props = defineProps({
routes: { type: Array, default: () => [] }, routes: { type: Array, default: () => [] },
live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }]
pins: { type: Array, default: () => [] }, // jobs NON assignés à situer : [{ lat, lon, subject, name, color(priorité), city }]
height: { type: String, default: '440px' }, height: { type: String, default: '440px' },
}) })
const emit = defineEmits(['metrics', 'stop-click', 'pin-click']) const emit = defineEmits(['metrics', 'stop-click'])
let el = null; let map = null; let ro = null; let ready = false; let drawSeq = 0 let el = null; let map = null; let ro = null; let ready = false; let drawSeq = 0
let stopMk = []; let homeMk = []; let liveMk = []; let pinMk = [] // marqueurs HTML (arrêts + domiciles + positions live + jobs non assignés) let stopMk = []; let homeMk = [] // marqueurs HTML (arrêts + domiciles)
const _rmCache = RouteMapCache() // cache module (partagé entre instances) : signature { geometry, km, mins } const _rmCache = RouteMapCache() // cache module (partagé entre instances) : signature { geometry, km, mins }
function RouteMapCache () { const g = globalThis; if (!g.__opsRouteMapCache) g.__opsRouteMapCache = new Map(); return g.__opsRouteMapCache } function RouteMapCache () { const g = globalThis; if (!g.__opsRouteMapCache) g.__opsRouteMapCache = new Map(); return g.__opsRouteMapCache }
function setEl (node) { el = node } function setEl (node) { el = node }
@ -70,46 +68,6 @@ async function realLine (r) { // géométrie routière réelle d'une tournée (O
// Marqueurs HTML : le numéro est DANS la pastille (même élément, même niveau) // Marqueurs HTML : le numéro est DANS la pastille (même élément, même niveau)
function clearMarkers () { for (const m of stopMk) m.mk.remove(); for (const m of homeMk) m.mk.remove(); stopMk = []; homeMk = [] } function clearMarkers () { for (const m of stopMk) m.mk.remove(); for (const m of homeMk) m.mk.remove(); stopMk = []; homeMk = [] }
// Positions GPS LIVE des techs (marqueur distinct des arrêts : pastille ronde à initiales + halo pulsé)
function initials (n) { return String(n || '?').trim().split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() }
function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] }
function clearPins () { for (const m of pinMk) m.mk.remove(); pinMk = [] }
function renderLive (list) {
if (!map || !ready) return
clearLive()
const mapboxgl = window.mapboxgl; const now = Date.now()
for (const p of (list || [])) {
if (!okLL(p.lon, p.lat)) continue
const ageMin = p.time ? Math.round((now - Date.parse(p.time)) / 60000) : null
const stale = ageMin != null && ageMin > 10 // > 10 min sans fix = position vieillie (pas de halo, grisée)
const color = p.color || '#1e88e5'
const wrap = document.createElement('div'); wrap.className = 'rm-live' + (stale ? ' stale' : '')
wrap.innerHTML = '<span class="rm-live-pulse" style="background:' + color + '"></span>' +
'<span class="rm-live-dot" style="background:' + color + '">' + initials(p.name || p.techName) + '</span>'
const spd = (p.speed != null && p.speed > 1) ? Math.round(p.speed * 1.852) + ' km/h' : 'arrêté' // Traccar speed = nœuds km/h
const when = ageMin == null ? 'position' : (ageMin <= 1 ? "à l'instant" : 'il y a ' + ageMin + ' min')
wrap.title = (p.name || p.techName || '') + ' — ' + when + ' · ' + spd
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map)
liveMk.push({ mk, el: wrap })
}
}
// Jobs NON assignés du jour : gouttes oranges (à situer/répartir) ; clic la page ouvre le détail.
function renderPins (list) {
if (!map || !ready) return
clearPins()
const mapboxgl = window.mapboxgl
for (const p of (list || [])) {
if (!okLL(p.lon, p.lat)) continue
const color = p.color || '#f97316'
const wrap = document.createElement('div'); wrap.className = 'rm-pin'
wrap.innerHTML = '<span class="rm-pin-drop" style="background:' + color + '"></span>'
wrap.title = (p.subject || 'Job') + (p.city ? ' · ' + p.city : '') + ' — non assigné'
wrap.addEventListener('click', () => emit('pin-click', p))
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'bottom' }).setLngLat([+p.lon, +p.lat]).addTo(map)
pinMk.push({ mk, el: wrap })
}
}
function renderMarkers (routes) { function renderMarkers (routes) {
clearMarkers() clearMarkers()
const mapboxgl = window.mapboxgl const mapboxgl = window.mapboxgl
@ -191,10 +149,8 @@ async function draw () {
const ls = map.getSource('rm-line'); if (!ls) return const ls = map.getSource('rm-line'); if (!ls) return
ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) }) ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) })
renderMarkers(routes) renderMarkers(routes)
renderLive(props.live) const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|')
renderPins(props.pins) if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} }
const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|') + '#' + (props.pins || []).length
if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); for (const p of (props.pins || [])) if (okLL(p.lon, p.lat)) b.extend([+p.lon, +p.lat]); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} }
// Routes RÉELLES en asynchrone : remplace les segments droits + émet km/min réels. Ignoré si un draw plus récent est parti. // Routes RÉELLES en asynchrone : remplace les segments droits + émet km/min réels. Ignoré si un draw plus récent est parti.
const results = await Promise.all(routes.map(async r => ({ r, info: await realLine(r) }))) const results = await Promise.all(routes.map(async r => ({ r, info: await realLine(r) })))
if (seq !== drawSeq || !map || !map.getSource('rm-line')) return if (seq !== drawSeq || !map || !map.getSource('rm-line')) return
@ -243,10 +199,8 @@ onMounted(async () => {
draw() draw()
}) })
}) })
onBeforeUnmount(() => { clearMarkers(); clearLive(); clearPins(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false }) onBeforeUnmount(() => { clearMarkers(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false })
watch(() => props.routes, () => { draw() }, { deep: true }) watch(() => props.routes, () => { draw() }, { deep: true })
watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live sans redessiner les tournées
watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // jobs non assignés (secteur) sans redessiner les tournées
</script> </script>
<template> <template>
@ -274,15 +228,4 @@ watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // job
.rm-mk.route-hot { z-index: 5; } .rm-mk.route-hot { z-index: 5; }
.rm-mk.fanned { z-index: 6; } .rm-mk.fanned { z-index: 6; }
.rm-mk.fanned .rm-pill { box-shadow: 0 3px 10px rgba(0,0,0,.55); } .rm-mk.fanned .rm-pill { box-shadow: 0 3px 10px rgba(0,0,0,.55); }
/* Position GPS LIVE d'un tech : pastille ronde à initiales + halo pulsé (distincte des pastilles d'arrêt). */
.rm-live { position: relative; width: 0; height: 0; z-index: 8; pointer-events: auto; }
.rm-live .rm-live-dot { position: absolute; transform: translate(-50%, -50%); width: 28px; height: 28px; border-radius: 50%; border: 2.5px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,.5); color: #fff; font-size: 10.5px; font-weight: 800; display: flex; align-items: center; justify-content: center; z-index: 2; cursor: default; }
.rm-live .rm-live-pulse { position: absolute; width: 28px; height: 28px; border-radius: 50%; opacity: .45; z-index: 1; animation: rm-live-pulse 1.8s ease-out infinite; }
@keyframes rm-live-pulse { 0% { transform: translate(-50%,-50%) scale(1); opacity: .45; } 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } }
.rm-live.stale .rm-live-pulse { display: none; }
.rm-live.stale .rm-live-dot { opacity: .55; filter: grayscale(.5); }
/* Jobs NON assignés (goutte, couleur = priorité) — à situer/répartir */
.rm-pin { position: relative; width: 0; height: 0; z-index: 7; cursor: pointer; pointer-events: auto; }
.rm-pin-drop { position: absolute; left: -9px; bottom: 0; width: 18px; height: 18px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); border: 2px solid #fff; box-shadow: 0 2px 5px rgba(0, 0, 0, .4); }
.rm-pin:hover .rm-pin-drop { filter: brightness(1.12); }
</style> </style>

View File

@ -351,9 +351,9 @@ function confirmDelete () {
}) })
} }
// Navigate to planification (ex-Dispatch, page retirée) // Navigate to dispatch
function goToDispatch () { function goToDispatch () {
router.push('/planification') router.push('/dispatch')
} }
// Visual computed // Visual computed

View File

@ -19,15 +19,13 @@ const emit = defineEmits(['update:modelValue', 'apply'])
const DAYS = [['mon', 'Lun'], ['tue', 'Mar'], ['wed', 'Mer'], ['thu', 'Jeu'], ['fri', 'Ven'], ['sat', 'Sam'], ['sun', 'Dim']] const DAYS = [['mon', 'Lun'], ['tue', 'Mar'], ['wed', 'Mer'], ['thu', 'Jeu'], ['fri', 'Ven'], ['sat', 'Sam'], ['sun', 'Dim']]
const sched = reactive({}) const sched = reactive({})
const weeks = ref(1) const weeks = ref(1)
const mode = ref('recurring') // 'recurring' = patron de base (matérialisé, exceptions auto) · 'exception' = override ponctuel (semaines précises)
const selected = ref(new Set()) // techIds cochés (mode lot) const selected = ref(new Set()) // techIds cochés (mode lot)
const bulk = computed(() => (props.techs || []).length > 1) const bulk = computed(() => (props.techs || []).length > 1)
const title = computed(() => bulk.value ? ((props.techs || []).length + ' techniciens') : (props.techName || (props.techs[0] && props.techs[0].name) || '')) const title = computed(() => bulk.value ? ((props.techs || []).length + ' techniciens') : (props.techName || (props.techs[0] && props.techs[0].name) || ''))
function fill (preset) { for (const [k] of DAYS) { const s = preset[k]; sched[k] = s ? { on: true, start: s.start, end: s.end } : { on: false, start: '08:00', end: '16:00' } } } function fill (preset) { for (const [k] of DAYS) { const s = preset[k]; sched[k] = s ? { on: true, start: s.start, end: s.end } : { on: false, start: '08:00', end: '16:00' } } }
fill(SCHEDULE_PRESETS[0].schedule) fill(SCHEDULE_PRESETS[0].schedule)
watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = mode.value === 'recurring' ? 6 : 1; selected.value = new Set((props.techs || []).map(t => t.id)) } }) watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = 1; selected.value = new Set((props.techs || []).map(t => t.id)) } })
watch(mode, (m) => { weeks.value = m === 'recurring' ? 6 : 1 })
function toggleTech (id) { const s = new Set(selected.value); s.has(id) ? s.delete(id) : s.add(id); selected.value = s } function toggleTech (id) { const s = new Set(selected.value); s.has(id) ? s.delete(id) : s.add(id); selected.value = s }
function dayHours (d) { if (!d || !d.on) return 0; const [h1, m1] = d.start.split(':').map(Number); const [h2, m2] = d.end.split(':').map(Number); let mn = (h2 * 60 + m2) - (h1 * 60 + m1); if (mn < 0) mn += 1440; return Math.round(mn / 60 * 10) / 10 } function dayHours (d) { if (!d || !d.on) return 0; const [h1, m1] = d.start.split(':').map(Number); const [h2, m2] = d.end.split(':').map(Number); let mn = (h2 * 60 + m2) - (h1 * 60 + m1); if (mn < 0) mn += 1440; return Math.round(mn / 60 * 10) / 10 }
@ -36,7 +34,7 @@ const totalHours = () => DAYS.reduce((s, [k]) => s + dayHours(sched[k]), 0)
function submit () { function submit () {
const techIds = bulk.value ? [...selected.value] : (props.techs[0] ? [props.techs[0].id] : []) const techIds = bulk.value ? [...selected.value] : (props.techs[0] ? [props.techs[0].id] : [])
if (!techIds.length) return if (!techIds.length) return
emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)), techIds, mode: mode.value }) emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)), techIds })
emit('update:modelValue', false) emit('update:modelValue', false)
} }
</script> </script>
@ -51,12 +49,6 @@ function submit () {
</q-card-section> </q-card-section>
<q-separator /> <q-separator />
<q-card-section class="q-gutter-sm"> <q-card-section class="q-gutter-sm">
<!-- Mode : patron RÉCURRENT (base, matérialisé avec fériés/vacances sautés) vs EXCEPTION ponctuelle (semaines précises) -->
<q-btn-toggle v-model="mode" spread no-caps dense unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[{ value: 'recurring', slot: 'rec' }, { value: 'exception', slot: 'exc' }]">
<template #rec><div class="column items-center" style="line-height:1.1;padding:2px 0"><span>🔁 Horaire récurrent</span><span style="font-size:10px;opacity:.7">base · fériés/vacances sautés</span></div></template>
<template #exc><div class="column items-center" style="line-height:1.1;padding:2px 0"><span>📌 Exception</span><span style="font-size:10px;opacity:.7">semaine(s) précise(s)</span></div></template>
</q-btn-toggle>
<!-- Mode LOT : choisir les techniciens à qui appliquer le modèle (tous cochés par défaut) --> <!-- Mode LOT : choisir les techniciens à qui appliquer le modèle (tous cochés par défaut) -->
<div v-if="bulk"> <div v-if="bulk">
<div class="text-caption text-grey-7 q-mb-xs">Appliquer à ({{ selected.size }}/{{ techs.length }}) clic pour ()cocher :</div> <div class="text-caption text-grey-7 q-mb-xs">Appliquer à ({{ selected.size }}/{{ techs.length }}) clic pour ()cocher :</div>
@ -83,18 +75,17 @@ function submit () {
<span v-else class="wse-repos">Repos</span> <span v-else class="wse-repos">Repos</span>
</div> </div>
</div> </div>
<!-- Horizon : récurrent = fenêtre à matérialiser ; exception = nb de semaines dès la semaine affichée --> <!-- Automatisation : répéter sur N semaines -->
<div class="row items-center q-gutter-sm q-pt-xs"> <div class="row items-center q-gutter-sm q-pt-xs">
<q-input dense outlined type="number" v-model.number="weeks" :label="mode === 'recurring' ? 'Matérialiser N semaines' : 'N semaine(s) dès la semaine affichée'" :min="1" :max="12" style="width:250px"> <q-input dense outlined type="number" v-model.number="weeks" label="Générer pour N semaine(s)" :min="1" :max="12" style="width:220px">
<template #prepend><q-icon name="repeat" size="18px" /></template> <template #prepend><q-icon name="repeat" size="18px" /></template>
</q-input> </q-input>
<div class="text-caption text-grey-6">{{ totalDays() }} j / {{ Math.round(totalHours() * 10) / 10 }}h par semaine</div> <div class="text-caption text-grey-6">à partir de la semaine affichée · {{ totalDays() }} j / {{ Math.round(totalHours() * 10) / 10 }}h par semaine</div>
</div> </div>
<div class="text-caption text-grey-6">{{ mode === 'recurring' ? 'Devient l\'horaire par défaut : quarts générés automatiquement, fériés & vacances exclus, tes ajustements manuels préservés.' : 'Écrit des quarts pour ces semaines uniquement (override) — ex. semaine de nuit. Ne change pas l\'horaire de base.' }}</div>
</q-card-section> </q-card-section>
<q-card-actions align="right" class="q-px-md q-pb-md"> <q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat no-caps label="Annuler" color="grey-7" @click="emit('update:modelValue', false)" /> <q-btn flat no-caps label="Annuler" color="grey-7" @click="emit('update:modelValue', false)" />
<q-btn unelevated no-caps color="primary" icon="event_available" :disable="bulk && !selected.size" :label="(mode === 'recurring' ? 'Définir l\'horaire' : 'Créer l\'exception') + (bulk ? ' ×' + selected.size + ' tech' : '')" @click="submit" /> <q-btn unelevated no-caps color="primary" icon="event_available" :disable="bulk && !selected.size" :label="'Générer' + (bulk ? ' ×' + selected.size + ' tech' : '') + (weeks > 1 ? ' · ' + weeks + ' sem.' : '')" @click="submit" />
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>

View File

@ -29,16 +29,6 @@ export function timeToH (t) {
return h + m / 60 return h + m / 60
} }
// Occupation → couleur « chaleur » (vert = libre → rouge = saturé). SOURCE UNIQUE :
// bande mobile Planif (.pm-strip), <OccupancyStrip> du tableau de bord, en-tête desktop Planif.
export function heatColor (ratio) {
const r = +ratio || 0
if (r >= 1) return '#ef4444' // saturé / dépassé
if (r >= 0.75) return '#f59e0b' // chargé
if (r >= 0.45) return '#84cc16' // moyen
return '#22c55e' // libre
}
export function hToTime (h) { export function hToTime (h) {
const totalMin = Math.round(h * 60) const totalMin = Math.round(h * 60)
const hh = Math.floor(totalMin / 60) const hh = Math.floor(totalMin / 60)

View File

@ -16,44 +16,34 @@ let fetchPromise = null // Prevent duplicate fetches
* Fetch effective permissions for the current user from targo-hub. * Fetch effective permissions for the current user from targo-hub.
* Reads X-Authentik-Email header (injected by Traefik). * Reads X-Authentik-Email header (injected by Traefik).
* @param {string} email user email (from Authentik header or session) * @param {string} email user email (from Authentik header or session)
* @param {string} [username] Authentik username, used as a FALLBACK lookup when the email
* matches no provisioned OPS account (empty/wrong email the hub retries by username).
*/ */
async function loadPermissions (email, username) { async function loadPermissions (email) {
if (!email && !username) return if (!email) return
if (fetchPromise) return fetchPromise if (fetchPromise) return fetchPromise
loading.value = true loading.value = true
error.value = null error.value = null
const qs = new URLSearchParams() fetchPromise = fetch(`${HUB_URL}/auth/permissions?email=${encodeURIComponent(email)}`)
if (email) qs.set('email', email) .then(r => {
if (username) qs.set('username', username) if (!r.ok) throw new Error('Permission fetch failed: ' + r.status)
const url = `${HUB_URL}/auth/permissions?${qs.toString()}` return r.json()
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' })
.then(data => {
// RÉSILIENCE : le tout 1er appel /hub au démarrage échoue parfois (session pas encore « chaude » / course au boot)
// alors que les appels /hub suivants (ex. recherche) passent. On RÉESSAIE (jusqu'à 4 tentatives, backoff 0,7→2,1 s)
// avant de conclure « pas d'accès » → un superuser n'est plus bloqué par un échec transitoire (le vrai bug observé).
async function attempt (n) {
try {
const r = await fetch(url, { cache: 'no-store' })
if (!r.ok) throw new Error('HTTP ' + r.status)
const data = await r.json()
permissions.value = data permissions.value = data
error.value = null })
} catch (e) { .catch(e => {
if (n < 4) { await new Promise(res => setTimeout(res, 700 * n)); return attempt(n + 1) } console.error('[usePermissions]', e.message)
console.error('[usePermissions] load failed after retries:', e.message)
error.value = e.message error.value = e.message
// DEV local (pas de hub devant) : profil permissif pour prévisualiser/auditer ; PROD → null (App.vue « fail-loud »). // DEV local (pas de hub devant) : le fetch échoue → on débloque l'app avec un profil permissif
// pour pouvoir prévisualiser/auditer toutes les pages. En PROD on reste null (= restrictif/sûr).
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
permissions.value = isLocal ? { email, is_superuser: true, capabilities: {}, groups: ['admin'] } : null permissions.value = isLocal ? { email, is_superuser: true, capabilities: {}, groups: ['admin'] } : null
} })
} .finally(() => {
fetchPromise = attempt(1).finally(() => { loading.value = false
loading.value = false fetchPromise = null
fetchPromise = null })
})
return fetchPromise return fetchPromise
} }

View File

@ -11,13 +11,8 @@
* EventSource (/sse, /conversations) work, and rides the existing Authentik * EventSource (/sse, /conversations) work, and rides the existing Authentik
* forward-auth on /ops. Mirrors the /ops/api ERPNext pattern (config/erpnext.js). * forward-auth on /ops. Mirrors the /ops/api ERPNext pattern (config/erpnext.js).
* *
* VITE_HUB_URL n'est honoré qu'en DEV (proxy du serveur de dev). * Can also be overridden via VITE_HUB_URL env variable.
*/ */
const _opsBase = (import.meta.env.BASE_URL || '/').replace(/\/$/, '') // '' in dev, '/ops' in prod const _opsBase = (import.meta.env.BASE_URL || '/').replace(/\/$/, '') // '' in dev, '/ops' in prod
// ⚠️ PROD : TOUJOURS le proxy same-origin `_opsBase + '/hub'` (= /ops/hub → Traefik strip /ops → nginx `location /hub/` export const HUB_URL = import.meta.env.VITE_HUB_URL
// → hub). On n'honore VITE_HUB_URL qu'en DEV : il vaut `/hub` dans .env.local (proxy du serveur de dev) et, comme || (window.location.hostname === 'localhost' ? 'http://localhost:3300' : _opsBase + '/hub')
// .env.local est chargé MÊME au build prod (cf. fuite VITE_DEV_USER), l'utiliser en prod ferait appeler `/hub` à la
// RACINE → hors du préfixe /ops → routé vers ERPNext → **404** (= le bug « menu vide / Accès OPS indisponible »).
export const HUB_URL = import.meta.env.DEV
? (import.meta.env.VITE_HUB_URL || 'http://localhost:3300')
: _opsBase + '/hub'

View File

@ -17,7 +17,8 @@ export const navItems = [
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets', section: 'service' }, { path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets', section: 'service' },
{ path: '/evaluations', icon: 'Star', label: 'Évaluations', requires: 'view_clients', section: 'service' }, { path: '/evaluations', icon: 'Star', label: 'Évaluations', requires: 'view_clients', section: 'service' },
{ path: '/pipeline', icon: 'Filter', label: 'Pipeline (ventes)', requires: 'view_clients', section: 'service' }, { path: '/pipeline', icon: 'Filter', label: 'Pipeline (ventes)', requires: 'view_clients', section: 'service' },
// ── Terrain (effectifs / interventions) — page Dispatch retirée, tout est sur Planification ── // ── Terrain (effectifs / interventions) ──
{ path: '/dispatch', icon: 'Truck', label: 'Dispatch', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/planification', icon: 'CalendarRange', label: 'Planification', requires: 'view_all_jobs', section: 'terrain' }, { path: '/planification', icon: 'CalendarRange', label: 'Planification', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/rdv', icon: 'CalendarClock', label: 'Rendez-vous', requires: 'view_all_jobs', section: 'terrain' }, { path: '/rdv', icon: 'CalendarClock', label: 'Rendez-vous', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs', section: 'terrain' }, { path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs', section: 'terrain' },

View File

@ -168,7 +168,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, nextTick, watch, defineAsyncComponent } from 'vue' import { ref, computed, nextTick, watch } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'src/stores/auth' import { useAuthStore } from 'src/stores/auth'
@ -180,18 +180,16 @@ import {
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat,
} from 'lucide-vue-next' } from 'lucide-vue-next'
// Composants lourds montés conditionnellement (v-if) chargés à la demande (defineAsyncComponent) au lieu d'alourdir import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
// le chunk MainLayout livré sur CHAQUE page. Aucun accès par ref sûr. NotificationBell reste synchrone (barre du haut, toujours visible). import PhoneModal from 'src/components/customer/PhoneModal.vue'
const ConversationPanel = defineAsyncComponent(() => import('src/components/shared/ConversationPanel.vue'))
const PhoneModal = defineAsyncComponent(() => import('src/components/customer/PhoneModal.vue'))
import { useSoftphone } from 'src/composables/useSoftphone' import { useSoftphone } from 'src/composables/useSoftphone'
import NotificationBell from 'src/components/shared/NotificationBell.vue' import NotificationBell from 'src/components/shared/NotificationBell.vue'
import { useConversations } from 'src/composables/useConversations' import { useConversations } from 'src/composables/useConversations'
const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue')) import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
const NewTicketDialog = defineAsyncComponent(() => import('src/components/shared/NewTicketDialog.vue')) import NewTicketDialog from 'src/components/shared/NewTicketDialog.vue'
const OrchestratorDialog = defineAsyncComponent(() => import('src/components/shared/OrchestratorDialog.vue')) import OrchestratorDialog from 'src/components/shared/OrchestratorDialog.vue'
const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue')) import ServiceStatusDialog from 'src/components/shared/ServiceStatusDialog.vue'
const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/OutboxPanel.vue')) import OutboxPanel from 'src/components/shared/OutboxPanel.vue'
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat } const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat }

View File

@ -278,7 +278,7 @@ const editorOptions = {
mergeTags: [ mergeTags: [
{ name: 'Prénom', value: '{{firstname}}', sample: 'Louis' }, { name: 'Prénom', value: '{{firstname}}', sample: 'Louis' },
{ name: 'Nom de famille', value: '{{lastname}}', sample: 'Tremblay' }, { name: 'Nom de famille', value: '{{lastname}}', sample: 'Tremblay' },
{ name: 'Courriel', value: '{{email}}', sample: 'exemple@gigafibre.ca' }, { name: 'Courriel', value: '{{email}}', sample: 'louis@targo.ca' },
{ name: 'Adresse service', value: '{{description}}', sample: '123 Rue de la Rivière, Ste-Clotilde' }, { name: 'Adresse service', value: '{{description}}', sample: '123 Rue de la Rivière, Ste-Clotilde' },
], ],
}, },
@ -562,7 +562,7 @@ const sampleExpiryDate = new Date(Date.now() + 30 * 86400 * 1000)
.toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' }) .toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' })
const testSendForm = ref({ const testSendForm = ref({
to: '', to: 'louis@targo.ca',
subject: '[TEST] Aperçu du courriel TARGO', subject: '[TEST] Aperçu du courriel TARGO',
via: 'gmail', via: 'gmail',
vars: { vars: {

View File

@ -16,7 +16,7 @@ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
skills: { type: Array, default: () => [] }, // [{ skill, dur, color }] skills: { type: Array, default: () => [] }, // [{ skill, dur, color }]
}) })
const emit = defineEmits(['update:modelValue', 'select', 'plan-shifts']) const emit = defineEmits(['update:modelValue', 'select'])
const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch() const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
const target = reactive({ address: '', latitude: null, longitude: null, ville: '' }) const target = reactive({ address: '', latitude: null, longitude: null, ville: '' })
@ -48,9 +48,6 @@ function pick (s) {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
// « Aucun créneau » renvoie le parent (Planif) vers la timeline des techs (filtrée compétence) au jour cherché, pour CRÉER un quart.
function planShifts () { emit('plan-shifts', { skill: skill.value, date: afterDate.value }); emit('update:modelValue', false) }
const DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam'] const DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) } function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) }
</script> </script>
@ -101,12 +98,7 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
<q-separator v-if="searched" /> <q-separator v-if="searched" />
<q-card-section v-if="searched" style="max-height:44vh;overflow:auto" class="q-pt-sm"> <q-card-section v-if="searched" style="max-height:44vh;overflow:auto" class="q-pt-sm">
<div v-if="loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /></div> <div v-if="loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /></div>
<div v-else-if="!slots.length" class="text-center q-pa-md"> <div v-else-if="!slots.length" class="text-grey-6 text-center q-pa-md">Aucun créneau{{ skill ? ' pour « ' + skill + ' »' : '' }} vérifie qu'un tech qualifié a un <b>quart</b> sur ces jours (les créneaux suivent les quarts réels ; week-ends réservés aux réparations), ou élargis la date.</div>
<div class="text-grey-6 q-mb-sm">Aucun créneau{{ skill ? ' pour « ' + skill + ' »' : '' }} vérifie qu'un tech qualifié a un <b>quart</b> sur ces jours (les créneaux suivent les quarts réels ; week-ends réservés aux réparations), ou élargis la date.</div>
<q-btn unelevated no-caps size="sm" color="teal-8" icon="event" :label="skill ? 'Planifier des quarts pour « ' + skill + ' »' : 'Planifier des quarts'" @click="planShifts">
<q-tooltip>Ouvre la Planification (timeline des techs {{ skill ? 'de « ' + skill + ' »' : '' }}, au {{ afterDate }}) pour créer un quart des créneaux apparaîtront.</q-tooltip>
</q-btn>
</div>
<q-list v-else separator> <q-list v-else separator>
<q-item v-for="(s, i) in slots" :key="i" clickable @click="pick(s)" class="rounded-borders"> <q-item v-for="(s, i) in slots" :key="i" clickable @click="pick(s)" class="rounded-borders">
<q-item-section avatar><q-avatar size="34px" color="blue-grey-1" text-color="blue-grey-8" class="text-weight-bold" style="font-size:12px">{{ dayLabel(s.date).slice(0, 3) }}</q-avatar></q-item-section> <q-item-section avatar><q-avatar size="34px" color="blue-grey-1" text-color="blue-grey-8" class="text-weight-bold" style="font-size:12px">{{ dayLabel(s.date).slice(0, 3) }}</q-avatar></q-item-section>

View File

@ -3,45 +3,21 @@
<!-- Real-time outage alerts --> <!-- Real-time outage alerts -->
<OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push('/tickets')" /> <OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push('/tickets')" />
<!-- KPI cards (cliquables page liée · sous-ligne = tendance/contexte) --> <!-- KPI cards -->
<div class="row q-col-gutter-md q-mb-lg"> <div class="row q-col-gutter-md q-mb-lg">
<div class="col-6 col-md" v-for="stat in stats" :key="stat.label"> <div class="col-6 col-md" v-for="stat in stats" :key="stat.label">
<div class="ops-card ops-stat" :class="{ 'ops-stat-clk': stat.to }" @click="stat.to && $router.push(stat.to)"> <div class="ops-card ops-stat">
<div class="row items-center no-wrap q-gutter-x-sm"> <div class="row items-center no-wrap q-gutter-x-sm">
<q-icon :name="stat.icon" :style="{ color: stat.color }" size="22px" /> <q-icon :name="stat.icon" :style="{ color: stat.color }" size="22px" />
<div class="col"> <div>
<div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div> <div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
<div class="ops-stat-label">{{ stat.label }}</div> <div class="ops-stat-label">{{ stat.label }}</div>
<div v-if="stat.sub" class="ops-stat-sub" :class="stat.subClass">{{ stat.sub }}</div>
</div> </div>
<q-icon v-if="stat.to" name="chevron_right" size="18px" class="ops-stat-go" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Charge 2 semaines : 2 bandes d'occupation (courante + prochaine), jour courant marqué (liseré indigo). Clic jour Planification (jour ciblé). -->
<div class="ops-card q-mb-lg">
<div class="row items-center q-mb-sm">
<div class="text-subtitle1 text-weight-bold">Charge <span class="text-caption text-grey-6 q-ml-xs">2 semaines</span></div>
<q-space />
<q-btn flat dense no-caps size="sm" color="primary" icon-right="chevron_right" label="Planification" @click="$router.push('/planification')" />
</div>
<template v-if="weekLoad.length">
<div class="dash-wk-lbl">Cette semaine</div>
<OccupancyStrip :days="weekLoad.slice(0, 7)" :selected="todayIso" @select="iso => $router.push({ path: '/planification', query: { day: iso } })" />
<div class="dash-wk-lbl q-mt-sm">Semaine prochaine</div>
<OccupancyStrip :days="weekLoad.slice(7, 14)" :selected="todayIso" @select="iso => $router.push({ path: '/planification', query: { day: iso } })" />
</template>
<div v-else class="text-caption text-grey-6 q-py-sm">Chargement de la charge</div>
<div class="text-caption text-grey-6 q-mt-sm row items-center q-gutter-x-sm">
<span><span class="os-leg-dot" style="background:#22c55e"></span>libre</span>
<span><span class="os-leg-dot" style="background:#f59e0b"></span>chargé</span>
<span><span class="os-leg-dot" style="background:#ef4444"></span>saturé</span>
<span v-if="weekUnplaced" class="text-warning">· <b>{{ weekUnplaced }}h</b> à répartir</span>
</div>
</div>
<!-- Admin controls --> <!-- Admin controls -->
<div class="row q-col-gutter-md q-mb-lg"> <div class="row q-col-gutter-md q-mb-lg">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@ -111,9 +87,9 @@
icon="credit_score" icon="credit_score"
:loading="runningPPA" :loading="runningPPA"
dense no-caps dense no-caps
@click="confirmPPA" @click="runPPA"
> >
<q-tooltip>Prélève les cartes enregistrées (confirmation requise)</q-tooltip> <q-tooltip>Prélève automatiquement les cartes enregistrées</q-tooltip>
</q-btn> </q-btn>
</div> </div>
<div class="q-mt-xs q-gutter-xs" style="min-height:20px"> <div class="q-mt-xs q-gutter-xs" style="min-height:20px">
@ -165,7 +141,7 @@
<q-badge v-if="todayJobs.length" :label="todayJobs.length" color="primary" class="q-ml-sm" /> <q-badge v-if="todayJobs.length" :label="todayJobs.length" color="primary" class="q-ml-sm" />
</div> </div>
<q-list separator> <q-list separator>
<q-item v-for="j in todayJobs" :key="j.name" clickable @click="$router.push('/planification')"> <q-item v-for="j in todayJobs" :key="j.name" clickable @click="$router.push('/dispatch')">
<q-item-section avatar style="min-width:32px"> <q-item-section avatar style="min-width:32px">
<q-icon :name="j.status === 'completed' ? 'check_circle' : j.assigned_tech ? 'person' : 'radio_button_unchecked'" <q-icon :name="j.status === 'completed' ? 'check_circle' : j.assigned_tech ? 'person' : 'radio_button_unchecked'"
:color="j.status === 'completed' ? 'green-6' : j.assigned_tech ? 'blue-6' : 'grey-5'" size="20px" /> :color="j.status === 'completed' ? 'green-6' : j.assigned_tech ? 'blue-6' : 'grey-5'" size="20px" />
@ -195,57 +171,23 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { listDocs, countDocs } from 'src/api/erp' import { listDocs, countDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth' import { authFetch } from 'src/api/auth'
import { getCapacity } from 'src/api/roster'
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
import { HUB_SSE_URL } from 'src/config/dispatch' import { HUB_SSE_URL } from 'src/config/dispatch'
import { startOfWeek, localDateStr } from 'src/composables/useHelpers'
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue' import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue'
const $q = useQuasar()
const stats = ref([ const stats = ref([
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people', to: '/clients', sub: '', subClass: '' }, { label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people' },
{ label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business', to: '/clients', sub: '', subClass: '' }, { label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business' },
{ label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money', to: '/rapports/revenus', sub: 'récurrent', subClass: 'text-grey-6' }, { label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money' },
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number', to: '/tickets', sub: '', subClass: '' }, { label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number' },
{ label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping', to: '/planification', sub: '', subClass: '' }, { label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping' },
]) ])
const openTickets = ref([]) const openTickets = ref([])
const todayJobs = ref([]) const todayJobs = ref([])
// Charge de la semaine (bande d'occupation, réutilise /roster/capacity)
const weekLoad = ref([])
const weekUnplaced = ref(0)
const todayIso = localDateStr(new Date())
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
async function loadWeek () {
try {
const mon = startOfWeek(new Date()) // lundi de la semaine courante 2 semaines (courante + prochaine)
const { capacity } = await getCapacity(localDateStr(mon), 14)
const days = []; let unplaced = 0
for (let i = 0; i < 14; i++) {
const dt = new Date(mon); dt.setDate(mon.getDate() + i)
const iso = localDateStr(dt)
const c = capacity[iso] || {}
const h = +c.due_h || 0; const cap = +c.cap_h || 0
unplaced += +c.unplaced_h || 0
days.push({
iso, dow: FR_DOW[dt.getDay()], dnum: dt.getDate() + '/' + String(dt.getMonth() + 1).padStart(2, '0'),
h, cap, ratio: cap > 0 ? h / cap : (h > 0 ? 1 : 0), over: !!c.overbooked,
weekend: dt.getDay() === 0 || dt.getDay() === 6, noShift: cap === 0 && h > 0, today: iso === todayIso,
})
}
weekLoad.value = days
weekUnplaced.value = Math.round(unplaced)
} catch { /* strip masqué si le hub ne répond pas */ }
}
// Scheduler controls // Scheduler controls
const schedulerEnabled = ref(false) const schedulerEnabled = ref(false)
const togglingScheduler = ref(false) const togglingScheduler = ref(false)
@ -403,19 +345,6 @@ async function submitDrafts () {
submittingDrafts.value = false submittingDrafts.value = false
} }
// Garde-fou : le PPA prélève IMMÉDIATEMENT les cartes enregistrées. Un double lancement peut
// charger deux fois (cf. incident 2026-06) confirmation explicite obligatoire avant d'exécuter.
function confirmPPA () {
$q.dialog({
title: 'Prélèvement automatique (PPA)',
message: 'Cette action prélève <b>immédiatement</b> les cartes enregistrées des clients éligibles. À n\'exécuter <b>qu\'une seule fois</b> par cycle — un double lancement peut charger deux fois. Continuer ?',
html: true,
ok: { label: 'Prélever maintenant', color: 'positive', noCaps: true, unelevated: true },
cancel: { label: 'Annuler', flat: true, noCaps: true },
persistent: true,
}).onOk(() => { runPPA() })
}
async function runPPA () { async function runPPA () {
runningPPA.value = true runningPPA.value = true
ppaResult.value = null ppaResult.value = null
@ -444,12 +373,10 @@ async function runPPA () {
onMounted(async () => { onMounted(async () => {
fetchSchedulerStatus() fetchSchedulerStatus()
refreshDraftCount() refreshDraftCount()
loadWeek()
const today = new Date().toISOString().slice(0, 10) const today = new Date().toISOString().slice(0, 10)
const d7 = localDateStr(new Date(Date.now() - 7 * 86400000)) // il y a 7 jours (tendance tickets)
const [clients, tickets, todayDispatch, openTix, newTickets7d] = await Promise.all([ const [clients, tickets, todayDispatch, openTix] = await Promise.all([
countDocs('Customer', { disabled: 0 }), countDocs('Customer', { disabled: 0 }),
countDocs('Issue', { status: 'Open' }), countDocs('Issue', { status: 'Open' }),
listDocs('Dispatch Job', { listDocs('Dispatch Job', {
@ -462,7 +389,6 @@ onMounted(async () => {
fields: ['name', 'subject', 'customer', 'customer_name', 'priority', 'opening_date'], fields: ['name', 'subject', 'customer', 'customer_name', 'priority', 'opening_date'],
limit: 10, orderBy: 'opening_date desc', limit: 10, orderBy: 'opening_date desc',
}), }),
countDocs('Issue', { opening_date: ['>=', d7] }).catch(() => 0),
]) ])
// Abonnés = unique customers with active subscriptions (via server script) // Abonnés = unique customers with active subscriptions (via server script)
@ -489,31 +415,7 @@ onMounted(async () => {
stats.value[3].value = tickets.toLocaleString() stats.value[3].value = tickets.toLocaleString()
stats.value[4].value = todayDispatch.length.toLocaleString() stats.value[4].value = todayDispatch.length.toLocaleString()
// Tendance / contexte des KPI (à partir des données déjà chargées 0 requête de plus, sauf le compte 7 j)
const urgents = openTix.filter(t => t.priority === 'Urgent' || t.priority === 'High').length
const tkParts = []
if (urgents) tkParts.push(urgents + ' urgents')
if (newTickets7d) tkParts.push('+' + newTickets7d + ' (7j)')
stats.value[3].sub = tkParts.join(' · ')
stats.value[3].subClass = urgents ? 'text-negative' : 'text-grey-6'
const unassigned = todayDispatch.filter(j => !j.assigned_tech).length
stats.value[4].sub = todayDispatch.length ? (unassigned ? unassigned + ' non assignés' : 'tous assignés ✓') : ''
stats.value[4].subClass = unassigned ? 'text-warning' : 'text-positive'
openTickets.value = openTix openTickets.value = openTix
todayJobs.value = todayDispatch todayJobs.value = todayDispatch
}) })
</script> </script>
<style scoped>
/* KPI cliquable : affordance au survol + sous-ligne tendance/contexte */
.ops-stat-clk { cursor: pointer; transition: transform .12s, box-shadow .12s; }
.ops-stat-clk:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(2, 6, 23, .08); }
.ops-stat-clk .ops-stat-go { color: var(--ops-text-secondary, #94a3b8); opacity: 0; transition: opacity .12s; }
.ops-stat-clk:hover .ops-stat-go { opacity: 1; }
.ops-stat-sub { font-size: 11px; line-height: 1.2; margin-top: 1px; }
/* Légende de la bande d'occupation */
.os-leg-dot { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 3px; vertical-align: middle; }
/* Libellé de semaine au-dessus de chaque bande (« Cette semaine » / « Semaine prochaine ») */
.dash-wk-lbl { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; color: var(--ops-text-secondary, #94a3b8); margin-bottom: 4px; }
</style>

File diff suppressed because it is too large Load Diff

View File

@ -696,6 +696,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch, nextTick, reactive } from 'vue' import { ref, computed, onMounted, watch, nextTick, reactive } from 'vue'
import cytoscape from 'cytoscape'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { listDocs, updateDoc, createDoc } from 'src/api/erp' import { listDocs, updateDoc, createDoc } from 'src/api/erp'
import { Notify } from 'quasar' import { Notify } from 'quasar'
@ -1171,7 +1172,6 @@ const mapSelectedSite = ref(null)
const mapSelectedSiteId = ref(null) const mapSelectedSiteId = ref(null)
const cyContainer = ref(null) const cyContainer = ref(null)
let cyInstance = null let cyInstance = null
let cytoscape = null // chargé à la demande (lib ~350 KB) seulement quand on ouvre l'onglet Carte
const mapSelectedSiteLinks = computed(() => { const mapSelectedSiteLinks = computed(() => {
if (!mapData.value || !mapSelectedSiteId.value) return [] if (!mapData.value || !mapSelectedSiteId.value) return []
@ -1183,7 +1183,6 @@ async function loadNetworkMap () {
try { try {
const resp = await fetch(`${hubUrl}/network/map`) const resp = await fetch(`${hubUrl}/network/map`)
mapData.value = await resp.json() mapData.value = await resp.json()
if (!cytoscape) cytoscape = (await import('cytoscape')).default // import dynamique : sort la lib du chunk NetworkPage
await nextTick() await nextTick()
renderCytoscape() renderCytoscape()
} catch (e) { } catch (e) {

View File

@ -16,6 +16,7 @@
<q-separator /> <q-separator />
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="primary" /></q-item-section><q-item-section>Générer (auto)</q-item-section></q-item> <q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="primary" /></q-item-section><q-item-section>Générer (auto)</q-item-section></q-item>
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" color="teal" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item> <q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" color="teal" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
<q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="dashboard" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch</q-item-section></q-item>
<q-separator /> <q-separator />
<q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item> <q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item>
<q-item clickable v-close-popup @click="openCreateJob"><q-item-section avatar><q-icon name="add_task" color="teal-7" /></q-item-section><q-item-section>Créer un job</q-item-section></q-item> <q-item clickable v-close-popup @click="openCreateJob"><q-item-section avatar><q-icon name="add_task" color="teal-7" /></q-item-section><q-item-section>Créer un job</q-item-section></q-item>
@ -96,7 +97,6 @@
</q-list> </q-list>
</q-menu> </q-menu>
</q-item> </q-item>
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="indigo" /></q-item-section><q-item-section>Générer l'horaire (semaine)<q-item-label caption>solveur de quarts la semaine affichée</q-item-label></q-item-section><q-item-section side><q-spinner v-if="generating" size="16px" color="indigo" /></q-item-section></q-item>
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)<q-item-label caption>modèle plusieurs techs, N semaines</q-item-label></q-item-section></q-item> <q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)<q-item-label caption>modèle plusieurs techs, N semaines</q-item-label></q-item-section></q-item>
<q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item> <q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
<q-separator /> <q-separator />
@ -105,12 +105,13 @@
<q-item clickable v-close-popup @click="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item> <q-item clickable v-close-popup @click="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item> <q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
<q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item> <q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
<q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="open_in_new" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch (détails & priorités)</q-item-section></q-item>
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item> <q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
</q-list> </q-list>
</q-btn-dropdown> </q-btn-dropdown>
<q-btn v-if="defaultTemplate" dense flat color="warning" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn> <q-btn v-if="defaultTemplate" dense flat color="warning" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn>
<q-separator vertical class="q-mx-xs" /> <q-separator vertical class="q-mx-xs" />
<q-btn unelevated color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip class="bg-grey-9">Répartition automatique des jobs du <b>jour sélectionné</b> (distance · compétence · priorité · taux d'occupation) — revue avant d'appliquer</q-tooltip></q-btn> <q-btn unelevated color="primary" icon="auto_awesome" label="Générer" :loading="generating" @click="doGenerate" />
<q-checkbox v-model="notifySms" label="SMS" dense size="sm"><q-tooltip>Notifier les techs par SMS à la publication</q-tooltip></q-checkbox> <q-checkbox v-model="notifySms" label="SMS" dense size="sm"><q-tooltip>Notifier les techs par SMS à la publication</q-tooltip></q-checkbox>
<q-btn :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" /> <q-btn :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
<q-btn outline color="deep-orange" icon="sync_alt" label="Publier au legacy" @click="openLegacyPush"><q-tooltip class="bg-grey-9">Réassigne les tickets aux techniciens DANS le système legacy (osTicket) selon l'assignation Ops — aperçu avant d'écrire.</q-tooltip></q-btn> <q-btn outline color="deep-orange" icon="sync_alt" label="Publier au legacy" @click="openLegacyPush"><q-tooltip class="bg-grey-9">Réassigne les tickets aux techniciens DANS le système legacy (osTicket) selon l'assignation Ops — aperçu avant d'écrire.</q-tooltip></q-btn>
@ -120,6 +121,7 @@
<!-- Rangée 2 : filtres + légende en popover DESKTOP/tablette uniquement (gt-sm) --> <!-- Rangée 2 : filtres + légende en popover DESKTOP/tablette uniquement (gt-sm) -->
<div class="row items-center q-gutter-sm q-mb-sm gt-sm"> <div class="row items-center q-gutter-sm q-mb-sm gt-sm">
<q-input dense outlined clearable v-model="search" placeholder="Rechercher un technicien…" style="width:230px"><template #prepend><q-icon name="search" /></template></q-input> <q-input dense outlined clearable v-model="search" placeholder="Rechercher un technicien…" style="width:230px"><template #prepend><q-icon name="search" /></template></q-input>
<q-select dense outlined clearable v-model="groupFilter" :options="groupOptions" emit-value map-options label="Équipe" style="width:180px" />
<q-input dense outlined type="number" v-model.number="maxHours" label="Max h/sem" style="width:110px" /> <q-input dense outlined type="number" v-model.number="maxHours" label="Max h/sem" style="width:110px" />
<span class="text-caption text-grey-6">{{ visibleTechs.length }} / {{ techs.length }} techs</span> <span class="text-caption text-grey-6">{{ visibleTechs.length }} / {{ techs.length }} techs</span>
<q-chip v-if="cellClipboard.length" dense size="sm" color="indigo" text-color="white" icon="content_paste">{{ cellClipboard.length }} copié(s)</q-chip> <q-chip v-if="cellClipboard.length" dense size="sm" color="indigo" text-color="white" icon="content_paste">{{ cellClipboard.length }} copié(s)</q-chip>
@ -406,7 +408,6 @@
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)"> <th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div> <div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
<div v-if="visStat(d.iso).hours || estLoad(d.iso)" class="cap-strip" @click.stop> <div v-if="visStat(d.iso).hours || estLoad(d.iso)" class="cap-strip" @click.stop>
<span class="hdr-vbar"><span class="hdr-vfill" :style="{ height: hdrFillH(d.iso) + '%', background: heatColor(hdrRatio(d.iso)) }"></span></span>
<span class="cap-seg load-cell" :class="loadClass(d.iso)">{{ estLoad(d.iso) }}/{{ visStat(d.iso).hours }}h <span class="cap-seg load-cell" :class="loadClass(d.iso)">{{ estLoad(d.iso) }}/{{ visStat(d.iso).hours }}h
<q-tooltip class="bg-grey-9"><b>{{ estLoad(d.iso) }} h estimées</b> (jobs prévus) / <b>{{ visStat(d.iso).hours }} h de shift</b><template v-if="visStat(d.iso).hours"> · {{ Math.round(estLoad(d.iso) / visStat(d.iso).hours * 100) }}%</template><br><span style="opacity:.8">{{ skillFilter.length ? ('compétence(s) : ' + skillFilter.join(', ')) : 'ressources affichées' }} ({{ visibleTechs.length }} tech)</span></q-tooltip> <q-tooltip class="bg-grey-9"><b>{{ estLoad(d.iso) }} h estimées</b> (jobs prévus) / <b>{{ visStat(d.iso).hours }} h de shift</b><template v-if="visStat(d.iso).hours"> · {{ Math.round(estLoad(d.iso) / visStat(d.iso).hours * 100) }}%</template><br><span style="opacity:.8">{{ skillFilter.length ? ('compétence(s) : ' + skillFilter.join(', ')) : 'ressources affichées' }} ({{ visibleTechs.length }} tech)</span></q-tooltip>
</span> </span>
@ -482,9 +483,8 @@
<div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm"> <div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm">
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap"> <div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
<span class="text-caption text-grey-7">Journée :</span> <span class="text-caption text-grey-7">Journée :</span>
<OccupancyStrip :days="routeStripDays" :selected="routesDay" style="flex:1 1 100%;min-width:0" @select="routesDay = $event" /> <q-btn-toggle v-model="routesDay" dense no-caps unelevated toggle-color="indigo" color="grey-3" text-color="grey-8" :options="dayList.slice(0, 7).map(d => ({ label: d.dow + ' ' + d.dnum, value: d.iso }))" />
<q-space /> <q-space />
<q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn>
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span> <span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
</div> </div>
<!-- Chips techs : enlever/ajouter une tournée de la carte d'un clic pastille = nb de jobs, dans la couleur du tech --> <!-- Chips techs : enlever/ajouter une tournée de la carte d'un clic pastille = nb de jobs, dans la couleur du tech -->
@ -495,26 +495,7 @@
<q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }}</q-tooltip> <q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }}</q-tooltip>
</span> </span>
</div> </div>
<!-- Jobs DUS ce jour, NON assignés (mis en évidence) chips secteur filtrent liste+carte · clic = détail · « Suggérer » = dispatch auto --> <RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" />
<div v-if="routeDayUnassigned.length" class="rt-unassigned">
<div class="rt-ua-top">
<span class="rt-ua-lbl"><q-icon name="inbox" size="15px" color="deep-orange-7" /> <b>{{ routeDayUnassignedView.length }}</b> à répartir<span v-if="routeSectorSel.size" class="text-grey-6"> / {{ routeDayUnassigned.length }}</span> :</span>
<template v-if="routeSectors.length > 1">
<button v-for="s in routeSectors" :key="s.sector" type="button" class="rt-sect" :class="{ on: routeSectorSel.has(s.sector) }" @click="toggleRouteSector(s.sector)"><q-icon name="place" size="11px" />{{ s.sector }}<span class="rt-sect-n">{{ s.n }}</span></button>
<button v-if="routeSectorSel.size" type="button" class="rt-sect rt-sect-x" @click="routeSectorSel = new Set()"> tout</button>
</template>
<q-space />
<q-btn dense unelevated no-caps size="sm" color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip>Répartir automatiquement les jobs {{ routeSectorSel.size ? 'du secteur' : 'de ce jour' }}</q-tooltip></q-btn>
</div>
<div class="rt-ua-jobs">
<span v-for="j in routeDayUnassignedView.slice(0, 16)" :key="j.name" class="rt-ua-chip" :style="{ borderLeftColor: prioColor(j.priority) }" @click="openJobDetail(j)">
<q-icon :name="skillSym(j.required_skill || j.service_type)" size="14px" :style="{ color: getTagColor(j.required_skill || j.service_type) }" />{{ j.subject || j.service_type || j.name }}<template v-if="jobCity(j)"> · {{ jobCity(j) }}</template>
<q-tooltip class="bg-grey-9">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template></q-tooltip>
</span>
<span v-if="routeDayUnassignedView.length > 16" class="text-caption text-grey-6 q-ml-xs">+{{ routeDayUnassignedView.length - 16 }} de plus</span>
</div>
</div>
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" :pins="routeUnassignedPins" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" @pin-click="p => openJobDetail(p.job)" />
<div class="suggest-legend q-mt-xs"> <div class="suggest-legend q-mt-xs">
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)"> <span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }} <span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }}
@ -899,7 +880,7 @@
<div class="assign-hdr" @mousedown="panelHeaderDown"> <div class="assign-hdr" @mousedown="panelHeaderDown">
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignTypeFilter.length ? assignJobsFiltered.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space /> <q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignTypeFilter.length ? assignJobsFiltered.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space />
<q-btn v-if="assignNoCoord" flat dense no-caps size="sm" color="warning" icon="wrong_location" :label="String(assignNoCoord)" :loading="assignPanel.locating" class="q-mr-xs" @click="batchLocatePool"><q-tooltip>Localiser les {{ assignNoCoord }} job(s) sans coordonnées : adresse base RQA + GPS (ils apparaissent ensuite sur la carte)</q-tooltip></q-btn> <q-btn v-if="assignNoCoord" flat dense no-caps size="sm" color="warning" icon="wrong_location" :label="String(assignNoCoord)" :loading="assignPanel.locating" class="q-mr-xs" @click="batchLocatePool"><q-tooltip>Localiser les {{ assignNoCoord }} job(s) sans coordonnées : adresse base RQA + GPS (ils apparaissent ensuite sur la carte)</q-tooltip></q-btn>
<q-btn flat dense no-caps size="sm" color="amber-4" icon="auto_awesome" :label="suggestFiltered ? 'Suggérer (' + suggestJobCount + ')' : 'Suggérer'" :loading="suggestDlg.building" class="q-mr-xs" @click="openSuggest"><q-tooltip>Proposer une répartition optimisée (proximité · charge · compétence){{ suggestFiltered ? ' — ' + suggestJobCount + ' job(s) ' + suggestScope : '' }} tu revois et ajustes avant d'appliquer</q-tooltip></q-btn> <q-btn flat dense no-caps size="sm" color="amber-4" icon="auto_awesome" :label="selectedNames.length ? 'Suggérer (' + selectedNames.length + ')' : 'Suggérer'" :loading="suggestDlg.building" class="q-mr-xs" @click="openSuggest"><q-tooltip>Proposer une répartition optimisée (proximité · charge · compétence){{ selectedNames.length ? ' pour la sélection' : '' }} tu revois et ajustes avant d'appliquer</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="map" :color="assignPanel.showMap ? 'amber-4' : 'white'" @click="toggleAssignMap"><q-tooltip>Carte des jobs (regrouper par région)</q-tooltip></q-btn> <q-btn flat dense round size="sm" icon="map" :color="assignPanel.showMap ? 'amber-4' : 'white'" @click="toggleAssignMap"><q-tooltip>Carte des jobs (regrouper par région)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" /> <q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" /> <q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
@ -1039,21 +1020,13 @@
<q-input v-model.number="solverOpts.speedKmh" type="number" dense outlined style="width:86px" label="km/h" :min="20" :max="90"><q-tooltip>Vitesse de repli (nœuds sans coordonnées)</q-tooltip></q-input> <q-input v-model.number="solverOpts.speedKmh" type="number" dense outlined style="width:86px" label="km/h" :min="20" :max="90"><q-tooltip>Vitesse de repli (nœuds sans coordonnées)</q-tooltip></q-input>
<q-input v-model.number="solverOpts.maxSeconds" type="number" dense outlined style="width:92px" label="Calcul s" :min="2" :max="30"><q-tooltip>Budget de calcul par jour</q-tooltip></q-input> <q-input v-model.number="solverOpts.maxSeconds" type="number" dense outlined style="width:92px" label="Calcul s" :min="2" :max="30"><q-tooltip>Budget de calcul par jour</q-tooltip></q-input>
</div> </div>
<!-- FENÊTRE de répartition = les CHIPS de date cochés dans le pool (ou la sélection lasso). Sinon un jour choisi ici. --> <!-- JOUR CIBLÉ (une seule journée) : les jobs en retard / sans date sont tirés sur ce jour. Re-simuler aujourd'hui = OK (non destructif). -->
<div class="row items-center q-mb-sm" style="gap:6px;color:#475569;background:#eef2ff;border-radius:6px;padding:5px 8px"> <div class="row items-center q-mb-sm" style="gap:6px;color:#475569;background:#eef2ff;border-radius:6px;padding:5px 8px">
<q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir :</span> <q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir pour :</span>
<template v-if="suggestFiltered"> <q-btn dense unelevated size="sm" no-caps :color="suggestDay === todayISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === todayISO() ? 'white' : 'grey-8'" label="Aujourd'hui" @click="suggestDay = todayISO()" />
<span class="text-caption text-weight-medium text-indigo-8">{{ suggestJobCount }} job(s) {{ suggestScope }}</span> <q-btn dense unelevated size="sm" no-caps :color="suggestDay === tomorrowISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === tomorrowISO() ? 'white' : 'grey-8'" label="Demain" @click="suggestDay = tomorrowISO()" />
<q-icon name="arrow_forward" size="13px" color="grey-6" /><span class="text-caption text-grey-7">{{ suggestWindow.map(windowDayLabel).join(', ') }}</span> <q-input dense outlined type="date" v-model="suggestDay" style="width:150px" />
<q-space /><span class="text-caption text-grey-6">{{ selectedNames.length ? 'sélection lasso' : 'ajuste les chips sous le pool' }}</span> <q-space /><span class="text-caption text-grey-6">jobs en retard/sans date tirés sur ce jour</span>
</template>
<template v-else>
<span class="text-caption">pour :</span>
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === todayISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === todayISO() ? 'white' : 'grey-8'" label="Aujourd'hui" @click="suggestDay = todayISO()" />
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === tomorrowISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === tomorrowISO() ? 'white' : 'grey-8'" label="Demain" @click="suggestDay = tomorrowISO()" />
<q-input dense outlined type="date" v-model="suggestDay" style="width:150px" />
<q-space /><span class="text-caption text-grey-6">{{ suggestJobCount }} job(s) de ce jour · retards exclus (chip « en retard » pour les inclure)</span>
</template>
</div> </div>
<div class="row items-center q-mb-xs" style="gap:4px"> <div class="row items-center q-mb-xs" style="gap:4px">
<span class="text-caption text-grey-7 q-mr-xs">Rapide :</span> <span class="text-caption text-grey-7 q-mr-xs">Rapide :</span>
@ -1361,16 +1334,6 @@
<div v-if="jobDetail.skill"><q-icon name="construction" size="16px" /> {{ jobDetail.skill }}</div> <div v-if="jobDetail.skill"><q-icon name="construction" size="16px" /> {{ jobDetail.skill }}</div>
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div> <div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
</div> </div>
<!-- Suivi terrain (géofencing GPS) : étapes façon suivi de colis En route Arrivé Reparti, avec l'heure atteinte. -->
<div v-if="jobDetail.geofence && jobDetail.geofence.state" class="jd-geo">
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="my_location" size="18px" class="q-mr-xs" color="teal-7" /> Suivi terrain (GPS)</div>
<div class="jd-geo-track">
<div v-for="s in geoTimeline" :key="s.k" class="jd-geo-step" :class="{ done: s.done, current: s.current }">
<div class="jd-geo-ic"><q-icon :name="s.icon" size="15px" /></div>
<div class="jd-geo-lbl">{{ s.label }}<span v-if="s.at" class="jd-geo-at">{{ s.at }}</span></div>
</div>
</div>
</div>
<div v-if="jobDetail.canTeam" class="jd-team"> <div v-if="jobDetail.canTeam" class="jd-team">
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="group" size="18px" class="q-mr-xs" /> Équipe / renfort<q-space /><q-spinner v-if="jobDetail.teamLoading" size="16px" color="deep-purple" /></div> <div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="group" size="18px" class="q-mr-xs" /> Équipe / renfort<q-space /><q-spinner v-if="jobDetail.teamLoading" size="16px" color="deep-purple" /></div>
<div v-if="jobDetail.team && jobDetail.team.length" class="row items-center q-gutter-xs q-mb-xs"> <div v-if="jobDetail.team && jobDetail.team.length" class="row items-center q-gutter-xs q-mb-xs">
@ -1780,7 +1743,7 @@
<UnifiedCreateModal v-model="createJobOpen" mode="work-order" :context="createJobCtx" <UnifiedCreateModal v-model="createJobOpen" mode="work-order" :context="createJobCtx"
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" /> :technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" />
<!-- « Trouver un créneau » chips compétence (durée par défaut) + adresse choisit tech+date+heure puis pré-remplit la création --> <!-- « Trouver un créneau » chips compétence (durée par défaut) + adresse choisit tech+date+heure puis pré-remplit la création -->
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" @plan-shifts="onPlanShifts" /> <SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" />
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment --> <!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment -->
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" /> <WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
</q-page> </q-page>
@ -1806,7 +1769,6 @@
* 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor * 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor
*/ */
import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router' // deep-link ?day=&skill= (tableau de bord, slot-finder)
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation. // Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined' import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
import { onBeforeRouteLeave, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
@ -1815,7 +1777,7 @@ import * as roster from 'src/api/roster'
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE' import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>) import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur) import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
import { relTime } from 'src/composables/useFormatters' // temps relatif COHÉRENT (même formateur que la Boîte) import { relTime } from 'src/composables/useFormatters' // temps relatif COHÉRENT (même formateur que la Boîte)
import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations
@ -1825,7 +1787,6 @@ import TechSelect from 'src/components/shared/TechSelect.vue'
import SkillSelect from 'src/components/shared/SkillSelect.vue' import SkillSelect from 'src/components/shared/SkillSelect.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) Dispatch déprécié, tout ici import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) Dispatch déprécié, tout ici
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » pré-remplit la création (tech+date+heure+adresse) import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » pré-remplit la création (tech+date+heure+adresse)
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) repris/amélioré depuis Dispatch import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) repris/amélioré depuis Dispatch
@ -1896,9 +1857,6 @@ const estLoadByDay = computed(() => {
}) })
function estLoad (iso) { return estLoadByDay.value[iso] || 0 } 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 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
// Barre verticale d'occupation de l'en-tête desktop (miroir de la bande mobile) : ratio charge estimée / heures de quart.
function hdrRatio (iso) { const cap = visStat(iso).hours || 0, load = estLoad(iso) || 0; return cap > 0 ? load / cap : (load > 0 ? 1 : 0) }
function hdrFillH (iso) { return (estLoad(iso) || 0) > 0 ? Math.max(12, Math.min(100, Math.round(hdrRatio(iso) * 100))) : 0 }
// 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 // 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. // tech×jour (réutilise onJobDragStart/onCellDrop assigne + replanifie à la date de la case). Suit le filtre compétence.
const showDayJobs = ref(false) const showDayJobs = ref(false)
@ -2163,7 +2121,7 @@ function exportGpx (techId) {
window.open(roster.gpxUrl(techId, from, to), '_blank') 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. // 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, geofence: null }) 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 })
// Décode les entités HTML (« d&#039;équipement » « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés). // Décode les entités HTML (« d&#039;équipement » « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null
function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value } function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value }
@ -2171,21 +2129,9 @@ async function openJobDetail (b, t) {
if (!b) return if (!b) return
jdShowTrack.value = false jdShowTrack.value = false
Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || ''), address: deEnt(b.address || ''), skill: b.skill || '', time: (b.start || (b.s != null ? fmtH(b.s) : '')) + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : ''), detail: deEnt(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 }) Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || ''), address: deEnt(b.address || ''), skill: b.skill || '', time: (b.start || (b.s != null ? fmtH(b.s) : '')) + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : ''), detail: deEnt(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 })
// Géofencing (suivi façon colis) : timeline En route Arrivé Reparti, non bloquant.
jobDetail.geofence = null
if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {})
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 (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 } } 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 } }
} }
// Timeline géofencing pour l'affichage (façon suivi de colis) : 4 étapes + heure atteinte.
const GEO_STEPS = [{ k: 'assigned', label: 'Assigné', icon: 'assignment_ind' }, { k: 'en_route', label: 'En route', icon: 'directions_car' }, { k: 'on_site', label: 'Arrivé sur place', icon: 'location_on' }, { k: 'departed', label: 'Reparti', icon: 'check_circle' }]
const geoTimeline = computed(() => {
const gf = jobDetail.geofence; const events = (gf && gf.events) || []
const atOf = (k) => { const e = [...events].reverse().find(x => x.status === k); return e ? new Date(e.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Toronto' }) : null }
const order = { assigned: 0, en_route: 1, on_site: 2, departed: 3 }
const curIdx = order[(gf && gf.state) || 'assigned'] ?? 0
return GEO_STEPS.map((s, i) => ({ ...s, at: s.k === 'assigned' ? null : atOf(s.k), done: i <= curIdx, current: i === curIdx }))
})
// Options du sélecteur d'assistant : tous les techs sauf le lead courant + ceux déjà dans l'équipe. // 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 } })) }) 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 () { async function jdAddAssistant () {
@ -2405,20 +2351,8 @@ const schedGenOpen = ref(false); const schedGenTechs = ref([])
function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech
function openSchedGenBulk () { schedGenTechs.value = (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name })); schedGenOpen.value = true } // LOT : tous les techs visibles function openSchedGenBulk () { schedGenTechs.value = (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name })); schedGenOpen.value = true } // LOT : tous les techs visibles
const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 } const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
async function onScheduleApply ({ schedule, weeks, techIds, mode }) { async function onScheduleApply ({ schedule, weeks, techIds }) {
const targets = (schedGenTechs.value || []).filter(t => (techIds || []).includes(t.id)); if (!targets.length) return const targets = (schedGenTechs.value || []).filter(t => (techIds || []).includes(t.id)); if (!targets.length) return
// RÉCURRENT : le patron devient la SOURCE (weekly_schedule) matérialisation auto (fériés/vacances sautés, manuels préservés)
if (mode === 'recurring') {
const patt = {}; for (const d of schedule) patt[d.dow] = d.on ? { start: d.start, end: d.end } : null
let okT = 0
for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++ } catch (e) {} }
let mat = null; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { err(e) }
try { await loadWeek() } catch (e) {}
$q.notify({ type: okT ? 'positive' : 'warning', icon: 'event_repeat', message: 'Horaire récurrent défini · ' + okT + ' tech' + (mat ? ' → ' + mat.created + ' quart(s) sur ' + weeks + ' sem.' + (mat.skipped_holiday ? ' (' + mat.skipped_holiday + ' férié(s) sauté(s))' : '') : ''), timeout: 4800 })
schedGenTechs.value = []
return
}
// EXCEPTION : override ponctuel (source='manuel', préservé par la matérialisation) sur N semaines dès la semaine affichée
let created = 0, failed = 0; const tplByKey = {} let created = 0, failed = 0; const tplByKey = {}
const base = start.value // lundi de la semaine affichée const base = start.value // lundi de la semaine affichée
for (const t of targets) { for (const t of targets) {
@ -2618,18 +2552,10 @@ const assignDates = computed(() => {
function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] } function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] }
// Depuis l'aperçu « jobs sur la colonne du jour » ouvre le panneau « À assigner » filtré sur ce jour. // Depuis l'aperçu « jobs sur la colonne du jour » ouvre le panneau « À assigner » filtré sur ce jour.
async function openDayInPanel (iso) { await openAssignPanel(); assignSort.value = 'date'; assignDateFilter.value = [iso] } async function openDayInPanel (iso) { await openAssignPanel(); assignSort.value = 'date'; assignDateFilter.value = [iso] }
// Un job correspond-il aux CHIPS de date sélectionnées ? (vide = toutes). '__overdue__' = toutes dates passées.
// Partagé par le filtre d'affichage ET le dispatch auto (« Suggérer n'utilise que les jobs des jours cochés »).
function jobMatchesDateChips (j) {
const df = assignDateFilter.value; if (!df.length) return true
const ds = new Set(df); const t = todayISO()
const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'
return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t)
}
const assignJobsFiltered = computed(() => { const assignJobsFiltered = computed(() => {
let arr = assignPanel.jobs let arr = assignPanel.jobs
const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) } const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) }
if (assignDateFilter.value.length) arr = arr.filter(jobMatchesDateChips) const df = assignDateFilter.value; if (df.length) { const ds = new Set(df); const t = todayISO(); arr = arr.filter(j => { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t) }) } // '__overdue__' = toutes dates passées
return arr return arr
}) })
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) } function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
@ -3248,7 +3174,7 @@ function gotoDispatch (t, dateIso) {
const q = {} const q = {}
if (t) q.tech = t.id if (t) q.tech = t.id
q.date = dateIso || (timelineDays.value[0] && timelineDays.value[0].iso) || start.value q.date = dateIso || (timelineDays.value[0] && timelineDays.value[0].iso) || start.value
router.push({ path: '/planification', query: q }) // page Dispatch retirée reste sur Planification router.push({ path: '/dispatch', query: q })
} }
// Éditeur de JOURNÉE (fenêtre contextuelle ciblée clic sur le progressbar) // Éditeur de JOURNÉE (fenêtre contextuelle ciblée clic sur le progressbar)
// Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job. // Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job.
@ -3779,7 +3705,7 @@ const priorityScores = computed(() => {
function techScore (t) { const v = priorityScores.value[t.id]; return v == null ? 0 : v } function techScore (t) { const v = priorityScores.value[t.id]; return v == null ? 0 : v }
function techRank (t) { if (!skillFilter.value.length) return null; const i = visibleTechs.value.findIndex(x => x.id === t.id); return i >= 0 ? i + 1 : null } function techRank (t) { if (!skillFilter.value.length) return null; const i = visibleTechs.value.findIndex(x => x.id === t.id); return i >= 0 ? i + 1 : null }
const visibleTechs = computed(() => { const visibleTechs = computed(() => {
const q = (search.value || '').trim().toLowerCase() // clearable (X) met search=null null-safe (sinon le computed plante et le champ « ne se vide pas ») const q = search.value.trim().toLowerCase()
const list = techs.value.filter(t => (showHidden.value || !isHidden(t.id)) && (!groupFilter.value || t.group === groupFilter.value) && techHasSkill(t) && (!q || (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q))) const list = techs.value.filter(t => (showHidden.value || !isHidden(t.id)) && (!groupFilter.value || t.group === groupFilter.value) && techHasSkill(t) && (!q || (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q)))
// Filtre par compétence actif on TRIE par priorité (meilleur score d'abord) ; sinon ordre équipe/nom. // Filtre par compétence actif on TRIE par priorité (meilleur score d'abord) ; sinon ordre équipe/nom.
if (skillFilter.value.length) return list.slice().sort((a, b) => techScore(a) - techScore(b) || (a.name || '').localeCompare(b.name || '')) if (skillFilter.value.length) return list.slice().sort((a, b) => techScore(a) - techScore(b) || (a.name || '').localeCompare(b.name || ''))
@ -4006,7 +3932,7 @@ function hasShiftDay (techId, iso) {
// Vue mobile « bande de jours + heat » (téléphone) : charge agrégée par jour + par tech, lecture rapide. // Vue mobile « bande de jours + heat » (téléphone) : charge agrégée par jour + par tech, lecture rapide.
const todayIso = computed(() => (nowET.value && nowET.value.iso) || new Date().toISOString().slice(0, 10)) const todayIso = computed(() => (nowET.value && nowET.value.iso) || new Date().toISOString().slice(0, 10))
// heatColor composables/useHelpers.js (source unique, partagé avec <OccupancyStrip> + tableau de bord) function heatColor (ratio) { const r = +ratio || 0; if (r >= 1) return '#ef4444'; if (r >= 0.75) return '#f59e0b'; if (r >= 0.45) return '#84cc16'; return '#22c55e' }
const _CAP_H = 8 // capacité indicative par tech-jour (h) pour normaliser les barres const _CAP_H = 8 // capacité indicative par tech-jour (h) pour normaliser les barres
const mobileSelIso = ref(null) const mobileSelIso = ref(null)
// Bande de jours mobile = fenêtre LONGUE (4 semaines) carrousel horizontal CONTINU (sans flèches). // Bande de jours mobile = fenêtre LONGUE (4 semaines) carrousel horizontal CONTINU (sans flèches).
@ -4295,7 +4221,8 @@ function jobDur (j) { return (j && (j.est_min ? j.est_min / 60 : Number(j.durati
function buildSuggestion () { function buildSuggestion () {
const useSel = selectedNames.value.length > 0 const useSel = selectedNames.value.length > 0
suggestDlg.fromSel = useSel suggestDlg.fromSel = useSel
const jobs = suggestJobs() // lasso si actif, sinon pool filtré par les chips de date (jours cochés) let jobs = (assignPanel.jobs || []).filter(j => j.status !== 'On Hold')
if (useSel) jobs = jobs.filter(j => selectedJobs[j.name])
const days = (dayList.value || []); const dayIsos = days.map(d => d.iso) const days = (dayList.value || []); const dayIsos = days.map(d => d.iso)
const techs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id]) // seulement les techs choisis (disponibles ce jour) const techs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id]) // seulement les techs choisis (disponibles ce jour)
if (!jobs.length || !dayIsos.length || !techs.length) { suggestDlg.plan = []; return } if (!jobs.length || !dayIsos.length || !techs.length) { suggestDlg.plan = []; return }
@ -4408,50 +4335,19 @@ function buildSuggestion () {
suggestDlg.plan = plan suggestDlg.plan = plan
} }
function openSuggest () { function openSuggest () {
// Jour de base = la sélection de la vue active (strip Tournées, Jour kanban, ou bande mobile) « Suggérer se base sur la journée sélectionnée » suggestDay.value = mobileSelIso.value || kbSelIso.value || todayISO() // jour ciblé = celui sélectionné au tableau (défaut auj.)
suggestDay.value = (boardView.value === 'routes' && routesDay.value) || (boardView.value === 'kanban' && kbSelIso.value) || mobileSelIso.value || routesDay.value || kbSelIso.value || todayISO()
suggestDlg.fromSel = selectedNames.value.length > 0 suggestDlg.fromSel = selectedNames.value.length > 0
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart sur un des jours de la fenêtre (= disponibles) ; sinon tous les visibles. // Étape « disponibilité » d'abord : défaut = techs AVEC un quart CE JOUR (= disponibles) ; sinon tous les visibles.
const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur
const techs = visibleTechs.value || [] const techs = visibleTechs.value || []
const withShift = techs.filter(t => win.some(iso => hasShiftDay(t.id, iso))) const withShift = techs.filter(t => hasShiftDay(t.id, suggestDay.value))
const base = withShift.length ? withShift : techs const base = withShift.length ? withShift : techs
const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel
suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true
} }
const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length) const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length)
// Dispatch auto : la FENÊTRE = les CHIPS de date cochées dans le pool (jours à répartir). Sinon (aucun chip) le jour choisi dans le sélecteur. // Dispatch auto = UNE SEULE journée (celle sélectionnée). Les jobs en retard / sans date sont tirés sur CE jour. Non destructif : on revoit avant d'appliquer (re-simuler aujourd'hui = OK).
// Les chips « en retard » / « Sans date » ne sont pas des jours calendaires les jobs correspondants sont placés AUJOURD'HUI. Non destructif (revue avant d'appliquer).
const suggestDay = ref('') const suggestDay = ref('')
const suggestWindow = computed(() => { const suggestWindow = computed(() => [suggestDay.value || todayISO()])
const f = assignDateFilter.value || []
if (!f.length) return [suggestDay.value || todayISO()] // aucun chip coché sélecteur de jour du dialogue
const days = new Set(f.filter(iso => iso !== '__overdue__' && iso !== 'Sans date')) // jours concrets cochés (auj./futur)
if (f.includes('__overdue__') || f.includes('Sans date') || !days.size) days.add(todayISO()) // en retard / sans date placés aujourd'hui
return [...days].sort()
})
// Jobs que « Suggérer » doit répartir : la sélection lasso si active, sinon « ce que je vois » = le pool filtré
// par les CHIPS de TYPE **et** de DATE (assignJobsFiltered). Ex. décocher « téléphonie » non réparti aux techs terrain.
const suggestJobs = () => {
if (selectedNames.value.length) return (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && selectedJobs[j.name]) // sélection lasso explicite
let jobs = (assignJobsFiltered.value || []).filter(j => j.status !== 'On Hold') // pool tel qu'affiché (type + date)
// Par DÉFAUT (aucun chip de date coché) : ne répartir QUE les jobs DATÉS sur le(s) jour(s) de la fenêtre jamais les
// jobs en retard/sans date/d'autres jours (souvent faits mais pas encore fermés) qui pollueraient la simulation.
// (Pour dispatcher les retards, cocher explicitement le chip « en retard ».)
if (!assignDateFilter.value.length) { const win = new Set(suggestWindow.value); jobs = jobs.filter(j => win.has(j.scheduled_date)) }
return jobs
}
const suggestJobCount = computed(() => suggestJobs().length)
// Y a-t-il un filtre restreignant le dispatch ? (lasso, chips de date ou de type)
const suggestFiltered = computed(() => !!(selectedNames.value.length || assignDateFilter.value.length || assignTypeFilter.value.length))
// Libellé du périmètre : « sélectionnés » (lasso) · « des jours/types cochés » (chips) décrit ce qui limite le jeu de jobs.
const suggestScope = computed(() => {
if (selectedNames.value.length) return 'sélectionnés'
const parts = []
if (assignDateFilter.value.length) parts.push('jours')
if (assignTypeFilter.value.length) parts.push('types')
return parts.length ? ('des ' + parts.join(' + ') + ' cochés') : 'du pool'
})
const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso)) const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso))
const suggestWindowLabel = computed(() => { const suggestWindowLabel = computed(() => {
const w = suggestWindow.value; if (!w.length) return '—' const w = suggestWindow.value; if (!w.length) return '—'
@ -4528,7 +4424,6 @@ async function optimizeSuggestion () {
try { try {
const r = await roster.optimizePlanHub({ const r = await roster.optimizePlanHub({
dates: suggestWindow.value, tech_ids: selTechs.map(t => t.id), dates: suggestWindow.value, tech_ids: selTechs.map(t => t.id),
job_names: suggestJobs().map(j => j.name), // ne répartir QUE les jobs des jours cochés (ou la sélection lasso)
opts: { rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh, max_seconds: solverOpts.maxSeconds, overtime_coef: solverOpts.overtimeCoef }, opts: { rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh, max_seconds: solverOpts.maxSeconds, overtime_coef: solverOpts.overtimeCoef },
dur_overrides: { ...durOverride }, job_windows, dur_overrides: { ...durOverride }, job_windows,
}) })
@ -4702,48 +4597,6 @@ const suggestRoutes = computed(() => {
watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || '' }) watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || '' })
// P3 Onglet « Tournées » : tournées RÉELLES (jobs assignés, occupation) de la journée choisie, 1 couleur/tech, tracés OSRM (RouteMap). // P3 Onglet « Tournées » : tournées RÉELLES (jobs assignés, occupation) de la journée choisie, 1 couleur/tech, tracés OSRM (RouteMap).
const routesDay = ref(null) const routesDay = ref(null)
// Sélecteur de jour (Tournées) = bande d'occupation sur 2 SEMAINES (14 j depuis le lundi affiché) par défaut.
// Données via /roster/capacity (14 j) DÉCOUPLÉ de la fenêtre de charge du grid (7 j) barres réelles même en semaine 2.
const routeCapacity = ref({})
async function loadRouteCapacity () { try { const r = await roster.getCapacity(start.value, 14); routeCapacity.value = r.capacity || {} } catch (e) { routeCapacity.value = {} } }
watch([boardView, start], () => { if (boardView.value === 'routes') loadRouteCapacity() }, { immediate: true })
const routeStripDays = computed(() => {
const out = []
for (let i = 0; i < 14; i++) {
const iso = addDaysISO(start.value, i); const c = routeCapacity.value[iso] || {}
const h = +c.due_h || 0; const cap = +c.cap_h || 0; const dw = dowOf(iso)
out.push({ iso, dow: FR_DOW[dw], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dw === 0 || dw === 6, holiday: isHoliday(iso), h, cap, ratio: cap > 0 ? h / cap : (h > 0 ? 1 : 0), over: !!c.overbooked, today: iso === todayISO(), unassigned: (unassignedByDay.value[iso] || []).length })
}
return out
})
// Jobs DUS le jour sélectionné (Tournées) mais NON assignés mis en évidence + « Suggérer ». Respecte le filtre de compétence (unassignedByDay).
const routeDayUnassigned = computed(() => unassignedByDay.value[routesDay.value] || [])
// Chips SECTEUR (par ville) des non-assignés du jour filtre la liste ET la carte. Reset au changement de jour.
const routeSectorSel = ref(new Set())
watch(routesDay, () => { routeSectorSel.value = new Set() })
const routeSectors = computed(() => { const m = {}; for (const j of routeDayUnassigned.value) { const c = jobCity(j) || 'Sans secteur'; m[c] = (m[c] || 0) + 1 } return Object.entries(m).map(([sector, n]) => ({ sector, n })).sort((a, b) => b.n - a.n) })
function toggleRouteSector (s) { const set = new Set(routeSectorSel.value); if (set.has(s)) set.delete(s); else set.add(s); routeSectorSel.value = set }
const routeDayUnassignedView = computed(() => { const sel = routeSectorSel.value; return sel.size ? routeDayUnassigned.value.filter(j => sel.has(jobCity(j) || 'Sans secteur')) : routeDayUnassigned.value })
const _jlat = j => (j.latitude != null ? +j.latitude : (j.lat != null ? +j.lat : null))
const _jlon = j => (j.longitude != null ? +j.longitude : (j.lon != null ? +j.lon : null))
// Gouttes carte = non-assignés du jour (filtrés secteur) qui ont des coordonnées ; couleur = priorité
const routeUnassignedPins = computed(() => routeDayUnassignedView.value.filter(j => { const la = _jlat(j); const lo = _jlon(j); return la != null && lo != null && isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01 }).map(j => ({ lat: _jlat(j), lon: _jlon(j), subject: j.subject || j.service_type || j.name, name: j.name, color: prioColor(j.priority), city: jobCity(j), job: j })))
// Deep-link (?day=YYYY-MM-DD & ?skill=) + action « planifier des quarts » du slot-finder
const route = useRoute()
function matchSkill (skill) { return skill ? (allSkills.value || []).find(s => String(s).toLowerCase() === String(skill).toLowerCase()) : null }
function focusDay (iso, skill) { // cadre la semaine sur le jour, le sélectionne (grille/mobile/tournées), filtre compétence si connue
if (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) { start.value = mondayISO(iso); mobileSelIso.value = iso; routesDay.value = iso }
const m = matchSkill(skill); if (m) skillFilter.value = [m]
}
// « Aucun créneau » « Planifier des quarts » : ouvre la timeline des techs (filtrée compétence) au bon jour pour créer des quarts.
function onPlanShifts ({ skill, date } = {}) {
showSuggestSlots.value = false
boardView.value = 'grid' // vue timeline = voir/créer les quarts
focusDay(date, skill)
const m = matchSkill(skill)
$q.notify({ type: 'info', icon: 'event_busy', timeout: 4500, message: m ? `Techs « ${m} » — crée un quart ce jour pour ouvrir des créneaux` : (skill ? `Aucun tech n'a « ${skill} » comme compétence — ajoute-la à un tech ou crée un quart` : 'Crée un quart pour ouvrir des créneaux') })
}
const routesMapRef = ref(null) const routesMapRef = ref(null)
const dayRouteMetrics = reactive({}) const dayRouteMetrics = reactive({})
const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic) const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic)
@ -4762,23 +4615,6 @@ const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs
return out return out
}) })
const dayRoutes = computed(() => allDayRoutes.value.filter(r => !hiddenRouteTechs.value.has(r.id))) // ce que la carte affiche const dayRoutes = computed(() => allDayRoutes.value.filter(r => !hiddenRouteTechs.value.has(r.id))) // ce que la carte affiche
// Positions GPS LIVE des techs (onglet Tournées) : polling doux (25 s) tant que l'onglet est ouvert ; couleur = celle de la tournée du tech
const livePosRaw = ref([]) // [{ techId, techName, lat, lon, time, speed }]
const showLivePos = ref(true) // afficher/masquer les positions GPS live sur la carte des tournées
let _livePosTimer = null
async function refreshLivePositions () { try { const r = await roster.techPositions(); livePosRaw.value = (r && r.positions) || [] } catch (e) { /* non bloquant */ } }
function startLivePositions () { if (_livePosTimer) return; refreshLivePositions(); _livePosTimer = setInterval(refreshLivePositions, 25000) }
function stopLivePositions () { if (_livePosTimer) { clearInterval(_livePosTimer); _livePosTimer = null } }
// Positions pour la carte : masque les techs cachés (chips), colore selon leur tournée du jour (sinon gris neutre).
const dayLivePositions = computed(() => {
const colorByTech = {}; for (const r of (allDayRoutes.value || [])) colorByTech[r.id] = r.color
const visIds = new Set((visibleTechs.value || []).map(t => t.id)) // filtre de compétence : seules les positions des techs capables (+ non masqués) sur la carte
return (livePosRaw.value || [])
.filter(p => visIds.has(p.techId) && !hiddenRouteTechs.value.has(p.techId))
.map(p => ({ techId: p.techId, name: p.techName, lat: p.lat, lon: p.lon, time: p.time, speed: p.speed, color: colorByTech[p.techId] || '#455a64' }))
})
watch(boardView, (v) => { if (v === 'routes') startLivePositions(); else stopLivePositions() }, { immediate: true })
watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() }) watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() })
watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true }) watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true })
// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées). // Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées).
@ -5549,8 +5385,8 @@ function onLegacyUpdate (data) {
} }
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } }) 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) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ }) 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(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ 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); stopLivePositions(); if (_kbRO) _kbRO.disconnect() }) 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 }) onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
</script> </script>
@ -5574,10 +5410,7 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; } .loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; } .loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
/* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */ /* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */
.cap-strip { display: flex; gap: 3px; justify-content: center; align-items: center; margin-top: 2px; } .cap-strip { display: flex; gap: 2px; justify-content: center; align-items: center; margin-top: 2px; }
/* Barre verticale d'occupation (miroir de la bande mobile) dans l'en-tête desktop */
.hdr-vbar { width: 7px; height: 22px; background: #eef2f7; border-radius: 4px; overflow: hidden; display: inline-flex; align-items: flex-end; flex: 0 0 auto; }
.hdr-vfill { width: 100%; border-radius: 4px; transition: height .3s; }
.cap-seg { font-size: 9px; font-weight: 700; line-height: 1; min-width: 15px; padding: 2px 2px; border-radius: 3px; text-align: center; cursor: default; } .cap-seg { font-size: 9px; font-weight: 700; line-height: 1; min-width: 15px; padding: 2px 2px; border-radius: 3px; text-align: center; cursor: default; }
.cap-ok { background: #c8e6c9; color: #1b5e20; } .cap-ok { background: #c8e6c9; color: #1b5e20; }
.cap-low { background: #ffe0b2; color: #e65100; } .cap-low { background: #ffe0b2; color: #e65100; }
@ -5673,17 +5506,6 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.rt-chip.off { opacity: 0.55; border-style: dashed; } .rt-chip.off { opacity: 0.55; border-style: dashed; }
.rt-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .rt-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.rt-n { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; border-radius: 9px; padding: 0 4px; color: #fff; font-size: 10px; font-weight: 800; } .rt-n { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; border-radius: 9px; padding: 0 4px; color: #fff; font-size: 10px; font-weight: 800; }
/* Bande « jobs dus non assignés » (vue Tournées) : chips secteur (filtrent liste+carte) + chips job + Suggérer */
.rt-unassigned { margin-bottom: 6px; padding: 6px 8px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
.rt-ua-top { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
.rt-ua-jobs { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; margin-top: 5px; max-height: 70px; overflow-y: auto; }
.rt-ua-lbl { font-size: 12px; color: #9a3412; display: inline-flex; align-items: center; gap: 3px; }
.rt-sect { display: inline-flex; align-items: center; gap: 2px; padding: 2px 8px; border: 1px solid #cbd5e1; border-radius: 12px; background: #fff; font-size: 11.5px; font-weight: 600; color: #475569; cursor: pointer; user-select: none; }
.rt-sect.on { background: #6366f1; border-color: #6366f1; color: #fff; }
.rt-sect-n { font-weight: 800; margin-left: 2px; opacity: .85; }
.rt-sect-x { border-style: dashed; color: #94a3b8; }
.rt-ua-chip { display: inline-flex; align-items: center; gap: 3px; max-width: 230px; padding: 2px 8px 2px 6px; border: 1px solid #e2e8f0; border-left: 3px solid #9e9e9e; border-radius: 6px; background: #fff; font-size: 11.5px; color: #334155; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rt-ua-chip:hover { border-color: #6366f1; }
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; } .jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
.jt-chip:hover { border-color: #6366f1; } .jt-chip:hover { border-color: #6366f1; }
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; } .jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
@ -5823,19 +5645,6 @@ tr.res-hidden .hide-eye { opacity: 1; }
.jd-meta > div { display: flex; align-items: center; gap: 6px; } .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-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-team { margin-top: 12px; padding: 10px; border: 1px solid #e6e2f2; background: #faf9fe; border-radius: 8px; }
/* Suivi terrain (géofencing) : étapes horizontales façon suivi de colis. */
.jd-geo { margin-top: 12px; padding: 10px 8px; border: 1px solid #d6eeeb; background: #f3fbfa; border-radius: 8px; }
.jd-geo-track { display: flex; align-items: flex-start; }
.jd-geo-step { flex: 1; text-align: center; position: relative; }
.jd-geo-step::before { content: ''; position: absolute; top: 13px; left: -50%; width: 100%; height: 2px; background: #cfe3e0; z-index: 0; }
.jd-geo-step:first-child::before { display: none; }
.jd-geo-step.done::before { background: #26a69a; }
.jd-geo-ic { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: #e0e0e0; color: #fff; }
.jd-geo-step.done .jd-geo-ic { background: #26a69a; }
.jd-geo-step.current .jd-geo-ic { box-shadow: 0 0 0 3px rgba(38,166,154,.3); }
.jd-geo-lbl { font-size: 10.5px; color: #607d8b; margin-top: 3px; line-height: 1.25; }
.jd-geo-step.done .jd-geo-lbl { color: #37474f; font-weight: 600; }
.jd-geo-at { display: block; font-size: 10px; color: #26a69a; font-weight: 700; }
.jd-map { width: 100%; height: 240px; border-radius: 8px; overflow: hidden; margin-bottom: 8px; background: #eceff3; } .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; } .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 { cursor: pointer; margin-left: 8px; padding: 0 7px; border-radius: 10px; border: 1px solid #ef6c00; color: #ef6c00; font-weight: 600; white-space: nowrap; }

View File

@ -30,7 +30,7 @@ import { fetchARReport } from 'src/api/reports'
import { formatMoney } from 'src/composables/useFormatters' import { formatMoney } from 'src/composables/useFormatters'
import { exportCsv } from 'src/composables/useCsvExport' import { exportCsv } from 'src/composables/useCsvExport'
import ReportScaffold from 'src/components/shared/ReportScaffold.vue' import ReportScaffold from 'src/components/shared/ReportScaffold.vue'
let Chart = null // chart.js ~90 KB chargé à la demande au 1er rendu de graphique (hors du chunk de la page) import Chart from 'chart.js/auto'
const asOfDate = ref(new Date().toISOString().slice(0, 10)) const asOfDate = ref(new Date().toISOString().slice(0, 10))
const loading = ref(false) const loading = ref(false)
@ -83,10 +83,9 @@ async function loadReport () {
} }
} }
async function renderChart () { function renderChart () {
if (chartInstance) chartInstance.destroy() if (chartInstance) chartInstance.destroy()
if (!chartCanvas.value || !summary.value) return if (!chartCanvas.value || !summary.value) return
if (!Chart) Chart = (await import('chart.js/auto')).default
const s = summary.value const s = summary.value
chartInstance = new Chart(chartCanvas.value, { chartInstance = new Chart(chartCanvas.value, {
type: 'doughnut', type: 'doughnut',

View File

@ -104,7 +104,7 @@ import { fetchRevenueReport } from 'src/api/reports'
import { formatMoney } from 'src/composables/useFormatters' import { formatMoney } from 'src/composables/useFormatters'
import StatCard from 'src/components/shared/StatCard.vue' import StatCard from 'src/components/shared/StatCard.vue'
import DataTable from 'src/components/shared/DataTable.vue' import DataTable from 'src/components/shared/DataTable.vue'
let Chart = null // chart.js ~90 KB chargé à la demande au 1er rendu de graphique (hors du chunk de la page) import Chart from 'chart.js/auto'
const now = new Date() const now = new Date()
const startDate = ref(new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10)) const startDate = ref(new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10))
@ -197,10 +197,9 @@ const CHART_COLORS = [
'#a855f7', '#3b82f6', '#d946ef', '#0ea5e9', '#f43f5e', '#a855f7', '#3b82f6', '#d946ef', '#0ea5e9', '#f43f5e',
] ]
async function renderChart () { function renderChart () {
if (chartInstance) chartInstance.destroy() if (chartInstance) chartInstance.destroy()
if (!chartCanvas.value || !chartAccounts.value.length) return if (!chartCanvas.value || !chartAccounts.value.length) return
if (!Chart) Chart = (await import('chart.js/auto')).default
const months = data.value.months || [] const months = data.value.months || []
const labels = months.map(m => { const labels = months.map(m => {

View File

@ -73,7 +73,7 @@ import { fetchTaxReport } from 'src/api/reports'
import { formatMoney } from 'src/composables/useFormatters' import { formatMoney } from 'src/composables/useFormatters'
import { exportCsv } from 'src/composables/useCsvExport' import { exportCsv } from 'src/composables/useCsvExport'
import ReportScaffold from 'src/components/shared/ReportScaffold.vue' import ReportScaffold from 'src/components/shared/ReportScaffold.vue'
let Chart = null // chart.js ~90 KB chargé à la demande au 1er rendu de graphique (hors du chunk de la page) import Chart from 'chart.js/auto'
const now = new Date() const now = new Date()
const startDate = ref(new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10)) const startDate = ref(new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10))
@ -136,10 +136,9 @@ async function loadReport () {
} }
} }
async function renderChart () { function renderChart () {
if (chartInstance) chartInstance.destroy() if (chartInstance) chartInstance.destroy()
if (!chartCanvas.value || !periods.value.length) return if (!chartCanvas.value || !periods.value.length) return
if (!Chart) Chart = (await import('chart.js/auto')).default
const labels = periods.value.map(p => p.label) const labels = periods.value.map(p => p.label)
chartInstance = new Chart(chartCanvas.value, { chartInstance = new Chart(chartCanvas.value, {
type: 'bar', type: 'bar',

View File

@ -73,13 +73,12 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, defineAsyncComponent } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { listFlowTemplates, getFlowTemplate, updateFlowTemplate } from 'src/api/flow-templates' import { listFlowTemplates, getFlowTemplate, updateFlowTemplate } from 'src/api/flow-templates'
import { HUB_URL } from 'src/config/hub' import { HUB_URL } from 'src/config/hub'
import TaskDependencyGraph from 'src/components/flow-editor/TaskDependencyGraph.vue' import TaskDependencyGraph from 'src/components/flow-editor/TaskDependencyGraph.vue'
// Vue Gantt (hy-vue-gantt, lourd) rendu seulement dans la vue « gantt » chargé à la demande. import TaskGantt from 'src/components/flow-editor/TaskGantt.vue'
const TaskGantt = defineAsyncComponent(() => import('src/components/flow-editor/TaskGantt.vue'))
const $q = useQuasar() const $q = useQuasar()
const view = ref('dag') const view = ref('dag')

View File

@ -47,7 +47,7 @@ const routes = [
{ path: 'theme', component: () => import('src/pages/ThemePage.vue') }, { path: 'theme', component: () => import('src/pages/ThemePage.vue') },
{ path: 'facturation/approbations', component: () => import('src/pages/BillingApprovalsPage.vue') }, { path: 'facturation/approbations', component: () => import('src/pages/BillingApprovalsPage.vue') },
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') }, { path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
{ path: 'dispatch', redirect: '/planification' }, // page Dispatch retirée → redirige (anciens signets) { path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
{ path: 'historique', component: () => import('src/pages/HistoriquePage.vue') }, { path: 'historique', component: () => import('src/pages/HistoriquePage.vue') },
{ path: 'evaluations', component: () => import('src/pages/EvaluationsPage.vue') }, { path: 'evaluations', component: () => import('src/pages/EvaluationsPage.vue') },
{ path: 'planification', component: () => import('src/pages/PlanificationPage.vue') }, { path: 'planification', component: () => import('src/pages/PlanificationPage.vue') },

View File

@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { getLoggedUser, logout, getAuthUsername } from 'src/api/auth' import { getLoggedUser, logout } from 'src/api/auth'
import { usePermissions } from 'src/composables/usePermissions' import { usePermissions } from 'src/composables/usePermissions'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
@ -12,10 +12,10 @@ export const useAuthStore = defineStore('auth', () => {
loading.value = true loading.value = true
try { try {
user.value = await getLoggedUser() user.value = await getLoggedUser()
// Permissions depuis le hub : par courriel, avec REPLI par username Authentik (whoami sans courriel / // Load permissions from targo-hub using the user's email
// courriel non provisionné). Si les deux sont vides → permissions non chargées → App.vue affiche « fail-loud ». if (user.value && user.value !== 'authenticated') {
const email = (user.value && user.value !== 'authenticated') ? user.value : '' await loadPermissions(user.value)
await loadPermissions(email, getAuthUsername()) }
} catch { } catch {
user.value = 'authenticated' user.value = 'authenticated'
} finally { } finally {

View File

@ -59,15 +59,6 @@ async function findUserByEmail (email) {
return user ? { user } : { error: 'User not found: ' + email, status: 404 } return user ? { user } : { error: 'User not found: ' + email, status: 404 }
} }
// Repli : quand le courriel ne correspond à aucun compte OPS (courriel vide côté whoami, ou différent de
// celui provisionné), on retrouve l'utilisateur par son username Authentik (transmis aussi par whoami).
async function findUserByUsername (username) {
const r = await akFetch('/core/users/?search=' + encodeURIComponent(username) + '&page_size=5')
if (r.status !== 200) return { error: 'Authentik user lookup failed', status: 502 }
const user = r.data.results?.find(u => u.username?.toLowerCase() === username.toLowerCase())
return user ? { user } : { error: 'User not found: ' + username, status: 404 }
}
function validatePermissions (body) { function validatePermissions (body) {
const validKeys = new Set(CAPABILITIES.map(c => c.key)) const validKeys = new Set(CAPABILITIES.map(c => c.key))
const result = {} const result = {}
@ -90,12 +81,9 @@ async function handle (req, res, method, path, url) {
if (resource === 'permissions' && method === 'GET') { if (resource === 'permissions' && method === 'GET') {
const email = url.searchParams.get('email') || req.headers['x-authentik-email'] const email = url.searchParams.get('email') || req.headers['x-authentik-email']
const username = url.searchParams.get('username') || req.headers['x-authentik-username'] if (!email) return json(res, 400, { error: 'Missing email parameter' })
if (!email && !username) return json(res, 400, { error: 'Missing email or username parameter' })
// Recherche par courriel, puis REPLI par username (courriel absent/non provisionné). const lookup = await findUserByEmail(email)
let lookup = email ? await findUserByEmail(email) : { error: 'no email', status: 404 }
if (lookup.error && username) lookup = await findUserByUsername(username)
if (lookup.error) return json(res, lookup.status, { error: lookup.error }) if (lookup.error) return json(res, lookup.status, { error: lookup.error })
const { user } = lookup const { user } = lookup

View File

@ -14,17 +14,6 @@ const conversations = new Map()
const pushSubscriptions = new Map() const pushSubscriptions = new Map()
const agentTimers = new Map() const agentTimers = new Map()
// Jeton d'ingestion webhook (courriel/canal) : env sinon fichier — lu UNE seule fois (mémoïsé) au lieu d'un
// fs.readFileSync SYNCHRONE à CHAQUE webhook entrant (bloquait la boucle d'événements). Redémarrage requis pour une rotation.
let _mailIngestToken
function mailIngestToken () {
if (_mailIngestToken === undefined) {
_mailIngestToken = (process.env.MAIL_INGEST_TOKEN || '').trim()
if (!_mailIngestToken) { try { _mailIngestToken = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) { _mailIngestToken = '' } }
}
return _mailIngestToken
}
function loadFromDisk () { function loadFromDisk () {
try { try {
if (!fs.existsSync(CONV_FILE)) return if (!fs.existsSync(CONV_FILE)) return
@ -1263,16 +1252,20 @@ async function handle (req, res, method, p, url) {
// INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik). // INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik).
if (p === '/conversations/email-ingest' && method === 'POST') { if (p === '/conversations/email-ingest' && method === 'POST') {
const fs = require('fs')
const tok = req.headers['x-mail-token'] || '' const tok = req.headers['x-mail-token'] || ''
const want = mailIngestToken() let want = (process.env.MAIL_INGEST_TOKEN || '').trim()
if (!want) { try { want = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) {} }
if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' }) if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' })
try { const b = await parseBody(req); const r = await ingestEmail(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) } try { const b = await parseBody(req); const r = await ingestEmail(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) }
} }
// INGESTION CANAL (web chat nouveau site / 3CX / Facebook) — webhook externe. Protégé par X-Mail-Token (même jeton que l'ingest courriel). // INGESTION CANAL (web chat nouveau site / 3CX / Facebook) — webhook externe. Protégé par X-Mail-Token (même jeton que l'ingest courriel).
if (p === '/conversations/channel-ingest' && method === 'POST') { if (p === '/conversations/channel-ingest' && method === 'POST') {
const fs = require('fs')
const tok = req.headers['x-mail-token'] || '' const tok = req.headers['x-mail-token'] || ''
const want = mailIngestToken() let want = (process.env.MAIL_INGEST_TOKEN || '').trim()
if (!want) { try { want = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) {} }
if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' }) if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' })
try { const b = await parseBody(req); const r = await channelIngest(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) } try { const b = await parseBody(req); const r = await channelIngest(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) }
} }

View File

@ -1,102 +0,0 @@
'use strict'
// Géofencing des jobs (rec C) : compare la position GPS live de chaque tech (Traccar) à la localisation de SES jobs
// du jour → détecte les transitions EN ROUTE → ARRIVÉ (sur place) → REPARTI, et les journalise sur le job (timeline
// façon « suivi de colis »). Le statut « live » du job est DÉRIVÉ de ce journal (compute-on-read, aucune écriture ERPNext).
// Persistance : data/geofence.json (par job). Scheduler doux (~90 s) tant que le hub tourne.
const fs = require('fs')
const path = require('path')
const { log } = require('./helpers')
const erp = require('./erp')
const traccar = require('./traccar')
const STORE = path.join(__dirname, '..', 'data', 'geofence.json')
const RADIUS_M = Number(process.env.GEOFENCE_RADIUS_M) || 150 // entrer à < 150 m = candidat « sur place »
const EXIT_M = Number(process.env.GEOFENCE_EXIT_M) || 230 // sortir à > 230 m (hystérésis anti-jitter GPS)
const DWELL_MIN = Number(process.env.GEOFENCE_DWELL_MIN) || 3 // rester ≥ 3 min dans le rayon → « Arrivé »
const SCAN_SEC = Number(process.env.GEOFENCE_SCAN_SEC) || 90
let _store = null
function store () { if (_store) return _store; try { _store = JSON.parse(fs.readFileSync(STORE, 'utf8')) } catch { _store = {} } return _store }
function persist () { try { fs.writeFileSync(STORE, JSON.stringify(store())) } catch (e) { log('geofence persist err: ' + e.message) } }
function haversineM (aLat, aLon, bLat, bLon) {
const R = 6371000, toRad = d => d * Math.PI / 180
const dLat = toRad(bLat - aLat), dLon = toRad(bLon - aLon)
const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(s))
}
const todayISO = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
// Jobs assignés AUJOURD'HUI, avec tech + coordonnées valides.
async function todayAssignedJobs () {
const rows = await erp.list('Dispatch Job', {
filters: [['scheduled_date', '=', todayISO()], ['status', 'in', ['open', 'assigned', 'On Hold']]],
fields: ['name', 'assigned_tech', 'latitude', 'longitude', 'subject', 'customer_name', 'status'],
limit: 400,
})
return (rows || []).filter(j => j.assigned_tech && j.latitude != null && j.longitude != null &&
isFinite(+j.latitude) && isFinite(+j.longitude) && Math.abs(+j.latitude) > 0.01)
}
// Position GPS live par technician_id (appareil Traccar associé).
async function techPositionsById () {
const techs = await erp.list('Dispatch Technician', {
filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'traccar_device_id'], limit: 200,
})
const withDev = (techs || []).filter(t => t.traccar_device_id)
const ids = [...new Set(withDev.map(t => parseInt(t.traccar_device_id)).filter(Boolean))]
if (!ids.length) return {}
let positions = []
try { positions = await traccar.getPositions(ids) } catch (e) { return {} }
const byDev = {}; for (const p of positions) if (p && p.deviceId != null) byDev[p.deviceId] = p
const out = {}
for (const t of withDev) { const p = byDev[parseInt(t.traccar_device_id)]; if (p && p.latitude != null) out[t.technician_id] = { lat: p.latitude, lon: p.longitude, time: p.fixTime || p.deviceTime || p.serverTime || null, speed: p.speed || 0 } }
return out
}
// UN passage : met à jour la timeline de chaque job selon la distance tech↔job. Idempotent (n'ajoute un événement qu'à un CHANGEMENT d'état).
async function runScan ({ dryRun = false } = {}) {
let jobs, pos
try { [jobs, pos] = await Promise.all([todayAssignedJobs(), techPositionsById()]) }
catch (e) { return { ok: false, error: e.message } }
const s = store(); const now = new Date().toISOString(); const nowMs = Date.parse(now)
let arrived = 0, departed = 0, enroute = 0; const changes = []
for (const j of jobs) {
const rec = s[j.name] || { state: 'assigned', events: [] }
const p = pos[j.assigned_tech]
if (p) {
const dist = haversineM(p.lat, p.lon, +j.latitude, +j.longitude)
if (rec.state === 'on_site') {
if (dist > EXIT_M) { rec.state = 'departed'; rec.events.push({ status: 'departed', at: now, dist: Math.round(dist) }); departed++; changes.push({ job: j.name, to: 'departed' }) }
} else if (rec.state !== 'departed') { // assigned / en_route → détecte l'arrivée (séjour) et le départ vers le site
if (dist <= RADIUS_M) {
if (!rec.enterMs) rec.enterMs = nowMs
if ((nowMs - rec.enterMs) / 60000 >= DWELL_MIN) { rec.state = 'on_site'; rec.enterMs = null; rec.events.push({ status: 'on_site', at: now, dist: Math.round(dist) }); arrived++; changes.push({ job: j.name, to: 'on_site' }) }
} else {
rec.enterMs = null
if (rec.state === 'assigned' && (p.speed > 3 || dist < 3000)) { rec.state = 'en_route'; rec.events.push({ status: 'en_route', at: now, dist: Math.round(dist) }); enroute++ }
}
}
}
rec.tech = j.assigned_tech; rec.subject = j.subject || ''; s[j.name] = rec
}
if (!dryRun && (arrived || departed || enroute)) persist()
return { ok: true, dryRun, jobs: jobs.length, positions: Object.keys(pos).length, arrived, departed, enroute, changes }
}
// Timeline d'un job (UI) : { state, events:[{status,at,dist}] }. state ∈ null|assigned|en_route|on_site|departed.
function timeline (jobName) { const e = store()[jobName]; return e ? { state: e.state, events: (e.events || []) } : { state: null, events: [] } }
// États live pour une LISTE de jobs (badges sur le board/carte) : { jobName: state }.
function statesFor (names) { const s = store(); const out = {}; for (const n of (names || [])) { const e = s[n]; if (e) out[n] = e.state } return out }
let _timer = null
function startScan () {
if (String(process.env.GEOFENCE_SCAN || 'on').toLowerCase() === 'off') { log('geofence scan DÉSACTIVÉ (GEOFENCE_SCAN=off)'); return }
if (_timer) return
const tick = () => runScan().then(r => { if (r && (r.arrived || r.departed)) log(`geofence: +${r.arrived} arrivé, +${r.departed} reparti (${r.jobs} jobs, ${r.positions} pos)`) }).catch(e => log('geofence scan err: ' + e.message))
_timer = setInterval(tick, SCAN_SEC * 1000)
setTimeout(tick, 8000) // amorçage après le boot
log(`geofence scan activé (${SCAN_SEC}s · rayon ${RADIUS_M}m · sortie ${EXIT_M}m · séjour ${DWELL_MIN}min)`)
}
module.exports = { startScan, runScan, timeline, statesFor }

View File

@ -1120,18 +1120,14 @@ function startSync () {
if (!/^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || ''))) { log('legacy-dispatch-sync: récurrence désactivée (poser LEGACY_DISPATCH_SYNC=on pour activer)'); return } if (!/^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || ''))) { log('legacy-dispatch-sync: récurrence désactivée (poser LEGACY_DISPATCH_SYNC=on pour activer)'); return }
if (!mysql) { log('legacy-dispatch-sync: mysql2 absent → pont inactif'); return } if (!mysql) { log('legacy-dispatch-sync: mysql2 absent → pont inactif'); return }
const minutes = Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15 const minutes = Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15
// Garde anti-chevauchement : si un import dépasse l'intervalle (beaucoup de tickets), NE PAS relancer par-dessus const tick = () => sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message))
// (sinon doublons Dispatch Job + contention ERPNext). On saute le tick tant que le précédent tourne.
let _syncInFlight = false
const tick = () => { if (_syncInFlight) { log('legacy-dispatch-sync: passage précédent encore en cours → tick sauté'); return } _syncInFlight = true; sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message)).finally(() => { _syncInFlight = false }) }
// 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`. // 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`.
setTimeout(tick, 90 * 1000) setTimeout(tick, 90 * 1000)
_timer = setInterval(tick, minutes * 60 * 1000) _timer = setInterval(tick, minutes * 60 * 1000)
log(`legacy-dispatch-sync: pont actif (toutes les ${minutes} min, staff ${TARGO_TECH_STAFF_ID})`) log(`legacy-dispatch-sync: pont actif (toutes les ${minutes} min, staff ${TARGO_TECH_STAFF_ID})`)
// VEILLE temps-réel (poll léger → SSE) : cadence courte, indépendante de l'import. // VEILLE temps-réel (poll léger → SSE) : cadence courte, indépendante de l'import.
const watchMin = Number(process.env.LEGACY_DISPATCH_WATCH_MIN) || 3 const watchMin = Number(process.env.LEGACY_DISPATCH_WATCH_MIN) || 3
let _watchInFlight = false const wtick = () => watchLegacy().catch(e => log('legacy-watch error:', e.message))
const wtick = () => { if (_watchInFlight) return; _watchInFlight = true; watchLegacy().catch(e => log('legacy-watch error:', e.message)).finally(() => { _watchInFlight = false }) }
setTimeout(wtick, 60 * 1000) // amorçage du snapshot ~1 min après le boot setTimeout(wtick, 60 * 1000) // amorçage du snapshot ~1 min après le boot
_watchTimer = setInterval(wtick, watchMin * 60 * 1000) _watchTimer = setInterval(wtick, watchMin * 60 * 1000)
log(`legacy-watch: veille active (toutes les ${watchMin} min → SSE topic 'dispatch')`) log(`legacy-watch: veille active (toutes les ${watchMin} min → SSE topic 'dispatch')`)

View File

@ -35,7 +35,6 @@ const erp = require('./erp')
const cfg = require('./config') const cfg = require('./config')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const holidaysQc = require('./holidays-qc') // fériés QC déterministes — exception AUTO de la matérialisation d'horaire
const POLICY_FILE = path.join(__dirname, '..', 'data', 'dispatch-policy.json') const POLICY_FILE = path.join(__dirname, '..', 'data', 'dispatch-policy.json')
// Niveau requis PAR JOB, persistant. (L'API Custom Field d'ERPNext v16 échoue sur ce doctype custom → store hub durable.) // Niveau requis PAR JOB, persistant. (L'API Custom Field d'ERPNext v16 échoue sur ce doctype custom → store hub durable.)
const JOB_LEVELS_FILE = path.join(__dirname, '..', 'data', 'job-levels.json') const JOB_LEVELS_FILE = path.join(__dirname, '..', 'data', 'job-levels.json')
@ -165,7 +164,6 @@ async function optimizePlan (body) {
const durOv = body.dur_overrides || {} const durOv = body.dur_overrides || {}
const winOv = body.job_windows || {} // {jobName:{tw_start_min,tw_end_min}} (AM/PM) const winOv = body.job_windows || {} // {jobName:{tw_start_min,tw_end_min}} (AM/PM)
const wantTech = Array.isArray(body.tech_ids) && body.tech_ids.length ? new Set(body.tech_ids) : null const wantTech = Array.isArray(body.tech_ids) && body.tech_ids.length ? new Set(body.tech_ids) : null
const wantJobs = Array.isArray(body.job_names) && body.job_names.length ? new Set(body.job_names) : null // ne répartir QUE ces jobs (jours cochés / sélection UI)
const [pool, techsAll, templates, assignments, occ, absences] = await Promise.all([ const [pool, techsAll, templates, assignments, occ, absences] = await Promise.all([
buildUnassigned(), fetchTechnicians(), fetchTemplates(), fetchAssignments(lo, span), occupancyByTechDay(lo, span), absencesByTechDay(lo, span), buildUnassigned(), fetchTechnicians(), fetchTemplates(), fetchAssignments(lo, span), occupancyByTechDay(lo, span), absencesByTechDay(lo, span),
@ -198,7 +196,6 @@ async function optimizePlan (body) {
// 🖥 Les jobs SANS DÉPLACEMENT (remote) sont EXCLUS des tournées : aucun arrêt, aucun épinglage d'adresse — à faire du bureau. // 🖥 Les jobs SANS DÉPLACEMENT (remote) sont EXCLUS des tournées : aucun arrêt, aucun épinglage d'adresse — à faire du bureau.
const dset = new Set(dates); const byDay = {}; const skipped = []; const remote = [] const dset = new Set(dates); const byDay = {}; const skipped = []; const remote = []
for (const j of pool) { for (const j of pool) {
if (wantJobs && !wantJobs.has(j.name)) continue // restreint aux jobs demandés (chips de date / sélection)
const sched = (j.scheduled_date && /^\d{4}-\d{2}-\d{2}$/.test(String(j.scheduled_date))) ? j.scheduled_date : null const sched = (j.scheduled_date && /^\d{4}-\d{2}-\d{2}$/.test(String(j.scheduled_date))) ? j.scheduled_date : null
if (sched && sched > hi) { skipped.push(j.name); continue } if (sched && sched > hi) { skipped.push(j.name); continue }
const iso = (sched && dset.has(sched)) ? sched : dates[0] const iso = (sched && dset.has(sched)) ? sched : dates[0]
@ -301,23 +298,12 @@ async function retryWrite (fn, tries = 5) {
} }
// ── Lecture des techniciens humains + compétences + indisponibilités ──────── // ── Lecture des techniciens humains + compétences + indisponibilités ────────
// Cache court de la liste des techs : appelée souvent (endpoint /roster/technicians, generate, materialize, tech-positions
// polling 25 s…) pour des données quasi statiques. TTL 30 s → coupe les hits ERPNext répétés. Staleness acceptable
// (les éditions inline sont optimistes côté UI, pas de re-fetch immédiat). invalidateTechCache() dispo après écriture.
let _techCache = { ts: 0, data: null }
function invalidateTechCache () { _techCache.ts = 0 }
async function fetchTechnicians () { async function fetchTechnicians () {
if (_techCache.data && (nowMs() - _techCache.ts) < 30000) return _techCache.data
const data = await _fetchTechniciansRaw()
_techCache = { ts: nowMs(), data }
return data
}
async function _fetchTechniciansRaw () {
const rows = await erp.list('Dispatch Technician', { const rows = await erp.list('Dispatch Technician', {
filters: [['resource_type', '=', 'human']], filters: [['resource_type', '=', 'human']],
fields: ['name', 'technician_id', 'full_name', 'status', 'color_hex', 'tech_group', 'efficiency', 'skills', fields: ['name', 'technician_id', 'full_name', 'status', 'color_hex', 'tech_group', 'efficiency', 'skills',
'cost_salary_h', 'cost_charges_pct', 'cost_other_h', 'traccar_device_id', 'cost_salary_h', 'cost_charges_pct', 'cost_other_h', 'traccar_device_id',
'absence_from', 'absence_until', 'employee', 'phone', '_user_tags', 'skill_levels', 'skill_eff', 'weekly_schedule'], 'absence_from', 'absence_until', 'employee', 'phone', '_user_tags', 'skill_levels', 'skill_eff'],
limit: 500, limit: 500,
}) })
return rows.map(t => ({ return rows.map(t => ({
@ -339,7 +325,6 @@ async function _fetchTechniciansRaw () {
skill_eff: (() => { try { return JSON.parse(t.skill_eff || '{}') } catch { return {} } })(), // {compétence: facteur d'efficacité (vitesse)} skill_eff: (() => { try { return JSON.parse(t.skill_eff || '{}') } catch { return {} } })(), // {compétence: facteur d'efficacité (vitesse)}
absence_from: t.absence_from, absence_from: t.absence_from,
absence_until: t.absence_until, absence_until: t.absence_until,
weekly_schedule: (() => { try { return JSON.parse(t.weekly_schedule || 'null') } catch { return null } })(), // patron récurrent {mon:{start,end}|null,…} — source des quarts matérialisés (hybride)
})) }))
} }
@ -404,60 +389,6 @@ async function fetchAssignments (start, days) {
})) }))
} }
// ── HYBRIDE : matérialisation des quarts à partir du PATRON récurrent (weekly_schedule) de chaque tech ──
// Le patron est la SOURCE ; on génère des Shift Assignment (source='pattern') sur l'horizon. Exceptions AUTO :
// fériés (holidays-qc) + vacances (Tech Availability) → pas de quart (et retrait d'un quart pattern existant).
// PRÉSERVE les quarts manuels (source≠'pattern') = overrides (ex. semaine de nuit). Idempotent (re-lançable / cron).
const DOW_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
const _timeH = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
const _timeStr = (s) => (String(s || '').length === 5 ? s + ':00' : String(s || ''))
async function ensureShiftTemplate (start, end, cache) { // start/end 'HH:MM' → nom de doc Shift Template (créé si absent)
const key = start + '-' + end; if (cache[key]) return cache[key]
const st = _timeStr(start), et = _timeStr(end)
try { const rows = await erp.list('Shift Template', { filters: [['start_time', '=', st], ['end_time', '=', et], ['on_call', '=', 0]], fields: ['name'], limit: 1 }); if (rows && rows[0]) { cache[key] = rows[0].name; return rows[0].name } } catch (e) {}
const hours = Math.round((_timeH(end) - _timeH(start)) * 100) / 100
const r = await retryWrite(() => erp.create('Shift Template', { template_name: start + 'h' + end + 'h', start_time: st, end_time: et, hours, on_call: 0, default_required: 1, color: '#1976d2' }))
const name = (r && (r.name || (r.data && r.data.name))) || null
if (name) cache[key] = name
return name
}
async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } = {}) {
const weeksN = Math.max(1, Math.min(12, Number(weeks) || 4))
const start = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
const days = weeksN * 7
const dates = rangeDates(start, days)
const [techsAll, existing, absences] = await Promise.all([fetchTechnicians(), fetchAssignments(start, days), absencesByTechDay(start, days)])
const techs = techsAll.filter(t => t.status !== PAUSE_STATUS && t.weekly_schedule && (!techId || t.id === techId))
const rowsByKey = {}; for (const a of existing) { (rowsByKey[a.tech + '|' + a.date] = rowsByKey[a.tech + '|' + a.date] || []).push(a) }
const tplCache = {}
let created = 0, updated = 0, deleted = 0, keptManual = 0, skipHoliday = 0, skipVacation = 0
for (const t of techs) {
const sched = t.weekly_schedule || {}
for (const iso of dates) {
const day = sched[DOW_KEYS[new Date(iso + 'T12:00:00Z').getUTCDay()]] // {start,end} ou null/absent
const key = t.id + '|' + iso; const rows = rowsByKey[key] || []
if (rows.some(r => r.source !== 'pattern')) { keptManual++; continue } // override manuel présent → intouchable
const pat = rows.filter(r => r.source === 'pattern')
const holiday = holidaysQc.isHoliday(iso); const vacation = !!absences[key]
const wants = !!(day && day.start && day.end) && !holiday && !vacation
if (!wants) { // férié / vacances / jour OFF du patron → retirer le quart pattern
if (holiday) skipHoliday++; else if (vacation) skipVacation++
for (const r of pat) { if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } }
continue
}
const tplName = await ensureShiftTemplate(day.start, day.end, tplCache); if (!tplName) continue
if (pat.some(r => r.shift === tplName)) continue // déjà bon (idempotent)
const hours = Math.round((_timeH(day.end) - _timeH(day.start)) * 100) / 100
if (pat.length) { // patron changé → mettre à jour le quart pattern (+ nettoyer doublons)
if (dryRun) updated++; else { const rr = await erp.update('Shift Assignment', pat[0].name, { shift_template: tplName, hours }); if (rr && rr.ok) updated++ }
for (const extra of pat.slice(1)) { if (!dryRun) await erp.remove('Shift Assignment', extra.name) }
} else if (dryRun) created++
else { const rr = await retryWrite(() => erp.create('Shift Assignment', { technician: t.id, technician_name: t.name, assignment_date: iso, shift_template: tplName, hours, status: 'Publié', source: 'pattern' })); if (rr && rr.ok) created++ }
}
}
return { ok: true, dryRun, weeks: weeksN, techs: techs.length, created, updated, deleted, kept_manual: keptManual, skipped_holiday: skipHoliday, skipped_vacation: skipVacation }
}
// ── Construit le payload du solveur + l'appelle ───────────────────────────── // ── Construit le payload du solveur + l'appelle ─────────────────────────────
async function generate (start, days, weights) { async function generate (start, days, weights) {
const dateList = rangeDates(start, days) const dateList = rangeDates(start, days)
@ -1791,7 +1722,7 @@ async function handle (req, res, method, path, url) {
const patch = { skills: (b.skills || '').trim() } const patch = { skills: (b.skills || '').trim() }
if (b.skill_levels !== undefined) patch.skill_levels = typeof b.skill_levels === 'string' ? b.skill_levels : JSON.stringify(b.skill_levels || {}) // niveaux 15 par compétence (JSON) if (b.skill_levels !== undefined) patch.skill_levels = typeof b.skill_levels === 'string' ? b.skill_levels : JSON.stringify(b.skill_levels || {}) // niveaux 15 par compétence (JSON)
if (b.skill_eff !== undefined) patch.skill_eff = typeof b.skill_eff === 'string' ? b.skill_eff : JSON.stringify(b.skill_eff || {}) // efficacité (facteur vitesse) PAR compétence (JSON) if (b.skill_eff !== undefined) patch.skill_eff = typeof b.skill_eff === 'string' ? b.skill_eff : JSON.stringify(b.skill_eff || {}) // efficacité (facteur vitesse) PAR compétence (JSON)
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)); if (r && r.ok) invalidateTechCache() const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId }) return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
} }
const mCost = path.match(/^\/roster\/technician\/(.+)\/cost$/) const mCost = path.match(/^\/roster\/technician\/(.+)\/cost$/)
@ -1799,7 +1730,7 @@ async function handle (req, res, method, path, url) {
const techId = decodeURIComponent(mCost[1]); const b = await parseBody(req) const techId = decodeURIComponent(mCost[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId) const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId }) if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { cost_salary_h: Number(b.salary) || 0, cost_charges_pct: Number(b.charges) || 0, cost_other_h: Number(b.other) || 0 })); if (r && r.ok) invalidateTechCache() const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { cost_salary_h: Number(b.salary) || 0, cost_charges_pct: Number(b.charges) || 0, cost_other_h: Number(b.other) || 0 }))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId }) return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
} }
const mEff = path.match(/^\/roster\/technician\/(.+)\/efficiency$/) const mEff = path.match(/^\/roster\/technician\/(.+)\/efficiency$/)
@ -1807,7 +1738,7 @@ async function handle (req, res, method, path, url) {
const techId = decodeURIComponent(mEff[1]); const b = await parseBody(req) const techId = decodeURIComponent(mEff[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId) const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId }) if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { efficiency: Number(b.efficiency) || 1 })); if (r && r.ok) invalidateTechCache() const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { efficiency: Number(b.efficiency) || 1 }))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, efficiency: Number(b.efficiency) || 1 }) return json(res, r.ok ? 200 : 500, { ...r, technician: techId, efficiency: Number(b.efficiency) || 1 })
} }
const mPause = path.match(/^\/roster\/technician\/(.+)\/pause$/) const mPause = path.match(/^\/roster\/technician\/(.+)\/pause$/)
@ -1819,39 +1750,9 @@ async function handle (req, res, method, path, url) {
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId }) if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const patch = { status: b.paused ? PAUSE_STATUS : AVAIL_STATUS } const patch = { status: b.paused ? PAUSE_STATUS : AVAIL_STATUS }
if (b.paused && b.reason) patch.absence_reason = b.reason if (b.paused && b.reason) patch.absence_reason = b.reason
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)); if (r && r.ok) invalidateTechCache() const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status }) return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status })
} }
// Positions GPS LIVE de TOUS les techs ayant un appareil Traccar → marqueurs « live » sur la carte des tournées
// (rafraîchi périodiquement côté client). Léger : une requête position par appareil (getPositions parallèle).
if (path === '/roster/tech-positions' && method === 'GET') {
const techs = (await fetchTechnicians()).filter(t => t.traccar_device_id)
if (!techs.length) return json(res, 200, { positions: [] })
const { getPositions } = require('./traccar')
const ids = [...new Set(techs.map(t => parseInt(t.traccar_device_id)).filter(Boolean))]
let positions = []
try { positions = await getPositions(ids) } catch (e) { return json(res, 200, { positions: [], error: e.message }) }
const byDev = {}; for (const p of positions) if (p && p.deviceId != null) byDev[p.deviceId] = p
const out = []
for (const t of techs) {
const p = byDev[parseInt(t.traccar_device_id)]
if (p && p.latitude != null) out.push({ techId: t.id, techName: t.name, lat: p.latitude, lon: p.longitude, time: p.fixTime || p.deviceTime || p.serverTime || null, speed: p.speed || 0 })
}
return json(res, 200, { positions: out })
}
// Géofencing : timeline d'un job (En route → Arrivé → Reparti, façon suivi de colis).
const mGf = path.match(/^\/roster\/job\/(.+)\/geofence$/)
if (mGf && method === 'GET') { return json(res, 200, require('./geofence').timeline(decodeURIComponent(mGf[1]))) }
// Géofencing : états live pour une LISTE de jobs (badges board/carte). ?jobs=a,b,c
if (path === '/roster/geofence-states' && method === 'GET') {
const u = new URL(req.url, 'http://localhost'); const names = (u.searchParams.get('jobs') || '').split(',').map(x => x.trim()).filter(Boolean)
return json(res, 200, { states: require('./geofence').statesFor(names) })
}
// Géofencing : forcer un passage (diagnostic/test). ?dry=1 = aperçu sans écrire.
if (path === '/roster/geofence-scan' && method === 'GET') {
const u = new URL(req.url, 'http://localhost')
return json(res, 200, await require('./geofence').runScan({ dryRun: u.searchParams.get('dry') === '1' }))
}
// Associer / changer (ou retirer) l'appareil Traccar d'un tech (GPS live + tracé) — éditable INLINE depuis Planif. // Associer / changer (ou retirer) l'appareil Traccar d'un tech (GPS live + tracé) — éditable INLINE depuis Planif.
// device_id = id numérique Traccar (ou '' pour dissocier). // device_id = id numérique Traccar (ou '' pour dissocier).
const mTrk = path.match(/^\/roster\/technician\/(.+)\/traccar-device$/) const mTrk = path.match(/^\/roster\/technician\/(.+)\/traccar-device$/)
@ -1860,23 +1761,9 @@ async function handle (req, res, method, path, url) {
const techName = await resolveTechName(techId) const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId }) if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const deviceId = (b.device_id == null || b.device_id === '') ? '' : String(b.device_id) const deviceId = (b.device_id == null || b.device_id === '') ? '' : String(b.device_id)
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { traccar_device_id: deviceId })); if (r && r.ok) invalidateTechCache() const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { traccar_device_id: deviceId }))
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, traccar_device_id: deviceId }) return json(res, r.ok ? 200 : 500, { ...r, technician: techId, traccar_device_id: deviceId })
} }
// Patron d'horaire RÉCURRENT (weekly_schedule) — source des quarts matérialisés (hybride). body.schedule = {mon:{start,end}|null,…}
const mSched = path.match(/^\/roster\/technician\/(.+)\/weekly-schedule$/)
if (mSched && method === 'POST') {
const techId = decodeURIComponent(mSched[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId); if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const sched = (b.schedule && typeof b.schedule === 'object') ? b.schedule : null
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { weekly_schedule: sched ? JSON.stringify(sched) : '' })); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
}
// HYBRIDE : matérialise les quarts depuis les patrons (fériés/vacances sautés, manuels préservés). GET=aperçu / POST=applique. ?weeks= ?tech=
if (path === '/roster/materialize-shifts' && (method === 'GET' || method === 'POST')) {
const u = new URL(req.url, 'http://localhost'); const b = method === 'POST' ? await parseBody(req) : {}
return json(res, 200, await materializeShifts({ dryRun: method === 'GET', weeks: Number(b.weeks || u.searchParams.get('weeks')) || 4, techId: b.tech || u.searchParams.get('tech') || null }))
}
// Supprimer une assignation publiée // Supprimer une assignation publiée
const mDelA = path.match(/^\/roster\/assignment\/(.+)$/) const mDelA = path.match(/^\/roster\/assignment\/(.+)$/)
if (mDelA && method === 'DELETE') { if (mDelA && method === 'DELETE') {

View File

@ -1,144 +0,0 @@
// Import du calendrier de vacances (.xlsx Google Sheets export) → absences (Tech Availability) + brouillon.
// AUCUNE dépendance npm : lit le ZIP xlsx via zlib.inflateRawSync (comme gmail.js évite googleapis).
// Voir memory project_vacation_import. Sheet = couleurs cyan/jaune/magenta = Choix1/Choix2/Approuvé.
const zlib = require('zlib')
// ── ZIP minimal : Buffer → { nom: Buffer } (via central directory, méthodes 0=stored / 8=deflate) ──
function unzip (buf) {
const files = {}
let eocd = -1
for (let i = buf.length - 22; i >= 0 && i >= buf.length - 22 - 65536; i--) { if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break } }
if (eocd < 0) throw new Error('xlsx: ZIP EOCD introuvable (fichier corrompu ?)')
const count = buf.readUInt16LE(eocd + 10)
let off = buf.readUInt32LE(eocd + 16)
for (let n = 0; n < count; n++) {
if (buf.readUInt32LE(off) !== 0x02014b50) break
const method = buf.readUInt16LE(off + 10)
const compSize = buf.readUInt32LE(off + 20)
const nameLen = buf.readUInt16LE(off + 28)
const extraLen = buf.readUInt16LE(off + 30)
const commentLen = buf.readUInt16LE(off + 32)
const localOff = buf.readUInt32LE(off + 42)
const name = buf.toString('utf8', off + 46, off + 46 + nameLen)
const lNameLen = buf.readUInt16LE(localOff + 26)
const lExtraLen = buf.readUInt16LE(localOff + 28)
const start = localOff + 30 + lNameLen + lExtraLen
const raw = buf.slice(start, start + compSize)
try { files[name] = method === 0 ? raw : zlib.inflateRawSync(raw) } catch (e) { /* entrée illisible → ignorée */ }
off += 46 + nameLen + extraLen + commentLen
}
return files
}
// ── XML helpers ──
const decode = s => String(s).replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'").replace(/&amp;/g, '&')
const colToNum = c => { let n = 0; for (const ch of c) n = n * 26 + (ch.charCodeAt(0) - 64); return n }
const numToCol = n => { let s = ''; while (n > 0) { const r = (n - 1) % 26; s = String.fromCharCode(65 + r) + s; n = Math.floor((n - 1) / 26) } return s }
function parseShared (xml) { const out = []; if (!xml) return out; const re = /<si>([\s\S]*?)<\/si>/g; let m; while ((m = re.exec(xml))) { let t = ''; const tre = /<t[^>]*>([\s\S]*?)<\/t>/g; let tm; while ((tm = tre.exec(m[1]))) t += tm[1]; out.push(decode(t)) } return out }
function parseStyles (xml) {
const fb = (xml.match(/<fills[^>]*>([\s\S]*?)<\/fills>/) || [])[1] || ''
const fills = []; const fre = /<fill>([\s\S]*?)<\/fill>/g; let fm; while ((fm = fre.exec(fb))) fills.push((fm[1].match(/<fgColor rgb="([0-9A-Fa-f]{8})"/) || [])[1] || null)
const xb = (xml.match(/<cellXfs[^>]*>([\s\S]*?)<\/cellXfs>/) || [])[1] || ''
const xfs = []; const xre = /<xf\b[^>]*\/?>/g; let xm; while ((xm = xre.exec(xb))) xfs.push(+((xm[0].match(/fillId="(\d+)"/) || [])[1] || 0))
return { fills, xfs }
}
const COLOR_STATUS = { '00FFFF': 'Choix 1', 'FFFF00': 'Choix 2', 'FF00FF': 'Approuvé' } // vert 00FF00 = instruction → ignoré
const statusOf = rgb => rgb ? (COLOR_STATUS[rgb.slice(2).toUpperCase()] || null) : null
function parseSheet (xml, shared, styles) {
const cells = {}; const cre = /<c r="([A-Z]+)(\d+)"([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g; let cm
while ((cm = cre.exec(xml))) {
const col = cm[1]; const row = +cm[2]; const at = cm[3] || ''; const inner = cm[4] || ''
const s = (at.match(/\bs="(\d+)"/) || [])[1]; const t = (at.match(/\bt="([^"]+)"/) || [])[1]
const rgb = s != null ? styles.fills[styles.xfs[+s]] : null
let v = ''; const vm = inner.match(/<v>([\s\S]*?)<\/v>/); const im = inner.match(/<is>[\s\S]*?<t[^>]*>([\s\S]*?)<\/t>/)
if (t === 's' && vm) v = shared[+vm[1]] || ''; else if (im) v = decode(im[1]); else if (vm) v = decode(vm[1])
cells[col + row] = { v, status: statusOf(rgb), colN: colToNum(col), row }
}
return cells
}
// ── Dates ──
const isoOf = d => d.toISOString().slice(0, 10)
const serialToDate = s => new Date(Date.UTC(1899, 11, 30) + Math.round(parseFloat(s)) * 86400000) // système 1900 (avec bug)
const MONTHS_FR = { janvier: 0, fevrier: 1, mars: 2, avril: 3, mai: 4, juin: 5, juillet: 6, aout: 7, septembre: 8, octobre: 9, novembre: 10, decembre: 11 }
const norm = s => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim()
// numéro de jour → date la PLUS PROCHE de refDate ayant ce quantième (gère bornes de mois + listes multi-semaines)
function resolveDay (D, refDate) { let best = null; for (let dm = -1; dm <= 1; dm++) { const cand = new Date(Date.UTC(refDate.getUTCFullYear(), refDate.getUTCMonth() + dm, D)); if (cand.getUTCDate() !== D) continue; const diff = Math.abs(cand - refDate); if (!best || diff < best.diff) best = { iso: isoOf(cand), diff } } return best ? best.iso : null }
// Texte de cellule → jours ISO (ou { week:true } = semaine entière). refDate = dimanche de la colonne-semaine.
function parseCell (text, refDate) {
const raw = (text == null ? '' : String(text)).trim(); if (!raw) return { week: true, days: [] }
const low = norm(raw)
if (/^(vacances?|conges?|choix ?\d|approuve|x|oui|off)$/.test(low.replace(/\s+/g, ' '))) return { week: true, days: [] }
const rng = low.match(/(\d{1,2})\s*(?:er)?\s*([a-z]+)?\s*au\s*(\d{1,2})\s*(?:er)?\s*([a-z]+)?/)
if (rng) { const y = refDate.getUTCFullYear(); const m1 = MONTHS_FR[rng[2]] != null ? MONTHS_FR[rng[2]] : refDate.getUTCMonth(); const m2 = MONTHS_FR[rng[4]] != null ? MONTHS_FR[rng[4]] : m1; const out = []; let cur = new Date(Date.UTC(y, m1, +rng[1])); const end = new Date(Date.UTC(y, m2, +rng[3])); for (let g = 0; g < 400 && cur <= end; g++) { out.push(isoOf(cur)); cur = new Date(cur.getTime() + 86400000) } return { week: false, days: out } }
const nums = low.split(/\s*(?:,|\.|;|-|\bet\b|\s)+\s*/).filter(x => /^\d{1,2}$/.test(x)).map(Number)
if (nums.length) return { week: false, days: [...new Set(nums.map(d => resolveDay(d, refDate)).filter(Boolean))].sort() }
return { week: true, days: [] }
}
const weekDays = d => { const out = []; for (let i = 0; i < 7; i++) out.push(isoOf(new Date(d.getTime() + i * 86400000))); return out }
// jours ISO triés → plages contiguës [{from,to}]
function toRanges (isos) { const s = [...new Set(isos)].sort(); const out = []; for (const iso of s) { const last = out[out.length - 1]; const prev = last ? isoOf(new Date(Date.parse(last.to + 'T00:00:00Z') + 86400000)) : null; if (last && iso === prev) last.to = iso; else out.push({ from: iso, to: iso }) } return out }
// ── Extraction : cells → personnes [{ name, group, byStatus:{status:[iso]} }] ──
function extractPeople (cells) {
const cell = ref => cells[ref] || { v: '', status: null }
const weekCols = []
for (let n = colToNum('D'); n <= colToNum('AZ'); n++) { const c = cell(numToCol(n) + '5'); if (c.v && /^\d+(\.\d+)?$/.test(c.v)) weekCols.push({ col: numToCol(n), date: serialToDate(c.v) }) }
const people = []; let group = ''
for (let r = 6; r <= 80; r++) { const a = cell('A' + r).v; if (a) group = a; const name = cell('B' + r).v; if (!name || r < 7) continue
const byStatus = {}; const notes = []
for (const wc of weekCols) { const c = cell(wc.col + r); if (!c.status && !c.v) continue; const status = c.status || (c.v ? 'À classer' : null); if (!status) continue; const p = parseCell(c.v, wc.date); const days = p.week ? weekDays(wc.date) : p.days; if (!days.length) continue;(byStatus[status] || (byStatus[status] = [])).push(...days); if (c.v) notes.push(c.v) }
if (Object.keys(byStatus).length) people.push({ name, group, byStatus, notes })
}
return { people, weeks: weekCols.length, first: weekCols[0] && isoOf(weekCols[0].date), last: weekCols.length && isoOf(weekCols[weekCols.length - 1].date) }
}
// ── Matching global des noms → techs (2 passes : d'abord les certains, puis lever les ambiguïtés sur le reste) ──
const tokens = s => norm(s).split(/[\s\-]+/).filter(Boolean)
function matchAll (people, techs) {
const T = techs.map(t => ({ ...t, tk: tokens(t.name) })).filter(t => t.tk.length)
const taken = new Set(); const result = {}
const cands = p => { const [first, hint] = tokens(p.name); let c = T.filter(t => t.tk[0] === first || t.tk[0].startsWith(first) || first.startsWith(t.tk[0])); if (hint) { const d = c.filter(t => t.tk.slice(1).some(x => x.startsWith(hint))); if (d.length) c = d } return { first, hint, c } }
// passe 1 : match certain (1 seul candidat, OU indice a réduit à 1)
for (const p of people) { const { hint, c } = cands(p); if (c.length === 1) { result[p.name] = { tech: c[0], confidence: hint ? 'high' : 'medium' }; taken.add(c[0].id) } }
// passe 2 : ambigus → contre les techs NON réservés
for (const p of people) { if (result[p.name]) continue; const { hint, c } = cands(p); const free = c.filter(t => !taken.has(t.id)); if (free.length === 1) { result[p.name] = { tech: free[0], confidence: hint ? 'high' : 'medium' }; taken.add(free[0].id) } else if (free.length > 1) { free.sort((a, b) => a.tk.length - b.tk.length); result[p.name] = { tech: free[0], confidence: 'low', ambiguous: free.map(t => t.name) }; taken.add(free[0].id) } else result[p.name] = { tech: null, confidence: 'none' } }
return result
}
// ── Aperçu complet : buffer xlsx + techs → payload de prévisualisation ──
function buildPreview (buffer, techs, { sheetName } = {}) {
const files = unzip(buffer)
const shared = parseShared(files['xl/sharedStrings.xml'] && files['xl/sharedStrings.xml'].toString('utf8'))
const styles = parseStyles(files['xl/styles.xml'].toString('utf8'))
// choisir la feuille : par nom (workbook) sinon la 1re visible ; défaut sheet2 (« De mars à septembre 2026 »)
const wb = files['xl/workbook.xml'].toString('utf8')
const rels = files['xl/_rels/workbook.xml.rels'].toString('utf8')
const sheetsMeta = [...wb.matchAll(/<sheet[^>]*name="([^"]*)"[^>]*r:id="(rId\d+)"[^>]*\/>/g)].map(m => ({ name: decode(m[1]), rid: m[2] }))
const ridToFile = {}; for (const m of rels.matchAll(/Id="(rId\d+)"[^>]*Target="(worksheets\/sheet\d+\.xml)"/g)) ridToFile[m[1]] = 'xl/' + m[2]
let target = sheetsMeta.find(s => sheetName && s.name === sheetName) || sheetsMeta.find(s => /2026/.test(s.name) && /mars|sept/i.test(norm(s.name))) || sheetsMeta[0]
const cells = parseSheet(files[ridToFile[target.rid]].toString('utf8'), shared, styles)
const ex = extractPeople(cells)
const matches = matchAll(ex.people, techs)
const rows = ex.people.map(p => { const m = matches[p.name]; const byStatus = {}; for (const st in p.byStatus) byStatus[st] = toRanges(p.byStatus[st]); const totalDays = Object.values(p.byStatus).reduce((s, a) => s + new Set(a).size, 0); return { sheet_name: p.name, group: p.group, tech_id: m.tech ? m.tech.id : null, tech_name: m.tech ? m.tech.name : null, confidence: m.confidence, ambiguous: m.ambiguous || null, total_days: totalDays, ranges: byStatus, notes: p.notes.slice(0, 6) } })
return { sheet: target.name, sheets: sheetsMeta.map(s => s.name), weeks: ex.weeks, span: [ex.first, ex.last], count: rows.length, matched: rows.filter(r => r.tech_id).length, rows }
}
module.exports = { buildPreview, unzip, parseCell, matchAll, extractPeople, _internals: { parseShared, parseStyles, parseSheet, resolveDay, toRanges } }
// ── Test local : node lib/vacation-import.js <fichier.xlsx> ──
if (require.main === module) {
const fs = require('fs')
const path = process.argv[2]; if (!path) { console.error('usage: node vacation-import.js <fichier.xlsx>'); process.exit(1) }
// techs réels (fetch prod 2026-07-04) pour tester le matching
const TECHS = ['Anthony Dion', 'Antoine Wilson-Guay', 'Antoine-Marie Bourdon', 'Benjamin Djanpou', 'Carmen Ouellet', 'David Richer', 'Dominique Liaud', 'Fanny Laroche', 'Frederick Pruneau', 'Frédérique Soulard', 'Geneviève Bourdon', 'Gilles Drolet', 'Jean-Pierre Bourdon', 'Joseph Muzowindanga', 'Kadi Nongtodbo', 'Lion Arar', 'Louis Morneau', 'Louis-Paul Bourdon', 'Marc Leduc', 'Marc-André Henrico', 'Marcelle Sylviane Rasoarimalala', 'Matthieu Haineault', 'Maxim Murray Gendron', 'Michel Blais', 'Nathan Boucher', 'Nathan Morrisseau', 'Nicolas Drolet', 'Patrick Doucet', 'Patrick Moïse', 'Philip St-Amour', 'Philippe Bourdon', 'Pierre Brodeur', 'Rina Sylvia', 'Robinson Viaud', 'Simon Clot-Gagnon', 'Simon Goyette', 'Stéphane Beaudoin', 'Stéphane Moïse', 'Thaiz Menezes', 'Tommy Larouche Dionne', 'William Alexander Murray', 'Xavier Gilbert Blais'].map((n, i) => ({ id: 'TECH-' + i, name: n }))
const pv = buildPreview(fs.readFileSync(path), TECHS)
console.log(`Feuille « ${pv.sheet} » · ${pv.weeks} semaines (${pv.span[0]}${pv.span[1]}) · ${pv.count} personnes · ${pv.matched} appariées\n`)
for (const r of pv.rows) {
const tag = r.confidence === 'none' ? '❌ NON APPARIÉ' : (r.confidence === 'low' ? '⚠ ' + r.confidence : '✓ ' + r.confidence)
const rng = Object.entries(r.ranges).map(([st, rs]) => `${st}: ${rs.map(x => x.from === x.to ? x.from : x.from + '→' + x.to).join(', ')}`).join(' | ')
console.log(`${r.sheet_name.padEnd(14)}${(r.tech_name || '???').padEnd(26)} [${tag}] ${r.total_days}j ${rng}`)
if (r.ambiguous) console.log(` ambigu: ${r.ambiguous.join(', ')}`)
}
}

View File

@ -392,7 +392,4 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
// Auto-reclaim Giftbit : pool les liens cadeaux EXPIRÉS non cliqués (réallocation auto, futures campagnes). Opt-in GIFT_RECLAIM_CRON=on // Auto-reclaim Giftbit : pool les liens cadeaux EXPIRÉS non cliqués (réallocation auto, futures campagnes). Opt-in GIFT_RECLAIM_CRON=on
try { require('./lib/campaigns').startGiftReclaimCron() } try { require('./lib/campaigns').startGiftReclaimCron() }
catch (e) { log('gift reclaim cron failed to start:', e.message) } catch (e) { log('gift reclaim cron failed to start:', e.message) }
// Géofencing des jobs : compare GPS live des techs (Traccar) aux jobs du jour → timeline En route/Arrivé/Reparti. Désactivable GEOFENCE_SCAN=off
try { require('./lib/geofence').startScan() }
catch (e) { log('geofence scan failed to start:', e.message) }
}) })