refactor(ops/dispatch): consolidate top toolbar with overflow ⋯ menu

The header right-side was getting noisy — 8 buttons + 2 indicators
all competing for screen width, with two visually-similar 📡 icons
(offer pool vs GPS settings) that confused dispatchers. On narrower
laptops the bar wrapped or icons overflowed.

New layout:

  [⚠ overload] [📋 unassigned + count] [🗺 Carte] [Publier + count] [+ WO] [⋯]

Everything else dropped into the ⋯ dropdown:
  • ↻ Actualiser
  •  Assistant IA
  • 📡 Offres aux techs (with green count badge)
  • 👥 Ressources & GPS  ← was 📡 in the bar; this is also where
                          the tech-management UI (rename, deactivate,
                          home location, Traccar device link) lives
  • ↗ Ouvrir ERPNext (with the inline status dot)

The ⋯ menu closes on Escape, on click outside, and after picking an
item. Same close-handler chain that already serves the job/tech
context menus.

The kept-up-front buttons all have either a status badge (counts,
overload alert) or are the primary CTAs (Publier, + WO) — so the
dispatcher's eye stays on workflow signal, not on chrome.
This commit is contained in:
louispaulb 2026-05-05 14:07:04 -04:00
parent c96092e9e8
commit 96a84c3e48
2 changed files with 58 additions and 11 deletions

View File

@ -396,6 +396,7 @@ const periodEndStr = computed(() => {
return localDateStr(d)
})
const onPublished = jobNames => store.publishJobsLocal(jobNames)
const moreMenuOpen = ref(false) // dropdown in the top toolbar (right side)
const gpsSettingsOpen = ref(false)
const gpsShowInactive = ref(false)
const gpsFilteredTechs = computed(() =>
@ -959,6 +960,7 @@ function onKeyDown (e) {
dispatchCriteriaModal.value = false; bookingOverlay.value = null
filterPanelOpen.value = false; projectsPanelOpen.value = false
selectedJob.value = null; multiSelect.value = []
moreMenuOpen.value = false
if (geoFixTech.value) cancelTechGeoFix()
}
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
@ -1172,7 +1174,7 @@ onMounted(async () => {
loadOffers()
loadPresets()
document.addEventListener('keydown', onKeyDown)
document.addEventListener('click', () => { closeCtxMenu(); assistCtx.value = null; techCtx.value = null })
document.addEventListener('click', () => { closeCtxMenu(); assistCtx.value = null; techCtx.value = null; moreMenuOpen.value = false })
if (!document.getElementById('mapbox-css')) {
const l = document.createElement('link'); l.id='mapbox-css'; l.rel='stylesheet'
l.href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l)
@ -1248,27 +1250,50 @@ onUnmounted(() => {
</button>
</div>
<div class="sb-header-right">
<!-- Overload alert -->
<!-- PRIMAIRE workflow quotidien
Sont gardés en première ligne ceux qui ont un statut visuel important
(badges, surcharge) ou qui sont des CTA principaux (Publier, + WO).
Le reste descend dans le menu pour libérer la largeur. -->
<span v-if="overloadedTechs.length" class="sb-overload-alert" :title="overloadedTechs.map(o => o.tech.fullName + ' ' + o.pct + '%').join(', ')">
{{ overloadedTechs.length }} surchargé{{ overloadedTechs.length > 1 ? 's' : '' }}
</span>
<button class="sb-icon-btn" :class="{ active: bottomPanelOpen }" @click="bottomPanelOpen=!bottomPanelOpen" title="Jobs non assignées">
📋 <span v-if="unscheduledJobs.length" class="sbs-count" style="position:relative;top:-2px;right:auto">{{ unscheduledJobs.length }}</span>
</button>
<!-- Offer pool -->
<button class="sb-icon-btn" :class="{ active: showOfferPool }" @click="showOfferPool=!showOfferPool; if(showOfferPool) loadOffers()" title="Offres de travail">
📡 <span v-if="activeOfferCount" class="sbs-count" style="position:relative;top:-2px;right:auto;background:#4ade80;color:#000">{{ activeOfferCount }}</span>
</button>
<button v-if="currentView==='day'" class="sb-icon-btn" :class="{ active: mapVisible }" @click="mapVisible=!mapVisible" title="Carte">🗺 Carte</button>
<button class="sb-icon-btn" @click="refreshData()" title="Actualiser"></button>
<button class="sb-icon-btn" :class="{ active: nlpVisible }" @click="nlpVisible=!nlpVisible" title="Assistant IA"></button>
<button class="sb-icon-btn" @click="gpsSettingsOpen=true" title="GPS Tracking">📡</button>
<button class="sb-wo-btn" style="background:#7c3aed" @click="publishModalOpen=true" title="Publier & envoyer l'horaire">
Publier <span v-if="draftCount" class="sbs-count" style="position:relative;top:-2px;right:auto;background:#ef4444">{{ draftCount }}</span>
</button>
<button class="sb-wo-btn" @click="openWoModal()" title="Nouveau work order">+ WO</button>
<a class="sb-erp-link" :href="erpUrl + '/desk'" target="_blank" title="Ouvrir ERPNext">ERP</a>
<div class="sb-erp-dot" :class="{ ok: store.erpStatus==='ok' }" :title="{ ok:'ERPNext ✓', error:'Hors ligne', loading:'Connexion…' }[store.erpStatus]||'ERPNext'"></div>
<!-- MENU secondaire / admin
Plus rarement utilisé : refresh, assistant IA, offres aux techs,
ressources/GPS, lien ERPNext. Tout ce qui n'est pas dans le hot
path du dispatcher au quotidien. -->
<div class="sb-menu-wrap">
<button class="sb-icon-btn sb-menu-btn" :class="{ active: moreMenuOpen }" @click.stop="moreMenuOpen = !moreMenuOpen" title="Plus d'options"></button>
<div v-if="moreMenuOpen" class="sb-menu-dropdown" @click.stop>
<button class="sb-menu-item" @click="refreshData(); moreMenuOpen=false">
<span class="sb-menu-icon"></span> Actualiser
</button>
<button class="sb-menu-item" :class="{ active: nlpVisible }" @click="nlpVisible=!nlpVisible; moreMenuOpen=false">
<span class="sb-menu-icon"></span> Assistant IA
</button>
<button class="sb-menu-item" :class="{ active: showOfferPool }" @click="showOfferPool=!showOfferPool; if(showOfferPool) loadOffers(); moreMenuOpen=false">
<span class="sb-menu-icon">📡</span> Offres aux techs
<span v-if="activeOfferCount" class="sbs-count" style="margin-left:auto;background:#4ade80;color:#000">{{ activeOfferCount }}</span>
</button>
<div class="sb-menu-sep"></div>
<button class="sb-menu-item" @click="gpsSettingsOpen=true; moreMenuOpen=false">
<span class="sb-menu-icon">👥</span> Ressources &amp; GPS
</button>
<div class="sb-menu-sep"></div>
<a class="sb-menu-item" :href="erpUrl + '/desk'" target="_blank" @click="moreMenuOpen=false">
<span class="sb-menu-icon"></span> Ouvrir ERPNext
<span class="sb-erp-dot" :class="{ ok: store.erpStatus==='ok' }" style="margin-left:auto" :title="{ ok:'ERPNext ✓', error:'Hors ligne', loading:'Connexion…' }[store.erpStatus]||'ERPNext'"></span>
</a>
</div>
</div>
</div>
</header>

View File

@ -85,6 +85,28 @@
.sb-logout-btn:hover { opacity:1; color:var(--sb-red); }
.sb-erp-dot { width:7px; height:7px; border-radius:50%; background:var(--sb-red); transition:background 0.3s; }
.sb-erp-dot.ok { background:var(--sb-green); }
/* ⋯ overflow menu in the top toolbar — secondary/admin actions live here. */
.sb-menu-wrap { position:relative; display:inline-block; }
.sb-menu-btn { font-size:0.95rem; line-height:0.85rem; padding:0.05rem 0.5rem 0.18rem; }
.sb-menu-dropdown {
position:absolute; top:calc(100% + 4px); right:0; z-index:200;
background:var(--sb-card); border:1px solid var(--sb-border);
border-radius:6px; box-shadow:0 6px 22px rgba(0,0,0,0.35);
min-width:220px; padding:4px 0; animation:sb-menu-fade 0.12s ease-out;
}
@keyframes sb-menu-fade { from { opacity:0; transform:translateY(-4px); } to { opacity:1; transform:translateY(0); } }
.sb-menu-item {
display:flex; align-items:center; gap:10px; width:100%;
background:none; border:none; color:var(--sb-text);
font-size:0.78rem; font-weight:500; padding:0.45rem 0.85rem;
cursor:pointer; text-align:left; text-decoration:none;
white-space:nowrap;
}
.sb-menu-item:hover, .sb-menu-item.active { background:var(--sb-sidebar); color:var(--sb-text); }
.sb-menu-item.active { color:var(--sb-accent); font-weight:700; }
.sb-menu-icon { display:inline-block; width:18px; text-align:center; font-size:0.95rem; }
.sb-menu-sep { height:1px; background:var(--sb-border); margin:3px 0; }
.sb-wo-btn { background:var(--sb-acc); border:none; border-radius:6px; color:var(--sb-text); font-size:0.7rem; font-weight:800; padding:0.22rem 0.65rem; cursor:pointer; white-space:nowrap; }
.sb-wo-btn:hover { filter:brightness(1.15); }