Planification: marquer une absence d'1 jour depuis la grille (touche A + menu)

Clic sur une case → touche « A » (bascule) ou menu « Marquer absent ce jour / Retirer l'absence » →
crée/supprime une Tech Availability d'1 jour APPROUVÉE (hachurée tout de suite). Multi-sélection
supportée (A marque toutes, re-A retire). Endpoint POST /roster/absence/set {tech,date,type,remove}
(remove ne touche que les absences d'un jour, pas les vacances multi-jours). Type défaut Congé.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-04 22:02:39 -04:00
parent 58253d2e2f
commit 761498d65c
3 changed files with 35 additions and 0 deletions

View File

@ -36,6 +36,7 @@ export const getStats = (start, days = 7) => jget(`/roster/stats?start=${start}&
export const getOccupancy = (start, days = 7) => jget(`/roster/occupancy?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 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 applyGardeHorizon = (start, weeks, assignments, shifts) => jpost('/roster/garde/apply', { start, weeks, assignments, shifts })
export const setAbsence = (tech, date, type, remove) => jpost('/roster/absence/set', { tech, date, type, remove })
export const generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights }) export const generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights })
export const publish = (assignments) => jpost('/roster/publish', { assignments }) export const publish = (assignments) => jpost('/roster/publish', { assignments })
export const publishWeek = (start, days, assignments, notify) => jpost('/roster/publish-week', { start, days, assignments, notify }) export const publishWeek = (start, days, assignments, notify) => jpost('/roster/publish-week', { start, days, assignments, notify })

View File

@ -344,6 +344,12 @@
</div> </div>
</div> </div>
<q-separator /> <q-separator />
<q-item clickable v-close-popup @click="toggleAbsentMenu">
<q-item-section avatar><q-icon name="event_busy" size="18px" :color="menuIsAbsent ? 'grey-7' : 'red-6'" /></q-item-section>
<q-item-section>{{ menuIsAbsent ? "Retirer l'absence" : 'Marquer absent ce jour' }}</q-item-section>
<q-item-section side><span class="text-caption text-grey-6">A</span></q-item-section>
</q-item>
<q-separator />
<!-- Shifts en place + actions compactes --> <!-- Shifts en place + actions compactes -->
<q-item v-for="a in menuCellShifts" :key="'c' + a.shift" dense> <q-item v-for="a in menuCellShifts" :key="'c' + a.shift" dense>
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section> <q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section>
@ -465,6 +471,15 @@ const occByTechDay = ref({})
const absByTechDay = ref({}) // tech|date → type d'absence (En pause / Congé / Maladie) hachuré const absByTechDay = ref({}) // tech|date → type d'absence (En pause / Congé / Maladie) hachuré
function isAbsent (techId, iso) { return !!absByTechDay.value[techId + '|' + iso] } function isAbsent (techId, iso) { return !!absByTechDay.value[techId + '|' + iso] }
function absenceLabel (techId, iso) { return absByTechDay.value[techId + '|' + iso] || 'Absent' } function absenceLabel (techId, iso) { return absByTechDay.value[techId + '|' + iso] || 'Absent' }
async function reloadAbsences () { try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) {} }
// Bascule absence d'1 jour sur des cases (clic + « A » ou menu). Si toutes absentes retire ; sinon marque.
async function toggleAbsentCells (targets) {
if (!targets || !targets.length) return
const allAbsent = targets.every(k => { const [tid, iso] = k.split('|'); return isAbsent(tid, iso) })
for (const k of targets) { const [tid, iso] = k.split('|'); try { await roster.setAbsence(tid, iso, 'Congé', allAbsent) } catch (e) { err(e) } }
await reloadAbsences()
$q.notify({ type: 'info', message: allAbsent ? 'Absence retirée' : (targets.length + ' absence(s) marquée(s)') })
}
function hToNum (t) { if (!t) return null; const p = String(t).split(':'); return Number(p[0]) + (Number(p[1]) || 0) / 60 } 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) } 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). // Axe ADAPTATIF : se cale sur l'amplitude réelle des shifts réguliers de la semaine (la garde n'élargit pas).
@ -793,6 +808,8 @@ function selectBlock (ti, di) { const a = anchor.value; const t0 = Math.min(a.ti
function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] } function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
function maybeSelectRow (ti) { const ks = dayList.value.map(d => visibleTechs.value[ti].id + '|' + d.iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] } function maybeSelectRow (ti) { const ks = dayList.value.map(d => visibleTechs.value[ti].id + '|' + d.iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
const menuCellShifts = computed(() => (menu.tech && menu.day) ? cellsOf(menu.tech.id, menu.day.iso) : []) const menuCellShifts = computed(() => (menu.tech && menu.day) ? cellsOf(menu.tech.id, menu.day.iso) : [])
const menuIsAbsent = computed(() => (menu.tech && menu.day) ? isAbsent(menu.tech.id, menu.day.iso) : false)
function toggleAbsentMenu () { if (menu.tech && menu.day) { toggleAbsentCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } }
function removeShiftFromMenu (a) { pushHistory(); removeShift(a.tech, a.date, a.shift) } function removeShiftFromMenu (a) { pushHistory(); removeShift(a.tech, a.date, a.shift) }
function clearOne () { if (menu.tech && menu.day) { pushHistory(); clearLocal(menu.tech.id, menu.day.iso); menu.show = false } } function clearOne () { if (menu.tech && menu.day) { pushHistory(); clearLocal(menu.tech.id, menu.day.iso); menu.show = false } }
// Menu : copier / coller (marche au clic, sans Cmd+clic) + ajuster l'horaire au slider // Menu : copier / coller (marche au clic, sans Cmd+clic) + ajuster l'horaire au slider
@ -844,6 +861,11 @@ function onKey (e) {
for (const key of targets) { const [tid, iso] = key.split('|'); clearLocal(tid, iso) } for (const key of targets) { const [tid, iso] = key.split('|'); clearLocal(tid, iso) }
if (selection.value.length) selection.value = [] if (selection.value.length) selection.value = []
} }
if (k === 'a' && !e.altKey && (selection.value.length || activeCell.value)) { // « A » = bascule absent
e.preventDefault(); menu.show = false
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
toggleAbsentCells(targets); if (selection.value.length) selection.value = []
}
} }
function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } } function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } }
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek() }) onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek() })

View File

@ -566,6 +566,18 @@ async function handle (req, res, method, path, url) {
if (!start) return json(res, 400, { error: 'start requis' }) if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { absences: await absencesByTechDay(start, days) }) return json(res, 200, { absences: await absencesByTechDay(start, days) })
} }
// Absence d'UN JOUR depuis la grille (approuvée → hachurée tout de suite) ou retrait (jour unique seulement).
if (path === '/roster/absence/set' && method === 'POST') {
const b = await parseBody(req)
if (!b.tech || !b.date) return json(res, 400, { error: 'tech + date requis' })
if (b.remove) {
const rows = await erp.list('Tech Availability', { filters: [['technician', '=', b.tech], ['from_date', '=', b.date], ['to_date', '=', b.date]], fields: ['name'], limit: 20 })
let removed = 0; for (const r of rows) { const x = await retryWrite(() => erp.remove('Tech Availability', r.name)); if (x.ok) removed++ }
return json(res, 200, { ok: true, removed })
}
const r = await retryWrite(() => erp.create('Tech Availability', { technician: b.tech, from_date: b.date, to_date: b.date, availability_type: b.type || 'Congé', status: 'Approuvé', reason: 'Grille' }))
return json(res, r.ok ? 200 : 500, r)
}
// Prise de RDV : créneaux dispo (roster + compétence + zone) pour proposer/valider // Prise de RDV : créneaux dispo (roster + compétence + zone) pour proposer/valider
if (path === '/roster/book/slots' && method === 'GET') { if (path === '/roster/book/slots' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' }) if (!start) return json(res, 400, { error: 'start requis' })