diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js
index 9dbfbce..e694424 100644
--- a/apps/ops/src/api/roster.js
+++ b/apps/ops/src/api/roster.js
@@ -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 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 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 publish = (assignments) => jpost('/roster/publish', { assignments })
export const publishWeek = (start, days, assignments, notify) => jpost('/roster/publish-week', { start, days, assignments, notify })
diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue
index df8e981..bd3d1d0 100644
--- a/apps/ops/src/pages/PlanificationPage.vue
+++ b/apps/ops/src/pages/PlanificationPage.vue
@@ -344,6 +344,12 @@
+
+
+ {{ menuIsAbsent ? "Retirer l'absence" : 'Marquer absent ce jour' }}
+ A
+
+
{{ a.shift_name || a.shift }} {{ a.hours }}h
@@ -465,6 +471,15 @@ const occByTechDay = ref({})
const absByTechDay = ref({}) // tech|date → type d'absence (En pause / Congé / Maladie…) → hachuré
function isAbsent (techId, iso) { return !!absByTechDay.value[techId + '|' + iso] }
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 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).
@@ -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 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 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 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
@@ -844,6 +861,11 @@ function onKey (e) {
for (const key of targets) { const [tid, iso] = key.split('|'); clearLocal(tid, iso) }
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 = '' } }
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek() })
diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js
index 2938a72..0db23f8 100644
--- a/services/targo-hub/lib/roster.js
+++ b/services/targo-hub/lib/roster.js
@@ -566,6 +566,18 @@ async function handle (req, res, method, path, url) {
if (!start) return json(res, 400, { error: 'start requis' })
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
if (path === '/roster/book/slots' && method === 'GET') {
if (!start) return json(res, 400, { error: 'start requis' })