Roster: quart de Garde (on_call) = réserve d'urgence, jamais offert au booking

Modèle: champ on_call (Check) sur Shift Template. Un quart garde:
- N'est JAMAIS offert au booking client (techGaps retourne null) — vérifié: tech Jour+Garde
  n'offre que la fenêtre Jour, aucun créneau dans la plage de garde.
- Est EXCLU du dénominateur d'occupation (heures offrables), affiché à part.
- Timeline: bande HACHURÉE (vs neutre pour l'offrable) + 🛡️ dans le label + tag (garde) en infobulle.
- Éditeur de modèles: bascule '🛡️ Garde' pour créer/marquer un quart de garde.
hub: fetchTemplates expose on_call; create/update template le gèrent. Champ ajouté à
setup_dispatch_custom_fields.py (persistance). Démo: Garde 18h-minuit marquée on_call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-04 15:37:34 -04:00
parent 049897e021
commit 1ab9f64b48
2 changed files with 29 additions and 20 deletions

View File

@ -120,10 +120,10 @@
<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">{{ cellPeriod(t.id, d.iso) }} {{ cellOcc(t.id, d.iso).usedH }}/{{ cellOcc(t.id, d.iso).shiftH }}</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>
</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" :style="b"></div>
<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>
@ -161,7 +161,7 @@
</q-card-section>
<q-card-section>
<table class="demand-tbl">
<thead><tr><th>Nom</th><th>Début</th><th>Fin</th><th>Heures</th><th>Couleur</th><th></th></tr></thead>
<thead><tr><th>Nom</th><th>Début</th><th>Fin</th><th>Heures</th><th>Couleur</th><th>🛡 Garde</th><th></th></tr></thead>
<tbody>
<tr v-for="t in editTpls" :key="t.name">
<td><span class="code-chip" :style="chip(t.color)">{{ (t.template_name||'?')[0].toUpperCase() }}</span> {{ t.template_name }}</td>
@ -169,6 +169,7 @@
<td><q-input dense outlined type="time" v-model="t.end" style="width:115px" /></td>
<td class="text-center text-weight-medium">{{ calcHours(t.start, t.end) }} h</td>
<td><input type="color" v-model="t.color" style="width:36px;height:26px;border:none;background:none" /></td>
<td class="text-center"><q-toggle dense v-model="t.on_call" :true-value="1" :false-value="0" color="brown"><q-tooltip>Quart de garde (urgences) non offert au booking</q-tooltip></q-toggle></td>
<td><q-btn flat dense round size="sm" icon="save" color="primary" @click="saveShiftTpl(t)"><q-tooltip>Enregistrer</q-tooltip></q-btn><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="delShiftTpl(t)" /></td>
</tr>
</tbody>
@ -180,6 +181,7 @@
<q-input dense outlined type="time" v-model="newTpl.end" label="Fin" style="width:110px" />
<span class="text-caption text-grey-7">{{ calcHours(newTpl.start, newTpl.end) }} h</span>
<input type="color" v-model="newTpl.color" style="width:36px;height:26px;border:none;background:none" />
<q-toggle dense v-model="newTpl.on_call" :true-value="1" :false-value="0" label="🛡️ Garde" color="brown" />
<q-btn dense unelevated color="primary" icon="add" label="Ajouter" @click="addShiftTpl" />
</div>
</q-card-section>
@ -297,7 +299,7 @@ const showTeamEditor = ref(false); const editTechs = ref([])
const notifySms = ref(false)
const showLeave = ref(false); const leaveRows = ref([]); const leaveFilter = ref('Demandé')
const newLeave = reactive({ technician: '', availability_type: 'Congé', from_date: '', to_date: '', reason: '' })
const newTpl = reactive({ template_name: '', start: '08:00', end: '16:00', color: '#1976d2' })
const newTpl = reactive({ template_name: '', start: '08:00', end: '16:00', color: '#1976d2', on_call: 0 })
const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'
@ -357,37 +359,41 @@ 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 + '%' } }
// Bandes neutres = chaque shift du jour (multi-shift : Jour + Garde), gère le passage minuit.
// Bandes = chaque shift du jour. Garde (on_call) = bande hachurée (réserve, non offrable).
function cellBands (techId, iso) {
const out = []
for (const a of cellsOf(techId, iso)) {
const t = tplByName.value[a.shift]; if (!t) continue
const oc = !!t.on_call
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
if (e <= s) { out.push({ ...pos(s, 24), oncall: oc }); out.push({ ...pos(0, e), oncall: oc }) } else out.push({ ...pos(s, e), oncall: oc }) // chevauche minuit
}
return out
}
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: occColor(pct) } }
function cellWindow (techId, iso) {
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) 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)) ? { s, e } : null
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 cellPeriod (techId, iso) { const w = cellWindow(techId, iso); if (!w) return ''; const m = (w.s + w.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 ? '🛡️' : '') }
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 cells = ct[techId][iso]; if (!cells.length) continue
let bookableH = 0; let hasGarde = false; let hasReg = false
for (const a of cells) { const t = tplByName.value[a.shift]; if (t && t.on_call) hasGarde = true; else { hasReg = true; bookableH += Number(a.hours) || 0 } }
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 || [] }
m[techId + '|' + iso] = { bookableH, usedH: Math.round((o.h || 0) * 10) / 10, hasGarde, hasReg, pct: bookableH > 0 ? Math.round((o.h || 0) / bookableH * 100) : null, 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' }
function cellInterval (techId, iso) {
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(' + ')
return cellsOf(techId, iso).map(a => { const t = tplByName.value[a.shift]; const nm = (t && t.template_name) ? t.template_name.split(' ')[0] + ' ' : ''; const tag = (t && t.on_call) ? ' 🛡️ garde' : ''; return (t && t.start_time) ? (nm + t.start_time.slice(0, 5) + '' + (t.end_time || '').slice(0, 5) + tag) : (a.shift_name || a.shift) }).join(' + ')
}
// coût de main-d'œuvre (coût chargé × heures)
@ -441,9 +447,9 @@ async function saveCost (t) { try { await roster.setTechCost(t.id, { salary: t.s
// éditeur de types de shift (intervalle d'heures)
function calcHours (st, et) { if (!st || !et) return 0; const [h1, m1] = st.split(':').map(Number); const [h2, m2] = et.split(':').map(Number); let mins = (h2 * 60 + m2) - (h1 * 60 + m1); if (mins < 0) mins += 1440; return Math.round(mins / 60 * 100) / 100 }
function openShiftEditor () { editTpls.value = templates.value.map(t => ({ name: t.name, template_name: t.template_name, start: (t.start_time || '08:00:00').slice(0, 5), end: (t.end_time || '16:00:00').slice(0, 5), color: t.color || '#1976d2' })); showShiftEditor.value = true }
async function saveShiftTpl (t) { try { await roster.updateTemplate(t.name, { start_time: t.start + ':00', end_time: t.end + ':00', hours: calcHours(t.start, t.end), color: t.color }); await refreshTemplates(); $q.notify({ type: 'positive', message: t.template_name + ' enregistré (' + calcHours(t.start, t.end) + ' h)' }) } catch (e) { err(e) } }
async function addShiftTpl () { if (!newTpl.template_name) { $q.notify({ type: 'warning', message: 'Nom requis' }); return } try { await roster.createTemplate({ template_name: newTpl.template_name, start_time: newTpl.start + ':00', end_time: newTpl.end + ':00', hours: calcHours(newTpl.start, newTpl.end), color: newTpl.color, default_required: 1 }); newTpl.template_name = ''; await refreshTemplates(); openShiftEditor(); $q.notify({ type: 'positive', message: 'Type ajouté' }) } catch (e) { err(e) } }
function openShiftEditor () { editTpls.value = templates.value.map(t => ({ name: t.name, template_name: t.template_name, start: (t.start_time || '08:00:00').slice(0, 5), end: (t.end_time || '16:00:00').slice(0, 5), color: t.color || '#1976d2', on_call: t.on_call ? 1 : 0 })); showShiftEditor.value = true }
async function saveShiftTpl (t) { try { await roster.updateTemplate(t.name, { start_time: t.start + ':00', end_time: t.end + ':00', hours: calcHours(t.start, t.end), color: t.color, on_call: t.on_call ? 1 : 0 }); await refreshTemplates(); $q.notify({ type: 'positive', message: t.template_name + ' enregistré (' + calcHours(t.start, t.end) + ' h)' }) } catch (e) { err(e) } }
async function addShiftTpl () { if (!newTpl.template_name) { $q.notify({ type: 'warning', message: 'Nom requis' }); return } try { await roster.createTemplate({ template_name: newTpl.template_name, start_time: newTpl.start + ':00', end_time: newTpl.end + ':00', hours: calcHours(newTpl.start, newTpl.end), color: newTpl.color, default_required: 1, on_call: newTpl.on_call ? 1 : 0 }); newTpl.template_name = ''; newTpl.on_call = 0; await refreshTemplates(); openShiftEditor(); $q.notify({ type: 'positive', message: 'Type ajouté' }) } catch (e) { err(e) } }
async function delShiftTpl (t) { if (!window.confirm('Supprimer le type « ' + t.template_name + ' » ?')) return; try { await roster.deleteShiftTemplate(t.name); await refreshTemplates(); editTpls.value = editTpls.value.filter(x => x.name !== t.name); $q.notify({ type: 'info', message: 'Type supprimé' }) } catch (e) { err(e) } }
function snapshotServer (list) { serverSet.value = new Set(list.map(a => a.tech + '|' + a.date + '|' + a.shift)) }
async function loadWeek () {
@ -595,6 +601,7 @@ th.clk, td.clk { cursor: pointer; }
.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-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; }

View File

@ -150,7 +150,7 @@ async function buildUnavailability (techs, dateList) {
async function fetchTemplates () {
const rows = await erp.list('Shift Template', {
filters: [['active', '=', 1]],
fields: ['name', 'template_name', 'start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills'],
fields: ['name', 'template_name', 'start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills', 'on_call'],
limit: 100,
})
return rows
@ -311,6 +311,7 @@ function techGaps (a, d, skill, zone) {
if (skill && !(t.skills || []).includes(skill)) return null
if (zone && a.zone && a.zone !== zone) return null
const tpl = d.tplByName[a.shift]; if (!tpl) return null
if (tpl.on_call) return null // garde (sur appel) = capacité d'urgence, JAMAIS offerte au booking client
const sh = timeToH(tpl.start_time) || 8; const eh = timeToH(tpl.end_time) || (sh + (Number(tpl.hours) || 8))
const day = (d.booked[a.tech + '|' + a.date] || []).slice().sort((x, y) => x.s - y.s)
let cursor = sh; const gaps = []
@ -508,6 +509,7 @@ async function handle (req, res, method, path, url) {
template_name: b.template_name, start_time: b.start_time, end_time: b.end_time,
hours: b.hours, color: b.color || '#1976d2', zone: b.zone || '',
default_required: b.default_required || 1, required_skills: b.required_skills || '', active: 1,
on_call: b.on_call ? 1 : 0,
})
return json(res, r.ok ? 200 : 500, r)
}
@ -684,7 +686,7 @@ async function handle (req, res, method, path, url) {
if (mTpl && method === 'PUT') {
const name = decodeURIComponent(mTpl[1]); const b = await parseBody(req)
const patch = {}
for (const f of ['start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills', 'active']) if (b[f] !== undefined) patch[f] = b[f]
for (const f of ['start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills', 'active', 'on_call']) if (b[f] !== undefined) patch[f] = b[f]
const r = await retryWrite(() => erp.update('Shift Template', name, patch))
return json(res, r.ok ? 200 : 500, r)
}