gigafibre-fsm/apps/ops/src/api/roster.js
louispaulb 0ba789a5c0 planif Notifier : toggles SMS/courriel PAR TECH (SMS préféré) + édition inline du contact
- Chaque tech a ses propres toggles SMS/Courriel (cochés selon l'info dispo, SMS préféré) → on peut exclure
  quelqu'un (ex. envoi test). Envoi scindé : 1 appel SMS (techs cochés+tél) + 1 appel courriel (cochés+email).
  Bouton « Envoyer (N) » = nb réel de destinataires.
- Édition INLINE dans le dialogue : tech sans téléphone/courriel → champ + « Enregistrer » sans quitter
  (roster.setTechContact → POST /roster/technician/contact → update Dispatch Technician + invalide le cache).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 14:04:11 -04:00

196 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Roster (Planification) API — appelle targo-hub /roster/*.
* Le backend lit les techs/modèles/besoins dans ERPNext facturation et appelle
* le solveur OR-Tools. Voir services/targo-hub/lib/roster.js.
*/
import { HUB_URL as HUB } from 'src/config/hub'
// Toute requête a un TIMEOUT (AbortController) : un solveur/hub lent ou injoignable ne doit JAMAIS
// laisser l'interface figée (ex. « Suggérer » qui tourne sans fin). Au-delà, on lève → repli local.
async function _fetch (path, init = {}, ms = 45000) {
const ctl = new AbortController()
const timer = setTimeout(() => ctl.abort(), ms)
try {
const r = await fetch(HUB + path, { ...init, signal: ctl.signal })
if (!r.ok) throw new Error('Roster API ' + r.status)
return await r.json()
} catch (e) {
if (e && e.name === 'AbortError') throw new Error('Roster API timeout (' + Math.round(ms / 1000) + 's) — serveur trop lent ou injoignable')
throw e
} finally { clearTimeout(timer) }
}
async function jget (path, ms) { return _fetch(path, {}, ms) }
async function jpost (path, body, ms) { return _fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }, ms) }
async function jput (path, body, ms) { return _fetch(path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }, ms) }
export const listTechnicians = () => jget('/roster/technicians')
export const listTemplates = () => jget('/roster/templates')
export const createTemplate = (t) => jpost('/roster/templates', t)
// Fériés QC déterministes (calendrier) + préavis de disponibilité avant un férié.
export const holidays = (from, to) => jget(`/roster/holidays?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
export const holidayNotice = (days = 35) => jget('/roster/holiday-notice?days=' + days)
export const sendHolidayNotice = (date) => jpost('/roster/holiday-notice', { date })
export const listRequirements = (start, days = 7) => jget(`/roster/requirements?start=${start}&days=${days}`)
export const createRequirement = (r) => jpost('/roster/requirements', r)
export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`)
export const getCoverage = (start, days = 7) => jget(`/roster/coverage?start=${start}&days=${days}`)
// Dispo restante par jour × segment (AM/PM/Soir) : capacité (quarts) jobs placés, + charge due
export const getCapacity = (start, days = 7) => jget(`/roster/capacity?start=${start}&days=${days}`)
export const getStats = (start, days = 7) => jget(`/roster/stats?start=${start}&days=${days}`)
export const getOccupancy = (start, days = 7) => jget(`/roster/occupancy?start=${start}&days=${days}`)
export const getAbsences = (start, days = 7) => jget(`/roster/absences?start=${start}&days=${days}`)
export const applyGardeHorizon = (start, weeks, assignments, shifts) => jpost('/roster/garde/apply', { start, weeks, assignments, shifts })
export const setAbsence = (tech, date, type, remove) => jpost('/roster/absence/set', { tech, date, type, remove })
export const generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights })
export const publish = (assignments) => jpost('/roster/publish', { assignments })
export const publishWeek = (start, days, assignments, notify) => jpost('/roster/publish-week', { start, days, assignments, notify })
// Notifier les techs de leur tournée (SMS/Email, lien token + infos) : preview=true → compose sans envoyer.
export const notifyDispatch = (payload) => jpost('/roster/notify-dispatch', payload)
export const updateTemplate = (name, patch) => jput('/roster/template/' + encodeURIComponent(name), patch)
// ── Copilote (Gemini Flash) + politique de reprise ──
export const askAssistant = (message, history) => jpost('/roster/assistant', { message, history })
export const getPolicy = () => jget('/roster/policy')
export const savePolicy = (policy) => jpost('/roster/policy', policy)
export const setJobLevel = (name, level) => jpost('/roster/job-level', { name, level }) // niveau requis persistant par job
export const setJobRemote = (name, remote) => jpost('/roster/job-remote', { name, remote }) // 🖥 sans déplacement (à distance) — exclu des tournées
export const groupJobs = (parent, children) => jpost('/roster/job-group', { parent, children }) // 📦 grouper en « projet » (parent_job)
export async function deleteShiftTemplate (name) {
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)
return r.json()
}
export const bulkRequirements = (requirements) => jpost('/roster/requirements/bulk', { requirements })
export const clearRequirements = (start, days = 7) => jpost('/roster/requirements/clear', { start, days })
export async function deleteAssignment (name) {
const r = await fetch(HUB + '/roster/assignment/' + encodeURIComponent(name), { method: 'DELETE' })
if (!r.ok) throw new Error('Suppression échouée: ' + r.status)
return r.json()
}
export const listAvailability = (status) => jget('/roster/availability' + (status ? '?status=' + encodeURIComponent(status) : ''))
export const requestAvailability = (a) => jpost('/roster/availability', a)
export const approveAvailability = (name, body) => jpost('/roster/availability/' + encodeURIComponent(name) + '/approve', body || {})
export const pauseTechnician = (id, paused, reason) => jpost(`/roster/technician/${encodeURIComponent(id)}/pause`, { paused, reason })
export const setTechEfficiency = (id, efficiency) => jpost(`/roster/technician/${encodeURIComponent(id)}/efficiency`, { efficiency })
export const setTechCost = (id, body) => jpost(`/roster/technician/${encodeURIComponent(id)}/cost`, body)
export const setTechSkills = (id, skills, skillLevels, skillEff) => { const body = { skills }; if (skillLevels !== undefined) body.skill_levels = skillLevels; if (skillEff !== undefined) body.skill_eff = skillEff; return jpost(`/roster/technician/${encodeURIComponent(id)}/skills`, body) }
// ── Prise de RDV ──
export const bookJobs = () => jget('/roster/book/jobs')
// Méta de l'éditeur type→compétence : { service_types, skills, skill_by_type }
export const bookMeta = () => jget('/roster/book/meta')
export const bookSlots = (p) => jget('/roster/book/slots?' + new URLSearchParams(p).toString())
export const bookFit = (body) => jpost('/roster/book/fit', body)
export const bookConfirm = (body) => jpost('/roster/book/confirm', body)
// Hold temporaire d'une fenêtre (agent qui sélectionne) — { date, start, minutes } ou { date, start, release:true }
export const bookHold = (body) => jpost('/roster/book/hold', body)
// Lien client (token) pour un job → { token, url }
export const bookLink = (job) => jpost('/roster/book/link', { job })
// File « À recontacter » (jobs À reporter)
export const jobsToReschedule = () => jget('/roster/jobs-to-reschedule')
// Impact d'un retrait de compétence : jobs assignés exigeant cette compétence
export const skillImpact = (tech, skill) => jget('/roster/skill-impact?tech=' + encodeURIComponent(tech) + '&skill=' + encodeURIComponent(skill))
// Impact d'une absence : jobs assignés au tech sur ces dates (CSV)
export const absenceImpact = (tech, dates) => jget('/roster/absence-impact?tech=' + encodeURIComponent(tech) + '&dates=' + encodeURIComponent((dates || []).join(',')))
// Redistribuer ces jobs : mode 'auto' (re-match) ou 'requeue' (À recontacter)
export const redistributeSkillJobs = (jobs, skill, mode) => jpost('/roster/skill-impact/redistribute', { jobs, skill, mode })
// Candidats classés pour reprendre un job (techs qualifiés + libres au créneau)
export const jobCandidates = (job, exclude) => jget('/roster/job-candidates?job=' + encodeURIComponent(job) + '&exclude=' + encodeURIComponent(exclude || ''))
// Plan explicite : [{ job, tech } | { job, requeue:true }]
export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribute', { plan })
// Jobs non assignés (+ groupe/dépendances) pour le panneau glisser-déposer
export const unassignedJobs = () => jget('/roster/unassigned-jobs')
// Optimisation de tournées (VRPTW + compétences + temps sur place) via le solveur OR-Tools. payload = { jobs[], vehicles[], ... }
export const optimizeRoutes = (payload) => jpost('/roster/optimize-routes', payload)
// P4 — orchestration COMPLÈTE côté hub (pool+quarts+occupation+règles d'adresse) : { dates?|days?, tech_ids?, opts, dur_overrides, job_windows } → { plan[], unassigned[], assumed_shift }
export const optimizePlanHub = (payload) => jpost('/roster/optimize-plan', payload, 90000) // solveur VRP : timeout 90s → sinon repli local (jamais figé)
// Proxys OSRM auto-hébergé (points [[lon,lat],…]) — tracés + matrices SANS Mapbox payant.
export const osrmRoute = (points) => jpost('/roster/osrm-route', { points }) // → { km, mins, geometry, legs[] (temps entre arrêts) }
export const osrmTable = (points) => jpost('/roster/osrm-table', { points }) // → { durations (s), distances (m) } même forme que Mapbox Matrix
export const osrmTrip = (points) => jpost('/roster/osrm-trip', { points }) // TSP → { code, waypoints:[{waypoint_index}] } (remplace Mapbox optimized-trips)
// Write-back legacy : aperçu (0 écriture) puis application (réassigne ticket.assign_to au tech dans osTicket)
export const pushLegacyPreview = () => jget('/dispatch/legacy-sync/push-assignments')
export const pushLegacyApply = (notify = true) => jpost('/dispatch/legacy-sync/push-assignments' + (notify ? '' : '?notify=0'), {})
// Fil complet d'un ticket legacy (osTicket) : messages + réponses, pour l'expand au clic
export const ticketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
// Poster au fil d'un ticket : isPublic=false (défaut) = note INTERNE (jamais au client) · true = message public
export const postTicket = (ticket, msg, isPublic) => jpost('/dispatch/legacy-sync/ticket-post', { ticket, msg, public: !!isPublic })
// GÉNÉRALISÉ par JOB (osTicket OU ERPNext-natif) : fil+contact (lecture seule) et résolution de conversation pour répondre.
export const jobThread = (job) => jget('/dispatch/legacy-sync/job-thread?job=' + encodeURIComponent(job))
export const jobConversation = (job) => jget('/dispatch/legacy-sync/job-conversation?job=' + encodeURIComponent(job))
// P2 — réponse au CLIENT via le MÊME chemin que la Boîte (/conversations/{token}/messages → Outbox courriel/SMS + miroir osTicket).
// X-Authentik-Email REQUIS : sans lui le hub enregistre l'envoi comme message CLIENT (from='customer') + déclenche l'IA. À passer (useAuthStore().user).
export async function sendConvMessage (token, text, agentEmail) {
const headers = { 'Content-Type': 'application/json' }
if (agentEmail) headers['X-Authentik-Email'] = agentEmail
const r = await fetch(HUB + '/conversations/' + encodeURIComponent(token) + '/messages', { method: 'POST', headers, body: JSON.stringify({ text }) })
if (!r.ok) throw new Error('Envoi conv ' + r.status)
return r.json()
}
// Fermer un ticket dans le legacy (status=closed + date_closed + closed_by=acteur + réouverture enfants)
export const closeLegacyTicket = (ticket) => jpost('/dispatch/legacy-sync/close-ticket?ticket=' + encodeURIComponent(ticket), {})
// Fermeture EN LOT (1 appel pour N tickets) — efficace
export const batchCloseLegacy = (ids) => jpost('/dispatch/legacy-sync/batch-close?tickets=' + encodeURIComponent(ids.join(',')), {})
// Assigner un job à un tech (date = case déposée)
export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
// Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only
export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
// Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }]
export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates })
// Retirer un job d'un tech (retour au pool non assigné) — ERPNext SEULEMENT (redistributions auto)
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte
export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set-location', { job, lat, lon, address })
// Actions rapides du pool (mobile) : patch d'un Dispatch Job (priority/status/required_skill/scheduled_date/notes — liste blanche côté hub).
export const updateJob = (job, patch) => jpost('/roster/job/update', { job, patch })
// Persister UN quart immédiatement (sinon addShift reste local/Proposé et disparaît au rechargement). Idempotent côté hub.
export const createShift = (s) => jpost('/roster/shift', s)
// Hybride : patron récurrent (source) + matérialisation (fériés/vacances sautés, manuels préservés)
export const setWeeklySchedule = (techId, schedule) => jpost('/roster/technician/' + encodeURIComponent(techId) + '/weekly-schedule', { schedule })
export const materializeShifts = (payload) => jpost('/roster/materialize-shifts', payload || {})
// ÉQUIPE d'un job (assistants en renfort) — le lead reste inchangé ; l'assistant épinglé voit un bloc hachuré dans son horaire.
export const setTechContact = (b) => jpost('/roster/technician/contact', b) // édition inline téléphone/courriel d'un tech
export const getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job))
export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a })
export const removeAssistant = (job, techId) => jpost('/roster/job/team', { job, remove: techId })
// Réconciliation des techniciens (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) : rapport + apply manuel
// Lien app PWA du tech (à copier / SMS) + son téléphone
export const techAppLink = (tech) => jget('/roster/tech-link?tech=' + encodeURIComponent(tech))
// Table additive « durées par caractéristique » (éditable inline) — seed + apprentissage futur (learned_min)
export const getJobChars = () => jget('/roster/job-characteristics')
export const saveJobChars = (items) => jpost('/roster/job-characteristics', { items })
// Chrono (boucle de capture) : début/fin réels d'un job → durée réelle (apprentissage)
export const startJob = (job) => jpost('/roster/job/start', { job })
export const finishJob = (job) => jpost('/roster/job/finish', { job })
export const techSyncReport = () => jget('/dispatch/legacy-sync/tech-sync')
export const techSyncApply = (ids) => jpost('/dispatch/legacy-sync/tech-sync/apply?ids=' + encodeURIComponent((ids || []).join(',')), {})
// Jobs Legacy (osTicket) ouverts assignés à UN tech (lecture seule) — vue « jobs courants du tech »
export const legacyTechTickets = (tech) => jget('/dispatch/legacy-sync/tech-tickets?tech=' + encodeURIComponent(tech))
// Charge Legacy DATÉE par tech×jour sur la fenêtre affichée → durées estimées appliquées aux timelines
export const legacyWindowLoad = (start, days) => jget('/dispatch/legacy-sync/window-load?start=' + encodeURIComponent(start) + '&days=' + encodeURIComponent(days))
// Retour au POOL legacy (action EXPLICITE) : désassigne (ERPNext) + réécrit le ticket à Tech Targo (3301) dans osTicket
export const returnJobToPool = (job) => jpost('/dispatch/legacy-sync/return-to-pool?job=' + encodeURIComponent(job), {})
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)
// URL d'export GPX du parcours GPS d'un tech (jour courant) via Traccar — { tech, from, to } ISO 8601. window.open() = téléchargement.
export const gpxUrl = (tech, from, to) => HUB + '/traccar/gpx?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to)
// Tracé GPS (breadcrumb) d'un tech sur UNE journée → { coords:[[lon,lat]…], count }. Plage exacte (1 jour) obligatoire (Traccar lourd sinon).
export const traccarTrack = (tech, from, to) => jget('/traccar/track?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to))
// Validation « service livré » d'un job : statut service+modem (en ligne + signal) pour le client/adresse du job.
// Croise tech-présent (geofence) + service-en-ligne → preuve de complétion d'install.
export const jobServiceStatus = (job) => jget('/roster/job-service-status?job=' + encodeURIComponent(job))
// Aperçu croisement temps-sur-site (Traccar × emplacement job) + ancre d'activation GenieACS (clamped_minutes / service_anchor).
export const onsiteCross = (days = 30) => jget('/traccar/onsite?days=' + days)
// Position GPS LIVE (dernière connue) d'un tech → { ok, lat, lon, time, speed } ; pour fixer son point de départ (domicile).
export const techLivePosition = (tech) => jget('/traccar/live?tech=' + encodeURIComponent(tech))
// Positions GPS LIVE de TOUS les techs (appareil Traccar associé) → { positions:[{techId,techName,lat,lon,time,speed}] }.
export const techPositions = () => jget('/roster/tech-positions')
// Géofencing : timeline d'un job → { state, events:[{status,at,dist}] } (status: en_route|on_site|departed).
export const jobGeofence = (name) => jget('/roster/job/' + encodeURIComponent(name) + '/geofence')
// Géofencing : états live pour une liste de jobs → { states: { jobName: state } }.
export const geofenceStates = (names) => jget('/roster/geofence-states?jobs=' + encodeURIComponent((names || []).join(',')))
// Liste des appareils Traccar (pour associer un tech à son GPS) — { id, name, uniqueId, status, lastUpdate }.
export const listTraccarDevices = () => jget('/traccar/devices')
// Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif.
export const setTechTraccarDevice = (id, deviceId) => jpost('/roster/technician/' + encodeURIComponent(id) + '/traccar-device', { device_id: deviceId })