- 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>
176 lines
15 KiB
JavaScript
176 lines
15 KiB
JavaScript
/**
|
||
* 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 })
|