refactor(planif): fusion « Réserver du temps » + « Bloquer / job générique » en UN dialogue

Deux dialogues faisaient la même chose (rendre un tech indisponible pour un bloc)
avec des champs ~80% identiques, des points d'entrée différents (bouton volet
réglages vs menu de cellule) et aucune explication de leur différence → lus comme
un doublon. Seul vrai écart : bloc DUR (hors dispatch, ticket optionnel) vs réserve
SOUPLE (dé-priorise, urgences passent).

→ UN dialogue « Réserver / bloquer du temps » avec bascule de MODE (segmented
control : « Hors dispatch (bloc) » / « Urgences OK (souple) »). Champs communs
(tech · date · début · durée · journée complète · motif/intitulé) ; priorité/type/
ticket seulement en mode bloc. Soumission `doBlockOrReserve` → /roster/reserve OU
/roster/filler-job selon le mode (endpoints inchangés). Les 2 points d'entrée
(bouton « Réserver / bloquer… » du volet réglages [souple par défaut] · item de menu
de cellule [bloc par défaut, pré-rempli]) ouvrent le MÊME dialogue.

Supprimé : resvDlg + doReserve (le dialogue « Réserver » séparé). Aperçu vérifié :
bascule de mode montre/masque priorité/type/ticket + change libellé/info/bouton.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-16 14:52:12 -04:00
parent 9c1b578d5d
commit ba6967adc6
2 changed files with 54 additions and 71 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>Bloquer / job générique</q-item-label><q-item-label caption>Occupe la journée (durée éditable) · retire du dispatch · ticket</q-item-label></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>
<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…" @click="openReserve(skillDialog)"><q-tooltip>Réserver du temps pour une tâche/projet (bloc priorité moyenne) -priorise ce tech au dispatch auto. Réparations/urgences passent quand même (créneau en mode urgence).</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>
</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,42 +1359,26 @@
</q-card>
</q-dialog>
<!-- #5 Réserver du temps d'un tech (bloc « Réservation » priorité moyenne -priorise au dispatch auto ; urgences passent) -->
<q-dialog v-model="resvDlg.open">
<q-card style="min-width:320px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="event_busy" color="deep-orange-7" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Réserver 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>{{ resvDlg.tech && resvDlg.tech.name }}</b> <span class="text-grey-6"> indisponible au dispatch auto pendant ce bloc</span></div>
<div class="row q-col-gutter-sm">
<q-input class="col-6" dense outlined type="date" v-model="resvDlg.date" label="Date" />
<q-input class="col-3" dense outlined type="time" v-model="resvDlg.start" label="Début" />
<q-input class="col-3" dense outlined type="number" v-model.number="resvDlg.dur" label="Durée (h)" min="0.5" step="0.5" />
</div>
<q-input dense outlined v-model="resvDlg.reason" label="Motif (tâche / projet)" placeholder="Ex. Projet déploiement fibre secteur X" />
<div class="text-caption text-grey-6"><q-icon name="info" size="14px" /> Les <b>réparations & urgences</b> peuvent quand même utiliser ce tech (recherche de créneau en mode urgence).</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" v-close-popup />
<q-btn unelevated no-caps color="deep-orange-7" icon="event_busy" label="Réserver" :loading="resvDlg.busy" @click="doReserve" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Job « bouche-trou » (filler) : job générique de priorité STANDARD assigné occupe la journée retire le tech du dispatch. Optionnel : génère un ticket. -->
<!-- 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). -->
<q-dialog v-model="fillerDlg.open">
<q-card style="min-width:340px;max-width:96vw">
<q-card style="min-width:360px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="block" color="deep-orange-6" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Bloquer / job générique</div>
<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-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> <span class="text-grey-6"> occupé toute la période retiré du dispatch régulier</span></div>
<q-input dense outlined v-model="fillerDlg.subject" label="Intitulé" placeholder="Ex. Formation · Réunion · Entretien véhicule" autofocus />
<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 />
<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" />
@ -1402,14 +1386,17 @@
<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 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 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é" />
</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>
<q-input dense outlined v-model="fillerDlg.job_type" label="Type" placeholder="Interne" />
<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="add" label="Créer" :loading="fillerDlg.busy" :disable="!fillerDlg.subject" @click="doFiller" />
<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-card-actions>
</q-card>
</q-dialog>
@ -2572,21 +2559,22 @@ const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près
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.
const resvDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 2, reason: '', busy: false })
function openReserve (t) {
if (!t) return
skillMenuShown.value = false
const iso = mobileSelIso.value || kbSelIso.value || start.value || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
Object.assign(resvDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, reason: '', busy: false })
}
// Job « bouche-trou » (filler) : depuis le menu de cellule (clic case vide / clic droit). Pré-remplit tech+date+durée.
// La durée « journée complète » = longueur du quart de la case s'il existe, sinon 8 h. Occupe l'occupation hors dispatch.
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 })
// 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 })
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.
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 })
}
// 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).
function openFillerFromMenu () {
if (!menu.tech || !menu.day) return
const sh = shiftHoursOf(menu.tech.id, menu.day.iso)
@ -2595,36 +2583,31 @@ 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,
priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false,
mode: 'block', 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
async function doFiller () {
if (!fillerDlg.tech || !fillerDlg.date || !fillerDlg.subject || fillerDlg.busy) return
// 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
fillerDlg.busy = true
const dur = fillerDlg.fullDay ? (fillerDlg.shiftH || 8) : (Number(fillerDlg.dur) || (fillerDlg.mode === 'reserve' ? 2 : 8))
try {
const dur = fillerDlg.fullDay ? (fillerDlg.shiftH || 8) : (Number(fillerDlg.dur) || 8)
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' + (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: 'Création impossible : ' + (e.message || e) }) } finally { fillerDlg.busy = false }
}
async function doReserve () {
if (!resvDlg.tech || !resvDlg.date || resvDlg.busy) return
resvDlg.busy = true
try {
const r = await roster.reserveTech({ tech: resvDlg.tech.id, date: resvDlg.date, start_time: resvDlg.start, duration_h: resvDlg.dur, reason: resvDlg.reason })
if (r && r.ok) { $q.notify({ type: 'positive', icon: 'event_busy', message: 'Temps réservé — ' + resvDlg.tech.name + ' · ' + resvDlg.dur + 'h' }); resvDlg.open = false; try { await reloadOccupancy() } catch (e) {} }
else $q.notify({ type: 'negative', message: (r && r.error) || 'Réservation impossible' })
} catch (e) { $q.notify({ type: 'negative', message: 'Réservation impossible' }) } finally { resvDlg.busy = false }
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 }
}
// « 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) {