Sélectionner la rangée
-
{{ isPaused(t) ? 'Réactiver' : 'Pause' }}
+
Horaire de {{ t.name }} — récurrent · pause · congés{{ isPaused(t) ? ' (en pause)' : '' }}
{{ techRank(t) }}Priorité {{ techRank(t) }} — combine maîtrise (niveau) + vitesse (efficacité) + coût.
Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée{{ t.name }}
@@ -1911,6 +1918,7 @@
+
@@ -1964,6 +1972,7 @@ import QuoteWizard from 'src/components/shared/QuoteWizard.vue' // soumission :
import { useCreateSignal } from 'src/composables/useCreateSignal' // FAB global « Créer » → ouvre le chooser ici
import ProjectWizard from 'src/components/shared/ProjectWizard.vue' // moteur soumission existant (panier + rabais + devis + acceptation) — réutilisé
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) — repris/amélioré depuis Dispatch
+import TechScheduleDialog from 'src/components/planif/TechScheduleDialog.vue' // écran unique horaire/pause/congés par tech (icône « event » de la rangée)
const $q = useQuasar()
const router = useRouter()
@@ -2731,6 +2740,17 @@ 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 })
}
+// ── Écran unique « Horaire » par tech (icône event de la rangée) : pause indéfinie + horaire récurrent + calendrier congés + archivage. ──
+const techSchedOpen = ref(false); const techSchedTech = ref(null)
+function openTechSchedule (t) { techSchedTech.value = t; techSchedOpen.value = true }
+function onTechSchedEdit (t) { techSchedOpen.value = false; openSchedGen(t) } // « Modifier l'horaire récurrent » → réutilise WeeklyScheduleEditor existant
+async function onTechSchedChanged (ev) {
+ // pause : reflète le statut localement ; archivage : recharge la base (le tech disparaît). Congés : recharge la semaine (hachures).
+ if (ev && ev.status) { const tt = techs.value.find(x => x.id === ev.id); if (tt) tt.status = ev.status }
+ if (ev && ev.archived) { await loadBase() }
+ await loadWeek()
+}
+
// ── Génération de quarts HEBDO par tech (modèles + N semaines) — écrit DIRECTEMENT les Shift Assignment (Publié). ──
const schedGenOpen = ref(false); const schedGenTechs = ref([])
function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech
@@ -5939,9 +5959,23 @@ function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.v
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
// #2 — AUTO-SAVE : chaque édition de la grille est persistée en BROUILLON (statut 'Proposé') après un court debounce. Plus rien n'est « non sauvegardé ».
const autosaving = ref(false)
-const changeLog = ref([]) // journal de session : { at, text }
+const changeLog = ref([]) // journal de session : { id, at, text, revert?:{tech,date,before}, reverted? }
let _draftT = null
-function logChange (text) { changeLog.value.unshift({ at: Date.now(), text }); if (changeLog.value.length > 60) changeLog.value.pop() }
+let _chgSeq = 0
+// revert (optionnel) = état AVANT d'une cellule (tech|date) → permet d'annuler CE changement précis depuis le journal (icône history), à la façon de la liste « Publier ».
+function logChange (text, revert) { changeLog.value.unshift({ id: ++_chgSeq, at: Date.now(), text, revert: revert || null, reverted: false }); if (changeLog.value.length > 60) changeLog.value.pop() }
+// Annule UN changement du journal : restaure la cellule (tech, date) à son état d'avant. L'annulation est elle-même dans l'historique global (undo).
+function revertChange (c) {
+ if (!c || !c.revert || c.reverted) return
+ const { tech, date, before } = c.revert
+ pushHistory()
+ const others = assignments.value.filter(a => !(a.tech === tech && a.date === date))
+ assignments.value = [...others, ...JSON.parse(JSON.stringify(before || []))]
+ c.reverted = true
+ logChange('↶ Annulé · ' + c.text)
+ $q.notify({ type: 'info', message: 'Changement annulé', timeout: 1600 })
+ scheduleDraftSave()
+}
function scheduleDraftSave () { if (_draftT) clearTimeout(_draftT); _draftT = setTimeout(autosaveDraft, 800) }
async function autosaveDraft () {
if (_draftT) { clearTimeout(_draftT); _draftT = null }
@@ -5949,10 +5983,11 @@ async function autosaveDraft () {
autosaving.value = true
try { await roster.publishWeek(start.value, days.value, assignments.value, false, 'draft') } catch (e) { /* non bloquant : réessaie au prochain edit */ } finally { autosaving.value = false }
}
-function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }]; logChange('Quart ajouté · ' + techName + ' · ' + iso); scheduleDraftSave() }
-function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept; logChange('Quart défini · ' + techName + ' · ' + iso); scheduleDraftSave() }
+function cellSnap (techId, iso) { return JSON.parse(JSON.stringify(cellsOf(techId, iso))) } // état AVANT d'une cellule → payload de revert du journal
+function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; const before = cellSnap(techId, iso); assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }]; logChange('Quart ajouté · ' + techName + ' · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() }
+function setCellReplace (techId, techName, iso, tpl) { const before = cellSnap(techId, iso); const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept; logChange('Quart défini · ' + techName + ' · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() }
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
-function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)); logChange('Quart retiré · ' + iso); scheduleDraftSave() }
+function clearLocal (techId, iso) { const before = cellSnap(techId, iso); assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)); logChange('Quart retiré · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() }
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
function openShiftMenu (t, d, ev, ti, di) {
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d
@@ -6493,6 +6528,7 @@ tr.res-hidden .hide-eye { opacity: 1; }
.leg-absent { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg,#cfcfcf 0,#cfcfcf 3px,#f0f0f0 3px,#f0f0f0 6px); }
.leg-garde { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; background: rgba(255,179,0,.14); border: 1px dashed #f9a825; }
tr.paused .tech-col { color: #aaa; }
+.chg-reverted .text-caption { text-decoration: line-through; color: #9ca3af; }
tfoot .sum td { background: #fafafa; font-size: 11px; color: #555; font-weight: 600; }
tfoot .sum .tech-col { background: #fafafa; }
.eff { font-size: 9px; border-radius: 3px; padding: 0 4px; margin-left: 3px; font-weight: 600; }
diff --git a/apps/ops/src/pages/TicketsPage.vue b/apps/ops/src/pages/TicketsPage.vue
index 9eb12fd..a30cd6b 100644
--- a/apps/ops/src/pages/TicketsPage.vue
+++ b/apps/ops/src/pages/TicketsPage.vue
@@ -161,7 +161,7 @@
-
+
@@ -352,6 +352,11 @@ async function toggleFollow (name) {
myFollows.value = following ? myFollows.value.filter(n => n !== name) : [...myFollows.value, name]
} catch (e) { /* suivi best-effort */ }
}
+// Le dialogue « Intervenir » peut aussi (dé)suivre → garder l'étoile de l'en-tête synchronisée.
+function onIntervenedFollow ({ name, doctype, following }) {
+ if (doctype !== 'Issue' || !name) return
+ myFollows.value = following ? [...new Set([...myFollows.value, name])] : myFollows.value.filter(n => n !== name)
+}
onMounted(async () => {
await Promise.all([loadIssueTypes(), loadMyDepartments(), loadMyFollows()])
diff --git a/services/targo-hub/lib/conversation.js b/services/targo-hub/lib/conversation.js
index 1bea27c..3b09234 100644
--- a/services/targo-hub/lib/conversation.js
+++ b/services/targo-hub/lib/conversation.js
@@ -501,6 +501,23 @@ function loadQueueMembers () { return readJsonFile('/app/data/queue_members.json
function loadVisibility () { return readJsonFile('/app/data/queue_visibility.json', {}) }
// Suivi de tickets PAR PERSONNE (watch perso) : { email: [ticketName,...] } → toujours visibles dans « Mes tickets ».
function loadTicketFollows () { return readJsonFile('/app/data/ticket_follows.json', {}) }
+// Suivi GÉNÉRALISÉ (au-delà des tickets) : { email: { doctype: [name,...] } }. Migre une seule fois l'ancien ticket_follows.json sous « Issue ».
+function loadFollows () {
+ const all = readJsonFile('/app/data/follows.json', null)
+ if (all) return all
+ const legacy = loadTicketFollows() // { email: [ticketName] }
+ const migrated = {}
+ for (const [email, arr] of Object.entries(legacy || {})) migrated[email] = { Issue: Array.isArray(arr) ? arr.slice() : [] }
+ writeJsonFile('/app/data/follows.json', migrated)
+ return migrated
+}
+function saveFollows (all) {
+ writeJsonFile('/app/data/follows.json', all)
+ // Miroir rétro-compatible : garde ticket_follows.json à jour (schéma email → [ticketName]) pour tout lecteur legacy.
+ const tf = {}; for (const [email, byType] of Object.entries(all || {})) tf[email] = (byType && byType.Issue) ? byType.Issue.slice() : []
+ writeJsonFile('/app/data/ticket_follows.json', tf)
+}
+function followsFor (all, email, doctype) { return (email && all[email] && all[email][doctype]) || [] }
// Règles d'inbox définies par l'agent (« Filtrer comme ceci ») : [{ id, field:'from'|'subject', contains, action:'mask'|'allow' }]. « allow » prime sur « mask ».
function loadInboxRules () { return readJsonFile('/app/data/inbox_rules.json', []) }
function matchInboxRule (email, subject) {
@@ -1268,17 +1285,37 @@ async function handle (req, res, method, p, url) {
} catch (e) { return json(res, 500, { error: e.message }) }
}
// Suivi PERSO d'un ticket (watch) : GET → mes tickets suivis ; POST {ticket, follow} → (dé)suivre. Toujours visibles dans « Mes tickets ».
+ // Rétro-compatible : délègue au store généralisé (doctype « Issue »).
if (p === '/conversations/ticket-follow' && (method === 'GET' || method === 'POST')) {
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
- const all = loadTicketFollows()
- if (method === 'GET') return json(res, 200, { follows: (email && all[email]) || [] })
+ const all = loadFollows()
+ if (method === 'GET') return json(res, 200, { follows: followsFor(all, email, 'Issue') })
try {
const b = await parseBody(req); const t = String(b.ticket || '').trim()
if (!email || !t) return json(res, 400, { error: 'email + ticket requis' })
- const arr = new Set(all[email] || [])
+ const arr = new Set(followsFor(all, email, 'Issue'))
if (b.follow === false) arr.delete(t); else arr.add(t)
- all[email] = [...arr]; writeJsonFile('/app/data/ticket_follows.json', all)
- return json(res, 200, { ok: true, follows: all[email], following: arr.has(t) })
+ ;(all[email] || (all[email] = {})).Issue = [...arr]; saveFollows(all)
+ return json(res, 200, { ok: true, follows: all[email].Issue, following: arr.has(t) })
+ } catch (e) { return json(res, 500, { error: e.message }) }
+ }
+ // Suivi GÉNÉRALISÉ (tout doctype) : GET ?doctype=X → mes noms suivis ; POST {doctype, name, follow} → (dé)suivre.
+ // Ex. suivre un Dispatch Job, un Customer… au-delà des tickets (Issue). Le suivi « Issue » reste synchronisé avec ticket-follow.
+ if (p === '/conversations/follow' && (method === 'GET' || method === 'POST')) {
+ const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
+ const all = loadFollows()
+ if (method === 'GET') {
+ const dt = String(url.searchParams.get('doctype') || '').trim()
+ if (dt) return json(res, 200, { doctype: dt, follows: followsFor(all, email, dt) })
+ return json(res, 200, { follows: (email && all[email]) || {} }) // tous doctypes → { doctype: [names] }
+ }
+ try {
+ const b = await parseBody(req); const dt = String(b.doctype || '').trim(); const nm = String(b.name || '').trim()
+ if (!email || !dt || !nm) return json(res, 400, { error: 'email + doctype + name requis' })
+ const arr = new Set(followsFor(all, email, dt))
+ if (b.follow === false) arr.delete(nm); else arr.add(nm)
+ ;(all[email] || (all[email] = {}))[dt] = [...arr]; saveFollows(all)
+ return json(res, 200, { ok: true, doctype: dt, follows: all[email][dt], following: arr.has(nm) })
} catch (e) { return json(res, 500, { error: e.message }) }
}
// Règles d'inbox (« Filtrer comme ceci ») : masquer/afficher par expéditeur ou sujet — l'agent choisit ce qui est masqué.
diff --git a/services/targo-hub/lib/dispatch.js b/services/targo-hub/lib/dispatch.js
index baea30e..6523f2f 100644
--- a/services/targo-hub/lib/dispatch.js
+++ b/services/targo-hub/lib/dispatch.js
@@ -141,7 +141,7 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
const wantSkill = String(skill || '').trim().toLowerCase()
const isRepair = /r[ée]par|d[ée]pann|panne|bris/.test(wantSkill) // les WEEK-ENDS sont RÉSERVÉS aux réparations → sautés pour les autres compétences
- const [techRes, jobRes, tplRes, shiftRes] = await Promise.all([
+ const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
'name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude',
'absence_from', 'absence_until', 'skills', '_user_tags',
@@ -156,11 +156,20 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
]))}&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`),
+ // Congés/absences APPROUVÉS (Tech Availability) — où vivent les vacances (import calendrier, dialogue congés, grille). Sinon un tech en congé mais avec un quart matérialisé ressort quand même.
+ erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
])
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
// FILTRE COMPÉTENCE : ne garder que les techs qui ONT le skill demandé (champ skills ou tags Frappe).
const hasSkill = (t) => { if (!wantSkill) return true; return String(t.skills || t._user_tags || '').toLowerCase().split(/[,;]/).some(s => { s = s.trim(); return s && (s === wantSkill || (s.length >= 3 && (s.includes(wantSkill) || wantSkill.includes(s)))) }) }
- const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable' && hasSkill(t))
+ const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'Archivé' && t.status !== 'En pause' && hasSkill(t))
+ // Jours de congé approuvé par tech (clé = technician, qui correspond au technician_id ou au docname). Aligné sur roster.buildUnavailability.
+ const vacBy = {}
+ for (const a of ((availRes.data && availRes.data.data) || [])) {
+ const set = vacBy[a.technician] || (vacBy[a.technician] = new Set())
+ for (const d of dates) if (d >= a.from_date && d <= a.to_date) set.add(d)
+ }
+ const vacDays = (t) => vacBy[t.technician_id] || vacBy[t.name] || null
const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
// Fenêtres de quart RÉELLES par tech×jour (Shift Assignment × Shift Template) — plus de fenêtre 8-17 par défaut : un tech SANS quart ce jour = pas de créneau.
const tpl = {}; for (const t of ((tplRes.data && tplRes.data.data) || [])) tpl[t.name] = t
@@ -179,12 +188,14 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
for (const tech of techs) {
const homeCoords = tech.longitude && tech.latitude
? [parseFloat(tech.longitude), parseFloat(tech.latitude)] : null
+ const techVac = vacDays(tech) // jours de congé approuvé (Tech Availability)
for (const dateStr of dates) {
// WEEK-END réservé aux réparations : on saute sam/dim pour toute autre compétence.
const dow = new Date(dateStr + 'T12:00:00').getUTCDay()
if ((dow === 0 || dow === 6) && !isRepair) continue
- // Absence window skip.
+ // Congé approuvé (Tech Availability) OU fenêtre d'absence du Dispatch Technician → pas de créneau.
+ if (techVac && techVac.has(dateStr)) continue
if (tech.absence_from && tech.absence_until &&
dateStr >= tech.absence_from && dateStr <= tech.absence_until) continue
// QUART RÉEL du tech ce jour (Shift Assignment) — pas de quart = pas de créneau (créer un quart pour en ouvrir).
@@ -284,6 +295,91 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
return picked
}
+// Occupation par ressource (tech) sur l'horizon, FILTRÉE par compétence — même modèle de données que suggestSlots
+// (quart réel = capacité, jobs planifiés = occupé), mais renvoie l'OCCUPATION plutôt que les trous. Alimente la
+// visualisation « bandes verticales » de « Trouver un créneau » : on voit d'un coup qui a de la place pour le skill choisi.
+async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = '' } = {}) {
+ const baseDate = after_date || todayET()
+ const span = Math.max(1, Math.min(21, parseInt(days, 10) || SLOT_HORIZON_DAYS))
+ const dates = Array.from({ length: span }, (_, i) => dateAddDays(baseDate, i))
+ const wantSkill = String(skill || '').trim().toLowerCase()
+
+ const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
+ erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
+ 'name', 'technician_id', 'full_name', 'status', 'absence_from', 'absence_until', 'skills', '_user_tags',
+ ]))}&limit_page_length=50`),
+ erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([
+ ['status', 'in', ['open', 'assigned']],
+ ['scheduled_date', '>=', dates[0]],
+ ['scheduled_date', '<=', dates[dates.length - 1]],
+ ]))}&fields=${encodeURIComponent(JSON.stringify([
+ 'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'job_type',
+ ]))}&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`),
+ erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
+ ])
+ if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
+ const hasSkill = (t) => { if (!wantSkill) return true; return String(t.skills || t._user_tags || '').toLowerCase().split(/[,;]/).some(s => { s = s.trim(); return s && (s === wantSkill || (s.length >= 3 && (s.includes(wantSkill) || wantSkill.includes(s)))) }) }
+ const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'Archivé' && t.status !== 'En pause' && hasSkill(t))
+ const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
+ // Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster).
+ const vacBy = {}
+ for (const a of ((availRes.data && availRes.data.data) || [])) {
+ const set = vacBy[a.technician] || (vacBy[a.technician] = new Set())
+ for (const d of dates) if (d >= a.from_date && d <= a.to_date) set.add(d)
+ }
+ const tpl = {}; for (const t of ((tplRes.data && tplRes.data.data) || [])) tpl[t.name] = t
+ const winBy = {} // 'tech|date' → { start_h, end_h } (quart réel)
+ for (const a of ((shiftRes.data && shiftRes.data.data) || [])) {
+ const tp = tpl[a.shift_template]; if (!tp || tp.on_call) continue
+ const s = timeToHours(tp.start_time), e = timeToHours(tp.end_time); if (s == null || e == null || !(e > s)) continue
+ const k = a.technician + '|' + a.assignment_date
+ const w = winBy[k]; winBy[k] = w ? { start_h: Math.min(w.start_h, s), end_h: Math.max(w.end_h, e) } : { start_h: s, end_h: e }
+ }
+
+ const out = techs.map(tech => {
+ const techVac = vacBy[tech.technician_id] || vacBy[tech.name] || null
+ const cells = dates.map(dateStr => {
+ const dow = new Date(dateStr + 'T12:00:00').getUTCDay()
+ const onLeave = techVac && techVac.has(dateStr)
+ const absent = tech.absence_from && tech.absence_until && dateStr >= tech.absence_from && dateStr <= tech.absence_until
+ const shift = winBy[tech.technician_id + '|' + dateStr] || winBy[tech.name + '|' + dateStr]
+ if (onLeave) return { date: dateStr, dow, off: true, reason: 'vacation', occupancy: null }
+ if (absent) return { date: dateStr, dow, off: true, reason: 'absence', occupancy: null }
+ if (!shift) return { date: dateStr, dow, off: true, reason: (dow === 0 || dow === 6) ? 'weekend' : 'no_shift', occupancy: null }
+ const shiftH = shift.end_h - shift.start_h
+ const dayJobs = allJobs
+ .filter(j => j.assigned_tech === tech.technician_id && j.scheduled_date === dateStr && j.start_time)
+ .map(j => { const s = timeToHours(j.start_time); return { start_h: s, end_h: s + (parseFloat(j.duration_h) || 1), reserved: j.job_type === 'Réservation' } })
+ .sort((a, b) => a.start_h - b.start_h)
+ const clamp = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
+ const busyH = dayJobs.reduce((acc, j) => acc + clamp(j), 0)
+ const blocks = dayJobs.map(j => ({
+ start: hoursToTime(Math.max(j.start_h, shift.start_h)), end: hoursToTime(Math.min(j.end_h, shift.end_h)),
+ top: shiftH > 0 ? +Math.max(0, (Math.max(j.start_h, shift.start_h) - shift.start_h) / shiftH).toFixed(3) : 0,
+ height: shiftH > 0 ? +Math.max(0, clamp(j) / shiftH).toFixed(3) : 0,
+ reserved: j.reserved,
+ }))
+ return {
+ date: dateStr, dow, off: false, shift_start: hoursToTime(shift.start_h), shift_end: hoursToTime(shift.end_h),
+ shift_h: +shiftH.toFixed(1), busy_h: +busyH.toFixed(1), free_h: +Math.max(0, shiftH - busyH).toFixed(1), jobs: dayJobs.length,
+ occupancy: shiftH > 0 ? Math.min(1, +(busyH / shiftH).toFixed(2)) : 0, blocks,
+ }
+ })
+ const totShift = cells.reduce((a, c) => a + (c.shift_h || 0), 0)
+ const totBusy = cells.reduce((a, c) => a + (c.busy_h || 0), 0)
+ return {
+ tech_id: tech.technician_id, tech_name: tech.full_name,
+ shift_h: +totShift.toFixed(1), busy_h: +totBusy.toFixed(1), free_h: +Math.max(0, totShift - totBusy).toFixed(1),
+ occupancy: totShift > 0 ? Math.min(1, +(totBusy / totShift).toFixed(2)) : null, days: cells,
+ }
+ })
+ // Le moins occupé d'abord = meilleurs candidats ; les techs sans aucun quart sur l'horizon en dernier.
+ out.sort((a, b) => (a.occupancy == null ? 2 : a.occupancy) - (b.occupancy == null ? 2 : b.occupancy) || String(a.tech_name).localeCompare(b.tech_name))
+ return { start: baseDate, days: span, dates, skill: wantSkill, techs: out }
+}
+
// Numéro de job STANDARDISÉ « YYYYMMDD-XXX » (compteur PAR JOUR). Le nom du Dispatch Job = ce champ (autoname field:ticket_id).
// Best-effort (max du jour + 1) ; le VRAI garde-fou anti-collision = l'unicité du nom à l'insertion (le créateur réessaie au besoin).
async function nextJobRef () {
@@ -742,6 +838,17 @@ async function handle (req, res, method, path) {
}
}
+ // POST /dispatch/occupancy — occupation par ressource (tech) sur l'horizon, filtrée par compétence → bandes « Trouver un créneau »
+ if (sub === 'occupancy' && method === 'POST') {
+ try {
+ const body = await parseBody(req)
+ return json(res, 200, await techOccupancy(body))
+ } catch (e) {
+ log('occupancy error:', e.message)
+ return json(res, 500, { error: e.message })
+ }
+ }
+
// GET /dispatch/group-jobs?group=Tech+Targo&exclude_tech=TECH-001
// List unassigned jobs that a tech can self-claim. The tech PWA uses this
// to render the "Tâches du groupe" subscription feed.
@@ -949,4 +1056,4 @@ async function agentCreateDispatchJob ({ customer_id, service_location, subject,
}
}
-module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots, unblockDependents, setJobStatusWithChain, activateSubscriptionForJob, deleteJobSafely, nextJobRef, createInstallChain }
+module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots, techOccupancy, unblockDependents, setJobStatusWithChain, activateSubscriptionForJob, deleteJobSafely, nextJobRef, createInstallChain }
diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js
index 94be724..16fb3e2 100644
--- a/services/targo-hub/lib/roster.js
+++ b/services/targo-hub/lib/roster.js
@@ -72,6 +72,7 @@ function setJobRemote (name, remote) {
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
const PAUSE_STATUS = 'En pause'
const AVAIL_STATUS = 'Disponible'
+const ARCHIVE_STATUS = 'Archivé' // tech archivé (réversible) : masqué du dispatch/solveur/créneaux, historique conservé
// ── Date helpers (local, sans dépendance) ──────────────────────────────────
function iso (d) { return d.toISOString().slice(0, 10) }
@@ -356,7 +357,15 @@ async function _fetchTechniciansRaw () {
absence_from: t.absence_from,
absence_until: t.absence_until,
weekly_schedule: (() => { try { return JSON.parse(t.weekly_schedule || 'null') } catch { return null } })(), // patron récurrent {mon:{start,end}|null,…} — source des quarts matérialisés (hybride)
- }))
+ })).filter(t => t.status !== ARCHIVE_STATUS) // techs archivés = masqués partout (planif, solveur, stats) ; restaurables via /roster/technicians?archived=1
+}
+// Techs ARCHIVÉS uniquement (pour l'UI de restauration) — requête directe (hors cache).
+async function fetchArchivedTechnicians () {
+ const rows = await erp.list('Dispatch Technician', {
+ filters: [['resource_type', '=', 'human'], ['status', '=', ARCHIVE_STATUS]],
+ fields: ['name', 'technician_id', 'full_name', 'status', 'absence_reason'], limit: 500,
+ })
+ return rows.map(t => ({ id: t.technician_id || t.name, name: t.full_name || t.technician_id, status: t.status, reason: t.absence_reason || '' }))
}
// Construit, pour chaque tech, la liste des dates indisponibles dans l'horizon.
@@ -1266,6 +1275,10 @@ async function handle (req, res, method, path, url) {
const days = parseInt(qs.get('days') || '7', 10)
if (path === '/roster/technicians' && method === 'GET') {
+ if (url && url.searchParams && url.searchParams.get('archived') === '1') {
+ const arch = await fetchArchivedTechnicians()
+ return json(res, 200, { technicians: arch, count: arch.length, archived: true })
+ }
const techs = await fetchTechnicians()
return json(res, 200, { technicians: techs, count: techs.length })
}
@@ -2025,6 +2038,17 @@ async function handle (req, res, method, path, url) {
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status })
}
+ // Archiver / restaurer un technicien (réversible) : status Archivé ↔ Disponible. Masqué partout mais historique conservé.
+ const mArchive = path.match(/^\/roster\/technician\/(.+)\/archive$/)
+ if (mArchive && method === 'POST') {
+ const techId = decodeURIComponent(mArchive[1])
+ const b = await parseBody(req)
+ const techName = await resolveTechName(techId)
+ if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
+ const patch = { status: b.archived ? ARCHIVE_STATUS : AVAIL_STATUS }
+ const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)); if (r && r.ok) invalidateTechCache()
+ return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status, archived: !!b.archived })
+ }
// Positions GPS LIVE de TOUS les techs ayant un appareil Traccar → marqueurs « live » sur la carte des tournées
// (rafraîchi périodiquement côté client). Léger : une requête position par appareil (getPositions parallèle).
if (path === '/roster/tech-positions' && method === 'GET') {