fix(dispatch): reuse the Communications priority flag (grey outline → list), not a red emoji
The urgent flag was a red emoji shown red before being set — wrong. Now reuses the exact Communications component: priorityMeta() + a priority list (Urgent/Haute/Normale/Basse/Aucune) — grey outlined_flag by default, colored only when set. - SPA: per-job flag = q-btn(icon=priorityMeta(...).icon) + q-menu list, same as ConversationPanel. setEntryPriority(name,lvl) (renamed to avoid clashing with the existing pool setJobPriority(j,p)). medium (ERPNext default) shows as grey/none. - hub: job-flags store now holds a dispatch PRIORITY (urgent|high|normal| low) overriding j.priority in buildUnassigned — needed because the ERPNext job-priority field is whitelisted to low/medium/high (can't do urgent), just like conversations keep their own priority. Lesson applied: grep for an existing symbol before declaring (2nd collision this session — techsForJob, setJobPriority). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
86bd8080d2
commit
4e5ad011ee
|
|
@ -53,7 +53,7 @@ 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 setJobFlag = (name, urgent) => jpost('/roster/job-flag', { name, urgent }) // drapeau urgent persistant par job (SLA/commercial)
|
export const setJobFlag = (name, priority) => jpost('/roster/job-flag', { name, priority }) // priorité de dispatch persistante par job (SLA/commercial)
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -1078,7 +1078,20 @@
|
||||||
<q-icon v-if="!e.capable" name="warning" size="14px" color="deep-orange" class="q-mr-xs"><q-tooltip>{{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner</q-tooltip></q-icon>
|
<q-icon v-if="!e.capable" name="warning" size="14px" color="deep-orange" class="q-mr-xs"><q-tooltip>{{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner</q-tooltip></q-icon>
|
||||||
<q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais ⚠ hors quart)</q-tooltip></q-icon>
|
<q-icon v-if="e.noShift" name="event_busy" size="14px" color="amber-8" class="q-mr-xs"><q-tooltip>Aucun quart publié ce jour (assignable, mais ⚠ hors quart)</q-tooltip></q-icon>
|
||||||
<!-- Contraintes horaires (prises en compte par ⚡ Ré-optimiser) : AM/PM = fenêtre · ⚡ = urgent, servir en début de journée -->
|
<!-- Contraintes horaires (prises en compte par ⚡ Ré-optimiser) : AM/PM = fenêtre · ⚡ = urgent, servir en début de journée -->
|
||||||
<span class="jt-chip jt-urgent" :class="{ on: isJobUrgent(e.jobName) }" @click="toggleJobUrgent(e.jobName)"><q-tooltip>Drapeau URGENT persistant (client SLA/commercial) — jamais laissé de côté + servi en début de journée. Ré-optimiser pour appliquer.</q-tooltip>🚩</span>
|
<!-- PRIORITÉ persistante (MÊME drapeau que les conversations) : contour gris = non classé, coloré = niveau. Liste rapide. -->
|
||||||
|
<q-btn flat dense round size="sm" :icon="priorityMeta(jobPriorityOf(e.jobName)).icon" :color="priorityMeta(jobPriorityOf(e.jobName)).color">
|
||||||
|
<q-tooltip>Priorité : {{ priorityMeta(jobPriorityOf(e.jobName)).label }} (persistante — SLA/commercial). Ré-optimiser pour appliquer.</q-tooltip>
|
||||||
|
<q-menu auto-close anchor="bottom right" self="top right"><q-list dense style="min-width:180px">
|
||||||
|
<q-item-label header class="q-py-xs">Priorité</q-item-label>
|
||||||
|
<q-item v-for="lvl in PRIORITY_LEVELS" :key="lvl" clickable @click="setEntryPriority(e.jobName,lvl)" :active="jobPriorityOf(e.jobName) === lvl" active-class="bg-green-1">
|
||||||
|
<q-item-section avatar><q-icon name="flag" :color="priorityMeta(lvl).color" size="18px" /></q-item-section>
|
||||||
|
<q-item-section>{{ priorityMeta(lvl).label }}</q-item-section>
|
||||||
|
<q-item-section side v-if="jobPriorityOf(e.jobName) === lvl"><q-icon name="check" color="primary" size="16px" /></q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-separator />
|
||||||
|
<q-item clickable @click="setEntryPriority(e.jobName,'')" :active="!jobPriorityOf(e.jobName)"><q-item-section avatar><q-icon name="outlined_flag" color="grey-5" size="18px" /></q-item-section><q-item-section class="text-grey-7">Aucune</q-item-section></q-item>
|
||||||
|
</q-list></q-menu>
|
||||||
|
</q-btn>
|
||||||
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'am' }" @click="toggleJobAmpm(e.jobName, 'am')"><q-tooltip>Avant-midi (8-12) — Ré-optimiser pour appliquer</q-tooltip>AM</span>
|
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'am' }" @click="toggleJobAmpm(e.jobName, 'am')"><q-tooltip>Avant-midi (8-12) — Ré-optimiser pour appliquer</q-tooltip>AM</span>
|
||||||
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'pm' }" @click="toggleJobAmpm(e.jobName, 'pm')"><q-tooltip>Après-midi (12-16) — Ré-optimiser pour appliquer</q-tooltip>PM</span>
|
<span class="jt-chip" :class="{ on: jobTimeOf(e.jobName).ampm === 'pm' }" @click="toggleJobAmpm(e.jobName, 'pm')"><q-tooltip>Après-midi (12-16) — Ré-optimiser pour appliquer</q-tooltip>PM</span>
|
||||||
<q-space />
|
<q-space />
|
||||||
|
|
@ -1669,7 +1682,7 @@ import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API
|
||||||
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
|
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
|
||||||
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
|
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
|
||||||
import { relTime } from 'src/composables/useFormatters' // temps relatif COHÉRENT (même formateur que la Boîte)
|
import { relTime } from 'src/composables/useFormatters' // temps relatif COHÉRENT (même formateur que la Boîte)
|
||||||
import { messageIdentity } from 'src/composables/useConversationDisplay' // resolvers partagés nom/initiales/couleur (cohérence des fils)
|
import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations
|
||||||
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
|
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
|
||||||
import TechSelect from 'src/components/shared/TechSelect.vue'
|
import TechSelect from 'src/components/shared/TechSelect.vue'
|
||||||
import SkillSelect from 'src/components/shared/SkillSelect.vue'
|
import SkillSelect from 'src/components/shared/SkillSelect.vue'
|
||||||
|
|
@ -3917,14 +3930,14 @@ 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)
|
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 } }
|
||||||
// Drapeau URGENT PERSISTANT par job (comme le flag urgent des conversations) — SLA/commercial. Écrit au hub + reflété localement.
|
// PRIORITÉ de dispatch PERSISTANTE par job — MÊME composant que les conversations (priorityMeta : drapeau contour gris → coloré + liste de niveaux). SLA/commercial.
|
||||||
const poolUrgent = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = !!j.urgent; return m })
|
const poolPriority = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = String(j.priority || '').toLowerCase(); return m })
|
||||||
function isJobUrgent (name) { return !!poolUrgent.value[name] }
|
function jobPriorityOf (name) { const p = poolPriority.value[name] || ''; return (p === 'medium' || p === 'moyenne') ? '' : p } // medium ERPNext = non classé → drapeau contour gris (invite à classer)
|
||||||
async function toggleJobUrgent (name) {
|
async function setEntryPriority (name, lvl) { // priorité de DISPATCH (store override, supporte urgent que le champ ERPNext low/medium/high ne fait pas)
|
||||||
const j = (assignPanel.jobs || []).find(x => x.name === name); const next = !(j && j.urgent)
|
const j = (assignPanel.jobs || []).find(x => x.name === name); const prev = j ? j.priority : undefined
|
||||||
if (j) j.urgent = next // optimiste
|
if (j) j.priority = lvl || 'medium' // optimiste
|
||||||
try { await roster.setJobFlag(name, next) } catch (e) { if (j) j.urgent = !next; err(e); return }
|
try { await roster.setJobFlag(name, lvl) } catch (e) { if (j) j.priority = prev; 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 })
|
$q.notify({ type: 'info', icon: priorityMeta(lvl).icon, message: 'Priorité : ' + priorityMeta(lvl).label, timeout: 1600 })
|
||||||
}
|
}
|
||||||
// Réglages de l'optimiseur VRP (curseur distance↔spécialité, vitesse repli, budget de calcul) — persistés localement, ajustables sans redéploiement.
|
// 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 })
|
const solverOpts = reactive({ rankWeight: 2, speedKmh: 45, maxSeconds: 8 })
|
||||||
|
|
@ -4145,10 +4158,8 @@ async function optimizeSuggestion () {
|
||||||
const jobs = entries.map(e => {
|
const jobs = entries.map(e => {
|
||||||
const jt = suggestDlg.jobTime[e.jobName] || {}
|
const jt = suggestDlg.jobTime[e.jobName] || {}
|
||||||
const win = AMPM_WIN[jt.ampm] // AM/PM → fenêtre horaire dure
|
const win = AMPM_WIN[jt.ampm] // AM/PM → fenêtre horaire dure
|
||||||
const urg = poolUrgent.value[e.jobName] // drapeau URGENT persistant (SLA/commercial)
|
const p = poolPr[e.jobName] // priorité (surcharge persistante incluse via buildUnassigned) : urgent/high jamais lâché + servi tôt
|
||||||
const priority_boost = Math.max(prBoost(poolPr[e.jobName]), urg ? 400000 : 0) // urgent = jamais lâché
|
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(p), urgent_weight: prEarly(p), ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) }
|
||||||
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 = []
|
const vehicles = []
|
||||||
for (const t of selTechs) {
|
for (const t of selTechs) {
|
||||||
|
|
@ -5110,7 +5121,6 @@ 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 { 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; }
|
||||||
.jt-urgent.on { background: #ef4444; border-color: #ef4444; color: #fff; }
|
|
||||||
.suggest-day { padding: 2px 8px 6px; }
|
.suggest-day { padding: 2px 8px 6px; }
|
||||||
.suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; }
|
.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; }
|
.suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; }
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,14 @@ function setJobLevel (name, level) {
|
||||||
try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {}
|
try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {}
|
||||||
fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m
|
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.
|
// PRIORITÉ de dispatch persistante par job (même notion que la priorité des conversations : urgent|high|normal|low).
|
||||||
|
// Store durable = SURCHARGE de la priorité ERPNext pour le dispatch (client commercial/SLA/contrainte).
|
||||||
const JOB_FLAGS_FILE = path.join(__dirname, '..', 'data', 'job-flags.json')
|
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 getJobFlags () { try { return JSON.parse(fs.readFileSync(JOB_FLAGS_FILE, 'utf8')) || {} } catch { return {} } }
|
||||||
function setJobFlag (name, urgent) {
|
const PRIO_LEVELS = ['urgent', 'high', 'normal', 'low']
|
||||||
const m = getJobFlags()
|
function setJobFlag (name, priority) {
|
||||||
if (urgent) m[String(name)] = 1; else delete m[String(name)]
|
const m = getJobFlags(); const p = String(priority || '').toLowerCase()
|
||||||
|
if (PRIO_LEVELS.includes(p)) m[String(name)] = p; else delete m[String(name)] // niveau valide → surcharge ; sinon (vide/« aucune ») → retire
|
||||||
try { fs.mkdirSync(path.dirname(JOB_FLAGS_FILE), { recursive: true }) } catch (e) {}
|
try { fs.mkdirSync(path.dirname(JOB_FLAGS_FILE), { recursive: true }) } catch (e) {}
|
||||||
fs.writeFileSync(JOB_FLAGS_FILE, JSON.stringify(m)); return m
|
fs.writeFileSync(JOB_FLAGS_FILE, JSON.stringify(m)); return m
|
||||||
}
|
}
|
||||||
|
|
@ -455,7 +457,7 @@ async function buildUnassigned () {
|
||||||
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 jfl = getJobFlags()
|
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 }
|
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; if (jfl[j.name]) j.priority = jfl[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
|
||||||
|
|
@ -1212,10 +1214,11 @@ 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-flag' && method === 'POST') { // drapeau URGENT persistant par job (SLA/commercial) → store hub durable
|
if (path === '/roster/job-flag' && method === 'POST') { // PRIORITÉ de dispatch persistante par job (SLA/commercial) → store hub durable (surcharge)
|
||||||
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
|
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
|
const p = String(b.priority || '').toLowerCase()
|
||||||
return json(res, 200, { ok: true, name: b.name, urgent: !!b.urgent })
|
setJobFlag(b.name, p); invalidatePool() // le pool porte j.priority → rafraîchir
|
||||||
|
return json(res, 200, { ok: true, name: b.name, priority: PRIO_LEVELS.includes(p) ? p : '' })
|
||||||
}
|
}
|
||||||
// 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).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user