Planification: hachuré = ABSENT (congé/pause), garde = pointillé ambre (sur appel hors heures)
- Hachuré gris = ABSENT (Tech Availability approuvée + En pause). Nouvel endpoint /roster/absences (En pause global + congés approuvés par jour) → la cellule d'un tech absent est hachurée (tooltip = type). Remplace l'ancien 'P' pause. - GARDE = nouveau visuel: bande à CONTOUR POINTILLÉ AMBRE + fond ambre léger (sur appel, hors heures d'ouverture) — distinct du travail planifié et de l'absent. - Légende: dispo (matin→soir) · occupation · absent (hachuré) · garde (pointillé). Retrait 'P pause'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
021417f29f
commit
89a366d197
|
|
@ -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 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 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 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 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 })
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,8 @@
|
||||||
<span class="text-grey-7 q-mr-xs">Légende :</span>
|
<span class="text-grey-7 q-mr-xs">Légende :</span>
|
||||||
<span class="tod-leg"></span><span class="text-grey-7 q-mr-sm">dispo (matin → soir)</span>
|
<span class="tod-leg"></span><span class="text-grey-7 q-mr-sm">dispo (matin → soir)</span>
|
||||||
<span class="occ-leg"></span><span class="text-grey-7 q-mr-sm">occupation</span>
|
<span class="occ-leg"></span><span class="text-grey-7 q-mr-sm">occupation</span>
|
||||||
<span class="tod-garde"></span><span class="text-grey-7 q-mr-sm">garde</span>
|
<span class="leg-absent"></span><span class="text-grey-7 q-mr-sm">absent</span>
|
||||||
<span class="code-chip" style="background:#e0e0e0;color:#777">P</span><span class="text-grey-7 q-mr-sm">pause</span>
|
<span class="leg-garde"></span><span class="text-grey-7 q-mr-sm">garde</span>
|
||||||
<span class="free q-mr-xs">·</span><span class="text-grey-7 q-mr-sm">libre</span>
|
<span class="free q-mr-xs">·</span><span class="text-grey-7 q-mr-sm">libre</span>
|
||||||
<span class="cell-dirty-demo q-mr-xs">J</span><span class="text-grey-7 q-mr-sm">modifié (non publié)</span>
|
<span class="cell-dirty-demo q-mr-xs">J</span><span class="text-grey-7 q-mr-sm">modifié (non publié)</span>
|
||||||
<span class="text-teal-8">· <b>glisser</b> = sélection · shift+clic = bloc · clic en-tête = colonne · clic nom = rangée · ctrl+clic = +1 · <b>ctrl+C/V</b> = copier/coller une case</span>
|
<span class="text-teal-8">· <b>glisser</b> = sélection · shift+clic = bloc · clic en-tête = colonne · clic nom = rangée · ctrl+clic = +1 · <b>ctrl+C/V</b> = copier/coller une case</span>
|
||||||
|
|
@ -124,14 +124,16 @@
|
||||||
<span v-if="hoursOf(t.id)" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'"> · {{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
|
<span v-if="hoursOf(t.id)" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'"> · {{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
|
||||||
</td>
|
</td>
|
||||||
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso) }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)">
|
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso) }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)">
|
||||||
<template v-if="cellsOf(t.id, d.iso).length">
|
<template v-if="isAbsent(t.id, d.iso) || isPaused(t)">
|
||||||
|
<div class="tl"><div class="tl-absent"></div><q-tooltip class="bg-grey-9">Absent · {{ absenceLabel(t.id, d.iso) }}</q-tooltip></div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="cellsOf(t.id, d.iso).length">
|
||||||
<div v-if="cellOcc(t.id, d.iso)" class="tl">
|
<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" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></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, background: b.bg || undefined }"></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 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>
|
||||||
<q-tooltip class="bg-grey-9">{{ cellInterval(t.id, d.iso) }}<template v-if="cellOcc(t.id, d.iso).bookableH"> · {{ cellOcc(t.id, d.iso).usedH }} h occupé / {{ cellOcc(t.id, d.iso).bookableH }} h offrable ({{ cellOcc(t.id, d.iso).pct }} %)</template></q-tooltip>
|
<q-tooltip class="bg-grey-9">{{ cellInterval(t.id, d.iso) }}<template v-if="cellOcc(t.id, d.iso).bookableH"> · {{ cellOcc(t.id, d.iso).usedH }} h occupé / {{ cellOcc(t.id, d.iso).bookableH }} h offrable ({{ cellOcc(t.id, d.iso).pct }} %)</template></q-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<span v-else-if="isPaused(t)" class="code-chip" style="background:#eee;color:#999">P</span>
|
|
||||||
<span v-else class="free">·</span>
|
<span v-else class="free">·</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -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é.
|
// Micro-timeline 24 h par cellule : fenêtre(s) du shift = bande neutre, jobs pris = trait coloré.
|
||||||
const occByTechDay = ref({})
|
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 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).
|
||||||
|
|
@ -508,6 +513,7 @@ async function loadWeek () {
|
||||||
async function loadStats () {
|
async function loadStats () {
|
||||||
try { const s = await roster.getStats(start.value, days.value); dailyStats.value = s.stats || [] } catch (e) { /* non bloquant */ }
|
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 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 () {
|
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; }
|
.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 { 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 { 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-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */
|
||||||
.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; } /* occupé = assombrit la dispo */
|
.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%)); }
|
.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%)); }
|
.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; }
|
tr.paused .tech-col { color: #aaa; }
|
||||||
tfoot .sum td { background: #fafafa; font-size: 11px; color: #555; font-weight: 600; }
|
tfoot .sum td { background: #fafafa; font-size: 11px; color: #555; font-weight: 600; }
|
||||||
tfoot .sum .tech-col { background: #fafafa; }
|
tfoot .sum .tech-col { background: #fafafa; }
|
||||||
|
|
|
||||||
|
|
@ -490,6 +490,21 @@ async function occupancyByTechDay (start, days) {
|
||||||
return m
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Routeur ──────────────────────────────────────────────────────────────────
|
||||||
// technician_id n'est pas le docname → résoudre le docname Dispatch Technician.
|
// technician_id n'est pas le docname → résoudre le docname Dispatch Technician.
|
||||||
async function resolveTechName (techId) {
|
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' })
|
if (!start) return json(res, 400, { error: 'start requis' })
|
||||||
return json(res, 200, { occupancy: await occupancyByTechDay(start, days) })
|
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
|
// 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' })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user