diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js
index d6e3384..764d057 100644
--- a/apps/ops/src/api/roster.js
+++ b/apps/ops/src/api/roster.js
@@ -53,6 +53,7 @@ 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 setJobFlag = (name, urgent) => jpost('/roster/job-flag', { name, urgent }) // drapeau urgent persistant par job (SLA/commercial)
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 ee14949..135476a 100644
--- a/apps/ops/src/pages/PlanificationPage.vue
+++ b/apps/ops/src/pages/PlanificationPage.vue
@@ -1076,9 +1076,9 @@
{{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner
Aucun quart publié ce jour (assignable, mais ⚠ hors quart)
+ Drapeau URGENT persistant (client SLA/commercial) — jamais laissé de côté + servi en début de journée. Ré-optimiser pour appliquer.🚩
Avant-midi (8-12) — Ré-optimiser pour appliquerAM
Après-midi (12-16) — Ré-optimiser pour appliquerPM
- Urgent — servir en DÉBUT de journée (Ré-optimiser)⚡
Déplacer vers un autre tech (classés par proximité + charge)
@@ -3914,7 +3914,15 @@ const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: f
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 } }
-function toggleJobEarly (name) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, early: !c.early } }
+// Drapeau URGENT PERSISTANT par job (comme le flag urgent des conversations) — SLA/commercial. Écrit au hub + reflété localement.
+const poolUrgent = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = !!j.urgent; return m })
+function isJobUrgent (name) { return !!poolUrgent.value[name] }
+async function toggleJobUrgent (name) {
+ const j = (assignPanel.jobs || []).find(x => x.name === name); const next = !(j && j.urgent)
+ if (j) j.urgent = next // optimiste
+ try { await roster.setJobFlag(name, next) } catch (e) { if (j) j.urgent = !next; err(e); return }
+ $q.notify({ type: next ? 'warning' : 'info', icon: next ? 'flag' : 'outlined_flag', message: next ? 'Job marqué URGENT (prioritaire, servi tôt)' : 'Drapeau urgent retiré', timeout: 2000 })
+}
// Réglages de l'optimiseur VRP (curseur distance↔spécialité, vitesse repli, budget de calcul) — persistés localement, ajustables sans redéploiement.
const solverOpts = reactive({ rankWeight: 2, speedKmh: 45, maxSeconds: 8 })
try { const s = JSON.parse(localStorage.getItem('ops.solverOpts') || 'null'); if (s && typeof s === 'object') Object.assign(solverOpts, s) } catch (e) { /* défauts */ }
@@ -4128,7 +4136,7 @@ async function optimizeSuggestion () {
// Priorité du job → « priority_boost » (pénalité de drop) : URGENT jamais laissé de côté ; BASSE lâchée en 1er si la capacité manque.
const poolPr = Object.fromEntries((assignPanel.jobs || []).map(j => [j.name, String(j.priority || '').toLowerCase()]))
const prBoost = p => ({ urgent: 400000, high: 400000, 'élevée': 400000, medium: 0, moyenne: 0, normal: 0, low: -60000, basse: -60000 }[p] ?? 0)
- const prEarly = p => ({ urgent: 6, high: 3, 'élevée': 3 }[p] || 0) // priorité → poids « servir tôt »
+ const prEarly = p => ({ urgent: 10, high: 4, 'élevée': 4 }[p] || 0) // priorité → poids « servir tôt »
const out = [...kept]
for (const iso of Object.keys(byDay)) {
const entries = byDay[iso]
@@ -4136,8 +4144,10 @@ async function optimizeSuggestion () {
const jobs = entries.map(e => {
const jt = suggestDlg.jobTime[e.jobName] || {}
const win = AMPM_WIN[jt.ampm] // AM/PM → fenêtre horaire dure
- const urgent_weight = Math.max(prEarly(poolPr[e.jobName]), jt.early ? 12 : 0) // urgence (priorité OU ⚡ manuel) → début de journée
- return { id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '', priority_boost: prBoost(poolPr[e.jobName]), urgent_weight, ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) }
+ const urg = poolUrgent.value[e.jobName] // drapeau URGENT persistant (SLA/commercial)
+ const priority_boost = Math.max(prBoost(poolPr[e.jobName]), urg ? 400000 : 0) // urgent = jamais lâché
+ const urgent_weight = Math.max(prEarly(poolPr[e.jobName]), urg ? 10 : 0) // urgent = servi tôt (début de journée)
+ return { id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '', priority_boost, urgent_weight, ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) }
})
const vehicles = []
for (const t of selTechs) {
@@ -5094,7 +5104,7 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.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; }
-.jt-early.on { background: #f59e0b; border-color: #f59e0b; }
+.jt-urgent.on { background: #ef4444; border-color: #ef4444; color: #fff; }
.suggest-day { padding: 2px 8px 6px; }
.suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; }
.suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; }
diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js
index e28f285..21b693f 100644
--- a/services/targo-hub/lib/roster.js
+++ b/services/targo-hub/lib/roster.js
@@ -45,6 +45,15 @@ function setJobLevel (name, level) {
try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {}
fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m
}
+// Drapeau URGENT persistant par job (comme le flag urgent des conversations) — client commercial/SLA/contrainte.
+const JOB_FLAGS_FILE = path.join(__dirname, '..', 'data', 'job-flags.json')
+function getJobFlags () { try { return JSON.parse(fs.readFileSync(JOB_FLAGS_FILE, 'utf8')) || {} } catch { return {} } }
+function setJobFlag (name, urgent) {
+ const m = getJobFlags()
+ if (urgent) m[String(name)] = 1; else delete m[String(name)]
+ try { fs.mkdirSync(path.dirname(JOB_FLAGS_FILE), { recursive: true }) } catch (e) {}
+ fs.writeFileSync(JOB_FLAGS_FILE, JSON.stringify(m)); return m
+}
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
const PAUSE_STATUS = 'En pause'
@@ -445,8 +454,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 jfl = getJobFlags()
+ 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.urgent = !!jfl[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
@@ -1203,6 +1212,11 @@ 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-flag' && method === 'POST') { // drapeau URGENT persistant par job (SLA/commercial) → store hub durable
+ const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
+ setJobFlag(b.name, !!b.urgent); invalidatePool() // le pool porte j.urgent → rafraîchir
+ return json(res, 200, { ok: true, name: b.name, urgent: !!b.urgent })
+ }
// 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') {