-
-
+
+
+ Bloc prioritaire — même les réparations/urgences ne prendront pas ce tech.
+ Occupe la période (dé-priorisé au dispatch). Les réparations & urgences peuvent quand même le prendre — passez la priorité à Urgent pour un bloc dur.
+
-
+
@@ -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) {
diff --git a/services/targo-hub/lib/dispatch.js b/services/targo-hub/lib/dispatch.js
index fbfd85f..c070b52 100644
--- a/services/targo-hub/lib/dispatch.js
+++ b/services/targo-hub/lib/dispatch.js
@@ -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 => {