diff --git a/apps/ops/src/components/shared/OccupancyStrip.vue b/apps/ops/src/components/shared/OccupancyStrip.vue index 63c5032..7054279 100644 --- a/apps/ops/src/components/shared/OccupancyStrip.vue +++ b/apps/ops/src/components/shared/OccupancyStrip.vue @@ -51,6 +51,8 @@ function dayTitle (d) { .os-day:hover { border-color: #c7d2fe; } .os-day.active { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, .18); } .os-day.today .os-num { color: #6366f1; font-weight: 700; } +/* Différenciateur « jour courant » : liseré indigo en haut de la cellule (n'écrase pas la bague .active) */ +.os-day.today::before { content: ''; position: absolute; top: -1px; left: 22%; right: 22%; height: 3px; border-radius: 0 0 3px 3px; background: #6366f1; } .os-day.weekend { background: #f8fafc; } .os-day.holiday { background: #fff7ed; border-color: #fdba74; } .os-hol { position: absolute; top: 1px; left: 3px; color: #ea580c; font-size: 9px; line-height: 1; } diff --git a/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue b/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue index b33cbf1..44a44df 100644 --- a/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue +++ b/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue @@ -16,7 +16,7 @@ const props = defineProps({ modelValue: { type: Boolean, default: false }, skills: { type: Array, default: () => [] }, // [{ skill, dur, color }] }) -const emit = defineEmits(['update:modelValue', 'select']) +const emit = defineEmits(['update:modelValue', 'select', 'plan-shifts']) const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch() const target = reactive({ address: '', latitude: null, longitude: null, ville: '' }) @@ -48,6 +48,9 @@ function pick (s) { emit('update:modelValue', false) } +// « Aucun créneau » → renvoie le parent (Planif) vers la timeline des techs (filtrée compétence) au jour cherché, pour CRÉER un quart. +function planShifts () { emit('plan-shifts', { skill: skill.value, date: afterDate.value }); emit('update:modelValue', false) } + const DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam'] function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) } @@ -98,7 +101,12 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
-
Aucun créneau{{ skill ? ' pour « ' + skill + ' »' : '' }} — vérifie qu'un tech qualifié a un quart sur ces jours (les créneaux suivent les quarts réels ; week-ends réservés aux réparations), ou élargis la date.
+
+
Aucun créneau{{ skill ? ' pour « ' + skill + ' »' : '' }} — vérifie qu'un tech qualifié a un quart sur ces jours (les créneaux suivent les quarts réels ; week-ends réservés aux réparations), ou élargis la date.
+ + Ouvre la Planification (timeline des techs {{ skill ? 'de « ' + skill + ' »' : '' }}, au {{ afterDate }}) pour créer un quart → des créneaux apparaîtront. + +
{{ dayLabel(s.date).slice(0, 3) }} diff --git a/apps/ops/src/pages/DashboardPage.vue b/apps/ops/src/pages/DashboardPage.vue index 275a2ba..0c8fac2 100644 --- a/apps/ops/src/pages/DashboardPage.vue +++ b/apps/ops/src/pages/DashboardPage.vue @@ -20,16 +20,21 @@ - +
-
Charge à venir 7 jours
+
Charge 2 semaines
- +
Chargement de la charge…
-
+
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 })