feat(dashboard): bande d'occupation + KPI cliquables/tendance + garde-fou PPA ; barres de charge en-tête Planif

- <OccupancyStrip> réutilisable (extrait de .pm-strip) ; heatColor déplacé dans useHelpers = source unique
- Tableau de bord : bande « Charge à venir » (7 j glissants, réutilise /roster/capacity) ; clic jour → Planif ; légende + heures à répartir
- KPI cliquables (→ page liée) + sous-ligne tendance/contexte (urgents, +N tickets 7 j, non assignés)
- Garde-fou PPA : confirmation explicite avant prélèvement (cf. incident double-charge 2026-06)
- Planif : barres verticales d'occupation dans l'en-tête desktop (hdr-vbar) + sélecteur de jour « Tournées » = OccupancyStrip (remplace les chips « lun 29/06 »)

Vérifié en préview (strip, clics jour, dialog PPA annulé sans charge, nav KPI) + build vert + leak-check 0. Aucun changement hub (réutilise /roster/capacity).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-04 08:00:46 -04:00
parent cf9feb67f4
commit 9a507d35db
4 changed files with 193 additions and 15 deletions

View File

@ -0,0 +1,64 @@
<template>
<!-- Bande d'occupation réutilisable : 1 cellule par jour, barre verticale « chaleur » (vert rouge).
Extraite de la bande mobile Planif (.pm-strip). Utilisée : tableau de bord (charge de la semaine),
et partout un aperçu de charge par jour est utile. Clic @select(iso). -->
<div class="os-strip">
<button
v-for="d in days" :key="d.iso" type="button" class="os-day"
:class="{ active: d.iso === selected, today: d.today, weekend: d.weekend, holiday: d.holiday }"
:title="dayTitle(d)"
@click="$emit('select', d.iso)"
>
<span v-if="d.holiday" class="os-hol"></span>
<span v-if="d.noShift" class="os-warn" title="Aucun quart planifié — capacité estimée"></span>
<span class="os-dow">{{ d.dow }}</span>
<span class="os-num">{{ d.dnum }}</span>
<span class="os-bar"><span class="os-fill" :style="{ height: fillH(d) + '%', background: heatColor(d.ratio) }"></span></span>
<span class="os-h">{{ d.cap > 0 ? Math.round(d.h) + '/' + Math.round(d.cap) : (Math.round(d.h) + 'h') }}<span v-if="d.over" class="os-over"></span></span>
</button>
</div>
</template>
<script setup>
import { heatColor } from 'src/composables/useHelpers'
defineProps({
days: { type: Array, default: () => [] }, // [{ iso, dow, dnum, h, cap, ratio, over, weekend, holiday, noShift, today }]
selected: { type: String, default: '' },
})
defineEmits(['select'])
// Barre : min 12 % quand il y a de la charge (visible), 0 si rien ; plafonnée à 100 %.
function fillH (d) { return d.h > 0 ? Math.max(12, Math.min(100, Math.round((d.ratio || 0) * 100))) : 0 }
function dayTitle (d) {
const parts = [Math.round(d.h) + 'h à faire']
if (d.cap > 0) parts.push('/ ' + Math.round(d.cap) + 'h de capacité')
if (d.over) parts.push('· surchargé ⚠')
if (d.noShift) parts.push('· aucun quart (estimé)')
if (d.holiday) parts.unshift('★ férié —')
return parts.join(' ')
}
</script>
<style scoped>
.os-strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
.os-strip::-webkit-scrollbar { height: 0; }
.os-day {
position: relative; flex: 0 0 auto; width: 52px; border: 1px solid var(--ops-border, #e2e8f0);
border-radius: 10px; background: #fff; display: flex; flex-direction: column; align-items: center;
gap: 2px; padding: 7px 2px; cursor: pointer; transition: border-color .15s, box-shadow .15s;
}
.os-day:hover { border-color: #c7d2fe; }
.os-day.active { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, .18); }
.os-day.today .os-num { color: #6366f1; font-weight: 700; }
.os-day.weekend { background: #f8fafc; }
.os-day.holiday { background: #fff7ed; border-color: #fdba74; }
.os-hol { position: absolute; top: 1px; left: 3px; color: #ea580c; font-size: 9px; line-height: 1; }
.os-warn { position: absolute; top: 2px; right: 3px; color: #ef4444; font-size: 9px; line-height: 1; pointer-events: none; }
.os-dow { font-size: 10px; color: var(--ops-text-secondary, #64748b); text-transform: capitalize; }
.os-num { font-size: 13px; font-weight: 600; color: var(--ops-text, #1e293b); }
.os-bar { width: 10px; height: 44px; background: #eef2f7; border-radius: 5px; overflow: hidden; display: flex; align-items: flex-end; margin: 3px 0; }
.os-fill { width: 100%; border-radius: 5px; transition: height .3s; }
.os-h { font-size: 10px; color: var(--ops-text-secondary, #64748b); white-space: nowrap; }
.os-over { color: #ef4444; margin-left: 1px; }
</style>

View File

@ -29,6 +29,16 @@ export function timeToH (t) {
return h + m / 60 return h + m / 60
} }
// Occupation → couleur « chaleur » (vert = libre → rouge = saturé). SOURCE UNIQUE :
// bande mobile Planif (.pm-strip), <OccupancyStrip> du tableau de bord, en-tête desktop Planif.
export function heatColor (ratio) {
const r = +ratio || 0
if (r >= 1) return '#ef4444' // saturé / dépassé
if (r >= 0.75) return '#f59e0b' // chargé
if (r >= 0.45) return '#84cc16' // moyen
return '#22c55e' // libre
}
export function hToTime (h) { export function hToTime (h) {
const totalMin = Math.round(h * 60) const totalMin = Math.round(h * 60)
const hh = Math.floor(totalMin / 60) const hh = Math.floor(totalMin / 60)

View File

@ -3,21 +3,40 @@
<!-- Real-time outage alerts --> <!-- Real-time outage alerts -->
<OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push('/tickets')" /> <OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push('/tickets')" />
<!-- KPI cards --> <!-- KPI cards (cliquables page liée · sous-ligne = tendance/contexte) -->
<div class="row q-col-gutter-md q-mb-lg"> <div class="row q-col-gutter-md q-mb-lg">
<div class="col-6 col-md" v-for="stat in stats" :key="stat.label"> <div class="col-6 col-md" v-for="stat in stats" :key="stat.label">
<div class="ops-card ops-stat"> <div class="ops-card ops-stat" :class="{ 'ops-stat-clk': stat.to }" @click="stat.to && $router.push(stat.to)">
<div class="row items-center no-wrap q-gutter-x-sm"> <div class="row items-center no-wrap q-gutter-x-sm">
<q-icon :name="stat.icon" :style="{ color: stat.color }" size="22px" /> <q-icon :name="stat.icon" :style="{ color: stat.color }" size="22px" />
<div> <div class="col">
<div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div> <div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
<div class="ops-stat-label">{{ stat.label }}</div> <div class="ops-stat-label">{{ stat.label }}</div>
<div v-if="stat.sub" class="ops-stat-sub" :class="stat.subClass">{{ stat.sub }}</div>
</div> </div>
<q-icon v-if="stat.to" name="chevron_right" size="18px" class="ops-stat-go" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Charge de la semaine : bande d'occupation par jour (vert rouge). Clic sur un jour Planification. -->
<div class="ops-card q-mb-lg">
<div class="row items-center q-mb-sm">
<div class="text-subtitle1 text-weight-bold">Charge à venir <span class="text-caption text-grey-6 q-ml-xs">7 jours</span></div>
<q-space />
<q-btn flat dense no-caps size="sm" color="primary" icon-right="chevron_right" label="Planification" @click="$router.push('/planification')" />
</div>
<OccupancyStrip v-if="weekLoad.length" :days="weekLoad" :selected="todayIso" @select="$router.push('/planification')" />
<div v-else class="text-caption text-grey-6 q-py-sm">Chargement de la charge</div>
<div class="text-caption text-grey-6 q-mt-xs row items-center q-gutter-x-sm">
<span><span class="os-leg-dot" style="background:#22c55e"></span>libre</span>
<span><span class="os-leg-dot" style="background:#f59e0b"></span>chargé</span>
<span><span class="os-leg-dot" style="background:#ef4444"></span>saturé</span>
<span v-if="weekUnplaced" class="text-warning">· <b>{{ weekUnplaced }}h</b> à répartir</span>
</div>
</div>
<!-- Admin controls --> <!-- Admin controls -->
<div class="row q-col-gutter-md q-mb-lg"> <div class="row q-col-gutter-md q-mb-lg">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@ -87,9 +106,9 @@
icon="credit_score" icon="credit_score"
:loading="runningPPA" :loading="runningPPA"
dense no-caps dense no-caps
@click="runPPA" @click="confirmPPA"
> >
<q-tooltip>Prélève automatiquement les cartes enregistrées</q-tooltip> <q-tooltip>Prélève les cartes enregistrées (confirmation requise)</q-tooltip>
</q-btn> </q-btn>
</div> </div>
<div class="q-mt-xs q-gutter-xs" style="min-height:20px"> <div class="q-mt-xs q-gutter-xs" style="min-height:20px">
@ -171,23 +190,57 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { listDocs, countDocs } from 'src/api/erp' import { listDocs, countDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth' import { authFetch } from 'src/api/auth'
import { getCapacity } from 'src/api/roster'
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
import { HUB_SSE_URL } from 'src/config/dispatch' import { HUB_SSE_URL } from 'src/config/dispatch'
import { localDateStr } from 'src/composables/useHelpers'
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue' import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue'
const $q = useQuasar()
const stats = ref([ const stats = ref([
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people' }, { label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people', to: '/clients', sub: '', subClass: '' },
{ label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business' }, { label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business', to: '/clients', sub: '', subClass: '' },
{ label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money' }, { label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money', to: '/rapports/revenus', sub: 'récurrent', subClass: 'text-grey-6' },
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number' }, { label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number', to: '/tickets', sub: '', subClass: '' },
{ label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping' }, { label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping', to: '/planification', sub: '', subClass: '' },
]) ])
const openTickets = ref([]) const openTickets = ref([])
const todayJobs = ref([]) const todayJobs = ref([])
// Charge de la semaine (bande d'occupation, réutilise /roster/capacity)
const weekLoad = ref([])
const weekUnplaced = ref(0)
const todayIso = localDateStr(new Date())
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
async function loadWeek () {
try {
const start = new Date(); start.setHours(0, 0, 0, 0) // fenêtre glissante : aujourd'hui + 6 j (toujours actionnable)
const { capacity } = await getCapacity(localDateStr(start), 7)
const days = []; let unplaced = 0
for (let i = 0; i < 7; i++) {
const dt = new Date(start); dt.setDate(start.getDate() + i)
const iso = localDateStr(dt)
const c = capacity[iso] || {}
const h = +c.due_h || 0; const cap = +c.cap_h || 0
unplaced += +c.unplaced_h || 0
days.push({
iso, dow: FR_DOW[dt.getDay()], dnum: dt.getDate() + '/' + String(dt.getMonth() + 1).padStart(2, '0'),
h, cap, ratio: cap > 0 ? h / cap : (h > 0 ? 1 : 0), over: !!c.overbooked,
weekend: dt.getDay() === 0 || dt.getDay() === 6, noShift: cap === 0 && h > 0, today: iso === todayIso,
})
}
weekLoad.value = days
weekUnplaced.value = Math.round(unplaced)
} catch { /* strip masqué si le hub ne répond pas */ }
}
// Scheduler controls // Scheduler controls
const schedulerEnabled = ref(false) const schedulerEnabled = ref(false)
const togglingScheduler = ref(false) const togglingScheduler = ref(false)
@ -345,6 +398,19 @@ async function submitDrafts () {
submittingDrafts.value = false submittingDrafts.value = false
} }
// Garde-fou : le PPA prélève IMMÉDIATEMENT les cartes enregistrées. Un double lancement peut
// charger deux fois (cf. incident 2026-06) confirmation explicite obligatoire avant d'exécuter.
function confirmPPA () {
$q.dialog({
title: 'Prélèvement automatique (PPA)',
message: 'Cette action prélève <b>immédiatement</b> les cartes enregistrées des clients éligibles. À n\'exécuter <b>qu\'une seule fois</b> par cycle — un double lancement peut charger deux fois. Continuer ?',
html: true,
ok: { label: 'Prélever maintenant', color: 'positive', noCaps: true, unelevated: true },
cancel: { label: 'Annuler', flat: true, noCaps: true },
persistent: true,
}).onOk(() => { runPPA() })
}
async function runPPA () { async function runPPA () {
runningPPA.value = true runningPPA.value = true
ppaResult.value = null ppaResult.value = null
@ -373,10 +439,12 @@ async function runPPA () {
onMounted(async () => { onMounted(async () => {
fetchSchedulerStatus() fetchSchedulerStatus()
refreshDraftCount() refreshDraftCount()
loadWeek()
const today = new Date().toISOString().slice(0, 10) const today = new Date().toISOString().slice(0, 10)
const d7 = localDateStr(new Date(Date.now() - 7 * 86400000)) // il y a 7 jours (tendance tickets)
const [clients, tickets, todayDispatch, openTix] = await Promise.all([ const [clients, tickets, todayDispatch, openTix, newTickets7d] = await Promise.all([
countDocs('Customer', { disabled: 0 }), countDocs('Customer', { disabled: 0 }),
countDocs('Issue', { status: 'Open' }), countDocs('Issue', { status: 'Open' }),
listDocs('Dispatch Job', { listDocs('Dispatch Job', {
@ -389,6 +457,7 @@ onMounted(async () => {
fields: ['name', 'subject', 'customer', 'customer_name', 'priority', 'opening_date'], fields: ['name', 'subject', 'customer', 'customer_name', 'priority', 'opening_date'],
limit: 10, orderBy: 'opening_date desc', limit: 10, orderBy: 'opening_date desc',
}), }),
countDocs('Issue', { opening_date: ['>=', d7] }).catch(() => 0),
]) ])
// Abonnés = unique customers with active subscriptions (via server script) // Abonnés = unique customers with active subscriptions (via server script)
@ -415,7 +484,29 @@ onMounted(async () => {
stats.value[3].value = tickets.toLocaleString() stats.value[3].value = tickets.toLocaleString()
stats.value[4].value = todayDispatch.length.toLocaleString() stats.value[4].value = todayDispatch.length.toLocaleString()
// Tendance / contexte des KPI (à partir des données déjà chargées 0 requête de plus, sauf le compte 7 j)
const urgents = openTix.filter(t => t.priority === 'Urgent' || t.priority === 'High').length
const tkParts = []
if (urgents) tkParts.push(urgents + ' urgents')
if (newTickets7d) tkParts.push('+' + newTickets7d + ' (7j)')
stats.value[3].sub = tkParts.join(' · ')
stats.value[3].subClass = urgents ? 'text-negative' : 'text-grey-6'
const unassigned = todayDispatch.filter(j => !j.assigned_tech).length
stats.value[4].sub = todayDispatch.length ? (unassigned ? unassigned + ' non assignés' : 'tous assignés ✓') : ''
stats.value[4].subClass = unassigned ? 'text-warning' : 'text-positive'
openTickets.value = openTix openTickets.value = openTix
todayJobs.value = todayDispatch todayJobs.value = todayDispatch
}) })
</script> </script>
<style scoped>
/* KPI cliquable : affordance au survol + sous-ligne tendance/contexte */
.ops-stat-clk { cursor: pointer; transition: transform .12s, box-shadow .12s; }
.ops-stat-clk:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(2, 6, 23, .08); }
.ops-stat-clk .ops-stat-go { color: var(--ops-text-secondary, #94a3b8); opacity: 0; transition: opacity .12s; }
.ops-stat-clk:hover .ops-stat-go { opacity: 1; }
.ops-stat-sub { font-size: 11px; line-height: 1.2; margin-top: 1px; }
/* Légende de la bande d'occupation */
.os-leg-dot { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 3px; vertical-align: middle; }
</style>

View File

@ -406,6 +406,7 @@
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)"> <th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div> <div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
<div v-if="visStat(d.iso).hours || estLoad(d.iso)" class="cap-strip" @click.stop> <div v-if="visStat(d.iso).hours || estLoad(d.iso)" class="cap-strip" @click.stop>
<span class="hdr-vbar"><span class="hdr-vfill" :style="{ height: hdrFillH(d.iso) + '%', background: heatColor(hdrRatio(d.iso)) }"></span></span>
<span class="cap-seg load-cell" :class="loadClass(d.iso)">{{ estLoad(d.iso) }}/{{ visStat(d.iso).hours }}h <span class="cap-seg load-cell" :class="loadClass(d.iso)">{{ estLoad(d.iso) }}/{{ visStat(d.iso).hours }}h
<q-tooltip class="bg-grey-9"><b>{{ estLoad(d.iso) }} h estimées</b> (jobs prévus) / <b>{{ visStat(d.iso).hours }} h de shift</b><template v-if="visStat(d.iso).hours"> · {{ Math.round(estLoad(d.iso) / visStat(d.iso).hours * 100) }}%</template><br><span style="opacity:.8">{{ skillFilter.length ? ('compétence(s) : ' + skillFilter.join(', ')) : 'ressources affichées' }} ({{ visibleTechs.length }} tech)</span></q-tooltip> <q-tooltip class="bg-grey-9"><b>{{ estLoad(d.iso) }} h estimées</b> (jobs prévus) / <b>{{ visStat(d.iso).hours }} h de shift</b><template v-if="visStat(d.iso).hours"> · {{ Math.round(estLoad(d.iso) / visStat(d.iso).hours * 100) }}%</template><br><span style="opacity:.8">{{ skillFilter.length ? ('compétence(s) : ' + skillFilter.join(', ')) : 'ressources affichées' }} ({{ visibleTechs.length }} tech)</span></q-tooltip>
</span> </span>
@ -481,7 +482,7 @@
<div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm"> <div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm">
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap"> <div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
<span class="text-caption text-grey-7">Journée :</span> <span class="text-caption text-grey-7">Journée :</span>
<q-btn-toggle v-model="routesDay" dense no-caps unelevated toggle-color="indigo" color="grey-3" text-color="grey-8" :options="dayList.slice(0, 7).map(d => ({ label: d.dow + ' ' + d.dnum, value: d.iso }))" /> <OccupancyStrip :days="routeStripDays" :selected="routesDay" style="max-width:420px" @select="routesDay = $event" />
<q-space /> <q-space />
<q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn> <q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn>
<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>
@ -1794,7 +1795,7 @@ import * as roster from 'src/api/roster'
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE' import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch) import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>)
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, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations
@ -1804,6 +1805,7 @@ import TechSelect from 'src/components/shared/TechSelect.vue'
import SkillSelect from 'src/components/shared/SkillSelect.vue' import SkillSelect from 'src/components/shared/SkillSelect.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) Dispatch déprécié, tout ici import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) Dispatch déprécié, tout ici
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » pré-remplit la création (tech+date+heure+adresse) import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » pré-remplit la création (tech+date+heure+adresse)
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) repris/amélioré depuis Dispatch import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) repris/amélioré depuis Dispatch
@ -1874,6 +1876,9 @@ const estLoadByDay = computed(() => {
}) })
function estLoad (iso) { return estLoadByDay.value[iso] || 0 } function estLoad (iso) { return estLoadByDay.value[iso] || 0 }
function loadClass (iso) { const est = estLoad(iso), dispo = visStat(iso).hours || 0; if (!dispo) return est > 0 ? 'cap-full' : 'cap-none'; const r = est / dispo; return r > 1 ? 'cap-full' : (r > 0.85 ? 'cap-low' : 'cap-ok') } // vert/orange/rouge selon est/dispo function loadClass (iso) { const est = estLoad(iso), dispo = visStat(iso).hours || 0; if (!dispo) return est > 0 ? 'cap-full' : 'cap-none'; const r = est / dispo; return r > 1 ? 'cap-full' : (r > 0.85 ? 'cap-low' : 'cap-ok') } // vert/orange/rouge selon est/dispo
// Barre verticale d'occupation de l'en-tête desktop (miroir de la bande mobile) : ratio charge estimée / heures de quart.
function hdrRatio (iso) { const cap = visStat(iso).hours || 0, load = estLoad(iso) || 0; return cap > 0 ? load / cap : (load > 0 ? 1 : 0) }
function hdrFillH (iso) { return (estLoad(iso) || 0) > 0 ? Math.max(12, Math.min(100, Math.round(hdrRatio(iso) * 100))) : 0 }
// Vue « jobs prévus en en-tête de jour » : jobs NON assignés ayant une date, groupés par jour, glissables sur une case // Vue « jobs prévus en en-tête de jour » : jobs NON assignés ayant une date, groupés par jour, glissables sur une case
// tech×jour (réutilise onJobDragStart/onCellDrop assigne + replanifie à la date de la case). Suit le filtre compétence. // tech×jour (réutilise onJobDragStart/onCellDrop assigne + replanifie à la date de la case). Suit le filtre compétence.
const showDayJobs = ref(false) const showDayJobs = ref(false)
@ -3981,7 +3986,7 @@ function hasShiftDay (techId, iso) {
// Vue mobile « bande de jours + heat » (téléphone) : charge agrégée par jour + par tech, lecture rapide. // Vue mobile « bande de jours + heat » (téléphone) : charge agrégée par jour + par tech, lecture rapide.
const todayIso = computed(() => (nowET.value && nowET.value.iso) || new Date().toISOString().slice(0, 10)) const todayIso = computed(() => (nowET.value && nowET.value.iso) || new Date().toISOString().slice(0, 10))
function heatColor (ratio) { const r = +ratio || 0; if (r >= 1) return '#ef4444'; if (r >= 0.75) return '#f59e0b'; if (r >= 0.45) return '#84cc16'; return '#22c55e' } // heatColor composables/useHelpers.js (source unique, partagé avec <OccupancyStrip> + tableau de bord)
const _CAP_H = 8 // capacité indicative par tech-jour (h) pour normaliser les barres const _CAP_H = 8 // capacité indicative par tech-jour (h) pour normaliser les barres
const mobileSelIso = ref(null) const mobileSelIso = ref(null)
// Bande de jours mobile = fenêtre LONGUE (4 semaines) carrousel horizontal CONTINU (sans flèches). // Bande de jours mobile = fenêtre LONGUE (4 semaines) carrousel horizontal CONTINU (sans flèches).
@ -4676,6 +4681,11 @@ const suggestRoutes = computed(() => {
watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || '' }) watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || '' })
// P3 Onglet « Tournées » : tournées RÉELLES (jobs assignés, occupation) de la journée choisie, 1 couleur/tech, tracés OSRM (RouteMap). // P3 Onglet « Tournées » : tournées RÉELLES (jobs assignés, occupation) de la journée choisie, 1 couleur/tech, tracés OSRM (RouteMap).
const routesDay = ref(null) const routesDay = ref(null)
// Sélecteur de jour (Tournées) enrichi : bande d'occupation (barres de charge) au lieu de simples chips « lun 29/06 ».
const routeStripDays = computed(() => dayList.value.slice(0, 7).map(d => {
const load = estLoad(d.iso) || 0; const cap = visStat(d.iso).hours || 0
return { iso: d.iso, dow: d.dow, dnum: d.dnum, weekend: d.weekend, holiday: isHoliday(d.iso), h: load, cap, ratio: hdrRatio(d.iso), over: cap > 0 && load > cap, today: d.iso === todayISO() }
}))
const routesMapRef = ref(null) const routesMapRef = ref(null)
const dayRouteMetrics = reactive({}) const dayRouteMetrics = reactive({})
const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic) const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic)
@ -5505,7 +5515,10 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; } .loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; } .loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
/* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */ /* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */
.cap-strip { display: flex; gap: 2px; justify-content: center; align-items: center; margin-top: 2px; } .cap-strip { display: flex; gap: 3px; justify-content: center; align-items: center; margin-top: 2px; }
/* Barre verticale d'occupation (miroir de la bande mobile) dans l'en-tête desktop */
.hdr-vbar { width: 7px; height: 22px; background: #eef2f7; border-radius: 4px; overflow: hidden; display: inline-flex; align-items: flex-end; flex: 0 0 auto; }
.hdr-vfill { width: 100%; border-radius: 4px; transition: height .3s; }
.cap-seg { font-size: 9px; font-weight: 700; line-height: 1; min-width: 15px; padding: 2px 2px; border-radius: 3px; text-align: center; cursor: default; } .cap-seg { font-size: 9px; font-weight: 700; line-height: 1; min-width: 15px; padding: 2px 2px; border-radius: 3px; text-align: center; cursor: default; }
.cap-ok { background: #c8e6c9; color: #1b5e20; } .cap-ok { background: #c8e6c9; color: #1b5e20; }
.cap-low { background: #ffe0b2; color: #e65100; } .cap-low { background: #ffe0b2; color: #e65100; }