Planification: axe timeline adaptatif + intervalle texte + modèles triés par usage

#1 axe trop large (0-24) → axe ADAPTATIF calé sur l'amplitude réelle des shifts réguliers de la
semaine (garde n'élargit pas) → barres plus larges, position lisible. Intervalle début–fin REMIS
en texte dans la cellule (☀ 8–16 🛡️) = référence sans survol. Infobulle = capacité offrable
(corrige aussi un bug: shiftH→bookableH).
#2 modèles d'assignation TRIÉS PAR USAGE (les plus utilisés en premier) + infobulle nom.
(Rappel: créer des modèles custom = éditeur « Types de shift ».)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-04 15:48:47 -04:00
parent 1ab9f64b48
commit 72845e2057

View File

@ -81,7 +81,7 @@
<q-banner v-if="selection.length" dense rounded class="bg-teal-1 text-teal-9 q-mb-sm">
{{ selection.length }} cellule(s) assigner :
<q-btn v-for="t in templates" :key="t.name" dense unelevated size="sm" class="q-mx-xs" :style="chip(t.color)" :label="code(t)" @click="assignBulk(t)" />
<q-btn v-for="t in templatesRanked" :key="t.name" dense unelevated size="sm" class="q-mx-xs" :style="chip(t.color)" :label="code(t)" @click="assignBulk(t)"><q-tooltip>{{ t.template_name }}</q-tooltip></q-btn>
<q-btn dense flat size="sm" icon="layers_clear" label="Libérer" @click="clearBulk" />
<q-btn dense flat size="sm" icon="close" label="Annuler" @click="selection = []" />
</q-banner>
@ -120,13 +120,12 @@
<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 v-if="cellOcc(t.id, d.iso)" class="cell-int">{{ cellIcon(t.id, d.iso) }}<template v-if="cellOcc(t.id, d.iso).bookableH > 0"> {{ cellOcc(t.id, d.iso).usedH }}/{{ cellOcc(t.id, d.iso).bookableH }}</template></span>
<span v-if="cellOcc(t.id, d.iso)" class="cell-int">{{ cellLabel(t.id, d.iso) }}</span>
</div>
<div v-if="cellOcc(t.id, d.iso)" class="tl">
<div v-for="(b, bi) in cellBands(t.id, d.iso)" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width }"></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 ({{ cellOcc(t.id, d.iso).pct }} %)</q-tooltip>
<q-tooltip class="bg-grey-9">{{ cellInterval(t.id, d.iso) }}<template v-if="cellOcc(t.id, d.iso).bookableH"> · {{ cellOcc(t.id, d.iso).usedH }} h occupé / {{ cellOcc(t.id, d.iso).bookableH }} h offrable ({{ cellOcc(t.id, d.iso).pct }} %)</template></q-tooltip>
</div>
</template>
<span v-else-if="isPaused(t)" class="code-chip" style="background:#eee;color:#999">P</span>
@ -320,6 +319,11 @@ function code (t) { return (t.template_name || t.name || '?').trim()[0].toUpperC
const codeByShift = computed(() => Object.fromEntries(templates.value.map(t => [t.name, code(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])))
// Modèles triés par usage (les plus utilisés en premier) pour l'assignation rapide
const templatesRanked = computed(() => {
const cnt = {}; for (const a of assignments.value) cnt[a.shift] = (cnt[a.shift] || 0) + 1
return templates.value.slice().sort((x, y) => (cnt[y.name] || 0) - (cnt[x.name] || 0) || (x.template_name || '').localeCompare(y.template_name || ''))
})
function cellCode (a) { return codeByShift.value[a.shift] || (a.shift_name || a.shift || '?')[0].toUpperCase() }
function cellColor (a) { return a.color || colorByShift.value[a.shift] || '#1976d2' }
function chip (color) { return { background: color || '#1976d2', color: '#fff' } }
@ -356,9 +360,17 @@ function stat (iso) { return statByDate.value[iso] || {} }
// Micro-timeline 24 h par cellule : fenêtre(s) du shift = bande neutre, jobs pris = trait coloré.
const occByTechDay = ref({})
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 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 + '%' } }
function fmtH (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return mm ? (hh + ':' + String(mm).padStart(2, '0')) : ('' + hh) }
// Axe ADAPTATIF : se cale sur l'amplitude réelle des shifts réguliers de la semaine (la garde n'élargit pas).
const axisBounds = computed(() => {
let lo = Infinity; let hi = -Infinity
for (const techId in cellsByTechDay.value) { const day = cellsByTechDay.value[techId]; for (const iso in day) for (const a of day[iso]) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; const s = hToNum(t.start_time); const e = hToNum(t.end_time); if (s != null) lo = Math.min(lo, s); if (e != null) hi = Math.max(hi, e <= s ? 24 : e) } }
if (!isFinite(lo) || !isFinite(hi)) return { min: 7, max: 19 }
lo = Math.max(0, Math.floor(lo)); hi = Math.min(24, Math.ceil(hi)); if (hi - lo < 4) hi = Math.min(24, lo + 4)
return { min: lo, max: hi }
})
function pos (s, e) { const b = axisBounds.value; const span = (b.max - b.min) || 24; const L = Math.max(0, (s - b.min) / span * 100); const R = Math.min(100, (e - b.min) / span * 100); return { left: L + '%', width: Math.max(1.5, R - L) + '%' } }
// Bandes = chaque shift du jour. Garde (on_call) = bande hachurée (réserve, non offrable).
function cellBands (techId, iso) {
const out = []
@ -371,14 +383,11 @@ function cellBands (techId, iso) {
return out
}
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: pct == null ? '#6d4c41' : occColor(pct) } }
// Période d'après les shifts RÉGULIERS seulement (la garde ne définit pas le jour/soir offrable)
function cellPeriod (techId, iso) {
let s = Infinity; let e = -Infinity
for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || t.on_call) 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) }
if (!isFinite(s)) return ''
const m = (s + e) / 2; return m < 14 ? '☀️' : m < 20 ? '🌆' : '🌙'
}
function cellIcon (techId, iso) { const o = cellOcc(techId, iso); if (!o) return ''; return (o.hasReg ? cellPeriod(techId, iso) : '') + (o.hasGarde ? '🛡️' : '') }
// Fenêtre des shifts RÉGULIERS (la garde ne compte pas comme dispo offrable)
function cellRegWindow (techId, iso) { let s = Infinity; let e = -Infinity; for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || t.on_call) 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) ? { s, e } : null }
function cellPeriod (techId, iso) { const w = cellRegWindow(techId, iso); if (!w) return ''; const m = (w.s + w.e) / 2; return m < 14 ? '☀️' : m < 20 ? '🌆' : '🌙' }
// Libellé inline = icône période + intervalle débutfin (la référence) + 🛡 si garde
function cellLabel (techId, iso) { const o = cellOcc(techId, iso); if (!o) return ''; const w = cellRegWindow(techId, iso); let s = o.hasReg ? cellPeriod(techId, iso) : ''; if (w) s += (s ? ' ' : '') + fmtH(w.s) + '' + fmtH(w.e); if (o.hasGarde) s += ' 🛡️'; return s || '🛡️ garde' }
const occCells = computed(() => {
const m = {}; const ct = cellsByTechDay.value
for (const techId in ct) for (const iso in ct[techId]) {
@ -603,7 +612,6 @@ th.clk, td.clk { cursor: pointer; }
.tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 1px; } /* fenêtre dispo = neutre */
.tl-shift.oncall { background: repeating-linear-gradient(45deg, #d7ccc8 0, #d7ccc8 2px, transparent 2px, transparent 4px); box-shadow: inset 0 0 0 1px #a1887f; } /* garde = hachuré (réserve, non offrable) */
.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; }