Planification: mini-timeline positionnée (fenêtre réelle du shift + blocs pris)
Avant: 'J8' ne distinguait pas 7-15 de 9-17 → mêmes créneaux apparents, dispo réelle différente.
Maintenant chaque cellule affiche: chip (lettre) + intervalle '7–15', et une mini-timeline sur un
axe de journée (06:00→21:00) où la fenêtre du shift est positionnée (donc 7-15 à gauche, 9-17 à
droite = visuellement distinctes) avec les blocs de jobs pris (couleur selon charge) → les TROUS
restants = créneaux offrables. Infobulle = intervalle + h occupées/h (%).
- hub occupancyByTechDay renvoie {h, blocks:[{s,e}]} (heures de début réelles des jobs).
- ops: cellWindow/axisPos/shiftStyle/blockStyle, rendu .tl/.tl-shift/.tl-blk + tick midi.
- démo 8 juin: modèles Matinal 7-15 + Décalé 9-17, techs alignés (7→13.8, 9→18.6 surbooké).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
49795f858b
commit
341c8e5a64
|
|
@ -118,11 +118,15 @@
|
|||
</td>
|
||||
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso) }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)">
|
||||
<template v-if="cellsOf(t.id, d.iso).length">
|
||||
<div class="cell-chips"><span v-for="(a, ai) in cellsOf(t.id, d.iso)" :key="ai" class="code-chip" :style="chip(cellColor(a))">{{ cellCode(a) }}<small class="ch-h">{{ cellHours(a) }}</small></span></div>
|
||||
<div v-if="cellOcc(t.id, d.iso)" class="occ">
|
||||
<div class="occ-bar"><div class="occ-fill" :style="{ width: Math.min(100, cellOcc(t.id, d.iso).pct) + '%', background: occColor(cellOcc(t.id, d.iso).pct) }"></div></div>
|
||||
<small class="occ-txt" :style="{ color: occColor(cellOcc(t.id, d.iso).pct) }">{{ cellOcc(t.id, d.iso).pct }}%</small>
|
||||
<q-tooltip class="bg-grey-9">{{ cellInterval(t.id, d.iso) }} · {{ cellOcc(t.id, d.iso).usedH }} h / {{ cellOcc(t.id, d.iso).shiftH }} h ({{ cellOcc(t.id, d.iso).pct }} %)</q-tooltip>
|
||||
<div class="cell-chips">
|
||||
<span v-for="(a, ai) in cellsOf(t.id, d.iso)" :key="ai" class="code-chip" :style="chip(cellColor(a))">{{ cellCode(a) }}</span>
|
||||
<span class="cell-int">{{ cellWindowLabel(t.id, d.iso) }}</span>
|
||||
</div>
|
||||
<div v-if="cellOcc(t.id, d.iso)" class="tl">
|
||||
<div class="tl-shift" :style="shiftStyle(t.id, d.iso)"></div>
|
||||
<div v-for="(b, bi) in cellOcc(t.id, d.iso).blocks" :key="bi" class="tl-blk" :style="blockStyle(b, cellOcc(t.id, d.iso).pct)"></div>
|
||||
<div class="tl-noon"></div>
|
||||
<q-tooltip class="bg-grey-9">{{ cellInterval(t.id, d.iso) }} · {{ cellOcc(t.id, d.iso).usedH }} h / {{ cellOcc(t.id, d.iso).shiftH }} h occupé ({{ cellOcc(t.id, d.iso).pct }} %)</q-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else-if="isPaused(t)" class="code-chip" style="background:#eee;color:#999">P</span>
|
||||
|
|
@ -315,7 +319,6 @@ const codeByShift = computed(() => Object.fromEntries(templates.value.map(t => [
|
|||
const colorByShift = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t.color || '#1976d2'])))
|
||||
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
|
||||
function cellCode (a) { return codeByShift.value[a.shift] || (a.shift_name || a.shift || '?')[0].toUpperCase() }
|
||||
function cellHours (a) { return Number(a.hours) || '' }
|
||||
function cellColor (a) { return a.color || colorByShift.value[a.shift] || '#1976d2' }
|
||||
function chip (color) { return { background: color || '#1976d2', color: '#fff' } }
|
||||
|
||||
|
|
@ -349,20 +352,34 @@ function isSelected (techId, iso) { return selSet.value.has(techId + '|' + iso)
|
|||
const statByDate = computed(() => Object.fromEntries(dailyStats.value.map(s => [s.date, s])))
|
||||
function stat (iso) { return statByDate.value[iso] || {} }
|
||||
|
||||
// Taux d'occupation par cellule : Σ heures de jobs assignés (occByTechDay) / Σ heures du shift.
|
||||
// Occupation + fenêtre par cellule, visualisées sur un axe de journée (06:00 → 21:00).
|
||||
const occByTechDay = ref({})
|
||||
const AXIS_START = 6; const AXIS_SPAN = 15 // 06:00 → 21:00
|
||||
function hToNum (t) { if (!t) return null; const p = String(t).split(':'); return Number(p[0]) + (Number(p[1]) || 0) / 60 }
|
||||
function fmtH (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return mm ? (hh + ':' + String(mm).padStart(2, '0')) : ('' + hh) }
|
||||
// Fenêtre de travail de la cellule = min(début) → max(fin) des shifts du jour
|
||||
function cellWindow (techId, iso) {
|
||||
let s = Infinity; let e = -Infinity
|
||||
for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t) continue; const st = hToNum(t.start_time); const en = hToNum(t.end_time); if (st != null) s = Math.min(s, st); if (en != null) e = Math.max(e, en) }
|
||||
return (isFinite(s) && isFinite(e) && e > s) ? { s, e } : null
|
||||
}
|
||||
function cellWindowLabel (techId, iso) { const w = cellWindow(techId, iso); return w ? (fmtH(w.s) + '–' + fmtH(w.e)) : '' }
|
||||
const occCells = computed(() => {
|
||||
const m = {}; const ct = cellsByTechDay.value
|
||||
for (const techId in ct) for (const iso in ct[techId]) {
|
||||
const shiftH = ct[techId][iso].reduce((s, a) => s + (Number(a.hours) || 0), 0)
|
||||
if (shiftH <= 0) continue
|
||||
const usedH = occByTechDay.value[techId + '|' + iso] || 0
|
||||
m[techId + '|' + iso] = { shiftH, usedH: Math.round(usedH * 10) / 10, pct: Math.round(usedH / shiftH * 100) }
|
||||
const o = occByTechDay.value[techId + '|' + iso] || { h: 0, blocks: [] }
|
||||
m[techId + '|' + iso] = { shiftH, usedH: Math.round((o.h || 0) * 10) / 10, pct: Math.round((o.h || 0) / shiftH * 100), blocks: o.blocks || [] }
|
||||
}
|
||||
return m
|
||||
})
|
||||
function cellOcc (techId, iso) { return occCells.value[techId + '|' + iso] || null }
|
||||
function occColor (pct) { return pct >= 100 ? '#e53935' : pct >= 70 ? '#fb8c00' : '#43a047' }
|
||||
// Positionnement sur l'axe (en %)
|
||||
function axisPos (s, e) { const left = Math.max(0, (s - AXIS_START) / AXIS_SPAN * 100); const width = Math.max(2, Math.min(100 - left, (e - s) / AXIS_SPAN * 100)); return { left: left + '%', width: width + '%' } }
|
||||
function shiftStyle (techId, iso) { const w = cellWindow(techId, iso); return w ? axisPos(w.s, w.e) : { display: 'none' } }
|
||||
function blockStyle (blk, pct) { return { ...axisPos(blk.s, blk.e), background: occColor(pct) } }
|
||||
function cellInterval (techId, iso) {
|
||||
return cellsOf(techId, iso).map(a => { const t = tplByName.value[a.shift]; return t && t.start_time ? (t.start_time.slice(0, 5) + '–' + (t.end_time || '').slice(0, 5)) : (a.shift_name || a.shift) }).join(' + ')
|
||||
}
|
||||
|
|
@ -568,11 +585,12 @@ th.clk, td.clk { cursor: pointer; }
|
|||
.cell-dirty-demo { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 4px; font-weight: 700; font-size: 11px; background: #1976d2; color: #fff; box-shadow: inset 0 0 0 2px #ff9800; }
|
||||
.ch-h { opacity: .7; font-weight: 400; font-size: 9px; margin-left: 1px; }
|
||||
.free { color: #ccc; }
|
||||
.cell-chips { line-height: 1; }
|
||||
.occ { display: flex; align-items: center; justify-content: center; gap: 3px; margin-top: 2px; }
|
||||
.occ-bar { flex: 0 1 38px; height: 4px; background: #e8e8e8; border-radius: 2px; overflow: hidden; }
|
||||
.occ-fill { height: 100%; border-radius: 2px; transition: width .2s; }
|
||||
.occ-txt { font-size: 9px; font-weight: 700; line-height: 1; }
|
||||
.cell-chips { line-height: 1; white-space: nowrap; }
|
||||
.cell-int { font-size: 9px; color: #555; font-weight: 600; margin-left: 3px; }
|
||||
.tl { position: relative; height: 7px; background: #eef0f2; border-radius: 2px; margin-top: 3px; overflow: hidden; }
|
||||
.tl-shift { position: absolute; top: 0; bottom: 0; background: #cfd8dc; border-radius: 2px; }
|
||||
.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 2px; opacity: .92; }
|
||||
.tl-noon { position: absolute; top: 0; bottom: 0; left: 40%; width: 1px; background: rgba(0, 0, 0, .12); }
|
||||
tr.paused .tech-col { color: #aaa; }
|
||||
tfoot .sum td { background: #fafafa; font-size: 11px; color: #555; font-weight: 600; }
|
||||
tfoot .sum .tech-col { background: #fafafa; }
|
||||
|
|
|
|||
|
|
@ -463,19 +463,22 @@ async function statsByDay (start, days) {
|
|||
return dates.map(d => ({ date: d, staff: by[d].staff.size, hours: by[d].hours, tickets: by[d].tickets }))
|
||||
}
|
||||
|
||||
// Occupation par (technicien, jour) : Σ heures des Dispatch Jobs planifiés assignés.
|
||||
// Sert à afficher le taux d'occupation vs les heures du shift dans la grille Planification.
|
||||
// Occupation par (technicien, jour) : Σ heures + blocs horaires des Dispatch Jobs planifiés.
|
||||
// → la grille Planification affiche la fenêtre du shift ET les blocs pris (donc les trous offrables).
|
||||
async function occupancyByTechDay (start, days) {
|
||||
const dates = rangeDates(start, days)
|
||||
const jobs = await erp.list('Dispatch Job', {
|
||||
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
|
||||
fields: ['assigned_tech', 'scheduled_date', 'duration_h'], limit: 5000,
|
||||
fields: ['assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 5000,
|
||||
})
|
||||
const m = {}
|
||||
for (const j of jobs) {
|
||||
if (!j.assigned_tech || !j.scheduled_date) continue
|
||||
const k = j.assigned_tech + '|' + j.scheduled_date
|
||||
m[k] = (m[k] || 0) + (Number(j.duration_h) || 0)
|
||||
const o = m[k] || (m[k] = { h: 0, blocks: [] })
|
||||
const dur = Number(j.duration_h) || 0
|
||||
o.h += dur
|
||||
if (j.start_time) { const s = timeToH(j.start_time); o.blocks.push({ s, e: s + dur }) }
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user