refactor(planif): « Bloquer du temps » = UN job/ticket qui occupe la timeline, priorité = curseur dur/souple

Suite retour Louis : plus de mode soft/hard (toggle du commit précédent retiré). Un
seul concept — un job/ticket interne qui OCCUPE la timeline (formation · réunion ·
projet · entretien) → dé-priorise le tech au dispatch. La PRIORITÉ est le curseur :
Moyenne/Basse = les urgences/réparations peuvent quand même prendre le tech ; Urgent
(high) = bloc dur, non traversé.

Front : dialogue unique « Bloquer du temps » (Intitulé · journée complète · date ·
début · durée · Priorité [défaut Moyenne] · ticket optionnel), info adaptée à la
priorité. Un seul submit `doBlockTime` → /roster/filler-job. Retiré : bascule de mode,
branche réserve, `roster.reserveTech`. Les 2 points d'entrée (bouton volet réglages +
menu de cellule) → même dialogue, relabellés « Bloquer du temps… ».

Hub (dispatch.js suggestSlots) : la traversée urgence passe du flag `job_type ===
'Réservation'` à la PRIORITÉ — `isSoftBlock` = bloc interne (Interne/Réservation) de
priorité ≤ moyenne → traversé en mode urgent ; high résiste ; les vrais jobs client ne
sont JAMAIS traversés (pas de double-booking). Ajout du champ `priority` au fetch.
(/roster/reserve laissé en place, inutilisé — retrait ultérieur.)

Vérifié : test unitaire pierce (medium/low traversé · high résiste · job client jamais)
+ aperçu (dialogue unique, priorité Moyenne, info souple, pas de toggle). Build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-16 18:38:17 -04:00
parent ba6967adc6
commit ef7b51399d
3 changed files with 45 additions and 49 deletions

View File

@ -34,7 +34,7 @@
<!-- Job « bouche-trou » : occuper la journée du tech (formation, réunion, admin) le retire du dispatch -->
<q-item clickable dense @click="$emit('filler')">
<q-item-section avatar style="min-width:30px"><q-icon name="block" color="deep-orange-6" /></q-item-section>
<q-item-section><q-item-label>Réserver / bloquer du temps</q-item-label><q-item-label caption>Bloc (hors dispatch · ticket) ou réserve souple (urgences OK)</q-item-label></q-item-section>
<q-item-section><q-item-label>Bloquer du temps</q-item-label><q-item-label caption>Job/ticket qui occupe la journée hors dispatch (priorité = curseur dur/souple)</q-item-label></q-item-section>
</q-item>
<q-separator />
<!-- Shifts en place + actions compactes -->

View File

@ -743,7 +743,7 @@
<div class="row items-center q-gutter-xs">
<q-btn dense outline size="sm" no-caps color="indigo-7" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>Ouvre l'assistant de quarts (modèles 5×8h · 4×10h · 3×12h + heures/jour + N semaines) → publie les quarts. Ce n'est PAS un bouton « enregistrer ».</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>
<q-btn dense outline size="sm" no-caps color="deep-orange-7" icon="event_busy" label="Réserver / bloquer…" @click="openReserve(skillDialog)"><q-tooltip>Réserver ou bloquer du temps (tâche/projet, formation, réunion). Choix dans le dialogue : bloc dur (hors dispatch) ou réserve souple (urgences OK).</q-tooltip></q-btn>
<q-btn dense outline size="sm" no-caps color="deep-orange-7" icon="event_busy" label="Bloquer du temps…" @click="openReserve(skillDialog)"><q-tooltip>Bloquer du temps (formation · réunion · projet · entretien) : un job/ticket qui occupe la journée -priorise au dispatch. Priorité Urgent = bloc dur ; sinon les urgences peuvent quand même passer.</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>
@ -1359,26 +1359,20 @@
</q-card>
</q-dialog>
<!-- Réserver / bloquer du temps d'un tech dialogue UNIQUE (fusion « Réserver du temps » + « Bloquer / job générique »).
Bascule de MODE : bloc (dur, hors dispatch, ticket optionnel) OU réserve souple (-priorise ; urgences passent). -->
<!-- Bloquer du temps d'un tech = UN simple job/ticket qui OCCUPE la timeline (formation, réunion, projet, entretien).
Occupe la journée -priorise au dispatch. La PRIORITÉ est le curseur : Moyenne/Basse = les urgences/réparations
peuvent quand même le prendre ; Urgent (high) = bloc dur, même les urgences ne le prennent pas. (Fusionne les
ex- « Réserver du temps » + « Bloquer / job générique » un seul concept.) -->
<q-dialog v-model="fillerDlg.open">
<q-card style="min-width:360px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon :name="fillerDlg.mode === 'reserve' ? 'event_busy' : 'block'" color="deep-orange-6" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Réserver / bloquer du temps</div>
<q-icon name="event_busy" color="deep-orange-6" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Bloquer du temps</div>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-sm">
<div class="text-body2"><b>{{ fillerDlg.tech && fillerDlg.tech.name }}</b></div>
<!-- LE seul vrai choix entre les 2 anciens dialogues : bloc dur vs réserve souple -->
<q-btn-toggle v-model="fillerDlg.mode" dense spread unelevated no-caps toggle-color="deep-orange-6" color="grey-3" text-color="grey-8"
:options="[{ value: 'block', label: 'Hors dispatch (bloc)', icon: 'block' }, { value: 'reserve', label: 'Urgences OK (souple)', icon: 'event_busy' }]" />
<div class="text-caption text-grey-6">
<q-icon name="info" size="14px" />
<template v-if="fillerDlg.mode === 'reserve'"> -priorise ce tech au dispatch auto ; les <b>réparations & urgences</b> peuvent quand même le prendre.</template>
<template v-else> Occupe toute la période <b>retiré du dispatch régulier</b>.</template>
</div>
<q-input dense outlined v-model="fillerDlg.subject" :label="fillerDlg.mode === 'reserve' ? 'Motif (tâche / projet)' : 'Intitulé'" :placeholder="fillerDlg.mode === 'reserve' ? 'Ex. Projet déploiement fibre secteur X' : 'Ex. Formation · Réunion · Entretien véhicule'" autofocus />
<q-input dense outlined v-model="fillerDlg.subject" label="Intitulé" placeholder="Ex. Formation · Réunion · Projet · Entretien véhicule" autofocus />
<div class="row items-center q-gutter-sm">
<q-toggle v-model="fillerDlg.fullDay" dense size="sm" color="deep-orange-6" label="Journée complète" @update:model-value="onFillerFullDay" />
<q-input class="col" dense outlined type="date" v-model="fillerDlg.date" label="Date" />
@ -1386,17 +1380,18 @@
<div class="row q-col-gutter-sm">
<q-input class="col-4" dense outlined type="time" v-model="fillerDlg.start" label="Début" :disable="fillerDlg.fullDay" />
<q-input class="col-4" dense outlined type="number" v-model.number="fillerDlg.dur" label="Durée (h)" min="0.5" step="0.5" :disable="fillerDlg.fullDay" />
<q-select v-if="fillerDlg.mode === 'block'" class="col-4" dense outlined emit-value map-options v-model="fillerDlg.priority" :options="POOL_PRIOS" option-value="value" option-label="label" label="Priorité" />
<q-select class="col-4" dense outlined emit-value map-options v-model="fillerDlg.priority" :options="POOL_PRIOS" option-value="value" option-label="label" label="Priorité" />
</div>
<!-- Type + ticket : mode BLOC seulement (la réserve souple ne crée pas de ticket) -->
<div v-if="fillerDlg.mode === 'block'" class="row items-center q-col-gutter-sm">
<q-input class="col" dense outlined v-model="fillerDlg.job_type" label="Type" placeholder="Interne" />
<q-toggle class="col-auto" v-model="fillerDlg.createTicket" dense size="sm" color="primary" label="Générer un ticket lié" />
<div class="text-caption text-grey-6">
<q-icon name="info" size="14px" />
<template v-if="fillerDlg.priority === 'high'"> Bloc <b>prioritaire</b> même les réparations/urgences ne prendront pas ce tech.</template>
<template v-else> Occupe la période (-priorisé au dispatch). Les <b>réparations & urgences</b> peuvent quand même le prendre passez la priorité à <b>Urgent</b> pour un bloc dur.</template>
</div>
<q-toggle v-model="fillerDlg.createTicket" dense size="sm" color="primary" label="Générer un ticket lié" />
</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" v-close-popup />
<q-btn unelevated no-caps color="deep-orange-6" :icon="fillerDlg.mode === 'reserve' ? 'event_busy' : 'add'" :label="fillerDlg.mode === 'reserve' ? 'Réserver' : 'Bloquer'" :loading="fillerDlg.busy" :disable="fillerDlg.mode === 'block' && !fillerDlg.subject" @click="doBlockOrReserve" />
<q-btn unelevated no-caps color="deep-orange-6" icon="event_busy" label="Bloquer" :loading="fillerDlg.busy" :disable="!fillerDlg.subject" @click="doBlockTime" />
</q-card-actions>
</q-card>
</q-dialog>
@ -2558,23 +2553,24 @@ const skillDialog = ref(null) // tech dont on édite les compétences
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
const skillMenuShown = ref(false)
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true; loadTraccarDevices() } // charge les appareils GPS pour la section « Appareil GPS » du volet
// #5 Réserver du temps d'un tech (bloc « Réservation », priorité moyenne). Occupe l'occupation dé-priorise au dispatch auto + soustrait des créneaux.
// Dialogue UNIQUE « Réserver / bloquer du temps » (fusion des ex- « Réserver du temps » + « Bloquer / job générique »).
// `mode` = SEUL vrai écart entre les deux : 'block' (dur, hors dispatch, ticket optionnel) · 'reserve' (souple, urgences passent).
const fillerDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 8, fullDay: true, shiftH: 8, mode: 'block', priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false })
// BLOQUER DU TEMPS = un simple job/ticket qui OCCUPE la timeline (formation, réunion, projet, entretien). Occupe la
// journée dé-priorise au dispatch. La PRIORITÉ est le curseur : Moyenne/Basse = urgences/réparations peuvent quand
// même le prendre (suggestSlots pierce les blocs priorité moyenne en mode urgent) ; Urgent (high) = bloc dur.
// UN seul concept (fusion des ex- « Réserver du temps » + « Bloquer / job générique » plus de mode soft/hard).
const fillerDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 8, fullDay: true, shiftH: 8, priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false })
function shiftHoursOf (techId, iso) { // longueur cumulée des quarts de la case (0 si aucun)
const occ = cellOcc(techId, iso)
if (occ && occ.hasReg && occ.shiftE > occ.shiftS) return Math.round((occ.shiftE - occ.shiftS) * 10) / 10
return 0
}
// Ouverture depuis le volet réglages du tech (bouton « Réserver / bloquer ») : jour = sélection courante, souple par défaut.
// Ouverture depuis le volet réglages du tech (bouton « Bloquer du temps ») : jour = sélection courante, 2 h par défaut.
function openReserve (t) {
if (!t) return
skillMenuShown.value = false
const iso = selDay.value || mobileSelIso.value || kbSelIso.value || start.value || todayISO()
Object.assign(fillerDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, fullDay: false, shiftH: 0, mode: 'reserve', priority: 'medium', job_type: 'Interne', subject: '', createTicket: false, busy: false })
Object.assign(fillerDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, fullDay: false, shiftH: 0, priority: 'medium', job_type: 'Interne', subject: '', createTicket: false, busy: false })
}
// Ouverture depuis le menu de cellule (clic case) : bloc dur par défaut, pré-rempli tech+date+durée (= longueur du quart, sinon 8 h).
// Ouverture depuis le menu de cellule (clic case) : journée complète par défaut, pré-rempli tech+date+durée (= longueur du quart, sinon 8 h).
function openFillerFromMenu () {
if (!menu.tech || !menu.day) return
const sh = shiftHoursOf(menu.tech.id, menu.day.iso)
@ -2583,31 +2579,27 @@ function openFillerFromMenu () {
Object.assign(fillerDlg, {
open: true, tech: { id: menu.tech.id, name: menu.tech.name }, date: menu.day.iso,
start: numToTime(startH), dur: sh || 8, fullDay: true, shiftH: sh || 8,
mode: 'block', priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false,
priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false,
})
menu.show = false
}
function onFillerFullDay (v) { if (v) { fillerDlg.dur = fillerDlg.shiftH || 8 } } // journée complète durée = quart
// Soumission unifiée : réserve souple (/reserve) OU bloc dur + ticket (/filler-job) selon le mode.
async function doBlockOrReserve () {
if (!fillerDlg.tech || !fillerDlg.date || fillerDlg.busy) return
if (fillerDlg.mode === 'block' && !fillerDlg.subject) return
// Soumission UNIQUE : crée le job/ticket interne qui occupe la timeline (priorité = curseur dur/souple).
async function doBlockTime () {
if (!fillerDlg.tech || !fillerDlg.date || !fillerDlg.subject || fillerDlg.busy) return
fillerDlg.busy = true
const dur = fillerDlg.fullDay ? (fillerDlg.shiftH || 8) : (Number(fillerDlg.dur) || (fillerDlg.mode === 'reserve' ? 2 : 8))
const dur = fillerDlg.fullDay ? (fillerDlg.shiftH || 8) : (Number(fillerDlg.dur) || 8)
try {
if (fillerDlg.mode === 'reserve') {
const r = await roster.reserveTech({ tech: fillerDlg.tech.id, date: fillerDlg.date, start_time: fillerDlg.start, duration_h: dur, reason: fillerDlg.subject })
if (r && r.ok) { $q.notify({ type: 'positive', icon: 'event_busy', message: 'Temps réservé — ' + fillerDlg.tech.name + ' · ' + dur + 'h (urgences OK)' }); fillerDlg.open = false; try { await reloadOccupancy() } catch (e) {} }
else $q.notify({ type: 'negative', message: (r && r.error) || 'Réservation impossible' })
} else {
const r = await roster.createFillerJob({
tech: fillerDlg.tech.id, date: fillerDlg.date, start_time: fillerDlg.start, duration_h: dur,
priority: fillerDlg.priority, job_type: fillerDlg.job_type || 'Interne', subject: fillerDlg.subject, create_ticket: fillerDlg.createTicket,
})
if (r && r.ok) { $q.notify({ type: 'positive', icon: 'block', message: fillerDlg.tech.name + ' — ' + dur + 'h bloquées (hors dispatch)' + (r.issue ? ' · ticket ' + r.issue : '') }); fillerDlg.open = false; try { await reloadOccupancy() } catch (e) {} }
else $q.notify({ type: 'negative', message: (r && r.error) || 'Création impossible' })
}
} catch (e) { $q.notify({ type: 'negative', message: 'Action impossible : ' + (e.message || e) }) } finally { fillerDlg.busy = false }
const r = await roster.createFillerJob({
tech: fillerDlg.tech.id, date: fillerDlg.date, start_time: fillerDlg.start, duration_h: dur,
priority: fillerDlg.priority, job_type: fillerDlg.job_type || 'Interne', subject: fillerDlg.subject, create_ticket: fillerDlg.createTicket,
})
if (r && r.ok) {
const soft = fillerDlg.priority !== 'high'
$q.notify({ type: 'positive', icon: 'event_busy', message: fillerDlg.tech.name + ' — ' + dur + 'h bloquées' + (soft ? ' (urgences OK)' : ' (bloc dur)') + (r.issue ? ' · ticket ' + r.issue : '') })
fillerDlg.open = false; try { await reloadOccupancy() } catch (e) {}
} else $q.notify({ type: 'negative', message: (r && r.error) || 'Blocage impossible' })
} catch (e) { $q.notify({ type: 'negative', message: 'Blocage impossible : ' + (e.message || e) }) } finally { fillerDlg.busy = false }
}
// « Voir sa tournée » depuis le volet réglages du tech : bascule en vue Tournées + isole ce tech sur la carte.
function viewTechTournee (tech) {

View File

@ -157,7 +157,7 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
['scheduled_date', '<=', dates[dates.length - 1]],
]))}&fields=${encodeURIComponent(JSON.stringify([
'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h',
'longitude', 'latitude', 'job_type',
'longitude', 'latitude', 'job_type', 'priority',
]))}&limit_page_length=500`),
erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`),
erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`),
@ -209,8 +209,12 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
// Day's pinned jobs (only those with a real start_time) — servent à découper les trous.
// Match par technician_id OU docname (les jobs legacy portent parfois l'un ou l'autre).
// BLOC DE TEMPS interne (formation/réunion/projet…) = job_type Interne/Réservation. En mode URGENT (ignoreReserved),
// la PRIORITÉ est le curseur : un bloc de priorité ≤ moyenne est TRAVERSÉ (l'urgence peut prendre le tech) ; un bloc
// « Urgent » (high) RÉSISTE = bloc dur. Les vrais jobs client ne sont JAMAIS traversés (jamais de double-booking).
const isSoftBlock = (j) => (j.job_type === 'Réservation' || j.job_type === 'Interne') && String(j.priority || 'medium').toLowerCase() !== 'high'
const belongs = (j) => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) &&
j.scheduled_date === dateStr && !(ignoreReserved && j.job_type === 'Réservation')
j.scheduled_date === dateStr && !(ignoreReserved && isSoftBlock(j))
const dayJobs = allJobs
.filter(j => belongs(j) && j.start_time)
.map(j => {