Planification: taux d'occupation par cellule (jobs assignés / heures du shift)

Chaque cellule tech×jour avec un shift affiche, sous le chip (J8), une mini-barre + % colorés
(vert <70, orange 70-99, rouge >=100 surbooké) + infobulle = intervalle du shift + h occupées/h.
Occupation = Σ duration_h des Dispatch Jobs planifiés assignés ce jour ÷ Σ heures du shift.

- hub: occupancyByTechDay(start,days) + GET /roster/occupancy → map 'TECH|date': heures.
- ops api: getOccupancy ; PlanificationPage: occCells (computed), cellOcc/occColor/cellInterval,
  rendu barre + q-tooltip, chargé dans loadStats. Données démo semaine 8 juin (45/85/120%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-04 15:13:27 -04:00
parent 88b2702489
commit 49795f858b
3 changed files with 57 additions and 2 deletions

View File

@ -33,6 +33,7 @@ export const createRequirement = (r) => jpost('/roster/requirements', r)
export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`)
export const getCoverage = (start, days = 7) => jget(`/roster/coverage?start=${start}&days=${days}`)
export const getStats = (start, days = 7) => jget(`/roster/stats?start=${start}&days=${days}`)
export const getOccupancy = (start, days = 7) => jget(`/roster/occupancy?start=${start}&days=${days}`)
export const generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights })
export const publish = (assignments) => jpost('/roster/publish', { assignments })
export const publishWeek = (start, days, assignments, notify) => jpost('/roster/publish-week', { start, days, assignments, notify })

View File

@ -117,7 +117,14 @@
<span v-if="hoursOf(t.id)" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'"> · {{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
</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"><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></template>
<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>
</template>
<span v-else-if="isPaused(t)" class="code-chip" style="background:#eee;color:#999">P</span>
<span v-else class="free">·</span>
</td>
@ -342,6 +349,24 @@ 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.
const occByTechDay = ref({})
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) }
}
return m
})
function cellOcc (techId, iso) { return occCells.value[techId + '|' + iso] || null }
function occColor (pct) { return pct >= 100 ? '#e53935' : pct >= 70 ? '#fb8c00' : '#43a047' }
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(' + ')
}
// coût de main-d'œuvre (coût chargé × heures)
const costByTech = computed(() => Object.fromEntries(techs.value.map(t => [t.id, t.cost_h || 0])))
const costByDate = computed(() => { const m = {}; for (const a of assignments.value) m[a.date] = (m[a.date] || 0) + (Number(a.hours) || 0) * (costByTech.value[a.tech] || 0); return m })
@ -408,7 +433,10 @@ async function loadWeek () {
await loadStats()
} catch (e) { err(e) } finally { loading.value = false }
}
async function loadStats () { try { const s = await roster.getStats(start.value, days.value); dailyStats.value = s.stats || [] } catch (e) { /* non bloquant */ } }
async function loadStats () {
try { const s = await roster.getStats(start.value, days.value); dailyStats.value = s.stats || [] } catch (e) { /* non bloquant */ }
try { const o = await roster.getOccupancy(start.value, days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ }
}
async function doGenerate () {
generating.value = true
@ -540,6 +568,11 @@ 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; }
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; }

View File

@ -463,6 +463,23 @@ 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.
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,
})
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)
}
return m
}
// ── Routeur ──────────────────────────────────────────────────────────────────
// technician_id n'est pas le docname → résoudre le docname Dispatch Technician.
async function resolveTechName (techId) {
@ -515,6 +532,10 @@ async function handle (req, res, method, path, url) {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { stats: await statsByDay(start, days) })
}
if (path === '/roster/occupancy' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { occupancy: await occupancyByTechDay(start, days) })
}
// Prise de RDV : créneaux dispo (roster + compétence + zone) pour proposer/valider
if (path === '/roster/book/slots' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })