Planification: micro-timeline 24h, multi-shift, neutre/coloré, label h utilisées + icône période

Refonte selon retour: axe 24h (00→24). Chaque shift = bande NEUTRE positionnée (multi-shift OK:
Jour + Garde affichés en 2 bandes, gère le passage minuit). Jobs pris = traits COLORÉS (charge:
vert/orange/rouge). Label compact = icône période (☀/🌆/🌙) + heures utilisées/total (ex 4/8).
Intervalles exacts par shift dans l'infobulle. Tick à midi (50%).

Démo 8 juin: TECH-4738 = Jour 8-16 + Garde 18h-minuit (multi-shift), Matinal 7-15 / Décalé 9-17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-04 15:27:32 -04:00
parent 341c8e5a64
commit 049897e021

View File

@ -120,13 +120,13 @@
<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) }}</span>
<span class="cell-int">{{ cellWindowLabel(t.id, d.iso) }}</span>
<span v-if="cellOcc(t.id, d.iso)" class="cell-int">{{ cellPeriod(t.id, d.iso) }} {{ cellOcc(t.id, d.iso).usedH }}/{{ cellOcc(t.id, d.iso).shiftH }}</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 v-for="(b, bi) in cellBands(t.id, d.iso)" :key="'b' + bi" class="tl-shift" :style="b"></div>
<div v-for="(b, bi) in cellOcc(t.id, d.iso).blocks" :key="'j' + 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>
<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>
</template>
<span v-else-if="isPaused(t)" class="code-chip" style="background:#eee;color:#999">P</span>
@ -352,18 +352,28 @@ 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] || {} }
// Occupation + fenêtre par cellule, visualisées sur un axe de journée (06:00 21:00).
// Micro-timeline 24 h par cellule : fenêtre(s) du shift = bande neutre, jobs pris = trait coloré.
const occByTechDay = ref({})
const AXIS_START = 6; const AXIS_SPAN = 15 // 06:00 21:00
const AXIS_SPAN = 24 // axe 00:00 24: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 pos (s, e) { const left = Math.max(0, s / AXIS_SPAN * 100); const width = Math.max(1.5, Math.min(100 - left, (e - s) / AXIS_SPAN * 100)); return { left: left + '%', width: width + '%' } }
// Bandes neutres = chaque shift du jour (multi-shift : Jour + Garde), gère le passage minuit.
function cellBands (techId, iso) {
const out = []
for (const a of cellsOf(techId, iso)) {
const t = tplByName.value[a.shift]; if (!t) continue
const s = hToNum(t.start_time); const e = hToNum(t.end_time); if (s == null || e == null) continue
if (e <= s) { out.push(pos(s, 24)); out.push(pos(0, e)) } else out.push(pos(s, e)) // chevauche minuit
}
return out
}
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: occColor(pct) } }
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
return (isFinite(s) && isFinite(e)) ? { s, e } : null
}
function cellWindowLabel (techId, iso) { const w = cellWindow(techId, iso); return w ? (fmtH(w.s) + '' + fmtH(w.e)) : '' }
function cellPeriod (techId, iso) { const w = cellWindow(techId, iso); if (!w) return ''; const m = (w.s + w.e) / 2; return m < 14 ? '☀️' : m < 20 ? '🌆' : '🌙' }
const occCells = computed(() => {
const m = {}; const ct = cellsByTechDay.value
for (const techId in ct) for (const iso in ct[techId]) {
@ -376,12 +386,8 @@ const occCells = computed(() => {
})
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(' + ')
return cellsOf(techId, iso).map(a => { const t = tplByName.value[a.shift]; const nm = (t && t.template_name) ? t.template_name.split(' ')[0] + ' ' : ''; return (t && t.start_time) ? (nm + t.start_time.slice(0, 5) + '' + (t.end_time || '').slice(0, 5)) : (a.shift_name || a.shift) }).join(' + ')
}
// coût de main-d'œuvre (coût chargé × heures)
@ -586,11 +592,11 @@ th.clk, td.clk { cursor: pointer; }
.ch-h { opacity: .7; font-weight: 400; font-size: 9px; margin-left: 1px; }
.free { color: #ccc; }
.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); }
.cell-int { font-size: 9px; color: #555; font-weight: 600; margin-left: 3px; white-space: nowrap; }
.tl { position: relative; height: 8px; min-width: 58px; background: #f1f3f5; border-radius: 2px; margin-top: 3px; overflow: hidden; }
.tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 1px; } /* fenêtre dispo = neutre */
.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; opacity: .95; } /* occupé = trait coloré */
.tl-noon { position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; background: rgba(0, 0, 0, .10); }
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; }