feat(dispatch): tech chips on map, distinct colors, remote (no-travel) jobs, group-as-project
- Tournées tab: removable/addable TECH CHIPS above the map — each chip in the tech's color with a job-count badge; click toggles the route on/off (colors stay stable). Route colors switched from a 12-color fixed palette to golden-angle HSL (137.5°) — consecutive techs always get highly distinguishable hues, unlimited count. - 🖥 SANS DÉPLACEMENT: per-job persistent flag (hub store data/ job-remote.json + POST /roster/job-remote; buildUnassigned sets j.remote). optimizePlan EXCLUDES remote jobs from routing and address pinning (e.g. "Configuration de boîtier tel" doable via ACS — nobody travels) → returned in a remote[] bucket, shown as a "🖥 Sans déplacement" group in the review, assignable to an agent. Toggle button (computer icon) on every review entry. Verified: "Boitier ouvert | Lac des pins" flagged → excluded from routes, then reverted. - 📦 GROUPER EN PROJET: same-address entries (🔗) now open a menu → "Grouper en projet" sets ERPNext parent_job on the shorter tasks (parent = longest), POST /roster/job-group. Plugs into the existing parent-child pool sort ("Groupe (parent-enfant)"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cd12dbe601
commit
c3f1c77164
|
|
@ -53,6 +53,8 @@ export const askAssistant = (message, history) => jpost('/roster/assistant', { m
|
||||||
export const getPolicy = () => jget('/roster/policy')
|
export const getPolicy = () => jget('/roster/policy')
|
||||||
export const savePolicy = (policy) => jpost('/roster/policy', policy)
|
export const savePolicy = (policy) => jpost('/roster/policy', policy)
|
||||||
export const setJobLevel = (name, level) => jpost('/roster/job-level', { name, level }) // niveau requis persistant par job
|
export const setJobLevel = (name, level) => jpost('/roster/job-level', { name, level }) // niveau requis persistant par job
|
||||||
|
export const setJobRemote = (name, remote) => jpost('/roster/job-remote', { name, remote }) // 🖥 sans déplacement (à distance) — exclu des tournées
|
||||||
|
export const groupJobs = (parent, children) => jpost('/roster/job-group', { parent, children }) // 📦 grouper en « projet » (parent_job)
|
||||||
export async function deleteShiftTemplate (name) {
|
export async function deleteShiftTemplate (name) {
|
||||||
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
|
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||||
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)
|
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)
|
||||||
|
|
|
||||||
|
|
@ -480,6 +480,14 @@
|
||||||
<q-space />
|
<q-space />
|
||||||
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
|
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Chips techs : enlever/ajouter une tournée de la carte d'un clic — pastille = nb de jobs, dans la couleur du tech -->
|
||||||
|
<div class="row items-center q-mb-xs" style="gap:5px;flex-wrap:wrap">
|
||||||
|
<span v-for="r in allDayRoutes" :key="'chip' + r.id" class="rt-chip" :class="{ off: hiddenRouteTechs.has(r.id) }" :style="hiddenRouteTechs.has(r.id) ? {} : { borderColor: r.color, background: r.color + '14' }" @click="toggleRouteTech(r.id)">
|
||||||
|
<span class="rt-dot" :style="{ background: hiddenRouteTechs.has(r.id) ? '#cbd5e1' : r.color }"></span>{{ r.name }}
|
||||||
|
<span class="rt-n" :style="{ background: hiddenRouteTechs.has(r.id) ? '#94a3b8' : r.color }">{{ r.stops.length }}</span>
|
||||||
|
<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>
|
||||||
<RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" />
|
<RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" />
|
||||||
<div class="suggest-legend q-mt-xs">
|
<div class="suggest-legend q-mt-xs">
|
||||||
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)">
|
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)">
|
||||||
|
|
@ -1109,7 +1117,12 @@
|
||||||
<q-icon v-if="e.eff && e.eff < 0.95" name="bolt" size="12px" color="green-6" class="q-mr-xs"><q-tooltip>Tech rapide sur ce type ({{ e.eff }})</q-tooltip></q-icon>
|
<q-icon v-if="e.eff && e.eff < 0.95" name="bolt" size="12px" color="green-6" class="q-mr-xs"><q-tooltip>Tech rapide sur ce type ({{ e.eff }})</q-tooltip></q-icon>
|
||||||
<!-- Coordination d'adresse : 🔗 teal = épinglé sur le tech qui a DÉJÀ un job à cette adresse · 🔗 indigo = N jobs du pool fusionnés en 1 arrêt -->
|
<!-- Coordination d'adresse : 🔗 teal = épinglé sur le tech qui a DÉJÀ un job à cette adresse · 🔗 indigo = N jobs du pool fusionnés en 1 arrêt -->
|
||||||
<q-icon v-if="e.coordWith" name="link" size="14px" color="teal-7" class="q-mr-xs"><q-tooltip>Coordonné : même adresse que « {{ e.coordWith }} » déjà assigné à {{ e.techName }} — placé sur SON arrêt</q-tooltip></q-icon>
|
<q-icon v-if="e.coordWith" name="link" size="14px" color="teal-7" class="q-mr-xs"><q-tooltip>Coordonné : même adresse que « {{ e.coordWith }} » déjà assigné à {{ e.techName }} — placé sur SON arrêt</q-tooltip></q-icon>
|
||||||
<q-icon v-else-if="e.sameAddrN > 1" name="link" size="14px" color="indigo-4" class="q-mr-xs"><q-tooltip>{{ e.sameAddrN }} jobs à la MÊME adresse — regroupés en 1 seul arrêt</q-tooltip></q-icon>
|
<q-btn v-else-if="e.sameAddrN > 1" flat dense round size="xs" :icon="e.grouped ? 'inventory_2' : 'link'" :color="e.grouped ? 'indigo-7' : 'indigo-4'" class="q-mr-xs">
|
||||||
|
<q-tooltip>{{ e.grouped ? 'Groupé en projet 📦' : e.sameAddrN + ' jobs à la MÊME adresse — 1 seul arrêt · clic pour grouper en projet' }}</q-tooltip>
|
||||||
|
<q-menu auto-close><q-list dense style="min-width:300px">
|
||||||
|
<q-item clickable @click="groupSameAddr(e, g, d)" :disable="!!e.grouped"><q-item-section avatar style="min-width:32px"><q-icon name="inventory_2" size="18px" color="indigo" /></q-item-section><q-item-section>📦 Grouper en « projet » ({{ e.sameAddrN }} tâches même adresse — parent = la plus longue)</q-item-section></q-item>
|
||||||
|
</q-list></q-menu>
|
||||||
|
</q-btn>
|
||||||
<span class="suggest-entry-t ellipsis suggest-clk" @click="openEntryDetail(e, g)" title="Voir le détail du ticket (adresse, description, fil)">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span>
|
<span class="suggest-entry-t ellipsis suggest-clk" @click="openEntryDetail(e, g)" title="Voir le détail du ticket (adresse, description, fil)">{{ e.subject }}<span v-if="e.customer" class="text-grey-6"> · {{ e.customer }}</span></span>
|
||||||
<span class="suggest-dur q-mx-xs" title="Temps sur place — cliquer pour réviser">{{ Math.round(e.dur * 10) / 10 }}h
|
<span class="suggest-dur q-mx-xs" title="Temps sur place — cliquer pour réviser">{{ Math.round(e.dur * 10) / 10 }}h
|
||||||
<q-popup-edit :model-value="e.dur" v-slot="scope" buttons label-set="OK" label-cancel="Annuler" @save="v => setEntryDur(e, v)">
|
<q-popup-edit :model-value="e.dur" v-slot="scope" buttons label-set="OK" label-cancel="Annuler" @save="v => setEntryDur(e, v)">
|
||||||
|
|
@ -1144,6 +1157,7 @@
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list></q-menu>
|
</q-list></q-menu>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
<q-btn flat dense round size="sm" icon="computer" :color="isJobRemote(e.jobName) ? 'indigo-6' : 'grey-4'" @click="toggleJobRemote(e)"><q-tooltip>{{ isJobRemote(e.jobName) ? '🖥 SANS déplacement (à distance) — Ré-optimiser pour l\'exclure des tournées' : 'Marquer « sans déplacement » (configurable à distance, ex. boîtier tel via ACS)' }}</q-tooltip></q-btn>
|
||||||
<q-btn flat dense round size="sm" icon="inbox" color="grey-6" @click="removeSuggestEntry(e)"><q-tooltip>Retirer (le job reste au pool)</q-tooltip></q-btn>
|
<q-btn flat dense round size="sm" icon="inbox" color="grey-6" @click="removeSuggestEntry(e)"><q-tooltip>Retirer (le job reste au pool)</q-tooltip></q-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="legInfo(g, d, ei)" class="suggest-leg-time" :title="legInfo(g, d, ei).real ? 'Temps de route réel (OSRM)' : 'Estimation à vol d\'oiseau'">🚗 {{ legInfo(g, d, ei).mins }} min{{ legInfo(g, d, ei).real ? '' : ' ≈' }}</div>
|
<div v-if="legInfo(g, d, ei)" class="suggest-leg-time" :title="legInfo(g, d, ei).real ? 'Temps de route réel (OSRM)' : 'Estimation à vol d\'oiseau'">🚗 {{ legInfo(g, d, ei).mins }} min{{ legInfo(g, d, ei).real ? '' : ' ≈' }}</div>
|
||||||
|
|
@ -1868,7 +1882,7 @@ const dayList = computed(() => {
|
||||||
function dowOf (iso) { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() }
|
function dowOf (iso) { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() }
|
||||||
const isWeekendIso = iso => { const d = dowOf(iso); return d === 0 || d === 6 } // samedi/dimanche → PAS de quart auto (techs de fin de semaine dédiés)
|
const isWeekendIso = iso => { const d = dowOf(iso); return d === 0 || d === 6 } // samedi/dimanche → PAS de quart auto (techs de fin de semaine dédiés)
|
||||||
// File PLACEHOLDER (dispatch auto) : pas un vrai tech → « à assigner ». `__wknd__` (legacy) + `__hold__|<iso>|<n>` (tournées sans quart, tout jour).
|
// File PLACEHOLDER (dispatch auto) : pas un vrai tech → « à assigner ». `__wknd__` (legacy) + `__hold__|<iso>|<n>` (tournées sans quart, tout jour).
|
||||||
const isHoldId = id => id === '__wknd__' || id === '__unassigned__' || (typeof id === 'string' && id.startsWith('__hold__'))
|
const isHoldId = id => id === '__wknd__' || id === '__unassigned__' || id === '__remote__' || (typeof id === 'string' && id.startsWith('__hold__'))
|
||||||
// ── MODE JOUR (style Gaiia) : 1 seule colonne pleine largeur → timelines hautes + règle d'heures, idéal pour disposer
|
// ── MODE JOUR (style Gaiia) : 1 seule colonne pleine largeur → timelines hautes + règle d'heures, idéal pour disposer
|
||||||
// les jobs (glisser depuis le panneau « jobs du jour » vers un tech, réordonner via l'éditeur de jour). ─────────────
|
// les jobs (glisser depuis le panneau « jobs du jour » vers un tech, réordonner via l'éditeur de jour). ─────────────
|
||||||
const dayMode = computed(() => dayList.value.length === 1)
|
const dayMode = computed(() => dayList.value.length === 1)
|
||||||
|
|
@ -3972,6 +3986,30 @@ function legHome (g, d) { const v = (legsByKey[g.techId + '|' + d.iso] || {}).ho
|
||||||
const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge)
|
const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge)
|
||||||
function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} }
|
function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} }
|
||||||
function toggleJobAmpm (name, v) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, ampm: c.ampm === v ? null : v } }
|
function toggleJobAmpm (name, v) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, ampm: c.ampm === v ? null : v } }
|
||||||
|
// 🖥 « SANS DÉPLACEMENT » (à distance, ex. config boîtier tel via ACS) : persistant (store hub), exclu des tournées par l'optimiseur.
|
||||||
|
const poolRemote = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = !!j.remote; return m })
|
||||||
|
function isJobRemote (name) { return !!poolRemote.value[name] }
|
||||||
|
async function toggleJobRemote (e) {
|
||||||
|
const j = (assignPanel.jobs || []).find(x => x.name === e.jobName); const next = !(j ? j.remote : e.remote)
|
||||||
|
if (j) j.remote = next; e.remote = next // optimiste
|
||||||
|
try { await roster.setJobRemote(e.jobName, next) } catch (er) { if (j) j.remote = !next; e.remote = !next; err(er); return }
|
||||||
|
$q.notify({ type: 'info', icon: 'computer', message: next ? '🖥 Sans déplacement — Ré-optimiser pour l\'exclure des tournées' : 'Déplacement requis rétabli — Ré-optimiser', timeout: 2600 })
|
||||||
|
}
|
||||||
|
// 📦 Grouper les tâches MÊME ADRESSE en « projet » (parent_job ERPNext) — parent = la plus longue, les autres deviennent sous-tâches.
|
||||||
|
async function groupSameAddr (e, g, d) {
|
||||||
|
const key = x => (x.lat != null && x.lon != null) ? (+x.lat).toFixed(4) + ',' + (+x.lon).toFixed(4) : null
|
||||||
|
const k = key(e); if (!k) return
|
||||||
|
const grp = d.entries.filter(x => key(x) === k)
|
||||||
|
if (grp.length < 2) { $q.notify({ type: 'info', message: 'Une seule tâche à cette adresse dans ce plan.' }); return }
|
||||||
|
const parent = grp.slice().sort((a, b) => (b.dur || 0) - (a.dur || 0))[0]
|
||||||
|
const children = grp.filter(x => x !== parent).map(x => x.jobName)
|
||||||
|
try {
|
||||||
|
const r = await roster.groupJobs(parent.jobName, children)
|
||||||
|
if (!r || !r.ok) throw new Error((r && r.error) || 'échec du groupement')
|
||||||
|
grp.forEach(x => { x.grouped = true })
|
||||||
|
$q.notify({ type: 'positive', icon: 'inventory_2', message: `📦 Projet créé : « ${(parent.subject || '').slice(0, 42)} » + ${children.length} sous-tâche(s)`, timeout: 3500 })
|
||||||
|
} catch (er) { err(er) }
|
||||||
|
}
|
||||||
// PRIORITÉ du job — MÊME drapeau que les conversations (priorityMeta) + champ ERPNext STANDARD (low/medium/high) via patchJob, comme la fiche du pool. Une seule source.
|
// PRIORITÉ du job — MÊME drapeau que les conversations (priorityMeta) + champ ERPNext STANDARD (low/medium/high) via patchJob, comme la fiche du pool. Une seule source.
|
||||||
const poolPriority = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = String(j.priority || '').toLowerCase(); return m })
|
const poolPriority = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = String(j.priority || '').toLowerCase(); return m })
|
||||||
function jobPriorityOf (name) { return poolPriority.value[name] || '' } // medium → priorityMeta = contour gris (non classé)
|
function jobPriorityOf (name) { return poolPriority.value[name] || '' } // medium → priorityMeta = contour gris (non classé)
|
||||||
|
|
@ -4202,6 +4240,7 @@ async function optimizeSuggestion () {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for (const e of (r.unassigned || [])) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 })
|
for (const e of (r.unassigned || [])) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 })
|
||||||
|
for (const e of (r.remote || [])) out.push({ ...e, techId: '__remote__', techName: '🖥 Sans déplacement — à faire à distance (assigner à un agent)', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 })
|
||||||
suggestDlg.plan = out
|
suggestDlg.plan = out
|
||||||
return
|
return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -4331,7 +4370,9 @@ const suggestGroups = computed(() => {
|
||||||
}).sort((a, b) => b.jobs - a.jobs)
|
}).sort((a, b) => b.jobs - a.jobs)
|
||||||
})
|
})
|
||||||
// Carte des tournées (grande carte dans la revue) : 1 couleur par tech, domicile + arrêts ordonnés, POUR UN SEUL JOUR.
|
// Carte des tournées (grande carte dans la revue) : 1 couleur par tech, domicile + arrêts ordonnés, POUR UN SEUL JOUR.
|
||||||
const ROUTE_COLORS = ['#e11d48', '#2563eb', '#16a34a', '#d97706', '#7c3aed', '#0891b2', '#db2777', '#65a30d', '#0d9488', '#4f46e5', '#dc2626', '#ca8a04']
|
// Couleur de tournée par index : ANGLE D'OR (137,5°) sur la roue HSL → couleurs consécutives toujours très éloignées,
|
||||||
|
// nombre illimité de techs sans collision visuelle (remplace la palette fixe de 12 qui se ressemblait).
|
||||||
|
const routeColor = (i) => `hsl(${Math.round((i * 137.508) % 360)}, 70%, 40%)`
|
||||||
const suggestMapDay = ref('')
|
const suggestMapDay = ref('')
|
||||||
const suggestRoutes = computed(() => {
|
const suggestRoutes = computed(() => {
|
||||||
const day = suggestMapDay.value || (suggestWindow.value[0] || '')
|
const day = suggestMapDay.value || (suggestWindow.value[0] || '')
|
||||||
|
|
@ -4349,7 +4390,7 @@ const suggestRoutes = computed(() => {
|
||||||
}
|
}
|
||||||
if (!stops.length) continue
|
if (!stops.length) continue
|
||||||
const home = techOrigin(g.techId) // domicile, sinon bureau TARGO
|
const home = techOrigin(g.techId) // domicile, sinon bureau TARGO
|
||||||
out.push({ techId: g.techId, techName: g.techName, color: ROUTE_COLORS[ci % ROUTE_COLORS.length], km: d.km, mins: d.mins, home: home ? { lat: home.lat, lon: home.lon } : null, stops })
|
out.push({ techId: g.techId, techName: g.techName, color: routeColor(ci), km: d.km, mins: d.mins, home: home ? { lat: home.lat, lon: home.lon } : null, stops })
|
||||||
ci++
|
ci++
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
@ -4360,7 +4401,9 @@ watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) s
|
||||||
const routesDay = ref(null)
|
const routesDay = ref(null)
|
||||||
const routesMapRef = ref(null)
|
const routesMapRef = ref(null)
|
||||||
const dayRouteMetrics = reactive({})
|
const dayRouteMetrics = reactive({})
|
||||||
const dayRoutes = computed(() => {
|
const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic)
|
||||||
|
function toggleRouteTech (id) { const s = new Set(hiddenRouteTechs.value); if (s.has(id)) s.delete(id); else s.add(id); hiddenRouteTechs.value = s }
|
||||||
|
const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs STABLES par index, même quand on masque)
|
||||||
const iso = routesDay.value; if (!iso) return []
|
const iso = routesDay.value; if (!iso) return []
|
||||||
const out = []; let ci = 0
|
const out = []; let ci = 0
|
||||||
for (const t of (visibleTechs.value || [])) {
|
for (const t of (visibleTechs.value || [])) {
|
||||||
|
|
@ -4369,11 +4412,12 @@ const dayRoutes = computed(() => {
|
||||||
if (!jobs.length) continue
|
if (!jobs.length) continue
|
||||||
jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel
|
jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel
|
||||||
const h = techOrigin(t.id) // domicile, sinon bureau TARGO
|
const h = techOrigin(t.id) // domicile, sinon bureau TARGO
|
||||||
out.push({ id: t.id, name: t.name, color: ROUTE_COLORS[ci++ % ROUTE_COLORS.length], home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject })) })
|
out.push({ id: t.id, name: t.name, color: routeColor(ci++), home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject })) })
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k] })
|
const dayRoutes = computed(() => allDayRoutes.value.filter(r => !hiddenRouteTechs.value.has(r.id))) // ce que la carte affiche
|
||||||
|
watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() })
|
||||||
watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true })
|
watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true })
|
||||||
// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées).
|
// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées).
|
||||||
const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s))
|
const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s))
|
||||||
|
|
@ -5256,6 +5300,10 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
|
||||||
.suggest-dur { font-size: 11px; white-space: nowrap; color: #475569; border-bottom: 1px dashed #cbd5e1; cursor: pointer; }
|
.suggest-dur { font-size: 11px; white-space: nowrap; color: #475569; border-bottom: 1px dashed #cbd5e1; cursor: pointer; }
|
||||||
.suggest-dur:hover { color: #4f46e5; border-bottom-color: #4f46e5; }
|
.suggest-dur:hover { color: #4f46e5; border-bottom-color: #4f46e5; }
|
||||||
.suggest-leg-time { font-size: 10px; color: #94a3b8; padding: 0 6px 2px 30px; line-height: 1.2; }
|
.suggest-leg-time { font-size: 10px; color: #94a3b8; padding: 0 6px 2px 30px; line-height: 1.2; }
|
||||||
|
.rt-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12px; font-weight: 600; color: #334155; cursor: pointer; user-select: none; background: #fff; }
|
||||||
|
.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; }
|
||||||
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
|
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
|
||||||
.jt-chip:hover { border-color: #6366f1; }
|
.jt-chip:hover { border-color: #6366f1; }
|
||||||
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
|
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,15 @@ function setJobLevel (name, level) {
|
||||||
fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m
|
fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m
|
||||||
}
|
}
|
||||||
// Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle.
|
// Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle.
|
||||||
|
// 🖥 Job « SANS DÉPLACEMENT » (configurable à distance, ex. boîtier tel via ACS) : exclu des tournées (aucun arrêt), à faire du bureau.
|
||||||
|
const JOB_REMOTE_FILE = path.join(__dirname, '..', 'data', 'job-remote.json')
|
||||||
|
function getJobRemote () { try { return JSON.parse(fs.readFileSync(JOB_REMOTE_FILE, 'utf8')) || {} } catch { return {} } }
|
||||||
|
function setJobRemote (name, remote) {
|
||||||
|
const m = getJobRemote()
|
||||||
|
if (remote) m[String(name)] = 1; else delete m[String(name)]
|
||||||
|
try { fs.mkdirSync(path.dirname(JOB_REMOTE_FILE), { recursive: true }) } catch (e) {}
|
||||||
|
fs.writeFileSync(JOB_REMOTE_FILE, JSON.stringify(m)); return m
|
||||||
|
}
|
||||||
|
|
||||||
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
|
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
|
||||||
const PAUSE_STATUS = 'En pause'
|
const PAUSE_STATUS = 'En pause'
|
||||||
|
|
@ -180,15 +189,17 @@ async function optimizePlan (body) {
|
||||||
jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '',
|
jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '',
|
||||||
iso, dur: Math.round(dur * 100) / 100, skill: j.required_skill || '', reqLevel: Number(j.required_level) || 1,
|
iso, dur: Math.round(dur * 100) / 100, skill: j.required_skill || '', reqLevel: Number(j.required_level) || 1,
|
||||||
lat: (j.latitude != null && isFinite(+j.latitude)) ? +j.latitude : null, lon: (j.longitude != null && isFinite(+j.longitude)) ? +j.longitude : null,
|
lat: (j.latitude != null && isFinite(+j.latitude)) ? +j.latitude : null, lon: (j.longitude != null && isFinite(+j.longitude)) ? +j.longitude : null,
|
||||||
priority: String(j.priority || '').toLowerCase(),
|
priority: String(j.priority || '').toLowerCase(), remote: !!j.remote, parent: j.parent_job || null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bucket par jour : daté dans la fenêtre → son jour ; daté APRÈS → laissé pour plus tard ; en retard/sans date → 1er jour (déborde vers le suivant si non casé).
|
// Bucket par jour : daté dans la fenêtre → son jour ; daté APRÈS → laissé pour plus tard ; en retard/sans date → 1er jour (déborde vers le suivant si non casé).
|
||||||
const dset = new Set(dates); const byDay = {}; const skipped = []
|
// 🖥 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) {
|
for (const j of pool) {
|
||||||
const sched = (j.scheduled_date && /^\d{4}-\d{2}-\d{2}$/.test(String(j.scheduled_date))) ? j.scheduled_date : null
|
const sched = (j.scheduled_date && /^\d{4}-\d{2}-\d{2}$/.test(String(j.scheduled_date))) ? j.scheduled_date : null
|
||||||
if (sched && sched > hi) { skipped.push(j.name); continue }
|
if (sched && sched > hi) { skipped.push(j.name); continue }
|
||||||
const iso = (sched && dset.has(sched)) ? sched : dates[0]
|
const iso = (sched && dset.has(sched)) ? sched : dates[0]
|
||||||
|
if (j.remote) { remote.push(entryOf(j, iso, durOf(j))); continue }
|
||||||
;(byDay[iso] = byDay[iso] || []).push(j)
|
;(byDay[iso] = byDay[iso] || []).push(j)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -268,7 +279,7 @@ async function optimizePlan (body) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const j of spill) unassigned.push(entryOf(j, dates[dates.length - 1], durOf(j))) // sécurité (spill après le dernier jour)
|
for (const j of spill) unassigned.push(entryOf(j, dates[dates.length - 1], durOf(j))) // sécurité (spill après le dernier jour)
|
||||||
return { ok: true, dates, plan, unassigned, assumed_shift: assumedShift, skipped_later: skipped.length }
|
return { ok: true, dates, plan, unassigned, remote, assumed_shift: assumedShift, skipped_later: skipped.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
@ -603,8 +614,8 @@ async function buildUnassigned () {
|
||||||
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'creation', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'assigned_group', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 })
|
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'creation', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'assigned_group', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 })
|
||||||
const jobs = (rows || []).filter(j => !j.assigned_tech)
|
const jobs = (rows || []).filter(j => !j.assigned_tech)
|
||||||
const chars = readJobChar().items
|
const chars = readJobChar().items
|
||||||
const jlv = getJobLevels()
|
const jlv = getJobLevels(); const jrm = getJobRemote()
|
||||||
for (const j of jobs) { j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject); j.required_level = jlv[j.name] || 0; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels }
|
for (const j of jobs) { j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject); j.required_level = jlv[j.name] || 0; j.remote = !!jrm[j.name]; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels }
|
||||||
await attachLocations(jobs)
|
await attachLocations(jobs)
|
||||||
log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement)
|
log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement)
|
||||||
return jobs
|
return jobs
|
||||||
|
|
@ -1386,6 +1397,19 @@ async function handle (req, res, method, path, url) {
|
||||||
setJobLevel(b.name, b.level); invalidatePool() // le pool porte required_level → rafraîchir
|
setJobLevel(b.name, b.level); invalidatePool() // le pool porte required_level → rafraîchir
|
||||||
return json(res, 200, { ok: true, name: b.name, level: Math.max(0, Math.min(5, Math.round(Number(b.level) || 0))) })
|
return json(res, 200, { ok: true, name: b.name, level: Math.max(0, Math.min(5, Math.round(Number(b.level) || 0))) })
|
||||||
}
|
}
|
||||||
|
if (path === '/roster/job-remote' && method === 'POST') { // 🖥 « sans déplacement » (configurable à distance, ex. boîtier tel via ACS) — exclu des tournées
|
||||||
|
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
|
||||||
|
setJobRemote(b.name, !!b.remote); invalidatePool() // le pool porte j.remote → rafraîchir
|
||||||
|
return json(res, 200, { ok: true, name: b.name, remote: !!b.remote })
|
||||||
|
}
|
||||||
|
if (path === '/roster/job-group' && method === 'POST') { // 📦 grouper des tâches (même adresse) en « projet » : parent_job ERPNext sur les enfants
|
||||||
|
const b = await parseBody(req); const kids = (Array.isArray(b.children) ? b.children : []).filter(c => c && c !== b.parent)
|
||||||
|
if (!b.parent || !kids.length) return json(res, 400, { error: 'parent + children[] requis' })
|
||||||
|
const results = []
|
||||||
|
for (const c of kids) { const r = await retryWrite(() => erp.update('Dispatch Job', c, { parent_job: b.parent })); results.push({ job: c, ok: !!r.ok }) }
|
||||||
|
invalidatePool()
|
||||||
|
return json(res, 200, { ok: results.every(x => x.ok), parent: b.parent, results })
|
||||||
|
}
|
||||||
// Persister UN quart immédiatement (ex. « Créer un quart et assigner » sur un tech sans quart) — sinon `addShift` reste LOCAL
|
// Persister UN quart immédiatement (ex. « Créer un quart et assigner » sur un tech sans quart) — sinon `addShift` reste LOCAL
|
||||||
// (status Proposé en mémoire) et le quart disparaît au rechargement. Idempotent par clé tech|date|template (pas de doublon vs Publier).
|
// (status Proposé en mémoire) et le quart disparaît au rechargement. Idempotent par clé tech|date|template (pas de doublon vs Publier).
|
||||||
if (path === '/roster/shift' && method === 'POST') {
|
if (path === '/roster/shift' && method === 'POST') {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user