- default strategy = optimize → the FIRST Générer runs the VRP (real
techs, no more greedy placeholders on first attempt).
- occupation bar is now PER-DAY (worst day), so a 2-day window shows /8h
not /16h; overload shows in red.
- solver: each vehicle may go up to +20% overtime (≈120% cap, hard) but
time past the nominal shift is soft-penalized → overflow spreads to
techs still under 100% before anyone does overtime. Verified: 20 jobs /
2 techs → 9h+9h (balanced, ≤9.6h), overflow dropped; under capacity it
still concentrates (no forced equalization).
- priority medium flag = amber (yellow/orange) per request.
Verified live: default optimize on, 16 real techs / 0 placeholders,
per-day /8h occupation, none over, medium flag amber.
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>
- Overload fix: each vehicle's window is reduced by the tech's already-
assigned load that day (shift_start += existing) — no more 20.8/16h
(verified: William 20.8→5.3/8h). Capacity is now real.
- No more "Tournée N / non casé" pseudo-techs: the optimizer uses ONLY
the selected techs; everything it can't place goes to ONE plain
"⚠️ Non assignés" bucket (weekend + capacity/skill overflow). Assign
them via the proximity+load ⇄ menu, or add techs.
- Not forced to fill every tech: unneeded techs are simply left empty
(day off) — the VRP no longer fake-distributes.
- Edit/reorder skills from the optimizer: ✏️ on each tech header opens
the same skill editor (drag-reorder chips + ★ + rank list).
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>
Each job's move menu in the optimize review now reuses techsForJob()
(capable → with-shift → nearest → least-loaded) instead of alphabetical,
showing per tech: distance (home→job km) and load (h/cap). Lets you drop
a "non casé" job onto the best nearby, least-busy capable tech — the
manual version of "move a job to free a tech's capacity". Works on
placeholder/unassigned entries too (same row).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Time-aware dispatch in the VRP optimizer:
- solver: per-job urgent_weight → SetCumulVarSoftUpperBound(idx, 0, w),
a soft cost on arrival time that pulls urgent jobs to the START of the
route (conditions the tech's day start). Verified: a far urgent job is
served first instead of last. AM/PM handled via existing tw_start/end.
- SPA: per-entry AM / PM / ⚡ chips (session map suggestDlg.jobTime,
survives re-solves) + a "Ré-optimiser" button. optimizeSuggestion maps
AM→[480,720], PM→[720,960] (hard window) and urgency (job priority OR
manual ⚡) → urgent_weight. Verified: chips render, toggle persists
across re-optimize, OSRM still engaged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Config UI (Optimiser mode): live settings — a distance↔specialty
slider (rank_weight 0-10), fallback speed km/h, and solve-time budget.
Persisted to localStorage, passed to the solver per run → tune the
curve without redeploying.
- Urgent jobs prioritized: each job's priority maps to priority_boost
(drop penalty) — urgent/high never dropped, low priority dropped first
when capacity is tight.
SPA-only (solver already accepts rank_weight/speed_kmh/max_seconds/
priority_boost). Verified: settings panel renders, optimize runs
end-to-end (16 techs, OSRM).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Skill capability stays a hard filter, so a multi-skill tech (e.g. an
installer who also has the repair skill) can take those jobs. But the
specialist bias (skill-order rank) was too strong (6 virtual min/rank),
so a far repair-specialist beat a nearby installer-who-repairs. Lower
rank_weight to 2 → distance dominates; specialty only breaks near-ties.
Verified: repair job near a rank-3 multi-skill installer (4 min) vs a
far rank-0 specialist (14 min) — old weight picked the far specialist
(bug), new weight picks the nearby installer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The VRP « Optimiser » now treats each SELECTED tech as having a 8-16
shift (created at Publish via makeShifts) — not just those already
shifted. Weekday placeholders are folded back into their day so the
solver assigns them across all selected techs; weekend placeholders
stay (no auto weekend shift). Result: full multi-tech consolidation
instead of everything falling to placeholders — verified 34 jobs → 16
techs, tight routes (0.5–14.7 km each). Techs without a real shift show
« Créer quart » (their assumed 8h, created at Publish).
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>
Skill order (not just level) now drives auto-dispatch: index 0 in a
tech's skills = their primary function. A job goes to the specialists
of that skill first, sparing less-specialized techs for other work
(e.g. Louis-Paul does repairs but his primary is sales → repair jobs
prefer the repair/install specialists).
- buildSuggestion: skillRank = t.skills.indexOf(reqSkill) (0 = primary);
score += skillRank * W.rank, tuned per strategy (enough/smart weight
specialty highest). Skill capability stays a hard filter.
- TagEditor (shared component): chips are now drag-reorderable
(vuedraggable, touch-friendly) via `sortable` prop; the 1st chip is
marked ★ primary. Reorder emits the reordered array → persists as the
ordered skills CSV (order round-trips; stored in custom `skills`
field, not Frappe _user_tags, so no alphabetical re-sort).
- Skill editor: enable sortable chips + explicit priority numbers
(1,2,3…) on the per-skill list, #1 highlighted, plus a hint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Overhaul of the Planification « Suggérer » (auto-dispatch) flow:
- fix: read latitude/longitude (hub pool field), not lat/lon — the
distance scoring was inert (NaN) so jobs scattered; they now cluster
by real route (haversine) with a same-city fallback
- skill = HARD filter: incapable techs are no longer candidates
(Josée-Anne no longer gets réparation/installation). Assign/move menus
list capable techs first and block the rest, unless no capable
alternative exists (avoids trapping an unassignable queue)
- placeholder queues for any no-shift day (not just weekend), grouped by
skill then geography into ~8h tournées; the owner is swappable to a
real tech, and two queues can be merged
- per-tech occupation bar in the review (existing load + suggested /
capacity, red on overload); "Créer quart 8-16" button when the
assigned tech has no shift that day (local, saved on Publier)
- fix: "N jobs sans coordonnées" badge vs locate mismatch — honest
message + open the manual map picker for address-less jobs; also sync
pool jobs (latitude/longitude) on manual save so the badge refreshes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Logique métier : c'est le job précis qui impose la compétence/niveau, pas une
caractéristique globale. Retrait du bouton + éditeur « Niveau requis par compétence »
(level_by_skill/level_by_type globaux) et du repli global dans le moteur → le niveau
vient UNIQUEMENT du job (required_level persistant). La gestion compétences+niveaux se
fait PAR TECHNICIEN dans le Suggérer via l'icône ✏️ = TagEditor (chips + ★), le MÊME
composant que le tableau timeline (Dispatch). Sous-compétences (ex. émondage) = simples
compétences/tags créables et assignables au tech.
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>
- Sécurité : sanitize DOMPurify sur tout HTML entrant courriel/osTicket (v-html) →
ferme le vecteur XSS (ConversationPanel + IssueDetail). Nouveau src/utils/sanitize.js,
dompurify ajouté en dépendance directe.
- Fix : piège TDZ (watches de la carte des tournées placés avant les const suggestDlg/
suggestMapDay/suggestGroups) qui cassait le montage de PlanificationPage → déplacés
après les définitions.
- Finition : étiquette de fenêtre du dispatch auto « Aujourd'hui + Demain » (au lieu de l'ISO).
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>
- Email posts now render at FULL content height (fitFrame cap raised 1800→20000 guard)
so there is no per-message internal scroll — the whole thread scrolls as one.
- The live draft mirror ("X rédige une réponse…") gets a close X (closeOtherDraft):
dismiss another agent's in-progress draft preview; reset on conversation switch so
a genuinely new draft re-appears.
Verified: a 60-paragraph email renders at 2681px (no inner scrollbar); the mirror
shows a close button that hides it on click.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Email messages now size to their own content (fitEmailFrame): iframe sandbox gains
allow-same-origin (NO allow-scripts — scripts stay blocked, parent just measures
body.scrollHeight) and the height is set on @load + re-measured on expand. Removes
the fixed 300px frame and the "fill empty space" stretch (no more big whitespace).
msg-body is now v-if (renders on expand) so the measure runs while visible + lazier.
- Reply arrow in each CUSTOMER message header → opens + focuses the composer without
scrolling to the bottom button.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sender identity on outbound:
- Replies/new emails now go out as e.g. "Gilles Drolet" <support@targo.ca> instead
of the generic alias name "Service TARGO". The name is derived from the agent's SSO
email (gilles.drolet@ -> "Gilles Drolet"); the ADDRESS stays the monitored alias so
replies still land in the shared inbox. Verified: Gmail honors the custom display
name on the send-as alias (sent From header = "Gilles Drolet <support@targo.ca>").
- gmail.sendFrom() exported; conversation.js agentSendFrom() builds the From from the
stamped msg.agent (replies) / x-authentik-email (new sends via email-new).
UI cleanup (space + clarity, Gmail-style):
- One thin separator line between messages; removed the rounded gray card box around
emails (.email-card flat), the email-card-head bar, and the blue expanded-head
highlight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On mobile the conversation full-page used a fixed height + overflow:hidden, and
the reply q-editor had no max-height — a long body/signature grew it unbounded
and pushed the send button past the clipped bottom (user had to shorten the email).
- q-editor: max-height=40vh → content scrolls internally, toolbar + send row stay put.
- ConversationFullPage @media (max-width:700px): height auto + min-height 100dvh +
overflow visible → the page scrolls vertically instead of clipping.
Verified at 390x844 with a 27-line signature: editor caps at 40vh (overflow auto),
"Envoyer le courriel" stays in view.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inbox identity/grouping (fixes mislabeled threads):
- customerName = real From display name, then a customer matched BY EMAIL,
then the raw address — never the AI's content-guessed name (a gilles@ relay
showed as "Sylvie Juteau").
- never group a thread by one of our own domains (support@targo.ca,
*@targointernet.com): re-ingested outbound was collapsing unrelated threads
into one mislabeled "Guylaine Gagnon" thread.
Refactor: queues/TYPES/QUEUE_OF/CAT centralized in lib/categories.js (were
drifting across conversation.js + inbox-triage.js; telephonie/television had
no matching triage type). Removed dead export findConversationByEmail.
Feat: addMessage stamps msg.agent on outbound replies -> per-agent stats.
Historique (/historique, HistoriquePage.vue): tournées par technicien (ALL
statuses incl. Completed/Cancelled — the board hid them), tech leaderboard,
inbox leaderboard. Hub: /dispatch/history, /dispatch/leaderboard,
/conversations/leaderboard.
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>
- Panneau « Jobs à assigner » : tri/groupes par DATE DUE (≠ création) avec étiquettes
relatives (« Aujourd'hui », « MM-DD ⏰ en retard », future) + date due affichée sur chaque ligne
(overdue en orange). ASC/DESC place aujourd'hui dans l'ordre chronologique.
- Fermeture (unitaire + lot) : confirm via $q.dialog (au lieu de window.confirm) — propre + fiable.
- Carte → liste : clic sur un pin sélectionne + scrolle la ligne dans la liste + déplie son fil + flash visuel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Axe de temps (hdr-ruler 0..24) retiré des en-têtes de jour (inutile dans la grille ; encombrant).
- La bande d'occupation `.tl` ne stoppe plus la propagation (plus de @mousedown.stop/@click.stop) → le
clic/drag sur la cellule remonte à `<td>` → onCellClick (menu d'horaire) + onDown/onEnter (sélection) refonctionnent.
- L'éditeur de journée s'ouvre désormais en cliquant un BLOC de job (.tl-blk, @click.stop) — pas toute la
bande. Donc : clic sur un bloc coloré = éditer la tournée ; clic ailleurs dans la cellule = modifier l'horaire.
Le tooltip d'occupation (survol) reste actif. Curseur/hover sur les blocs cliquables.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Barre timeline : l'espace entre 2 jobs (le déplacement) est rendu en POINTILLÉS (au lieu du gris vide),
tooltip « 🚗 déplacement ». dayTravelSegs() = gaps entre packedDay[i].end et [i+1].start.
- Carte : clic sur un pin → POPUP avec détails du job (n°, sujet, heure, client) ; curseur main au survol.
- Liste : clic sur une ligne → recentre la carte (easeTo zoom 14) sur ce job, en plus d'ouvrir le détail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remplace la mini-image statique (segments à vol d'oiseau) par une carte Mapbox GL :
- Itinéraire ROUTIER réel via l'API Directions (geometries=geojson) tracé sur la carte (halo + ligne).
- Pins numérotés dans l'ordre de tournée (cercle coloré par compétence + numéro).
- Navigable : zoom molette + boutons NavigationControl (+/-), déplacement (pan), ajustée au territoire (fitBounds).
- Lifecycle : init à l'ouverture du dialogue (après anim + resize), refresh débouncé au réordonnancement
(re-trace l'itinéraire), destruction à la fermeture (pas de fuite). mapbox-gl chargé en CDN (comme le Dispatch).
- Avertissement « N sans coords » conservé. Validé : Directions OK (géométrie 392 pts).
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>
- Minimap Mapbox Static ajoutée sous la timeline : pins numérotés dans l'ordre de tournée + tracé reliant
les arrêts, ajustée auto au territoire des jobs → on VÉRIFIE d'un coup d'œil que chaque arrêt tombe à la
bonne adresse (un pin mal placé = coord à corriger via Conformité adresses). Clic → carte interactive (Dispatch).
Indique « N sans coords (absent de la carte) » le cas échéant. Helper encodePolyline (precision 5) pour le tracé.
- Sélecteur de priorité retiré de chaque ligne (défaut « Moyenne » conservé en donnée, géré au Dispatch) → gain de place.
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>
Pont (legacy-dispatch-sync.js) :
- Import des coordonnées par job via cascade : table legacy `delivery` (point de service exact,
JOIN ticket.delivery_id) > Service Location ERPNext > géocodage RQA > géocodage Mapbox.
Validation bornes Québec (coord()). Couverture 153/172 (89%).
- Géocodage RQA corrigé : retrait du générique de voie (Rue/Rang/Chemin absent de
odonyme_recompose_normal) + code postal non accolé au terme (sinon ilike ne matche jamais).
- Repli Mapbox geocoding pour rues trop récentes pour le RQA (MAPBOX_TOKEN).
- Backfill + UPGRADE : coords delivery écrasent des coords SL moins précises (jamais l'inverse).
- Comptabilité honnête : vérifie r.ok sur create/update (erp ne throw pas) → errors + error_samples.
- Verrou de sérialisation sync() : tick + runs manuels ne se chevauchent plus (frappe_pg).
- Subject tronqué à 140 (champ Data) → corrige CharacterLengthExceededError sur jobs sans SL.
- Observabilité : coord_src tally + error_samples dans le résumé.
Ops Planification (éditeur de journée) :
- travelBetween() consulte une matrice Mapbox Matrix chargée à l'ouverture (loadDayRoute) →
temps de trajet ROUTIERS RÉELS ; réordonnancement instantané sans nouvelle requête.
Repli haversine si Mapbox indispo. Indicateur 🚗 réel vs 📏 vol d'oiseau.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- FIX réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (au lieu du splice live jittery)
- durée éditable par job en MINUTES (pas de 5, best practice précision) → persistée via reorder-jobs (duration_h)
- temps de transport estimé entre 2 jobs (haversine sur coords Service Location, 40km/h + 5min) affiché entre les lignes
→ en attendant la géoloc live (Capacitor background-geolocation, noté pour plus tard)
- hub : occupancyByTechDay renvoie lat/lon par job ; reorder-jobs accepte duration_h ; total h en pied
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>
- réutilisation max + cohérence : le clic sur le progressbar ouvre le tableau Dispatch focalisé sur
le tech + le jour cliqué (gotoDispatch(t, d.iso)) = LE timeline éditable (drag-drop réordonner, supprimer/désaffecter)
- retire le popup cellJobsMenu (réordonner/priorité) → règle aussi le chevauchement avec l'infobulle mouseover
- (endpoint /roster/reorder-jobs conservé, réutilisable ; le réordonnancement se fait désormais côté Dispatch)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- assignSort + assignGroups regroupe/trie selon le mode (défaut = groupe parent-enfant) ;
ajout du tri par COMPÉTENCE (demandé) + date/ville/priorité (jobCity = dernier segment adresse ou « Ville | » du sujet)
- barre de tri dans le panneau (hors zone de drag) + en-tête de groupe par label ; indentation enfant seulement en mode groupe
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>
- pont : extrait le lien connect_ministra.php du ticket_msg (déjà posté par le wizard legacy à la vente)
→ champ legacy_activation_url sur le Dispatch Job (backfill des 10 tickets TV). Read-only legacy, aucun 2e
chemin d'écriture → zéro risque de double-facturation (cf. analyse processus).
- store _mapJob : expose legacyActivationUrl ; RightPanel : bouton violet « 📺 Activer STB (Ministra) »
(MÊME lien que le tech reçoit, ouvre connect_ministra.php avec tid/did/sid/cr/m intacts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- useHelpers.legacyReplyUrl(job, staffId?) → https://store.targo.ca/targo/reply_ticket.php?ticket=<legacy_ticket_id>&staff=<3301 par défaut = Tech Targo>
- RightPanel : n° ticket legacy cliquable + bouton d'action « 📝 Répondre dans legacy »
- permet au tech d'écrire dans le ticket legacy depuis ERPNext (lecture seule de notre côté)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- BottomPanel : pastille couleur (jobColor → type legacy) par ligne + badge ⏰ EN RETARD
sur les groupes de date passée (le pool est déjà groupé/trié par date)
- RightPanel : badge « en retard » près de la date planifiée (hors Completed)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- pont : stocke legacy_dept (dept osTicket) sur le Dispatch Job + backfill des 70 existants
- useHelpers.jobColor/legacyDeptColor : palette comme legacy (Installation vert, Réparation or,
Télé rose, Téléphonie vert pâle, Désinstallation rouge foncé) ; tech en pause = rouge vif prioritaire
- store _mapJob : expose legacyDept + legacyTicketId
- RightPanel : champ « Ticket legacy » (#id · dept) — le client est déjà un lien cliquable vers la fiche
- doc mise à jour
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- On Hold : onCellDrop REFUSE d'assigner un job en attente d'un prérequis (notify), reste au panneau (≠ 🔒 visuel)
- Hors quart publié : marqueur ⚠ dans la cellule libre (offShiftJobs/rawCellJobs lit occByTechDay brut) +
badge « hors quart » dans la timeline ressource — surface les jobs assignés un jour sans quart
- Deep-link : Planif gotoDispatch(tech) → /dispatch?tech=&date= ; DispatchPage lit route.query
(goToDay(date+T12:00:00) anti-décalage tz + selectTechOnBoard)
- #58 : bouton « Désaffecter + aviser le client » dans le dialogue d'unassign Dispatch →
roster.notifyReschedule (désassigne serveur + SMS lien /book au mobile du Customer)
- Doc docs/features/roster.md mise à jour (Fait récemment / TODO)
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>
Une règle a maintenant un shift SEMAINE + un shift FIN DE SEMAINE (optionnel). La rotation reste
UNE séquence (même tech sur la semaine) ; la génération choisit l'horaire selon le jour : weekend
(sam/dim) → shiftWeekend, sinon shift semaine. Wipe couvre les 2 shifts. Modèles semés:
« Garde soir 17h-00h » (17:00-23:59) + « Garde fin de sem. 8h-00h » (08:00-23:59), on_call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Les règles créées avant le refactor (techs[]+periodWeeks, sans steps/anchor) donnaient 0 assignation
(rotationTech voyait 0 étape) + anchor manquant → toujours step 0. Fix: ruleSteps() convertit techs[]+
periodWeeks en étapes {tech,weeks} (collapse des doublons consécutifs) et ruleAnchor() retombe sur une
référence stable (epoch). rotationTech/gardeSeqLabel/editGardeRule passent par ces helpers → les vieilles
règles génèrent + s'affichent correctement, sans devoir tout recréer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refonte du principe de rotation suite aux bugs (3 sem. au lieu de 2, édition non reflétée) :
- Séquence = étapes {tech, nb de semaines} ; « 2 semaines consécutives » = weeks:2 (fini les
doublons+periodWeeks qui se multipliaient). Tours inégaux = weeks différents par étape.
- ANCRAGE explicite (semaine de départ, donnée de référence stockée) : on PARCOURT la séquence
semaine par semaine depuis l'ancrage → déterministe, reflète les éditions, pas de dérive.
- Vérifié: A×2,B,C ancré 8 juin → A,A,B,C,A,A,B,C (A toujours 2 consécutives) ; réordonner reflète.
- Aperçu et génération utilisent le même parcours. Migration auto des anciennes règles (techs+period→steps).
Rappel: après édition, re-cliquer « Générer la garde » (l'horizon est réécrit, wipe ciblé du shift).
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>
- Plages combinables: « Soirs de semaine » (L-V) + « Fin de semaine » (S-D) en toggles (+ chips fins).
- Rotation par défaut = PAR SEMAINE (garde = bloc hebdo) → corrige la séquence brisée (par jour, un
week-end pouvait avoir 2 personnes). Index de semaine continu dans le temps → ordre respecté en
naviguant. Séquence [A,A,B] = A 2 semaines consécutives, puis B. (Sélecteur jour/semaine conservé.)
- APERÇU live : qui est de garde sur les 8 prochaines semaines, reflète la file en cours d'édition →
on voit l'ordre respecté + l'effet des modifs avant d'appliquer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: rotation par semaine → dans une vue d'1 semaine, un seul membre apparaissait. Maintenant la
rotation avance par OCCURRENCE de garde (occurrenceIndex = nb de jours matching weekdays depuis l'époque)
→ chaque jour de garde prend le membre suivant de la séquence (A,A,B,C…), visible sur toute la vue.
Sélecteur « Rotation : par jour de garde / par semaine » (+ N consécutifs). Défaut = par jour ;
les règles existantes (sans unit) basculent en par-jour. Saut d'absent + doublons conservés.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Le multi-select est remplacé par un constructeur de SUITE ordonnée : « Ajouter un tech à la suite »
(push, doublons autorisés → tours inégaux ex. A,A,B,C) ; chaque position a un select pour REMPLACER
le tech, + ↑/↓ pour réordonner, + ✕ pour retirer. Couvre rotations inégales et substitutions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix listes vides: département = champ libre optionnel (existants OU texte), liste des techs = TOUS
(plus de désactivation sur dept). Réordonnancement de la rotation (↑/↓), édition d'une règle (crayon
→ recharge dans le formulaire → Mettre à jour). Champ « Sem. consécutives / tech » (mettre 2 = un tech
fait 2 semaines de suite). Annuler l'édition.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tech Availability gagne long_term (Check). Case à cocher « Longue durée » dans le dialogue Congés.
absencesByTechDay encode « (longue durée) » → applyTemplate force « à remplacer » (pas juste sauter
comme des vacances), même si l'absence ne couvre pas toute la semaine. Complète l'intelligence
permanent vs vacances (avant: déduit de « absent toute la semaine »).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dialogue « Garde » : règles par département (tech_group) = {shift de garde, jours, période (toutes
les X sem.), techs en rotation ordonnés}. Indépendantes entre départements (non synchronisées).
« Appliquer à la semaine » génère les gardes : pour chaque jour ciblé, le tech de garde = rotation
(index de semaine / période % liste) ; un tech absent est SAUTÉ au profit du suivant. Règles persistées
(localStorage roster-garde-rules-v1). Les gardes s'affichent en pointillé ambre (on_call), hors heures
travaillées/booking déjà en place.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Marquer un modèle de semaine comme défaut (★ dans le menu Modèles, un seul à la fois) →
bouton ★ <nom> dans la barre pour l'appliquer en 1 clic (avec l'intelligence d'absences déjà en place).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
applyTemplate respecte maintenant les absences : on n'assigne pas un tech absent ce jour-là.
- Absent toute la semaine (≈ congé permanent: maternité/blessure) → signalé « à remplacer » (warning).
- Absent quelques jours (≈ vacances) → ces jours sautés, le reste du patron tient.
Le toast résume: N assignations, absences partielles ignorées, et qui est à remplacer (avec le type).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Menu réduit: 2 raccourcis (Normal 8–17, Soir 16–20) + slider d'ajustement remontés EN HAUT
(près du clic) ; Appliquer dans la rangée du slider → reste visible même si la case est en bas
(max-height 85vh + flip auto Quasar). Retrait de la longue liste des modèles.
- quickShift(min,max) + applyWindow() factorisés. Shifts en place + Copier/Coller/Vider en icônes
compactes sous le slider. Nettoyage cellCode/cellColor/codeByShift/colorByShift/addFromMenu inutilisés.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
onKey: garde anti-champ (n'intercepte pas dans input/textarea/select/contenteditable) + empêche
le 'retour arrière' du navigateur (preventDefault). Suppr/⌫ vide la sélection (ou la case cliquée),
avec pushHistory (annulable). Hint de légende mis à jour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
La bande pâle se confondait avec le fond gris du timeline → contour 1px bleu-violet foncé
(rgba 55,65,120,.5) box-sizing border-box pour ne pas déborder. Garde = contour brun.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- user-select:none sur le menu (rendu en portail → n'héritait pas du no-select de la grille)
→ le glissement ne sélectionne plus le texte.
- Retrait des bulles de label de la q-range (8h/18:30h) qui changeaient de largeur → le menu ne
se redimensionne plus. La valeur reste affichée en direct sous le slider (8h–18:30h).
- Largeur du menu fixée (260px) + @mousedown.stop sur le slider.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- En-tête de chaque jour: règle horaire (graduations alignées sur l'axe adaptatif, ex 8/12/16/20).
- Cellule réduite à LA barre de dispo (dégradé matin→soir, occupé assombri, garde hachuré) —
plus de texte d'intervalle : on lit la position contre la règle, détails au survol.
- Barre un peu plus haute (11px). Retrait cellLabel/cell-int/cell-chips.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Cellules: plus de chip-lettre (J/S/M/D). La bande de la timeline est colorée par l'HEURE :
dégradé bleu pâle le matin → violet le soir (hsl 210→270). On lit 'quand' d'un coup d'œil.
- Occupé = assombrit la bande (clair = libre/offrable, foncé = pris) ; rouge si surbooké.
(Remplace le feu vert/orange/rouge — plus sobre.) Garde reste hachurée.
- Légende: échantillon dégradé « matin → soir » + « garde » (hachuré) au lieu de la liste de lettres.
- Intervalle (8–16) gardé en texte. occColor retiré.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Nettoyage des modèles auto en double fait côté données (8h–16h→Jour, 7h–15h→Matinal,
9h–17h→Décalé, 8h–17h→Jour, 8h–22h→Soir) — restent 5 presets propres.
- Barre d'assignation + légende = presetTemplates (modèles NOMMÉS seulement) → restent propres
même si des modèles auto réapparaissent.
- Fix copier-coller : le clic ouvrait le menu ET vidait la sélection → Cmd+C voyait 0 cellule.
Maintenant on mémorise activeCell (dernière case cliquée) ; Cmd+C/V ferment le menu et marchent
sans multi-sélection. Coller = vers la sélection si multi, sinon la case active.
- Indicateur « N copié(s) » visible. Coller multi-cases via la barre (déjà) + Cmd+V sur sélection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BUG: le menu de case plantait sur les cases occupées (cellHours retiré mais encore appelé l.259)
→ c'est ce qui cassait le copier-coller au clic. Corrigé ({{ a.hours }}h).
- Copier/Coller déplacés dans le MENU de la case (clic simple, plus besoin de Cmd+clic) :
« Copier cette case » / « Coller ». (Boutons barre + Ctrl/Cmd+C/V conservés.)
- « Ajuster l'horaire (glisser) » : q-range dans le menu → élargir/réduire le créneau de dispo,
+ toggle Garde. Applique = trouve/crée un modèle auto-nommé (8h–18h) et remplace la case.
→ la dispo élargie est aussitôt offerte au booking (modèle standard).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Copier-coller pour bâtir l'horaire vite : sélectionne une case → Copier (ses shifts) →
sélectionne des cibles → Coller (duplique). Boutons dans la barre + raccourcis Ctrl+C / Ctrl+V.
Copier une case vide puis Coller = vider les cases.
- Créneaux CUSTOM : nouveau modèle créé via slider q-range (2 poignées, pas 0.5 h) → plus besoin
de prévoir tous les types. Nom AUTO si vide (« 8h–17h » d'après les heures).
- Presets standards semés : 7h–15h, 9h–17h, 8h–17h (+ Jour 8h-16h existant) — triés par usage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retrait des icônes en cellule (☀/🌆/🌙/🛡️). Le libellé = uniquement l'intervalle début–fin
(ex 8–16) ; le timeline (bande pleine vs garde hachurée) porte le reste visuellement.
Tag garde dans l'infobulle: '(garde)' au lieu de l'emoji.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Garde (on_call) exclue partout des heures travaillées + du coût:
- hub statsByDay: heures = somme des shifts NON-garde ; nouveau compteur on_call/jour (techs en dispo).
- Ops: hoursOf (heures/tech + alerte heures supp) et costByDate/weekCost excluent la garde.
- nouvelle ligne de pied '🛡️ Garde' = nb de techs en disponibilité/jour (si applicable).
Cohérent avec l'occupation (déjà hors-garde) : la garde = réserve d'urgence, ni offerte ni facturée.
Vérifié 8 juin: 112 h travaillées (garde 6 h exclue), garde=1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#1 axe trop large (0-24) → axe ADAPTATIF calé sur l'amplitude réelle des shifts réguliers de la
semaine (garde n'élargit pas) → barres plus larges, position lisible. Intervalle début–fin REMIS
en texte dans la cellule (☀ 8–16 🛡️) = référence sans survol. Infobulle = capacité offrable
(corrige aussi un bug: shiftH→bookableH).
#2 modèles d'assignation TRIÉS PAR USAGE (les plus utilisés en premier) + infobulle nom.
(Rappel: créer des modèles custom = éditeur « Types de shift ».)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Modèle: champ on_call (Check) sur Shift Template. Un quart garde:
- N'est JAMAIS offert au booking client (techGaps retourne null) — vérifié: tech Jour+Garde
n'offre que la fenêtre Jour, aucun créneau dans la plage de garde.
- Est EXCLU du dénominateur d'occupation (heures offrables), affiché à part.
- Timeline: bande HACHURÉE (vs neutre pour l'offrable) + 🛡️ dans le label + tag (garde) en infobulle.
- Éditeur de modèles: bascule '🛡️ Garde' pour créer/marquer un quart de garde.
hub: fetchTemplates expose on_call; create/update template le gèrent. Champ ajouté à
setup_dispatch_custom_fields.py (persistance). Démo: Garde 18h-minuit marquée on_call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avant: 'J8' ne distinguait pas 7-15 de 9-17 → mêmes créneaux apparents, dispo réelle différente.
Maintenant chaque cellule affiche: chip (lettre) + intervalle '7–15', et une mini-timeline sur un
axe de journée (06:00→21:00) où la fenêtre du shift est positionnée (donc 7-15 à gauche, 9-17 à
droite = visuellement distinctes) avec les blocs de jobs pris (couleur selon charge) → les TROUS
restants = créneaux offrables. Infobulle = intervalle + h occupées/h (%).
- hub occupancyByTechDay renvoie {h, blocks:[{s,e}]} (heures de début réelles des jobs).
- ops: cellWindow/axisPos/shiftStyle/blockStyle, rendu .tl/.tl-shift/.tl-blk + tick midi.
- démo 8 juin: modèles Matinal 7-15 + Décalé 9-17, techs alignés (7→13.8, 9→18.6 surbooké).
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>
Corrige les manques signalés: champ technicien (congés) → autosuggest typeahead; compétences
(demande de shift + éditeur équipe) → chips au lieu de texte libre. Composants réutilisables
pour une UX cohérente partout (et le copilote/réassignation à venir).
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>
- Presets Standard 360→240 (10$/mois) / Simple 180→120 (5$/mois) / Aucune
- 2 cases éditables Prix original / Prix financé + aperçu live (barré, $/mois, crédité→0$)
- Alimente install_fee/install_regular du Service Contract → page d'acceptation affiche le détail
- Placé sous Code de référence dans la vue résidentielle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Prix original (regular_price) disponible sur lignes récurrentes ET ponctuelles (avant: ponctuelles seulement)
- Si Prix original > Prix marketing → barré affiché (sommaire + récap + aperçu live dans l'éditeur)
- Forfait récurrent barré remonte au contrat (monthly_regular) → page d'acceptation affiche <s>99.95</s> 79.95/mois
- Cohérent avec install (install_regular 360→240). Aucun impact CRTC (rabais go-forward, jamais récupéré)
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>
Adds a sortable 'Ville' column (field city) to the report. Quasar's default
filter scans all columns, so the existing search box now matches city too.
Street address caption drops the now-redundant city (keeps postal code).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cogeco's address checker is gated by reCAPTCHA Enterprise (risk-score 401
on the protected /boutique/api/address/search call), so per-address
serviceability can't be scraped reliably from a datacenter IP without a
residential proxy. Per product decision, pivot to an assisted spot-check
instead of automated qualification.
- ReportInternetCherPage: add a "Concurrent" column with a one-click
button that copies the full service address and opens Cogeco's
availability page in a new tab (human reads the verdict in ~10s, only
for the leads that matter). fullAddress() builds "addr, city, QC ZIP".
- cogeco-checker: harden the POC anyway — track service-address/search
responses, retry the verdict call on 401 (re-register cadence), and
prioritize the authoritative JSON body in interpret(). Recon confirmed
the wall is reCAPTCHA scoring, not a timing/selector bug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User correctly spotted that Julie Dupuis shows 114.95$ but actually pays
69.95$ — investigation revealed the legacy COPY (legacy-db container) is a
one-shot snapshot from 2026-05-05 with data through 2026-04-30 and NO
auto-sync. She renegotiated in May (a -50$ discount on service 50999) which
the copy never received. The report was correct vs the copy, but the copy
is ~1 month stale.
Two changes (data-source strategy still pending operator decision —
prod 10.100.80.100:3306 is reachable for a future live/refresh option):
1. data_as_of — the report now reports MAX(invoice.date_orig) from the
copy and the Ops page shows a banner ("Données legacy au 30 avril —
copie figée, N jours"). Turns orange past 7 days so nobody acts on
stale prices unknowingly.
2. recent_expired_discount column — per-address sum of deactivated credit
lines (status=0, price<0) whose actif_until fell in the last 180 days.
Surfaces clients whose discount just lapsed (Julie's RAB24M -15 + RAB_X
-35 expired 2026-03-01), i.e. the prime retention targets whose bill is
about to jump. Shown in amber with a warning icon + tooltip; included in
the CSV.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User flagged that several listed accounts are inactive (Or Viande Inc,
Denis Henderson). Root cause: I filtered service.status=1 but NOT the
account, so terminated accounts carrying an orphan active service line
slipped through. The legacy billing job (LEGACY-ACCOUNTING-ANALYSIS.md
§6.1) bills only when BOTH service.status=1 AND account.status=1.
Three account-level filters added:
- account.status = 1 → drops terminated accounts. Or Viande Inc is
status=4, terminated 2014 (terminate_date set), but still had a
service.status=1 row. 8602 accounts are status=4 vs 6537 status=1.
- account.group_id = 5 → "Client" per account_group. Drops 6 Prospect,
7 Fournisseur, 8 Relais (network infra, e.g. Denis Henderson's
REL_CHRY_CHARLES tower account), 10 Équipement motorisé.
- customer_id NOT LIKE 'PROPRIO%' → 59 landowner-hosts-our-gear accounts
that live in group 5 but aren't paying customers (Denis Henderson's
other account PROPRIOH_STCHARLES). A genuine same-name customer
(Robert Henderson, ROBEH...) correctly stays.
Residential >90$/mo: 983 → 554 (was inflated ~44% by dead/non-customer
accounts). Commercial: 255 → 240.
Ops page note updated to state "comptes clients actifs uniquement" and
list what's excluded.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
A reminder campaign is a deep-copy of its parent's non-clicked
recipients with NEW gift_tokens. Clicks on the reminder were flagging
the CHILD recipient's gift_link_clicked but the parent campaign's
counters never updated — operators had to check two campaigns to see
the cumulative click rate.
Hub:
- New cascadeClickToParent() helper — when a recipient with
parent_campaign_id is flagged as gift_link_clicked, mirror the flag
+ timestamp onto parent.recipients[parent_row_index] and broadcast
a recipient-update SSE event so the parent's open page refreshes
live. Adds a gift_clicked_via_reminder breadcrumb (the child
campaign id) so the parent UI can show "↩ via la relance XXX".
Idempotent — already-clicked rows are no-op.
- Three cascade call sites: applyWebhookEvent fast path (CustomID),
applyWebhookEvent fallback (msgId scan), handleGiftRedirect wrapper.
- handleGiftRedirect also now sets gift_link_clicked=true on first
successful redirect (Mailjet webhook can lag or drop; the wrapper
redirect is the most reliable click signal we have).
- GET /campaigns/:id now attaches a "reminders" array with summary
counters for every reminder child of the campaign.
Ops UI:
- "Cette campagne est une relance" banner on child detail pages with
a back-link to the parent.
- "N relance(s) envoyée(s)" banner on parent detail pages with
clickable chips showing each child's gift_clicked/total ratio.
- Recipient table: 🔁 icon next to the gift-click indicator when the
click came via reminder, plus a "↩ via la relance XXX" line in the
tooltip so the operator can trace the engagement channel.
One-time backfill applied on prod to mirror clicks that happened
between reminder send and this deploy (1 click cascaded —
cmp-20260522-2d4605 gift_clicked 27 → 28).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The edit-params picker was showing "2026-06-22" for an expiry stored
as 2026-06-22T03:59:59Z because it sliced the UTC string. But that
UTC instant is actually 23:59 EDT on June 21 in Montreal, which is
what the email recipient sees (and what the operator picked).
Fixes both sides of the round-trip:
DISPLAY (UTC → picker)
- Convert stored ISO UTC to YYYY-MM-DD interpreted in America/Montreal
using en-CA locale (which returns ISO-style YYYY-MM-DD).
SAVE (picker → ISO UTC)
- New endOfDayMontreal() helper that probes Montreal's offset for the
target date (noon UTC always lands in morning Montreal, never spans
a day) and anchors at 23:59:59.999 local. Handles EDT/EST swaps
automatically — verified with edge cases 2026-03-08 (post-DST-spring),
2026-06-21 (mid-summer), 2026-11-01 (post-DST-fall), 2026-12-31 (winter).
Previously the save path relied on the BROWSER's local TZ inference
(new Date('YYYY-MM-DDT23:59:59').toISOString()) which is fine for
Quebec operators but quietly wrong for anyone editing from elsewhere.
The bulk email send was already correct because the worker's
toLocaleDateString uses timeZone: 'America/Montreal' (last commit).
This commit only fixes what the OPERATOR sees in the picker.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Operator can now choose an exact date for the wrapper expiry (e.g.
"valid until June 15") instead of computing days from today. Useful
when communicating a specific deadline to recipients.
Worker resolution order:
1. params.gift_expires_at (full ISO datetime, set by the date picker)
— all recipients of this campaign get THIS exact date, regardless
of when the worker fires the send.
2. Fallback: now() + gift_expiry_days (relative deadline, shifts
forward by queue lag).
UI in both wizard (new campaign) and edit-params dialog (draft):
- Date picker at the top with cursor-pointer event icon + clear (x)
- Preset toggle (15/30/60/90/180/Custom days) below — auto-disabled
when explicit date is set so the operator picks ONE mode
- Indicator "≈ N jours à partir d'aujourd'hui" when explicit date is
active so the operator sees both representations
UI carries the picker value as YYYY-MM-DD (gift_expires_at_display);
launchSend / saveEditParams translate to ISO YYYY-MM-DDT23:59:59Z
before PATCH/POST. Anchoring at end-of-day local means "until June 15"
stays valid through all of June 15, not just the start.
dateAfterToday validator blocks past dates in the picker.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The reminder copy read as pushy on test sends ("Hâte-toi! ... Tu n'as
encore rien fait, et le délai approche"). Toned down to factual and
friendly: state availability + offer the no-pressure path.
FR before / after:
⏰ Hâte-toi! Ton cadeau de 60 $ expire le ___. (red bold)
→ 🎁 Ton cadeau de 60 $ reste disponible jusqu'au 1 juillet 2026.
(brand dark green)
Tu n'as encore rien fait, et le délai approche. Si tu n'utilises
pas ton cadeau d'ici là, il ne pourra plus être réclamé.
→ On voulait juste s'assurer que tu ne l'as pas manqué — la carte-
cadeau qu'on t'a envoyée peut s'utiliser chez des centaines de
marques canadiennes, en quelques clics.
Si tu préfères ne pas l'utiliser, aucun souci — pas besoin de
répondre à ce courriel.
EN copy mirrored.
Also: {{expires_at_date}} was rendering empty in test sends and
previews because neither the test-send endpoint, the preview
endpoint, nor the editor's testSendForm.vars seeded it. Three fixes:
- Hub preview endpoint: compute now+30d as default sample date.
- Hub test-send endpoint: same default + expose view_url='' so the
Mustache section block collapses cleanly in internal tests.
- Editor test-send dialog: pre-fill expires_at_date (and expires_in_
days) with the same now+30d value, plus expose both fields as
editable inputs so the operator can override per-test.
Verified live on prod: the preview endpoint with no vars now renders
"Ton cadeau de 60 $ reste disponible jusqu'au 1 juillet 2026."
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new buttons on the campaign detail page header — both visible only
when campaign.status === 'draft' to keep operators from accidentally
mutating a campaign mid-send.
"Éditer les paramètres" → q-dialog with:
- name (internal)
- subject (the email Subject: line)
- from (sender)
- amount displayed in the body (overrides per-recipient default)
- commitment_months
- expiry text
- template_fr / template_en dropdowns (refresh on popup-show so newly
created templates show up without a page reload)
Saves via the existing PATCH /campaigns/:id, which merges into
params. A live load() refresh updates the Confirmation recap and any
visible counters.
"Éditer le template" → opens the Unlayer editor in a new tab on the
campaign's configured template_fr (most TARGO customers FR). For
campaign-specific tweaks the dialog tells the operator to create a
variant template (+ Nouveau) and select it here.
Addresses the gap a user hit on a reminder draft — they wanted to add
a condition to the body before launching but had no edit affordance
on the detail page.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <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>
parseMapCsv now collects the actual rows it drops (capped at 200),
each with its skip reason and the raw source-CSV columns (full_name,
email, phone, address, postal). Returned alongside the existing
counters as skipped_rows on the parse response.
Wizard Step 2 adds an "N ligne(s) du Map CSV non importée(s)"
expansion below the imbalance banner, showing:
Ligne # | Raison | Nom au CSV | Email au CSV | Adresse | CP | →
The action column has a "Ajouter manuellement" button on rows that
have an email (duplicate, multi_skip) — clicking opens the manual-
add dialog pre-filled from the dropped row, so the operator can
recover the contact in two clicks. no_email rows can't be recovered
that way and don't get the button.
The source_row index is the Excel-relative line number (counting the
header) so the operator can cross-reference the actual file.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous breakdown only rendered when at least one of the drop
counters was > 0. When the Map CSV cleanly parses every row and the
imbalance comes purely from the Giftbit CSV having more entries than
the Map CSV, the operator was left with "13 surplus gifts" and no
explanation.
The summary now always shows "Map CSV: N raw rows → M contacts paired"
and, when no rows were dropped, explicitly states that the imbalance
must come from the Giftbit side (asks the operator to confirm the
generated gift count matches the Map file).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Map CSV rows that had a valid email but no name in the source column
were silently dropped at parsing — that's why a campaign would end up
with N unpaired Giftbit shortlinks for N "missing" contacts that
weren't actually missing, just nameless.
The send worker already handles a missing firstname by substituting
"cher client" / "dear customer", so dropping the row was wasteful.
Now we keep the contact and surface a name_warning on the row so the
operator can either edit the firstname in Step 2 or accept the
default.
Also added counters for previously-silent skip paths:
- duplicate: row's email was already seen above (1 gift / household
consolidation, depending on the multi setting)
- multi_skip: couple skipped because multi='skip' was selected
Wizard Step 2 imbalance banner now ventilates the skip breakdown so
the operator understands exactly where the N "missing" contacts went:
Ventilation des contacts droppés au parsing du Map CSV (sur 213
lignes brutes) : 8 sans email valide · 5 emails en double · 0
couples ignorés · 3 sans nom (gardés, utilisent "cher client" à
l'envoi)
Unrelated reassurance on the question that triggered this: language
fallback to French is already in place (matchCustomer returns
language:'fr' on miss, worker reads (r.language || 'fr')) so any
unmatched recipient gets the FR template, never an English one by
default.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Manual workaround for redemption status until /gifts/{uuid} polling
ships (task #25). The trailing path segment of the Giftbit shortlink
is the lookup key for Giftbit's admin search:
http://gft.link/4kpZMApLK4B
→ https://app.giftbit.com/app/rewards?search=4kpZMApLK4B
Surfaced in three places:
- Inventory page row: 🔗 button next to the copy-URL action
- Campaign detail page recipient table: same button next to the
Giftbit shortlink
- CSV report: new giftbit_admin_url column for bulk audits in Excel
(one click per row, no manual concat)
Defensive: only renders if the trailing segment is ≥4 chars (avoids
producing useless searches on malformed/test URLs).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new template variables are auto-derived from r.gift_expires_at at
render time (separately by the worker and the /view fallback to keep
them consistent):
{{expires_at_date}} locale-formatted FR/EN long date — "21 août 2026"
/ "August 21, 2026". Empty when no wrapper token.
{{expires_in_days}} remaining days as string (rounded up). Useful
for tight deadlines where a date is too distant
to convey urgency.
Templates: a small centered badge appears between the CTA button and
the prorata disclaimer, wrapped in a Mustache section so it disappears
cleanly on campaigns that pre-date the wrapper feature.
⏰ Cadeau valide jusqu'au <strong>21 août 2026</strong>
⏰ Gift valid until <strong>August 21, 2026</strong>
Editor merge-tag panel updated so authors can drop these into custom
copy without remembering the exact variable names. The legacy
{{expiry}} field stays — it's still the right tool for promotion-end
dates that don't track the gift link's own deadline.
🤖 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>
Each campaign recipient now gets a short opaque token (10 base64url
chars, ~60 bits entropy). The email contains
https://msg.gigafibre.ca/g/<token>
which 302-redirects to the underlying Giftbit shortlink — but ONLY if
the recipient hasn't passed our own expires_at and we haven't revoked
the token. This gives us two new operational capabilities:
1. End-date control independent of Giftbit. The wizard now has a
"Expiration interne (jours)" field (default 90) that sets our
own deadline. Useful when the Giftbit gift is valid 12 months
but the campaign offer should expire in 30 days.
2. Reuse of unredeemed gifts. After our expiry, the old wrapper
stops working but the Giftbit URL is still valid on their side.
Pasting that same gift_url into a new campaign (via the manual-add
dialog) generates a NEW token pointing to the same Giftbit gift —
the original recipient's old wrapper URL says "expired", the new
recipient gets a fresh window.
Per-recipient new fields:
- gift_token short ID used in the wrapper URL
- gift_expires_at ISO timestamp of our cutoff
- gift_revoked manual kill-switch (false by default)
- gift_redirected_count clicks that successfully reached Giftbit
- gift_first_redirected_at first successful redirect timestamp
Routing:
- GET /g/:token — public, validates and 302s (or expired-page)
- Mailjet click event handler updated to recognise wrapper URLs
alongside legacy gft.link/giftbit.com URLs.
- /view (browser fallback for in-email rendering) also wraps the
gift link so expiry/revoke is honoured consistently.
Bootstrap rebuilds the in-memory token→recipient index by scanning
all campaign JSONs on startup — no separate index file to keep in
sync.
CSV report adds gift_token, gift_expires_at, gift_revoked,
gift_redirected_count, gift_first_redirected_at.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues with the per-language template dropdowns:
1. Strict filter — only -fr / -en templates appeared. Anyone naming a
template gift-email-test or gift-email-es (no recognized language
suffix) saw nothing show up in either dropdown.
2. Loaded once on mount — creating a template in another tab and
switching back to a wizard already open kept showing the stale list.
Fix:
- Templates without a -fr / -en suffix are added to BOTH dropdowns
with a "· sans suffixe de langue" tag so they're discoverable but
visually distinct from the recommended ones.
- Sort: matching-suffix templates first, then alphabetical.
- @popup-show triggers a refresh on every dropdown open.
- Visible "refresh" icon in the dropdown's append slot for manual
triggering without having to close/reopen the popup.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new dropdowns in Step 1 ("Template français" / "Template anglais")
populated from /campaigns/templates filtered by suffix (-fr / -en).
Selection is stored on campaign.params.template_fr / .template_en
and the worker resolves the actual path via a new resolveTemplatePath
helper:
1. params.template_<lang> (per-lang override, set here)
2. params.template_path (legacy single-template campaign override)
3. templateForLanguage() (default gift-email-<lang>.html)
Defensive name regex inside resolveTemplatePath blocks path traversal —
operator can pick any *-fr / *-en template that exists, nothing else.
The Step 3 summary list now shows which template will actually ship
per language so the operator can sanity-check before launch.
Use cases: seasonal variants (gift-email-2026-summer-fr), A/B tests,
draft templates that aren't ready to be the default yet.
🤖 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>
Mailjet's click event includes the actual URL the recipient clicked. We
previously bumped every click — CTA button, mailto support, footer link —
to status='clicked' indiscriminately. Now we additionally flag clicks on
the Giftbit shortlink (matched by r.gift_url prefix, fallback to gft.link
or giftbit.com host) as the high-signal "gift_link_clicked" event.
Adds:
- recipient.gift_link_clicked (bool) + gift_clicked_at (ISO timestamp),
set on first matching click; later non-gift clicks don't unset
- counters.gift_clicked aggregated alongside existing status counters
- "Cadeau cliqué" counter card on detail page (deep-purple, redeem icon)
- 🎁 redeem icon next to status chip when the recipient engaged
- CSV report: new gift_link_clicked + gift_clicked_at columns
Why this matters: "opened" is noisy (Apple Mail Privacy Protection, image
proxies prefetch). A click on the CTA is the only reliable indicator
that the offer landed and the recipient is engaging.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous discoverability path was clic-text → floating toolbar → {}
icon, which assumes the user already knows how to invoke Unlayer's merge
tag UI. A direct "Variables" button now opens a dialog listing all 9
placeholders grouped by category (Client / Offre / Système) with their
sample value and a click-to-copy action. Reads from the same mergeTags
config Unlayer consumes — single source of truth, no drift risk.
Banner inside hints at the upcoming CSV-driven custom variable feature.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After Mailjet's Event API webhook moves rows from 'sent' to 'opened' or
'clicked', the counters.sent bucket empties and the list page showed
0/N even though every email had successfully landed. Use the same
sent+opened+clicked sum as the detail page so the list reflects
"emails that left our SMTP" rather than "emails still flagged sent".
🤖 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>
Two improvements to the template editor:
1. "+ Nouveau" button + creation dialog
Users can now create new templates from the editor UI without us
re-deploying the hub. Click "Nouveau" next to the template selector,
pick a name + prefix + starter (blank or copy from existing), submit.
The hub PUTs the new template (existing endpoint, no new code needed
on the backend — just relaxed validation).
Form:
• Type (prefix): gift-email / newsletter / transactional
• Name suffix: lowercase letters/digits/dashes (e.g. summer-2026)
• Starter: "Vide" or "Copier depuis <existing template>"
On submit:
• If starter != blank: GET source template's html + design
• PUT new template name with that content
• Refresh templates list + switch editor to the new one
2. Backend: replace hardcoded EDITABLE_TEMPLATES allow-list with
regex-validated prefix matching + disk scan
• EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-',
'transactional-'] — bounds what categories users can create
• TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ — prevents path traversal
• isValidTemplateName() validates both regex + prefix membership
• scanEditableTemplates() returns all matching .html/.mjml files
currently on disk (excludes .bak-* and .legacy-* variants)
• listEditableTemplates() now scans disk instead of a static list,
so newly-created templates appear automatically in the dropdown
3. Enable Unlayer's built-in panels
• templates: true — exposes Unlayer's template library (limited
free-tier selection but ~10-20 starters available without a
projectId)
• stockImages: true — Unsplash search built into image picker
• imageEditor: true — basic crop/resize on inserted images
• undoRedo: true — history navigation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two bugs from the first prod test of the Unlayer editor:
1. `editor.value.loadBlank is not a function` — the loadBlank() method
exists in newer Unlayer versions but NOT in vue-email-editor 2.2
which wraps an older Unlayer. When no design is stored yet, just let
the editor render its default empty state ("No content here. Drag
content from left.") and show a Quasar notification telling the
user how to start. No explicit load call needed.
2. Editor renders cramped/small — the EmailEditor component's nested
iframe doesn't inherit dimensions from Quasar's q-page wrapper.
Wrap the EmailEditor in an explicit-sized container:
<div style="height: calc(100vh - 60px); width: 100%; overflow: hidden;">
Plus pass style="height: 100%; width: 100%" to the EmailEditor itself.
This gives the editor a full viewport-minus-toolbar canvas.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Improvements to the variable insertion UX in the Unlayer editor:
1. Reorganized mergeTags from a flat object into 3 logical groups so
Unlayer's "Merge Tags" dropdown shows them under sub-headers
instead of a long flat list:
• Client (firstname, lastname, email, description)
• Offre (amount, gift_url, expiry, commitment_months)
• Système (year)
Format switched from { id: {name, value} } to grouped array
format (Unlayer accepts both, but groups give better UX).
2. Added `sample` field to each merge tag — Unlayer renders these
as the visible content while editing, so the canvas shows
"Louis Tremblay" / "60 $" / "https://gft.link/abc" instead of
literal "{{firstname}} {{lastname}}". Makes the live preview
look like real content during edit. Substitution still happens
server-side at send time via Mustache.
3. New toolbar hint button (code icon, grey) explaining where to
find merge tags in the Unlayer UI:
"Insertion : clic dans un texte → barre flottante → icône {}
Merge Tags. Marche aussi dans les champs URL (boutons, images,
mailto)."
This addresses a common discoverability issue: users don't
always realize variables work in URL fields too (e.g. setting
a button's "Action URL" to {{gift_url}} so each recipient gets
their own Giftbit link).
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>
Replace the broken GrapesJS-mjml integration with an iframe pointing to
the standalone email-editor microservice at editor.gigafibre.ca (created
in Phase 1).
What changed:
- Dropped all grapesjs* imports and ~250 lines of editor init/save/preview
glue code. That logic now lives in the React app on the other side of
the iframe.
- Page becomes a thin wrapper:
• Top bar: back button, template selector, "saved" chip,
"Aperçu inbox" button, "Envoyer un test" button, reload button.
• Below: full-height iframe to editor.gigafibre.ca/?name=<template-name>.
- Template switching: bumping iframeKey forces a fresh iframe load so the
new ?name= param takes effect. Route is updated via router.replace.
- postMessage listener: receives { type: 'email-editor:saved', ts }
from the editor iframe and shows a positive toast + updates the
"Sauvegardé · il y a Xs" chip. Origin-checked against EDITOR_BASE.
- Preview dialog: unchanged — fetches compiled HTML from hub's preview
endpoint and renders in srcdoc iframe.
- Test-send dialog: unchanged from previous version.
Removed (now handled inside the iframe):
- Visual / HTML / Aperçu view-mode toggle (editor.gigafibre.ca handles
all editing modes natively)
- "Vide" / "Réinitialiser" buttons (editor has its own)
- "Annuler" / "Enregistrer" buttons (editor saves itself on Cmd-S /
toolbar button)
- spell-check on textarea (editor handles it)
- GrapesJS asset manager wiring (editor will use its own image picker
in Phase 3)
DNS prerequisite handled separately: editor.gigafibre.ca → 96.125.196.67
created via Cloudflare API (proxied=false to match the existing pattern
that lets Traefik handle Let's Encrypt directly).
Container running on prod via /opt/email-editor/docker-compose.yml,
Traefik routing to Host(`editor.gigafibre.ca`). HTTPS verified live.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two big moves:
1. Promote MJML to the canonical template format
- Move gift-email-fr-mjml.{mjml,html} → gift-email-fr.{mjml,html}
- Create gift-email-en.mjml (English translation of FR MJML)
- Compile EN MJML → gift-email-en.html
- Remove obsolete variants:
• gift-email-fr-simple.html (now replaced by MJML)
• gift-email-en-simple.html (same)
• gift-email-fr-mjml.* (renamed to canonical)
- The old gift-email-fr.html (rich-with-merchant-grid version) is
backed up as gift-email-fr.legacy-rich.html.bak — kept on disk
for reference but not in the editable list.
- EDITABLE_TEMPLATES is now just ['gift-email-fr', 'gift-email-en'],
both backed by .mjml source + .html auto-compiled output.
2. Add "Envoyer un test" feature
Backend:
- POST /campaigns/templates/:name/test-send accepts { to, vars,
from?, subject? }. Reads compiled .html, renders Mustache vars,
sends via Mailjet through email.sendEmail with X-MJ-CustomID
"test-send:<name>:<timestamp>" so webhook events for tests are
identifiable. Returns { sent, to, from, message_id, bytes }.
- Default vars are sensible: firstname="Louis", amount="60 $",
gift_url="https://gft.link/TEST123", etc. User overrides any
via the request body.
Frontend (TemplateEditorPage):
- Toolbar button "Envoyer un test" (orange) — opens a dialog.
- Dialog has email input + subject + 7 variable inputs
(firstname, lastname, amount, commitment_months, gift_url,
description, expiry) with sensible defaults.
- "Dirty" banner warning: if the user has unsaved changes, the
test will use the LAST SAVED version (so save first to test the
latest). Mentions explicitly in card footer.
- On send: live notification with the message_id + byte count.
Errors surface clearly.
Verified live in prod:
POST /campaigns/templates/gift-email-fr/test-send → 200, message_id
returned, ~32 KB rendered MJML→HTML output, sent from
TARGO <support@targointernet.com> (Mailjet-validated sender).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pivot the template editor toward email-marketing-grade visual editing
by replacing grapesjs-preset-newsletter (permissive HTML, fails to parse
nested table structures) with grapesjs-mjml (the industry-standard
email markup language used by Mailchimp/Sendgrid/Twilio).
Why MJML: it was specifically designed to solve the "visual editor +
email-safe HTML" problem. You write semantic <mj-section>, <mj-column>,
<mj-button>, <mj-image> components — MJML compiles them to the gnarly
email-safe HTML with Outlook fallbacks + responsive media queries
auto-generated. Source is 3x more compact than hand-written HTML and
parses cleanly in visual editors.
Backend (lib/campaigns.js):
- Add `mjml` (v5, async) dependency. Compilation happens server-side
at SAVE time only; the send-worker reads pre-compiled .html (no
per-recipient compile cost).
- Each template can now be in 'mjml' or 'html' format. Detection by
file extension on disk: .mjml present → format='mjml', otherwise
format='html'. Source of truth for MJML templates = .mjml file;
.html is the auto-compiled output kept alongside for the send-worker.
- GET /campaigns/templates → returns { name, format, size } per template.
- GET /campaigns/templates/:name → returns { format, mjml?, html }
(mjml field present only when format=mjml; html always present).
- PUT /campaigns/templates/:name accepts:
{ mjml: "<mjml>..." } → compile to HTML, save both .mjml + .html
{ html: "..." } → save .html only (legacy path, unchanged)
Compilation errors return 400 with details (MJML validation soft mode).
Both files backed up as .bak-<ts>.<ext> before overwrite.
Frontend (TemplateEditorPage.vue):
- Detect format from API response on load.
- For format='mjml': swap grapesjs-preset-newsletter for grapesjs-mjml
plugin. Editor's getHtml() returns MJML source (not compiled HTML);
Save POSTs the MJML, hub compiles + persists both files.
- For format='html': existing behavior unchanged.
- Editor is destroyed + reinitialized when format changes (different
plugin sets).
- Custom variable blocks ({{firstname}}, {{amount}}, etc.) work for
both formats — they're text content, format-agnostic.
API client (apps/ops/src/api/campaigns.js):
- saveTemplate(name, content, { format }) routes to the right PUT body
shape based on format param.
Prototype: gift-email-fr-mjml — full MJML conversion of the simple
variant, ~7.5 KB MJML source compiling to ~32 KB email-safe HTML with
0 validation errors. All 6 Mustache variables preserved through
compilation (firstname, amount, gift_url, description, commitment_months,
year). User compares the MJML editor experience to the existing HTML
templates and decides whether to migrate the others.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Background: existing Mailjet-hosted brand logos in the gift email templates
stay as-is — those URLs are stable and live on Mailjet's CDN. This change
adds infrastructure for ADDITIONAL images the user wants to drop into the
editor going forward (event photos, custom illustrations, technician
photos for service campaigns, etc.) without uploading to Mailjet first.
Why self-hosted: avoids vendor lock-in for new assets, gives us control
over retention + immutable URLs, integrates natively with our GrapesJS
editor's AssetManager. The cost is ~5 MB max per image and one new bind
mount on the hub.
Backend (lib/campaigns.js):
- Storage at services/targo-hub/uploads/ (new bind mount, RW, mounted into
the container at /app/uploads). Files named by SHA-256 of content for:
• Automatic dedup (same image twice → same URL, no extra disk)
• Immutable URLs (content never changes for a given filename)
• Path-traversal defence (regex-locked filename pattern)
- POST /campaigns/assets/upload — accepts JSON { name, data } where data
is a data:image/...;base64,... URL. Decodes, validates MIME against
allow-list (png/jpg/gif/webp/svg), enforces 5 MB cap, hashes, persists,
returns { url, filename, size, content_type, data: [...] }. The `data`
array shape matches what GrapesJS' AssetManager expects on upload
success. Using base64-in-JSON avoids pulling a multipart parser
dependency — the ~33% encoding overhead is fine for ≤5 MB images.
- GET /campaigns/assets — list all uploaded assets with metadata
(filename, url, size, modified, content_type).
- GET /campaigns/assets/:hash.<ext> — serve image bytes with
Content-Type matching the extension + Cache-Control:
public, max-age=31536000, immutable. The 1-year cache is safe because
filename = content hash → URL never serves different bytes. Aligns
with how Gmail's image proxy and Outlook's caching work.
- DELETE /campaigns/assets/:hash.<ext> — admin removal from disk.
- Helpers (persistUpload / readUpload / deleteUpload) live at module
scope so they can call `path.join` (otherwise shadowed by the `path`
URL parameter inside handle()).
API client (apps/ops/src/api/campaigns.js):
- listAssets() → GET /campaigns/assets
- uploadAsset(file) → reads file via FileReader, posts base64 JSON
- deleteAsset(filename) → DELETE the hash-named file
GrapesJS editor (TemplateEditorPage.vue):
- assetManager config with custom uploadFile callback that bypasses
GrapesJS' built-in multipart uploader. Drag-drop or file-picker
triggers our base64 upload, on success the URL is added to the
AssetManager library so it appears in the editor sidebar for reuse.
- onMounted: preload all previously-uploaded assets via listAssets()
so the user sees their image library immediately when opening the
editor (no need to re-upload images used in past campaigns).
End-to-end verified live in prod:
POST /campaigns/assets/upload → 200 (with data URL JSON body)
GET /campaigns/assets → 200 (list)
GET /campaigns/assets/:hash → 200 (serves PNG bytes)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Map CSV migrated from the legacy ERP carries names with two common
defects: missing French accents (Stephane, Andre, Frederic), and
compound first names that were typed without a separator (Marcandre,
Mariejosee, Jeanphilippe). Sending an email "Bonjour stephane," instead
of "Bonjour Stéphane," reads as sloppy automation. Fix both at parse
time so the user sees the corrected names in Step 2 and can override
inline if the auto-cleaner got it wrong.
Backend (lib/campaigns.js):
- FR_NAME_FIXES — 100+ entry dictionary mapping lowercase no-accent
Québec first names to their canonical accented form (André, Stéphane,
Frédéric, Geneviève, Hélène, Joséée, etc.). Sourced from MIQ baby
names + older-generation curation.
- COMPOUND_PARTS — list of common name parts (jean, marie, anne, marc,
philippe, françois, etc.) that combine into QC compound first names.
When two parts appear concatenated with no separator, the cleaner
splits and hyphenates them. Example: "Marcandre" → ["marc","andre"]
→ "Marc-André" (dictionary then applies accent).
- titleCaseToken — proper Title Case respecting apostrophes (O'Brien,
L'Heureux) and hyphens (Marie-Ève). Uses \p{L} Unicode class so it
works on accented chars correctly.
- cleanName(raw) — full pipeline: trim → Title Case → dictionary
lookup per word → compound split fallback. Applied to firstname AND
lastname in parseMapCsv.
- nameWarning(name) — heuristic flag for cases the cleaner couldn't
confidently handle: digit in name, single letter, abnormally long
without separator (likely two stuck names not in COMPOUND_PARTS).
Returns a short FR description for the UI tooltip.
- parseMapCsv now returns firstname/lastname (cleaned) + firstname_raw/
lastname_raw (original from CSV) + cleaned_changed bool + name_warnings
per recipient. UI uses these to show before/after + flags.
UI (CampaignNewPage Step 2):
- New counter card "Noms à vérifier" — count of recipients with at least
one nameWarning. Only renders if > 0.
- Info banner above the recipients table:
"X nom(s) auto-corrigés (...) Y nom(s) suspects (...)"
- Per-row icons in the firstname + lastname columns:
• ⚠ amber WARNING — cleaner flagged this name as suspicious
(tooltip shows the reason: "deux prénoms collés", "contient un
chiffre", etc.)
• ✨ green AUTO_FIX_HIGH — auto-cleaner changed something at parse
time (tooltip shows the original raw value)
Both icons are tooltip-only — no action required.
- Click any name cell → q-popup-edit opens an inline input. Type the
correction, Enter saves. ESC cancels. This is the manual override
path for any name the auto-cleaner mishandled.
Tests (manual via end-to-end smoke against prod):
STEPHANE TREMBLAY → Stéphane Tremblay ✓ accent + Title Case
marie tremblay → Marie Tremblay ✓ Title Case only
Marcandre Boileau → Marc-André Boileau ✓ compound + accent
Jean Francois Lebrun → Jean François Lebrun ✓ accent only
Mariejosee Lapierre → Marie-Josée Lapierre ✓ compound + double accent
Andre LAPRISE → André Laprise ✓ both fixed
Helene St-Pierre → Hélène St-Pierre ✓ accent, hyphen preserved
Frederic O'Brien → Frédéric O'Brien ✓ accent, apostrophe preserved
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User confusion: the "Approuver — 3 à envoyer" button at the end of Step 2
had a send icon, suggesting it fired emails immediately. It actually
just navigated to Step 3 (the confirmation step). The current flow has
two consent moments (Step 2 approve → Step 3 launch) but the UI made
them look like one.
Three changes to address this:
1. Step 2 navigation button:
- Icon changed from 'send' to 'arrow_forward' — clearly "next step"
- Label changed from "Approuver — N à envoyer" to "Continuer — N prêts"
- Added tooltip explaining the send only happens at Step 3
2. Inline preview dialog:
- New "Aperçu du courriel" button in Step 2 (and Step 3)
- Opens a maximized dialog with an iframe rendering the actual template
via POST /campaigns/templates/:name/preview, using the first sendable
recipient's real data + the campaign params (amount, expiry, etc.)
- FR/EN toggle inside the dialog so the user can verify both templates
before launching a mixed-language campaign
- Defaults to the recipient's own language for first view
- Non-destructive — fires zero emails
3. Always-accessible "Éditer le template" link:
- Persistent button in the page header (visible all 3 steps)
- Plus secondary buttons in Step 2 + Step 3 action rows
- Opens the template editor in a NEW TAB so the wizard's state
(uploaded CSVs, parsed recipients) stays intact in the original
tab — the user can tweak the template, save, switch back, click
"Aperçu" to see the change, then continue with the send
4. Step 3 confirmation hardening:
- Banner color escalated from amber to red (this IS the point of no
return for actual delivery)
- Wrap the launch button click in a Quasar confirm dialog ("Envoyer
N courriel(s) maintenant ? Pas annulable.") — adds a third friction
point against accidental clicks
- Launch button is red (negative) — visually distinct from the green
navigation primaries to signal "destructive action ahead"
- Back-to-Step 2 button renamed "Retour modifier" with arrow_back
icon for clarity
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step 2 of the new-campaign wizard previously dropped unpaired contacts
silently (Math.min(contacts, gifts) iteration) — if you uploaded 5
contacts and 3 gift links, you got 3 recipients in the table with no
visible signal that 2 contacts were left out. Step 1 only showed
"contacts skipped: N" in a small banner, easy to miss.
Surface the imbalance explicitly so the user can decide before sending:
Backend (POST /campaigns/parse):
- Return unpaired_contacts[] and unused_gifts[] arrays (with row_index
for source-CSV cross-reference), in addition to the existing
recipients[]. Old leftover_gifts / leftover_contacts counters kept
for backward compat.
UI (CampaignNewPage Step 2):
- New columns in the recipients table:
• # (row index from the source CSVs)
• Lien-cadeau (truncated shortlink, clickable to verify)
These let the user eyeball the contact↔link pairing line by line.
- New counter strip:
Paires / À envoyer / Client lié / Sans client / Sans lien / Liens surplus
- "Sans lien" and "Liens surplus" counters appear only when relevant.
- Explicit warning banner explaining what unpaired/unused means
(acquire more links and re-upload, or proceed knowing N won't get).
- Expansion panel listing each unpaired contact with their row_index +
details, so the user can verify which specific contacts will be
excluded before approving.
- Expansion panel listing each unused gift URL (extra capacity).
- "Approuver" button now shows the exact send count: "Approuver — N à
envoyer". Disabled when 0. Step 3 recap also reflects sendableCount.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Step 1 file-upload widgets displayed `(newlines) - 1` for both CSVs,
assuming both files have a header row to discount. This breaks for the
Giftbit Link Order export which is headerless (one URL per line): a
3-URL file was showing "2 cartes-cadeaux" because the parser ate URL #1
as a fake header.
The backend parser was already correct (detects Link Order vs Campaign
format by inspecting the first line). The bug was UI-only — the count
display reused the same arithmetic for both formats.
Fix: introduce countMapRows / countGiftRows helpers that mirror the
backend's format detection. Map CSV subtracts 2 (preamble + header).
Gift CSV subtracts 0 for Link Order (headerless) or 1 for Campaign
export (with header). Plus a "(format: Link Order)" hint next to the
count so the user sees which detection path was taken.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues spotted during first real-data test:
1. parseGiftbitCsv only handled the Campaign-export format (header row
+ columns firstname/lastname/email/gift_url/uuid/...). The Link Order
product Giftbit ships when you pre-buy N links exports a different
format: headerless, one URL per line. Detect this by checking the
first non-empty line: if it starts with http(s):// and has no
comma/pipe/tab separators, treat the whole file as bare URLs. Each
URL maps to one recipient (row-order matching, same as before).
2. The template editor was hard-coded to load the existing
gift-email-fr.html into GrapesJS on mount. Hand-crafted email HTML
with deeply nested tables doesn't parse cleanly into GrapesJS
components, so the visual canvas often renders blank. Two new
toolbar actions to address this:
• "Vide" — clears the canvas to a minimal table-based skeleton.
For composing brand-new templates from scratch in the visual
editor without inheriting the existing template's structure.
Confirms before resetting, then sets dirty=true so the next Save
overwrites the on-disk template (with hub-side backup).
• "Réinitialiser" — reloads the last on-disk version, discarding
any unsaved canvas state. Confirms if dirty.
Plus an amber banner in visual mode (auto-hidden when blank-canvas
is active) explaining that Visual mode is for new templates and
the existing template should be edited in HTML mode.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New /campaigns section in the ops SPA, gated by manage_users (proxy until a
dedicated manage_campaigns capability is added).
Pages (apps/ops/src/modules/campaigns/pages/):
- CampaignsListPage: table of all campaigns with status chip + progress
bar (sent/total, with fail count), "Nouvelle campagne" + "Éditer le
template" buttons. Empty state with onboarding copy.
- CampaignNewPage: 3-step Quasar Stepper wizard.
Step 1 — upload Map CSV + Giftbit CSV, configure params (name, amount,
commitment_months, sender, throttle, multi-email handling).
Step 2 — preview the matched send list from POST /campaigns/parse, with
counters (matched/unmatched/excluded), per-row match-method
chip, and exclude/include toggle. Banner warns when CSVs are
mis-aligned (leftover gifts or contacts).
Step 3 — confirmation recap with estimated send duration, then fire
POST /campaigns + POST /campaigns/:id/send and redirect to the
live detail page.
- CampaignDetailPage: per-recipient table with status chips updated live
via EventSource on the campaign:<id> SSE topic. Counters bar
(envoyés / cliqués / queued / échecs / non envoyés), progress bar,
per-row customer-link badge with deep-link into /clients/<id>.
Auto-subscribes to SSE when status is draft|sending; "Lancer l'envoi"
button for draft campaigns.
- TemplateEditorPage: GrapesJS-based visual editor for the campaign
templates. Three view modes (Visuel / HTML / Aperçu) — the HTML mode
is the fallback for our table-heavy hand-crafted template that
GrapesJS-preset-newsletter may parse imperfectly. Aperçu mode calls
POST /campaigns/templates/:name/preview on the hub for live variable
substitution. Custom GrapesJS blocks under "Variables" category for
drag-drop insertion of {{firstname}}, {{amount}}, {{gift_url}},
{{description}}, {{expiry}}, {{commitment_months}}. Saves via PUT
with hub-side backup of the previous version.
Wiring:
- api/campaigns.js: hubFetch wrapper, exports parseCsvs / createCampaign
/ listCampaigns / getCampaign / updateCampaign / sendCampaign +
campaignSseUrl(id) for EventSource subscription, + listTemplates /
getTemplate / saveTemplate / previewTemplate for the editor.
- router/index.js: three new routes under /campaigns. The
/campaigns/templates/:name? route is positioned ABOVE /campaigns/:id
to prevent the wildcard from catching template paths.
- config/nav.js + layouts/MainLayout.vue: "Campagnes" sidebar entry with
Lucide Gift icon.
- package.json: grapesjs + grapesjs-preset-newsletter dependencies.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three connected dispatcher-facing issues from C-LPB4 audit:
1. **Monthly total was wrong on customer cards.** Section subtotal and
`locSubsMonthlyTotal` summed `actual_price` for ALL subscriptions
regardless of status, so cancelled rows (rendered with strikethrough)
still pumped up the displayed billing figure. C-LPB4 showed
"Total mensuel: 86,10$" computed as `196.05 - 109.95 = 86.10`,
where 196.05 included 3 cancelled internet plans (Megafibre 80,
TEST-E2E-FTTH, FTTH100 — all struck through in the UI). Real
active monthly is 5.00$ (109.95 active + 5 frais réseau − 109.95
loyalty rebate). Fixed both `sectionTotal` and `locSubsMonthlyTotal`
/`locSubsAnnualTotal` to filter on `status === 'Active'`.
2. **"Lieu" link from a dispatch task pointed to ERPNext desk** which
shows a raw doctype form (no abonnements, no totals, no contacts —
just the bare fields). Now points in-app to
`#/clients/<customer>?location=<SL>`. ClientDetailPage reads the
query string on mount and:
• scrolls the matching `loc-card` into view
• pulses an indigo halo around it for ~2s so the rep finds it
immediately even when the customer has many service locations.
3. **The shipping/billing distinction was invisible** on the customer
page. Added an "Adresses de livraison" badge next to the "Lieux de
service" section title — clarifies that this section IS the
shipping address, distinct from the (future) billing address that
will live on the Customer record. Cosmetic for now; the data
migration to formalize that distinction is the next step.
These three round out the C-LPB4 audit triggered by the wrong
mapbox-pin location: now the customer card on the dispatcher's
screen shows correct totals, the dispatch link drops them right at
the spot they're trying to reach, and the role of each address-bearing
record is named explicitly.
Two things ride together because the user noticed the URL bug while
testing the work-in-progress address validation:
1. **Broken Frappe URL pattern.** Three places in the dispatch UI
were generating `/desk/Service Location/<id>` and
`/desk/Dispatch Technician/<id>` links — both return "Page not
found" on Frappe v14+ (= our v16) because the modern desk URL
format is `/app/<slug>/<id>` where slug is lowercase + hyphens.
Fixed in:
• RightPanel.vue (Lieu link in the job details panel)
• DispatchPage.vue (Lieu in the job ctx menu)
• DispatchPage.vue (Ouvrir dans ERPNext in the tech ctx menu)
2. **`POST /address/validate` endpoint** on the hub. Wraps the
existing RQA Supabase search (`address-search.js`) with a
confidence-scored output:
• exact_match (boolean) — score >= 0.7
• best (the top RQA candidate with aq_address_id, lat, lng)
• candidates[] (top 5 ranked)
• confidence (0..1)
• recommendation: validated | review | unmatched
Score combines civic-number exact match, road-name fuzzy overlap,
FSA+full postal-code bonuses, and city-name bonus. The endpoint
is called from ops UI when adding/editing a Service Location to
auto-populate aq_address_id + canonical lat/lng instead of
trusting human typing or Mapbox geocode.
(Custom Fields aq_address_id, address_validation_status,
address_validated_at, linked_address have been added on Service
Location via the Frappe REST API in a separate operation — not in
this commit since they're DB-only.)
Two related issues, one PR:
1. **Bad coords** on customer C-LPB4's "Wifi buggy" job (DJ-MNP8WIKT).
Address on file is `691 rue des Hirondelles, Saint-Michel J0L2J0`,
but the saved lat/lng (-73.677086, 45.159206) reverse-geocodes to
`2336 rue René-Vinet, Sainte-Clotilde J0L1W0` — ~9 km away. The
delta matches the Gigafibre HQ default fallback (-73.6756, 45.1599)
pretty closely, suggesting the geocoder either failed silently at
Service Location creation time or got pinned to the HQ centroid.
Fixed live in DB (UPDATE on tabService Location LOC-0000000004 +
tabDispatch Job DJ-MNP8WIKT to lng=-73.5792377, lat=45.2408452,
verified via Nominatim against the typed address). The job pin
should now show on the correct house.
2. **No way to jump from a job to the client** — the dispatcher had
to memorize/type the customer ID. Now both the RightPanel and the
job context-menu surface clickable shortcuts:
• Client → `#/clients/<id>` (opens ClientDetailPage in-app)
• Lieu → `/desk/Service Location/<id>` (opens ERPNext in a new
tab; the ops SPA doesn't have a dedicated SL detail page)
Required wiring `customer` + `serviceLocation` into the job map in
`stores/dispatch.js` — the API (`fetchJobsFast` uses `["*"]`) was
already returning the fields, the store just wasn't surfacing them.
Note on the deeper bug: the SL lat/lng is the source of truth and the
job currently *copies* it at creation time (rather than reading from
the SL link dynamically). If a Service Location's coords are corrected
after a job exists, the job retains stale coords. A follow-up could
either (a) re-read on render, or (b) trigger a backfill when SL coords
change. Out of scope for this fix — for now, the dispatcher who fixes
an SL must also update any open jobs at that location.
A few connected fixes around the invite UI shipped in 969fe42:
1. **Bug in 969fe42**: `auth.js` referenced `erpFetch` without importing
it, so every invite returned `erpnext.ok=false` with the silent
"erpFetch is not defined" error in the catch. Imported it from
./helpers alongside the other helpers we already used.
2. **Authentik recovery flow not configured** (caught while smoke-testing):
the brand `auth.targo.ca` has `flow_recovery=None` and no SMTP, so
`POST /core/users/{pk}/recovery_email/` returned 400 "No recovery
flow set." Rather than build out a full Authentik recovery flow
via API (multiple stages, brand patch, SMTP env var changes), the
hub now generates a strong-but-readable temp password
(`X7K2-9NQB-4GHM-3RTW` style — no look-alike chars), POSTs it via
`/core/users/{pk}/set_password/`, and emails it via the existing
Mailjet SMTP (already wired into lib/email.js for invoice sends).
Returns `{temp_password, password_set, email_sent}` so the admin
has a fallback if Mailjet drops the message.
3. **Settings dialog** now shows a credentials panel after submit:
• Green banner "✓ Courriel envoyé" when email_sent=true
• Yellow "⚠ transmettez manuellement" when email_sent=false
• The temp password as a copyable field either way
• ERPNext User creation status
4. **Dev onboarding**: added `apps/ops/.env.example`,
`services/targo-hub/.env.example`, and a top-level `docs/SETUP.md`
that explains the local-dev flow (clone → cp .env.example .env →
npm install → npx quasar dev). The example envs are commented
per-section so a new dev knows which keys correspond to which
external integration. None of the real secrets are checked in —
the .gitignore already covers .env files.
Surfaces a "Inviter" button in Settings → Utilisateurs that, in one
round-trip:
1. Creates the Authentik user (random password, requested OPS_GROUPS,
auto username from local-part of email with collision suffix).
2. Triggers Authentik's recovery email so the user picks their own
password on first login. If the Email stage isn't configured,
falls back to /core/users/{pk}/recovery/ which returns a one-time
URL the admin can copy + send via SMS or Slack.
3. Creates the matching ERPNext System User with the requested
roles (default: Employee) and `social_logins=[{provider:authentik,
userid:email}]` so OAuth2 finds them on first SSO login.
send_welcome_email=1 also fires Frappe's invite mail.
Idempotent on both sides: if the Authentik user already exists, we
PATCH the requested groups; if the ERPNext User exists, we skip the
POST and return existing=true. Lets the admin re-invite somebody
after a botched first try without breaking anything.
UI:
• "Inviter" button next to the user search bar, gated by the
`manage_users` capability (existing pattern).
• q-dialog with full_name + email + chip-pickable Authentik groups
(admin/sysadmin/tech/support/comptabilite/facturation/dev) + a
comma-separated ERPNext roles input (defaults to Employee).
• Optimistic insert into the visible list on success; the next
search reconciles.
Two issues conflated in the same PR because they touch the same pixels:
1. **Resource filter no longer treats techs and materials as equals.**
Was a 3-button inline toggle [Tous][👤 45][🔧 6] with all three
visually similar — and the wrench glyph clashed with the wrench
used for the filter-settings button. Now:
• Default = 'human' (techs only). Materials are secondary
resources; they don't deserve front-of-bar real estate.
• Single chip [👥 45 ▾] in the toolbar. Click → dropdown:
· Techs (45) ← active by default
· Matériel (6) (only shown if materialCount > 0)
· Tous (51) (only shown if materialCount > 0)
• Defaults to localStorage 'sbv2-filterResType' if previously
persisted, otherwise 'human' instead of '' (was '').
2. **Mixed-style icons (emoji + Lucide SVG) replaced with consistent
single-color Lucide-style strokes.**
Each is a stroke-only inline <svg> with stroke="currentColor", so
they inherit the surrounding text color (no green/red/yellow
tinting). Added to the existing ICON set in useHelpers.js:
user, users, package, sliders, chevDown, map, clipboard,
sparkles, signal, rotateCw, alertTri, moreH, pause, play,
externalLink, target, calendar
Replaced in the dispatch top toolbar:
⚠️ → ICON.alertTri (overload alert)
📋 → ICON.clipboard (unscheduled jobs)
🗺 → ICON.map (map toggle)
🗓 → ICON.calendar (planning toggle)
👥 → ICON.users (team-jobs button + Ressources menu)
🔧 → ICON.sliders (filter-settings — was wrench, which
collided with the materials filter)
👤/🔧 → ICON.users / .package (resource type dropdown)
↻ → ICON.rotateCw (refresh in ⋯ menu)
✨ → ICON.sparkles (AI in ⋯ menu)
📡 → ICON.signal (offers in ⋯ menu)
↗ → ICON.externalLink (ERPNext link in ⋯ menu)
⋯ → ICON.moreH (the ⋯ button itself)
.sb-icon-svg gives them consistent sizing (14px in buttons, 15px
in dropdown items, 16px in the ⋯ trigger) so they're crisp at all
the spots they appear.
Emojis still in place elsewhere (job-tile chips, status badges, etc.)
will be migrated incrementally — out of scope for this pass which
only targeted the user's visible header.
Four fixes around the dispatch header following dispatcher feedback:
1. **⋯ overflow menu was invisible**: .sb-header had `overflow:hidden`,
which clipped the absolutely-positioned dropdown right at the
header's bottom edge. Switched the header to `overflow:visible`
(children all have flex-shrink:0 + a flex:1 center, so the layout
doesn't actually overflow horizontally). Bumped z-index to 5000
for safety on top of map/calendar layers.
2. **NLP/Assistant IA bar hidden by default**: was eagerly rendering
on every page load, with the long French placeholder polluting
the header below the toolbar. The user just wanted the icon. Now
`nlpVisible` defaults to false, persisted in localStorage so power
users who flip it on keep it open across sessions. Toggle still
lives in the ⋯ menu.
3. **Click a tech in the resource list now flies the map to them**:
selectTechOnBoard previously only opened the map panel. Now it
also `map.flyTo({ center })` using `tech.gpsCoords ?? tech.coords`
— live Traccar position wins when the tech is online; falls back
to the saved home base. Animated, deferred a tick so map.resize()
happens first, otherwise flyTo can land on garbage coords during
the panel's open transition.
4. **Board view tabs collapsed into a "Vue principale ▾" dropdown**:
was [Vue principale][Par région][+] inline. Now a single button
showing the active view; click reveals the others + the future
"+ Nouvelle vue" entry. Same dropdown component as the ⋯ menu
(shared CSS, click-outside + ESC close).
The header right-side was getting noisy — 8 buttons + 2 indicators
all competing for screen width, with two visually-similar 📡 icons
(offer pool vs GPS settings) that confused dispatchers. On narrower
laptops the bar wrapped or icons overflowed.
New layout:
[⚠ overload] [📋 unassigned + count] [🗺 Carte] [Publier + count] [+ WO] [⋯]
Everything else dropped into the ⋯ dropdown:
• ↻ Actualiser
• ✨ Assistant IA
• 📡 Offres aux techs (with green count badge)
• 👥 Ressources & GPS ← was 📡 in the bar; this is also where
the tech-management UI (rename, deactivate,
home location, Traccar device link) lives
• ↗ Ouvrir ERPNext (with the inline status dot)
The ⋯ menu closes on Escape, on click outside, and after picking an
item. Same close-handler chain that already serves the job/tech
context menus.
The kept-up-front buttons all have either a status badge (counts,
overload alert) or are the primary CTAs (Publier, + WO) — so the
dispatcher's eye stays on workflow signal, not on chrome.
Three connected UX changes:
1. **Map centered on Gigafibre HQ on first load** —
Sainte-Clotilde (lng=-73.6756, lat=45.1599), zoom 10 — covers the
service area (Sainte-Clotilde + Châteauguay + Napierville +
Hemmingford). Was downtown Montréal.
2. **Right-click on a tech pin** opens the existing techCtx menu
(already used from the calendar via @ctx-tech). New entries:
• 📍 Adresse de départ… → openTechHomeDialog
• 🎯 Choisir sur la carte → startTechGeoFix (mirrors the existing
geoFixJob flow used for jobs)
3. **The 📍 button in the GPS sidebar** now offers a 2-option chooser
first: "Saisir une adresse" or "Cliquer sur la carte". Picking the
map option drops the user into geoFixTech mode.
Implementation:
• useMap.js: new geoFixTech ref + startTechGeoFix/cancelTechGeoFix
+ a contextmenu listener on each tech outer wrapper that calls
openTechCtx(e, tech). The map's main click handler now branches:
if geoFixTech is set, persist the lng/lat via saveTechHome (passed
in via deps as a forward-bound arrow because saveTechHome is
destructured below the useMap call in DispatchPage).
• DispatchPage.vue: new banner shown while in pick mode (animated
indigo bar at top, "Cliquez sur la carte pour {tech}", with a
cancel button); ESC also cancels.
• dispatch-styles.scss: .sb-geofix-banner styles + reusing the
existing pulse keyframe.
Two changes around tech "departure point" coords (used for route
optimization when the tech has no live GPS yet):
1. New default fallback = 1867 chemin de la Rivière, Sainte-Clotilde
(Gigafibre HQ, lng=-73.6756, lat=45.1599). Was downtown Montréal,
which never made sense — every tech started the day with a 70 km
imaginary commute.
2. Per-tech editable home base via a 📍 button on each row of the
tech sidebar. Clicking it opens a dialog that accepts either:
• a free-text address — geocoded via OpenStreetMap Nominatim
(browser-side, sane User-Agent, no hub proxy needed)
• or a literal "lat, lng" pair pasted directly
On confirm: PUT to ERPNext (Dispatch Technician.latitude /
.longitude), patch the local store row, and trigger a route
recompute since the start point changed.
The geocode hits Nominatim public — fine for a low-volume
internal tool. If we ever exceed their fair-use limits, swap to
the existing /address-search hub route which already has the
AQ + RQA pipeline.
The map marker container was being created with an inline
`position:relative`, which overrode Mapbox GL's `.mapboxgl-marker`
class (which applies `position:absolute`). Mapbox writes
`transform: translate(<x>, <y>)` to that exact element on every
zoom/pan frame to project lat/lng → screen coordinates. With the
element kept in the document flow (relative), the transform is
interpreted against the document origin instead of the map pane,
so the pin visually drifts as the user zooms in on a tech.
Removing the inline `position:relative` lets the Mapbox class win.
The SVG ring and the avatar div are children with `position:absolute`
inside outer; absolute children only need a positioned ancestor to
form a containing block — `position:absolute` (Mapbox's value)
qualifies just as well as relative, so the avatar stays centered.
Contract termination is a fee-bearing, auditable workflow — it belongs
on the contract, not buried in a sub's delete dialog. Standard SaaS /
telecom practice: subs are an immutable event stream, contracts
orchestrate their lifecycle.
ServiceContractDetail.vue (new)
• Status banner: contract type, dates, status — "Résilier" button
when actionable, termination invoice link when already résilié.
• Term progress bar: months_elapsed / duration_months with color
ramp (primary → amber near end → positive when done).
• Financial summary grid: mensualité, abonnement (clickable), devis,
lieu, total avantages, résiduel, signature method & date.
• Benefits detail table: per-row description, regular_price vs
granted_price, économie, reconnu à date, et "À rembourser"
(valeur résiduelle) — this is what the rep needs to see before
deciding to break a contract.
• Termination recap (only when status=Résilié): date, raison,
penalty breakdown, link to the termination invoice.
• "Résilier" action runs a 2-step dialog: first calls
/contract/calculate-termination for the preview, then prompts for
a reason (textarea, min 3 chars) before firing /contract/terminate.
On success: cascade-cancels the linked sub (status=Annulé +
end_date + cancellation_date — no hard delete), mutates the
local doc so the modal refreshes in place, and emits
contract-terminated so the parent page updates its sub + contract
rows + drops an audit comment on the customer.
DetailModal
• SECTION_MAP now routes Service Contract → ServiceContractDetail.
Also added 'Service Subscription' → SubscriptionDetail (same
template fits; was falling through to the generic grid).
• Re-emits contract-terminated so the parent can listen.
ClientDetailPage
• confirmDeleteSub: when a live contract references the sub, the
dialog now simply redirects the rep to the contract modal
("Voir le contrat") instead of trying to do termination from
the sub row. Terminal-state contracts (Résilié/Complété/Expiré)
still get the inline link-scrub path so stale refs don't block
a legit delete.
• onContractTerminated: reflects the cascade locally — contract
row → Résilié, sub row → Cancelled + end_date, audit Comment
posted to the customer's notes feed.
The raw DELETE on Service Subscription was blowing up with
LinkExistsError because Service Contract.service_subscription still
referenced the sub. Worse: silently unlinking a live contract would
cost the business the break fee (résidentiel = avantages résiduels,
commercial = mensualités restantes).
Now when the user clicks 🗑 on a sub:
1. loadServiceContracts pulls `service_subscription` so the client
can spot the link without a round-trip.
2. If a non-terminal contract is linked, the dialog upgrades to:
• header: Contract name + type
• term bar: start → end, months elapsed / months remaining
(pulled live from /contract/calculate-termination)
• penalty breakdown box: total fee, split into benefits to
refund + remaining months, plus a warning that a termination
invoice will be created
• radio: "Désactiver seulement (conserver le contrat)" vs
"Résilier + facturer X$ + supprimer"
Suspend-only route goes through toggleSubStatus (no fee).
Terminate route hits /contract/terminate (status→Résilié +
invoice), then unlinks + deletes the sub, and drops an audit
line referencing the generated invoice.
3. If the linked contract is already Résilié/Complété we just scrub
the stale link inline in the plain confirm path so the
dispatcher isn't forced into the termination UI.
- InlineField on monthly row price (dblclick) + annual row monthly base
price. Saves via Service Subscription.monthly_price → mirrored back
into the UI row's actual_price; drops an audit line on the customer
timeline.
- Delete button (confirm dialog, v-if=can('delete_records')) on both
monthly + annual rows. Uses deleteDoc + local splice + invalidates
location + section caches.
- display_order custom Int field on Service Subscription, persisted in
10-step increments on drag reorder (so manual inserts have room to
squeeze between without a full re-number pass). loadSubscriptions
sorts by display_order first so the dispatcher-controlled order
survives a page reload and can drive invoice print ordering later.
- Rebate rows nested visually: 32px indent + arrow glyph + lighter
red background + smaller type + inherited red color on the inline
price input. Matches the invoice PDF grouping dispatchers expect.
Adding a forfait from the client detail dialog failed with `Update
failed: 417` because the code path manipulated ERPNext's stock
Subscription doctype — a parent/child (Subscription Plan rows) model
with tight validation ("Subscription End Date is mandatory to follow
calendar months"), and whose `plan` field expects an `SP-<hash>` doc
name rather than a free-form string.
Meanwhile all new subscription work — contract signing, chain
activation, prorated invoicing — already writes to our flat custom
`Service Subscription` doctype. The two systems were not talking to
each other: the Service Subscription created for CTR-00008 was
invisible in the client UI (which only read stock Subscription), and
the stock Subscription created by "Ajouter un service" was invisible
to the contract/chain system.
This commit makes Service Subscription the canonical doctype for
everything the ops UI does:
- useClientData.loadSubscriptions: read Service Subscription directly
(flat doc → UI row) instead of reading stock Subscription + joining
its Subscription Plan child rows to Items. Legacy stock Subscription
rows (~39k from the 2026-03-29 migration) stay as audit records
but are no longer surfaced.
- ClientDetailPage.createService: POST a Service Subscription doc
(category inferred from item_group). No parent/child logic, no
calendar-month coupling, no SP-<hash> plan reference. Manual
description + price entry now works without a catalog pick.
- useSubscriptionActions.updateSub: drop the bogus `ASUB-*` name-based
doctype detection (ASUB is not a real prefix — both stock and
Service subs are named SUB-<hex|digits>) and always target Service
Subscription. Also surface ERPNext's exception one-liner instead of
raw HTML when an update fails.
- searchPlans: empty/short query now returns top-50 of the Subscription
Plan catalog so dispatchers can browse instead of being forced to
guess a name prefix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>