Commit Graph

60 Commits

Author SHA1 Message Date
louispaulb
3599ec995f feat(dispatch): P1-C "Trouver un créneau" — wire suggest-slots to a booking dialog
The hub /dispatch/suggest-slots (gap-finding + travel buffer + proximity
ranking) existed but had NO UI (Gemini claimed SuggestSlotsDialog.vue was
coded — it did not exist). Built it:

- api/dispatch.js: suggestSlots(payload) → POST /dispatch/suggest-slots.
- SuggestSlotsDialog.vue: address (RQA geocode via useAddressSearch) +
  duration + earliest date → ranked slots (tech, date, time, travel min,
  reasons). Selecting a slot emits it.
- DispatchPage: "📅 Créneau" button in the header opens the dialog;
  onSlotSelected prefills the WO create modal (tech + date + address +
  duration) and confirmWo injects the slot's start_time + geocoded coords
  into createJob. Manual "+ WO" clears the booking slot.

Verified: dialog compiles/opens with inputs; suggest-slots returns ranked
slots (e.g. Gilles Drolet 08:15–09:45, journée libre, 15 min). Note: the
address geocode (/api/method/search_address) 403s in the dev preview
because the dev proxy doesn't inject the ERP token that prod nginx adds —
works in prod (same call the create modal already uses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 07:30:08 -04:00
louispaulb
fc1f61eecf fix(dispatch): OSRM for ALL routing + fix waypoint overshoot + detail dialog polish
Routing: the last Mapbox routing calls now go through our OSRM —
useMap.computeDayRoute (Dispatch board route lines) → /roster/osrm-route,
useAutoDispatch optimizeRoute → new /roster/osrm-trip (OSRM /trip TSP,
same response shape as Mapbox optimized-trips). 0 Mapbox directions/
matrix/optimized-trips fetches left in the SPA. (Remaining Mapbox =
gl-js renderer + base tiles + geocoding, which OSRM can't provide.)

Anthony route bug: /roster/osrm-route now sends continue_straight=false.
Without it OSRM forbids U-turns at intermediate waypoints, so on a
home→stop→home loop it overshoots the address and turns around in a side
street ("s'éloigne et tourne"). Now it goes straight to the door.
Benefits every map (shared osrm-route).

Job detail dialog:
- Highlighted assigned-tech chip (indigo, avatar+name) in the header —
  "who does this job" is now obvious; grey "Non assigné" otherwise.
- Mini-map shows nearby jobs (pool + occupancy within ~3km) as pale
  grey dots for context, under the job's own pin; centered on the job.
- Normalize displayed strings: decode HTML entities (subject
  "d&#039;équipement" → "d'équipement") via deEnt().

Verified: osrm-trip returns Ok order; detail shows Anthony Dion chip +
decoded subject; routes render (43 pins), Anthony 31.7km/35min round trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:41:00 -04:00
louispaulb
c3f1c77164 feat(dispatch): tech chips on map, distinct colors, remote (no-travel) jobs, group-as-project
- Tournées tab: removable/addable TECH CHIPS above the map — each chip
  in the tech's color with a job-count badge; click toggles the route
  on/off (colors stay stable). Route colors switched from a 12-color
  fixed palette to golden-angle HSL (137.5°) — consecutive techs always
  get highly distinguishable hues, unlimited count.
- 🖥 SANS DÉPLACEMENT: per-job persistent flag (hub store data/
  job-remote.json + POST /roster/job-remote; buildUnassigned sets
  j.remote). optimizePlan EXCLUDES remote jobs from routing and address
  pinning (e.g. "Configuration de boîtier tel" doable via ACS — nobody
  travels) → returned in a remote[] bucket, shown as a "🖥 Sans
  déplacement" group in the review, assignable to an agent. Toggle
  button (computer icon) on every review entry. Verified: "Boitier
  ouvert | Lac des pins" flagged → excluded from routes, then reverted.
- 📦 GROUPER EN PROJET: same-address entries (🔗) now open a menu →
  "Grouper en projet" sets ERPNext parent_job on the shorter tasks
  (parent = longest), POST /roster/job-group. Plugs into the existing
  parent-child pool sort ("Groupe (parent-enfant)").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 19:25:39 -04:00
louispaulb
cd12dbe601 feat(dispatch): P4 hub-side optimize orchestration + same-address rules
P4 — the FULL optimize orchestration now lives in the hub
(POST /roster/optimize-plan): pool (buildUnassigned), day bucketing
(dated-in-window → its day; overdue/undated → day 1 with spillover to
next day if unplaced), vehicles from real shift windows (assignments ×
templates) or assumed 8-16 weekdays (assumed_shift map returned; never
weekends; absences excluded), existing load (occupancyByTechDay) reduces
capacity, origins = home else TARGO depot, priorities from j.priority,
per-job AM/PM windows + duration overrides accepted, OSRM matrix +
OR-Tools solve per day (shared solveVrp, also used by /optimize-routes).
Reusable by the morning auto-plan cron and the field app.

Address rules (user cases baked in, hub-side):
- SAME ADDRESS = ONE STOP: pool jobs sharing an address key (~10m coords
  or normalized address) are merged into one solver node (service times
  summed) → same tech, single stop. Verified: install + TV boîtier of
  the same customer (Cazaville) grouped on Stéphane, 🔗 badges.
- COORDINATION: a pool job at the address of an ALREADY-ASSIGNED job
  that day is pinned onto THAT tech (they're going anyway) — verified:
  "Réinstallation #253028" pinned to Frédérique who already has the
  install there; capable flag kept honest (⚠ if skill missing).

SPA: optimizeSuggestion → hub-first (dates/techs/opts/dur_overrides/
job_windows), entries enriched client-side (lvl/eff/noShift from
assumed_shift); local orchestration kept as emergency fallback. Review
shows 🔗 teal (coordonné avec déjà-assigné) / 🔗 indigo (N jobs même
adresse = 1 arrêt); map merges same-coord stops into one numbered pin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 19:14:38 -04:00
louispaulb
462fcef9d5 feat(dispatch): P1 OSRM everywhere + review ticket detail, editable time, per-leg travel
P1 — self-hosted OSRM replaces ALL paid Mapbox routing in the SPA:
- hub: POST /roster/osrm-route (geometry + total + per-leg km/min) and
  /roster/osrm-table (durations/distances matrix, Mapbox-shaped) proxying
  the local OSRM; osrmGet helper.
- SPA: loadRealRoutes (review map), kanban travel matrix, day-editor
  matrix, day-map route geometry → all via hub OSRM. 0 paid
  directions/matrix calls left (tiles + geocoding only).

Review usability (evaluate whether times fit):
- Click a job → the SAME job-detail dialog as the grid (address, ticket
  thread, duration, team). openEntryDetail maps the pool job.
- Per-task time is editable (q-popup-edit on the "X.Xh"): updates the
  plan live, survives Ré-optimiser (durOverride), persists duration_h
  via patchJob (hub whitelist extended, 0-24h).
- Real travel time BETWEEN stops: one OSRM route per tech×day (cached)
  → "🚗 X min" rows between entries + "domicile → 1er arrêt";
  haversine ≈ fallback when coords missing.

Verified: OSRM proxies live (route 7.8km/12min with legs 7+6min, table
3x3); review shows 10 real leg rows; detail dialog shows address;
duration popup opens/cancels cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:41:44 -04:00
louispaulb
7fa8c6dc74 refactor(priority): unify on 3 levels (ERPNext standard), high = red, jobs + conversations
- 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>
2026-07-02 16:01:25 -04:00
louispaulb
4e5ad011ee fix(dispatch): reuse the Communications priority flag (grey outline → list), not a red emoji
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>
2026-07-02 15:50:45 -04:00
louispaulb
7d1a5b067f feat(dispatch): persistent URGENT flag per job (SLA/commercial)
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>
2026-07-02 15:16:13 -04:00
louispaulb
a6508845a7 feat(dispatch): VRP route optimizer (OR-Tools) — "Optimiser" mode
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>
2026-07-02 11:24:08 -04:00
louispaulb
aa6cc3e732 feat(dispatch): niveau requis persistant + lasso freeform + routes réelles Mapbox
- 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>
2026-07-02 09:18:33 -04:00
louispaulb
512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
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>
2026-07-02 08:49:35 -04:00
louispaulb
6628eaaa5b feat(ops+hub): sender picker, per-user notifs, editable rating emails, planif & tickets UX
- 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>
2026-06-16 22:41:51 -04:00
louispaulb
1b7bad1686 security: harden payments/hub auth + remove leaked ERPNext token from source
Auth hardening:
- payments: per-customer JWT authorization on dual-use /payments routes
  (balance/methods/invoice/checkout/setup/portal/toggle-ppa) via authorizeCustomer
  + PAYMENTS_AUTH=enforce; portal retains+sends magic-link JWT (sessionStorage)
- hub: fail-closed Stripe webhook, /accept/doc-pdf IDOR gate, telephony field-name
  guard (SQLi), modem-bridge private-IP guard (SSRF), ALWAYS_ENFORCE expansion

Leaked-credential cleanup (token already rotated in ERPNext):
- de-hardcode ERPNext API token -> env in bulk_submit.py, import_items.py,
  apps/client/deploy.sh; placeholder in apps/ops/infra/nginx.conf (nginx injects)
- ops prod build no longer bakes VITE_ERP_TOKEN (.env.production empty)
- de-hardcode legacy DB password -> env in import_items.py
- gitignore legacy migration PII exports (tsv/json)

Conversations/UI:
- floating (un-docked) conversation panel; full-width mailbox
- per-message real sender from email headers; unified scroll; header spacing
- campaign pre-send review (subject/from/channel), Gmail send channel, clone-as-draft

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:17:17 -04:00
louispaulb
0f65c02d83 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
Accumulated work on the dispatch/legacy-writeback branch:
- Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard,
  PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs;
  hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js.
- Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js,
  sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental
  migration scripts; LegacySyncPage, SupplierInvoices + negative-billing /
  terminated-active reports.
- Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit,
  olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers,
  legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets,
  ClientDetail updates.
- docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:12:12 -04:00
louispaulb
98458861c3 feat(dispatch): capacité AM/PM, durées additives, capture terrain (/field), sync techs
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>
2026-06-08 19:47:21 -04:00
louispaulb
3d65b994e5 feat(dispatch): fermer ticket (unitaire + lot), tri ASC/DESC, batch PHP
- PHP ops_reassign.php : action `close` (single) + `batch_close` (N tickets en 1 connexion → efficace)
  ; close = status=closed + date_closed + closed_by=acteur Authentik + réouverture enfants waiting_for + log.
- Hub : closeTicketLegacy + batchCloseLegacy + routes POST close-ticket / batch-close (marque Dispatch Job Completed).
- Ops : bouton « Fermer ce ticket » (fil du panneau) ; « Fermer (N) » sur la sélection (fermeture en lot) ;
  tri ASC/DESC du panneau (toggle, surtout pour la date).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:40:26 -04:00
louispaulb
4abce6fd66 feat(dispatch): pont d'écriture legacy + adresses/coords/carte/UX Planification
Pont d'écriture Ops → legacy osTicket via endpoint PHP token-gated (hérite des
droits write de l'app facturation.targo.ca → AUCUN grant DB). Fidèle à ticket_view.php :
réassignation (assign_to + participant[assistants] + followed_by + ticket_msg + lock
respecté) + fermeture (status=closed + date_closed + closed_by + réouverture des enfants).
Auteur du log = utilisateur Authentik réel (email → staff legacy par email/nom).

Hub (legacy-dispatch-sync.js):
- adresse de SERVICE importée (champ address) + reimportAddresses + fillMissingCoords
- garde TERRITOIRE (rejette les homonymes hors-zone Gaspésie/Outaouais/Estrie) + centroïde CP/ville
- purgeStaleOrphans (anciens imports TT- périmés), # ticket legacy dans titre + description
- legacyWrite (POST form + X-Ops-Token lu d'un fichier), pushAssignments (reassign), closeTicketLegacy
roster.js: occupancy + unassigned-jobs exposent address/latitude/longitude.

Ops (PlanificationPage.vue):
- carte des jobs à assigner (pins couleur=compétence + lettre repère liste↔carte), chips
  filtre par type, panneau ouvert par défaut
- clics cellule: bande quart/garde + blocs jobs → tournée ; fond → menu horaire (fini le clic droit)
- éditeur de tournée: itinéraire routier réel + adresse + fil ticket (expand), refresh occupation à l'assignation
- bouton « Publier au legacy » (aperçu + ✕ désassigner) + « Fermer le ticket »
- fix dates (lundi calculé en local — plus de décalage UTC) + nav ±1 jour

services/legacy-bridge/ops_reassign.php: endpoint (placeholders; secrets injectés au déploiement, hors repo).
scripts/migration: backfill_dispatch_address.sql + diagnostics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:32:00 -04:00
louispaulb
ab5eceb961 Conformité : repli « centre du code postal / ville » pour les unmatched restants (statut 'area')
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>
2026-06-07 17:14:41 -04:00
louispaulb
ed651d3ce9 Campings : gestion du registre + réapplication self-service depuis Ops
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>
2026-06-07 17:07:15 -04:00
louispaulb
72d23665b2 Page Ops « Conformité des adresses » — source de vérité unique pour résoudre le backlog
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>
2026-06-07 01:06:55 -04:00
louispaulb
d96e7be672 feat(planif): éditeur de JOURNÉE contextuel au clic sur le progressbar (drag-drop réordonner + retirer)
- 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>
2026-06-06 13:29:55 -04:00
louispaulb
510f8c10e0 feat(planif): blocs cellule colorés par compétence + menu réordonner/re-prioriser au clic sur le progressbar
- 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>
2026-06-06 12:44:22 -04:00
louispaulb
2631ed9199 feat(dispatch): couleur ticket = couleur skill + fil complet du ticket + tri pool (date/ville/priorité)
- 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>
2026-06-06 12:37:45 -04:00
louispaulb
e33dd8e83a roster(planif): assignation drag-drop + timeline ressource + occupation + nettoyage
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>
2026-06-05 15:50:17 -04:00
louispaulb
9ec829fc6f Planification: marquer une absence d'1 jour depuis la grille (touche A + menu)
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>
2026-06-04 22:02:39 -04:00
louispaulb
a60a9bee91 Garde: génération sur un HORIZON multi-semaines (évènement récurrent) au lieu d'une seule semaine
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>
2026-06-04 21:58:15 -04:00
louispaulb
bb337db14f Planification: hachuré = ABSENT (congé/pause), garde = pointillé ambre (sur appel hors heures)
- Hachuré gris = ABSENT (Tech Availability approuvée + En pause). Nouvel endpoint /roster/absences
  (En pause global + congés approuvés par jour) → la cellule d'un tech absent est hachurée (tooltip = type).
  Remplace l'ancien 'P' pause.
- GARDE = nouveau visuel: bande à CONTOUR POINTILLÉ AMBRE + fond ambre léger (sur appel, hors heures
  d'ouverture) — distinct du travail planifié et de l'absent.
- Légende: dispo (matin→soir) · occupation · absent (hachuré) · garde (pointillé). Retrait 'P pause'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:43:47 -04:00
louispaulb
fd5c83d367 Planification: taux d'occupation par cellule (jobs assignés / heures du shift)
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>
2026-06-04 15:13:27 -04:00
louispaulb
ee71f69adc Ops RDV+Copilote: vue agent (semaine/jour + hold), file À recontacter, réglages #56
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>
2026-06-04 14:28:14 -04:00
louispaulb
a5270f8749 Ops: authFetch robuste — reconnexion auto sur session Authentik expirée
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>
2026-06-04 12:20:01 -04:00
louispaulb
7bcca6e5ca Ops: page Copilote dispatch (chat + voix + sélecteur de politique)
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>
2026-06-04 11:09:16 -04:00
louispaulb
1a4c45662a Roster AI (planification) + prise de rendez-vous client
Solveur OR-Tools (services/roster-solver) : couverture, compétences,
équité, coût chargé, cadence/efficacité, capacité-par-job ; contraintes
dures/souples façon Timefold.

Hub (lib/roster.js) : génération via solveur, publication par réécriture
de semaine (anti-doublons), demande (effectif ou nb de jobs), cadence/coût/
compétences par tech, pause, congés (Tech Availability + approbation),
booking (slots roster-aware / fit 3-dispos / confirm) + portail public /book.
Réessai sur serialization failures frappe_pg ; appels ERP séquentiels.

Ops : page Planification (grille compacte « J8 », multi-shift, drag-select
+ undo/redo, modèles de semaine, éditeur cadence&coût, congés, SMS opt-in),
page Rendez-vous (répartiteur), jobColor tech en pause → tickets rouges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:42:44 -04:00
louispaulb
7f8382496e feat(ops): Email Queue admin page (view/delete/purge ERPNext outbound)
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>
2026-06-02 17:29:33 -04:00
louispaulb
5592ab6871 feat(ops): per-address competitor column via Québec IHV open data
Replaces the reCAPTCHA-blocked Cogeco scraper with the authoritative Québec
"Accès Internet haute vitesse" open ArcGIS data (providers declared to the
gov by the providers themselves — validated to match Cogeco's own popup).

- hub lib/serviceability.js: address → ADR (Adresse_complete → IdAdresse +
  Etat_hiv, civic+postal match w/ JS street disambiguation) → FRN table
  (IdAdresse → FRN_nom providers + signup URLs). Referer-gated proxy, disk
  cache (90d), polite rate limit. Routes /serviceability/lookup[-batch].
- ops ReportInternetCherPage: "Concurrence (FSI)" column — provider chips
  (Cogeco highlighted), batch-fetched on demand with progress; "Cogeco
  disponible" summary card = churn-risk count; manual Cogeco verify icon kept.

Validated live: 37 Chemin Noël → Cogeco+Targo, 147 Montée Richard → Targo
only, Repentigny → Bell+Cogeco. Endpoints documented in
memory/reference_quebec_ihv.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:40:47 -04:00
louispaulb
37c53019f4 feat(ops/reports): "Internet trop cher" legacy report
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>
2026-06-01 19:06:05 -04:00
louispaulb
0f59191fde feat(campaigns): reminder campaign for non-clickers
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>
2026-06-01 11:43:35 -04:00
louispaulb
1774418689 feat(campaigns/send): real SMTP error + auto-retry + one-click Renvoyer
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>
2026-05-22 13:29:25 -04:00
louispaulb
ec007ed406 feat(campaigns): gifts inventory page + expiry presets
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>
2026-05-22 10:21:05 -04:00
louispaulb
9f3a991442 feat(campaigns): delete campaign from the list
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>
2026-05-22 09:33:44 -04:00
louispaulb
1b64e3123d feat(campaigns): CSV report, manual recipients, template polish
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>
2026-05-22 09:09:07 -04:00
louispaulb
43c19048a7 feat(campaigns): AI template translator via Gemini Flash
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>
2026-05-22 07:21:45 -04:00
louispaulb
0cd99b6d44 feat(ops/campaigns): pivot template editor to Unlayer (vue-email-editor)
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>
2026-05-22 06:14:06 -04:00
louispaulb
1c2fa10f7f feat(campaigns): MJML canonical templates + test-send button
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>
2026-05-21 22:36:35 -04:00
louispaulb
2f11b532c8 feat(campaigns/editor): MJML mode — proper email-focused visual builder
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>
2026-05-21 22:29:42 -04:00
louispaulb
1d05c5c4c5 feat(campaigns/assets): self-hosted image upload + GrapesJS asset manager
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>
2026-05-21 21:53:01 -04:00
louispaulb
2de87c4c7b feat(ops/campaigns): UI module for gift campaigns + GrapesJS template editor
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>
2026-05-21 19:08:04 -04:00
louispaulb
8f11313b50 feat: unify vision on Gemini + port field tech scan/device into /j
- Invoice OCR migrated from Ollama (GPU-bound, local) to Gemini 2.5
  Flash via new targo-hub /vision/invoice endpoint with responseSchema
  enforcement. Ops VM no longer needs a GPU.
- Ops /j/* now has full camera scanner (TechScanPage) ported from
  apps/field with 8s timeout + offline queue + auto-link to Dispatch
  Job context on serial/barcode/MAC 3-tier lookup.
- New TechDevicePage reached via /j/device/:serial showing every
  ERPNext entity related to a scanned device: Service Equipment,
  Customer, Service Location, active Subscription, open Issues,
  upcoming Dispatch Jobs, OLT info.
- New docs/VISION_AND_OCR.md (full pipeline + §10 relationship graph
  + §8.1 secrets/rotation policy). Cross-linked from ARCHITECTURE,
  ROADMAP, HANDOFF, README.
- Nginx /ollama/ proxy blocks removed from both ops + field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:26:01 -04:00
louispaulb
cec5252944 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00
louispaulb
10d0854e51 refactor: reduce token count, DRY code, consolidate docs
Backend services:
- targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons
  lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas,
  extract dispatch scoring weights, trim section dividers across 9 files
- modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(),
  consolidate DM query factory, fix duplicate username fill bug, trim headers
  (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%)

Frontend:
- useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into
  6 focused helpers (processOnlineStatus, processWanIPs, processRadios,
  processMeshNodes, processClients, checkRadioIssues)
- EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments

Documentation (17 → 13 files, -1,400 lines):
- New consolidated README.md (architecture, services, dependencies, auth)
- Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md
- Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md
- Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md
- Update ROADMAP.md with current phase status
- Delete CONTEXT.md (absorbed into README)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:39:58 -04:00
louispaulb
9b9d307bdf feat: dispatch planning mode, offer pool, shared presets, recurrence selector
- Planning mode toggle: shift availability as background blocks on timeline
  (week view shows green=available, yellow=on-call; month view per-tech)
- On-call/guard shift editor with RRULE recurrence on tech schedules
- Uber-style job offer pool: broadcast/targeted/pool modes with pricing,
  SMS notifications, accept/decline flow, overload detection alerts
- Shared resource group presets via ERPNext Dispatch Preset doctype
  (replaces localStorage, shared between supervisors)
- Google Calendar-style RecurrenceSelector component with contextual
  quick options + custom RRULE editor, integrated in booking overlay
  and extra shift editor
- Remove default "Repos" ghost chips — only visible in planning mode
- Clean up debug console.logs across API, store, and page layers
- Add extra_shifts Custom Field on Dispatch Technician doctype

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 22:44:18 -04:00