Garde: génération sur un HORIZON multi-semaines (évènement récurrent) au lieu d'une seule semaine

Avant: « Appliquer à la semaine » n'écrivait qu'une semaine → 1 seul tech (rotation hebdo). Maintenant
« Générer la garde » matérialise la rotation sur N semaines (4/8/12/26) d'un coup, écrit direct (publié),
navigable semaine par semaine — comme un évènement récurrent. Endpoint /roster/garde/apply : wipe ciblé
des shifts de garde dans l'horizon + recréation (idempotent, reflète l'édition de la séquence). Saut
d'absent conservé. (source='manuel' car le Select n'autorise que solveur/manuel.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-04 21:58:15 -04:00
parent 2b71d1c78c
commit 58253d2e2f
3 changed files with 49 additions and 12 deletions

View File

@ -35,6 +35,7 @@ export const getCoverage = (start, days = 7) => jget(`/roster/coverage?start=${s
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 getAbsences = (start, days = 7) => jget(`/roster/absences?start=${start}&days=${days}`)
export const applyGardeHorizon = (start, weeks, assignments, shifts) => jpost('/roster/garde/apply', { start, weeks, assignments, shifts })
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

@ -317,8 +317,10 @@
<q-btn dense unelevated color="brown" :icon="editingGardeId ? 'save' : 'add'" :label="editingGardeId ? 'Mettre à jour' : 'Ajouter la règle'" @click="addGardeRule" />
<q-btn v-if="editingGardeId" flat dense class="q-ml-xs" label="Annuler" @click="editingGardeId = null; newGardeRule.techs = []; newGardeRule.weekdays = []" />
<q-space />
<q-btn dense unelevated color="primary" icon="auto_awesome" label="Appliquer à la semaine" @click="applyGardeRules" />
<q-select dense outlined v-model="gardeHorizon" :options="[4, 8, 12, 26]" emit-value map-options style="width:96px" label="semaines" />
<q-btn dense unelevated color="primary" icon="event_repeat" label="Générer la garde" class="q-ml-sm" @click="applyGardeRules" />
</div>
<div class="text-caption text-grey-6 q-mt-xs">Matérialise la rotation sur l'horizon (publié direct, comme un évènement récurrent). Re-générer reflète les modifs de séquence.</div>
</q-card-section>
</q-card>
</q-dialog>
@ -669,19 +671,31 @@ function addGardeRule () {
function removeGardeRule (i) { gardeRules.value = gardeRules.value.filter((_, j) => j !== i); saveGarde(); if (editingGardeId.value && !gardeRules.value.some(r => r.id === editingGardeId.value)) editingGardeId.value = null }
function gardeDowLabel (r) { return r.weekdays.map(w => (GARDE_DOW.find(x => x.v === w) || {}).l).join('') }
// Génère les gardes de la semaine affichée selon les règles (rotation par département)
function applyGardeRules () {
if (!gardeRules.value.length) { $q.notify({ type: 'info', message: 'Aucune règle — ouvre « Garde » pour en créer' }); return }
pushHistory(); let added = 0
for (const d of dayList.value) {
const dow = dowOf(d.iso)
for (const rule of gardeRules.value) {
if (!rule.weekdays.includes(dow)) continue
const tpl = tplByName.value[rule.shift]; if (!tpl) continue
const id = rotationTech(rule, d.iso); if (!id) continue
const t = techs.value.find(x => x.id === id); addShift(id, t ? t.name : id, d.iso, tpl); added++
const gardeHorizon = ref(8) // nb de semaines à matérialiser (évènement récurrent)
// Génère la garde sur un HORIZON (plusieurs semaines) et l'écrit directement (publié) navigable semaine par semaine.
async function applyGardeRules () {
if (!gardeRules.value.length) { $q.notify({ type: 'info', message: 'Aucune règle — ajoute-en une' }); return }
if (dirty.value && !window.confirm('Les modifications non publiées de la grille seront rechargées. Continuer ?')) return
const weeks = gardeHorizon.value || 8; const wk0 = mondayISO(start.value); const list = []
for (let i = 0; i < weeks; i++) {
const ws = addDaysISO(wk0, i * 7)
for (let k = 0; k < 7; k++) {
const d = addDaysISO(ws, k); const dow = dowOf(d)
for (const rule of gardeRules.value) {
if (!rule.weekdays.includes(dow)) continue
const tpl = tplByName.value[rule.shift]; if (!tpl) continue
const id = rotationTech(rule, d); if (!id) continue
const t = techs.value.find(x => x.id === id)
list.push({ tech: id, tech_name: t ? t.name : id, date: d, shift: rule.shift, hours: tpl.hours || 8, zone: tpl.zone || '' })
}
}
}
$q.notify({ type: 'positive', message: added + ' garde(s) appliquée(s) — Publier pour confirmer' })
const shifts = [...new Set(gardeRules.value.map(r => r.shift))]
try {
const r = await roster.applyGardeHorizon(wk0, weeks, list, shifts)
showGarde.value = false; await loadWeek()
$q.notify({ type: 'positive', message: `Garde générée sur ${weeks} sem. : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + '. Navigue les semaines pour voir la rotation.', timeout: 6000 })
} catch (e) { err(e) }
}
function saveDemand () { localStorage.setItem(LS_DEMAND, JSON.stringify(demand.value)) }
function addDemand () { demand.value = [...demand.value, { shift: templates.value[0] && templates.value[0].name, zone: 'Montréal', skills: '', job_h: 0, weekday: 1, weekend: 0, holiday: 0 }]; saveDemand() }

View File

@ -714,6 +714,28 @@ async function handle (req, res, method, path, url) {
}
return json(res, 200, { ok: errors === 0, created, deleted, errors, notified, unchanged })
}
// Garde : matérialiser la rotation sur un HORIZON (plusieurs semaines) — comme un évènement récurrent.
// Wipe ciblé sur les shifts de garde dans l'horizon + recréation (idempotent, reflète l'édition de la séquence).
if (path === '/roster/garde/apply' && method === 'POST') {
const b = await parseBody(req)
const dates = rangeDates(b.start, (b.weeks || 1) * 7)
const shifts = b.shifts || []
let deleted = 0
if (shifts.length) {
const existing = await erp.list('Shift Assignment', { filters: [['shift_template', 'in', shifts], ['assignment_date', 'in', dates]], fields: ['name'], limit: 3000 })
for (const a of existing) { const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++ }
}
let created = 0; let errors = 0
for (const a of (b.assignments || [])) {
const r = await retryWrite(() => erp.create('Shift Assignment', {
technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
status: 'Publié', source: 'manuel',
}))
if (r.ok) created++; else errors++
}
return json(res, 200, { ok: errors === 0, created, deleted, errors })
}
// Modifier / supprimer un type de shift (Shift Template)
const mTpl = path.match(/^\/roster\/template\/(.+)$/)
if (mTpl && method === 'PUT') {