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>
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>
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>
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>
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>
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>
The hub /dispatch/suggest-slots (gap-finding + travel buffer + proximity
ranking) existed but had NO UI (Gemini claimed SuggestSlotsDialog.vue was
coded — it did not exist). Built it:
- api/dispatch.js: suggestSlots(payload) → POST /dispatch/suggest-slots.
- SuggestSlotsDialog.vue: address (RQA geocode via useAddressSearch) +
duration + earliest date → ranked slots (tech, date, time, travel min,
reasons). Selecting a slot emits it.
- DispatchPage: "📅 Créneau" button in the header opens the dialog;
onSlotSelected prefills the WO create modal (tech + date + address +
duration) and confirmWo injects the slot's start_time + geocoded coords
into createJob. Manual "+ WO" clears the booking slot.
Verified: dialog compiles/opens with inputs; suggest-slots returns ranked
slots (e.g. Gilles Drolet 08:15–09:45, journée libre, 15 min). Note: the
address geocode (/api/method/search_address) 403s in the dev preview
because the dev proxy doesn't inject the ERP token that prod nginx adds —
works in prod (same call the create modal already uses).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Routing: the last Mapbox routing calls now go through our OSRM —
useMap.computeDayRoute (Dispatch board route lines) → /roster/osrm-route,
useAutoDispatch optimizeRoute → new /roster/osrm-trip (OSRM /trip TSP,
same response shape as Mapbox optimized-trips). 0 Mapbox directions/
matrix/optimized-trips fetches left in the SPA. (Remaining Mapbox =
gl-js renderer + base tiles + geocoding, which OSRM can't provide.)
Anthony route bug: /roster/osrm-route now sends continue_straight=false.
Without it OSRM forbids U-turns at intermediate waypoints, so on a
home→stop→home loop it overshoots the address and turns around in a side
street ("s'éloigne et tourne"). Now it goes straight to the door.
Benefits every map (shared osrm-route).
Job detail dialog:
- Highlighted assigned-tech chip (indigo, avatar+name) in the header —
"who does this job" is now obvious; grey "Non assigné" otherwise.
- Mini-map shows nearby jobs (pool + occupancy within ~3km) as pale
grey dots for context, under the job's own pin; centered on the job.
- Normalize displayed strings: decode HTML entities (subject
"d'équipement" → "d'équipement") via deEnt().
Verified: osrm-trip returns Ok order; detail shows Anthony Dion chip +
decoded subject; routes render (43 pins), Anthony 31.7km/35min round trip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Tournées tab: removable/addable TECH CHIPS above the map — each chip
in the tech's color with a job-count badge; click toggles the route
on/off (colors stay stable). Route colors switched from a 12-color
fixed palette to golden-angle HSL (137.5°) — consecutive techs always
get highly distinguishable hues, unlimited count.
- 🖥 SANS DÉPLACEMENT: per-job persistent flag (hub store data/
job-remote.json + POST /roster/job-remote; buildUnassigned sets
j.remote). optimizePlan EXCLUDES remote jobs from routing and address
pinning (e.g. "Configuration de boîtier tel" doable via ACS — nobody
travels) → returned in a remote[] bucket, shown as a "🖥 Sans
déplacement" group in the review, assignable to an agent. Toggle
button (computer icon) on every review entry. Verified: "Boitier
ouvert | Lac des pins" flagged → excluded from routes, then reverted.
- 📦 GROUPER EN PROJET: same-address entries (🔗) now open a menu →
"Grouper en projet" sets ERPNext parent_job on the shorter tasks
(parent = longest), POST /roster/job-group. Plugs into the existing
parent-child pool sort ("Groupe (parent-enfant)").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P4 — the FULL optimize orchestration now lives in the hub
(POST /roster/optimize-plan): pool (buildUnassigned), day bucketing
(dated-in-window → its day; overdue/undated → day 1 with spillover to
next day if unplaced), vehicles from real shift windows (assignments ×
templates) or assumed 8-16 weekdays (assumed_shift map returned; never
weekends; absences excluded), existing load (occupancyByTechDay) reduces
capacity, origins = home else TARGO depot, priorities from j.priority,
per-job AM/PM windows + duration overrides accepted, OSRM matrix +
OR-Tools solve per day (shared solveVrp, also used by /optimize-routes).
Reusable by the morning auto-plan cron and the field app.
Address rules (user cases baked in, hub-side):
- SAME ADDRESS = ONE STOP: pool jobs sharing an address key (~10m coords
or normalized address) are merged into one solver node (service times
summed) → same tech, single stop. Verified: install + TV boîtier of
the same customer (Cazaville) grouped on Stéphane, 🔗 badges.
- COORDINATION: a pool job at the address of an ALREADY-ASSIGNED job
that day is pinned onto THAT tech (they're going anyway) — verified:
"Réinstallation #253028" pinned to Frédérique who already has the
install there; capable flag kept honest (⚠ if skill missing).
SPA: optimizeSuggestion → hub-first (dates/techs/opts/dur_overrides/
job_windows), entries enriched client-side (lvl/eff/noShift from
assumed_shift); local orchestration kept as emergency fallback. Review
shows 🔗 teal (coordonné avec déjà-assigné) / 🔗 indigo (N jobs même
adresse = 1 arrêt); map merges same-coord stops into one numbered pin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1 — self-hosted OSRM replaces ALL paid Mapbox routing in the SPA:
- hub: POST /roster/osrm-route (geometry + total + per-leg km/min) and
/roster/osrm-table (durations/distances matrix, Mapbox-shaped) proxying
the local OSRM; osrmGet helper.
- SPA: loadRealRoutes (review map), kanban travel matrix, day-editor
matrix, day-map route geometry → all via hub OSRM. 0 paid
directions/matrix calls left (tiles + geocoding only).
Review usability (evaluate whether times fit):
- Click a job → the SAME job-detail dialog as the grid (address, ticket
thread, duration, team). openEntryDetail maps the pool job.
- Per-task time is editable (q-popup-edit on the "X.Xh"): updates the
plan live, survives Ré-optimiser (durOverride), persists duration_h
via patchJob (hub whitelist extended, 0-24h).
- Real travel time BETWEEN stops: one OSRM route per tech×day (cached)
→ "🚗 X min" rows between entries + "domicile → 1er arrêt";
haversine ≈ fallback when coords missing.
Verified: OSRM proxies live (route 7.8km/12min with legs 7+6min, table
3x3); review shows 10 real leg rows; detail dialog shows address;
duration popup opens/cancels cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- shared priorityMeta/PRIORITY_LEVELS → 3 levels: Haute (RED) · Moyenne
(neutral grey) · Basse. Default/medium = grey outlined_flag. Aliases
urgent→high, normal→medium for old conversation data.
- conversations: priority endpoint accepts 'medium' (keeps old values for
back-compat); the flag menu now shows the 3 levels.
- jobs: dropped the parallel job-flags override store — the job flag now
writes the REAL ERPNext priority (low/medium/high) via patchJob/
updateJob, same source as the pool sheet (no more two mechanisms).
setEntryPriority reuses patchJob. Solver: high never dropped + served
early, low dropped first.
Verified: job flag + conversation flag both grey-by-default, menu Haute/
Moyenne/Basse, high=red.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The urgent flag was a red emoji shown red before being set — wrong.
Now reuses the exact Communications component: priorityMeta() + a
priority list (Urgent/Haute/Normale/Basse/Aucune) — grey outlined_flag
by default, colored only when set.
- SPA: per-job flag = q-btn(icon=priorityMeta(...).icon) + q-menu list,
same as ConversationPanel. setEntryPriority(name,lvl) (renamed to avoid
clashing with the existing pool setJobPriority(j,p)). medium (ERPNext
default) shows as grey/none.
- hub: job-flags store now holds a dispatch PRIORITY (urgent|high|normal|
low) overriding j.priority in buildUnassigned — needed because the
ERPNext job-priority field is whitelisted to low/medium/high (can't do
urgent), just like conversations keep their own priority.
Lesson applied: grep for an existing symbol before declaring (2nd
collision this session — techsForJob, setJobPriority).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Like the omnichannel inbox urgent flag, but for jobs — a 🚩 that
persists and drives the optimizer.
- hub: durable job-flags store (data/job-flags.json) mirroring
job-levels — getJobFlags/setJobFlag + POST /roster/job-flag {name,
urgent}; buildUnassigned sets j.urgent; invalidatePool on write.
- SPA: 🚩 chip on each review entry (replaces the session ⚡). Optimistic
local toggle + persists via roster.setJobFlag; reflects j.urgent from
the pool. Flagged jobs → priority_boost (never dropped) + urgent_weight
(served early). Bumped urgent early-weight 6→10.
Verified: flag round-trips (set→urgent, revert→cleared) through the pool;
chips render (🚩/AM/PM).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real vehicle-routing optimization behind the Suggérer UI, reusing our
existing OR-Tools solver service (no new stack). Fixes the greedy's
structural limits (sector-splitting, no global optimization).
- roster-solver: new route_solver.py (OR-Tools Routing / VRPTW).
Minimizes real travel; skills = hard filter (VehicleVar ∈ allowed∪{-1};
SetAllowedVehiclesForIndex has a broken Span typemap in ortools 9.15);
on-site service time within each tech's shift window; optional per-job
time windows; unfittable jobs left unassigned (drop penalty) instead of
infeasible; specialist bias via skill order (per-vehicle arc cost).
New POST /route endpoint. Dockerfile now COPYs route_solver.py.
Unit-tested: skills respected, sectors consolidated, edge cases safe.
- hub: POST /roster/optimize-routes → proxies to solver /route.
- ops SPA: "⚡ Optimiser" strategy. Greedy buckets jobs into days +
placeholders, then the solver re-optimizes each day (assignment +
routes) among shifted techs; result maps into the same review dialog
(occupation bars, route map, swap/merge reused). Graceful fallback to
greedy if the solver is unreachable (never drops jobs). shiftWindowMin
derives each vehicle's shift from templates.
Phase 2 (later): OSRM/Mapbox road-time matrix for exact travel times.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Niveau requis PAR JOB persistant : store hub durable (job-levels.json) + endpoint
POST /roster/job-level + buildUnassigned enrichit required_level ; pastille « niv »
écrit via l'API (survit reload/session). NB : l'API Custom Field ERPNext v16 échoue
(IndexError) sur ce doctype custom → store hub (migratable en champ ERPNext plus tard).
- Lasso FREEFORM : tracé libre (souris + tactile) → polygone SVG + point-in-polygon sur
les pins/amas projetés (remplace le rectangle) + expansion des amas (getClusterLeaves).
- D v2 : carte des tournées = routes ROUTIÈRES réelles (Mapbox Directions par tech,
domicile→arrêts) + distance/temps RÉELS dans la légende (cache par signature ;
fallback segments droits + estimation haversine).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).
Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Comms: sélecteur d'expéditeur « De: » (défaut groupe Support TARGO) via resolveSendFrom + alias vérifiés
- Notifs: prefs de feeds PAR utilisateur (/conversations/notif-prefs) + cloche à bascules ; boot tooltip-ux (clic prioritaire + anti-empilement)
- Courriel: invitation à évaluer = modèle Unlayer éditable (transactional-rating-invite-*) ; test-send via Gmail + expansion {{rating}} ; logo TARGO auto-hébergé sur le magasin d'actifs du hub
- Planif: bloc « sans déplacement » (damier, début de quart, alerte si pas de quart), quart éditable dans l'éditeur de jour, icônes de compétence en vue jour (TV pour télé), clic cellule → éditeur, clic gauche lane → liste / clic droit → menu quart, lien ↗ ticket par job
- Tickets: défaut « Non fermés » + correction du filtre « Mes tickets » (owner)
- Inbox: poll Gmail 1 min + rafraîchir à la demande (poll-now)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hub (lib/roster.js, vision.js, legacy-dispatch-sync.js, server.js) + Ops + pont legacy.
- Capacité par jour AM/PM (Soir = réserve garde/urgence, jamais offerte) calculée
client-side sur les techs visibles -> suit le filtre de compétence.
- Modèle de durée ADDITIF (caractéristiques, tableur inline) + auto-détection
DÉTERMINISTE par mots-clés (sans IA permanente) ; est_min branché sur capacité + pool.
- Capture terrain passive : endpoints publics /field (job/tech/checkpoint/ts/photo/
device/vision), tokens HMAC signés sans PII ; dérive actual_start/end. UI hébergée
public/field-app.html (liste/carte Mapbox/Street View/photo/scan MLKit->Gemini).
- Chrono job (start/finish), repositionnement carte (set-location), vue satellite,
Street View clic-droit, année devant les dates dues groupées.
- Sync techniciens : rapport de réconciliation 3 systèmes (staff legacy / Dispatch
Technician / groupe Authentik), application MANUELLE, zéro écriture Authentik (+11 fiches).
- vision.js : extractEquipment() réutilisable (marque/modèle/série/MAC/codes-barres).
- Pont legacy (ops_reassign.php) : désassignation reflétée, fermeture ticket, retour
au pool ; notification courriel à l'assignation.
Déployé sur le hub ; ce commit aligne le repo sur l'état en production.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dernier recours quand l'adresse exacte est introuvable : placer le Service Location au CENTROÏDE
(rqa_addresses) de son code postal (préféré) sinon de sa ville → le job apparaît dans le bon secteur.
- Hub : applyAreaFallback() (CTE centroïdes CP/ville, index-friendly) + POST /address/conformity/apply-area.
Statut 'area', linked_address '≈ centre <CP/ville>'. Hors-QC/junk (absents de rqa_addresses) restent unmatched.
- Ops : carte stat « ≈ Secteur (CP/ville) » + bouton « ≈ Centre CP/ville (reste) » dans la page Conformité.
Exécuté : 317 placés (303 par code postal, 14 par ville) → unmatched 365 → 48 (Toronto/boîtes postales/junk).
État final : validated 16 257 · review 489 · area 317 · unmatched 48 → 99,7 % des services ont une position.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rend le mécanisme réutilisable (« faire de même pour tous les campings ») :
- Hub (address-conformity.js) : GET /address/conformity/campings (registre + nb lots par camping),
POST /campings (upsert {keyword,name,address,lat,lon} → applique direct), POST /campings/apply (réappliquer).
applyCampings() = UPDATE des lots (match ville normalisée) → géoloc fixe du camping.
- Ops (page Conformité adresses) : section « Campings — géoloc de remplacement fixe » : table du registre
(nom, adresse principale, GPS→Google Maps, nb lots) + formulaire d'ajout (nom/mot-clé/adresse/lat/lon)
qui ajoute ET applique, + bouton « réappliquer ». api/address.js : campingsList/Upsert/Apply.
→ Pour un nouveau camping : on saisit son adresse principale + GPS, tous ses lots pointent dessus (le tech
navigue au camping). Registre seedé : Lac des Pins, Dauphinais, SandySun, Frontière, Ensoleillé.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plus besoin de re-chercher avec un processus complexe : une page liste les adresses de service non
conformes (review/unmatched) avec leur proposition AQ canonique, et permet de RÉSOUDRE une fois (persisté) :
- Approuver : la proposition AQ devient officielle (validated, coords RQA).
- Corriger : recherche AQ locale (rqa_addresses + fibre) → lier la bonne adresse.
- GPS : saisir/coller lat,long (relevé sur map.targointernet.com qui a la géoloc des unités de camping)
+ lien direct « voir sur la carte » par ligne.
- Rejeter : pas d'adresse civique (boîte postale/hors-QC) → 'no_address'.
Tri par type (camping / civique à corriger / à confirmer / non-adresse) + stats + recherche + pagination.
Backend : lib/address-conformity.js (GET stats|list|candidates, POST resolve) sur le Postgres LOCAL,
routé /address/conformity/* (server.js). Front : api/address.js + pages/AddressConformityPage.vue + route
/conformite-adresses + entrée nav « Conformité adresses » (icône MapPinned, requires view_settings).
État courant : validated 15 195 · review 1 366 · unmatched 550 (camping 540 / civique 333 / non-adresse 93).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- clic sur le progressbar → q-dialog ciblé sur le tech×jour (garde le contexte de la grille derrière) :
timeline visuelle (blocs colorés par compétence) + liste éditable des jobs
- réordonnancement par DRAG-DROP (dragstart/dragover/dragend → route_order) + sélecteur de priorité + Enregistrer
- retrait d'un job (✕ → hub POST /roster/unassign-job : assigned_tech null, status open → retour au pool)
- bouton « Dispatch » comme échappatoire vers le tableau complet (gotoDispatch)
- réutilise occupancy/cellBands/cellBlocks/blockStyle + reorderJobs ; best-practice détail-drawer (pas de navigation pleine page)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Blocs d'occupation = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable),
au lieu du dégradé vert→rouge par taux. Hub occupancyByTechDay attache skill+job à chaque bloc
(skillForJob||deptToSkill) ; blockStyle → getTagColor(blk.skill).
- Clic sur le progressbar (.tl, @click.stop+@mousedown.stop) → menu : liste des jobs du tech×jour avec
réordonnancement (flèches → route_order) + re-priorisation (select) + Enregistrer.
Hub POST /roster/reorder-jobs (route_order/priority, séquentiel) ; tri occupancy par route_order.
- Clic HORS du progressbar dans la cellule → menu shift inchangé (créer/modifier).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Couleurs liées aux skills (éditable/cohérent) : hub deptToSkill() déduit une compétence du type legacy
→ /roster/unassigned-jobs renvoie required_skill ; PlanificationPage colore la carte par getTagColor(required_skill)
(même couleur que le chip skill) ; bordure 5px
- Fil complet du ticket : hub /dispatch/legacy-sync/ticket-thread (ticket_msg + auteur staff, HTML nettoyé) ;
api legacyTicketThread ; RightPanel bouton « 💬 Voir le fil / commentaires » (chargé au clic, messages+auteurs+dates)
- Order-by du pool dispatch : useBottomPanel.bottomSort (date|city|priority) + dropdown ⇅ dans BottomPanel
(ville = 2e segment adresse / token sujet avant |)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Panneau « jobs à assigner » v2 : multi-sélection (cases), groupes parent-enfant
surlignés, heuristique terrain/à distance (activation/netadmin pré-décochés),
pré-total d'heures, aperçu d'occupation PROJETÉE au survol (barre fantôme + badge).
Fix barre d'occupation figée après drop : /roster/assign-job pose désormais un
start_time (premier-trou-libre dans le shift) + garantit duration_h, sinon le job
compte dans les heures mais n'affiche aucun bloc. Nouvel endpoint
/roster/backfill-start-times (idempotent) pour rattraper l'historique.
Infobulle de cellule : nb de jobs + liste triée par priorité (occupancyByTechDay
renvoie jobs[]). Timeline contextuelle par ressource (dialogue, 0 appel réseau).
Lisibilité du drag : fantôme compact semi-transparent décalé sous le curseur
(ne masque plus l'aperçu) + source estompée.
Scoring de priorité : hook proximité (neutre — secteur géré manuellement),
réservé à 20% du score quand la géoloc arrivera.
Refactor hub : helper partagé firstFitStart (assign-job + backfill).
Nettoyage : retrait code mort (onDeleteRosterTag, projUsedH), carte des sections
en tête de PlanificationPage. Doc : docs/features/roster.md + index.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clic sur une case → touche « A » (bascule) ou menu « Marquer absent ce jour / Retirer l'absence » →
crée/supprime une Tech Availability d'1 jour APPROUVÉE (hachurée tout de suite). Multi-sélection
supportée (A marque toutes, re-A retire). Endpoint POST /roster/absence/set {tech,date,type,remove}
(remove ne touche que les absences d'un jour, pas les vacances multi-jours). Type défaut Congé.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: « Appliquer à la semaine » n'écrivait qu'une semaine → 1 seul tech (rotation hebdo). Maintenant
« Générer la garde » matérialise la rotation sur N semaines (4/8/12/26) d'un coup, écrit direct (publié),
navigable semaine par semaine — comme un évènement récurrent. Endpoint /roster/garde/apply : wipe ciblé
des shifts de garde dans l'horizon + recréation (idempotent, reflète l'édition de la séquence). Saut
d'absent conservé. (source='manuel' car le Select n'autorise que solveur/manuel.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chaque cellule tech×jour avec un shift affiche, sous le chip (J8), une mini-barre + % colorés
(vert <70, orange 70-99, rouge >=100 surbooké) + infobulle = intervalle du shift + h occupées/h.
Occupation = Σ duration_h des Dispatch Jobs planifiés assignés ce jour ÷ Σ heures du shift.
- hub: occupancyByTechDay(start,days) + GET /roster/occupancy → map 'TECH|date': heures.
- ops api: getOccupancy ; PlanificationPage: occCells (computed), cellOcc/occColor/cellInterval,
rendu barre + q-tooltip, chargé dans loadStats. Données démo semaine 8 juin (45/85/120%).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RendezVousPage:
- Vue segmentée À planifier / À recontacter / Tous.
- Créneaux proposés groupés Semaine → Jour (se situer dans le temps, comme /book).
- Hold à la sélection: bookHold(date,start,10min) → bloque les autres; libéré à la confirmation
ou au changement de job (onBeforeUnmount).
- File À recontacter (jobs À reporter) + actions: Lien client (copie URL self-serve),
Aviser par SMS (notify-reschedule: désassigne + SMS lien /book).
CopilotePage: carte réglages des créneaux offerts (#56) — lead_hours, plage horaire,
horizon, max/jour, hold, jours offerts (chips) → savePolicy({booking}).
api/roster.js: bookHold, bookLink, jobsToReschedule, notifyReschedule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: ne rechargeait que sur 401/403 stricts → ratait la redirection IdP / page HTML de
login (vrai cas d'expiration) → données vides nécessitant un refresh manuel. Maintenant:
détecte redirection auth.targo.ca + HTML-au-lieu-de-JSON → reload auto (anti-boucle 20s),
+ 1 retry sur coupure réseau transitoire (ex: backend qui redémarre).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nouvelle page /copilote : chat texte/voix (Web Speech API fr-CA) vers le copilote Gemini Flash
(impact d'absence + propositions de réassignation), + sélecteur de politique de reprise
(réassign/SMS/escalade) persistée. Route + nav (icône Sparkles ; ajout CalendarRange/CalendarClock
manquantes dans la map d'icônes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces ERPNext's Email Queue in Ops (nav « File courriels ») so ops can see
what's queued — important now that mute_emails=1 + scheduler paused mean nothing
flushes — and delete/purge stale entries without the ERPNext desk.
- hub lib/email-queue.js: GET list (by status, recipients read from each row's
full doc since ERPNext ignores fields on child-doctype REST), DELETE :name,
POST /purge {status}. Wired in server.js.
- ops: api/emailQueue.js + EmailQueuePage.vue (status filter, recipients,
reference, error tooltip, per-row delete + « Purger Not Sent »), route + nav.
Verified live: 13 'Not Sent' (old internal test emails, no invoice refs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New Ops report to surface clients whose net monthly Internet bill
exceeds a threshold — for spotting plans that should be revised.
Hub (lib/legacy-reports.js — new module, read-only MariaDB):
- GET /reports/legacy/overpriced-internet (+ .csv variant)
- Queries the legacy gestionclient DB directly via a small mysql2 pool
(reuses cfg.LEGACY_DB_* — same vars as auth.js sync-legacy; added
LEGACY_DB_PASS to the hub .env which was previously unset).
- Grain = delivery (service address), NOT account: a multi-unit
building (account 13166 has 82 doors / 205 services) would otherwise
show a single bogus $2117 line instead of ~45 per door.
- Net monthly Internet = SUM of effective per-line price across
Internet categories (32 fibre, 4 wireless, 23 camping + optional
add-ons 16/17/21), discounts included (products with price<0 are
recurring credits like RAB24M -15$).
- Effective price = service.hijack ? hijack_price : product.price.
- Only recurring lines (product.price_recurr_type=1) — excludes
one-time equipment/install charges.
- Annual plans (SKU LIKE '%ANN', e.g. FTTH_ANN @ 480$/yr) normalized
/12 so they compare correctly against a monthly threshold (was
falsely showing $480 → now $40, drops below 90$).
- Excludes TV (33,34) and téléphonie (9) entirely.
Validated counts at 90$/mo: 983 residential, 297 commercial addresses.
Ops UI:
- src/pages/ReportInternetCherPage.vue — threshold/segment/add-ons
filters, summary cards (count, total monthly, avg, discounts),
sortable+filterable table (client, address, net, gross, discount,
plan detail with full tooltip, contact), CSV download.
- Card on the Rapports hub + route /rapports/internet-cher.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "Créer une relance" button on the campaign detail page that
clones the parent campaign into a new draft, targeting only the
recipients who haven't clicked the Giftbit gift link yet.
Backend (POST /campaigns/:id/reminder):
- Filters parent recipients: status sent/opened, not excluded, not
revoked, wrapper not yet expired, has a gift_url.
- Builds a fresh recipients array — same gift_url (Giftbit shortlink),
same name/email/language/amount, but cleared gift_token so the worker
generates a brand-new wrapper at send time. Each campaign owns its
own click metrics.
- New campaign starts as 'draft' so the operator can review, tweak
subject/template, and click "Lancer l'envoi" when ready.
- Tracks parent_campaign_id + parent_row_index on each reminder row
for traceability in CSV reports and debugging.
Templates (gift-email-reminder-fr / gift-email-reminder-en):
- Header swap: "Petit rappel pour {firstname}" / "Quick reminder, X"
- Bold orange urgency line: "⏰ Hâte-toi! Ton cadeau de X expire le Y"
using the existing {{expires_at_date}} and {{amount}} merge vars
- Body shortened — drops the manifesto, focuses on "you have a gift,
redeem before it's gone"
- Same CTA button + prorata disclaimer + signature + footer as the
main templates so brand stays consistent.
UI:
- Button visible when campaign is sending/completed AND it's not
itself a reminder AND there's ≥ 1 eligible non-clicker.
- Confirmation dialog spells out the mechanics: same Giftbit URLs,
new wrapper tokens, reminder template, sample expiry date pulled
from the campaign's first recipient with a gift_expires_at.
- On OK, redirects to the new campaign's detail page.
Click stats on the existing campaign (cmp-20260522-2d4605) verified
intact before+after deploy (109 opens, 15 generic clicks, 27 gift CTA
clicks) — saveCampaign persists per-event so the hub restart was a
no-op for accumulated data.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The send worker used to write "SMTP send returned false (see hub logs)"
on every failure, forcing the operator to SSH into the box to find the
actual cause. Now we capture the real reason and surface it in the UI.
Three changes:
1. lib/email.js exposes getLastError() — a side-channel for the most
recent nodemailer error message, cleared at the start of every
sendEmail call. Legacy "if (await sendEmail(...))" callers stay on
the false-return contract; only the campaign worker reads the
side-channel for detailed error capture.
2. The worker now retries each recipient up to 3 times (initial +
2 retries with 2s/5s backoff). Most "Unexpected socket close"-style
transient Mailjet errors recover on the second attempt. We observed
exactly this case for Myriam Bergevin in cmp-20260522-2d4605 — a
single socket close interrupted 1 of 202 sends; auto-retry would
have caught it. retry_count is now stored on the recipient.
3. POST /campaigns/:id/recipients/:row/retry resets a single failed
row back to pending and re-fires the worker. Surfaced in the
detail-page table as a small 🔁 button next to the error text on
any row with status=failed. Useful when auto-retry exhausted its
3 attempts on a one-off transient.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wizard: gift_expiry_days now lives behind a preset toggle
(15/30/60/90/180 + Custom) instead of a naked number input. Operator
clicks a chip; the value flows back into the existing campaign param.
Inventory page (/campaigns/gifts):
- Cross-campaign view of every wrapper token with status taxonomy
(active / redeemed / expired / revoked / pending). Each card on
the counters strip is a click-to-filter shortcut.
- "Réassignables" highlighted in amber when > 0 — these are gifts
whose wrapper expired or was revoked but the Giftbit URL is still
unredeemed, ready for a fresh recipient.
- Search across name/email/url/token; per-status and per-campaign
filter dropdowns.
- One-click copy on the Giftbit URL with a tailored toast that walks
the operator through the reassignment workflow (paste into manual-
add dialog of a new campaign).
- Revoke action with confirmation; explicit about what survives
(the Giftbit URL stays valid on their side) vs what changes (our
wrapper stops redirecting).
Backend:
- GET /campaigns/gifts flattens every recipient with a gift across
every campaign — single-shot, no pagination yet (we're under 10k
gifts total).
- POST /campaigns/:id/recipients/:row/revoke sets gift_revoked=true
and broadcasts the recipient-update SSE event.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DELETE /campaigns/:id removes the JSON from /opt/targo-hub/data/campaigns/.
The Giftbit shortlinks already issued for that campaign live on Giftbit's
side and are unaffected — this is purely about clearing internal tracking
records (typically test runs cluttering the list).
Refuses (409) while the send worker is active for that id so we never
yank the file out from under saveCampaign(). Defensive id regex
(in campaignPath) blocks path-traversal attempts before unlink runs.
UI: red trash icon on each row, disabled while status=sending.
Confirmation dialog spells out what survives the deletion (Giftbit
links) vs what's lost (tracking, opens/clicks, CSV report) so the
operator isn't surprised.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ops UI
- CampaignDetailPage: "CSV" button — downloads per-recipient report
(shortlinks, status, opened/clicked timestamps, mailjet UUID)
- CampaignNewPage: "Saisie manuelle (sans CSV)" on Step 1 and
"Ajouter manuellement" on Step 2 — both open the same dialog with
firstname / email / gift_url / city / postal_code / language /
amount override. Indigo "manuel" chip in the recipients table.
- New "Ville" column shows city OR postal_code as fallback.
Hub
- GET /campaigns/:id/report.csv — RFC 4180 CSV with UTF-8 BOM so
Excel auto-detects encoding. 20 columns including new "city".
- Worker honours per-recipient amount override:
r.amount > derive from r.gift_value_cents > params.amount > "50 $".
Fixes manual-add showing campaign default instead of typed value.
- Default subject "Un cadeau pour toi" (tutoyer).
Templates
- Order: Intro → ✅ Option 1 → 🎁 marques → CTA → prorata → ⏭️ Option 2.
- New EN intro (manifesto): "Thank you for choosing local. Your
support helps keep our community connected. / Because great
connections aren't just about fiber — they're about people too."
- Amazon logo removed (incongruent with "achat local" framing).
- Body paragraphs: text-align justify (greeting/labels stay left).
- Support line: "N'hésite pas à nous écrire / Feel free to email us"
+ dash format 514-448-0773, drop "Support 7j/7" overpromise.
- Logo style fix: inline width:32px to beat Unlayer canvas CSS that
was rendering brand pills full-width.
Ignore template converter .bak-*.json backups.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New "Traduire (AI)" button in the template editor toolbar. One click
translates the current template's HTML to the opposite language
(detected from the -fr/-en suffix), writing the translated content as
the matching companion template.
Backend (lib/campaigns.js):
- New endpoint: POST /campaigns/templates/:name/translate-to/:targetName
- Reads source .html, calls lib/ai.js aiCall() with Gemini Flash
- System prompt enforces 7 strict preservation rules:
1. Byte-preserve all HTML tags/attributes/styles/Outlook conditionals
2. Don't translate Mustache {{vars}}
3. Preserve URLs/emails/phones/hex colors/CSS/brand names (TARGO,
Gigafibre, Giftbit, Amazon, IGA, Tim Hortons, etc.)
4. Preserve emojis (🎁⚡🤝🪂✅⏭️⏰)
5. Keep the warm informal tone (tu in FR, you in EN)
6. Translate only visible text inside elements (paragraphs, buttons,
alt attributes, link text)
7. Output full HTML doc only, no markdown wrapping
- temperature=0.2 for stable output, maxTokens=32768 to fit ~35 KB HTML
- Sanity validates output isn't truncated (>50% of source size)
- Strips defensive markdown fences if AI ignored rule 7
- Auto-backs up existing target before overwrite
- Regenerates Unlayer design JSON from the translated HTML so the
editor can reload the translated template visually
- Requires { override: true } in body to overwrite existing target
(409 Conflict otherwise — protects against accidental clobber)
API client (apps/ops/src/api/campaigns.js):
- translateTemplate(srcName, targetName, { override })
Frontend (TemplateEditorPage.vue):
- "Traduire (AI)" button (purple, icon=translate) in toolbar — disabled
when current template has no -fr/-en suffix
- aiTranslateTargetName computed: detects source lang from suffix,
flips to opposite (-fr → -en, -en → -fr)
- Confirmation dialog:
• Shows source → target template names
• Info banner explaining what's preserved (HTML, vars, brands, emojis)
• Amber banner + toggle if target exists (must confirm override)
- On success: positive notification with byte counts +
"Open" action button to jump to the translated template
- Refreshes templates list after translation so the new file appears
in the selector dropdown
UX: replaces the previous manual translation workflow (where the user
or I had to maintain two parallel templates). One click now does the
whole round-trip. User reviews + adjusts wording in the EN editor if
the AI translation needs polish.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After honest acknowledgment that easy-email-standard is abandoned and
limited (Chrome-only, no responsive preview, no AMP, no Unsplash, no
file manager), pivoted to Unlayer's vue-email-editor — a Vue 3 native
component giving all the features the user listed for free (internal
use; a small "Powered by Unlayer" badge shows in the sidebar but NOT
in sent emails).
Why drop MJML alongside:
• MJML was our SERVER-SIDE compilation step because we hand-wrote
templates. With a visual editor that outputs email-safe HTML
directly (responsive media queries, Outlook MSO fallbacks, AMP
where used), the compilation step is redundant.
• One fewer dependency on the hub (mjml package no longer needed).
• One fewer file format to persist (.mjml dropped, only .html
canonical + .json design).
Storage simplification:
Before: .mjml (source) + .html (compiled) + .json (editor state)
After: .html (canonical) + .json (Unlayer design tree)
The hub's send-worker reads .html as before — no changes to send
logic.
Architecture wins:
• Vue 3 native — zero iframe friction, no postMessage choreography
• No separate microservice — easy-email container decommissioned
(docker compose down, code kept under /opt/email-editor/ in case
of rollback)
• DNS editor.gigafibre.ca retained but unused — can be removed via
Cloudflare API cleanup later
• The editor's mergeTags option exposes our {{firstname}}, {{amount}},
{{gift_url}}, etc. in Unlayer's native "Merge tags" panel — same
pattern, more polished UI
• Features now native: responsive preview (mobile/tablet/desktop
breakpoints), Unsplash search, file manager, dark mode, design
history, undo/redo, layers panel, content blocks library
Frontend (TemplateEditorPage.vue):
• Imports EmailEditor from vue-email-editor
• onReady() callback: fetch template + loadDesign() to restore canvas
• saveTemplate(): exportHtml() → PUT { html, design } to hub
• Top bar kept: template selector, saved chip, preview, test-send,
save button
• Removed: iframe-related glue (postMessage listener, iframeKey,
EDITOR_BASE constant, Cmd-S handling that lived in the iframe)
API client (apps/ops/src/api/campaigns.js):
• saveTemplate() now accepts opts.design (Unlayer JSON tree) alongside
content. Legacy opts.format='mjml' still works for backward compat.
Hub (services/targo-hub/lib/campaigns.js):
• GET /campaigns/templates/:name unconditionally returns
{ name, format, html, design } (+ mjml when format=mjml for
legacy templates). The design field is null when no .json file
exists yet.
• PUT /campaigns/templates/:name HTML save path now accepts
body.design alongside body.html and persists both with backups.
• MJML save path (legacy) preserved for any callers using the old
contract.
Container decommissioned on prod: email-editor container stopped +
removed. The Vue editor lives inside the ops SPA, served from
erp.gigafibre.ca/ops as a normal route.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>