Commit Graph

537 Commits

Author SHA1 Message Date
louispaulb
54fdc22c97 feat(dispatch-agent): lecteur resource_availability — « a-t-on la ressource ? » avant de proposer
Complète la boucle diagnostic (raison→compétence→RESSOURCE dispo→créneau) demandée : nouveau lecteur resource_availability
(skill[, after_date, days]) → nb de techs QUALIFIÉS + heures libres sur la période (via dispatch.techOccupancy). Prompt mis
à jour : après resolve_skill, appeler resource_availability (combien dispo) puis find_slot (créneaux) avant de proposer.
Vérifié : skill=réparation → 12 techs, 431 h libres/7j ; outil listé dans /staff-agent/tools. Déployé, hub sain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:59:49 -04:00
louispaulb
2b5c1c6d07 fix(skill-resolver): diagnostic/panne → réparation (plus « fibre » forcé en installation)
Suite au diagnostic « voir signal fibre » → installation (faux). deptToSkill traitait « fibre » comme une intention
d'installation et ne reconnaissait pas « panne ». Corrigé (SOURCE UNIQUE → impacte diagnostic agent, AvailabilityByReason,
couleur des tags) :
- Nouvelle règle réparation AVANT installation : panne, signal, lent, coupé, hors service, ne fonctionne pas, sans
  internet, déconnecté, intermittent, vérifier, diagnostic, dépannage, problème, bris, défectueux.
- Installation = intention de POSER : install, raccordement, activation, nouveau, mise en service, branchement (« fibre »
  seul = techno, plus un déclencheur d'installation).
- Bonus : « tv » en texte libre (\btv\b) → tv.
Vérifié : voir signal fibre/panne/internet lent/coupé/ne fonctionne pas/vérifier le signal → réparation ; installation
fibre/raccordement/activation → installation ; ajout tv → tv. Déployé, hub sain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:57:16 -04:00
louispaulb
f97152c831 feat(dispatch-agent): diagnostic interactif (raison→compétence→dispo) + adresse libre avant de créer
Suite au « Failed to create dispatch job » : au-delà du fix nextJobRef (6022cdf), on rend l'assistant plus INTERACTIF
et on capte l'adresse tapée :
- create_job accepte désormais 'address' (texte libre) → transmis à agentCreateDispatchJob → stocké sur le Dispatch Job
  (avant, l'adresse dictée était perdue si pas de lieu de service lié). Vérifié : job.address = « 2338 rue Ste-Clotilde ».
- Nouveau lecteur resolve_skill (raison → compétence(s) requise(s) via skill-resolver) → l'agent DIAGNOSTIQUE la ressource
  nécessaire. Vérifié : « voir signal fibre » → installation.
- Prompt système : pour une intervention, procéder par étapes — (1) cerner la raison (question si vague), (2) resolve_skill,
  (3) find_slot (dispo réelle), (4) SEULEMENT ensuite proposer create_job/assign_tech/proposer_rdv_client + résumer le
  diagnostic avant l'action. L'agent ne saute plus à la création.
Déployé (3 fichiers), hub sain, resolve_skill listé dans /staff-agent/tools.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:41:42 -04:00
louispaulb
6022cdf5fb fix(dispatch): nextJobRef lisait r.data (objet) au lieu de r.data.data → collisions ticket_id → « Failed to create dispatch job »
Bug : nextJobRef() faisait 'for..of r.data' alors que la liste ERPNext est sous r.data.data (comme partout ailleurs
dans dispatch.js). r.data étant un objet → 'object is not iterable' → catch → repli ymd+'-001' à CHAQUE appel. Donc le
1er job du jour passe, les suivants COLLISIONNENT sur le même ticket_id (autoname) → POST rejeté → createDispatchJob
lève 'Failed to create dispatch job'. Touchait TOUTE création via createDispatchJob (agent NL create_job, /dispatch/
create-job, chaînes d'installation), pas seulement l'assistant.
Fix : itérer r.data.data. Vérifié : 2 créations consécutives → refs 20260719-001 puis -002 (distincts, incrémentés),
plus d'erreur 'not iterable'. Déployé, hub sain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:36:50 -04:00
louispaulb
c4c4cc6575 feat(ops): « Allouer un technicien » = chat IA (Gemini) avec typeahead client/adresse
L'action ouvrait déjà le copilote Gemini (staff-agent) mais sans autocomplétion → il fallait taper le client exactement.
Ajout d'un typeahead d'ENTITÉ dans le chat (OrchestratorDialog, prop entity-search) : on tape → suggestions live
(nom · adresse · téléphone · courriel via /collab/customer-search) → le choix INJECTE l'entité RÉSOLUE « client <id>
(<nom> — <adresse>) » dans la demande, que Gemini extrait pour agir (proposer_rdv_client / assign_tech). Activé sur le
copilote STAFF (MainLayout entity-search) ; graine « Allouer un technicien : » (MonAujourdhui). Séparateur propre après « : ».
Vérifié en dev : « tremblay » → 8 suggestions → choix → prompt « Allouer un technicien : client C-SYLVT… (Tremblay…) » ;
endpoint /collab/customer-search OK (12 matches) ; 0 erreur console. Build OK, leak-clean, déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:30:48 -04:00
louispaulb
788d969090 chore(reconcile): commit MES hunks déployés dans les fichiers co-édités (split via git apply --cached)
Réconciliation de l'audit : isole et commite UNIQUEMENT mon travail déployé-non-commité dans 7 fichiers co-édités,
sans toucher au working tree (donc sans capturer le travail en cours des autres sessions, qui reste non commité) :
- server.js : SSE event 'ready' + retry:3000 ; routes /olt/onu/plan|run + /olt/wifi-clients.
- conversation.js : endpoint /conversations/my-inbox-counts (accueil : en attente / suivis / en retard).
- useConversations.js : armSSE (reconnexion+resync+repli poll 25s) + cleanup.
- PlanificationPage.vue : chips géofence (Lane 1c) + sélecteur « Générer l'horaire » (Move 3) + isLate (arrivée en retard).
- SettingsPage.vue : section « Thème & couleurs » (ThemeEditor).
- IssueDetail.vue + ConversationPanel.vue : props source-issue/customer/service-location/phone (« Proposer au client »).
EXCLUS (restent non commités, propriété d'autres sessions) : /service-location, /conversations/msg-visibility, retrait
champ Mapbox, fix conv-message 'belongs', hiérarchie parent_incident, JobMediaModule, etc. Vérifié : 0 marqueur étranger
dans le staged. Tout est déjà LIVE ; ceci n'aligne que le dépôt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:06:47 -04:00
louispaulb
60f6d71615 chore(ops): commit ThemeEditor.vue (extrait de l'ex-page /theme, consolidé dans Paramètres)
Composant autonome créé lors de la consolidation Thème→Paramètres (redirect routeur /theme→/settings déjà commité en
23f0f72). Fichier 100% mien, sans mélange → seul élément sûr à commiter de l'audit ; le point de montage (import dans
SettingsPage.vue) reste non commité car ce fichier est co-édité (voir rapport d'audit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:00:39 -04:00
louispaulb
4718d025f3 improve(ops): accueil = tableau VIVANT — rafraîchit les compteurs (60 s + retour d'onglet)
L'accueil ne chargeait qu'au montage. Ajout : refetch toutes les 60 s + immédiat au retour sur l'onglet
(visibilitychange), avec nettoyage à onUnmounted. dash() conserve la valeur précédente pendant le refetch → pas de
clignotement vers « … ». Les compteurs « en attente de réponse / en retard / interventions » restent à jour sans
rechargement. Build OK, leak-clean, déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:56:16 -04:00
louispaulb
7371c1b446 improve(booking): /book libre-service — modifier ou annuler un RDV confirmé
Avant : une fois confirmé, le lien /book était un cul-de-sac (« déjà confirmé »). Maintenant le client peut, depuis SON lien :
- « Modifier mon rendez-vous » → ré-ouvre le sélecteur (mêmes créneaux) → nouveau choix écrase l'ancien.
- « Annuler mon rendez-vous » (2 clics de sécurité) → POST /book/api/cancel : libère le créneau, repasse le job en
  « À reporter » (open, sans tech/heure) pour recontact ; le token reste valide (« Reprendre un rendez-vous »).
Réduit les appels au support pour reporter/annuler. Vérifié : les 3 états (confirmé→Modifier→sélecteur ; annuler 2 clics→
annulé→reprendre), 0 erreur console ; live à msg.gigafibre.ca/book, /book/api/cancel token-gated (404 sans token). Hub sain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:04:14 -04:00
louispaulb
81af69e68c improve(booking): SMS de confirmation transactionnel quand le client réserve via /book
À la confirmation d'un créneau (confirmWindow, donc Option A « choisir » ET Option B « proposer 3 » qui aboutit), on
texte le client : « votre rendez-vous technique est confirmé : <date> à <heure> (technicien X) ». Déclenché par SA
sélection (transactionnel), fire-and-forget (ne bloque pas la réponse). Complète le .ics — le client a une trace immédiate.
Téléphone résolu depuis Customer.mobile_no. Déployé, hub sain ; pas de test d'envoi réel (client réel).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:41:12 -04:00
louispaulb
6b9f7b41fa improve(booking): confirmation /book → « Ajouter à mon calendrier » (.ics universel)
Après confirmation d'un créneau (Option A), le client obtient un bouton .ics (Apple/Google/Outlook) pré-rempli :
date/heure + durée, « Rendez-vous Gigafibre — <service> », technicien en description, adresse en LOCATION. Réduit les
oublis/no-shows côté client. Heure flottante (fuseau de l'appareil = Québec). Vérifié : flux confirmer→lien, .ics
bien formé (DTSTART/DTEND/SUMMARY), 0 erreur console ; live à msg.gigafibre.ca/book. Hub redémarré, sain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:38:08 -04:00
louispaulb
f8af4235fb improve(ops): accueil — détecte les ARRIVÉES EN RETARD sur les interventions du jour
Enrichit la carte « Interventions aujourd'hui » (au-delà du simple découpage par statut) : croise l'heure de début
prévue avec l'état géofence live → pastille ROUGE « N en retard » quand le début est passé de > 20 min mais le tech
n'est PAS montré sur place (ni parti). Signal proactif de retard/no-show pour le répartiteur. Réutilise /roster/geofence-states.
Propre (composant seul, aucun changement hub). Vérifié en dev : rendu 3 cartes, aucune erreur console (0 job aujourd'hui
en dev → pastille masquée, correct). Build OK, leak-clean, déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:34:28 -04:00
louispaulb
0d80c323f0 improve(ops): accueil — chiffres HONNÊTES depuis les vraies sources (pas de proxy trompeur)
Le décompte « exact » a révélé que la métrique elle-même était faible : _assign ERPNext quasi vide ici (1726 « open »
tous non assignés). Corrigé pour refléter la RÉALITÉ, via /conversations/my-inbox-counts :
- Boîte : « en attente de réponse » = conversations actives dont le DERNIER message vient du client (212) + contexte
  « N actives » — vraiment actionnable, vs « 540 total » ou « 1726 tickets ouverts ».
- Tickets sur moi : basé sur le SUIVI (le vrai « Mes tickets » d'OPS), pas _assign ; + « en retard » (ouvert > 48 h) en rouge.
Endpoint hub my-inbox-counts (conversation.js, déployé — co-édité, non commité ici). Vérifié en dev : 212 en attente / 540
actives / 0 suivis, 0 erreur console. Build OK, leak-clean, déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:04:57 -04:00
louispaulb
23f0f72731 feat(ops): « / » = accueil universel adapté au rôle (step 3/3) + consolidation Thème/Équipe→Paramètres
Step 3/3. Avant : '/' = « Tableau de bord » réservé (requires view_dashboard_kpi) → un agent de service n'avait pas
d'accueil pertinent. Maintenant '/' = « Accueil » visible par tous ; l'accueil PERSONNEL (MonAujourdhui) s'affiche pour
chacun, et les sections GESTION sont gated dans la page :
- KPI (stats) + Administration + Activité récente → can('view_dashboard_kpi')
- Charge 2 semaines (occupation) → can('view_all_jobs')
Résultat : un CSR voit un accueil épuré (sa boîte / ses tickets) ; un gestionnaire garde le tableau complet.
Embarque aussi la consolidation déjà déployée que ces fichiers portaient : Équipe + Thème → Paramètres (nav retirée +
routes redirigées vers /settings).
Vérifié en dev : nav « Accueil » (plus « Tableau de bord »), sections gestion rendues pour superuser, MonAujourdhui présent,
0 erreur console. Build OK, leak-clean, déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:35:33 -04:00
louispaulb
99f44daf31 feat(booking): page /book « jamais bloquée » — repli disponibilités libres quand aucun créneau
Step 2/3. Avant : zéro créneau en ligne → cul-de-sac « nous vous contacterons ». Maintenant : le client saisit
jusqu'à 3 disponibilités libres (date + Matin/Après-midi/Soir) → soumises en mode:rank → fitBooking tente de placer,
sinon enregistre en 'Proposé' pour le répartiteur. Le client n'est jamais coincé.
Vérifié : repli (mock empty) rend le formulaire + soumet OK ; aucune régression avec créneaux (8 tuiles + bascule 2 modes) ;
0 erreur console ; live à msg.gigafibre.ca/book (marqueurs présents). Hub redémarré, sain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:29:18 -04:00
louispaulb
e7b94947cf feat(dispatch): « Proposer au client » directement dans le flux Dispo par motif (in-ticket/conversation)
Step 1/3 des améliorations. AvailabilityByReason (dialogue partagé, monté dans le ticket ET la conversation) n'offrait
que « Trouver un créneau » (assigner soi-même). Ajout du 2e chemin — laisser le CLIENT choisir — là où le diagnostic se fait :
- Bouton « Proposer au client » → api proposeAppointment → hub /roster/book/propose : résout OU **crée** un Dispatch Job
  ouvert (via createDispatchJob) puis envoie le lien /book par texto. Toast avec « Copier le lien » si pas de SMS.
- roster.js : /roster/book/propose crée le job ouvert si aucun (client ou sujet requis) + pose required_skill.
- Props de contexte passées par les 2 panneaux (source_issue/customer/service_location/phone) — édition additive des
  panneaux co-édités = NON commitée ici (déployée via le bundle).
Vérifié : endpoint 404 attendu sans contexte ; « Proposer au client » compilé dans le bundle ; build OK, leak-clean, déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:25:37 -04:00
louispaulb
dbbcd721e4 feat(ops): accueil « Mon aujourd'hui » v2 — chiffres exacts + état terrain live + actions réelles
Applique « ne pas réutiliser une UX/métrique faible telle quelle » :
- Boîte de réception : remplace le compteur de session 'unread' (démarrait à 0, trompeur) par un VRAI décompte via
  /conversations/inbox-tickets — « à trier » (non assignés) + total ouvert. Tickets sur moi = _assign contient mon courriel.
- Interventions aujourd'hui : ajoute l'ÉTAT TERRAIN LIVE (en route / sur place via /roster/geofence-states, pastilles
  qui pulsent) en plus du découpage par statut.
- Barre d'ACTIONS réelles : « Allouer un technicien » → ouvre le copilote NL PRÉ-REMPLI (event ops:assistant → MainLayout),
  « Nouvelle vente », « Rechercher ⌘K ». Le pont ops:assistant est ajouté dans MainLayout (propre).
- États chargement (« … ») / vide, focus clavier, prefers-reduced-motion.
Vérifié en dev : rendu, 199 à trier (donnée réelle), event allocate + palette OK, 0 erreur console. Build OK, leak-clean, déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:10:07 -04:00
louispaulb
386e36f863 feat(ops): accueil « Mon aujourd'hui » adapté au rôle en tête du tableau de bord
Concrétise « chacun voit ses outils, pas les 24 » : bandeau personnel dont CHAQUE carte est gated par capacité —
- Ma boîte (view_clients) : notifications non lues → /communications
- Tickets sur moi (view_all_tickets|view_own_tickets) : Issues ouvertes filtrées sur _assign = mon courriel → /tickets
- Interventions aujourd'hui (view_all_jobs) : Dispatch Jobs du jour groupés par statut → /planification
Salutation + date FR localisées (America/Toronto). Données via endpoints EXISTANTS (erp listDocs + cloche), filtrage
« à moi » côté client, dégradé gracieux (« — ») si indispo. Additif (monté au-dessus de OutageAlertsPanel), styles --ops-*.
Vérifié en dev : rendu + 3 cartes + salutation nommée + 0 erreur console. Build OK, leak-clean, déployé.
Reste (différé) : atterrissage par rôle au niveau ROUTAGE (les non-managers arrivent ailleurs que sur '/') + sources
« à moi » dédiées (non-lus par canal, mes jobs par tech) au lieu du filtrage client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:58:33 -04:00
louispaulb
b99cbdd6cd feat(dispatch): NL « Proposer un RDV au client » (agent→client, sur staff-agent déployé)
Pont agent→client vers la page /book livrée : au lieu d'assigner soi-même, le staff laisse le CLIENT choisir.
- Hub POST /roster/book/propose : résout le job ({job}|{ticket}|{customer}) → assure booking_token → envoie le lien
  /book par texto (formulation « nouveau RDV »). 404 « créez d'abord l'intervention » si aucun job ouvert.
- Outil NL proposer_rdv_client (agent-tools.json + WRITES staff-agent.js, plan→confirm→run) → l'agent le déclenche
  depuis ⌘K en langage naturel. staff-agent était DÉJÀ déployé (assign_tech direct + find_slot existaient déjà).
Vérifié live : outil listé dans /staff-agent/tools ; /roster/book/propose répond (404 attendu sans job).
Reste (différé, nécessite édition des parents co-édités → deploy-only) : bouton « Proposer au client » in-ticket/conversation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:51:51 -04:00
louispaulb
21d6538e33 feat(booking): page client Gigafibre à 2 modes (choisir un créneau / proposer 3 dispos)
Réécrit BOOK_HTML : marque Gigafibre (vert #00C853, esprit gigafibre.ca) au lieu du bleu générique, mobile-first, 2 modes
via bascule segmentée —
- « Choisir un créneau » (Option A) : sélection unique → hold EXCLUSIF 5 min avec compte à rebours visible → confirmer ;
  à l'expiration le créneau redevient disponible (message + rechargement).
- « Proposer mes dispos » (Option B) : jusqu'à 3 créneaux classés → 1er possible confirmé, sinon enregistrés en 'Proposé'.
Consomme les endpoints publics /book/api/{options,hold,submit} (déjà déployés). Vérifié : rendu + les 2 flux (mock),
puis live à msg.gigafibre.ca/book (marqueurs présents). Hub redémarré, sain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:40:13 -04:00
louispaulb
1fc0e903eb feat(booking): endpoints publics Option A (choisir un créneau proposé)
Complète le backend RDV 2 voies (les 2 modes ont maintenant leur chemin, tous gardés F + hold 5 min) :
- POST /book/api/hold  : hold EXCLUSIF 5 min à la sélection d'un créneau (Option A) ; release possible ; retour au
  pool à l'expiration (bookingSlots re-soustrait les holds vivants).
- POST /book/api/submit mode:'pick' : le client confirme le créneau choisi → confirmWindow (garde F : si le ticket
  est déjà assigné à un autre tech dans F, enregistré en 'Proposé' au lieu d'écraser).
- (mode:'rank' = Option B « le client propose 3 dispos » inchangé : fitBooking → confirmWindow.)
Routes PUBLIC via le préfixe /book existant (aucun changement server.js). Vérifié live : token bidon → 404 lien invalide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:34:00 -04:00
louispaulb
777794cd28 fix(booking): garde anti-clobber F sur les confirmations de RDV + hold exclusif 5 min
Découverte du mapping : la confirmation de RDV (confirmWindow + POST /roster/book/confirm) écrivait assigned_tech
SANS la garde « déjà assigné dans F » que /roster/assign-job applique → une confirmation client (ou staff) pouvait
écraser une assignation faite manuellement dans F. Même classe que le bug double-assignation.

- fConflict(legacyId, techId) : garde PARTAGÉE extraite (ticketAssignState), réutilisée par les 3 chemins d'écriture
  (assign-job refactorisé pour l'utiliser → plus de dérive).
- confirmWindow (chemin CLIENT, pas de force) : si conflit F, N'ÉCRASE PAS — enregistre le créneau choisi
  (booking_status Proposé) et laisse le répartiteur confirmer le bon tech. Message client « nous confirmerons sous peu ».
- POST /roster/book/confirm (chemin STAFF) : 409 conflict sauf force=true → l'OPS réaffiche « déjà assigné dans F à X ».
- hold_minutes défaut 10 → 5 (décision Louis) : réservation exclusive 5 min → retour au pool → indicatif (revalidé
  à la confirmation via la garde ci-dessus).

Déployé (hub redémarré, sain, SSE clients se reconnectent via 187115c/89f7efc).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:30:40 -04:00
louispaulb
89f7efc4ba fix(realtime): l'app terrain et le panneau pannes SURVIVENT aux redéploiements du hub
Même classe de bug que 187115c, sur deux flux SSE qui manquaient la reconnexion :
- TechTasksPage (app terrain) : AUCUN onerror → au restart du hub (502→CLOSED) le technicien perdait TOUT le
  temps réel (nouvelles tâches, changements de statut) jusqu'au rechargement manuel. Ajout reconnexion 3 s + resync
  loadTasks() à la RE-connexion (le hub ne rejoue pas les événements manqués).
- OutageAlertsPanel : au restart, tombait en repli poll 30 s et n'en sortait JAMAIS (ni retour temps réel, ni
  resync immédiat). Ajout reconnexion 3 s + à la RE-connexion : arrêt du poll + fetchActive() pour rattraper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:37 -04:00
louispaulb
187115cb56 fix(realtime): les mises à jour live SURVIVENT aux redéploiements du hub (reconnexion + resync SSE)
Cause (diagnostic) : docker restart du hub à chaque déploiement détruit tous les clients SSE en mémoire. Les flux courriels/
cloche utilisaient un EventSource brut SANS onerror/reconnexion → le 502 pendant le restart les met CLOSED définitivement
(plus de reconnexion native) → morts jusqu'au rechargement manuel. Le dispatch se reconnectait mais ne resynchronisait jamais.

Fix (client) :
- useSSE : nouvelle option onReconnect() appelée à chaque RE-connexion (pas au 1er open) → resync après coupure.
- useNotifications (cloche) : reconnexion auto si le flux meurt (CLOSED → relance 3 s).
Côté co-édité déployé via bundle (non commité ici) : useConversations (inbox) = reconnexion + resync fetchList + repli poll 25 s
sur les 3 connexions SSE ; PlanificationPage = onReconnect → reloadOccupancy+reloadPool ; server.js = event 'ready' + retry:3000.
Hub 'ready' vérifié live. Reste (durabilité, différé) : persister _watchSnap pour rejouer les deltas Legacy manqués pendant la coupure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:19:43 -04:00
louispaulb
daeb86008b chore(ops): supprime le sous-système dispatch MORT (23 fichiers, 0 importeur)
Lane 4 (nettoyage) — vérifié 0 importeur vivant (aucun barrel, aucun import.meta.glob ; build OK après suppression) :
- stores/dispatch.js + 9 composables (useGpsTracking/useAutoDispatch/useBottomPanel/useJobOffers/useDragDrop/useScheduler/
  useSelection/useUndo/useResourceFilter)
- features/workforce/ (api/assignment + useAssignment + useResources)
- components/dispatch/NlpInput.vue
- 11/12 modules/dispatch/components/* (BottomPanel/CreateOfferModal/JobEditModal/MonthCalendar/OfferPoolPanel/
  PublishScheduleModal/RightPanel/SbContextMenu/SbModal/TimelineRow/WeekCalendar)
GARDÉ : modules/dispatch/components/SuggestSlotsDialog.vue (vivant — ClientDetailPage + PlanificationPage), OccupancyBands,
AvailabilityByReason, api/dispatch. Reliquat Mapbox mort (JobEditModal static image) supprimé avec le fichier.
Bundle inchangé (code déjà tree-shaké car 0 importeur) → pas de redéploiement requis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 10:32:18 -04:00
louispaulb
da9c918fa9 feat(ux): gate ONU device actions + delete behind manage_settings, own block
Lane 3 (durcissement) : OnuActionsPanel (suspendre/rétablir/redémarrer/vitesse/remplacer/retirer — écritures réseau live,
suspendre coupe le client) était monté SANS permission dans EquipmentDetail. Désormais gaté can('manage_settings') + sorti
du bloc olt_name (garde propre serial+olt_ip → visible même sans olt_name). Bouton « Supprimer cet équipement » aussi gaté.
(Panel déjà monté depuis un travail antérieur ; ici = gating + placement.)

Lane 1c (déployé via bundle, PlanificationPage co-édité non commité) : puces géofence live En route/Arrivé/Reparti sur les
cartes Jour (kanban) + lignes éditeur de journée, via l'endpoint /roster/geofence-states existant mais jamais appelé
(geofenceStates + refreshGeofenceStates hooké dans reloadOccupancy). Lane 1a : le picker lead exclut déjà le tech assigné
+ garde 409 « déjà assigné dans F » → re-pick déjà empêché (aucun code risqué ajouté).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 10:30:45 -04:00
louispaulb
c1a240023b feat(field-app): refonte accessibilité terrain (gros texte/contraste/cibles + flux guidé)
Priorité accessibilité (usage terrain, gants, luminosité variable — utilisable par une personne âgée / focus TDAH) :
- Type scale +, contraste renforcé (sous-textes #94a3b8→#aebccf), cibles tactiles ≥54-58px (action principale, boutons d'action, cartes).
- Liste : emphase du PROCHAIN arrêt (ruban vert + bordure/halo), compteur « X de Y terminé(s) », arrêts terminés atténués.
- Détail : NAV + action principale au-dessus de la ligne de flottaison — « 🧭 Démarrer l'itinéraire » (bouton bleu proéminent) puis « 📍 Je suis arrivé » (grand vert) + phrase d'aide selon l'état ; carte descendue en référence ; Street View en lien discret ; boutons Commenter/Photo/Appareil agrandis (icône+libellé empilés).
- focus-visible + micro-feedback tactile (reduced-motion respecté).
Vérifié live (Playwright mobile, token tech réel) : liste + détail rendus, carte OSM/MapLibre (0 Mapbox).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 10:23:19 -04:00
louispaulb
5dd38eb79b feat(gps): vue « appareil GPS → technicien » (assignation inverse) + non-associés
Avant : on associait un GPS à un tech UNIQUEMENT depuis le popover du tech (vue Semaine). Ajout du sens inverse demandé :
partir de l'APPAREIL. Hub roster.js :
- GET /roster/traccar-devices : roster enrichi (appareil + tech(s) assigné(s) + unassigned + duplicate + dernier signal),
  croise Traccar getDevices × Dispatch Technician.traccar_device_id. Vérifié live (45 appareils, ex. #4 → Benjamin Djanpou).
- POST /roster/traccar-device-assign {device_id,tech_id} : assigne un appareil→tech en LIBÉRANT d'abord tout autre tech qui
  le détient (le champ vit sur le tech → sinon 2 techs pointeraient le même device ; le doublon est aussi signalé dans la vue).
SPA : GpsDevicesDialog.vue (q-table statut/appareil/dernier signal/technicien via TechSelect + filtre « non associés »),
ouvert par un bouton « Appareils GPS » dans la barre Tournées de Planification. api/roster : listTraccarDevicesRoster + assignTraccarDevice.
PlanificationPage (bouton + mount) = co-édité → déployé, non commité.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:42:32 -04:00
louispaulb
48faeba72b fix(dispatch): coords job EXACTES (fibre par service) + hardening distances auto-dispatch
Précision coords (le levier amont unique : alimente géofence + matrice OSRM + solveur, tous lus à chaud) :
- fibreByDelivery(deliveryId) : AUTORITÉ #0 = le DROP d'installation exact via ticket.delivery_id → service → fibre.*_service_id
  (placemark d'infra si lié, sinon coord fibre). Câblé dans buildJob (avant resolveDevCoords) ET geolocateJobs (refreshFibre,
  seuil de réécriture 30 m car drop exact). Le tick horaire l'applique tout seul désormais.
- BACKFILL exécuté : 15 jobs actifs corrigés (14 fibre_service_placemarks) — dont LEG-247012 (7,5 km !) et LEG-254147 (14 km) qui
  étaient grossièrement mal géocodés → snappés au drop exact. Idempotent (re-run 0).

Hardening auto-dispatch (audit) :
- osrmMatrix : un nœud SANS coords n'est plus à 0 min de tout (« adjacent gratuit » → insertion n'importe où) mais à 180 min
  (« loin/inconnu ») → le solveur ne chaîne plus gratuitement les jobs sans coords au milieu des vrais arrêts.

Restent des jobs sans source coord (ex. lots camping Lac des Pins sans ligne fibre) = data gap F, pas résolvable ici.
Suivi recommandé (non fait, flag) : valider bornes QC sur les chemins de LECTURE (pas que l'ingestion) ; retirer les défauts
tech silencieux (Montréal/Ste-Clotilde) ; sortir les jobs sans coords du solveur vers le pool « à situer » ; invalider le cache
matrice SPA quand les coords changent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:36:08 -04:00
louispaulb
16da087e18 fix(geofence): arrivées manquées — passe TRACE rétrospective + dwell persisté + rayon 250m
Diagnostic (Nathan Morrisseau 2026-07-18) : 3 jobs où le tech était à 8-13m pendant 37-166 min sont restés « En route »,
jamais « Arrivé ». Cause : le scan live n'accroche « on_site » que si le hub tourne 3 min EN CONTINU avec le tech dans le
rayon, et le compteur de séjour (enterMs) était en mémoire, persisté SEULEMENT à un changement d'état → tout redémarrage du
hub le remettait à zéro. 18/07 = 0 arrivée pour TOUTE la flotte (seul jour), alors que « en route » (1 tick) s'accrochait.

Fixes (les 3 demandés) :
- FIX 1 dwell persisté : runScan marque le store 'dirty' quand enterMs change → survit aux redémarrages.
- FIX 2 passe TRACE rétrospective : reconcileFromTrack(date) rejoue l'historique GPS Traccar complet du jour (traccar.getHistory
  ajouté) et DÉRIVE arrivée/départ → insensible aux redémarrages/ticks manqués/bursts. Planifiée /15 min sur le jour courant
  (scan live conservé pour la réactivité UI) + route GET /roster/geofence-reconcile?date=&dry=1 (backfill/diag).
- FIX 3 rayon 150→250m (+ sortie 230→350m), aligné sur l'arrivée-auto de l'app terrain → couvre le géocodage rural imprécis.

Vérifié live : backfill 18/07 → LEG-254929/255009/255042 = Arrivé+Reparti (source track), idempotent (re-run 0/0), today OK.
Restent en_route : LEG-254770 (187m, séjour <3min) + LEG-255029 (579m, coords à corriger) = imprécision géocodage, pas géofence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:17:31 -04:00
louispaulb
9cb9739d3f feat(maps): zéro Mapbox (app terrain → MapLibre/OSM) + meilleur trajet trafic
- field-app.html (app tech) : carte Mapbox GL → MapLibre GL + tuiles raster OSM (aucun jeton). C'était le DERNIER Mapbox vivant.
- NOUVEAU « 🚀 Meilleur trajet du jour (trafic en direct) » : ordre optimal des arrêts via NOTRE OSRM /table + plus-proche-voisin (le plugin /trip n'est pas compilé), puis handoff à Google Maps multi-arrêts → trafic EN DIRECT gratuit (OSM seul n'a pas de trafic). Endpoint hub GET /field/route?t=&from=lat,lon.
- config/erpnext.js MAPBOX_TOKEN vidé + JobEditModal (module mort) : vignette statique api.mapbox.com → lien OSM. Bundle SPA désormais SANS jeton Mapbox ni api.mapbox.com (vérifié).
- serveFieldApp : retrait de l'injection du jeton Mapbox.

Vérifié live : field-app rendu = MapLibre (0 api.mapbox.com) ; OSRM /table Ok ; ordre A→C→B correct depuis origine ouest ; /field/route 200. Reste (co-édité, déployé non-commité) : tooltips PlanificationPage « Mapbox »→« OSRM/OSM » + retrait carte réglages Mapbox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:01:00 -04:00
louispaulb
218bef8c5a refactor(olt-ops): dédup plomberie n8n + effets pilotés par le spec ACTIONS
- extrait n8nRequest(verb,url,body,timeout) partagé par dostuff() et wifiClients() (fin de la double implémentation https-GET-with-body)
- effets déplacés dans ACTIONS[action].effect → supprime l'échelle de if dans plan()
Comportement préservé (vérifié live : plan tech-3 suspend identique, gardes run inchangées). Déployé prod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 07:38:48 -04:00
louispaulb
23dde4ecf0 feat(ops): native Payment Method/Arrangement + VoIP Line sections + G3 Raisecom SNMP writes
Native detail sections (fin de la grille+desk pour les derniers doctypes cliquables de la fiche client) :
- PaymentMethodDetail / PaymentArrangementDetail / VoipLineDetail — LECTURE SEULE (aucune charge/paiement déclenché ; mot de passe SIP jamais affiché). Enregistrés dans SECTION_MAP.

Phase 2 G3 (parité écriture fibre tech-2 Raisecom, ~10 500 ONUs) :
- olt-ops.js : écritures SNMP natives (net-snmp) suspend/rétablir (VLAN 40↔666) / redémarrer / vitesse (line-profile), OIDs EXTRAITS DU DRIVER F raisecom_rcmg.php (vérifiés à la source) + SAVE 8886.1.2.1.1.0 après chaque set. Encodages olt_id (slot*1e7+port*1e5+ontid) et onu_id ((slot+32)*8388608+port*65536+ontid) — round-trip vérifié. Repli coords ONU depuis le poller SNMP live (olt-snmp.getOnuBySerial) car les coords Raisecom ne sont pas dans Service Equipment. Gaté : env OLT_RW_COMMUNITY requis (absent → run refuse proprement, 0 set), confirm+idempotencyKey obligatoires. Vérifié live : plan tech-2 refuse sans coords/RW ; run refuse ; 0 écriture réseau tirée.
- OnuActionsPanel : ajout « Redémarrer ».

Déployé prod (hub olt-ops + SPA). DetailModal SECTION_MAP (5 entrées ajoutées) = déployé mais co-édité → non commité.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:39:59 -04:00
louispaulb
3d4700decf feat(ops): native Dispatch Job/Communication detail + ONU write parity (Phase 1-2)
Phase 1 (détail natif partout, fini le desk ERPNext) :
- DispatchJobDetail.vue : vue NATIVE du Dispatch Job (assemble les modules partagés : carte/tracé, géofence, photos terrain, durée, compétences, équipe+assign 409-aware, fil+réponse) → SECTION_MAP['Dispatch Job']. Répond à la plainte « le job tombe sur la grille générique + bouton desk ».
- CommunicationDetail.vue : vue NATIVE d'une Communication (courriel/appel) → SECTION_MAP['Communication'] ; HTML assaini.
- 3 pages orphelines (Réseau/GPON, Téléphonie SIP, Flux agent IA) surfacées en nav gatée view_settings ; OCR rattaché à Factures fournisseurs (bouton Numériser) ; stubs « Bientôt » retirés de Rapports.

Phase 2 (parité écriture fibre — remplace le « Do Stuff » de F) :
- lib/olt-ops.js : plan(lecture seule : résout OLT/slot/port + payload n8n exact + effets + avertissements) / run(fire le verbe n8n dostuff PUIS miroir Service Equipment). Idempotent (clé, fenêtre 10 min), confirm obligatoire, tech-3 seulement (tech-2 Raisecom → SNMP natif = G3 à venir). + wifi-clients (G1).
- OnuActionsPanel.vue : suspend/rétablir/forfait/remplacer/retirer/activer en plan→confirm→run, monté dans EquipmentDetail.

Backend déployé+vérifié live (plan/run/guards/auth : plan 200 résout EQP réel, run sans confirm→refus, sans auth→401). SPA déployée /opt/ops-app. AUCUNE écriture réseau réelle tirée (run = à déclencher par un humain via le bouton).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:04:19 -04:00
louispaulb
cd25fa2504 fix(dispatch): double-assign F + médias terrain dans le ticket + deep links natifs
- capacityByDay compte les jobs assignés SANS heure (untimed_used_h au prorata AM/PM/Soir)
- /roster/assign-job : garde 409 si le ticket F est déjà à un autre vrai tech (force pour écraser) ; SPA = dialog confirm
- mirrorAssign : start_time depuis F due_time (am/pm) + fix due_date (cur.→r.) + backfill fill-only dans watchLegacy (97% des tickets F = 'day' → le spread capacité est le fix porteur)
- pushAssignments : garde anti-clobber (conflits F skippés, force=1) + compteur conflicts
- ticket-collab : courriels de notification → deep link OPS /#/tickets?open= (fini le desk) ; TicketsPage lit ?open= ; Dashboard ouvre le ticket précis
- DetailModal + SupplierInvoices : lien desk gaté can('manage_settings')
- NOUVEAU JobMediaModule (photos tech géo-vérifiées + journal terrain) monté dans IssueDetail onsite + volet Planif ; hub /roster/job-media + /field/photo-file signé ; JobThread poll silencieux 45s
- legacy-sync emailList : strip caractères invisibles (soft hyphen U+00AD…) — fixait un 417 InvalidEmailAddress en boucle (compte 15988)

Déployé prod 2026-07-18 (hub restart + SPA /opt/ops-app), sondes live OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:02:37 -04:00
louispaulb
ef7b51399d refactor(planif): « Bloquer du temps » = UN job/ticket qui occupe la timeline, priorité = curseur dur/souple
Suite retour Louis : plus de mode soft/hard (toggle du commit précédent retiré). Un
seul concept — un job/ticket interne qui OCCUPE la timeline (formation · réunion ·
projet · entretien) → dé-priorise le tech au dispatch. La PRIORITÉ est le curseur :
Moyenne/Basse = les urgences/réparations peuvent quand même prendre le tech ; Urgent
(high) = bloc dur, non traversé.

Front : dialogue unique « Bloquer du temps » (Intitulé · journée complète · date ·
début · durée · Priorité [défaut Moyenne] · ticket optionnel), info adaptée à la
priorité. Un seul submit `doBlockTime` → /roster/filler-job. Retiré : bascule de mode,
branche réserve, `roster.reserveTech`. Les 2 points d'entrée (bouton volet réglages +
menu de cellule) → même dialogue, relabellés « Bloquer du temps… ».

Hub (dispatch.js suggestSlots) : la traversée urgence passe du flag `job_type ===
'Réservation'` à la PRIORITÉ — `isSoftBlock` = bloc interne (Interne/Réservation) de
priorité ≤ moyenne → traversé en mode urgent ; high résiste ; les vrais jobs client ne
sont JAMAIS traversés (pas de double-booking). Ajout du champ `priority` au fetch.
(/roster/reserve laissé en place, inutilisé — retrait ultérieur.)

Vérifié : test unitaire pierce (medium/low traversé · high résiste · job client jamais)
+ aperçu (dialogue unique, priorité Moyenne, info souple, pas de toggle). Build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:38:17 -04:00
louispaulb
ba6967adc6 refactor(planif): fusion « Réserver du temps » + « Bloquer / job générique » en UN dialogue
Deux dialogues faisaient la même chose (rendre un tech indisponible pour un bloc)
avec des champs ~80% identiques, des points d'entrée différents (bouton volet
réglages vs menu de cellule) et aucune explication de leur différence → lus comme
un doublon. Seul vrai écart : bloc DUR (hors dispatch, ticket optionnel) vs réserve
SOUPLE (dé-priorise, urgences passent).

→ UN dialogue « Réserver / bloquer du temps » avec bascule de MODE (segmented
control : « Hors dispatch (bloc) » / « Urgences OK (souple) »). Champs communs
(tech · date · début · durée · journée complète · motif/intitulé) ; priorité/type/
ticket seulement en mode bloc. Soumission `doBlockOrReserve` → /roster/reserve OU
/roster/filler-job selon le mode (endpoints inchangés). Les 2 points d'entrée
(bouton « Réserver / bloquer… » du volet réglages [souple par défaut] · item de menu
de cellule [bloc par défaut, pré-rempli]) ouvrent le MÊME dialogue.

Supprimé : resvDlg + doReserve (le dialogue « Réserver » séparé). Aperçu vérifié :
bascule de mode montre/masque priorité/type/ticket + change libellé/info/bouton.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:52:12 -04:00
louispaulb
9c1b578d5d feat(dispatch): provisioning identites enrichi Employee+Authentik (multi-courriels)
extEmailIndex(): index nom->emails depuis ERPNext Employee (company/prefered/
personal/user_id) + Authentik /core/users. provisionIdentities l utilise pour:
- CREATE des identites d ex-staff sans courriel legacy (si une source externe en a un)
- ENRICH: tous les courriels decouverts s accumulent en alias_emails (union,
  jamais overwrite) -> multi-courriels par personne, resolvable par chaque alias
- skip des comptes systeme (ticket dispatch, Gestion Inventaire, Tech Targo)
Applique en prod: +1 lien, +5 identites enrichies d alias (perso+corpo). Idempotent
(re-run 0 ecriture). Les 41 restants = ex-employes absents de toute source ->
mapping manuel via l onglet Identites (liste unresolved du rapport).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:50:23 -04:00
louispaulb
e8fd69c6f8 feat(planif): pause indéfinie STAGÉE → visible tout de suite, commise au Publier (retire quarts futurs + relève jobs)
Une mise en pause indéfinie ne s'applique plus immédiatement : elle est mise en
attente (pendingPause, localStorage — comme les congés), prend effet VISUELLEMENT
(nom barré + puce « pause à publier » sur la rangée, compteur « non publié »), et
n'est COMMISE qu'au Publier — au cas où on se trompe.

- TechScheduleDialog : le toggle « Pause indéfinie » émet `stage-pause` (plus
  d'écriture roster directe) ; l'état reflète serveur OU en attente (prop pendingPaused).
- Parent onStagePause : stage/dé-stage ; réactivation d'un tech DÉJÀ en pause reste
  immédiate (Disponible + re-matérialisation du patron).
- Résumé de Publier (groupé par tech) : ligne « Pause indéfinie · motif · N quart(s)
  futur(s) retiré(s) » + chaque quart futur listé en retrait ; en-tête « · N pause(s) ».
- doPublishConfirmed : exclut les quarts futurs (aujourd'hui→) des techs en pause de la
  charge → publish-week les SUPPRIME ; applique le statut En pause ; puis relève des jobs
  via l'impact IROPS existant (checkAbsenceImpact). Quarts passés conservés ; patron
  récurrent intact (réactivation re-matérialise).

Vérifié en aperçu (client-only, RIEN écrit en prod) : Philippe Bourdon → puce +
nom barré ; résumé « Pause indéfinie · Arrêt maladie · 14 quart(s) futur(s) retiré(s) »
+ 14 retraits, à côté du quart de Nathan Morrisseau ; annulé + nettoyé. Build OK.
⚠️ Chemin de COMMIT au Publier (suppression quarts + pause + relève jobs) non exercé en
prod (écriture réelle) — réutilise publishWeek/pauseTechnician/checkAbsenceImpact éprouvés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:33:08 -04:00
louispaulb
e98b21ba0a feat(dispatch): provisioning identites depuis legacy staff (convergence ledger)
provisionIdentities({dryRun,scope}) : pour chaque staff du ledger, garantit une
identite canonique portant legacy_staff_id (+ tech_id si Dispatch Technician, donc
les admins restent non-tech). LINK (identite existante par courriel/nom -> remplit
legacy_staff_id) ou CREATE (depuis staff si courriel). Idempotent. Route
GET|POST /dispatch/legacy-sync/tech-history/provision-identities?scope=ledger|active.
Applique en prod: +33 identites liees a leur legacy_staff_id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:23:23 -04:00
louispaulb
60da7a01da feat(dispatch): tech-history resolue via identite canonique (dynamique)
Le rapport techHistory groupe desormais par IDENTITE (lib/identity resolveIdentity:
staff_id -> nom), calculee A LA LECTURE -> corriger une identite (onglet Settings
Identites) reclasse le ledger AUSSITOT (pas de valeur figee).
- is_tech = l identite a un tech_id : le staff admin (ex. Megane Liaud, sans tech_id)
  n est PLUS compte comme technicien.
- Sortie: par assignee {label,email,is_tech,kind,total,from_pool} + liste unresolved
  (noms sans identite, ex. prenom seul) a mapper dynamiquement.
- Detail ?tech= accepte cle identite | courriel | tech_id | staff_id | nom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:14:30 -04:00
louispaulb
947595f086 feat(planif): résumé de Publier complet + groupé par technicien
« Toute modification doit être résumée avant d'être publiée (au cas où on se
trompe). » Le résumé était incomplet et groupé par date :
- retraits de quarts INVISIBLES (pubSummary.removed codé en dur à 0) alors que
  le hub SUPPRIME au Publier tout quart absent de la charge (publish-week) —
  une suppression accidentelle passait donc inaperçue ;
- groupé par date, difficile de voir ce qui change POUR UN TECH.

pubSummary regroupe désormais PAR TECHNICIEN (avatar + nom) et couvre tout ce
que Publier va appliquer : quarts à publier (ajouts/modifs), quarts RETIRÉS
(diff contre l'instantané serveur du chargement, serverSet → capte les
suppressions), et congés/absences en attente. En-tête : « N quart(s) · R
retiré(s) · A congé(s) · L levé(s) ». Un tech sans changement n'apparaît pas.
(La garde a son propre bouton « Publier la garde » — hors de ce flux.)

Vérifié : repro logique (modif = −ancien/+nouveau, retrait, congés, tech
inchangé omis) + aperçu live (Josée-Anne : Congé 14/07 + Maladie 15/07 ;
Nathan Morrisseau : quart 8h–16h) — rien écrit en prod (annulé). Build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:06:05 -04:00
louispaulb
9d568986fc fix(planif): afficher les congés en attente dans le résumé de Publier
Le résumé de « Publier l'horaire » (pubSummary) était construit UNIQUEMENT à
partir des quarts non publiés (unpublished) et ignorait pendingAbs. Un congé
mis en attente (ex. Josée-Anne, congé indéterminé) allumait bien le badge
« non publié » ET était bien envoyé au Publier (flushPendingAbsences), mais
n'apparaissait PAS dans le dialogue de confirmation — on n'y voyait que le
changement de quart (Nathan Morrisseau). Le répartiteur ne pouvait donc pas
réviser le congé avant de confirmer.

pubSummary intègre désormais pendingAbs, groupé par date, avec compteurs
absAdded/absRemoved ; l'en-tête affiche « N quart(s) · M congé(s) » et chaque
jour liste les congés (event_busy orange = ajout · event_available teal =
retrait) à côté des quarts.

Vérifié (aperçu, client-only, rien écrit) : congé indéfini Josée-Anne 13-17/07
→ résumé « 1 quart(s) · 5 congé(s) », lignes Congé par jour à côté du quart de
Nathan Morrisseau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:29:31 -04:00
louispaulb
6761c9e05a feat(dispatch): ledger historique d assignation par tech (ops_tech_ticket_history)
Reconstruit la timeline complete d assignation des tickets depuis le CHANGE LOG
osTicket (ticket_msg: 'assign... change de X a Y') vers une table plate dans la
PG ERPNext (a cote de tabDispatch Job/tabIssue), incluant les tickets fermes.

- techHistoryBackfill(): scan watermarke borne de ticket_msg (I/O previsible),
  parse + mapping nom->staff->TECH-<id>, upsert idempotent. Backfill initial + tick
  incremental (un seul chemin) -> capte les nouveaux changements (les notres ET ceux de F).
- techHistory({tech}): rapport par tech (detail) ou recap tous techs.
- Routes: GET /dispatch/legacy-sync/tech-history[?tech=], GET|POST .../tech-history/backfill.
- Tick live planifie dans startSync (LEGACY_TECH_HISTORY, defaut on; LEGACY_TECH_HISTORY_MIN=15).
- Detecte 'de Tech Targo a <tech>' = sortie du pool vers un tech (from_pool).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:58:03 -04:00
louispaulb
e43f04d3e5 feat(dispatch): write-back unifie Publier vers F + tickets perimes natifs
Hub (legacy-dispatch-sync.js):
- publishPreview() + GET /dispatch/legacy-sync/publish-preview: apercu unifie
  OPS->F (assignation + fermeture + horaire) + avertissements tech non mappe
  = la liste des changements avant du bouton. Lecture seule.
- publishJob() + GET|POST /dispatch/legacy-sync/publish-job: apply par job
  (GET=plan dry-run, POST=applique) via ops_reassign.php.
- staleTickets() + GET /dispatch/legacy-sync/stale: tickets ouverts sans
  activite >=N j (natif, remplace le courriel stale de F); scope buckets/maxAge en SQL.
- staleNudge() + GET|POST /stale-nudge + digest planifie opt-in
  (LEGACY_STALE_NUDGE), anti-spam persiste, nudge via alertWebhook.

Bridge F (ops_reassign.php):
- action schedule: write-back due_date/due_time (horaire OPS->F).

Verifie en prod (lecture seule + dry-run; aucune ecriture F reelle tiree).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:49:28 -04:00
louispaulb
956e96b8bc chore(hub): sync repo with deployed hub state (uncommitted prod work)
Le hub deploye (/opt/targo-hub) avait derive du depot (deploiements scp
directs non commites). Rapproche la branche de la realite deployee AVANT
d empiler le nouveau travail:
- legacy-dispatch-sync.js: liens natifs source_issue/depends_on/parent_incident
  + backfills (deployes, jamais commites)
- ops_reassign.php: action post (note/reponse au fil, deployee)

Aucun secret (creds via ops_secret.php). Fichiers = copie exacte du deploye.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:49:17 -04:00
louispaulb
e0e1da1c01 fix(dispatch): lever le cap 50 sur les techniciens (occupation + charge)
techOccupancy et getTechsWithLoad récupéraient les Dispatch Technician avec
limit_page_length=50 alors que l'effectif est de 56. Les techs au-delà du 50e
(ordre ERPNext) étaient donc ABSENTS de l'occupation → dans le calendrier mois
de leur horaire (TechScheduleDialog), c.hasShift est piloté UNIQUEMENT par
l'occupation (shiftMap), donc leurs quarts créés en Semaine/Jour n'y
apparaissaient jamais (tout gris) — ex. Philippe Bourdon & co., alors que Simon
Clot-Gagnon (dans les 50) s'affichait bien. Même cap = mêmes techs invisibles
au dispatch/ranking (getTechsWithLoad). Cap porté à 500 (comme les requêtes
jobs/disponibilités voisines).

Confirmé live : roster/technicians = 56 (Philippe présent) vs dispatch/occupancy
= 45 sans Philippe. Les Shift Assignment existaient (fetchés limit 2000) mais ne
nourrissent que le menu de cellule, pas la barre d'occupation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 07:19:15 -04:00
louispaulb
c7c9f81364 fix(planif): Suggérer = jobs DUS le jour sélectionné · jour partagé entre vues (session) · quarts brouillon reflétés au calendrier mois
1) Suggérer (retour terrain, ex. ticket 254403 en retard auto-dispatché) :
   - fenêtre = EXACTEMENT le jour sélectionné ; les chips de date du pool
     n'élargissent plus jamais le dispatch (elles filtrent l'affichage)
   - suggestJobs filtré à la SOURCE (couvre solveur VRP + heuristique) :
     scheduled_date === jour sélectionné ; retards/sans-date exclus —
     seule exception : sélection manuelle (lasso)
   - libellés du dialogue mis à jour (« N job(s) DUS ce jour · retards exclus »)
2) Jour sélectionné PARTAGÉ (selDay) persisté en sessionStorage (planif_sel_day) :
   écrit par bande Tournées / Jour kanban / sélecteur de date / bande mobile /
   dialogue Suggérer ; repris au changement de vue (Jour ne retombe plus sur
   « aujourd'hui », Tournées suit, Mois ancre son mois dessus)
3) Quarts créés en Semaine/Jour → calendrier mois par tech + vue Mois :
   flush du brouillon (debounce 800 ms) avant l'ouverture du dialogue horaire,
   flush au passage en vue Mois, et refresh auto des calendriers ouverts quand
   une auto-sauvegarde se termine (techSchedRefresh / monthRefresh ;
   MonthOverview.refreshKey enfin câblé du parent)

Vérifié (aperçu 9001, lecture seule) : pool 44 jobs dont 8 en retard → dialogue
n'offre que les 2 DUS le jour choisi ; 22/07 suivi Semaine→Jour→Tournées ;
Mois affiche « Août 2026 » pour selDay=2026-08-05 ; build SPA OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:17:12 -04:00
louispaulb
48edede793 refactor(planif): désencombrer l'en-tête selon UX_REFACTORING_PROMPT (PL1/PL2/PL4/PL5/PL6)
- PL5 : sous-menu « Modèles de semaine » (menu-dans-menu) → dialogue dédié
  components/planif/WeekTemplatesDialog.vue (props données / emits save·apply·
  set-default·delete ; état + mutations restent dans la page, EmptyState réutilisé)
- PL2 : menu Outils 13 items + sous-menu → 11 items à plat ; retrait des 2 entrées
  redondantes avec la barre (« À assigner », vue Mois)
- PL1/PL4 : barre allégée — bouton ★ modèle par défaut déplacé dans le ⋮ (groupé)
  + ⌘K (planif-templates, planif-apply-default) ; HelpHint « Suggérer » retiré
  (l'explication vit déjà dans le bandeau du dialogue)
- PL6 : champ orphelin « Max h/sem » supprimé (ref jamais consommée = code mort)
- docs : statuts PL1/PL2/PL4/PL5/PL6 mis à jour dans UX_REFACTORING_PROMPT.md

Vérifié : build SPA OK · aperçu 9001 — Outils/⋮/dialogue exercés, chaîne emit
save → prompt parent OK, grille Semaine + Tournées intactes, 0 nouvelle erreur
console (les « Anchor: target null » préexistent au baseline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 20:56:34 -04:00
louispaulb
8e7838d1fb docs: prompt réutilisable de refactor UX (UX_REFACTORING_PROMPT.md)
Réécriture vérifiée du brouillon initial — 43 corrections issues d'un review
multi-agents contre le code réel @ 718698f :
- 16 règles UX calibrées au repo (chrome-only, CTA desktop top-right,
  Function-props, fr-CA, raccourcis clavier, états async, LTE, 44px, tokens)
- tableau des primitives canoniques (OverflowMenu, DisclosureSection, HelpHint…)
- audit anti-patterns avec colonnes Status+Anchor (repérage par nom d'état,
  pas par ligne) ; lignes déjà résolues marquées (T2, TechTasks, PL4/PL7)
- plan de décomposition PlanificationPage réaliste (décomposition #4 reconnue,
  vagues 1-2, règle script-mapping, pas de composable fourre-tout)
- Definition of Done (lint/build/preview + invariants DnD/SSE/MapLibre/Publier)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:27:51 -04:00