feat(planif): weekly shift generator per tech (templates + N weeks) — reworked from Dispatch

The best schedule editor lived in the (to-be-deleted) Dispatch page.
Reworked it into a shared Quasar WeeklyScheduleEditor.vue:
- Reuses the shared useHelpers.SCHEDULE_PRESETS (5×8h / 4×10h / 3×12h)
  as one-click chips that fill a per-day form (toggle + start/end + hrs).
- "Générer pour N semaine(s)" → automation: repeats the pattern over N
  weeks from the displayed week.
- Emits the schedule; Planif writes Shift Assignments DIRECTLY (published)
  via ensureWindowTpl (per distinct window) + roster.createShift
  (idempotent), then refreshes the grid. No Publier round-trip.

Wired into the tech skill dialog ("Générer l'horaire…", + a "5×8h
rapide" quick path). This is what makes the créneau/skill-shift feature
usable (regular shifts now exist). Dispatch's schedule modal can be
retired — its useful bits (SCHEDULE_PRESETS) were already shared.

Verified: editor opens per tech with presets + 7 day rows + weeks input;
matches the target UX in Planification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-03 10:37:46 -04:00
parent 8b9d5376a7
commit b1ff46e8c1
2 changed files with 113 additions and 5 deletions

View File

@ -0,0 +1,83 @@
<script setup>
/**
* WeeklyScheduleEditor génération de quarts HEBDO par tech, à partir de MODÈLES.
* Reprend la meilleure UX (page Dispatch, qui sera supprimée) en Quasar + modèles partagés
* (useHelpers.SCHEDULE_PRESETS) : chips de modèle remplit le formulaire par jour (case + heures),
* + « nombre de semaines » pour AUTOMATISER (répéter le patron sur N semaines).
* Émet 'apply' { schedule:[{dow,on,start,end}] (Lun..Dim), weeks } le parent écrit les Shift Assignment.
*/
import { reactive, ref, watch } from 'vue'
import { SCHEDULE_PRESETS } from 'src/composables/useHelpers'
const props = defineProps({ modelValue: { type: Boolean, default: false }, techName: { type: String, default: '' } })
const emit = defineEmits(['update:modelValue', 'apply'])
const DAYS = [['mon', 'Lun'], ['tue', 'Mar'], ['wed', 'Mer'], ['thu', 'Jeu'], ['fri', 'Ven'], ['sat', 'Sam'], ['sun', 'Dim']]
const sched = reactive({})
const weeks = ref(1)
function fill (preset) { for (const [k] of DAYS) { const s = preset[k]; sched[k] = s ? { on: true, start: s.start, end: s.end } : { on: false, start: '08:00', end: '16:00' } } }
fill(SCHEDULE_PRESETS[0].schedule)
watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = 1 } })
function dayHours (d) { if (!d || !d.on) return 0; const [h1, m1] = d.start.split(':').map(Number); const [h2, m2] = d.end.split(':').map(Number); let mn = (h2 * 60 + m2) - (h1 * 60 + m1); if (mn < 0) mn += 1440; return Math.round(mn / 60 * 10) / 10 }
const totalDays = () => DAYS.filter(([k]) => sched[k]?.on).length
const totalHours = () => DAYS.reduce((s, [k]) => s + dayHours(sched[k]), 0)
function submit () { emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)) }); emit('update:modelValue', false) }
</script>
<template>
<q-dialog :model-value="modelValue" @update:model-value="v => emit('update:modelValue', v)">
<q-card style="width:600px;max-width:96vw">
<q-card-section class="row items-center q-pb-sm">
<q-icon name="event_note" color="primary" size="24px" class="q-mr-sm" />
<div class="text-h6">Horaire {{ techName }}</div>
<q-space /><q-btn flat round dense icon="close" @click="emit('update:modelValue', false)" />
</q-card-section>
<q-separator />
<q-card-section class="q-gutter-sm">
<!-- Modèles (1 clic remplit le formulaire) -->
<div class="row q-gutter-xs">
<span v-for="p in SCHEDULE_PRESETS" :key="p.key" class="wse-preset" @click="fill(p.schedule)">{{ p.label }}</span>
</div>
<!-- Jours -->
<div class="wse-grid">
<div v-for="[k, lbl] in DAYS" :key="k" class="wse-day" :class="{ off: !sched[k]?.on }">
<q-checkbox dense v-model="sched[k].on" :label="lbl" class="wse-day-lbl" />
<template v-if="sched[k]?.on">
<input type="time" v-model="sched[k].start" class="wse-time" />
<span class="wse-sep"></span>
<input type="time" v-model="sched[k].end" class="wse-time" />
<span class="wse-h">{{ dayHours(sched[k]) }}h</span>
</template>
<span v-else class="wse-repos">Repos</span>
</div>
</div>
<!-- Automatisation : répéter sur N semaines -->
<div class="row items-center q-gutter-sm q-pt-xs">
<q-input dense outlined type="number" v-model.number="weeks" label="Générer pour N semaine(s)" :min="1" :max="12" style="width:220px">
<template #prepend><q-icon name="repeat" size="18px" /></template>
</q-input>
<div class="text-caption text-grey-6">à partir de la semaine affichée · {{ totalDays() }} j / {{ Math.round(totalHours() * 10) / 10 }}h par semaine</div>
</div>
</q-card-section>
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat no-caps label="Annuler" color="grey-7" @click="emit('update:modelValue', false)" />
<q-btn unelevated no-caps color="primary" icon="event_available" :label="'Générer ' + (weeks > 1 ? weeks + ' semaines' : 'la semaine')" @click="submit" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<style scoped>
.wse-preset { display: inline-flex; align-items: center; padding: 4px 12px; border: 1.5px solid #c7d2fe; border-radius: 14px; font-size: 12.5px; font-weight: 600; color: #4338ca; background: #eef2ff; cursor: pointer; user-select: none; }
.wse-preset:hover { background: #e0e7ff; }
.wse-grid { display: flex; flex-direction: column; gap: 4px; }
.wse-day { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: 8px; background: #f8fafc; }
.wse-day.off { opacity: 0.7; }
.wse-day-lbl { min-width: 62px; font-weight: 600; }
.wse-time { border: 1px solid #cbd5e1; border-radius: 6px; padding: 3px 6px; font-size: 13px; font-family: inherit; }
.wse-sep { color: #94a3b8; }
.wse-h { color: #64748b; font-size: 12px; font-weight: 600; margin-left: auto; }
.wse-repos { color: #94a3b8; font-style: italic; font-size: 12.5px; margin-left: 8px; }
</style>

View File

@ -790,11 +790,10 @@
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> = maîtrise · <b>Efficacité</b> : <b>+</b> plus vite / <b></b> plus lent pour CETTE compétence (ex. <b>+80</b>), vide = globale · × sur le chip = retirer.</div>
</div>
<q-separator class="q-my-sm" />
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire semaine affichée</div>
<div class="row q-gutter-xs">
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h (LV)" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)" />
<q-btn dense outline size="sm" no-caps color="primary" label="4×10h (LJ)" @click="applyWeekPreset(skillDialog, [1,2,3,4], 7, 17)" />
<q-btn dense outline size="sm" no-caps color="primary" label="3×12h (LM)" @click="applyWeekPreset(skillDialog, [1,2,3], 7, 19)" />
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire</div>
<div class="row items-center q-gutter-xs">
<q-btn dense unelevated size="sm" no-caps color="primary" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>Modèles (5×8h · 4×10h · 3×12h) + heures par jour + N semaines publie les quarts</q-tooltip></q-btn>
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h rapide" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)"><q-tooltip>Applique 5×8h à la semaine affichée (à publier)</q-tooltip></q-btn>
</div>
<q-separator class="q-my-sm" />
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🏠 Domicile <span class="text-grey-5 text-weight-regular">(départ de tournée si plus proche que le dépôt)</span></div>
@ -1734,6 +1733,8 @@
: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" />
<!-- Génération de quarts hebdo par tech (modèles + N semaines) écrit les Shift Assignment -->
<WeeklyScheduleEditor v-model="schedGenOpen" :tech-name="schedGenTech && schedGenTech.name" @apply="onScheduleApply" />
</q-page>
</template>
@ -1777,6 +1778,7 @@ import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags pa
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) Dispatch déprécié, tout ici
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » pré-remplit la création (tech+date+heure+adresse)
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) repris/amélioré depuis Dispatch
const $q = useQuasar()
const router = useRouter()
@ -2333,6 +2335,29 @@ async function applyWeekPreset (t, dows, min, max) {
skillMenuShown.value = false
$q.notify({ type: 'positive', message: t.name + ' : horaire appliqué (semaine affichée) — pense à Publier', timeout: 2500 })
}
// Génération de quarts HEBDO par tech (modèles + N semaines) écrit DIRECTEMENT les Shift Assignment (Publié).
const schedGenOpen = ref(false); const schedGenTech = ref(null)
function openSchedGen (t) { schedGenTech.value = { id: t.id, name: t.name }; skillMenuShown.value = false; schedGenOpen.value = true }
const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
async function onScheduleApply ({ schedule, weeks }) {
const t = schedGenTech.value; if (!t) return
let created = 0, failed = 0; const tplByKey = {}
const base = start.value // lundi de la semaine affichée
for (let w = 0; w < weeks; w++) {
for (let i = 0; i < 7; i++) {
const day = schedule[i]; if (!day || !day.on) continue
const sh = _hmNum(day.start), eh = _hmNum(day.end); if (!(eh > sh)) { failed++; continue }
const key = sh + '-' + eh; let tpl = tplByKey[key]
if (!tpl) { tpl = await ensureWindowTpl(sh, eh); tplByKey[key] = tpl }
if (!tpl) { failed++; continue }
const iso = addDaysISO(base, w * 7 + i)
try { const r = await roster.createShift({ tech: t.id, tech_name: t.name, date: iso, shift: tpl.name, hours: tpl.hours || calcHours(day.start, day.end) }); if (r && (r.ok || r.existed)) created++; else failed++ } catch (e) { failed++ }
}
}
try { await loadWeek() } catch (e) {} // rafraîchit la grille (semaine affichée)
$q.notify({ type: created ? 'positive' : 'warning', icon: 'event_available', message: created + ' quart(s) publié(s) pour ' + t.name + (weeks > 1 ? ' (' + weeks + ' sem.)' : '') + (failed ? ' · ' + failed + ' ignoré(s)' : ''), timeout: 3500 })
schedGenTech.value = null
}
function skillEffOf (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? Number(e) : (Number(t.efficiency) || 1) } // facteur (pour le calcul de priorité)
// Efficacité saisie en % de PERFORMANCE : + = plus VITE (moins de temps) · = plus LENT. Conversion facteur.
function effPctOf (factor) { return Math.round((1 - (Number(factor) || 1)) * 100) }