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:
parent
cf9feb67f4
commit
9a507d35db
64
apps/ops/src/components/shared/OccupancyStrip.vue
Normal file
64
apps/ops/src/components/shared/OccupancyStrip.vue
Normal 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 où 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>
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user