+
libre
chargé
saturé
@@ -196,7 +201,7 @@ import { authFetch } from 'src/api/auth'
import { getCapacity } from 'src/api/roster'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_SSE_URL } from 'src/config/dispatch'
-import { localDateStr } from 'src/composables/useHelpers'
+import { startOfWeek, localDateStr } from 'src/composables/useHelpers'
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue'
@@ -221,11 +226,11 @@ const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
async function loadWeek () {
try {
- const start = new Date(); start.setHours(0, 0, 0, 0) // fenêtre glissante : aujourd'hui + 6 j (toujours actionnable)
- const { capacity } = await getCapacity(localDateStr(start), 7)
+ const mon = startOfWeek(new Date()) // lundi de la semaine courante → 2 semaines (courante + prochaine)
+ const { capacity } = await getCapacity(localDateStr(mon), 14)
const days = []; let unplaced = 0
- for (let i = 0; i < 7; i++) {
- const dt = new Date(start); dt.setDate(start.getDate() + i)
+ for (let i = 0; i < 14; i++) {
+ const dt = new Date(mon); dt.setDate(mon.getDate() + i)
const iso = localDateStr(dt)
const c = capacity[iso] || {}
const h = +c.due_h || 0; const cap = +c.cap_h || 0
@@ -509,4 +514,6 @@ onMounted(async () => {
.ops-stat-sub { font-size: 11px; line-height: 1.2; margin-top: 1px; }
/* Légende de la bande d'occupation */
.os-leg-dot { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 3px; vertical-align: middle; }
+/* Libellé de semaine au-dessus de chaque bande (« Cette semaine » / « Semaine prochaine ») */
+.dash-wk-lbl { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; color: var(--ops-text-secondary, #94a3b8); margin-bottom: 4px; }
diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue
index f84d95f..a627f4d 100644
--- a/apps/ops/src/pages/PlanificationPage.vue
+++ b/apps/ops/src/pages/PlanificationPage.vue
@@ -1761,7 +1761,7 @@
-
+
@@ -1787,6 +1787,7 @@
* 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor
*/
import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
+import { useRoute } from 'vue-router' // deep-link ?day=&skill= (tableau de bord, slot-finder)
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
@@ -4686,6 +4687,22 @@ const routeStripDays = computed(() => dayList.value.slice(0, 7).map(d => {
const load = estLoad(d.iso) || 0; const cap = visStat(d.iso).hours || 0
return { iso: d.iso, dow: d.dow, dnum: d.dnum, weekend: d.weekend, holiday: isHoliday(d.iso), h: load, cap, ratio: hdrRatio(d.iso), over: cap > 0 && load > cap, today: d.iso === todayISO() }
}))
+
+// ── Deep-link (?day=YYYY-MM-DD & ?skill=…) + action « planifier des quarts » du slot-finder ──
+const route = useRoute()
+function matchSkill (skill) { return skill ? (allSkills.value || []).find(s => String(s).toLowerCase() === String(skill).toLowerCase()) : null }
+function focusDay (iso, skill) { // cadre la semaine sur le jour, le sélectionne (grille/mobile/tournées), filtre compétence si connue
+ if (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) { start.value = mondayISO(iso); mobileSelIso.value = iso; routesDay.value = iso }
+ const m = matchSkill(skill); if (m) skillFilter.value = [m]
+}
+// « Aucun créneau » → « Planifier des quarts » : ouvre la timeline des techs (filtrée compétence) au bon jour pour créer des quarts.
+function onPlanShifts ({ skill, date } = {}) {
+ showSuggestSlots.value = false
+ boardView.value = 'grid' // vue timeline = voir/créer les quarts
+ focusDay(date, skill)
+ const m = matchSkill(skill)
+ $q.notify({ type: 'info', icon: 'event_busy', timeout: 4500, message: m ? `Techs « ${m} » — crée un quart ce jour pour ouvrir des créneaux` : (skill ? `Aucun tech n'a « ${skill} » comme compétence — ajoute-la à un tech ou crée un quart` : 'Crée un quart pour ouvrir des créneaux') })
+}
const routesMapRef = ref(null)
const dayRouteMetrics = reactive({})
const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic)
@@ -5490,7 +5507,7 @@ function onLegacyUpdate (data) {
}
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
-onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
+onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); stopLivePositions(); if (_kbRO) _kbRO.disconnect() })
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })