Compare commits

..

25 Commits

Author SHA1 Message Date
louispaulb
80c8005f0c feat(planif): symbole de compétence du job sur les chips « à répartir » (Tournées)
Remplace l'icône terrain/distant par le SYMBOLE de compétence (skillSym : live_tv/build/cable/cell_tower/camion-nacelle…, couleur = getTagColor) — cohérent avec les cartes kanban + blocs de tournée. Vérifié préview : chips = live_tv (TV), build (réparation)…

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:16:12 -04:00
louispaulb
c8afa97004 feat(planif): Tournées — chips secteur + jobs non assignés sur la carte (gouttes), filtre liste+carte
Batch D — regrouper/situer les non-assignés du jour :
- RouteMap : nouvelle prop `pins` (+ renderPins/clearPins/watch, gouttes oranges couleur=priorité, clic → @pin-click) — miroir de `live`, inclus dans le fit bounds
- Planif Tournées : chips SECTEUR (par ville, comptés, triés) qui filtrent SIMULTANÉMENT la liste « à répartir » ET les gouttes sur la carte ; routeUnassignedPins (coords robustes latitude/lat), clic goutte → détail ; « Suggérer » agit sur le secteur si filtré

Vérifié préview : jour 06/07 → 10 secteurs + 21 gouttes ; clic « STE CLOTILDE » → 4 chips + 4 gouttes (« 4 à répartir / 21 »).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:06:34 -04:00
louispaulb
21724e65f8 feat(planif): Tournées — filtre compétence sur la carte + jour ciblé + jobs non assignés en évidence
Batch B — le filtre de compétence pilote la carte : dayLivePositions (GPS techs) filtré par visibleTechs
(les tournées l'étaient déjà via visibleTechs) → seuls les techs capables + leurs jobs sur la carte.

Batch C — mise en évidence des non-assignés du jour :
- Badge rouge « dus non assignés » par jour sur la bande (OccupancyStrip.os-unassigned), depuis unassignedByDay
- routeDayUnassigned = jobs dus le jour sélectionné mais non assignés → bande « N à répartir » (chips cliquables → détail) + bouton « Suggérer » ; se met à jour au clic sur la strip

Vérifié préview : positions filtrées par compétence ; badges [1,21,16,15,3,8] sur la bande ; clic 06/07 → bande « 21 à répartir » (14 chips réels).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:58:58 -04:00
louispaulb
012f924683 feat(planif): simplification barre du haut — Suggérer (jour sélectionné), fix recherche X, retrait Équipe
- Bouton « Générer » (haut) → « Suggérer » (openSuggest) basé sur le jour de la vue ACTIVE (strip Tournées / Jour kanban / bande mobile). Le solveur de quarts est déplacé dans Outils (« Générer l'horaire (semaine) », doGenerate conservé).
- Fix recherche tech : le X (clearable) mettait search=null → `search.value.trim()` plantait le computed visibleTechs → le champ « ne se vidait pas ». Rendu null-safe.
- Retrait du sélecteur « Équipe » (groupOptions vide : aucun tech n'a de `group` ; le filtre par compétence suffit).

Vérifié préview (desktop 1400px) : X vide le champ sans planter, Suggérer ouvre le dispatch auto, « Équipe » absent, grille rendue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:47:42 -04:00
louispaulb
d385367095 feat(hub): lib/vacation-import — lecteur .xlsx (ZIP/zlib, sans dép) + parseur vacances + matcher techs
Cœur de l'import du calendrier de vacances (cf. memory project_vacation_import) :
- unzip() : lecteur ZIP minimal via zlib.inflateRawSync (aucune dépendance npm, comme gmail.js évite googleapis)
- parse xlsx : sharedStrings + styles(fills→couleur) + cellules ; couleur cyan/jaune/magenta = Choix1/Choix2/Approuvé (vert=instruction, ignoré)
- parseCell() : texte de cellule → jours ISO (« 6 et 7 », « 6 au 10 », « 13,14…24 », « 21 juin au 1er juillet », listes .-) ; résolution du quantième par proximité (gère bornes de mois + listes multi-semaines)
- extractPeople() : personnes → jours par statut, plages contiguës
- matchAll() : appariement noms→techs en 2 passes (certains d'abord, puis lever ambiguïtés sur techs restants ; indice = initiale de nom)
- buildPreview() : buffer + techs → payload dry-run

Vérifié EN LOCAL (node, runtime hub) contre le vrai fichier « Vacances 2026.xlsx » : 30 semaines, 24 personnes, 24/24 appariées ; ambiguïtés Louis/Simon + cellules texte-sans-couleur correctement signalées « À classer ». Endpoints + UI OPS = étape suivante.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:26:57 -04:00
louispaulb
b7c24f58b2 feat(planif): sélecteur de jour « Tournées » = 2 semaines par défaut
routeStripDays passe de 7 → 14 jours (depuis le lundi affiché), données via /roster/capacity
(14 j, découplé de la fenêtre de charge 7 j du grid → barres réelles en semaine 2). Chargé à
l'entrée de la vue Tournées + au changement de semaine (watch [boardView, start]). Bande pleine
largeur + défilement horizontal ; jour courant marqué.

Vérifié préview : 14 cellules (29/06→12/07), today marqué, scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 08:55:29 -04:00
louispaulb
5491aa8abe feat(dashboard/planif): vue 2 semaines + deep-link jour + slot-finder « Planifier des quarts »
- Tableau de bord : 2 bandes d'occupation (« Cette semaine » / « Semaine prochaine », 14 j depuis lundi), jour courant marqué (liseré indigo dans OccupancyStrip). Clic jour → /planification?day=ISO
- Planif : deep-link `?day=` (cadre la semaine + sélectionne le jour grille/mobile/tournées) & `?skill=` (filtre compétence, match casse-insensible vs allSkills) via focusDay() lu au montage
- Slot-finder (SuggestSlotsDialog) : état « Aucun créneau » → bouton « Planifier des quarts pour « X » » → émet plan-shifts → Planif ouvre la timeline (vue grille) au jour cherché, filtrée sur la compétence, + toast « crée un quart pour ouvrir des créneaux »

Vérifié préview bout-en-bout : 2×7 jours + today, clic → ?day=2026-07-09 cadre 06→12/07, recherche vide → bouton → dialog fermé + grille + toast compétence. Build vert + leak-check 0. Aucun changement hub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 08:34:43 -04:00
louispaulb
9a507d35db feat(dashboard): bande d'occupation + KPI cliquables/tendance + garde-fou PPA ; barres de charge en-tête Planif
- <OccupancyStrip> réutilisable (extrait de .pm-strip) ; heatColor déplacé dans useHelpers = source unique
- Tableau de bord : bande « Charge à venir » (7 j glissants, réutilise /roster/capacity) ; clic jour → Planif ; légende + heures à répartir
- KPI cliquables (→ page liée) + sous-ligne tendance/contexte (urgents, +N tickets 7 j, non assignés)
- Garde-fou PPA : confirmation explicite avant prélèvement (cf. incident double-charge 2026-06)
- Planif : barres verticales d'occupation dans l'en-tête desktop (hdr-vbar) + sélecteur de jour « Tournées » = OccupancyStrip (remplace les chips « lun 29/06 »)

Vérifié en préview (strip, clics jour, dialog PPA annulé sans charge, nav KPI) + build vert + leak-check 0. Aucun changement hub (réutilise /roster/capacity).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 08:00:46 -04:00
louispaulb
cf9feb67f4 refactor(ops): retirer la page Dispatch dépréciée — tout sur Planification (−2179 lignes, −240 KB)
Batch 4 de l'audit. La page Dispatch n'était plus utilisée ; Planification la remplace.
- Supprimé DispatchPage.vue (2179 lignes → chunk 240 KB disparu du build).
- router : route /dispatch → REDIRECT vers /planification (anciens signets OK).
- nav.js : entrée « Dispatch » retirée. PlanificationPage : 2 liens « Tableau Dispatch » retirés.
- Dashboard (clic job du jour) + TaskNode (goToDispatch) → redirigent vers /planification.
- src/api/dispatch + src/config/dispatch INCHANGÉS (modules partagés app-wide).
Vérifié : build OK (chunk DispatchPage disparu), nav sans Dispatch (26 items), Planif rend,
/dispatch redirige.

NON appliqué (vérif avant édition = l'audit ne tenait pas) : consolidation initials()
— PAS équivalent (staffInitials = 1re+DERNIÈRE initiale + fallback '' ; local = 1re+2e mot +
fallback '?') → changerait les initiales affichées. Fixes de fuites : composants ont déjà
onUnmounted → « fuites » = cas v-show non confirmés. Laissés tels quels (churn/risque > gain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:03:57 -04:00
louispaulb
e5bd63b2d6 chore(ops): remove unused ResponsiveDialog.vue (0 imports)
Batch 4 (dead code) : composant orphelin (aucun import dans tout src/). Non bundlé
(tree-shaké) → suppression = nettoyage repo pur, aucun impact runtime/bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:43:44 -04:00
louispaulb
0e5c890b74 perf(hub): cache liste techs (30s) + garde anti-chevauchement sync + jeton ingest mémoïsé
Batch 2 de l'audit (perf backend), items sûrs :
- roster.js : fetchTechnicians caché 30 s (appelé par endpoint + generate + materialize +
  tech-positions polling 25 s pour des données quasi statiques). invalidateTechCache() sur
  les 6 écritures Dispatch Technician (coût/effic/skills/pause/traccar/weekly) → pas de stale
  après édition. Vérifié : call1 71 ms → call2 5 ms (caché).
- legacy-dispatch-sync.js : garde _inFlight sur le tick d'import ET la veille → un passage
  long ne se relance plus par-dessus (évitait doublons Dispatch Job + contention ERPNext).
- conversation.js : jeton d'ingestion webhook mémoïsé (mailIngestToken) au lieu d'un
  fs.readFileSync SYNCHRONE à chaque courriel/canal entrant (bloquait la boucle d'événements).

NON fait (par prudence) : paralléliser les erp.list séquentiels de generate() (séquencement
DÉLIBÉRÉ anti-ECONNRESET Frappe, cf. feedback_hub_crash_502) ; batch N+1 Customer du triage
(refactor risqué d'une chaîne de fallback stateful — reporté) ; jitter des pollers (502 boot
déjà mitigé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:42:18 -04:00
louispaulb
4a641ac802 perf(ops): lazy-load heavy libs & shell components — chunks route/shell bien plus légers
Batch 1 de l'audit d'optimisation. Les libs lourdes étaient statiquement importées
donc livrées même inutilisées → passées en import dynamique / defineAsyncComponent :
- MainLayout : ConversationPanel/PhoneModal/FlowEditor/NewTicket/Orchestrator/
  ServiceStatus/OutboxPanel → async (chunk shell 312 KB → 56 KB, chargé sur CHAQUE page)
- TaskGraphPage : TaskGantt (hy-vue-gantt) → async (2.6 MB → 20 KB ; le Gantt ne charge
  que dans la vue Gantt)
- NetworkPage : cytoscape → import() dans loadNetworkMap (484 KB → 52 KB)
- ReportAR/Revenu/Taxes : chart.js/auto → import() dans renderChart (async) (~150→~10 KB/page)

Total dist inchangé (~8 M) — le code est DÉFÉRÉ en chunks à la demande, pas supprimé.
Gain réel = ce que l'utilisateur télécharge pour VOIR une page. Build OK, leak-check propre,
shell + route rapport vérifiés (aucune erreur d'import).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:35:30 -04:00
louispaulb
1af8c62142 feat(planif): géofencing des jobs — timeline En route/Arrivé/Reparti (suivi façon colis)
Le hub compare la position GPS live de chaque tech (Traccar) aux jobs du jour →
détecte les transitions et les journalise par job. Le statut « live » du job est
DÉRIVÉ du journal (compute-on-read, aucune écriture ERPNext — pas de valeur « en
cours » côté doctype). Fiche job : timeline horizontale Assigné → En route → Arrivé
(HH:MM) → Reparti (HH:MM).

- lib/geofence.js : runScan() (rayon 150 m, sortie 230 m hystérésis, séjour 3 min),
  store data/geofence.json par job, machine à états ; startScan() scheduler 90 s
  (GEOFENCE_SCAN=off pour couper). Vérifié prod : 12 jobs × 14 positions, transitions OK.
- server.js : démarre le scan au boot.
- roster.js : GET /roster/job/:name/geofence (timeline) + /geofence-states + /geofence-scan (test).
- api/roster.js : jobGeofence(), geofenceStates().
- PlanificationPage : openJobDetail charge la timeline ; stepper .jd-geo (scoped).

Suite possible : écrire le vrai statut ERPNext à l'arrivée (nécessite d'ajouter une
valeur « En cours » au doctype Dispatch Job) — non fait pour éviter le risque schéma.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:34:55 -04:00
louispaulb
d966e1b749 feat(planif): positions GPS live des techs (Traccar) sur la carte des tournées
Onglet Tournées : marqueurs « live » de chaque tech (appareil Traccar associé),
rafraîchis ~25 s tant que l'onglet est ouvert. Marqueur = pastille ronde à initiales,
couleur = celle de la tournée du tech, halo pulsé si position fraîche (grisé sans halo
si > 10 min). Tooltip : nom · « il y a X min » · vitesse (km/h) ou « arrêté ».
Bouton « GPS live (N) » pour afficher/masquer. Respecte les chips techs masqués.

- hub roster.js : GET /roster/tech-positions → {positions:[{techId,techName,lat,lon,time,speed}]}
  (batch getPositions par appareil). Vérifié prod : 14 positions.
- api/roster.js : techPositions().
- RouteMap.vue : prop `live` + marqueurs .rm-live (halo pulsé, staleness), watch dédié.
- PlanificationPage : polling 25 s (démarré sur l'onglet Tournées, arrêté sinon +
  onUnmounted), couleur mappée depuis dayRoutes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:06:16 -04:00
louispaulb
c74462f97b feat(planif): dispatch auto = SEULEMENT les jobs du jour sélectionné (retards exclus par défaut)
Sélectionner le 7 juillet ne devait pas tirer les jobs en retard/sans date (souvent
faits mais pas fermés) dans la simulation. Par défaut (aucun chip de date coché),
suggestJobs ne garde que les jobs DATÉS sur le(s) jour(s) de la fenêtre — plus de
pollution par les retards/autres jours. Pour dispatcher les retards : cocher le chip
«  en retard » (comportement inchangé). Le job_names envoyé au solveur hub ne contient
alors que les jobs du jour → même résultat côté Optimiser.

Vérifié preview : jour 07-07 → 15 jobs, plan = 15 arrêts tous le 07-07, 0 en retard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:56:56 -04:00
louispaulb
3a836cfb21 fix(ops): HUB_URL = /ops/hub en prod (fin du menu vide — la VRAIE cause)
RACINE du « Accès OPS indisponible / menu vide » : le bundle appelait `/hub/...`
au lieu de `/ops/hub/...`. Cause : `.env.local` contient `VITE_HUB_URL=/hub`
(pour le proxy du serveur de dev) et Vite charge .env.local MÊME au build prod
(même fuite que VITE_DEV_USER) → HUB_URL="/hub" inliné. `/hub` (hors préfixe /ops)
est routé vers ERPNext → **HTTP 404** sur /hub/auth/permissions → can()=false →
menu/recherche/avis vides. Preuve navigateur : app `/hub/...`→404, direct
`/ops/hub/...`→200 superuser.

Fix : config/hub.js n'honore VITE_HUB_URL qu'en DEV ; en PROD HUB_URL =
BASE_URL + '/hub' = **/ops/hub** (chemin conçu, proxifié par nginx → hub, vérifié 200).
Bundle vérifié : plus de "/hub" standalone, tout est BASE_URL+"/hub".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:43:44 -04:00
louispaulb
7b54e87a59 fix(ops): overlay accès — message exact « n'a pas pu charger » + détail erreur + piste extension
L'overlay disait à tort « pas de permissions » alors que le compte y a droit et que
le fetch /auth/permissions échoue (blocage client). Message corrigé : « OPS n'a pas pu
charger les permissions » + affiche le détail technique (usePermissions.error) + suggère
fenêtre privée/autre navigateur (une extension bloque souvent /auth/permissions). Rend
chaque capture d'écran auto-diagnostique.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:31:41 -04:00
louispaulb
01bcd6476d fix(ops): la déconnexion ramène sur OPS après login (plus la page Authentik)
logout() ajoute `?next=<url OPS>` au flow d'invalidation Authentik : après
déconnexion, Authentik renvoie vers erp.gigafibre.ca/ops → qui (non authentifié)
redéclenche le login AVEC chemin de retour → après login on revient sur OPS,
au lieu d'atterrir sur /if/user/#/library. Sans régression si Authentik ignore
`next` (= comportement actuel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:25:41 -04:00
louispaulb
f5d8d38b1e fix(ops): retry permission load (superuser plus jamais bloqué par un échec transitoire au boot)
Symptôme (overlay fail-loud confirmé) : « OPS a reçu louis@targo.ca mais pas de
permissions », alors que le compte EST superuser (is_superuser=True, admin+sysadmin,
vérifié live) et que le hub renvoie 200 (6/6, ~0,14 s). La recherche (même base /hub,
appelée plus tard) marche → seul le 1er appel /hub au démarrage (checkSession) échoue
= course de boot / session pas encore « chaude ».

Fix : loadPermissions réessaie jusqu'à 4× (backoff 0,7→2,1 s, cache:no-store) avant de
conclure « pas d'accès ». Un échec transitoire ne verrouille plus l'app derrière un
menu vide / overlay. (Les droits sont dans Authentik : groupes admin/sysadmin + is_superuser,
inchangés — ce n'était ni le jeton ni les droits.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:18:53 -04:00
louispaulb
e6ac8b11ef fix(ops): fail-loud when OPS perms don't load + username fallback (supersede 96da97b session overlay)
Root cause of the recurring "blank menu": the hub keys OPS permissions to the
exact Authentik email (louis@targo.ca). If whoami passes a different email
(duplicate account e.g. louism@targointernet.com) or an empty email, the hub
404s → can()=false → menu/search/avis silently blank, mistaken for a token bug.
The ERP token and Authentik provider config were verified correct throughout.

- REVERT the misleading session-fix (96da97b): drop verifySessionAlive /
  sessionExpired / auto-reload churn and the "Session expirée" overlay; restore
  the original keep-alive ping. (Re-login couldn't fix a perms/identity issue,
  so that overlay only added noise.)
- FAIL-LOUD (App.vue): when permissions don't load in prod, show a clear overlay
  naming the exact account OPS received ("OPS a reçu <email>, pas de permissions")
  + Changer de compte / Recharger — instead of a silent blank shell.
- USERNAME FALLBACK: whoami username is captured (api/auth getAuthUsername),
  passed to loadPermissions, and the hub (/auth/permissions) retries by username
  when the email matches no provisioned OPS account. Verified on prod: username=Louis
  alone resolves; bad-email+username falls back; unknown → 404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:49:26 -04:00
louispaulb
96da97bfad fix(ops): session expirée → overlay clair + ré-auth, plus de menu blanc silencieux
Quand la session Authentik expire, nginx /auth/whoami renvoie 200 avec un
courriel VIDE (pas un 401) → ni _reauth ni le keep-alive ne le voyaient →
getLoggedUser retombait sur le propriétaire du jeton ERP, loadPermissions
était sauté, can() = false partout → menu/recherche/avis vides EN SILENCE
(l'app avait l'air cassée alors que le backend était sain).

- api/auth.js : verifySessionAlive() lit whoami (rafraîchit aussi la fenêtre
  Authentik) ; 200 + courriel vide confirmé par une 2e lecture (filtre les
  blips) = session morte → 1 rechargement auto (Traefik → login Authentik),
  sinon état sessionExpired (anti-boucle 30 s). getLoggedUser renvoie '' pour
  une session morte au lieu de masquer avec « Administrator ».
- stores/auth.js : checkSession relance verifySessionAlive si courriel vide.
- useSessionKeepAlive : le battement passe par verifySessionAlive (détecte
  enfin la mort par courriel-vide, pas seulement les 401).
- App.vue : overlay « Session expirée → Se reconnecter » au lieu d'une coquille
  vide. Inerte en dev local (localhost court-circuité).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:28:09 -04:00
louispaulb
8d80b44a79 feat(planif): « Suggérer » respecte aussi le filtre de TYPE du pool
Étend le filtre du dispatch auto au type affiché (« ce que je vois = ce que
je répartis ») : suggestJobs part désormais de assignJobsFiltered (type + date)
au lieu des seules dates. Défaut inchangé (aucun filtre = tout le pool part).
Levier concret : décocher « téléphonie » → non réparti aux techs terrain.

- suggestJobs : base = assignJobsFiltered (type ∩ date) hors On Hold ; lasso prioritaire.
- suggestFiltered + suggestScope : bouton « Suggérer (N) » et dialogue
  « N job(s) des jours + types cochés → jours » quels que soient les filtres actifs.
- Aucun changement hub : optimize-plan filtre déjà par job_names (déployé).

Vérifié preview : réparation seul → 18 ; réparation + 07-06 → 5 arrêts,
tous réparation, tous le 07-06 (heuristique locale).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:09:18 -04:00
louispaulb
6c5ed0060f feat(planif): « Suggérer » ne répartit que les jobs des jours cochés
Le dispatch auto utilisait tout le pool avec son propre sélecteur de jour,
en ignorant les chips de date du pool (et même la sélection lasso, sur le
chemin solveur hub). Désormais les chips pilotent le dispatch :

- FENÊTRE = les chips de date cochés ( en retard / Sans date → placés
  aujourd'hui) ; aucun chip → repli sur le sélecteur Aujourd'hui/Demain/date.
- JEU DE JOBS restreint aux jours cochés (ou à la sélection lasso) sur les
  3 chemins : heuristique locale, solveur local, ET solveur hub (nouveau
  param `job_names` dans optimize-plan, additif/rétrocompatible).
- UI : bouton « Suggérer (N) » + dialogue « N job(s) des jours cochés → jours ».
- Helper partagé jobMatchesDateChips (dé-dup avec le filtre d'affichage).

Vérifié en preview : chip 07-06 → 19 arrêts tous le 07-06 (local) ;
chips 07-06+07-07 → 34 arrêts, uniquement ces 2 jours (solveur hub déployé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:58:38 -04:00
louispaulb
c209098a44 fix(security): stop baking dev-user email into the prod bundle
VITE_DEV_USER (.env.local = louis@targo.ca) was inlined by Vite into
auth.js's localhost fallback, shipping personal PII in every prod build.
Gate that branch behind import.meta.env.DEV so esbuild dead-code-eliminates
it (and the inlined string) from production builds — permanent, no reliance
on a build flag. Also neutralize the two hardcoded louis@targo.ca in the
campaign TemplateEditor (merge-tag sample → exemple@gigafibre.ca; test-send
default → empty, send button already guards on non-empty). Leak-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:36:04 -04:00
louispaulb
69f00840c8 feat(planif): hybrid recurring shift model — weekly_schedule pattern + materialization
The weekly_schedule pattern per tech is now the SOURCE of shifts. New
materializeShifts() (hub roster.js) generates source='pattern' Shift
Assignment rows over an N-week horizon, auto-skipping QC holidays
(holidays-qc) and vacations (Tech Availability), and PRESERVING manual
overrides (source≠'pattern') — e.g. a one-off night week. Idempotent
(re-runnable / cron-ready).

- hub: fetchTechnicians returns parsed weekly_schedule; ensureShiftTemplate
  (find/create Shift Template by exact times); endpoints
  POST /roster/technician/:id/weekly-schedule and /roster/materialize-shifts
  (GET=dry-run, POST=apply, weeks/tech params).
- api/roster.js: setWeeklySchedule + materializeShifts.
- WeeklyScheduleEditor: mode toggle 🔁 Récurrent (base, materialized) ↔
  📌 Exception (concrete override weeks); adaptive labels/horizon.
- PlanificationPage onScheduleApply branches: recurring → setWeeklySchedule
  per tech + materializeShifts; exception → direct-write source='manuel'.

No ERPNext schema change (weekly_schedule + source already exist).
Dry-run against prod: 6 techs patterned → 48 shifts/2wk, 0 collisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:35:57 -04:00
32 changed files with 1042 additions and 2339 deletions

View File

@ -1,17 +1,58 @@
<template>
<router-view />
<!-- « FAIL-LOUD » : les permissions OPS n'ont pas chargé (mauvais compte / identité Authentik non transmise).
On affiche un message CLAIR avec le compte réellement reçu, au lieu d'une coquille vide (menu blanc) qu'on
prenait à tort pour un bug de token/session. Inerte en dev local (profil permissif). -->
<div v-if="showAccessError" class="ops-access-error">
<div class="ops-access-card">
<q-icon name="person_off" size="44px" color="negative" />
<div class="text-h6 q-mt-sm">Accès OPS indisponible</div>
<div class="text-body2 text-grey-7 q-mt-sm" style="max-width:400px;line-height:1.5">
<template v-if="authIdentity">
OPS n'a pas pu charger les permissions de <b>{{ authIdentity }}</b> (le compte y a pourtant droit).
<div class="q-mt-sm">Souvent une <b>extension</b> (bloqueur de pub/vie privée) ou un proxy qui bloque la requête
<code>/auth/permissions</code> : essaie une <b>fenêtre privée</b> ou un <b>autre navigateur</b>.</div>
</template>
<template v-else>
Authentik n'a pas transmis ton identité (session incomplète). Reconnecte-toi via
<b>erp.gigafibre.ca/ops</b>.
</template>
<div v-if="permsError" class="text-caption text-negative q-mt-sm" style="font-family:monospace">Détail : {{ permsError }}</div>
</div>
<div class="row q-gutter-sm q-mt-md justify-center">
<q-btn outline no-caps color="grey-8" icon="logout" label="Changer de compte" @click="auth.doLogout()" />
<q-btn unelevated no-caps color="primary" icon="refresh" label="Recharger" @click="reload" />
</div>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useAuthStore } from 'src/stores/auth'
import { usePermissions } from 'src/composables/usePermissions'
import { loadSavedTheme } from 'src/composables/useTheme'
import { startSessionKeepAlive } from 'src/composables/useSessionKeepAlive'
loadSavedTheme() // applique le thème enregistré (couleurs --ops-*) dès le démarrage
const auth = useAuthStore()
const { isLoaded, loading: permsLoading, error: permsError } = usePermissions()
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
// PROD uniquement : une fois le contrôle de session ET le chargement des permissions terminés, si aucune
// permission n'a chargé accès indisponible (mauvais compte / identité vide) overlay clair.
const showAccessError = computed(() => !isLocal && !auth.loading && !permsLoading.value && !isLoaded.value)
// Le compte réellement transmis à OPS (courriel whoami) clé du diagnostic : « mauvais compte ? ».
const authIdentity = computed(() => (auth.user && auth.user !== 'authenticated') ? auth.user : '')
function reload () { window.location.reload() }
onMounted(() => {
auth.checkSession()
startSessionKeepAlive() // garde la session Authentik vivante plus besoin de recharger pour que les données/recherche reviennent
startSessionKeepAlive() // garde la session Authentik vivante (ping /auth/whoami /4 min + au retour d'onglet)
})
</script>
<style>
.ops-access-error { position: fixed; inset: 0; z-index: 99999; display: flex; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.55); backdrop-filter: blur(2px); }
.ops-access-card { background: #fff; border-radius: 14px; padding: 28px 32px; text-align: center; box-shadow: 0 12px 40px rgba(0,0,0,0.25); display: flex; flex-direction: column; align-items: center; max-width: 92vw; }
</style>

View File

@ -4,6 +4,13 @@ import { BASE_URL } from 'src/config/erpnext'
// Only needed for local dev (VITE_ERP_TOKEN in .env).
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || ''
const isLocalHost = () => window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
// Nom d'utilisateur Authentik renvoyé par le dernier whoami — repli de recherche de permissions côté hub
// quand le courriel ne correspond à aucun compte OPS provisionné (voir stores/auth.js → loadPermissions).
let _authUsername = ''
export function getAuthUsername () { return _authUsername }
// Reconnexion auto sur session Authentik expirée, anti-boucle (1 reload / 20 s max).
let _lastReload = 0
function _reauth (status) {
@ -53,14 +60,19 @@ export async function authFetch (url, opts = {}) {
}
export async function getLoggedUser () {
// ⚠️ APERÇU LOCAL UNIQUEMENT : identité forcée via VITE_DEV_USER (gardé localhost + env). Inerte en prod. À RETIRER avec le harnais.
if ((window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && import.meta.env.VITE_DEV_USER) return import.meta.env.VITE_DEV_USER
// ⚠️ APERÇU LOCAL UNIQUEMENT : identité forcée via VITE_DEV_USER (gardé localhost + env). `import.meta.env.DEV`
// vaut false en build prod → esbuild élimine toute cette branche (et la valeur inlinée) du bundle livré.
if (import.meta.env.DEV && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && import.meta.env.VITE_DEV_USER) return import.meta.env.VITE_DEV_USER
// First try nginx whoami endpoint (returns Authentik email header)
try {
const res = await fetch(BASE_URL + '/auth/whoami')
if (res.ok) {
const data = await res.json()
_authUsername = data.username || '' // mémorisé pour le repli par username (recherche de permissions)
if (data.email && data.email !== '') return data.email
// whoami joignable mais courriel VIDE en prod = identité Authentik non transmise → renvoyer '' (pas de repli
// trompeur sur le propriétaire du jeton ERP « Administrator »). App.vue affiche alors un message clair « fail-loud ».
if (!isLocalHost()) return ''
}
} catch {}
// Fallback: ask ERPNext (returns API token owner, usually "Administrator")
@ -76,5 +88,9 @@ export async function getLoggedUser () {
}
export async function logout () {
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/'
// Déconnexion Authentik PUIS retour sur OPS : le flow d'invalidation honore `?next=` → il renvoie vers OPS,
// qui (non authentifié) redéclenche le login Authentik AVEC chemin de retour → après login on revient sur OPS,
// au lieu d'atterrir sur la page par défaut d'Authentik (/if/user/#/library).
const back = window.location.origin + (import.meta.env.BASE_URL || '/') // ex. https://erp.gigafibre.ca/ops/
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/?next=' + encodeURIComponent(back)
}

View File

@ -145,6 +145,9 @@ export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set
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 getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job))
export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a })
@ -179,6 +182,12 @@ export const jobServiceStatus = (job) => jget('/roster/job-service-status?job='
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.

View File

@ -0,0 +1,69 @@
<template>
<!-- Bande d'occupation réutilisable : 1 cellule par jour, barre verticale « chaleur » (vert rouge).
Extraite de la bande mobile Planif (.pm-strip). Utilisée : tableau de bord (charge de la semaine),
et partout un aperçu de charge par jour est utile. Clic @select(iso). -->
<div class="os-strip">
<button
v-for="d in days" :key="d.iso" type="button" class="os-day"
:class="{ active: d.iso === selected, today: d.today, weekend: d.weekend, holiday: d.holiday }"
:title="dayTitle(d)"
@click="$emit('select', d.iso)"
>
<span v-if="d.holiday" class="os-hol"></span>
<span v-if="d.noShift && !d.unassigned" class="os-warn" title="Aucun quart planifié — capacité estimée"></span>
<span v-if="d.unassigned" class="os-unassigned" :title="d.unassigned + ' job(s) dû(s) ce jour, non assigné(s) — à répartir'">{{ d.unassigned }}</span>
<span class="os-dow">{{ d.dow }}</span>
<span class="os-num">{{ d.dnum }}</span>
<span class="os-bar"><span class="os-fill" :style="{ height: fillH(d) + '%', background: heatColor(d.ratio) }"></span></span>
<span class="os-h">{{ d.cap > 0 ? Math.round(d.h) + '/' + Math.round(d.cap) : (Math.round(d.h) + 'h') }}<span v-if="d.over" class="os-over"></span></span>
</button>
</div>
</template>
<script setup>
import { heatColor } from 'src/composables/useHelpers'
defineProps({
days: { type: Array, default: () => [] }, // [{ iso, dow, dnum, h, cap, ratio, over, weekend, holiday, noShift, today }]
selected: { type: String, default: '' },
})
defineEmits(['select'])
// Barre : min 12 % quand il y a de la charge (visible), 0 si rien ; plafonnée à 100 %.
function fillH (d) { return d.h > 0 ? Math.max(12, Math.min(100, Math.round((d.ratio || 0) * 100))) : 0 }
function dayTitle (d) {
const parts = [Math.round(d.h) + 'h à faire']
if (d.cap > 0) parts.push('/ ' + Math.round(d.cap) + 'h de capacité')
if (d.over) parts.push('· surchargé ⚠')
if (d.noShift) parts.push('· aucun quart (estimé)')
if (d.holiday) parts.unshift('★ férié —')
return parts.join(' ')
}
</script>
<style scoped>
.os-strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
.os-strip::-webkit-scrollbar { height: 0; }
.os-day {
position: relative; flex: 0 0 auto; width: 52px; border: 1px solid var(--ops-border, #e2e8f0);
border-radius: 10px; background: #fff; display: flex; flex-direction: column; align-items: center;
gap: 2px; padding: 7px 2px; cursor: pointer; transition: border-color .15s, box-shadow .15s;
}
.os-day:hover { border-color: #c7d2fe; }
.os-day.active { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, .18); }
.os-day.today .os-num { color: #6366f1; font-weight: 700; }
/* Différenciateur « jour courant » : liseré indigo en haut de la cellule (n'écrase pas la bague .active) */
.os-day.today::before { content: ''; position: absolute; top: -1px; left: 22%; right: 22%; height: 3px; border-radius: 0 0 3px 3px; background: #6366f1; }
.os-day.weekend { background: #f8fafc; }
.os-day.holiday { background: #fff7ed; border-color: #fdba74; }
.os-hol { position: absolute; top: 1px; left: 3px; color: #ea580c; font-size: 9px; line-height: 1; }
.os-warn { position: absolute; top: 2px; right: 3px; color: #ef4444; font-size: 9px; line-height: 1; pointer-events: none; }
/* Badge « dus non assignés ce jour » (flottant coin haut-droit) */
.os-unassigned { position: absolute; top: -5px; right: -5px; min-width: 16px; height: 16px; padding: 0 3px; border-radius: 8px; background: #ef4444; color: #fff; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; box-shadow: 0 1px 3px rgba(2, 6, 23, .3); pointer-events: none; }
.os-dow { font-size: 10px; color: var(--ops-text-secondary, #64748b); text-transform: capitalize; }
.os-num { font-size: 13px; font-weight: 600; color: var(--ops-text, #1e293b); }
.os-bar { width: 10px; height: 44px; background: #eef2f7; border-radius: 5px; overflow: hidden; display: flex; align-items: flex-end; margin: 3px 0; }
.os-fill { width: 100%; border-radius: 5px; transition: height .3s; }
.os-h { font-size: 10px; color: var(--ops-text-secondary, #64748b); white-space: nowrap; }
.os-over { color: #ef4444; margin-left: 1px; }
</style>

View File

@ -1,32 +0,0 @@
<template>
<!-- q-dialog qui se MAXIMISE sur téléphone (plein écran) et plafonne à 95vw ailleurs.
Remplace les `style="width:NNNpx"` qui débordent sur mobile.
Usage : <ResponsiveDialog v-model="open" :max-width="560"> contenu </ResponsiveDialog> -->
<q-dialog v-bind="$attrs" :maximized="isPhone">
<q-card v-if="card" class="rdlg-card column no-wrap" :style="cardStyle">
<slot />
</q-card>
<slot v-else />
</q-dialog>
</template>
<script setup>
import { computed } from 'vue'
import { useIsPhone } from 'src/composables/useMediaQuery'
defineOptions({ inheritAttrs: false })
const props = defineProps({
card: { type: Boolean, default: true }, // enveloppe automatiquement dans un q-card
maxWidth: { type: [String, Number], default: 560 },
})
const isPhone = useIsPhone()
const cardStyle = computed(() => {
if (isPhone.value) return 'width:100%;max-width:100%;height:100%'
const mw = typeof props.maxWidth === 'number' ? props.maxWidth + 'px' : props.maxWidth
return `width:100%;max-width:min(95vw, ${mw})`
})
</script>
<style scoped>
.rdlg-card { max-height: 95vh; }
</style>

View File

@ -15,12 +15,14 @@ import * as roster from 'src/api/roster'
const props = defineProps({
routes: { type: Array, default: () => [] },
live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }]
pins: { type: Array, default: () => [] }, // jobs NON assignés à situer : [{ lat, lon, subject, name, color(priorité), city }]
height: { type: String, default: '440px' },
})
const emit = defineEmits(['metrics', 'stop-click'])
const emit = defineEmits(['metrics', 'stop-click', 'pin-click'])
let el = null; let map = null; let ro = null; let ready = false; let drawSeq = 0
let stopMk = []; let homeMk = [] // marqueurs HTML (arrêts + domiciles)
let stopMk = []; let homeMk = []; let liveMk = []; let pinMk = [] // marqueurs HTML (arrêts + domiciles + positions live + jobs non assignés)
const _rmCache = RouteMapCache() // cache module (partagé entre instances) : signature { geometry, km, mins }
function RouteMapCache () { const g = globalThis; if (!g.__opsRouteMapCache) g.__opsRouteMapCache = new Map(); return g.__opsRouteMapCache }
function setEl (node) { el = node }
@ -68,6 +70,46 @@ async function realLine (r) { // géométrie routière réelle d'une tournée (O
// Marqueurs HTML : le numéro est DANS la pastille (même élément, même niveau)
function clearMarkers () { for (const m of stopMk) m.mk.remove(); for (const m of homeMk) m.mk.remove(); stopMk = []; homeMk = [] }
// Positions GPS LIVE des techs (marqueur distinct des arrêts : pastille ronde à initiales + halo pulsé)
function initials (n) { return String(n || '?').trim().split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() }
function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] }
function clearPins () { for (const m of pinMk) m.mk.remove(); pinMk = [] }
function renderLive (list) {
if (!map || !ready) return
clearLive()
const mapboxgl = window.mapboxgl; const now = Date.now()
for (const p of (list || [])) {
if (!okLL(p.lon, p.lat)) continue
const ageMin = p.time ? Math.round((now - Date.parse(p.time)) / 60000) : null
const stale = ageMin != null && ageMin > 10 // > 10 min sans fix = position vieillie (pas de halo, grisée)
const color = p.color || '#1e88e5'
const wrap = document.createElement('div'); wrap.className = 'rm-live' + (stale ? ' stale' : '')
wrap.innerHTML = '<span class="rm-live-pulse" style="background:' + color + '"></span>' +
'<span class="rm-live-dot" style="background:' + color + '">' + initials(p.name || p.techName) + '</span>'
const spd = (p.speed != null && p.speed > 1) ? Math.round(p.speed * 1.852) + ' km/h' : 'arrêté' // Traccar speed = nœuds km/h
const when = ageMin == null ? 'position' : (ageMin <= 1 ? "à l'instant" : 'il y a ' + ageMin + ' min')
wrap.title = (p.name || p.techName || '') + ' — ' + when + ' · ' + spd
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map)
liveMk.push({ mk, el: wrap })
}
}
// Jobs NON assignés du jour : gouttes oranges (à situer/répartir) ; clic la page ouvre le détail.
function renderPins (list) {
if (!map || !ready) return
clearPins()
const mapboxgl = window.mapboxgl
for (const p of (list || [])) {
if (!okLL(p.lon, p.lat)) continue
const color = p.color || '#f97316'
const wrap = document.createElement('div'); wrap.className = 'rm-pin'
wrap.innerHTML = '<span class="rm-pin-drop" style="background:' + color + '"></span>'
wrap.title = (p.subject || 'Job') + (p.city ? ' · ' + p.city : '') + ' — non assigné'
wrap.addEventListener('click', () => emit('pin-click', p))
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'bottom' }).setLngLat([+p.lon, +p.lat]).addTo(map)
pinMk.push({ mk, el: wrap })
}
}
function renderMarkers (routes) {
clearMarkers()
const mapboxgl = window.mapboxgl
@ -149,8 +191,10 @@ async function draw () {
const ls = map.getSource('rm-line'); if (!ls) return
ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) })
renderMarkers(routes)
const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|')
if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} }
renderLive(props.live)
renderPins(props.pins)
const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|') + '#' + (props.pins || []).length
if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); for (const p of (props.pins || [])) if (okLL(p.lon, p.lat)) b.extend([+p.lon, +p.lat]); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} }
// Routes RÉELLES en asynchrone : remplace les segments droits + émet km/min réels. Ignoré si un draw plus récent est parti.
const results = await Promise.all(routes.map(async r => ({ r, info: await realLine(r) })))
if (seq !== drawSeq || !map || !map.getSource('rm-line')) return
@ -199,8 +243,10 @@ onMounted(async () => {
draw()
})
})
onBeforeUnmount(() => { clearMarkers(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false })
onBeforeUnmount(() => { clearMarkers(); clearLive(); clearPins(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false })
watch(() => props.routes, () => { draw() }, { deep: true })
watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live sans redessiner les tournées
watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // jobs non assignés (secteur) sans redessiner les tournées
</script>
<template>
@ -228,4 +274,15 @@ watch(() => props.routes, () => { draw() }, { deep: true })
.rm-mk.route-hot { z-index: 5; }
.rm-mk.fanned { z-index: 6; }
.rm-mk.fanned .rm-pill { box-shadow: 0 3px 10px rgba(0,0,0,.55); }
/* Position GPS LIVE d'un tech : pastille ronde à initiales + halo pulsé (distincte des pastilles d'arrêt). */
.rm-live { position: relative; width: 0; height: 0; z-index: 8; pointer-events: auto; }
.rm-live .rm-live-dot { position: absolute; transform: translate(-50%, -50%); width: 28px; height: 28px; border-radius: 50%; border: 2.5px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,.5); color: #fff; font-size: 10.5px; font-weight: 800; display: flex; align-items: center; justify-content: center; z-index: 2; cursor: default; }
.rm-live .rm-live-pulse { position: absolute; width: 28px; height: 28px; border-radius: 50%; opacity: .45; z-index: 1; animation: rm-live-pulse 1.8s ease-out infinite; }
@keyframes rm-live-pulse { 0% { transform: translate(-50%,-50%) scale(1); opacity: .45; } 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } }
.rm-live.stale .rm-live-pulse { display: none; }
.rm-live.stale .rm-live-dot { opacity: .55; filter: grayscale(.5); }
/* Jobs NON assignés (goutte, couleur = priorité) — à situer/répartir */
.rm-pin { position: relative; width: 0; height: 0; z-index: 7; cursor: pointer; pointer-events: auto; }
.rm-pin-drop { position: absolute; left: -9px; bottom: 0; width: 18px; height: 18px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); border: 2px solid #fff; box-shadow: 0 2px 5px rgba(0, 0, 0, .4); }
.rm-pin:hover .rm-pin-drop { filter: brightness(1.12); }
</style>

View File

@ -351,9 +351,9 @@ function confirmDelete () {
})
}
// Navigate to dispatch
// Navigate to planification (ex-Dispatch, page retirée)
function goToDispatch () {
router.push('/dispatch')
router.push('/planification')
}
// Visual computed

View File

@ -19,13 +19,15 @@ const emit = defineEmits(['update:modelValue', 'apply'])
const DAYS = [['mon', 'Lun'], ['tue', 'Mar'], ['wed', 'Mer'], ['thu', 'Jeu'], ['fri', 'Ven'], ['sat', 'Sam'], ['sun', 'Dim']]
const sched = reactive({})
const weeks = ref(1)
const mode = ref('recurring') // 'recurring' = patron de base (matérialisé, exceptions auto) · 'exception' = override ponctuel (semaines précises)
const selected = ref(new Set()) // techIds cochés (mode lot)
const bulk = computed(() => (props.techs || []).length > 1)
const title = computed(() => bulk.value ? ((props.techs || []).length + ' techniciens') : (props.techName || (props.techs[0] && props.techs[0].name) || ''))
function fill (preset) { for (const [k] of DAYS) { const s = preset[k]; sched[k] = s ? { on: true, start: s.start, end: s.end } : { on: false, start: '08:00', end: '16:00' } } }
fill(SCHEDULE_PRESETS[0].schedule)
watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = 1; selected.value = new Set((props.techs || []).map(t => t.id)) } })
watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = mode.value === 'recurring' ? 6 : 1; selected.value = new Set((props.techs || []).map(t => t.id)) } })
watch(mode, (m) => { weeks.value = m === 'recurring' ? 6 : 1 })
function toggleTech (id) { const s = new Set(selected.value); s.has(id) ? s.delete(id) : s.add(id); selected.value = s }
function dayHours (d) { if (!d || !d.on) return 0; const [h1, m1] = d.start.split(':').map(Number); const [h2, m2] = d.end.split(':').map(Number); let mn = (h2 * 60 + m2) - (h1 * 60 + m1); if (mn < 0) mn += 1440; return Math.round(mn / 60 * 10) / 10 }
@ -34,7 +36,7 @@ const totalHours = () => DAYS.reduce((s, [k]) => s + dayHours(sched[k]), 0)
function submit () {
const techIds = bulk.value ? [...selected.value] : (props.techs[0] ? [props.techs[0].id] : [])
if (!techIds.length) return
emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)), techIds })
emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)), techIds, mode: mode.value })
emit('update:modelValue', false)
}
</script>
@ -49,6 +51,12 @@ function submit () {
</q-card-section>
<q-separator />
<q-card-section class="q-gutter-sm">
<!-- Mode : patron RÉCURRENT (base, matérialisé avec fériés/vacances sautés) vs EXCEPTION ponctuelle (semaines précises) -->
<q-btn-toggle v-model="mode" spread no-caps dense unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[{ value: 'recurring', slot: 'rec' }, { value: 'exception', slot: 'exc' }]">
<template #rec><div class="column items-center" style="line-height:1.1;padding:2px 0"><span>🔁 Horaire récurrent</span><span style="font-size:10px;opacity:.7">base · fériés/vacances sautés</span></div></template>
<template #exc><div class="column items-center" style="line-height:1.1;padding:2px 0"><span>📌 Exception</span><span style="font-size:10px;opacity:.7">semaine(s) précise(s)</span></div></template>
</q-btn-toggle>
<!-- Mode LOT : choisir les techniciens à qui appliquer le modèle (tous cochés par défaut) -->
<div v-if="bulk">
<div class="text-caption text-grey-7 q-mb-xs">Appliquer à ({{ selected.size }}/{{ techs.length }}) clic pour ()cocher :</div>
@ -75,17 +83,18 @@ function submit () {
<span v-else class="wse-repos">Repos</span>
</div>
</div>
<!-- Automatisation : répéter sur N semaines -->
<!-- Horizon : récurrent = fenêtre à matérialiser ; exception = nb de semaines dès la semaine affichée -->
<div class="row items-center q-gutter-sm q-pt-xs">
<q-input dense outlined type="number" v-model.number="weeks" label="Générer pour N semaine(s)" :min="1" :max="12" style="width:220px">
<q-input dense outlined type="number" v-model.number="weeks" :label="mode === 'recurring' ? 'Matérialiser N semaines' : 'N semaine(s) dès la semaine affichée'" :min="1" :max="12" style="width:250px">
<template #prepend><q-icon name="repeat" size="18px" /></template>
</q-input>
<div class="text-caption text-grey-6">à partir de la semaine affichée · {{ totalDays() }} j / {{ Math.round(totalHours() * 10) / 10 }}h par semaine</div>
<div class="text-caption text-grey-6">{{ totalDays() }} j / {{ Math.round(totalHours() * 10) / 10 }}h par semaine</div>
</div>
<div class="text-caption text-grey-6">{{ mode === 'recurring' ? 'Devient l\'horaire par défaut : quarts générés automatiquement, fériés & vacances exclus, tes ajustements manuels préservés.' : 'Écrit des quarts pour ces semaines uniquement (override) — ex. semaine de nuit. Ne change pas l\'horaire de base.' }}</div>
</q-card-section>
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat no-caps label="Annuler" color="grey-7" @click="emit('update:modelValue', false)" />
<q-btn unelevated no-caps color="primary" icon="event_available" :disable="bulk && !selected.size" :label="'Générer' + (bulk ? ' ×' + selected.size + ' tech' : '') + (weeks > 1 ? ' · ' + weeks + ' sem.' : '')" @click="submit" />
<q-btn unelevated no-caps color="primary" icon="event_available" :disable="bulk && !selected.size" :label="(mode === 'recurring' ? 'Définir l\'horaire' : 'Créer l\'exception') + (bulk ? ' ×' + selected.size + ' tech' : '')" @click="submit" />
</q-card-actions>
</q-card>
</q-dialog>

View File

@ -29,6 +29,16 @@ export function timeToH (t) {
return h + m / 60
}
// Occupation → couleur « chaleur » (vert = libre → rouge = saturé). SOURCE UNIQUE :
// bande mobile Planif (.pm-strip), <OccupancyStrip> du tableau de bord, en-tête desktop Planif.
export function heatColor (ratio) {
const r = +ratio || 0
if (r >= 1) return '#ef4444' // saturé / dépassé
if (r >= 0.75) return '#f59e0b' // chargé
if (r >= 0.45) return '#84cc16' // moyen
return '#22c55e' // libre
}
export function hToTime (h) {
const totalMin = Math.round(h * 60)
const hh = Math.floor(totalMin / 60)

View File

@ -16,34 +16,44 @@ let fetchPromise = null // Prevent duplicate fetches
* Fetch effective permissions for the current user from targo-hub.
* Reads X-Authentik-Email header (injected by Traefik).
* @param {string} email user email (from Authentik header or session)
* @param {string} [username] Authentik username, used as a FALLBACK lookup when the email
* matches no provisioned OPS account (empty/wrong email the hub retries by username).
*/
async function loadPermissions (email) {
if (!email) return
async function loadPermissions (email, username) {
if (!email && !username) return
if (fetchPromise) return fetchPromise
loading.value = true
error.value = null
fetchPromise = fetch(`${HUB_URL}/auth/permissions?email=${encodeURIComponent(email)}`)
.then(r => {
if (!r.ok) throw new Error('Permission fetch failed: ' + r.status)
return r.json()
})
.then(data => {
const qs = new URLSearchParams()
if (email) qs.set('email', email)
if (username) qs.set('username', username)
const url = `${HUB_URL}/auth/permissions?${qs.toString()}`
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
// RÉSILIENCE : le tout 1er appel /hub au démarrage échoue parfois (session pas encore « chaude » / course au boot)
// alors que les appels /hub suivants (ex. recherche) passent. On RÉESSAIE (jusqu'à 4 tentatives, backoff 0,7→2,1 s)
// avant de conclure « pas d'accès » → un superuser n'est plus bloqué par un échec transitoire (le vrai bug observé).
async function attempt (n) {
try {
const r = await fetch(url, { cache: 'no-store' })
if (!r.ok) throw new Error('HTTP ' + r.status)
const data = await r.json()
permissions.value = data
})
.catch(e => {
console.error('[usePermissions]', e.message)
error.value = null
} catch (e) {
if (n < 4) { await new Promise(res => setTimeout(res, 700 * n)); return attempt(n + 1) }
console.error('[usePermissions] load failed after retries:', e.message)
error.value = e.message
// DEV local (pas de hub devant) : le fetch échoue → on débloque l'app avec un profil permissif
// pour pouvoir prévisualiser/auditer toutes les pages. En PROD on reste null (= restrictif/sûr).
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
// DEV local (pas de hub devant) : profil permissif pour prévisualiser/auditer ; PROD → null (App.vue « fail-loud »).
permissions.value = isLocal ? { email, is_superuser: true, capabilities: {}, groups: ['admin'] } : null
})
.finally(() => {
loading.value = false
fetchPromise = null
})
}
}
fetchPromise = attempt(1).finally(() => {
loading.value = false
fetchPromise = null
})
return fetchPromise
}

View File

@ -11,8 +11,13 @@
* EventSource (/sse, /conversations) work, and rides the existing Authentik
* forward-auth on /ops. Mirrors the /ops/api ERPNext pattern (config/erpnext.js).
*
* Can also be overridden via VITE_HUB_URL env variable.
* VITE_HUB_URL n'est honoré qu'en DEV (proxy du serveur de dev).
*/
const _opsBase = (import.meta.env.BASE_URL || '/').replace(/\/$/, '') // '' in dev, '/ops' in prod
export const HUB_URL = import.meta.env.VITE_HUB_URL
|| (window.location.hostname === 'localhost' ? 'http://localhost:3300' : _opsBase + '/hub')
// ⚠️ PROD : TOUJOURS le proxy same-origin `_opsBase + '/hub'` (= /ops/hub → Traefik strip /ops → nginx `location /hub/`
// → hub). On n'honore VITE_HUB_URL qu'en DEV : il vaut `/hub` dans .env.local (proxy du serveur de dev) et, comme
// .env.local est chargé MÊME au build prod (cf. fuite VITE_DEV_USER), l'utiliser en prod ferait appeler `/hub` à la
// RACINE → hors du préfixe /ops → routé vers ERPNext → **404** (= le bug « menu vide / Accès OPS indisponible »).
export const HUB_URL = import.meta.env.DEV
? (import.meta.env.VITE_HUB_URL || 'http://localhost:3300')
: _opsBase + '/hub'

View File

@ -17,8 +17,7 @@ export const navItems = [
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets', section: 'service' },
{ path: '/evaluations', icon: 'Star', label: 'Évaluations', requires: 'view_clients', section: 'service' },
{ path: '/pipeline', icon: 'Filter', label: 'Pipeline (ventes)', requires: 'view_clients', section: 'service' },
// ── Terrain (effectifs / interventions) ──
{ path: '/dispatch', icon: 'Truck', label: 'Dispatch', requires: 'view_all_jobs', section: 'terrain' },
// ── Terrain (effectifs / interventions) — page Dispatch retirée, tout est sur Planification ──
{ path: '/planification', icon: 'CalendarRange', label: 'Planification', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/rdv', icon: 'CalendarClock', label: 'Rendez-vous', requires: 'view_all_jobs', section: 'terrain' },
{ path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs', section: 'terrain' },

View File

@ -168,7 +168,7 @@
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { ref, computed, nextTick, watch, defineAsyncComponent } from 'vue'
import { useQuasar } from 'quasar'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'src/stores/auth'
@ -180,16 +180,18 @@ import {
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat,
} from 'lucide-vue-next'
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
import PhoneModal from 'src/components/customer/PhoneModal.vue'
// Composants lourds montés conditionnellement (v-if) chargés à la demande (defineAsyncComponent) au lieu d'alourdir
// le chunk MainLayout livré sur CHAQUE page. Aucun accès par ref sûr. NotificationBell reste synchrone (barre du haut, toujours visible).
const ConversationPanel = defineAsyncComponent(() => import('src/components/shared/ConversationPanel.vue'))
const PhoneModal = defineAsyncComponent(() => import('src/components/customer/PhoneModal.vue'))
import { useSoftphone } from 'src/composables/useSoftphone'
import NotificationBell from 'src/components/shared/NotificationBell.vue'
import { useConversations } from 'src/composables/useConversations'
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
import NewTicketDialog from 'src/components/shared/NewTicketDialog.vue'
import OrchestratorDialog from 'src/components/shared/OrchestratorDialog.vue'
import ServiceStatusDialog from 'src/components/shared/ServiceStatusDialog.vue'
import OutboxPanel from 'src/components/shared/OutboxPanel.vue'
const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue'))
const NewTicketDialog = defineAsyncComponent(() => import('src/components/shared/NewTicketDialog.vue'))
const OrchestratorDialog = defineAsyncComponent(() => import('src/components/shared/OrchestratorDialog.vue'))
const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue'))
const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/OutboxPanel.vue'))
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat }

View File

@ -278,7 +278,7 @@ const editorOptions = {
mergeTags: [
{ name: 'Prénom', value: '{{firstname}}', sample: 'Louis' },
{ name: 'Nom de famille', value: '{{lastname}}', sample: 'Tremblay' },
{ name: 'Courriel', value: '{{email}}', sample: 'louis@targo.ca' },
{ name: 'Courriel', value: '{{email}}', sample: 'exemple@gigafibre.ca' },
{ name: 'Adresse service', value: '{{description}}', sample: '123 Rue de la Rivière, Ste-Clotilde' },
],
},
@ -562,7 +562,7 @@ const sampleExpiryDate = new Date(Date.now() + 30 * 86400 * 1000)
.toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' })
const testSendForm = ref({
to: 'louis@targo.ca',
to: '',
subject: '[TEST] Aperçu du courriel TARGO',
via: 'gmail',
vars: {

View File

@ -16,7 +16,7 @@ const props = defineProps({
modelValue: { type: Boolean, default: false },
skills: { type: Array, default: () => [] }, // [{ skill, dur, color }]
})
const emit = defineEmits(['update:modelValue', 'select'])
const emit = defineEmits(['update:modelValue', 'select', 'plan-shifts'])
const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
const target = reactive({ address: '', latitude: null, longitude: null, ville: '' })
@ -48,6 +48,9 @@ function pick (s) {
emit('update:modelValue', false)
}
// « Aucun créneau » renvoie le parent (Planif) vers la timeline des techs (filtrée compétence) au jour cherché, pour CRÉER un quart.
function planShifts () { emit('plan-shifts', { skill: skill.value, date: afterDate.value }); emit('update:modelValue', false) }
const DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) }
</script>
@ -98,7 +101,12 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
<q-separator v-if="searched" />
<q-card-section v-if="searched" style="max-height:44vh;overflow:auto" class="q-pt-sm">
<div v-if="loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /></div>
<div v-else-if="!slots.length" class="text-grey-6 text-center q-pa-md">Aucun créneau{{ skill ? ' pour « ' + skill + ' »' : '' }} vérifie qu'un tech qualifié a un <b>quart</b> sur ces jours (les créneaux suivent les quarts réels ; week-ends réservés aux réparations), ou élargis la date.</div>
<div v-else-if="!slots.length" class="text-center q-pa-md">
<div class="text-grey-6 q-mb-sm">Aucun créneau{{ skill ? ' pour « ' + skill + ' »' : '' }} vérifie qu'un tech qualifié a un <b>quart</b> sur ces jours (les créneaux suivent les quarts réels ; week-ends réservés aux réparations), ou élargis la date.</div>
<q-btn unelevated no-caps size="sm" color="teal-8" icon="event" :label="skill ? 'Planifier des quarts pour « ' + skill + ' »' : 'Planifier des quarts'" @click="planShifts">
<q-tooltip>Ouvre la Planification (timeline des techs {{ skill ? 'de « ' + skill + ' »' : '' }}, au {{ afterDate }}) pour créer un quart des créneaux apparaîtront.</q-tooltip>
</q-btn>
</div>
<q-list v-else separator>
<q-item v-for="(s, i) in slots" :key="i" clickable @click="pick(s)" class="rounded-borders">
<q-item-section avatar><q-avatar size="34px" color="blue-grey-1" text-color="blue-grey-8" class="text-weight-bold" style="font-size:12px">{{ dayLabel(s.date).slice(0, 3) }}</q-avatar></q-item-section>

View File

@ -3,21 +3,45 @@
<!-- Real-time outage alerts -->
<OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push('/tickets')" />
<!-- KPI cards -->
<!-- KPI cards (cliquables page liée · sous-ligne = tendance/contexte) -->
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-6 col-md" v-for="stat in stats" :key="stat.label">
<div class="ops-card ops-stat">
<div class="ops-card ops-stat" :class="{ 'ops-stat-clk': stat.to }" @click="stat.to && $router.push(stat.to)">
<div class="row items-center no-wrap q-gutter-x-sm">
<q-icon :name="stat.icon" :style="{ color: stat.color }" size="22px" />
<div>
<div class="col">
<div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
<div class="ops-stat-label">{{ stat.label }}</div>
<div v-if="stat.sub" class="ops-stat-sub" :class="stat.subClass">{{ stat.sub }}</div>
</div>
<q-icon v-if="stat.to" name="chevron_right" size="18px" class="ops-stat-go" />
</div>
</div>
</div>
</div>
<!-- Charge 2 semaines : 2 bandes d'occupation (courante + prochaine), jour courant marqué (liseré indigo). Clic jour Planification (jour ciblé). -->
<div class="ops-card q-mb-lg">
<div class="row items-center q-mb-sm">
<div class="text-subtitle1 text-weight-bold">Charge <span class="text-caption text-grey-6 q-ml-xs">2 semaines</span></div>
<q-space />
<q-btn flat dense no-caps size="sm" color="primary" icon-right="chevron_right" label="Planification" @click="$router.push('/planification')" />
</div>
<template v-if="weekLoad.length">
<div class="dash-wk-lbl">Cette semaine</div>
<OccupancyStrip :days="weekLoad.slice(0, 7)" :selected="todayIso" @select="iso => $router.push({ path: '/planification', query: { day: iso } })" />
<div class="dash-wk-lbl q-mt-sm">Semaine prochaine</div>
<OccupancyStrip :days="weekLoad.slice(7, 14)" :selected="todayIso" @select="iso => $router.push({ path: '/planification', query: { day: iso } })" />
</template>
<div v-else class="text-caption text-grey-6 q-py-sm">Chargement de la charge</div>
<div class="text-caption text-grey-6 q-mt-sm row items-center q-gutter-x-sm">
<span><span class="os-leg-dot" style="background:#22c55e"></span>libre</span>
<span><span class="os-leg-dot" style="background:#f59e0b"></span>chargé</span>
<span><span class="os-leg-dot" style="background:#ef4444"></span>saturé</span>
<span v-if="weekUnplaced" class="text-warning">· <b>{{ weekUnplaced }}h</b> à répartir</span>
</div>
</div>
<!-- Admin controls -->
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-12 col-md-6">
@ -87,9 +111,9 @@
icon="credit_score"
:loading="runningPPA"
dense no-caps
@click="runPPA"
@click="confirmPPA"
>
<q-tooltip>Prélève automatiquement les cartes enregistrées</q-tooltip>
<q-tooltip>Prélève les cartes enregistrées (confirmation requise)</q-tooltip>
</q-btn>
</div>
<div class="q-mt-xs q-gutter-xs" style="min-height:20px">
@ -141,7 +165,7 @@
<q-badge v-if="todayJobs.length" :label="todayJobs.length" color="primary" class="q-ml-sm" />
</div>
<q-list separator>
<q-item v-for="j in todayJobs" :key="j.name" clickable @click="$router.push('/dispatch')">
<q-item v-for="j in todayJobs" :key="j.name" clickable @click="$router.push('/planification')">
<q-item-section avatar style="min-width:32px">
<q-icon :name="j.status === 'completed' ? 'check_circle' : j.assigned_tech ? 'person' : 'radio_button_unchecked'"
:color="j.status === 'completed' ? 'green-6' : j.assigned_tech ? 'blue-6' : 'grey-5'" size="20px" />
@ -171,23 +195,57 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { listDocs, countDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { getCapacity } from 'src/api/roster'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_SSE_URL } from 'src/config/dispatch'
import { startOfWeek, localDateStr } from 'src/composables/useHelpers'
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue'
const $q = useQuasar()
const stats = ref([
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people' },
{ label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business' },
{ label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money' },
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number' },
{ label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping' },
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people', to: '/clients', sub: '', subClass: '' },
{ label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business', to: '/clients', sub: '', subClass: '' },
{ label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money', to: '/rapports/revenus', sub: 'récurrent', subClass: 'text-grey-6' },
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number', to: '/tickets', sub: '', subClass: '' },
{ label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping', to: '/planification', sub: '', subClass: '' },
])
const openTickets = ref([])
const todayJobs = ref([])
// Charge de la semaine (bande d'occupation, réutilise /roster/capacity)
const weekLoad = ref([])
const weekUnplaced = ref(0)
const todayIso = localDateStr(new Date())
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
async function loadWeek () {
try {
const mon = startOfWeek(new Date()) // lundi de la semaine courante 2 semaines (courante + prochaine)
const { capacity } = await getCapacity(localDateStr(mon), 14)
const days = []; let unplaced = 0
for (let i = 0; i < 14; i++) {
const dt = new Date(mon); dt.setDate(mon.getDate() + i)
const iso = localDateStr(dt)
const c = capacity[iso] || {}
const h = +c.due_h || 0; const cap = +c.cap_h || 0
unplaced += +c.unplaced_h || 0
days.push({
iso, dow: FR_DOW[dt.getDay()], dnum: dt.getDate() + '/' + String(dt.getMonth() + 1).padStart(2, '0'),
h, cap, ratio: cap > 0 ? h / cap : (h > 0 ? 1 : 0), over: !!c.overbooked,
weekend: dt.getDay() === 0 || dt.getDay() === 6, noShift: cap === 0 && h > 0, today: iso === todayIso,
})
}
weekLoad.value = days
weekUnplaced.value = Math.round(unplaced)
} catch { /* strip masqué si le hub ne répond pas */ }
}
// Scheduler controls
const schedulerEnabled = ref(false)
const togglingScheduler = ref(false)
@ -345,6 +403,19 @@ async function submitDrafts () {
submittingDrafts.value = false
}
// Garde-fou : le PPA prélève IMMÉDIATEMENT les cartes enregistrées. Un double lancement peut
// charger deux fois (cf. incident 2026-06) confirmation explicite obligatoire avant d'exécuter.
function confirmPPA () {
$q.dialog({
title: 'Prélèvement automatique (PPA)',
message: 'Cette action prélève <b>immédiatement</b> les cartes enregistrées des clients éligibles. À n\'exécuter <b>qu\'une seule fois</b> par cycle — un double lancement peut charger deux fois. Continuer ?',
html: true,
ok: { label: 'Prélever maintenant', color: 'positive', noCaps: true, unelevated: true },
cancel: { label: 'Annuler', flat: true, noCaps: true },
persistent: true,
}).onOk(() => { runPPA() })
}
async function runPPA () {
runningPPA.value = true
ppaResult.value = null
@ -373,10 +444,12 @@ async function runPPA () {
onMounted(async () => {
fetchSchedulerStatus()
refreshDraftCount()
loadWeek()
const today = new Date().toISOString().slice(0, 10)
const d7 = localDateStr(new Date(Date.now() - 7 * 86400000)) // il y a 7 jours (tendance tickets)
const [clients, tickets, todayDispatch, openTix] = await Promise.all([
const [clients, tickets, todayDispatch, openTix, newTickets7d] = await Promise.all([
countDocs('Customer', { disabled: 0 }),
countDocs('Issue', { status: 'Open' }),
listDocs('Dispatch Job', {
@ -389,6 +462,7 @@ onMounted(async () => {
fields: ['name', 'subject', 'customer', 'customer_name', 'priority', 'opening_date'],
limit: 10, orderBy: 'opening_date desc',
}),
countDocs('Issue', { opening_date: ['>=', d7] }).catch(() => 0),
])
// Abonnés = unique customers with active subscriptions (via server script)
@ -415,7 +489,31 @@ onMounted(async () => {
stats.value[3].value = tickets.toLocaleString()
stats.value[4].value = todayDispatch.length.toLocaleString()
// Tendance / contexte des KPI (à partir des données déjà chargées 0 requête de plus, sauf le compte 7 j)
const urgents = openTix.filter(t => t.priority === 'Urgent' || t.priority === 'High').length
const tkParts = []
if (urgents) tkParts.push(urgents + ' urgents')
if (newTickets7d) tkParts.push('+' + newTickets7d + ' (7j)')
stats.value[3].sub = tkParts.join(' · ')
stats.value[3].subClass = urgents ? 'text-negative' : 'text-grey-6'
const unassigned = todayDispatch.filter(j => !j.assigned_tech).length
stats.value[4].sub = todayDispatch.length ? (unassigned ? unassigned + ' non assignés' : 'tous assignés ✓') : ''
stats.value[4].subClass = unassigned ? 'text-warning' : 'text-positive'
openTickets.value = openTix
todayJobs.value = todayDispatch
})
</script>
<style scoped>
/* KPI cliquable : affordance au survol + sous-ligne tendance/contexte */
.ops-stat-clk { cursor: pointer; transition: transform .12s, box-shadow .12s; }
.ops-stat-clk:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(2, 6, 23, .08); }
.ops-stat-clk .ops-stat-go { color: var(--ops-text-secondary, #94a3b8); opacity: 0; transition: opacity .12s; }
.ops-stat-clk:hover .ops-stat-go { opacity: 1; }
.ops-stat-sub { font-size: 11px; line-height: 1.2; margin-top: 1px; }
/* Légende de la bande d'occupation */
.os-leg-dot { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 3px; vertical-align: middle; }
/* Libellé de semaine au-dessus de chaque bande (« Cette semaine » / « Semaine prochaine ») */
.dash-wk-lbl { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; color: var(--ops-text-secondary, #94a3b8); margin-bottom: 4px; }
</style>

File diff suppressed because it is too large Load Diff

View File

@ -696,7 +696,6 @@
<script setup>
import { ref, computed, onMounted, watch, nextTick, reactive } from 'vue'
import cytoscape from 'cytoscape'
import { useRouter } from 'vue-router'
import { listDocs, updateDoc, createDoc } from 'src/api/erp'
import { Notify } from 'quasar'
@ -1172,6 +1171,7 @@ const mapSelectedSite = ref(null)
const mapSelectedSiteId = ref(null)
const cyContainer = ref(null)
let cyInstance = null
let cytoscape = null // chargé à la demande (lib ~350 KB) seulement quand on ouvre l'onglet Carte
const mapSelectedSiteLinks = computed(() => {
if (!mapData.value || !mapSelectedSiteId.value) return []
@ -1183,6 +1183,7 @@ async function loadNetworkMap () {
try {
const resp = await fetch(`${hubUrl}/network/map`)
mapData.value = await resp.json()
if (!cytoscape) cytoscape = (await import('cytoscape')).default // import dynamique : sort la lib du chunk NetworkPage
await nextTick()
renderCytoscape()
} catch (e) {

View File

@ -16,7 +16,6 @@
<q-separator />
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="primary" /></q-item-section><q-item-section>Générer (auto)</q-item-section></q-item>
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" color="teal" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
<q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="dashboard" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch</q-item-section></q-item>
<q-separator />
<q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item>
<q-item clickable v-close-popup @click="openCreateJob"><q-item-section avatar><q-icon name="add_task" color="teal-7" /></q-item-section><q-item-section>Créer un job</q-item-section></q-item>
@ -97,6 +96,7 @@
</q-list>
</q-menu>
</q-item>
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="indigo" /></q-item-section><q-item-section>Générer l'horaire (semaine)<q-item-label caption>solveur de quarts la semaine affichée</q-item-label></q-item-section><q-item-section side><q-spinner v-if="generating" size="16px" color="indigo" /></q-item-section></q-item>
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)<q-item-label caption>modèle plusieurs techs, N semaines</q-item-label></q-item-section></q-item>
<q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
<q-separator />
@ -105,13 +105,12 @@
<q-item clickable v-close-popup @click="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
<q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
<q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="open_in_new" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch (détails & priorités)</q-item-section></q-item>
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
</q-list>
</q-btn-dropdown>
<q-btn v-if="defaultTemplate" dense flat color="warning" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn>
<q-separator vertical class="q-mx-xs" />
<q-btn unelevated color="primary" icon="auto_awesome" label="Générer" :loading="generating" @click="doGenerate" />
<q-btn unelevated color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip class="bg-grey-9">Répartition automatique des jobs du <b>jour sélectionné</b> (distance · compétence · priorité · taux d'occupation) — revue avant d'appliquer</q-tooltip></q-btn>
<q-checkbox v-model="notifySms" label="SMS" dense size="sm"><q-tooltip>Notifier les techs par SMS à la publication</q-tooltip></q-checkbox>
<q-btn :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
<q-btn outline color="deep-orange" icon="sync_alt" label="Publier au legacy" @click="openLegacyPush"><q-tooltip class="bg-grey-9">Réassigne les tickets aux techniciens DANS le système legacy (osTicket) selon l'assignation Ops — aperçu avant d'écrire.</q-tooltip></q-btn>
@ -121,7 +120,6 @@
<!-- Rangée 2 : filtres + légende en popover DESKTOP/tablette uniquement (gt-sm) -->
<div class="row items-center q-gutter-sm q-mb-sm gt-sm">
<q-input dense outlined clearable v-model="search" placeholder="Rechercher un technicien…" style="width:230px"><template #prepend><q-icon name="search" /></template></q-input>
<q-select dense outlined clearable v-model="groupFilter" :options="groupOptions" emit-value map-options label="Équipe" style="width:180px" />
<q-input dense outlined type="number" v-model.number="maxHours" label="Max h/sem" style="width:110px" />
<span class="text-caption text-grey-6">{{ visibleTechs.length }} / {{ techs.length }} techs</span>
<q-chip v-if="cellClipboard.length" dense size="sm" color="indigo" text-color="white" icon="content_paste">{{ cellClipboard.length }} copié(s)</q-chip>
@ -408,6 +406,7 @@
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
<div v-if="visStat(d.iso).hours || estLoad(d.iso)" class="cap-strip" @click.stop>
<span class="hdr-vbar"><span class="hdr-vfill" :style="{ height: hdrFillH(d.iso) + '%', background: heatColor(hdrRatio(d.iso)) }"></span></span>
<span class="cap-seg load-cell" :class="loadClass(d.iso)">{{ estLoad(d.iso) }}/{{ visStat(d.iso).hours }}h
<q-tooltip class="bg-grey-9"><b>{{ estLoad(d.iso) }} h estimées</b> (jobs prévus) / <b>{{ visStat(d.iso).hours }} h de shift</b><template v-if="visStat(d.iso).hours"> · {{ Math.round(estLoad(d.iso) / visStat(d.iso).hours * 100) }}%</template><br><span style="opacity:.8">{{ skillFilter.length ? ('compétence(s) : ' + skillFilter.join(', ')) : 'ressources affichées' }} ({{ visibleTechs.length }} tech)</span></q-tooltip>
</span>
@ -483,8 +482,9 @@
<div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm">
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
<span class="text-caption text-grey-7">Journée :</span>
<q-btn-toggle v-model="routesDay" dense no-caps unelevated toggle-color="indigo" color="grey-3" text-color="grey-8" :options="dayList.slice(0, 7).map(d => ({ label: d.dow + ' ' + d.dnum, value: d.iso }))" />
<OccupancyStrip :days="routeStripDays" :selected="routesDay" style="flex:1 1 100%;min-width:0" @select="routesDay = $event" />
<q-space />
<q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn>
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
</div>
<!-- Chips techs : enlever/ajouter une tournée de la carte d'un clic pastille = nb de jobs, dans la couleur du tech -->
@ -495,7 +495,26 @@
<q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }}</q-tooltip>
</span>
</div>
<RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" />
<!-- Jobs DUS ce jour, NON assignés (mis en évidence) chips secteur filtrent liste+carte · clic = détail · « Suggérer » = dispatch auto -->
<div v-if="routeDayUnassigned.length" class="rt-unassigned">
<div class="rt-ua-top">
<span class="rt-ua-lbl"><q-icon name="inbox" size="15px" color="deep-orange-7" /> <b>{{ routeDayUnassignedView.length }}</b> à répartir<span v-if="routeSectorSel.size" class="text-grey-6"> / {{ routeDayUnassigned.length }}</span> :</span>
<template v-if="routeSectors.length > 1">
<button v-for="s in routeSectors" :key="s.sector" type="button" class="rt-sect" :class="{ on: routeSectorSel.has(s.sector) }" @click="toggleRouteSector(s.sector)"><q-icon name="place" size="11px" />{{ s.sector }}<span class="rt-sect-n">{{ s.n }}</span></button>
<button v-if="routeSectorSel.size" type="button" class="rt-sect rt-sect-x" @click="routeSectorSel = new Set()"> tout</button>
</template>
<q-space />
<q-btn dense unelevated no-caps size="sm" color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip>Répartir automatiquement les jobs {{ routeSectorSel.size ? 'du secteur' : 'de ce jour' }}</q-tooltip></q-btn>
</div>
<div class="rt-ua-jobs">
<span v-for="j in routeDayUnassignedView.slice(0, 16)" :key="j.name" class="rt-ua-chip" :style="{ borderLeftColor: prioColor(j.priority) }" @click="openJobDetail(j)">
<q-icon :name="skillSym(j.required_skill || j.service_type)" size="14px" :style="{ color: getTagColor(j.required_skill || j.service_type) }" />{{ j.subject || j.service_type || j.name }}<template v-if="jobCity(j)"> · {{ jobCity(j) }}</template>
<q-tooltip class="bg-grey-9">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template></q-tooltip>
</span>
<span v-if="routeDayUnassignedView.length > 16" class="text-caption text-grey-6 q-ml-xs">+{{ routeDayUnassignedView.length - 16 }} de plus</span>
</div>
</div>
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" :pins="routeUnassignedPins" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" @pin-click="p => openJobDetail(p.job)" />
<div class="suggest-legend q-mt-xs">
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }}
@ -880,7 +899,7 @@
<div class="assign-hdr" @mousedown="panelHeaderDown">
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignTypeFilter.length ? assignJobsFiltered.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space />
<q-btn v-if="assignNoCoord" flat dense no-caps size="sm" color="warning" icon="wrong_location" :label="String(assignNoCoord)" :loading="assignPanel.locating" class="q-mr-xs" @click="batchLocatePool"><q-tooltip>Localiser les {{ assignNoCoord }} job(s) sans coordonnées : adresse base RQA + GPS (ils apparaissent ensuite sur la carte)</q-tooltip></q-btn>
<q-btn flat dense no-caps size="sm" color="amber-4" icon="auto_awesome" :label="selectedNames.length ? 'Suggérer (' + selectedNames.length + ')' : 'Suggérer'" :loading="suggestDlg.building" class="q-mr-xs" @click="openSuggest"><q-tooltip>Proposer une répartition optimisée (proximité · charge · compétence){{ selectedNames.length ? ' pour la sélection' : '' }} tu revois et ajustes avant d'appliquer</q-tooltip></q-btn>
<q-btn flat dense no-caps size="sm" color="amber-4" icon="auto_awesome" :label="suggestFiltered ? 'Suggérer (' + suggestJobCount + ')' : 'Suggérer'" :loading="suggestDlg.building" class="q-mr-xs" @click="openSuggest"><q-tooltip>Proposer une répartition optimisée (proximité · charge · compétence){{ suggestFiltered ? ' — ' + suggestJobCount + ' job(s) ' + suggestScope : '' }} tu revois et ajustes avant d'appliquer</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="map" :color="assignPanel.showMap ? 'amber-4' : 'white'" @click="toggleAssignMap"><q-tooltip>Carte des jobs (regrouper par région)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
@ -1020,13 +1039,21 @@
<q-input v-model.number="solverOpts.speedKmh" type="number" dense outlined style="width:86px" label="km/h" :min="20" :max="90"><q-tooltip>Vitesse de repli (nœuds sans coordonnées)</q-tooltip></q-input>
<q-input v-model.number="solverOpts.maxSeconds" type="number" dense outlined style="width:92px" label="Calcul s" :min="2" :max="30"><q-tooltip>Budget de calcul par jour</q-tooltip></q-input>
</div>
<!-- JOUR CIBLÉ (une seule journée) : les jobs en retard / sans date sont tirés sur ce jour. Re-simuler aujourd'hui = OK (non destructif). -->
<!-- FENÊTRE de répartition = les CHIPS de date cochés dans le pool (ou la sélection lasso). Sinon un jour choisi ici. -->
<div class="row items-center q-mb-sm" style="gap:6px;color:#475569;background:#eef2ff;border-radius:6px;padding:5px 8px">
<q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir pour :</span>
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === todayISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === todayISO() ? 'white' : 'grey-8'" label="Aujourd'hui" @click="suggestDay = todayISO()" />
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === tomorrowISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === tomorrowISO() ? 'white' : 'grey-8'" label="Demain" @click="suggestDay = tomorrowISO()" />
<q-input dense outlined type="date" v-model="suggestDay" style="width:150px" />
<q-space /><span class="text-caption text-grey-6">jobs en retard/sans date tirés sur ce jour</span>
<q-icon name="event" size="16px" color="primary" /><span class="text-caption text-weight-medium">Répartir :</span>
<template v-if="suggestFiltered">
<span class="text-caption text-weight-medium text-indigo-8">{{ suggestJobCount }} job(s) {{ suggestScope }}</span>
<q-icon name="arrow_forward" size="13px" color="grey-6" /><span class="text-caption text-grey-7">{{ suggestWindow.map(windowDayLabel).join(', ') }}</span>
<q-space /><span class="text-caption text-grey-6">{{ selectedNames.length ? 'sélection lasso' : 'ajuste les chips sous le pool' }}</span>
</template>
<template v-else>
<span class="text-caption">pour :</span>
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === todayISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === todayISO() ? 'white' : 'grey-8'" label="Aujourd'hui" @click="suggestDay = todayISO()" />
<q-btn dense unelevated size="sm" no-caps :color="suggestDay === tomorrowISO() ? 'primary' : 'grey-4'" :text-color="suggestDay === tomorrowISO() ? 'white' : 'grey-8'" label="Demain" @click="suggestDay = tomorrowISO()" />
<q-input dense outlined type="date" v-model="suggestDay" style="width:150px" />
<q-space /><span class="text-caption text-grey-6">{{ suggestJobCount }} job(s) de ce jour · retards exclus (chip « en retard » pour les inclure)</span>
</template>
</div>
<div class="row items-center q-mb-xs" style="gap:4px">
<span class="text-caption text-grey-7 q-mr-xs">Rapide :</span>
@ -1334,6 +1361,16 @@
<div v-if="jobDetail.skill"><q-icon name="construction" size="16px" /> {{ jobDetail.skill }}</div>
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
</div>
<!-- Suivi terrain (géofencing GPS) : étapes façon suivi de colis En route Arrivé Reparti, avec l'heure atteinte. -->
<div v-if="jobDetail.geofence && jobDetail.geofence.state" class="jd-geo">
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="my_location" size="18px" class="q-mr-xs" color="teal-7" /> Suivi terrain (GPS)</div>
<div class="jd-geo-track">
<div v-for="s in geoTimeline" :key="s.k" class="jd-geo-step" :class="{ done: s.done, current: s.current }">
<div class="jd-geo-ic"><q-icon :name="s.icon" size="15px" /></div>
<div class="jd-geo-lbl">{{ s.label }}<span v-if="s.at" class="jd-geo-at">{{ s.at }}</span></div>
</div>
</div>
</div>
<div v-if="jobDetail.canTeam" class="jd-team">
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="group" size="18px" class="q-mr-xs" /> Équipe / renfort<q-space /><q-spinner v-if="jobDetail.teamLoading" size="16px" color="deep-purple" /></div>
<div v-if="jobDetail.team && jobDetail.team.length" class="row items-center q-gutter-xs q-mb-xs">
@ -1743,7 +1780,7 @@
<UnifiedCreateModal v-model="createJobOpen" mode="work-order" :context="createJobCtx"
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" />
<!-- « Trouver un créneau » chips compétence (durée par défaut) + adresse choisit tech+date+heure puis pré-remplit la création -->
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" />
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" @plan-shifts="onPlanShifts" />
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment -->
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
</q-page>
@ -1769,6 +1806,7 @@
* 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor
*/
import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router' // deep-link ?day=&skill= (tableau de bord, slot-finder)
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
@ -1777,7 +1815,7 @@ import * as roster from 'src/api/roster'
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>)
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
import { relTime } from 'src/composables/useFormatters' // temps relatif COHÉRENT (même formateur que la Boîte)
import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations
@ -1787,6 +1825,7 @@ import TechSelect from 'src/components/shared/TechSelect.vue'
import SkillSelect from 'src/components/shared/SkillSelect.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) Dispatch déprécié, tout ici
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » pré-remplit la création (tech+date+heure+adresse)
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) repris/amélioré depuis Dispatch
@ -1857,6 +1896,9 @@ const estLoadByDay = computed(() => {
})
function estLoad (iso) { return estLoadByDay.value[iso] || 0 }
function loadClass (iso) { const est = estLoad(iso), dispo = visStat(iso).hours || 0; if (!dispo) return est > 0 ? 'cap-full' : 'cap-none'; const r = est / dispo; return r > 1 ? 'cap-full' : (r > 0.85 ? 'cap-low' : 'cap-ok') } // vert/orange/rouge selon est/dispo
// Barre verticale d'occupation de l'en-tête desktop (miroir de la bande mobile) : ratio charge estimée / heures de quart.
function hdrRatio (iso) { const cap = visStat(iso).hours || 0, load = estLoad(iso) || 0; return cap > 0 ? load / cap : (load > 0 ? 1 : 0) }
function hdrFillH (iso) { return (estLoad(iso) || 0) > 0 ? Math.max(12, Math.min(100, Math.round(hdrRatio(iso) * 100))) : 0 }
// Vue « jobs prévus en en-tête de jour » : jobs NON assignés ayant une date, groupés par jour, glissables sur une case
// tech×jour (réutilise onJobDragStart/onCellDrop assigne + replanifie à la date de la case). Suit le filtre compétence.
const showDayJobs = ref(false)
@ -2121,7 +2163,7 @@ function exportGpx (techId) {
window.open(roster.gpxUrl(techId, from, to), '_blank')
}
// Détails d'un job : double-clic sur un bloc grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour.
const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', time: '', detail: '', lid: null, dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null })
const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', time: '', detail: '', lid: null, dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, geofence: null })
// Décode les entités HTML (« d&#039;équipement » « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null
function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value }
@ -2129,9 +2171,21 @@ async function openJobDetail (b, t) {
if (!b) return
jdShowTrack.value = false
Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || ''), address: deEnt(b.address || ''), skill: b.skill || '', time: (b.start || (b.s != null ? fmtH(b.s) : '')) + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : ''), detail: deEnt(b.detail || ''), lid: b.legacy_id || null, dept: b.dept || '', techId: (t && t.id) || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : null, lon: b.lon != null ? +b.lon : null, loading: !!b.legacy_id, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null })
// Géofencing (suivi façon colis) : timeline En route Arrivé Reparti, non bloquant.
jobDetail.geofence = null
if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {})
if (b.legacy_id) { try { jobDetail.thread = await roster.ticketThread(b.legacy_id) } catch (e) { jobDetail.thread = { error: true, messages: [] } } finally { jobDetail.loading = false } }
if (jobDetail.canTeam) { jobDetail.teamLoading = true; try { const r = await roster.getJobTeam(b.name); jobDetail.team = r.assistants || [] } catch (e) { jobDetail.team = [] } finally { jobDetail.teamLoading = false } }
}
// Timeline géofencing pour l'affichage (façon suivi de colis) : 4 étapes + heure atteinte.
const GEO_STEPS = [{ k: 'assigned', label: 'Assigné', icon: 'assignment_ind' }, { k: 'en_route', label: 'En route', icon: 'directions_car' }, { k: 'on_site', label: 'Arrivé sur place', icon: 'location_on' }, { k: 'departed', label: 'Reparti', icon: 'check_circle' }]
const geoTimeline = computed(() => {
const gf = jobDetail.geofence; const events = (gf && gf.events) || []
const atOf = (k) => { const e = [...events].reverse().find(x => x.status === k); return e ? new Date(e.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Toronto' }) : null }
const order = { assigned: 0, en_route: 1, on_site: 2, departed: 3 }
const curIdx = order[(gf && gf.state) || 'assigned'] ?? 0
return GEO_STEPS.map((s, i) => ({ ...s, at: s.k === 'assigned' ? null : atOf(s.k), done: i <= curIdx, current: i === curIdx }))
})
// Options du sélecteur d'assistant : tous les techs sauf le lead courant + ceux déjà dans l'équipe.
const jdTeamOptions = computed(() => { const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)]); return (techs.value || []).filter(t => !taken.has(t.id)).map(t => ({ label: t.name, value: { id: t.id, name: t.name } })) })
async function jdAddAssistant () {
@ -2351,8 +2405,20 @@ 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
function openSchedGenBulk () { schedGenTechs.value = (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name })); schedGenOpen.value = true } // LOT : tous les techs visibles
const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
async function onScheduleApply ({ schedule, weeks, techIds }) {
async function onScheduleApply ({ schedule, weeks, techIds, mode }) {
const targets = (schedGenTechs.value || []).filter(t => (techIds || []).includes(t.id)); if (!targets.length) return
// RÉCURRENT : le patron devient la SOURCE (weekly_schedule) matérialisation auto (fériés/vacances sautés, manuels préservés)
if (mode === 'recurring') {
const patt = {}; for (const d of schedule) patt[d.dow] = d.on ? { start: d.start, end: d.end } : null
let okT = 0
for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++ } catch (e) {} }
let mat = null; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { err(e) }
try { await loadWeek() } catch (e) {}
$q.notify({ type: okT ? 'positive' : 'warning', icon: 'event_repeat', message: 'Horaire récurrent défini · ' + okT + ' tech' + (mat ? ' → ' + mat.created + ' quart(s) sur ' + weeks + ' sem.' + (mat.skipped_holiday ? ' (' + mat.skipped_holiday + ' férié(s) sauté(s))' : '') : ''), timeout: 4800 })
schedGenTechs.value = []
return
}
// EXCEPTION : override ponctuel (source='manuel', préservé par la matérialisation) sur N semaines dès la semaine affichée
let created = 0, failed = 0; const tplByKey = {}
const base = start.value // lundi de la semaine affichée
for (const t of targets) {
@ -2552,10 +2618,18 @@ const assignDates = computed(() => {
function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] }
// Depuis l'aperçu « jobs sur la colonne du jour » ouvre le panneau « À assigner » filtré sur ce jour.
async function openDayInPanel (iso) { await openAssignPanel(); assignSort.value = 'date'; assignDateFilter.value = [iso] }
// Un job correspond-il aux CHIPS de date sélectionnées ? (vide = toutes). '__overdue__' = toutes dates passées.
// Partagé par le filtre d'affichage ET le dispatch auto (« Suggérer n'utilise que les jobs des jours cochés »).
function jobMatchesDateChips (j) {
const df = assignDateFilter.value; if (!df.length) return true
const ds = new Set(df); const t = todayISO()
const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'
return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t)
}
const assignJobsFiltered = computed(() => {
let arr = assignPanel.jobs
const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) }
const df = assignDateFilter.value; if (df.length) { const ds = new Set(df); const t = todayISO(); arr = arr.filter(j => { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t) }) } // '__overdue__' = toutes dates passées
if (assignDateFilter.value.length) arr = arr.filter(jobMatchesDateChips)
return arr
})
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
@ -3174,7 +3248,7 @@ function gotoDispatch (t, dateIso) {
const q = {}
if (t) q.tech = t.id
q.date = dateIso || (timelineDays.value[0] && timelineDays.value[0].iso) || start.value
router.push({ path: '/dispatch', query: q })
router.push({ path: '/planification', query: q }) // page Dispatch retirée reste sur Planification
}
// Éditeur de JOURNÉE (fenêtre contextuelle ciblée clic sur le progressbar)
// Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job.
@ -3705,7 +3779,7 @@ const priorityScores = computed(() => {
function techScore (t) { const v = priorityScores.value[t.id]; return v == null ? 0 : v }
function techRank (t) { if (!skillFilter.value.length) return null; const i = visibleTechs.value.findIndex(x => x.id === t.id); return i >= 0 ? i + 1 : null }
const visibleTechs = computed(() => {
const q = search.value.trim().toLowerCase()
const q = (search.value || '').trim().toLowerCase() // clearable (X) met search=null null-safe (sinon le computed plante et le champ « ne se vide pas »)
const list = techs.value.filter(t => (showHidden.value || !isHidden(t.id)) && (!groupFilter.value || t.group === groupFilter.value) && techHasSkill(t) && (!q || (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q)))
// Filtre par compétence actif on TRIE par priorité (meilleur score d'abord) ; sinon ordre équipe/nom.
if (skillFilter.value.length) return list.slice().sort((a, b) => techScore(a) - techScore(b) || (a.name || '').localeCompare(b.name || ''))
@ -3932,7 +4006,7 @@ function hasShiftDay (techId, iso) {
// Vue mobile « bande de jours + heat » (téléphone) : charge agrégée par jour + par tech, lecture rapide.
const todayIso = computed(() => (nowET.value && nowET.value.iso) || new Date().toISOString().slice(0, 10))
function heatColor (ratio) { const r = +ratio || 0; if (r >= 1) return '#ef4444'; if (r >= 0.75) return '#f59e0b'; if (r >= 0.45) return '#84cc16'; return '#22c55e' }
// heatColor composables/useHelpers.js (source unique, partagé avec <OccupancyStrip> + tableau de bord)
const _CAP_H = 8 // capacité indicative par tech-jour (h) pour normaliser les barres
const mobileSelIso = ref(null)
// Bande de jours mobile = fenêtre LONGUE (4 semaines) carrousel horizontal CONTINU (sans flèches).
@ -4221,8 +4295,7 @@ function jobDur (j) { return (j && (j.est_min ? j.est_min / 60 : Number(j.durati
function buildSuggestion () {
const useSel = selectedNames.value.length > 0
suggestDlg.fromSel = useSel
let jobs = (assignPanel.jobs || []).filter(j => j.status !== 'On Hold')
if (useSel) jobs = jobs.filter(j => selectedJobs[j.name])
const jobs = suggestJobs() // lasso si actif, sinon pool filtré par les chips de date (jours cochés)
const days = (dayList.value || []); const dayIsos = days.map(d => d.iso)
const techs = (visibleTechs.value || []).filter(t => suggestDlg.techSel[t.id]) // seulement les techs choisis (disponibles ce jour)
if (!jobs.length || !dayIsos.length || !techs.length) { suggestDlg.plan = []; return }
@ -4335,19 +4408,50 @@ function buildSuggestion () {
suggestDlg.plan = plan
}
function openSuggest () {
suggestDay.value = mobileSelIso.value || kbSelIso.value || todayISO() // jour ciblé = celui sélectionné au tableau (défaut auj.)
// Jour de base = la sélection de la vue active (strip Tournées, Jour kanban, ou bande mobile) « Suggérer se base sur la journée sélectionnée »
suggestDay.value = (boardView.value === 'routes' && routesDay.value) || (boardView.value === 'kanban' && kbSelIso.value) || mobileSelIso.value || routesDay.value || kbSelIso.value || todayISO()
suggestDlg.fromSel = selectedNames.value.length > 0
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart CE JOUR (= disponibles) ; sinon tous les visibles.
// Étape « disponibilité » d'abord : défaut = techs AVEC un quart sur un des jours de la fenêtre (= disponibles) ; sinon tous les visibles.
const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur
const techs = visibleTechs.value || []
const withShift = techs.filter(t => hasShiftDay(t.id, suggestDay.value))
const withShift = techs.filter(t => win.some(iso => hasShiftDay(t.id, iso)))
const base = withShift.length ? withShift : techs
const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel
suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true
}
const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length)
// Dispatch auto = UNE SEULE journée (celle sélectionnée). Les jobs en retard / sans date sont tirés sur CE jour. Non destructif : on revoit avant d'appliquer (re-simuler aujourd'hui = OK).
// Dispatch auto : la FENÊTRE = les CHIPS de date cochées dans le pool (jours à répartir). Sinon (aucun chip) le jour choisi dans le sélecteur.
// Les chips « en retard » / « Sans date » ne sont pas des jours calendaires les jobs correspondants sont placés AUJOURD'HUI. Non destructif (revue avant d'appliquer).
const suggestDay = ref('')
const suggestWindow = computed(() => [suggestDay.value || todayISO()])
const suggestWindow = computed(() => {
const f = assignDateFilter.value || []
if (!f.length) return [suggestDay.value || todayISO()] // aucun chip coché sélecteur de jour du dialogue
const days = new Set(f.filter(iso => iso !== '__overdue__' && iso !== 'Sans date')) // jours concrets cochés (auj./futur)
if (f.includes('__overdue__') || f.includes('Sans date') || !days.size) days.add(todayISO()) // en retard / sans date placés aujourd'hui
return [...days].sort()
})
// Jobs que « Suggérer » doit répartir : la sélection lasso si active, sinon « ce que je vois » = le pool filtré
// par les CHIPS de TYPE **et** de DATE (assignJobsFiltered). Ex. décocher « téléphonie » non réparti aux techs terrain.
const suggestJobs = () => {
if (selectedNames.value.length) return (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && selectedJobs[j.name]) // sélection lasso explicite
let jobs = (assignJobsFiltered.value || []).filter(j => j.status !== 'On Hold') // pool tel qu'affiché (type + date)
// Par DÉFAUT (aucun chip de date coché) : ne répartir QUE les jobs DATÉS sur le(s) jour(s) de la fenêtre jamais les
// jobs en retard/sans date/d'autres jours (souvent faits mais pas encore fermés) qui pollueraient la simulation.
// (Pour dispatcher les retards, cocher explicitement le chip « en retard ».)
if (!assignDateFilter.value.length) { const win = new Set(suggestWindow.value); jobs = jobs.filter(j => win.has(j.scheduled_date)) }
return jobs
}
const suggestJobCount = computed(() => suggestJobs().length)
// Y a-t-il un filtre restreignant le dispatch ? (lasso, chips de date ou de type)
const suggestFiltered = computed(() => !!(selectedNames.value.length || assignDateFilter.value.length || assignTypeFilter.value.length))
// Libellé du périmètre : « sélectionnés » (lasso) · « des jours/types cochés » (chips) décrit ce qui limite le jeu de jobs.
const suggestScope = computed(() => {
if (selectedNames.value.length) return 'sélectionnés'
const parts = []
if (assignDateFilter.value.length) parts.push('jours')
if (assignTypeFilter.value.length) parts.push('types')
return parts.length ? ('des ' + parts.join(' + ') + ' cochés') : 'du pool'
})
const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso))
const suggestWindowLabel = computed(() => {
const w = suggestWindow.value; if (!w.length) return '—'
@ -4424,6 +4528,7 @@ async function optimizeSuggestion () {
try {
const r = await roster.optimizePlanHub({
dates: suggestWindow.value, tech_ids: selTechs.map(t => t.id),
job_names: suggestJobs().map(j => j.name), // ne répartir QUE les jobs des jours cochés (ou la sélection lasso)
opts: { rank_weight: solverOpts.rankWeight, speed_kmh: solverOpts.speedKmh, max_seconds: solverOpts.maxSeconds, overtime_coef: solverOpts.overtimeCoef },
dur_overrides: { ...durOverride }, job_windows,
})
@ -4597,6 +4702,48 @@ const suggestRoutes = computed(() => {
watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || '' })
// P3 Onglet « Tournées » : tournées RÉELLES (jobs assignés, occupation) de la journée choisie, 1 couleur/tech, tracés OSRM (RouteMap).
const routesDay = ref(null)
// Sélecteur de jour (Tournées) = bande d'occupation sur 2 SEMAINES (14 j depuis le lundi affiché) par défaut.
// Données via /roster/capacity (14 j) DÉCOUPLÉ de la fenêtre de charge du grid (7 j) barres réelles même en semaine 2.
const routeCapacity = ref({})
async function loadRouteCapacity () { try { const r = await roster.getCapacity(start.value, 14); routeCapacity.value = r.capacity || {} } catch (e) { routeCapacity.value = {} } }
watch([boardView, start], () => { if (boardView.value === 'routes') loadRouteCapacity() }, { immediate: true })
const routeStripDays = computed(() => {
const out = []
for (let i = 0; i < 14; i++) {
const iso = addDaysISO(start.value, i); const c = routeCapacity.value[iso] || {}
const h = +c.due_h || 0; const cap = +c.cap_h || 0; const dw = dowOf(iso)
out.push({ iso, dow: FR_DOW[dw], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dw === 0 || dw === 6, holiday: isHoliday(iso), h, cap, ratio: cap > 0 ? h / cap : (h > 0 ? 1 : 0), over: !!c.overbooked, today: iso === todayISO(), unassigned: (unassignedByDay.value[iso] || []).length })
}
return out
})
// Jobs DUS le jour sélectionné (Tournées) mais NON assignés mis en évidence + « Suggérer ». Respecte le filtre de compétence (unassignedByDay).
const routeDayUnassigned = computed(() => unassignedByDay.value[routesDay.value] || [])
// Chips SECTEUR (par ville) des non-assignés du jour filtre la liste ET la carte. Reset au changement de jour.
const routeSectorSel = ref(new Set())
watch(routesDay, () => { routeSectorSel.value = new Set() })
const routeSectors = computed(() => { const m = {}; for (const j of routeDayUnassigned.value) { const c = jobCity(j) || 'Sans secteur'; m[c] = (m[c] || 0) + 1 } return Object.entries(m).map(([sector, n]) => ({ sector, n })).sort((a, b) => b.n - a.n) })
function toggleRouteSector (s) { const set = new Set(routeSectorSel.value); if (set.has(s)) set.delete(s); else set.add(s); routeSectorSel.value = set }
const routeDayUnassignedView = computed(() => { const sel = routeSectorSel.value; return sel.size ? routeDayUnassigned.value.filter(j => sel.has(jobCity(j) || 'Sans secteur')) : routeDayUnassigned.value })
const _jlat = j => (j.latitude != null ? +j.latitude : (j.lat != null ? +j.lat : null))
const _jlon = j => (j.longitude != null ? +j.longitude : (j.lon != null ? +j.lon : null))
// Gouttes carte = non-assignés du jour (filtrés secteur) qui ont des coordonnées ; couleur = priorité
const routeUnassignedPins = computed(() => routeDayUnassignedView.value.filter(j => { const la = _jlat(j); const lo = _jlon(j); return la != null && lo != null && isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01 }).map(j => ({ lat: _jlat(j), lon: _jlon(j), subject: j.subject || j.service_type || j.name, name: j.name, color: prioColor(j.priority), city: jobCity(j), job: j })))
// Deep-link (?day=YYYY-MM-DD & ?skill=) + action « planifier des quarts » du slot-finder
const route = useRoute()
function matchSkill (skill) { return skill ? (allSkills.value || []).find(s => String(s).toLowerCase() === String(skill).toLowerCase()) : null }
function focusDay (iso, skill) { // cadre la semaine sur le jour, le sélectionne (grille/mobile/tournées), filtre compétence si connue
if (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) { start.value = mondayISO(iso); mobileSelIso.value = iso; routesDay.value = iso }
const m = matchSkill(skill); if (m) skillFilter.value = [m]
}
// « Aucun créneau » « Planifier des quarts » : ouvre la timeline des techs (filtrée compétence) au bon jour pour créer des quarts.
function onPlanShifts ({ skill, date } = {}) {
showSuggestSlots.value = false
boardView.value = 'grid' // vue timeline = voir/créer les quarts
focusDay(date, skill)
const m = matchSkill(skill)
$q.notify({ type: 'info', icon: 'event_busy', timeout: 4500, message: m ? `Techs « ${m} » — crée un quart ce jour pour ouvrir des créneaux` : (skill ? `Aucun tech n'a « ${skill} » comme compétence — ajoute-la à un tech ou crée un quart` : 'Crée un quart pour ouvrir des créneaux') })
}
const routesMapRef = ref(null)
const dayRouteMetrics = reactive({})
const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic)
@ -4615,6 +4762,23 @@ const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs
return out
})
const dayRoutes = computed(() => allDayRoutes.value.filter(r => !hiddenRouteTechs.value.has(r.id))) // ce que la carte affiche
// Positions GPS LIVE des techs (onglet Tournées) : polling doux (25 s) tant que l'onglet est ouvert ; couleur = celle de la tournée du tech
const livePosRaw = ref([]) // [{ techId, techName, lat, lon, time, speed }]
const showLivePos = ref(true) // afficher/masquer les positions GPS live sur la carte des tournées
let _livePosTimer = null
async function refreshLivePositions () { try { const r = await roster.techPositions(); livePosRaw.value = (r && r.positions) || [] } catch (e) { /* non bloquant */ } }
function startLivePositions () { if (_livePosTimer) return; refreshLivePositions(); _livePosTimer = setInterval(refreshLivePositions, 25000) }
function stopLivePositions () { if (_livePosTimer) { clearInterval(_livePosTimer); _livePosTimer = null } }
// Positions pour la carte : masque les techs cachés (chips), colore selon leur tournée du jour (sinon gris neutre).
const dayLivePositions = computed(() => {
const colorByTech = {}; for (const r of (allDayRoutes.value || [])) colorByTech[r.id] = r.color
const visIds = new Set((visibleTechs.value || []).map(t => t.id)) // filtre de compétence : seules les positions des techs capables (+ non masqués) sur la carte
return (livePosRaw.value || [])
.filter(p => visIds.has(p.techId) && !hiddenRouteTechs.value.has(p.techId))
.map(p => ({ techId: p.techId, name: p.techName, lat: p.lat, lon: p.lon, time: p.time, speed: p.speed, color: colorByTech[p.techId] || '#455a64' }))
})
watch(boardView, (v) => { if (v === 'routes') startLivePositions(); else stopLivePositions() }, { immediate: true })
watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() })
watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true })
// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées).
@ -5385,8 +5549,8 @@ function onLegacyUpdate (data) {
}
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); if (_kbRO) _kbRO.disconnect() })
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); stopLivePositions(); if (_kbRO) _kbRO.disconnect() })
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
</script>
@ -5410,7 +5574,10 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
/* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */
.cap-strip { display: flex; gap: 2px; justify-content: center; align-items: center; margin-top: 2px; }
.cap-strip { display: flex; gap: 3px; justify-content: center; align-items: center; margin-top: 2px; }
/* Barre verticale d'occupation (miroir de la bande mobile) dans l'en-tête desktop */
.hdr-vbar { width: 7px; height: 22px; background: #eef2f7; border-radius: 4px; overflow: hidden; display: inline-flex; align-items: flex-end; flex: 0 0 auto; }
.hdr-vfill { width: 100%; border-radius: 4px; transition: height .3s; }
.cap-seg { font-size: 9px; font-weight: 700; line-height: 1; min-width: 15px; padding: 2px 2px; border-radius: 3px; text-align: center; cursor: default; }
.cap-ok { background: #c8e6c9; color: #1b5e20; }
.cap-low { background: #ffe0b2; color: #e65100; }
@ -5506,6 +5673,17 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.rt-chip.off { opacity: 0.55; border-style: dashed; }
.rt-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.rt-n { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; border-radius: 9px; padding: 0 4px; color: #fff; font-size: 10px; font-weight: 800; }
/* Bande « jobs dus non assignés » (vue Tournées) : chips secteur (filtrent liste+carte) + chips job + Suggérer */
.rt-unassigned { margin-bottom: 6px; padding: 6px 8px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
.rt-ua-top { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
.rt-ua-jobs { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; margin-top: 5px; max-height: 70px; overflow-y: auto; }
.rt-ua-lbl { font-size: 12px; color: #9a3412; display: inline-flex; align-items: center; gap: 3px; }
.rt-sect { display: inline-flex; align-items: center; gap: 2px; padding: 2px 8px; border: 1px solid #cbd5e1; border-radius: 12px; background: #fff; font-size: 11.5px; font-weight: 600; color: #475569; cursor: pointer; user-select: none; }
.rt-sect.on { background: #6366f1; border-color: #6366f1; color: #fff; }
.rt-sect-n { font-weight: 800; margin-left: 2px; opacity: .85; }
.rt-sect-x { border-style: dashed; color: #94a3b8; }
.rt-ua-chip { display: inline-flex; align-items: center; gap: 3px; max-width: 230px; padding: 2px 8px 2px 6px; border: 1px solid #e2e8f0; border-left: 3px solid #9e9e9e; border-radius: 6px; background: #fff; font-size: 11.5px; color: #334155; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rt-ua-chip:hover { border-color: #6366f1; }
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
.jt-chip:hover { border-color: #6366f1; }
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }
@ -5645,6 +5823,19 @@ tr.res-hidden .hide-eye { opacity: 1; }
.jd-meta > div { display: flex; align-items: center; gap: 6px; }
.jd-detail { margin-top: 10px; padding: 8px 10px; background: #f6f8fb; border-radius: 6px; white-space: pre-wrap; font-size: 12.5px; color: #333; }
.jd-team { margin-top: 12px; padding: 10px; border: 1px solid #e6e2f2; background: #faf9fe; border-radius: 8px; }
/* Suivi terrain (géofencing) : étapes horizontales façon suivi de colis. */
.jd-geo { margin-top: 12px; padding: 10px 8px; border: 1px solid #d6eeeb; background: #f3fbfa; border-radius: 8px; }
.jd-geo-track { display: flex; align-items: flex-start; }
.jd-geo-step { flex: 1; text-align: center; position: relative; }
.jd-geo-step::before { content: ''; position: absolute; top: 13px; left: -50%; width: 100%; height: 2px; background: #cfe3e0; z-index: 0; }
.jd-geo-step:first-child::before { display: none; }
.jd-geo-step.done::before { background: #26a69a; }
.jd-geo-ic { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: #e0e0e0; color: #fff; }
.jd-geo-step.done .jd-geo-ic { background: #26a69a; }
.jd-geo-step.current .jd-geo-ic { box-shadow: 0 0 0 3px rgba(38,166,154,.3); }
.jd-geo-lbl { font-size: 10.5px; color: #607d8b; margin-top: 3px; line-height: 1.25; }
.jd-geo-step.done .jd-geo-lbl { color: #37474f; font-weight: 600; }
.jd-geo-at { display: block; font-size: 10px; color: #26a69a; font-weight: 700; }
.jd-map { width: 100%; height: 240px; border-radius: 8px; overflow: hidden; margin-bottom: 8px; background: #eceff3; }
.jd-track-row { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; }
.dt-toggle { cursor: pointer; margin-left: 8px; padding: 0 7px; border-radius: 10px; border: 1px solid #ef6c00; color: #ef6c00; font-weight: 600; white-space: nowrap; }

View File

@ -30,7 +30,7 @@ import { fetchARReport } from 'src/api/reports'
import { formatMoney } from 'src/composables/useFormatters'
import { exportCsv } from 'src/composables/useCsvExport'
import ReportScaffold from 'src/components/shared/ReportScaffold.vue'
import Chart from 'chart.js/auto'
let Chart = null // chart.js ~90 KB chargé à la demande au 1er rendu de graphique (hors du chunk de la page)
const asOfDate = ref(new Date().toISOString().slice(0, 10))
const loading = ref(false)
@ -83,9 +83,10 @@ async function loadReport () {
}
}
function renderChart () {
async function renderChart () {
if (chartInstance) chartInstance.destroy()
if (!chartCanvas.value || !summary.value) return
if (!Chart) Chart = (await import('chart.js/auto')).default
const s = summary.value
chartInstance = new Chart(chartCanvas.value, {
type: 'doughnut',

View File

@ -104,7 +104,7 @@ import { fetchRevenueReport } from 'src/api/reports'
import { formatMoney } from 'src/composables/useFormatters'
import StatCard from 'src/components/shared/StatCard.vue'
import DataTable from 'src/components/shared/DataTable.vue'
import Chart from 'chart.js/auto'
let Chart = null // chart.js ~90 KB chargé à la demande au 1er rendu de graphique (hors du chunk de la page)
const now = new Date()
const startDate = ref(new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10))
@ -197,9 +197,10 @@ const CHART_COLORS = [
'#a855f7', '#3b82f6', '#d946ef', '#0ea5e9', '#f43f5e',
]
function renderChart () {
async function renderChart () {
if (chartInstance) chartInstance.destroy()
if (!chartCanvas.value || !chartAccounts.value.length) return
if (!Chart) Chart = (await import('chart.js/auto')).default
const months = data.value.months || []
const labels = months.map(m => {

View File

@ -73,7 +73,7 @@ import { fetchTaxReport } from 'src/api/reports'
import { formatMoney } from 'src/composables/useFormatters'
import { exportCsv } from 'src/composables/useCsvExport'
import ReportScaffold from 'src/components/shared/ReportScaffold.vue'
import Chart from 'chart.js/auto'
let Chart = null // chart.js ~90 KB chargé à la demande au 1er rendu de graphique (hors du chunk de la page)
const now = new Date()
const startDate = ref(new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10))
@ -136,9 +136,10 @@ async function loadReport () {
}
}
function renderChart () {
async function renderChart () {
if (chartInstance) chartInstance.destroy()
if (!chartCanvas.value || !periods.value.length) return
if (!Chart) Chart = (await import('chart.js/auto')).default
const labels = periods.value.map(p => p.label)
chartInstance = new Chart(chartCanvas.value, {
type: 'bar',

View File

@ -73,12 +73,13 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, defineAsyncComponent } from 'vue'
import { useQuasar } from 'quasar'
import { listFlowTemplates, getFlowTemplate, updateFlowTemplate } from 'src/api/flow-templates'
import { HUB_URL } from 'src/config/hub'
import TaskDependencyGraph from 'src/components/flow-editor/TaskDependencyGraph.vue'
import TaskGantt from 'src/components/flow-editor/TaskGantt.vue'
// Vue Gantt (hy-vue-gantt, lourd) rendu seulement dans la vue « gantt » chargé à la demande.
const TaskGantt = defineAsyncComponent(() => import('src/components/flow-editor/TaskGantt.vue'))
const $q = useQuasar()
const view = ref('dag')

View File

@ -47,7 +47,7 @@ const routes = [
{ path: 'theme', component: () => import('src/pages/ThemePage.vue') },
{ path: 'facturation/approbations', component: () => import('src/pages/BillingApprovalsPage.vue') },
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
{ path: 'dispatch', redirect: '/planification' }, // page Dispatch retirée → redirige (anciens signets)
{ path: 'historique', component: () => import('src/pages/HistoriquePage.vue') },
{ path: 'evaluations', component: () => import('src/pages/EvaluationsPage.vue') },
{ path: 'planification', component: () => import('src/pages/PlanificationPage.vue') },

View File

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getLoggedUser, logout } from 'src/api/auth'
import { getLoggedUser, logout, getAuthUsername } from 'src/api/auth'
import { usePermissions } from 'src/composables/usePermissions'
export const useAuthStore = defineStore('auth', () => {
@ -12,10 +12,10 @@ export const useAuthStore = defineStore('auth', () => {
loading.value = true
try {
user.value = await getLoggedUser()
// Load permissions from targo-hub using the user's email
if (user.value && user.value !== 'authenticated') {
await loadPermissions(user.value)
}
// Permissions depuis le hub : par courriel, avec REPLI par username Authentik (whoami sans courriel /
// courriel non provisionné). Si les deux sont vides → permissions non chargées → App.vue affiche « fail-loud ».
const email = (user.value && user.value !== 'authenticated') ? user.value : ''
await loadPermissions(email, getAuthUsername())
} catch {
user.value = 'authenticated'
} finally {

View File

@ -59,6 +59,15 @@ async function findUserByEmail (email) {
return user ? { user } : { error: 'User not found: ' + email, status: 404 }
}
// Repli : quand le courriel ne correspond à aucun compte OPS (courriel vide côté whoami, ou différent de
// celui provisionné), on retrouve l'utilisateur par son username Authentik (transmis aussi par whoami).
async function findUserByUsername (username) {
const r = await akFetch('/core/users/?search=' + encodeURIComponent(username) + '&page_size=5')
if (r.status !== 200) return { error: 'Authentik user lookup failed', status: 502 }
const user = r.data.results?.find(u => u.username?.toLowerCase() === username.toLowerCase())
return user ? { user } : { error: 'User not found: ' + username, status: 404 }
}
function validatePermissions (body) {
const validKeys = new Set(CAPABILITIES.map(c => c.key))
const result = {}
@ -81,9 +90,12 @@ async function handle (req, res, method, path, url) {
if (resource === 'permissions' && method === 'GET') {
const email = url.searchParams.get('email') || req.headers['x-authentik-email']
if (!email) return json(res, 400, { error: 'Missing email parameter' })
const username = url.searchParams.get('username') || req.headers['x-authentik-username']
if (!email && !username) return json(res, 400, { error: 'Missing email or username parameter' })
const lookup = await findUserByEmail(email)
// Recherche par courriel, puis REPLI par username (courriel absent/non provisionné).
let lookup = email ? await findUserByEmail(email) : { error: 'no email', status: 404 }
if (lookup.error && username) lookup = await findUserByUsername(username)
if (lookup.error) return json(res, lookup.status, { error: lookup.error })
const { user } = lookup

View File

@ -14,6 +14,17 @@ const conversations = new Map()
const pushSubscriptions = new Map()
const agentTimers = new Map()
// Jeton d'ingestion webhook (courriel/canal) : env sinon fichier — lu UNE seule fois (mémoïsé) au lieu d'un
// fs.readFileSync SYNCHRONE à CHAQUE webhook entrant (bloquait la boucle d'événements). Redémarrage requis pour une rotation.
let _mailIngestToken
function mailIngestToken () {
if (_mailIngestToken === undefined) {
_mailIngestToken = (process.env.MAIL_INGEST_TOKEN || '').trim()
if (!_mailIngestToken) { try { _mailIngestToken = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) { _mailIngestToken = '' } }
}
return _mailIngestToken
}
function loadFromDisk () {
try {
if (!fs.existsSync(CONV_FILE)) return
@ -1252,20 +1263,16 @@ async function handle (req, res, method, p, url) {
// INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik).
if (p === '/conversations/email-ingest' && method === 'POST') {
const fs = require('fs')
const tok = req.headers['x-mail-token'] || ''
let want = (process.env.MAIL_INGEST_TOKEN || '').trim()
if (!want) { try { want = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) {} }
const want = mailIngestToken()
if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' })
try { const b = await parseBody(req); const r = await ingestEmail(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) }
}
// INGESTION CANAL (web chat nouveau site / 3CX / Facebook) — webhook externe. Protégé par X-Mail-Token (même jeton que l'ingest courriel).
if (p === '/conversations/channel-ingest' && method === 'POST') {
const fs = require('fs')
const tok = req.headers['x-mail-token'] || ''
let want = (process.env.MAIL_INGEST_TOKEN || '').trim()
if (!want) { try { want = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) {} }
const want = mailIngestToken()
if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' })
try { const b = await parseBody(req); const r = await channelIngest(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) }
}

View File

@ -0,0 +1,102 @@
'use strict'
// Géofencing des jobs (rec C) : compare la position GPS live de chaque tech (Traccar) à la localisation de SES jobs
// du jour → détecte les transitions EN ROUTE → ARRIVÉ (sur place) → REPARTI, et les journalise sur le job (timeline
// façon « suivi de colis »). Le statut « live » du job est DÉRIVÉ de ce journal (compute-on-read, aucune écriture ERPNext).
// Persistance : data/geofence.json (par job). Scheduler doux (~90 s) tant que le hub tourne.
const fs = require('fs')
const path = require('path')
const { log } = require('./helpers')
const erp = require('./erp')
const traccar = require('./traccar')
const STORE = path.join(__dirname, '..', 'data', 'geofence.json')
const RADIUS_M = Number(process.env.GEOFENCE_RADIUS_M) || 150 // entrer à < 150 m = candidat « sur place »
const EXIT_M = Number(process.env.GEOFENCE_EXIT_M) || 230 // sortir à > 230 m (hystérésis anti-jitter GPS)
const DWELL_MIN = Number(process.env.GEOFENCE_DWELL_MIN) || 3 // rester ≥ 3 min dans le rayon → « Arrivé »
const SCAN_SEC = Number(process.env.GEOFENCE_SCAN_SEC) || 90
let _store = null
function store () { if (_store) return _store; try { _store = JSON.parse(fs.readFileSync(STORE, 'utf8')) } catch { _store = {} } return _store }
function persist () { try { fs.writeFileSync(STORE, JSON.stringify(store())) } catch (e) { log('geofence persist err: ' + e.message) } }
function haversineM (aLat, aLon, bLat, bLon) {
const R = 6371000, toRad = d => d * Math.PI / 180
const dLat = toRad(bLat - aLat), dLon = toRad(bLon - aLon)
const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(s))
}
const todayISO = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
// Jobs assignés AUJOURD'HUI, avec tech + coordonnées valides.
async function todayAssignedJobs () {
const rows = await erp.list('Dispatch Job', {
filters: [['scheduled_date', '=', todayISO()], ['status', 'in', ['open', 'assigned', 'On Hold']]],
fields: ['name', 'assigned_tech', 'latitude', 'longitude', 'subject', 'customer_name', 'status'],
limit: 400,
})
return (rows || []).filter(j => j.assigned_tech && j.latitude != null && j.longitude != null &&
isFinite(+j.latitude) && isFinite(+j.longitude) && Math.abs(+j.latitude) > 0.01)
}
// Position GPS live par technician_id (appareil Traccar associé).
async function techPositionsById () {
const techs = await erp.list('Dispatch Technician', {
filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'traccar_device_id'], limit: 200,
})
const withDev = (techs || []).filter(t => t.traccar_device_id)
const ids = [...new Set(withDev.map(t => parseInt(t.traccar_device_id)).filter(Boolean))]
if (!ids.length) return {}
let positions = []
try { positions = await traccar.getPositions(ids) } catch (e) { return {} }
const byDev = {}; for (const p of positions) if (p && p.deviceId != null) byDev[p.deviceId] = p
const out = {}
for (const t of withDev) { const p = byDev[parseInt(t.traccar_device_id)]; if (p && p.latitude != null) out[t.technician_id] = { lat: p.latitude, lon: p.longitude, time: p.fixTime || p.deviceTime || p.serverTime || null, speed: p.speed || 0 } }
return out
}
// UN passage : met à jour la timeline de chaque job selon la distance tech↔job. Idempotent (n'ajoute un événement qu'à un CHANGEMENT d'état).
async function runScan ({ dryRun = false } = {}) {
let jobs, pos
try { [jobs, pos] = await Promise.all([todayAssignedJobs(), techPositionsById()]) }
catch (e) { return { ok: false, error: e.message } }
const s = store(); const now = new Date().toISOString(); const nowMs = Date.parse(now)
let arrived = 0, departed = 0, enroute = 0; const changes = []
for (const j of jobs) {
const rec = s[j.name] || { state: 'assigned', events: [] }
const p = pos[j.assigned_tech]
if (p) {
const dist = haversineM(p.lat, p.lon, +j.latitude, +j.longitude)
if (rec.state === 'on_site') {
if (dist > EXIT_M) { rec.state = 'departed'; rec.events.push({ status: 'departed', at: now, dist: Math.round(dist) }); departed++; changes.push({ job: j.name, to: 'departed' }) }
} else if (rec.state !== 'departed') { // assigned / en_route → détecte l'arrivée (séjour) et le départ vers le site
if (dist <= RADIUS_M) {
if (!rec.enterMs) rec.enterMs = nowMs
if ((nowMs - rec.enterMs) / 60000 >= DWELL_MIN) { rec.state = 'on_site'; rec.enterMs = null; rec.events.push({ status: 'on_site', at: now, dist: Math.round(dist) }); arrived++; changes.push({ job: j.name, to: 'on_site' }) }
} else {
rec.enterMs = null
if (rec.state === 'assigned' && (p.speed > 3 || dist < 3000)) { rec.state = 'en_route'; rec.events.push({ status: 'en_route', at: now, dist: Math.round(dist) }); enroute++ }
}
}
}
rec.tech = j.assigned_tech; rec.subject = j.subject || ''; s[j.name] = rec
}
if (!dryRun && (arrived || departed || enroute)) persist()
return { ok: true, dryRun, jobs: jobs.length, positions: Object.keys(pos).length, arrived, departed, enroute, changes }
}
// Timeline d'un job (UI) : { state, events:[{status,at,dist}] }. state ∈ null|assigned|en_route|on_site|departed.
function timeline (jobName) { const e = store()[jobName]; return e ? { state: e.state, events: (e.events || []) } : { state: null, events: [] } }
// États live pour une LISTE de jobs (badges sur le board/carte) : { jobName: state }.
function statesFor (names) { const s = store(); const out = {}; for (const n of (names || [])) { const e = s[n]; if (e) out[n] = e.state } return out }
let _timer = null
function startScan () {
if (String(process.env.GEOFENCE_SCAN || 'on').toLowerCase() === 'off') { log('geofence scan DÉSACTIVÉ (GEOFENCE_SCAN=off)'); return }
if (_timer) return
const tick = () => runScan().then(r => { if (r && (r.arrived || r.departed)) log(`geofence: +${r.arrived} arrivé, +${r.departed} reparti (${r.jobs} jobs, ${r.positions} pos)`) }).catch(e => log('geofence scan err: ' + e.message))
_timer = setInterval(tick, SCAN_SEC * 1000)
setTimeout(tick, 8000) // amorçage après le boot
log(`geofence scan activé (${SCAN_SEC}s · rayon ${RADIUS_M}m · sortie ${EXIT_M}m · séjour ${DWELL_MIN}min)`)
}
module.exports = { startScan, runScan, timeline, statesFor }

View File

@ -1120,14 +1120,18 @@ function startSync () {
if (!/^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || ''))) { log('legacy-dispatch-sync: récurrence désactivée (poser LEGACY_DISPATCH_SYNC=on pour activer)'); return }
if (!mysql) { log('legacy-dispatch-sync: mysql2 absent → pont inactif'); return }
const minutes = Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15
const tick = () => sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message))
// Garde anti-chevauchement : si un import dépasse l'intervalle (beaucoup de tickets), NE PAS relancer par-dessus
// (sinon doublons Dispatch Job + contention ERPNext). On saute le tick tant que le précédent tourne.
let _syncInFlight = false
const tick = () => { if (_syncInFlight) { log('legacy-dispatch-sync: passage précédent encore en cours → tick sauté'); return } _syncInFlight = true; sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message)).finally(() => { _syncInFlight = false }) }
// 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`.
setTimeout(tick, 90 * 1000)
_timer = setInterval(tick, minutes * 60 * 1000)
log(`legacy-dispatch-sync: pont actif (toutes les ${minutes} min, staff ${TARGO_TECH_STAFF_ID})`)
// VEILLE temps-réel (poll léger → SSE) : cadence courte, indépendante de l'import.
const watchMin = Number(process.env.LEGACY_DISPATCH_WATCH_MIN) || 3
const wtick = () => watchLegacy().catch(e => log('legacy-watch error:', e.message))
let _watchInFlight = false
const wtick = () => { if (_watchInFlight) return; _watchInFlight = true; watchLegacy().catch(e => log('legacy-watch error:', e.message)).finally(() => { _watchInFlight = false }) }
setTimeout(wtick, 60 * 1000) // amorçage du snapshot ~1 min après le boot
_watchTimer = setInterval(wtick, watchMin * 60 * 1000)
log(`legacy-watch: veille active (toutes les ${watchMin} min → SSE topic 'dispatch')`)

View File

@ -35,6 +35,7 @@ const erp = require('./erp')
const cfg = require('./config')
const fs = require('fs')
const path = require('path')
const holidaysQc = require('./holidays-qc') // fériés QC déterministes — exception AUTO de la matérialisation d'horaire
const POLICY_FILE = path.join(__dirname, '..', 'data', 'dispatch-policy.json')
// Niveau requis PAR JOB, persistant. (L'API Custom Field d'ERPNext v16 échoue sur ce doctype custom → store hub durable.)
const JOB_LEVELS_FILE = path.join(__dirname, '..', 'data', 'job-levels.json')
@ -164,6 +165,7 @@ async function optimizePlan (body) {
const durOv = body.dur_overrides || {}
const winOv = body.job_windows || {} // {jobName:{tw_start_min,tw_end_min}} (AM/PM)
const wantTech = Array.isArray(body.tech_ids) && body.tech_ids.length ? new Set(body.tech_ids) : null
const wantJobs = Array.isArray(body.job_names) && body.job_names.length ? new Set(body.job_names) : null // ne répartir QUE ces jobs (jours cochés / sélection UI)
const [pool, techsAll, templates, assignments, occ, absences] = await Promise.all([
buildUnassigned(), fetchTechnicians(), fetchTemplates(), fetchAssignments(lo, span), occupancyByTechDay(lo, span), absencesByTechDay(lo, span),
@ -196,6 +198,7 @@ async function optimizePlan (body) {
// 🖥 Les jobs SANS DÉPLACEMENT (remote) sont EXCLUS des tournées : aucun arrêt, aucun épinglage d'adresse — à faire du bureau.
const dset = new Set(dates); const byDay = {}; const skipped = []; const remote = []
for (const j of pool) {
if (wantJobs && !wantJobs.has(j.name)) continue // restreint aux jobs demandés (chips de date / sélection)
const sched = (j.scheduled_date && /^\d{4}-\d{2}-\d{2}$/.test(String(j.scheduled_date))) ? j.scheduled_date : null
if (sched && sched > hi) { skipped.push(j.name); continue }
const iso = (sched && dset.has(sched)) ? sched : dates[0]
@ -298,12 +301,23 @@ async function retryWrite (fn, tries = 5) {
}
// ── Lecture des techniciens humains + compétences + indisponibilités ────────
// Cache court de la liste des techs : appelée souvent (endpoint /roster/technicians, generate, materialize, tech-positions
// polling 25 s…) pour des données quasi statiques. TTL 30 s → coupe les hits ERPNext répétés. Staleness acceptable
// (les éditions inline sont optimistes côté UI, pas de re-fetch immédiat). invalidateTechCache() dispo après écriture.
let _techCache = { ts: 0, data: null }
function invalidateTechCache () { _techCache.ts = 0 }
async function fetchTechnicians () {
if (_techCache.data && (nowMs() - _techCache.ts) < 30000) return _techCache.data
const data = await _fetchTechniciansRaw()
_techCache = { ts: nowMs(), data }
return data
}
async function _fetchTechniciansRaw () {
const rows = await erp.list('Dispatch Technician', {
filters: [['resource_type', '=', 'human']],
fields: ['name', 'technician_id', 'full_name', 'status', 'color_hex', 'tech_group', 'efficiency', 'skills',
'cost_salary_h', 'cost_charges_pct', 'cost_other_h', 'traccar_device_id',
'absence_from', 'absence_until', 'employee', 'phone', '_user_tags', 'skill_levels', 'skill_eff'],
'absence_from', 'absence_until', 'employee', 'phone', '_user_tags', 'skill_levels', 'skill_eff', 'weekly_schedule'],
limit: 500,
})
return rows.map(t => ({
@ -325,6 +339,7 @@ async function fetchTechnicians () {
skill_eff: (() => { try { return JSON.parse(t.skill_eff || '{}') } catch { return {} } })(), // {compétence: facteur d'efficacité (vitesse)}
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)
}))
}
@ -389,6 +404,60 @@ async function fetchAssignments (start, days) {
}))
}
// ── HYBRIDE : matérialisation des quarts à partir du PATRON récurrent (weekly_schedule) de chaque tech ──
// Le patron est la SOURCE ; on génère des Shift Assignment (source='pattern') sur l'horizon. Exceptions AUTO :
// fériés (holidays-qc) + vacances (Tech Availability) → pas de quart (et retrait d'un quart pattern existant).
// PRÉSERVE les quarts manuels (source≠'pattern') = overrides (ex. semaine de nuit). Idempotent (re-lançable / cron).
const DOW_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
const _timeH = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
const _timeStr = (s) => (String(s || '').length === 5 ? s + ':00' : String(s || ''))
async function ensureShiftTemplate (start, end, cache) { // start/end 'HH:MM' → nom de doc Shift Template (créé si absent)
const key = start + '-' + end; if (cache[key]) return cache[key]
const st = _timeStr(start), et = _timeStr(end)
try { const rows = await erp.list('Shift Template', { filters: [['start_time', '=', st], ['end_time', '=', et], ['on_call', '=', 0]], fields: ['name'], limit: 1 }); if (rows && rows[0]) { cache[key] = rows[0].name; return rows[0].name } } catch (e) {}
const hours = Math.round((_timeH(end) - _timeH(start)) * 100) / 100
const r = await retryWrite(() => erp.create('Shift Template', { template_name: start + 'h' + end + 'h', start_time: st, end_time: et, hours, on_call: 0, default_required: 1, color: '#1976d2' }))
const name = (r && (r.name || (r.data && r.data.name))) || null
if (name) cache[key] = name
return name
}
async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } = {}) {
const weeksN = Math.max(1, Math.min(12, Number(weeks) || 4))
const start = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
const days = weeksN * 7
const dates = rangeDates(start, days)
const [techsAll, existing, absences] = await Promise.all([fetchTechnicians(), fetchAssignments(start, days), absencesByTechDay(start, days)])
const techs = techsAll.filter(t => t.status !== PAUSE_STATUS && t.weekly_schedule && (!techId || t.id === techId))
const rowsByKey = {}; for (const a of existing) { (rowsByKey[a.tech + '|' + a.date] = rowsByKey[a.tech + '|' + a.date] || []).push(a) }
const tplCache = {}
let created = 0, updated = 0, deleted = 0, keptManual = 0, skipHoliday = 0, skipVacation = 0
for (const t of techs) {
const sched = t.weekly_schedule || {}
for (const iso of dates) {
const day = sched[DOW_KEYS[new Date(iso + 'T12:00:00Z').getUTCDay()]] // {start,end} ou null/absent
const key = t.id + '|' + iso; const rows = rowsByKey[key] || []
if (rows.some(r => r.source !== 'pattern')) { keptManual++; continue } // override manuel présent → intouchable
const pat = rows.filter(r => r.source === 'pattern')
const holiday = holidaysQc.isHoliday(iso); const vacation = !!absences[key]
const wants = !!(day && day.start && day.end) && !holiday && !vacation
if (!wants) { // férié / vacances / jour OFF du patron → retirer le quart pattern
if (holiday) skipHoliday++; else if (vacation) skipVacation++
for (const r of pat) { if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } }
continue
}
const tplName = await ensureShiftTemplate(day.start, day.end, tplCache); if (!tplName) continue
if (pat.some(r => r.shift === tplName)) continue // déjà bon (idempotent)
const hours = Math.round((_timeH(day.end) - _timeH(day.start)) * 100) / 100
if (pat.length) { // patron changé → mettre à jour le quart pattern (+ nettoyer doublons)
if (dryRun) updated++; else { const rr = await erp.update('Shift Assignment', pat[0].name, { shift_template: tplName, hours }); if (rr && rr.ok) updated++ }
for (const extra of pat.slice(1)) { if (!dryRun) await erp.remove('Shift Assignment', extra.name) }
} else if (dryRun) created++
else { const rr = await retryWrite(() => erp.create('Shift Assignment', { technician: t.id, technician_name: t.name, assignment_date: iso, shift_template: tplName, hours, status: 'Publié', source: 'pattern' })); if (rr && rr.ok) created++ }
}
}
return { ok: true, dryRun, weeks: weeksN, techs: techs.length, created, updated, deleted, kept_manual: keptManual, skipped_holiday: skipHoliday, skipped_vacation: skipVacation }
}
// ── Construit le payload du solveur + l'appelle ─────────────────────────────
async function generate (start, days, weights) {
const dateList = rangeDates(start, days)
@ -1722,7 +1791,7 @@ async function handle (req, res, method, path, url) {
const patch = { skills: (b.skills || '').trim() }
if (b.skill_levels !== undefined) patch.skill_levels = typeof b.skill_levels === 'string' ? b.skill_levels : JSON.stringify(b.skill_levels || {}) // niveaux 15 par compétence (JSON)
if (b.skill_eff !== undefined) patch.skill_eff = typeof b.skill_eff === 'string' ? b.skill_eff : JSON.stringify(b.skill_eff || {}) // efficacité (facteur vitesse) PAR compétence (JSON)
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
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 })
}
const mCost = path.match(/^\/roster\/technician\/(.+)\/cost$/)
@ -1730,7 +1799,7 @@ async function handle (req, res, method, path, url) {
const techId = decodeURIComponent(mCost[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { cost_salary_h: Number(b.salary) || 0, cost_charges_pct: Number(b.charges) || 0, cost_other_h: Number(b.other) || 0 }))
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { cost_salary_h: Number(b.salary) || 0, cost_charges_pct: Number(b.charges) || 0, cost_other_h: Number(b.other) || 0 })); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
}
const mEff = path.match(/^\/roster\/technician\/(.+)\/efficiency$/)
@ -1738,7 +1807,7 @@ async function handle (req, res, method, path, url) {
const techId = decodeURIComponent(mEff[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { efficiency: Number(b.efficiency) || 1 }))
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { efficiency: Number(b.efficiency) || 1 })); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, efficiency: Number(b.efficiency) || 1 })
}
const mPause = path.match(/^\/roster\/technician\/(.+)\/pause$/)
@ -1750,9 +1819,39 @@ async function handle (req, res, method, path, url) {
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const patch = { status: b.paused ? PAUSE_STATUS : AVAIL_STATUS }
if (b.paused && b.reason) patch.absence_reason = b.reason
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
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 })
}
// 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') {
const techs = (await fetchTechnicians()).filter(t => t.traccar_device_id)
if (!techs.length) return json(res, 200, { positions: [] })
const { getPositions } = require('./traccar')
const ids = [...new Set(techs.map(t => parseInt(t.traccar_device_id)).filter(Boolean))]
let positions = []
try { positions = await getPositions(ids) } catch (e) { return json(res, 200, { positions: [], error: e.message }) }
const byDev = {}; for (const p of positions) if (p && p.deviceId != null) byDev[p.deviceId] = p
const out = []
for (const t of techs) {
const p = byDev[parseInt(t.traccar_device_id)]
if (p && p.latitude != null) out.push({ techId: t.id, techName: t.name, lat: p.latitude, lon: p.longitude, time: p.fixTime || p.deviceTime || p.serverTime || null, speed: p.speed || 0 })
}
return json(res, 200, { positions: out })
}
// Géofencing : timeline d'un job (En route → Arrivé → Reparti, façon suivi de colis).
const mGf = path.match(/^\/roster\/job\/(.+)\/geofence$/)
if (mGf && method === 'GET') { return json(res, 200, require('./geofence').timeline(decodeURIComponent(mGf[1]))) }
// Géofencing : états live pour une LISTE de jobs (badges board/carte). ?jobs=a,b,c
if (path === '/roster/geofence-states' && method === 'GET') {
const u = new URL(req.url, 'http://localhost'); const names = (u.searchParams.get('jobs') || '').split(',').map(x => x.trim()).filter(Boolean)
return json(res, 200, { states: require('./geofence').statesFor(names) })
}
// Géofencing : forcer un passage (diagnostic/test). ?dry=1 = aperçu sans écrire.
if (path === '/roster/geofence-scan' && method === 'GET') {
const u = new URL(req.url, 'http://localhost')
return json(res, 200, await require('./geofence').runScan({ dryRun: u.searchParams.get('dry') === '1' }))
}
// Associer / changer (ou retirer) l'appareil Traccar d'un tech (GPS live + tracé) — éditable INLINE depuis Planif.
// device_id = id numérique Traccar (ou '' pour dissocier).
const mTrk = path.match(/^\/roster\/technician\/(.+)\/traccar-device$/)
@ -1761,9 +1860,23 @@ async function handle (req, res, method, path, url) {
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const deviceId = (b.device_id == null || b.device_id === '') ? '' : String(b.device_id)
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { traccar_device_id: deviceId }))
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { traccar_device_id: deviceId })); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, traccar_device_id: deviceId })
}
// Patron d'horaire RÉCURRENT (weekly_schedule) — source des quarts matérialisés (hybride). body.schedule = {mon:{start,end}|null,…}
const mSched = path.match(/^\/roster\/technician\/(.+)\/weekly-schedule$/)
if (mSched && method === 'POST') {
const techId = decodeURIComponent(mSched[1]); const b = await parseBody(req)
const techName = await resolveTechName(techId); if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const sched = (b.schedule && typeof b.schedule === 'object') ? b.schedule : null
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { weekly_schedule: sched ? JSON.stringify(sched) : '' })); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
}
// HYBRIDE : matérialise les quarts depuis les patrons (fériés/vacances sautés, manuels préservés). GET=aperçu / POST=applique. ?weeks= ?tech=
if (path === '/roster/materialize-shifts' && (method === 'GET' || method === 'POST')) {
const u = new URL(req.url, 'http://localhost'); const b = method === 'POST' ? await parseBody(req) : {}
return json(res, 200, await materializeShifts({ dryRun: method === 'GET', weeks: Number(b.weeks || u.searchParams.get('weeks')) || 4, techId: b.tech || u.searchParams.get('tech') || null }))
}
// Supprimer une assignation publiée
const mDelA = path.match(/^\/roster\/assignment\/(.+)$/)
if (mDelA && method === 'DELETE') {

View File

@ -0,0 +1,144 @@
// Import du calendrier de vacances (.xlsx Google Sheets export) → absences (Tech Availability) + brouillon.
// AUCUNE dépendance npm : lit le ZIP xlsx via zlib.inflateRawSync (comme gmail.js évite googleapis).
// Voir memory project_vacation_import. Sheet = couleurs cyan/jaune/magenta = Choix1/Choix2/Approuvé.
const zlib = require('zlib')
// ── ZIP minimal : Buffer → { nom: Buffer } (via central directory, méthodes 0=stored / 8=deflate) ──
function unzip (buf) {
const files = {}
let eocd = -1
for (let i = buf.length - 22; i >= 0 && i >= buf.length - 22 - 65536; i--) { if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break } }
if (eocd < 0) throw new Error('xlsx: ZIP EOCD introuvable (fichier corrompu ?)')
const count = buf.readUInt16LE(eocd + 10)
let off = buf.readUInt32LE(eocd + 16)
for (let n = 0; n < count; n++) {
if (buf.readUInt32LE(off) !== 0x02014b50) break
const method = buf.readUInt16LE(off + 10)
const compSize = buf.readUInt32LE(off + 20)
const nameLen = buf.readUInt16LE(off + 28)
const extraLen = buf.readUInt16LE(off + 30)
const commentLen = buf.readUInt16LE(off + 32)
const localOff = buf.readUInt32LE(off + 42)
const name = buf.toString('utf8', off + 46, off + 46 + nameLen)
const lNameLen = buf.readUInt16LE(localOff + 26)
const lExtraLen = buf.readUInt16LE(localOff + 28)
const start = localOff + 30 + lNameLen + lExtraLen
const raw = buf.slice(start, start + compSize)
try { files[name] = method === 0 ? raw : zlib.inflateRawSync(raw) } catch (e) { /* entrée illisible → ignorée */ }
off += 46 + nameLen + extraLen + commentLen
}
return files
}
// ── XML helpers ──
const decode = s => String(s).replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'").replace(/&amp;/g, '&')
const colToNum = c => { let n = 0; for (const ch of c) n = n * 26 + (ch.charCodeAt(0) - 64); return n }
const numToCol = n => { let s = ''; while (n > 0) { const r = (n - 1) % 26; s = String.fromCharCode(65 + r) + s; n = Math.floor((n - 1) / 26) } return s }
function parseShared (xml) { const out = []; if (!xml) return out; const re = /<si>([\s\S]*?)<\/si>/g; let m; while ((m = re.exec(xml))) { let t = ''; const tre = /<t[^>]*>([\s\S]*?)<\/t>/g; let tm; while ((tm = tre.exec(m[1]))) t += tm[1]; out.push(decode(t)) } return out }
function parseStyles (xml) {
const fb = (xml.match(/<fills[^>]*>([\s\S]*?)<\/fills>/) || [])[1] || ''
const fills = []; const fre = /<fill>([\s\S]*?)<\/fill>/g; let fm; while ((fm = fre.exec(fb))) fills.push((fm[1].match(/<fgColor rgb="([0-9A-Fa-f]{8})"/) || [])[1] || null)
const xb = (xml.match(/<cellXfs[^>]*>([\s\S]*?)<\/cellXfs>/) || [])[1] || ''
const xfs = []; const xre = /<xf\b[^>]*\/?>/g; let xm; while ((xm = xre.exec(xb))) xfs.push(+((xm[0].match(/fillId="(\d+)"/) || [])[1] || 0))
return { fills, xfs }
}
const COLOR_STATUS = { '00FFFF': 'Choix 1', 'FFFF00': 'Choix 2', 'FF00FF': 'Approuvé' } // vert 00FF00 = instruction → ignoré
const statusOf = rgb => rgb ? (COLOR_STATUS[rgb.slice(2).toUpperCase()] || null) : null
function parseSheet (xml, shared, styles) {
const cells = {}; const cre = /<c r="([A-Z]+)(\d+)"([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g; let cm
while ((cm = cre.exec(xml))) {
const col = cm[1]; const row = +cm[2]; const at = cm[3] || ''; const inner = cm[4] || ''
const s = (at.match(/\bs="(\d+)"/) || [])[1]; const t = (at.match(/\bt="([^"]+)"/) || [])[1]
const rgb = s != null ? styles.fills[styles.xfs[+s]] : null
let v = ''; const vm = inner.match(/<v>([\s\S]*?)<\/v>/); const im = inner.match(/<is>[\s\S]*?<t[^>]*>([\s\S]*?)<\/t>/)
if (t === 's' && vm) v = shared[+vm[1]] || ''; else if (im) v = decode(im[1]); else if (vm) v = decode(vm[1])
cells[col + row] = { v, status: statusOf(rgb), colN: colToNum(col), row }
}
return cells
}
// ── Dates ──
const isoOf = d => d.toISOString().slice(0, 10)
const serialToDate = s => new Date(Date.UTC(1899, 11, 30) + Math.round(parseFloat(s)) * 86400000) // système 1900 (avec bug)
const MONTHS_FR = { janvier: 0, fevrier: 1, mars: 2, avril: 3, mai: 4, juin: 5, juillet: 6, aout: 7, septembre: 8, octobre: 9, novembre: 10, decembre: 11 }
const norm = s => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim()
// numéro de jour → date la PLUS PROCHE de refDate ayant ce quantième (gère bornes de mois + listes multi-semaines)
function resolveDay (D, refDate) { let best = null; for (let dm = -1; dm <= 1; dm++) { const cand = new Date(Date.UTC(refDate.getUTCFullYear(), refDate.getUTCMonth() + dm, D)); if (cand.getUTCDate() !== D) continue; const diff = Math.abs(cand - refDate); if (!best || diff < best.diff) best = { iso: isoOf(cand), diff } } return best ? best.iso : null }
// Texte de cellule → jours ISO (ou { week:true } = semaine entière). refDate = dimanche de la colonne-semaine.
function parseCell (text, refDate) {
const raw = (text == null ? '' : String(text)).trim(); if (!raw) return { week: true, days: [] }
const low = norm(raw)
if (/^(vacances?|conges?|choix ?\d|approuve|x|oui|off)$/.test(low.replace(/\s+/g, ' '))) return { week: true, days: [] }
const rng = low.match(/(\d{1,2})\s*(?:er)?\s*([a-z]+)?\s*au\s*(\d{1,2})\s*(?:er)?\s*([a-z]+)?/)
if (rng) { const y = refDate.getUTCFullYear(); const m1 = MONTHS_FR[rng[2]] != null ? MONTHS_FR[rng[2]] : refDate.getUTCMonth(); const m2 = MONTHS_FR[rng[4]] != null ? MONTHS_FR[rng[4]] : m1; const out = []; let cur = new Date(Date.UTC(y, m1, +rng[1])); const end = new Date(Date.UTC(y, m2, +rng[3])); for (let g = 0; g < 400 && cur <= end; g++) { out.push(isoOf(cur)); cur = new Date(cur.getTime() + 86400000) } return { week: false, days: out } }
const nums = low.split(/\s*(?:,|\.|;|-|\bet\b|\s)+\s*/).filter(x => /^\d{1,2}$/.test(x)).map(Number)
if (nums.length) return { week: false, days: [...new Set(nums.map(d => resolveDay(d, refDate)).filter(Boolean))].sort() }
return { week: true, days: [] }
}
const weekDays = d => { const out = []; for (let i = 0; i < 7; i++) out.push(isoOf(new Date(d.getTime() + i * 86400000))); return out }
// jours ISO triés → plages contiguës [{from,to}]
function toRanges (isos) { const s = [...new Set(isos)].sort(); const out = []; for (const iso of s) { const last = out[out.length - 1]; const prev = last ? isoOf(new Date(Date.parse(last.to + 'T00:00:00Z') + 86400000)) : null; if (last && iso === prev) last.to = iso; else out.push({ from: iso, to: iso }) } return out }
// ── Extraction : cells → personnes [{ name, group, byStatus:{status:[iso]} }] ──
function extractPeople (cells) {
const cell = ref => cells[ref] || { v: '', status: null }
const weekCols = []
for (let n = colToNum('D'); n <= colToNum('AZ'); n++) { const c = cell(numToCol(n) + '5'); if (c.v && /^\d+(\.\d+)?$/.test(c.v)) weekCols.push({ col: numToCol(n), date: serialToDate(c.v) }) }
const people = []; let group = ''
for (let r = 6; r <= 80; r++) { const a = cell('A' + r).v; if (a) group = a; const name = cell('B' + r).v; if (!name || r < 7) continue
const byStatus = {}; const notes = []
for (const wc of weekCols) { const c = cell(wc.col + r); if (!c.status && !c.v) continue; const status = c.status || (c.v ? 'À classer' : null); if (!status) continue; const p = parseCell(c.v, wc.date); const days = p.week ? weekDays(wc.date) : p.days; if (!days.length) continue;(byStatus[status] || (byStatus[status] = [])).push(...days); if (c.v) notes.push(c.v) }
if (Object.keys(byStatus).length) people.push({ name, group, byStatus, notes })
}
return { people, weeks: weekCols.length, first: weekCols[0] && isoOf(weekCols[0].date), last: weekCols.length && isoOf(weekCols[weekCols.length - 1].date) }
}
// ── Matching global des noms → techs (2 passes : d'abord les certains, puis lever les ambiguïtés sur le reste) ──
const tokens = s => norm(s).split(/[\s\-]+/).filter(Boolean)
function matchAll (people, techs) {
const T = techs.map(t => ({ ...t, tk: tokens(t.name) })).filter(t => t.tk.length)
const taken = new Set(); const result = {}
const cands = p => { const [first, hint] = tokens(p.name); let c = T.filter(t => t.tk[0] === first || t.tk[0].startsWith(first) || first.startsWith(t.tk[0])); if (hint) { const d = c.filter(t => t.tk.slice(1).some(x => x.startsWith(hint))); if (d.length) c = d } return { first, hint, c } }
// passe 1 : match certain (1 seul candidat, OU indice a réduit à 1)
for (const p of people) { const { hint, c } = cands(p); if (c.length === 1) { result[p.name] = { tech: c[0], confidence: hint ? 'high' : 'medium' }; taken.add(c[0].id) } }
// passe 2 : ambigus → contre les techs NON réservés
for (const p of people) { if (result[p.name]) continue; const { hint, c } = cands(p); const free = c.filter(t => !taken.has(t.id)); if (free.length === 1) { result[p.name] = { tech: free[0], confidence: hint ? 'high' : 'medium' }; taken.add(free[0].id) } else if (free.length > 1) { free.sort((a, b) => a.tk.length - b.tk.length); result[p.name] = { tech: free[0], confidence: 'low', ambiguous: free.map(t => t.name) }; taken.add(free[0].id) } else result[p.name] = { tech: null, confidence: 'none' } }
return result
}
// ── Aperçu complet : buffer xlsx + techs → payload de prévisualisation ──
function buildPreview (buffer, techs, { sheetName } = {}) {
const files = unzip(buffer)
const shared = parseShared(files['xl/sharedStrings.xml'] && files['xl/sharedStrings.xml'].toString('utf8'))
const styles = parseStyles(files['xl/styles.xml'].toString('utf8'))
// choisir la feuille : par nom (workbook) sinon la 1re visible ; défaut sheet2 (« De mars à septembre 2026 »)
const wb = files['xl/workbook.xml'].toString('utf8')
const rels = files['xl/_rels/workbook.xml.rels'].toString('utf8')
const sheetsMeta = [...wb.matchAll(/<sheet[^>]*name="([^"]*)"[^>]*r:id="(rId\d+)"[^>]*\/>/g)].map(m => ({ name: decode(m[1]), rid: m[2] }))
const ridToFile = {}; for (const m of rels.matchAll(/Id="(rId\d+)"[^>]*Target="(worksheets\/sheet\d+\.xml)"/g)) ridToFile[m[1]] = 'xl/' + m[2]
let target = sheetsMeta.find(s => sheetName && s.name === sheetName) || sheetsMeta.find(s => /2026/.test(s.name) && /mars|sept/i.test(norm(s.name))) || sheetsMeta[0]
const cells = parseSheet(files[ridToFile[target.rid]].toString('utf8'), shared, styles)
const ex = extractPeople(cells)
const matches = matchAll(ex.people, techs)
const rows = ex.people.map(p => { const m = matches[p.name]; const byStatus = {}; for (const st in p.byStatus) byStatus[st] = toRanges(p.byStatus[st]); const totalDays = Object.values(p.byStatus).reduce((s, a) => s + new Set(a).size, 0); return { sheet_name: p.name, group: p.group, tech_id: m.tech ? m.tech.id : null, tech_name: m.tech ? m.tech.name : null, confidence: m.confidence, ambiguous: m.ambiguous || null, total_days: totalDays, ranges: byStatus, notes: p.notes.slice(0, 6) } })
return { sheet: target.name, sheets: sheetsMeta.map(s => s.name), weeks: ex.weeks, span: [ex.first, ex.last], count: rows.length, matched: rows.filter(r => r.tech_id).length, rows }
}
module.exports = { buildPreview, unzip, parseCell, matchAll, extractPeople, _internals: { parseShared, parseStyles, parseSheet, resolveDay, toRanges } }
// ── Test local : node lib/vacation-import.js <fichier.xlsx> ──
if (require.main === module) {
const fs = require('fs')
const path = process.argv[2]; if (!path) { console.error('usage: node vacation-import.js <fichier.xlsx>'); process.exit(1) }
// techs réels (fetch prod 2026-07-04) pour tester le matching
const TECHS = ['Anthony Dion', 'Antoine Wilson-Guay', 'Antoine-Marie Bourdon', 'Benjamin Djanpou', 'Carmen Ouellet', 'David Richer', 'Dominique Liaud', 'Fanny Laroche', 'Frederick Pruneau', 'Frédérique Soulard', 'Geneviève Bourdon', 'Gilles Drolet', 'Jean-Pierre Bourdon', 'Joseph Muzowindanga', 'Kadi Nongtodbo', 'Lion Arar', 'Louis Morneau', 'Louis-Paul Bourdon', 'Marc Leduc', 'Marc-André Henrico', 'Marcelle Sylviane Rasoarimalala', 'Matthieu Haineault', 'Maxim Murray Gendron', 'Michel Blais', 'Nathan Boucher', 'Nathan Morrisseau', 'Nicolas Drolet', 'Patrick Doucet', 'Patrick Moïse', 'Philip St-Amour', 'Philippe Bourdon', 'Pierre Brodeur', 'Rina Sylvia', 'Robinson Viaud', 'Simon Clot-Gagnon', 'Simon Goyette', 'Stéphane Beaudoin', 'Stéphane Moïse', 'Thaiz Menezes', 'Tommy Larouche Dionne', 'William Alexander Murray', 'Xavier Gilbert Blais'].map((n, i) => ({ id: 'TECH-' + i, name: n }))
const pv = buildPreview(fs.readFileSync(path), TECHS)
console.log(`Feuille « ${pv.sheet} » · ${pv.weeks} semaines (${pv.span[0]}${pv.span[1]}) · ${pv.count} personnes · ${pv.matched} appariées\n`)
for (const r of pv.rows) {
const tag = r.confidence === 'none' ? '❌ NON APPARIÉ' : (r.confidence === 'low' ? '⚠ ' + r.confidence : '✓ ' + r.confidence)
const rng = Object.entries(r.ranges).map(([st, rs]) => `${st}: ${rs.map(x => x.from === x.to ? x.from : x.from + '→' + x.to).join(', ')}`).join(' | ')
console.log(`${r.sheet_name.padEnd(14)}${(r.tech_name || '???').padEnd(26)} [${tag}] ${r.total_days}j ${rng}`)
if (r.ambiguous) console.log(` ambigu: ${r.ambiguous.join(', ')}`)
}
}

View File

@ -392,4 +392,7 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
// Auto-reclaim Giftbit : pool les liens cadeaux EXPIRÉS non cliqués (réallocation auto, futures campagnes). Opt-in GIFT_RECLAIM_CRON=on
try { require('./lib/campaigns').startGiftReclaimCron() }
catch (e) { log('gift reclaim cron failed to start:', e.message) }
// Géofencing des jobs : compare GPS live des techs (Traccar) aux jobs du jour → timeline En route/Arrivé/Reparti. Désactivable GEOFENCE_SCAN=off
try { require('./lib/geofence').startScan() }
catch (e) { log('geofence scan failed to start:', e.message) }
})