feat(dashboard/planif): vue 2 semaines + deep-link jour + slot-finder « Planifier des quarts »

- Tableau de bord : 2 bandes d'occupation (« Cette semaine » / « Semaine prochaine », 14 j depuis lundi), jour courant marqué (liseré indigo dans OccupancyStrip). Clic jour → /planification?day=ISO
- Planif : deep-link `?day=` (cadre la semaine + sélectionne le jour grille/mobile/tournées) & `?skill=` (filtre compétence, match casse-insensible vs allSkills) via focusDay() lu au montage
- Slot-finder (SuggestSlotsDialog) : état « Aucun créneau » → bouton « Planifier des quarts pour « X » » → émet plan-shifts → Planif ouvre la timeline (vue grille) au jour cherché, filtrée sur la compétence, + toast « crée un quart pour ouvrir des créneaux »

Vérifié préview bout-en-bout : 2×7 jours + today, clic → ?day=2026-07-09 cadre 06→12/07, recherche vide → bouton → dialog fermé + grille + toast compétence. Build vert + leak-check 0. Aucun changement hub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-04 08:34:43 -04:00
parent 9a507d35db
commit 5491aa8abe
4 changed files with 47 additions and 13 deletions

View File

@ -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; }

View File

@ -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) }
</script>
@ -98,7 +101,12 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
<q-separator v-if="searched" />
<q-card-section v-if="searched" style="max-height:44vh;overflow:auto" class="q-pt-sm">
<div v-if="loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /></div>
<div v-else-if="!slots.length" class="text-grey-6 text-center q-pa-md">Aucun créneau{{ skill ? ' pour « ' + skill + ' »' : '' }} vérifie qu'un tech qualifié a un <b>quart</b> sur ces jours (les créneaux suivent les quarts réels ; week-ends réservés aux réparations), ou élargis la date.</div>
<div v-else-if="!slots.length" class="text-center q-pa-md">
<div class="text-grey-6 q-mb-sm">Aucun créneau{{ skill ? ' pour « ' + skill + ' »' : '' }} vérifie qu'un tech qualifié a un <b>quart</b> sur ces jours (les créneaux suivent les quarts réels ; week-ends réservés aux réparations), ou élargis la date.</div>
<q-btn unelevated no-caps size="sm" color="teal-8" icon="event" :label="skill ? 'Planifier des quarts pour « ' + skill + ' »' : 'Planifier des quarts'" @click="planShifts">
<q-tooltip>Ouvre la Planification (timeline des techs {{ skill ? 'de « ' + skill + ' »' : '' }}, au {{ afterDate }}) pour créer un quart des créneaux apparaîtront.</q-tooltip>
</q-btn>
</div>
<q-list v-else separator>
<q-item v-for="(s, i) in slots" :key="i" clickable @click="pick(s)" class="rounded-borders">
<q-item-section avatar><q-avatar size="34px" color="blue-grey-1" text-color="blue-grey-8" class="text-weight-bold" style="font-size:12px">{{ dayLabel(s.date).slice(0, 3) }}</q-avatar></q-item-section>

View File

@ -20,16 +20,21 @@
</div>
</div>
<!-- Charge de la semaine : bande d'occupation par jour (vert rouge). Clic sur un jour Planification. -->
<!-- Charge 2 semaines : 2 bandes d'occupation (courante + prochaine), jour courant marqué (liseré indigo). Clic jour Planification (jour ciblé). -->
<div class="ops-card q-mb-lg">
<div class="row items-center q-mb-sm">
<div class="text-subtitle1 text-weight-bold">Charge à venir <span class="text-caption text-grey-6 q-ml-xs">7 jours</span></div>
<div class="text-subtitle1 text-weight-bold">Charge <span class="text-caption text-grey-6 q-ml-xs">2 semaines</span></div>
<q-space />
<q-btn flat dense no-caps size="sm" color="primary" icon-right="chevron_right" label="Planification" @click="$router.push('/planification')" />
</div>
<OccupancyStrip v-if="weekLoad.length" :days="weekLoad" :selected="todayIso" @select="$router.push('/planification')" />
<template v-if="weekLoad.length">
<div class="dash-wk-lbl">Cette semaine</div>
<OccupancyStrip :days="weekLoad.slice(0, 7)" :selected="todayIso" @select="iso => $router.push({ path: '/planification', query: { day: iso } })" />
<div class="dash-wk-lbl q-mt-sm">Semaine prochaine</div>
<OccupancyStrip :days="weekLoad.slice(7, 14)" :selected="todayIso" @select="iso => $router.push({ path: '/planification', query: { day: iso } })" />
</template>
<div v-else class="text-caption text-grey-6 q-py-sm">Chargement de la charge</div>
<div class="text-caption text-grey-6 q-mt-xs row items-center q-gutter-x-sm">
<div class="text-caption text-grey-6 q-mt-sm row items-center q-gutter-x-sm">
<span><span class="os-leg-dot" style="background:#22c55e"></span>libre</span>
<span><span class="os-leg-dot" style="background:#f59e0b"></span>chargé</span>
<span><span class="os-leg-dot" style="background:#ef4444"></span>saturé</span>
@ -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; }
</style>

View File

@ -1761,7 +1761,7 @@
<UnifiedCreateModal v-model="createJobOpen" mode="work-order" :context="createJobCtx"
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" />
<!-- « Trouver un créneau » chips compétence (durée par défaut) + adresse choisit tech+date+heure puis pré-remplit la création -->
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" />
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" @plan-shifts="onPlanShifts" />
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment -->
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
</q-page>
@ -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 })
</script>