Compare commits
No commits in common. "80c8005f0cb7d4fabaa9a209955567923a2304b3" and "c267f9a3ddd8a0c9175f32a4456d8965bc416b2c" have entirely different histories.
80c8005f0c
...
c267f9a3dd
|
|
@ -1,58 +1,17 @@
|
|||
<template>
|
||||
<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>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from 'src/stores/auth'
|
||||
import { usePermissions } from 'src/composables/usePermissions'
|
||||
import { loadSavedTheme } from 'src/composables/useTheme'
|
||||
import { startSessionKeepAlive } from 'src/composables/useSessionKeepAlive'
|
||||
|
||||
loadSavedTheme() // applique le thème enregistré (couleurs --ops-*) dès le démarrage
|
||||
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(() => {
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -4,13 +4,6 @@ import { BASE_URL } from 'src/config/erpnext'
|
|||
// Only needed for local dev (VITE_ERP_TOKEN in .env).
|
||||
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).
|
||||
let _lastReload = 0
|
||||
function _reauth (status) {
|
||||
|
|
@ -60,19 +53,14 @@ export async function authFetch (url, opts = {}) {
|
|||
}
|
||||
|
||||
export async function getLoggedUser () {
|
||||
// ⚠️ APERÇU LOCAL UNIQUEMENT : identité forcée via VITE_DEV_USER (gardé localhost + env). `import.meta.env.DEV`
|
||||
// vaut false en build prod → esbuild élimine toute cette branche (et la valeur inlinée) du bundle livré.
|
||||
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
|
||||
// ⚠️ APERÇU LOCAL UNIQUEMENT : identité forcée via VITE_DEV_USER (gardé localhost + env). Inerte en prod. À RETIRER avec le harnais.
|
||||
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
|
||||
// First try nginx whoami endpoint (returns Authentik email header)
|
||||
try {
|
||||
const res = await fetch(BASE_URL + '/auth/whoami')
|
||||
if (res.ok) {
|
||||
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
|
||||
// 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 {}
|
||||
// Fallback: ask ERPNext (returns API token owner, usually "Administrator")
|
||||
|
|
@ -88,9 +76,5 @@ export async function getLoggedUser () {
|
|||
}
|
||||
|
||||
export async function logout () {
|
||||
// Déconnexion Authentik PUIS retour sur OPS : le flow d'invalidation honore `?next=` → il renvoie vers OPS,
|
||||
// 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)
|
||||
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
// 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)
|
||||
// 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.
|
||||
export const getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job))
|
||||
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)
|
||||
// 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))
|
||||
// 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 }.
|
||||
export const listTraccarDevices = () => jget('/traccar/devices')
|
||||
// Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif.
|
||||
|
|
|
|||
|
|
@ -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 où 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>
|
||||
32
apps/ops/src/components/shared/ResponsiveDialog.vue
Normal file
32
apps/ops/src/components/shared/ResponsiveDialog.vue
Normal 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>
|
||||
|
|
@ -15,14 +15,12 @@ import * as roster from 'src/api/roster'
|
|||
|
||||
const props = defineProps({
|
||||
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' },
|
||||
})
|
||||
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 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 }
|
||||
function RouteMapCache () { const g = globalThis; if (!g.__opsRouteMapCache) g.__opsRouteMapCache = new Map(); return g.__opsRouteMapCache }
|
||||
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) ──
|
||||
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) {
|
||||
clearMarkers()
|
||||
const mapboxgl = window.mapboxgl
|
||||
|
|
@ -191,10 +149,8 @@ async function draw () {
|
|||
const ls = map.getSource('rm-line'); if (!ls) return
|
||||
ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) })
|
||||
renderMarkers(routes)
|
||||
renderLive(props.live)
|
||||
renderPins(props.pins)
|
||||
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) {} }
|
||||
const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|')
|
||||
if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); 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.
|
||||
const results = await Promise.all(routes.map(async r => ({ r, info: await realLine(r) })))
|
||||
if (seq !== drawSeq || !map || !map.getSource('rm-line')) return
|
||||
|
|
@ -243,10 +199,8 @@ onMounted(async () => {
|
|||
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.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>
|
||||
|
||||
<template>
|
||||
|
|
@ -274,15 +228,4 @@ watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // job
|
|||
.rm-mk.route-hot { z-index: 5; }
|
||||
.rm-mk.fanned { z-index: 6; }
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -351,9 +351,9 @@ function confirmDelete () {
|
|||
})
|
||||
}
|
||||
|
||||
// ── Navigate to planification (ex-Dispatch, page retirée) ──
|
||||
// ── Navigate to dispatch ──
|
||||
function goToDispatch () {
|
||||
router.push('/planification')
|
||||
router.push('/dispatch')
|
||||
}
|
||||
|
||||
// ── Visual computed ──
|
||||
|
|
|
|||
|
|
@ -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 sched = reactive({})
|
||||
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 bulk = computed(() => (props.techs || []).length > 1)
|
||||
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' } } }
|
||||
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(mode, (m) => { weeks.value = m === 'recurring' ? 6 : 1 })
|
||||
watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = 1; selected.value = new Set((props.techs || []).map(t => t.id)) } })
|
||||
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 }
|
||||
|
|
@ -36,7 +34,7 @@ const totalHours = () => DAYS.reduce((s, [k]) => s + dayHours(sched[k]), 0)
|
|||
function submit () {
|
||||
const techIds = bulk.value ? [...selected.value] : (props.techs[0] ? [props.techs[0].id] : [])
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -51,12 +49,6 @@ function submit () {
|
|||
</q-card-section>
|
||||
<q-separator />
|
||||
<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) -->
|
||||
<div v-if="bulk">
|
||||
<div class="text-caption text-grey-7 q-mb-xs">Appliquer à ({{ selected.size }}/{{ techs.length }}) — clic pour (dé)cocher :</div>
|
||||
|
|
@ -83,18 +75,17 @@ function submit () {
|
|||
<span v-else class="wse-repos">Repos</span>
|
||||
</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">
|
||||
<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>
|
||||
</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 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-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 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>
|
||||
</q-dialog>
|
||||
|
|
|
|||
|
|
@ -29,16 +29,6 @@ export function timeToH (t) {
|
|||
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) {
|
||||
const totalMin = Math.round(h * 60)
|
||||
const hh = Math.floor(totalMin / 60)
|
||||
|
|
|
|||
|
|
@ -16,44 +16,34 @@ let fetchPromise = null // Prevent duplicate fetches
|
|||
* Fetch effective permissions for the current user from targo-hub.
|
||||
* Reads X-Authentik-Email header (injected by Traefik).
|
||||
* @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) {
|
||||
if (!email && !username) return
|
||||
async function loadPermissions (email) {
|
||||
if (!email) return
|
||||
if (fetchPromise) return fetchPromise
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const qs = new URLSearchParams()
|
||||
if (email) qs.set('email', email)
|
||||
if (username) qs.set('username', username)
|
||||
const url = `${HUB_URL}/auth/permissions?${qs.toString()}`
|
||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||||
|
||||
// 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()
|
||||
fetchPromise = fetch(`${HUB_URL}/auth/permissions?email=${encodeURIComponent(email)}`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Permission fetch failed: ' + r.status)
|
||||
return r.json()
|
||||
})
|
||||
.then(data => {
|
||||
permissions.value = data
|
||||
error.value = null
|
||||
} catch (e) {
|
||||
if (n < 4) { await new Promise(res => setTimeout(res, 700 * n)); return attempt(n + 1) }
|
||||
console.error('[usePermissions] load failed after retries:', e.message)
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('[usePermissions]', 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
|
||||
}
|
||||
}
|
||||
fetchPromise = attempt(1).finally(() => {
|
||||
loading.value = false
|
||||
fetchPromise = null
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
fetchPromise = null
|
||||
})
|
||||
|
||||
return fetchPromise
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,13 +11,8 @@
|
|||
* EventSource (/sse, /conversations) work, and rides the existing Authentik
|
||||
* 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
|
||||
// ⚠️ PROD : TOUJOURS le proxy same-origin `_opsBase + '/hub'` (= /ops/hub → Traefik strip /ops → nginx `location /hub/`
|
||||
// → hub). On n'honore VITE_HUB_URL qu'en DEV : il vaut `/hub` dans .env.local (proxy du serveur de dev) et, comme
|
||||
// .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'
|
||||
export const HUB_URL = import.meta.env.VITE_HUB_URL
|
||||
|| (window.location.hostname === 'localhost' ? 'http://localhost:3300' : _opsBase + '/hub')
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ export const navItems = [
|
|||
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets', 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' },
|
||||
// ── 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: '/rdv', icon: 'CalendarClock', label: 'Rendez-vous', requires: 'view_all_jobs', section: 'terrain' },
|
||||
{ path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs', section: 'terrain' },
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch, defineAsyncComponent } from 'vue'
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from 'src/stores/auth'
|
||||
|
|
@ -180,18 +180,16 @@ import {
|
|||
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
|
||||
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat,
|
||||
} from 'lucide-vue-next'
|
||||
// Composants lourds montés conditionnellement (v-if) → chargés à la demande (defineAsyncComponent) au lieu d'alourdir
|
||||
// le chunk MainLayout livré sur CHAQUE page. Aucun accès par ref → sûr. NotificationBell reste synchrone (barre du haut, toujours visible).
|
||||
const ConversationPanel = defineAsyncComponent(() => import('src/components/shared/ConversationPanel.vue'))
|
||||
const PhoneModal = defineAsyncComponent(() => import('src/components/customer/PhoneModal.vue'))
|
||||
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
||||
import PhoneModal from 'src/components/customer/PhoneModal.vue'
|
||||
import { useSoftphone } from 'src/composables/useSoftphone'
|
||||
import NotificationBell from 'src/components/shared/NotificationBell.vue'
|
||||
import { useConversations } from 'src/composables/useConversations'
|
||||
const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue'))
|
||||
const NewTicketDialog = defineAsyncComponent(() => import('src/components/shared/NewTicketDialog.vue'))
|
||||
const OrchestratorDialog = defineAsyncComponent(() => import('src/components/shared/OrchestratorDialog.vue'))
|
||||
const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue'))
|
||||
const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/OutboxPanel.vue'))
|
||||
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
||||
import NewTicketDialog from 'src/components/shared/NewTicketDialog.vue'
|
||||
import OrchestratorDialog from 'src/components/shared/OrchestratorDialog.vue'
|
||||
import ServiceStatusDialog from 'src/components/shared/ServiceStatusDialog.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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ const editorOptions = {
|
|||
mergeTags: [
|
||||
{ name: 'Prénom', value: '{{firstname}}', sample: 'Louis' },
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
|
|
@ -562,7 +562,7 @@ const sampleExpiryDate = new Date(Date.now() + 30 * 86400 * 1000)
|
|||
.toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
|
||||
const testSendForm = ref({
|
||||
to: '',
|
||||
to: 'louis@targo.ca',
|
||||
subject: '[TEST] Aperçu du courriel TARGO',
|
||||
via: 'gmail',
|
||||
vars: {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const props = defineProps({
|
|||
modelValue: { type: Boolean, default: false },
|
||||
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 target = reactive({ address: '', latitude: null, longitude: null, ville: '' })
|
||||
|
|
@ -48,9 +48,6 @@ function pick (s) {
|
|||
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']
|
||||
function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) }
|
||||
</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-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-else-if="!slots.length" class="text-center q-pa-md">
|
||||
<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>
|
||||
<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>
|
||||
<q-list v-else separator>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -3,45 +3,21 @@
|
|||
<!-- Real-time outage alerts -->
|
||||
<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="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">
|
||||
<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-label">{{ stat.label }}</div>
|
||||
<div v-if="stat.sub" class="ops-stat-sub" :class="stat.subClass">{{ stat.sub }}</div>
|
||||
</div>
|
||||
<q-icon v-if="stat.to" name="chevron_right" size="18px" class="ops-stat-go" />
|
||||
</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 -->
|
||||
<div class="row q-col-gutter-md q-mb-lg">
|
||||
<div class="col-12 col-md-6">
|
||||
|
|
@ -111,9 +87,9 @@
|
|||
icon="credit_score"
|
||||
:loading="runningPPA"
|
||||
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>
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
<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-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" />
|
||||
|
|
@ -195,57 +171,23 @@
|
|||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { listDocs, countDocs } from 'src/api/erp'
|
||||
import { authFetch } from 'src/api/auth'
|
||||
import { getCapacity } from 'src/api/roster'
|
||||
import { BASE_URL } from 'src/config/erpnext'
|
||||
import { HUB_SSE_URL } from 'src/config/dispatch'
|
||||
import { startOfWeek, localDateStr } from 'src/composables/useHelpers'
|
||||
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
|
||||
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue'
|
||||
|
||||
const $q = useQuasar()
|
||||
|
||||
const stats = ref([
|
||||
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people', to: '/clients', sub: '', subClass: '' },
|
||||
{ label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business', to: '/clients', sub: '', subClass: '' },
|
||||
{ label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money', to: '/rapports/revenus', sub: 'récurrent', subClass: 'text-grey-6' },
|
||||
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number', to: '/tickets', sub: '', subClass: '' },
|
||||
{ label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping', to: '/planification', sub: '', subClass: '' },
|
||||
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people' },
|
||||
{ label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business' },
|
||||
{ label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money' },
|
||||
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number' },
|
||||
{ label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping' },
|
||||
])
|
||||
|
||||
const openTickets = 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
|
||||
const schedulerEnabled = ref(false)
|
||||
const togglingScheduler = ref(false)
|
||||
|
|
@ -403,19 +345,6 @@ async function submitDrafts () {
|
|||
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 () {
|
||||
runningPPA.value = true
|
||||
ppaResult.value = null
|
||||
|
|
@ -444,12 +373,10 @@ async function runPPA () {
|
|||
onMounted(async () => {
|
||||
fetchSchedulerStatus()
|
||||
refreshDraftCount()
|
||||
loadWeek()
|
||||
|
||||
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('Issue', { status: 'Open' }),
|
||||
listDocs('Dispatch Job', {
|
||||
|
|
@ -462,7 +389,6 @@ onMounted(async () => {
|
|||
fields: ['name', 'subject', 'customer', 'customer_name', 'priority', 'opening_date'],
|
||||
limit: 10, orderBy: 'opening_date desc',
|
||||
}),
|
||||
countDocs('Issue', { opening_date: ['>=', d7] }).catch(() => 0),
|
||||
])
|
||||
|
||||
// Abonnés = unique customers with active subscriptions (via server script)
|
||||
|
|
@ -489,31 +415,7 @@ onMounted(async () => {
|
|||
stats.value[3].value = tickets.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
|
||||
todayJobs.value = todayDispatch
|
||||
})
|
||||
</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>
|
||||
|
|
|
|||
2179
apps/ops/src/pages/DispatchPage.vue
Normal file
2179
apps/ops/src/pages/DispatchPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -696,6 +696,7 @@
|
|||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick, reactive } from 'vue'
|
||||
import cytoscape from 'cytoscape'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listDocs, updateDoc, createDoc } from 'src/api/erp'
|
||||
import { Notify } from 'quasar'
|
||||
|
|
@ -1171,7 +1172,6 @@ const mapSelectedSite = ref(null)
|
|||
const mapSelectedSiteId = ref(null)
|
||||
const cyContainer = ref(null)
|
||||
let cyInstance = null
|
||||
let cytoscape = null // chargé à la demande (lib ~350 KB) — seulement quand on ouvre l'onglet Carte
|
||||
|
||||
const mapSelectedSiteLinks = computed(() => {
|
||||
if (!mapData.value || !mapSelectedSiteId.value) return []
|
||||
|
|
@ -1183,7 +1183,6 @@ async function loadNetworkMap () {
|
|||
try {
|
||||
const resp = await fetch(`${hubUrl}/network/map`)
|
||||
mapData.value = await resp.json()
|
||||
if (!cytoscape) cytoscape = (await import('cytoscape')).default // import dynamique : sort la lib du chunk NetworkPage
|
||||
await nextTick()
|
||||
renderCytoscape()
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
<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="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-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>
|
||||
|
|
@ -96,7 +97,6 @@
|
|||
</q-list>
|
||||
</q-menu>
|
||||
</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="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 />
|
||||
|
|
@ -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="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="$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-list>
|
||||
</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-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-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>
|
||||
|
|
@ -120,6 +121,7 @@
|
|||
<!-- 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">
|
||||
<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" />
|
||||
<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>
|
||||
|
|
@ -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)">
|
||||
<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>
|
||||
<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
|
||||
<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>
|
||||
|
|
@ -482,9 +483,8 @@
|
|||
<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">
|
||||
<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-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>
|
||||
</div>
|
||||
<!-- 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>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Jobs DUS ce jour, NON assignés (mis en évidence) — chips secteur filtrent liste+carte · clic = détail · « Suggérer » = dispatch auto -->
|
||||
<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)" />
|
||||
<RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" />
|
||||
<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 class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }}
|
||||
|
|
@ -899,7 +880,7 @@
|
|||
<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-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="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
|
||||
<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.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>
|
||||
<!-- 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">
|
||||
<q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir :</span>
|
||||
<template v-if="suggestFiltered">
|
||||
<span class="text-caption text-weight-medium text-indigo-8">{{ suggestJobCount }} job(s) {{ suggestScope }}</span>
|
||||
<q-icon name="arrow_forward" size="13px" color="grey-6" /><span class="text-caption text-grey-7">{{ suggestWindow.map(windowDayLabel).join(', ') }}</span>
|
||||
<q-space /><span class="text-caption text-grey-6">{{ selectedNames.length ? 'sélection lasso' : 'ajuste les chips sous le pool' }}</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>
|
||||
<q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir 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">jobs en retard/sans date → tirés sur ce jour</span>
|
||||
</div>
|
||||
<div class="row items-center q-mb-xs" style="gap:4px">
|
||||
<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.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</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 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">
|
||||
|
|
@ -1780,7 +1743,7 @@
|
|||
<UnifiedCreateModal v-model="createJobOpen" mode="work-order" :context="createJobCtx"
|
||||
: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 -->
|
||||
<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 -->
|
||||
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
|
||||
</q-page>
|
||||
|
|
@ -1806,7 +1769,6 @@
|
|||
* 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor
|
||||
*/
|
||||
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.
|
||||
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
|
||||
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 { 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 { 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 { 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
|
||||
|
|
@ -1825,7 +1787,6 @@ import TechSelect from 'src/components/shared/TechSelect.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 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 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
|
||||
|
|
@ -1896,9 +1857,6 @@ const estLoadByDay = computed(() => {
|
|||
})
|
||||
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
|
||||
// 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
|
||||
// tech×jour (réutilise onJobDragStart/onCellDrop → assigne + replanifie à la date de la case). Suit le filtre compétence.
|
||||
const showDayJobs = ref(false)
|
||||
|
|
@ -2163,7 +2121,7 @@ function exportGpx (techId) {
|
|||
window.open(roster.gpxUrl(techId, from, to), '_blank')
|
||||
}
|
||||
// ── Détails d'un job : double-clic sur un bloc → grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour. ──
|
||||
const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', time: '', detail: '', lid: null, dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, 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'équipement » → « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
|
||||
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 }
|
||||
|
|
@ -2171,21 +2129,9 @@ async function openJobDetail (b, t) {
|
|||
if (!b) return
|
||||
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 })
|
||||
// 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 (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.
|
||||
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 () {
|
||||
|
|
@ -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 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 }
|
||||
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
|
||||
// ── 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 = {}
|
||||
const base = start.value // lundi de la semaine affichée
|
||||
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] }
|
||||
// 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] }
|
||||
// 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(() => {
|
||||
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')) }
|
||||
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
|
||||
})
|
||||
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 = {}
|
||||
if (t) q.tech = t.id
|
||||
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) ──
|
||||
// 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 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 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)))
|
||||
// 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 || ''))
|
||||
|
|
@ -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. ──
|
||||
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 mobileSelIso = ref(null)
|
||||
// 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 () {
|
||||
const useSel = selectedNames.value.length > 0
|
||||
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 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 }
|
||||
|
|
@ -4408,50 +4335,19 @@ function buildSuggestion () {
|
|||
suggestDlg.plan = plan
|
||||
}
|
||||
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 = (boardView.value === 'routes' && routesDay.value) || (boardView.value === 'kanban' && kbSelIso.value) || mobileSelIso.value || routesDay.value || kbSelIso.value || todayISO()
|
||||
suggestDay.value = mobileSelIso.value || kbSelIso.value || todayISO() // jour ciblé = celui sélectionné au tableau (défaut auj.)
|
||||
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.
|
||||
const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur
|
||||
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart CE JOUR (= disponibles) ; sinon tous les visibles.
|
||||
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 sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel
|
||||
suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true
|
||||
}
|
||||
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.
|
||||
// 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).
|
||||
// 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).
|
||||
const suggestDay = ref('')
|
||||
const suggestWindow = computed(() => {
|
||||
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 suggestWindow = computed(() => [suggestDay.value || todayISO()])
|
||||
const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso))
|
||||
const suggestWindowLabel = computed(() => {
|
||||
const w = suggestWindow.value; if (!w.length) return '—'
|
||||
|
|
@ -4528,7 +4424,6 @@ async function optimizeSuggestion () {
|
|||
try {
|
||||
const r = await roster.optimizePlanHub({
|
||||
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 },
|
||||
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] || '' })
|
||||
// ── 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)
|
||||
// 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 dayRouteMetrics = reactive({})
|
||||
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
|
||||
})
|
||||
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(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).
|
||||
|
|
@ -5549,8 +5385,8 @@ function onLegacyUpdate (data) {
|
|||
}
|
||||
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
|
||||
|
||||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } 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 */ })
|
||||
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); stopLivePositions(); if (_kbRO) _kbRO.disconnect() })
|
||||
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); if (_kbRO) _kbRO.disconnect() })
|
||||
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
|
||||
</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-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
|
||||
/* 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; }
|
||||
/* 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-strip { display: flex; gap: 2px; justify-content: center; align-items: center; margin-top: 2px; }
|
||||
.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-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-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; }
|
||||
/* 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:hover { 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-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; }
|
||||
/* 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-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; }
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import { fetchARReport } from 'src/api/reports'
|
|||
import { formatMoney } from 'src/composables/useFormatters'
|
||||
import { exportCsv } from 'src/composables/useCsvExport'
|
||||
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 loading = ref(false)
|
||||
|
|
@ -83,10 +83,9 @@ async function loadReport () {
|
|||
}
|
||||
}
|
||||
|
||||
async function renderChart () {
|
||||
function renderChart () {
|
||||
if (chartInstance) chartInstance.destroy()
|
||||
if (!chartCanvas.value || !summary.value) return
|
||||
if (!Chart) Chart = (await import('chart.js/auto')).default
|
||||
const s = summary.value
|
||||
chartInstance = new Chart(chartCanvas.value, {
|
||||
type: 'doughnut',
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ import { fetchRevenueReport } from 'src/api/reports'
|
|||
import { formatMoney } from 'src/composables/useFormatters'
|
||||
import StatCard from 'src/components/shared/StatCard.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 startDate = ref(new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10))
|
||||
|
|
@ -197,10 +197,9 @@ const CHART_COLORS = [
|
|||
'#a855f7', '#3b82f6', '#d946ef', '#0ea5e9', '#f43f5e',
|
||||
]
|
||||
|
||||
async function renderChart () {
|
||||
function renderChart () {
|
||||
if (chartInstance) chartInstance.destroy()
|
||||
if (!chartCanvas.value || !chartAccounts.value.length) return
|
||||
if (!Chart) Chart = (await import('chart.js/auto')).default
|
||||
|
||||
const months = data.value.months || []
|
||||
const labels = months.map(m => {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ import { fetchTaxReport } from 'src/api/reports'
|
|||
import { formatMoney } from 'src/composables/useFormatters'
|
||||
import { exportCsv } from 'src/composables/useCsvExport'
|
||||
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 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 (!chartCanvas.value || !periods.value.length) return
|
||||
if (!Chart) Chart = (await import('chart.js/auto')).default
|
||||
const labels = periods.value.map(p => p.label)
|
||||
chartInstance = new Chart(chartCanvas.value, {
|
||||
type: 'bar',
|
||||
|
|
|
|||
|
|
@ -73,13 +73,12 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, defineAsyncComponent } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { listFlowTemplates, getFlowTemplate, updateFlowTemplate } from 'src/api/flow-templates'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
import TaskDependencyGraph from 'src/components/flow-editor/TaskDependencyGraph.vue'
|
||||
// Vue Gantt (hy-vue-gantt, lourd) rendu seulement dans la vue « gantt » → chargé à la demande.
|
||||
const TaskGantt = defineAsyncComponent(() => import('src/components/flow-editor/TaskGantt.vue'))
|
||||
import TaskGantt from 'src/components/flow-editor/TaskGantt.vue'
|
||||
|
||||
const $q = useQuasar()
|
||||
const view = ref('dag')
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ const routes = [
|
|||
{ path: 'theme', component: () => import('src/pages/ThemePage.vue') },
|
||||
{ path: 'facturation/approbations', component: () => import('src/pages/BillingApprovalsPage.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: 'evaluations', component: () => import('src/pages/EvaluationsPage.vue') },
|
||||
{ path: 'planification', component: () => import('src/pages/PlanificationPage.vue') },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { defineStore } from 'pinia'
|
||||
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'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
|
|
@ -12,10 +12,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
loading.value = true
|
||||
try {
|
||||
user.value = await getLoggedUser()
|
||||
// Permissions depuis le hub : par courriel, avec REPLI par username Authentik (whoami sans courriel /
|
||||
// courriel non provisionné). Si les deux sont vides → permissions non chargées → App.vue affiche « fail-loud ».
|
||||
const email = (user.value && user.value !== 'authenticated') ? user.value : ''
|
||||
await loadPermissions(email, getAuthUsername())
|
||||
// Load permissions from targo-hub using the user's email
|
||||
if (user.value && user.value !== 'authenticated') {
|
||||
await loadPermissions(user.value)
|
||||
}
|
||||
} catch {
|
||||
user.value = 'authenticated'
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -59,15 +59,6 @@ async function findUserByEmail (email) {
|
|||
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) {
|
||||
const validKeys = new Set(CAPABILITIES.map(c => c.key))
|
||||
const result = {}
|
||||
|
|
@ -90,12 +81,9 @@ async function handle (req, res, method, path, url) {
|
|||
|
||||
if (resource === 'permissions' && method === 'GET') {
|
||||
const email = url.searchParams.get('email') || req.headers['x-authentik-email']
|
||||
const username = url.searchParams.get('username') || req.headers['x-authentik-username']
|
||||
if (!email && !username) return json(res, 400, { error: 'Missing email or username parameter' })
|
||||
if (!email) return json(res, 400, { error: 'Missing email parameter' })
|
||||
|
||||
// Recherche par courriel, puis REPLI par username (courriel absent/non provisionné).
|
||||
let lookup = email ? await findUserByEmail(email) : { error: 'no email', status: 404 }
|
||||
if (lookup.error && username) lookup = await findUserByUsername(username)
|
||||
const lookup = await findUserByEmail(email)
|
||||
if (lookup.error) return json(res, lookup.status, { error: lookup.error })
|
||||
const { user } = lookup
|
||||
|
||||
|
|
|
|||
|
|
@ -14,17 +14,6 @@ const conversations = new Map()
|
|||
const pushSubscriptions = 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 () {
|
||||
try {
|
||||
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).
|
||||
if (p === '/conversations/email-ingest' && method === 'POST') {
|
||||
const fs = require('fs')
|
||||
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' })
|
||||
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).
|
||||
if (p === '/conversations/channel-ingest' && method === 'POST') {
|
||||
const fs = require('fs')
|
||||
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' })
|
||||
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 }) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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 (!mysql) { log('legacy-dispatch-sync: mysql2 absent → pont inactif'); return }
|
||||
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
|
||||
// (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 }) }
|
||||
const tick = () => sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message))
|
||||
// 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`.
|
||||
setTimeout(tick, 90 * 1000)
|
||||
_timer = setInterval(tick, minutes * 60 * 1000)
|
||||
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.
|
||||
const watchMin = Number(process.env.LEGACY_DISPATCH_WATCH_MIN) || 3
|
||||
let _watchInFlight = false
|
||||
const wtick = () => { if (_watchInFlight) return; _watchInFlight = true; watchLegacy().catch(e => log('legacy-watch error:', e.message)).finally(() => { _watchInFlight = false }) }
|
||||
const wtick = () => watchLegacy().catch(e => log('legacy-watch error:', e.message))
|
||||
setTimeout(wtick, 60 * 1000) // amorçage du snapshot ~1 min après le boot
|
||||
_watchTimer = setInterval(wtick, watchMin * 60 * 1000)
|
||||
log(`legacy-watch: veille active (toutes les ${watchMin} min → SSE topic 'dispatch')`)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ const erp = require('./erp')
|
|||
const cfg = require('./config')
|
||||
const fs = require('fs')
|
||||
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')
|
||||
// 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')
|
||||
|
|
@ -165,7 +164,6 @@ async function optimizePlan (body) {
|
|||
const durOv = body.dur_overrides || {}
|
||||
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 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([
|
||||
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.
|
||||
const dset = new Set(dates); const byDay = {}; const skipped = []; const remote = []
|
||||
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
|
||||
if (sched && sched > hi) { skipped.push(j.name); continue }
|
||||
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 ────────
|
||||
// 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 () {
|
||||
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', {
|
||||
filters: [['resource_type', '=', 'human']],
|
||||
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',
|
||||
'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,
|
||||
})
|
||||
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)}
|
||||
absence_from: t.absence_from,
|
||||
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 ─────────────────────────────
|
||||
async function generate (start, days, weights) {
|
||||
const dateList = rangeDates(start, days)
|
||||
|
|
@ -1791,7 +1722,7 @@ async function handle (req, res, method, path, url) {
|
|||
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 1–5 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 })
|
||||
}
|
||||
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 techName = await resolveTechName(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 })
|
||||
}
|
||||
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 techName = await resolveTechName(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 })
|
||||
}
|
||||
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 })
|
||||
const patch = { status: b.paused ? PAUSE_STATUS : AVAIL_STATUS }
|
||||
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 })
|
||||
}
|
||||
// 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.
|
||||
// device_id = id numérique Traccar (ou '' pour dissocier).
|
||||
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)
|
||||
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
|
||||
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 })
|
||||
}
|
||||
// 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
|
||||
const mDelA = path.match(/^\/roster\/assignment\/(.+)$/)
|
||||
if (mDelA && method === 'DELETE') {
|
||||
|
|
|
|||
|
|
@ -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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/&/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(', ')}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
try { require('./lib/campaigns').startGiftReclaimCron() }
|
||||
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) }
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user