gigafibre-fsm/apps/ops/src/api/roster.js
louispaulb aa6cc3e732 feat(dispatch): niveau requis persistant + lasso freeform + routes réelles Mapbox
- Niveau requis PAR JOB persistant : store hub durable (job-levels.json) + endpoint
  POST /roster/job-level + buildUnassigned enrichit required_level ; pastille « niv »
  écrit via l'API (survit reload/session). NB : l'API Custom Field ERPNext v16 échoue
  (IndexError) sur ce doctype custom → store hub (migratable en champ ERPNext plus tard).
- Lasso FREEFORM : tracé libre (souris + tactile) → polygone SVG + point-in-polygon sur
  les pins/amas projetés (remplace le rectangle) + expansion des amas (getClusterLeaves).
- D v2 : carte des tournées = routes ROUTIÈRES réelles (Mapbox Directions par tech,
  domicile→arrêts) + distance/temps RÉELS dans la légende (cache par signature ;
  fallback segments droits + estimation haversine).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:18:33 -04:00

176 lines
15 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'
async function jget (path) {
const r = await fetch(HUB + path)
if (!r.ok) throw new Error('Roster API ' + r.status)
return r.json()
}
async function jpost (path, body) {
const r = await fetch(HUB + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
})
if (!r.ok) throw new Error('Roster API ' + r.status)
return r.json()
}
async function jput (path, body) {
const r = await fetch(HUB + path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
if (!r.ok) throw new Error('Roster API ' + r.status)
return r.json()
}
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 })
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 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')
// 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)
// ÉQUIPE d'un job (assistants en renfort) — le lead reste inchangé ; l'assistant épinglé voit un bloc hachuré dans son horaire.
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))
// 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 })