diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js
index 812f9e9..4eaa3b6 100644
--- a/apps/ops/src/api/roster.js
+++ b/apps/ops/src/api/roster.js
@@ -34,6 +34,7 @@ export const listAssignments = (start, days = 7) => jget(`/roster/assignments?st
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 getAbsences = (start, days = 7) => jget(`/roster/absences?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 })
diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue
index 11fcf25..7a88536 100644
--- a/apps/ops/src/pages/PlanificationPage.vue
+++ b/apps/ops/src/pages/PlanificationPage.vue
@@ -94,8 +94,8 @@
Légende :
dispo (matin → soir)
occupation
- garde
- Ppause
+ absent
+ garde
·libre
Jmodifié (non publié)
· glisser = sélection · shift+clic = bloc · clic en-tête = colonne · clic nom = rangée · ctrl+clic = +1 · ctrl+C/V = copier/coller une case
@@ -124,14 +124,16 @@
· {{ hoursOf(t.id) }}h
-
+
+ Absent · {{ absenceLabel(t.id, d.iso) }}
+
+
{{ cellInterval(t.id, d.iso) }} · {{ cellOcc(t.id, d.iso).usedH }} h occupé / {{ cellOcc(t.id, d.iso).bookableH }} h offrable ({{ cellOcc(t.id, d.iso).pct }} %)
- P
·
|
@@ -385,6 +387,9 @@ const hasOnCall = computed(() => dailyStats.value.some(s => s.on_call > 0))
// Micro-timeline 24 h par cellule : fenêtre(s) du shift = bande neutre, jobs pris = trait coloré.
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' }
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).
@@ -508,6 +513,7 @@ async function loadWeek () {
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 */ }
+ try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) { /* non bloquant */ }
}
async function doGenerate () {
@@ -681,11 +687,13 @@ th.clk, td.clk { cursor: pointer; }
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }
.tl { position: relative; height: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; }
.tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 2px; border: 1px solid rgba(55,65,120,.5); box-sizing: border-box; } /* fenêtre dispo (contour foncé pour la distinguer du fond) */
-.tl-shift.oncall { background: repeating-linear-gradient(45deg, #d7ccc8 0, #d7ccc8 2px, transparent 2px, transparent 4px); border-color: #8d6e63; } /* garde = hachuré + contour brun */
-.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; } /* occupé = assombrit la dispo */
+.tl-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */
+.tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */
+.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; } /* occupé = barre de statut opaque */
.tod-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(210,45%,91%), hsl(270,45%,83%)); }
.occ-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(122,68%,44%), hsl(32,68%,44%)); }
-.tod-garde { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; background: repeating-linear-gradient(45deg,#d7ccc8 0,#d7ccc8 2px,transparent 2px,transparent 4px); box-shadow: inset 0 0 0 1px #a1887f; }
+.leg-absent { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg,#cfcfcf 0,#cfcfcf 3px,#f0f0f0 3px,#f0f0f0 6px); }
+.leg-garde { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; background: rgba(255,179,0,.14); border: 1px dashed #f9a825; }
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; }
diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js
index 97ed86f..754fb7b 100644
--- a/services/targo-hub/lib/roster.js
+++ b/services/targo-hub/lib/roster.js
@@ -490,6 +490,21 @@ async function occupancyByTechDay (start, days) {
return m
}
+// Absences par (technicien, jour) : En pause (global) + Tech Availability approuvées.
+// Sert à hachurer la grille (absent ≠ garde).
+async function absencesByTechDay (start, days) {
+ const dates = rangeDates(start, days); const lo = dates[0]; const hi = dates[dates.length - 1]
+ const techs = await fetchTechnicians()
+ const m = {}
+ for (const t of techs) if (t.status === PAUSE_STATUS) for (const d of dates) m[t.id + '|' + d] = 'En pause'
+ const avs = await erp.list('Tech Availability', {
+ filters: [['status', '=', 'Approuvé'], ['from_date', '<=', hi], ['to_date', '>=', lo]],
+ fields: ['technician', 'from_date', 'to_date', 'availability_type'], limit: 1000,
+ })
+ for (const a of avs) for (const d of dates) if (d >= a.from_date && d <= a.to_date) m[a.technician + '|' + d] = a.availability_type || 'Absent'
+ return m
+}
+
// ── Routeur ──────────────────────────────────────────────────────────────────
// technician_id n'est pas le docname → résoudre le docname Dispatch Technician.
async function resolveTechName (techId) {
@@ -547,6 +562,10 @@ async function handle (req, res, method, path, url) {
if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { occupancy: await occupancyByTechDay(start, days) })
}
+ if (path === '/roster/absences' && method === 'GET') {
+ if (!start) return json(res, 400, { error: 'start requis' })
+ return json(res, 200, { absences: await absencesByTechDay(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' })