diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js
index 87b4137..b41fb5e 100644
--- a/apps/ops/src/api/roster.js
+++ b/apps/ops/src/api/roster.js
@@ -53,6 +53,8 @@ export const askAssistant = (message, history) => jpost('/roster/assistant', { m
export const getPolicy = () => jget('/roster/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 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) {
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)
diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue
index 512692c..65bf6e2 100644
--- a/apps/ops/src/pages/PlanificationPage.vue
+++ b/apps/ops/src/pages/PlanificationPage.vue
@@ -480,6 +480,14 @@
{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail
+
+
+
+ {{ r.name }}
+ {{ r.stops.length }}
+ {{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }}
+
+
Object.assign(dayRouteMetrics, m)" />
@@ -1109,7 +1117,12 @@
Tech rapide sur ce type ({{ e.eff }})
Coordonné : même adresse que « {{ e.coordWith }} » déjà assigné à {{ e.techName }} — placé sur SON arrêt
- {{ e.sameAddrN }} jobs à la MÊME adresse — regroupés en 1 seul arrêt
+
+ {{ e.grouped ? 'Groupé en projet 📦' : e.sameAddrN + ' jobs à la MÊME adresse — 1 seul arrêt · clic pour grouper en projet' }}
+
+ 📦 Grouper en « projet » ({{ e.sameAddrN }} tâches même adresse — parent = la plus longue)
+
+
{{ e.subject }} · {{ e.customer }}
{{ Math.round(e.dur * 10) / 10 }}h
setEntryDur(e, v)">
@@ -1144,6 +1157,7 @@
+ {{ 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)' }}
Retirer (le job reste au pool)
🚗 {{ legInfo(g, d, ei).mins }} min{{ legInfo(g, d, ei).real ? '' : ' ≈' }}
@@ -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() }
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__||` (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
// 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)
@@ -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)
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 } }
+// 🖥 « 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.
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é)
@@ -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.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
return
} catch (e) {
@@ -4331,7 +4370,9 @@ const suggestGroups = computed(() => {
}).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.
-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 suggestRoutes = computed(() => {
const day = suggestMapDay.value || (suggestWindow.value[0] || '')
@@ -4349,7 +4390,7 @@ const suggestRoutes = computed(() => {
}
if (!stops.length) continue
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++
}
return out
@@ -4360,7 +4401,9 @@ watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) s
const routesDay = ref(null)
const routesMapRef = ref(null)
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 out = []; let ci = 0
for (const t of (visibleTechs.value || [])) {
@@ -4369,11 +4412,12 @@ const dayRoutes = computed(() => {
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
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
})
-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 })
// 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))
@@ -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:hover { color: #4f46e5; border-bottom-color: #4f46e5; }
.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:hover { border-color: #6366f1; }
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js
index 31c320b..a0aea9f 100644
--- a/services/targo-hub/lib/roster.js
+++ b/services/targo-hub/lib/roster.js
@@ -46,6 +46,15 @@ function setJobLevel (name, level) {
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.
+// 🖥 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 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 || '',
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,
- 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é).
- 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) {
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]
+ if (j.remote) { remote.push(entryOf(j, iso, durOf(j))); continue }
;(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)
- 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))
@@ -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 jobs = (rows || []).filter(j => !j.assigned_tech)
const chars = readJobChar().items
- const jlv = getJobLevels()
- 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 }
+ 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; j.remote = !!jrm[j.name]; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels }
await attachLocations(jobs)
log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement)
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
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
// (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') {