feat(planif): functional dispatch — single-day suggest, one overdue chip, sick-tech redistribute, bulk shift gen

Making the auto-dispatch match the real workflow:
- SINGLE selected day: suggestWindow is now one day (suggestDay), not
  today+tomorrow. openSuggest defaults it to the board's focus day
  (mobileSelIso/kbSelIso/today); config has a day picker (Aujourd'hui /
  Demain / date). Overdue + undated jobs are pulled onto that day.
  Re-simulating today is fine (non-destructive; apply is explicit).
- ONE " en retard" chip: assignDates collapses all past dates into a
  single overdue chip (was one per date); pool filter handles __overdue__.
- SICK/absent tech: a "sick" button per review group deselects the tech
  and re-optimizes → the solver redistributes their jobs across the rest
  (verified: removing Anthony reassigned his jobs, Gilles picked up).
- BULK shift generation (A): WeeklyScheduleEditor gains multi-tech mode
  (tech chips, all on by default); "Générer les horaires (lot)" in the
  Outils + mobile menus applies a template to N techs × N weeks at once.

Verified: overdue collapses to "en retard 13"; day selector + single-day
plan (13 jobs); sick redistributes; bulk shows 56 techs / "Générer ×56".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-03 10:59:39 -04:00
parent b1ff46e8c1
commit c267f9a3dd
2 changed files with 85 additions and 36 deletions

View File

@ -6,24 +6,37 @@
* + « nombre de semaines » pour AUTOMATISER (répéter le patron sur N semaines). * + « 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. * Émet 'apply' { schedule:[{dow,on,start,end}] (Lun..Dim), weeks } le parent écrit les Shift Assignment.
*/ */
import { reactive, ref, watch } from 'vue' import { reactive, ref, watch, computed } from 'vue'
import { SCHEDULE_PRESETS } from 'src/composables/useHelpers' import { SCHEDULE_PRESETS } from 'src/composables/useHelpers'
const props = defineProps({ modelValue: { type: Boolean, default: false }, techName: { type: String, default: '' } }) const props = defineProps({
modelValue: { type: Boolean, default: false },
techName: { type: String, default: '' },
techs: { type: Array, default: () => [] }, // [{id,name}] si >1 : sélection multi (application en LOT)
})
const emit = defineEmits(['update:modelValue', 'apply']) const emit = defineEmits(['update:modelValue', 'apply'])
const DAYS = [['mon', 'Lun'], ['tue', 'Mar'], ['wed', 'Mer'], ['thu', 'Jeu'], ['fri', 'Ven'], ['sat', 'Sam'], ['sun', 'Dim']] const DAYS = [['mon', 'Lun'], ['tue', 'Mar'], ['wed', 'Mer'], ['thu', 'Jeu'], ['fri', 'Ven'], ['sat', 'Sam'], ['sun', 'Dim']]
const sched = reactive({}) const sched = reactive({})
const weeks = ref(1) const weeks = ref(1)
const selected = ref(new Set()) // techIds cochés (mode lot)
const bulk = computed(() => (props.techs || []).length > 1)
const title = computed(() => bulk.value ? ((props.techs || []).length + ' techniciens') : (props.techName || (props.techs[0] && props.techs[0].name) || ''))
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' } } } 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) fill(SCHEDULE_PRESETS[0].schedule)
watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = 1 } }) watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = 1; selected.value = new Set((props.techs || []).map(t => t.id)) } })
function toggleTech (id) { const s = new Set(selected.value); s.has(id) ? s.delete(id) : s.add(id); selected.value = s }
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 } 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 totalDays = () => DAYS.filter(([k]) => sched[k]?.on).length
const totalHours = () => DAYS.reduce((s, [k]) => s + dayHours(sched[k]), 0) 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) } function submit () {
const techIds = bulk.value ? [...selected.value] : (props.techs[0] ? [props.techs[0].id] : [])
if (!techIds.length) return
emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)), techIds })
emit('update:modelValue', false)
}
</script> </script>
<template> <template>
@ -31,11 +44,20 @@ function submit () { emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...s
<q-card style="width:600px;max-width:96vw"> <q-card style="width:600px;max-width:96vw">
<q-card-section class="row items-center q-pb-sm"> <q-card-section class="row items-center q-pb-sm">
<q-icon name="event_note" color="primary" size="24px" class="q-mr-sm" /> <q-icon name="event_note" color="primary" size="24px" class="q-mr-sm" />
<div class="text-h6">Horaire {{ techName }}</div> <div class="text-h6">Horaire {{ title }}</div>
<q-space /><q-btn flat round dense icon="close" @click="emit('update:modelValue', false)" /> <q-space /><q-btn flat round dense icon="close" @click="emit('update:modelValue', false)" />
</q-card-section> </q-card-section>
<q-separator /> <q-separator />
<q-card-section class="q-gutter-sm"> <q-card-section class="q-gutter-sm">
<!-- Mode LOT : choisir les techniciens à qui appliquer le modèle (tous cochés par défaut) -->
<div v-if="bulk">
<div class="text-caption text-grey-7 q-mb-xs">Appliquer à ({{ selected.size }}/{{ techs.length }}) clic pour ()cocher :</div>
<div class="row q-gutter-xs" style="max-height:96px;overflow:auto">
<span v-for="t in techs" :key="t.id" class="wse-tech" :class="{ on: selected.has(t.id) }" @click="toggleTech(t.id)">
<q-icon :name="selected.has(t.id) ? 'check' : 'add'" size="12px" />{{ t.name }}
</span>
</div>
</div>
<!-- Modèles (1 clic remplit le formulaire) --> <!-- Modèles (1 clic remplit le formulaire) -->
<div class="row q-gutter-xs"> <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> <span v-for="p in SCHEDULE_PRESETS" :key="p.key" class="wse-preset" @click="fill(p.schedule)">{{ p.label }}</span>
@ -63,7 +85,7 @@ function submit () { emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...s
</q-card-section> </q-card-section>
<q-card-actions align="right" class="q-px-md q-pb-md"> <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 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-btn unelevated no-caps color="primary" icon="event_available" :disable="bulk && !selected.size" :label="'Générer' + (bulk ? ' ×' + selected.size + ' tech' : '') + (weeks > 1 ? ' · ' + weeks + ' sem.' : '')" @click="submit" />
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
@ -80,4 +102,6 @@ function submit () { emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...s
.wse-sep { color: #94a3b8; } .wse-sep { color: #94a3b8; }
.wse-h { color: #64748b; font-size: 12px; font-weight: 600; margin-left: auto; } .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; } .wse-repos { color: #94a3b8; font-style: italic; font-size: 12.5px; margin-left: 8px; }
.wse-tech { display: inline-flex; align-items: center; gap: 3px; padding: 2px 9px; border: 1.5px solid #cbd5e1; border-radius: 12px; font-size: 12px; font-weight: 600; color: #64748b; background: #fff; cursor: pointer; user-select: none; }
.wse-tech.on { border-color: #6366f1; background: #eef2ff; color: #4338ca; }
</style> </style>

View File

@ -21,6 +21,7 @@
<q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item> <q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item>
<q-item clickable v-close-popup @click="openCreateJob"><q-item-section avatar><q-icon name="add_task" color="teal-7" /></q-item-section><q-item-section>Créer un job</q-item-section></q-item> <q-item clickable v-close-popup @click="openCreateJob"><q-item-section avatar><q-icon name="add_task" color="teal-7" /></q-item-section><q-item-section>Créer un job</q-item-section></q-item>
<q-item clickable v-close-popup @click="showSuggestSlots = true"><q-item-section avatar><q-icon name="event_available" color="teal-8" /></q-item-section><q-item-section>Trouver un créneau</q-item-section></q-item> <q-item clickable v-close-popup @click="showSuggestSlots = true"><q-item-section avatar><q-icon name="event_available" color="teal-8" /></q-item-section><q-item-section>Trouver un créneau</q-item-section></q-item>
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)</q-item-section></q-item>
<q-item clickable v-close-popup @click="openLegacyPush"><q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section><q-item-section>Publier au legacy</q-item-section></q-item> <q-item clickable v-close-popup @click="openLegacyPush"><q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section><q-item-section>Publier au legacy</q-item-section></q-item>
<q-item clickable v-close-popup @click="() => guard(loadWeek)"><q-item-section avatar><q-icon name="refresh" /></q-item-section><q-item-section>Rafraîchir</q-item-section></q-item> <q-item clickable v-close-popup @click="() => guard(loadWeek)"><q-item-section avatar><q-icon name="refresh" /></q-item-section><q-item-section>Rafraîchir</q-item-section></q-item>
</q-list> </q-list>
@ -96,6 +97,7 @@
</q-list> </q-list>
</q-menu> </q-menu>
</q-item> </q-item>
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)<q-item-label caption>modèle plusieurs techs, N semaines</q-item-label></q-item-section></q-item>
<q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item> <q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
<q-separator /> <q-separator />
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item> <q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
@ -1018,7 +1020,14 @@
<q-input v-model.number="solverOpts.speedKmh" type="number" dense outlined style="width:86px" label="km/h" :min="20" :max="90"><q-tooltip>Vitesse de repli (nœuds sans coordonnées)</q-tooltip></q-input> <q-input v-model.number="solverOpts.speedKmh" type="number" dense outlined style="width:86px" label="km/h" :min="20" :max="90"><q-tooltip>Vitesse de repli (nœuds sans coordonnées)</q-tooltip></q-input>
<q-input v-model.number="solverOpts.maxSeconds" type="number" dense outlined style="width:92px" label="Calcul s" :min="2" :max="30"><q-tooltip>Budget de calcul par jour</q-tooltip></q-input> <q-input v-model.number="solverOpts.maxSeconds" type="number" dense outlined style="width:92px" label="Calcul s" :min="2" :max="30"><q-tooltip>Budget de calcul par jour</q-tooltip></q-input>
</div> </div>
<div class="text-caption q-mb-sm" style="color:#475569;background:#eef2ff;border-radius:6px;padding:4px 8px"><q-icon name="date_range" size="14px" class="q-mr-xs" color="primary" />Répartit sur : <b>{{ suggestWindowLabel }}</b><span class="text-grey-6"> défaut aujourd'hui + demain ; filtre par date (chips de la liste) pour cibler d'autres jours. Les jobs datés au-delà sont laissés pour plus tard.</span></div> <!-- JOUR CIBLÉ (une seule journée) : les jobs en retard / sans date sont tirés sur ce jour. Re-simuler aujourd'hui = OK (non destructif). -->
<div class="row items-center q-mb-sm" style="gap:6px;color:#475569;background:#eef2ff;border-radius:6px;padding:5px 8px">
<q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir pour :</span>
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === todayISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === todayISO() ? 'white' : 'grey-8'" label="Aujourd'hui" @click="suggestDay = todayISO()" />
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === tomorrowISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === tomorrowISO() ? 'white' : 'grey-8'" label="Demain" @click="suggestDay = tomorrowISO()" />
<q-input dense outlined type="date" v-model="suggestDay" style="width:150px" />
<q-space /><span class="text-caption text-grey-6">jobs en retard/sans date tirés sur ce jour</span>
</div>
<div class="row items-center q-mb-xs" style="gap:4px"> <div class="row items-center q-mb-xs" style="gap:4px">
<span class="text-caption text-grey-7 q-mr-xs">Rapide :</span> <span class="text-caption text-grey-7 q-mr-xs">Rapide :</span>
<q-btn dense flat size="sm" no-caps label="Tous" @click="suggestSelectTechs('all')" /> <q-btn dense flat size="sm" no-caps label="Tous" @click="suggestSelectTechs('all')" />
@ -1085,6 +1094,8 @@
<q-btn v-if="!g.placeholder" flat dense round size="sm" :icon="techHomes[g.techId] ? 'home' : 'business'" :color="techHomes[g.techId] ? 'teal-7' : 'blue-grey-4'" @click="openHomePicker(techById[g.techId] || { id: g.techId, name: g.techName })"><q-tooltip>Départ : {{ techHomes[g.techId] ? ('domicile — ' + (techHomes[g.techId].address || 'défini')) : 'bureau TARGO (défaut)' }} cliquer pour définir/modifier le domicile</q-tooltip></q-btn> <q-btn v-if="!g.placeholder" flat dense round size="sm" :icon="techHomes[g.techId] ? 'home' : 'business'" :color="techHomes[g.techId] ? 'teal-7' : 'blue-grey-4'" @click="openHomePicker(techById[g.techId] || { id: g.techId, name: g.techName })"><q-tooltip>Départ : {{ techHomes[g.techId] ? ('domicile — ' + (techHomes[g.techId].address || 'défini')) : 'bureau TARGO (défaut)' }} cliquer pour définir/modifier le domicile</q-tooltip></q-btn>
<!-- Éditer / réordonner les compétences du tech directement depuis l'optimiseur (même éditeur : chips + glisser priorité) --> <!-- Éditer / réordonner les compétences du tech directement depuis l'optimiseur (même éditeur : chips + glisser priorité) -->
<q-btn v-if="!g.placeholder" flat dense round size="sm" icon="edit" color="grey-6" @click="openSkillEditorFor(g.techId, $event)"><q-tooltip>Compétences & niveaux de {{ g.techName }} glisser pour l'ordre de priorité</q-tooltip></q-btn> <q-btn v-if="!g.placeholder" flat dense round size="sm" icon="edit" color="grey-6" @click="openSkillEditorFor(g.techId, $event)"><q-tooltip>Compétences & niveaux de {{ g.techName }} glisser pour l'ordre de priorité</q-tooltip></q-btn>
<!-- Tech malade/absent le retire ET redistribue ses jobs équitablement sur les autres (-optimise) -->
<q-btn v-if="!g.placeholder" flat dense round size="sm" icon="sick" color="deep-orange-4" @click="markTechSick(g)"><q-tooltip>{{ g.techName }} malade / absent retirer et redistribuer ses {{ g.jobs }} job(s) sur les autres</q-tooltip></q-btn>
<!-- Assigner / fusionner TOUTE la file (marche depuis un placeholder vrai tech, ou pour fusionner 2 files) --> <!-- Assigner / fusionner TOUTE la file (marche depuis un placeholder vrai tech, ou pour fusionner 2 files) -->
<q-btn flat dense round size="sm" :icon="g.placeholder ? 'person_add' : 'drive_file_move'" :color="g.placeholder ? 'amber-9' : 'grey-7'"><q-tooltip>{{ g.placeholder ? 'Assigner cette tournée à un tech' : ('Déplacer / fusionner tous les jobs de ' + g.techName) }}</q-tooltip> <q-btn flat dense round size="sm" :icon="g.placeholder ? 'person_add' : 'drive_file_move'" :color="g.placeholder ? 'amber-9' : 'grey-7'"><q-tooltip>{{ g.placeholder ? 'Assigner cette tournée à un tech' : ('Déplacer / fusionner tous les jobs de ' + g.techName) }}</q-tooltip>
<q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:42vh;overflow:auto"> <q-menu anchor="bottom right" self="top right"><q-list dense style="min-width:250px;max-height:42vh;overflow:auto">
@ -1733,8 +1744,8 @@
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" /> :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 --> <!-- « 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" />
<!-- Génération de quarts hebdo par tech (modèles + N semaines) écrit les Shift Assignment --> <!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment -->
<WeeklyScheduleEditor v-model="schedGenOpen" :tech-name="schedGenTech && schedGenTech.name" @apply="onScheduleApply" /> <WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
</q-page> </q-page>
</template> </template>
@ -2336,27 +2347,30 @@ async function applyWeekPreset (t, dows, min, max) {
$q.notify({ type: 'positive', message: t.name + ' : horaire appliqué (semaine affichée) — pense à Publier', timeout: 2500 }) $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é). // 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) const schedGenOpen = ref(false); const schedGenTechs = ref([])
function openSchedGen (t) { schedGenTech.value = { id: t.id, name: t.name }; skillMenuShown.value = false; schedGenOpen.value = true } function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech
function openSchedGenBulk () { schedGenTechs.value = (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name })); schedGenOpen.value = true } // LOT : tous les techs visibles
const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 } const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
async function onScheduleApply ({ schedule, weeks }) { async function onScheduleApply ({ schedule, weeks, techIds }) {
const t = schedGenTech.value; if (!t) return const targets = (schedGenTechs.value || []).filter(t => (techIds || []).includes(t.id)); if (!targets.length) return
let created = 0, failed = 0; const tplByKey = {} let created = 0, failed = 0; const tplByKey = {}
const base = start.value // lundi de la semaine affichée const base = start.value // lundi de la semaine affichée
for (let w = 0; w < weeks; w++) { for (const t of targets) {
for (let i = 0; i < 7; i++) { for (let w = 0; w < weeks; w++) {
const day = schedule[i]; if (!day || !day.on) continue for (let i = 0; i < 7; i++) {
const sh = _hmNum(day.start), eh = _hmNum(day.end); if (!(eh > sh)) { failed++; continue } const day = schedule[i]; if (!day || !day.on) continue
const key = sh + '-' + eh; let tpl = tplByKey[key] const sh = _hmNum(day.start), eh = _hmNum(day.end); if (!(eh > sh)) { failed++; continue }
if (!tpl) { tpl = await ensureWindowTpl(sh, eh); tplByKey[key] = tpl } const key = sh + '-' + eh; let tpl = tplByKey[key]
if (!tpl) { failed++; continue } if (!tpl) { tpl = await ensureWindowTpl(sh, eh); tplByKey[key] = tpl }
const iso = addDaysISO(base, w * 7 + i) if (!tpl) { failed++; continue }
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++ } 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) 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 }) $q.notify({ type: created ? 'positive' : 'warning', icon: 'event_available', message: created + ' quart(s) publié(s) · ' + targets.length + ' tech' + (weeks > 1 ? ' (' + weeks + ' sem.)' : '') + (failed ? ' · ' + failed + ' ignoré(s)' : ''), timeout: 4000 })
schedGenTech.value = null schedGenTechs.value = []
} }
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é) 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. // Efficacité saisie en % de PERFORMANCE : + = plus VITE (moins de temps) · = plus LENT. Conversion facteur.
@ -2525,7 +2539,15 @@ const assignDateFilter = ref([]) // isos retenus (vide = toutes les dates)
const assignDates = computed(() => { const assignDates = computed(() => {
const t = todayISO(); const m = {} const t = todayISO(); const m = {}
for (const j of assignPanel.jobs) { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; m[d] = (m[d] || 0) + 1 } for (const j of assignPanel.jobs) { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; m[d] = (m[d] || 0) + 1 }
return Object.entries(m).map(([iso, n]) => ({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: iso !== 'Sans date' && iso < t })).sort((a, b) => String(a.iso).localeCompare(String(b.iso))) // Toutes les dates PASSÉES UN SEUL chip « en retard » (déclutter). Les jours futurs + « Sans date » restent séparés.
const out = []; let overdueN = 0
for (const [iso, n] of Object.entries(m)) {
if (iso !== 'Sans date' && iso < t) { overdueN += n; continue }
out.push({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: false })
}
out.sort((a, b) => String(a.iso).localeCompare(String(b.iso)))
if (overdueN) out.unshift({ iso: '__overdue__', n: overdueN, label: '⏰ en retard', overdue: true })
return out
}) })
function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] } function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] }
// Depuis l'aperçu « jobs sur la colonne du jour » ouvre le panneau « À assigner » filtré sur ce jour. // Depuis l'aperçu « jobs sur la colonne du jour » ouvre le panneau « À assigner » filtré sur ce jour.
@ -2533,7 +2555,7 @@ async function openDayInPanel (iso) { await openAssignPanel(); assignSort.value
const assignJobsFiltered = computed(() => { const assignJobsFiltered = computed(() => {
let arr = assignPanel.jobs let arr = assignPanel.jobs
const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) } const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) }
const df = assignDateFilter.value; if (df.length) { const ds = new Set(df); arr = arr.filter(j => ds.has((j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date')) } const df = assignDateFilter.value; if (df.length) { const ds = new Set(df); const t = todayISO(); arr = arr.filter(j => { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t) }) } // '__overdue__' = toutes dates passées
return arr return arr
}) })
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) } function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
@ -4313,23 +4335,19 @@ function buildSuggestion () {
suggestDlg.plan = plan suggestDlg.plan = plan
} }
function openSuggest () { function openSuggest () {
suggestDay.value = mobileSelIso.value || kbSelIso.value || todayISO() // jour ciblé = celui sélectionné au tableau (défaut auj.)
suggestDlg.fromSel = selectedNames.value.length > 0 suggestDlg.fromSel = selectedNames.value.length > 0
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart dans la fenêtre (= disponibles) ; sinon tous les visibles. // Étape « disponibilité » d'abord : défaut = techs AVEC un quart CE JOUR (= disponibles) ; sinon tous les visibles.
const techs = visibleTechs.value || [] const techs = visibleTechs.value || []
const withShift = techs.filter(t => (dayList.value || []).some(d => hasShiftDay(t.id, d.iso))) const withShift = techs.filter(t => hasShiftDay(t.id, suggestDay.value))
const base = withShift.length ? withShift : techs const base = withShift.length ? withShift : techs
const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel
suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true
} }
const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length) const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length)
// Fenêtre du dispatch auto : dates SÉLECTIONNÉES (chips) sinon aujourd'hui + demain (max) on ne planifie pas loin (techs malades imprévus). // Dispatch auto = UNE SEULE journée (celle sélectionnée). Les jobs en retard / sans date sont tirés sur CE jour. Non destructif : on revoit avant d'appliquer (re-simuler aujourd'hui = OK).
const suggestWindow = computed(() => { const suggestDay = ref('')
const isos = (dayList.value || []).map(d => d.iso); const t = todayISO(); const tmrw = tomorrowISO() const suggestWindow = computed(() => [suggestDay.value || todayISO()])
const sel = (assignDateFilter.value || []).filter(iso => isos.includes(iso))
let win = sel.length ? [...sel].sort() : isos.filter(iso => iso === t || iso === tmrw)
if (!win.length) win = isos.filter(iso => iso >= t).slice(0, 2) // repli : 2 premiers jours futurs de la grille
return win
})
const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso)) const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso))
const suggestWindowLabel = computed(() => { const suggestWindowLabel = computed(() => {
const w = suggestWindow.value; if (!w.length) return '—' const w = suggestWindow.value; if (!w.length) return '—'
@ -4375,6 +4393,13 @@ async function runSuggestion () {
loadLegTimes().catch(() => {}) // temps de route réels entre arrêts (OSRM, non bloquant) loadLegTimes().catch(() => {}) // temps de route réels entre arrêts (OSRM, non bloquant)
} }
// Ré-optimiser depuis la revue : relance le solveur VRP en tenant compte des contraintes AM/PM/ posées sur les jobs. // Ré-optimiser depuis la revue : relance le solveur VRP en tenant compte des contraintes AM/PM/ posées sur les jobs.
// Tech malade/absent : le désélectionne puis RÉ-OPTIMISE ses jobs sont répartis équitablement (le solveur équilibre la charge) sur les techs restants.
async function markTechSick (g) {
if (!g || isHoldId(g.techId)) return
suggestDlg.techSel[g.techId] = false
$q.notify({ type: 'info', icon: 'sick', message: g.techName + ' retiré (malade/absent) — redistribution de ses ' + g.jobs + ' job(s)…', timeout: 2200 })
await reoptimize()
}
async function reoptimize () { async function reoptimize () {
suggestDlg.strategy = 'optimize'; suggestDlg.building = true suggestDlg.strategy = 'optimize'; suggestDlg.building = true
try { await optimizeSuggestion() } catch (e) { err(e) } finally { suggestDlg.building = false } try { await optimizeSuggestion() } catch (e) { err(e) } finally { suggestDlg.building = false }