Commit Graph

287 Commits

Author SHA1 Message Date
louispaulb
86bd8080d2 fix(dispatch): optimizer respects existing load; drop pseudo-tech placeholders; edit skills from review
- 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>
2026-07-02 15:35:25 -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
2566976191 feat(dispatch): review job-move menu ranks techs by proximity + occupancy
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>
2026-07-02 15:05:05 -04:00
louispaulb
f5a7547c1f feat(dispatch): urgency → early-in-day + AM/PM windows per job
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>
2026-07-02 14:42:36 -04:00
louispaulb
3513f7e15f feat(dispatch): solver settings (distance↔specialty, speed, time) + urgent priority
- 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>
2026-07-02 14:03:11 -04:00
louispaulb
bc157588d1 tune(dispatch): VRP prioritizes distance over specialty (rank_weight 6→2)
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>
2026-07-02 13:53:46 -04:00
louispaulb
5803fb3d0e feat(dispatch): optimizer assumes 8h shift for every selected tech
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>
2026-07-02 13:34:37 -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
4591ef6169 feat(dispatch): skill PRIORITY ordering per tech (specialists first)
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>
2026-07-02 10:50:51 -04:00
louispaulb
f6e5128859 feat(dispatch): skill-driven auto-dispatch — route grouping, placeholder queues, shift check
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>
2026-07-02 10:37:40 -04:00
louispaulb
72e84d6b8d refactor(dispatch): la JOB impose sa compétence — retrait du niveau global par compétence
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>
2026-07-02 09:38:54 -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
453bef006c fix(ops): sécurité + robustesse dispatch/comms
- 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>
2026-07-02 08:56:35 -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
54f0d271dd feat(inbox): global thread scroll + close (X) on another agent's draft mirror
- 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>
2026-06-15 08:09:07 -04:00
louispaulb
f519bb3dd6 feat(inbox): email height fits content + per-message reply button
- 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>
2026-06-15 07:29:34 -04:00
louispaulb
08069ec0dc feat(inbox): show the agent's real name as sender + cleaner Gmail-style thread
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>
2026-06-15 07:15:23 -04:00
louispaulb
e604cbf237 fix(inbox/mobile): reply composer no longer hides the "Envoyer" button
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>
2026-06-15 06:24:30 -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
19d31025a6 feat(inbox+historique): sender-identity fix, single-source taxonomy, dispatch history & leaderboards
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>
2026-06-15 06:11:47 -04:00
louispaulb
aa108ab13f feat(field-tech): app Capacitor native (geofence Transistorsoft + scan MLKit) + CI
App technicien : appairage 1x (QR), géorepérage natif en arrière-plan (app fermée)
-> checkpoints au hub /field/ts, scan série/MAC on-device MLKit. UI réutilisée depuis
le hub (/field). APK Android buildé (debug, 19 Mo, arm64-v8a + armeabi-v7a).

- apps/field-tech : Capacitor 6 + Vite ; src/main.js (appairage @capacitor/preferences
  + BackgroundGeolocation.addGeofences + redirection /field) ; projet android/ avec les
  4 correctifs Gradle commités (force work-runtime 2.9.1, minSdk 24, repos maven xms.g
  + Huawei, googlePlayServicesLocationVersion 21.0.1) ; abiFilters arm (APK 32->19 Mo).
- README : recette de build complète (toolchain M4, les 4 fixes, install/appairage) ;
  BUILD-ANDROID.md (Docker + Android Studio + release signée) ; CI-SETUP.md (runner Gitea).
- CI Gitea Actions (.gitea/workflows) : android (runner Linux HORS prod, cache Gradle/npm
  -> artefact APK) + ios (runner macOS, workflow_dispatch, en attente compte Apple).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:47:27 -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
ebad7066d6 feat(dispatch/assign): vue date DUE, confirm Quasar, carte→liste
- 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>
2026-06-07 22:46:33 -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
4a3dc01938 Planification grille : retrait de l'axe de temps + la bande n'intercepte plus le clic/drag de la cellule
- 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>
2026-06-07 18:02:39 -04:00
louispaulb
29054c49c2 Planification éditeur journée : déplacements en pointillés + clic pin = détails + clic ligne = recentre carte
- 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>
2026-06-07 17:50:52 -04:00
louispaulb
34b10e0a1e Planification : carte de journée INTERACTIVE (Mapbox GL) — itinéraire routier réel + zoom/déplacement
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>
2026-06-07 17:44:22 -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
5d4f66108d Planification : minimap du territoire (pins+tracé) dans l'éditeur de journée + retrait du sélecteur priorité
- 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>
2026-06-07 16:54:53 -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
e14026ae23 Pont legacy : coords GPS fiables (delivery→SL→RQA→Mapbox) + routage routier réel (Mapbox Matrix)
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>
2026-06-06 14:43:34 -04:00
louispaulb
22fbb81d3e feat(planif): éditeur journée = planificateur de tournée (heures recalculées, pas d'overlap, RDV verrouillables, détails)
- FIX overlap/ordre : packedDay recalcule les heures depuis la SÉQUENCE (ordre liste) + durées + transport ;
  le timeline et les heures affichées en découlent → réordonner/allonger repousse les suivants, plus d'overlap
- RDV à heure FIXE : verrou par job (🔒, défaut = booking_status 'Confirmé') → garde son heure ; flexibles = enchaînés
- clic sur un job → détails (legacy_detail : dates + description) pour juger urgence/durée ; + tooltip au survol
- save persiste start_time recalculé (+ route_order/priority/duration_h) via reorder-jobs
- hub occupancy renvoie booking_status/legacy_detail/legacy_id ; reorder-jobs accepte start_time
- fix collision fmtH → fmtHM (HH:MM padded)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:56:26 -04:00
louispaulb
faea3b0c1a feat(planif): éditeur journée — réordonnancement fiable (flèches+drop) + durée éditable (min) + temps de transport
- 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>
2026-06-06 13:41:28 -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
482cc948b3 refactor(planif): clic progressbar → timeline éditable Dispatch (tech+jour) au lieu du popup maison
- 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>
2026-06-06 13:19:59 -04:00
louispaulb
5eb1e587da feat(planif): tri du panneau flottant « Jobs à assigner » (groupe/compétence/date/ville/priorité)
- 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>
2026-06-06 12:48:44 -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
b492e30971 feat(dispatch): réconciliation + heartbeat + détails ticket dans Ops + couleurs panneau roster
#1 « ne rien échapper » :
- /dispatch/legacy-sync/reconcile : compare legacy(3301,open) ↔ Dispatch Jobs → missing/orphan (70↔70, 0/0)
- /dispatch/legacy-sync/status : heartbeat (last_sync + stale) pour Uptime-Kuma

Détails ticket dans Ops (bug signalé) :
- pont extrait le 1er message du fil legacy (stripHtml) → champ legacy_detail (backfill 70)
- store legacyDetail ; RightPanel : section « Détails du ticket » ; tooltip dans le panneau roster

Couleurs panneau roster (bug signalé) :
- /roster/unassigned-jobs renvoie job_type/legacy_dept/priority/legacy_* ; PlanificationPage colore
  chaque carte par type (legacyDeptColor partagé) — bordure gauche

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:50:21 -04:00
louispaulb
7f22647ce4 feat(dispatch): bouton « Activer STB (Ministra) » — lien d'activation TV extrait du ticket legacy (Phase 1)
- 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>
2026-06-06 11:20:09 -04:00
louispaulb
dab1281953 feat(dispatch): bouton « Répondre dans legacy » (lien reply_ticket.php du tech)
- 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>
2026-06-06 10:30:32 -04:00
louispaulb
5e32f5933a feat(dispatch): pastille couleur par type + badge « en retard » dans le pool & le détail
- 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>
2026-06-06 10:19:03 -04:00
louispaulb
3c17805770 feat(dispatch): coloriage cartes par type legacy + n° ticket dans le détail
- 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>
2026-06-06 10:05:45 -04:00
louispaulb
90f7237312 roster(planif): chip compteur « N hors quart » dans la barre (signal hebdo des jobs assignés sans quart publié)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:15:16 -04:00
louispaulb
f7343f6e0e roster(planif/dispatch): On-Hold bloqué, alerte hors-quart, deep-link Dispatch, aviser client (#58)
- 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>
2026-06-06 09:13:17 -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
d128adf152 Garde: 2 horaires par règle — semaine (soir 17h-minuit) vs fin de semaine (8h-minuit)
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>
2026-06-04 22:33:07 -04:00
louispaulb
42eac0101b Garde: fix « 0 assignations » — rétrocompat des règles sans steps/anchor
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>
2026-06-04 22:29:57 -04:00
louispaulb
9e47e28e2b Garde: moteur fiable — séquence d'étapes {tech, weeks} + semaine d'ancrage (parcours déterministe)
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>
2026-06-04 22:15:50 -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
8bd3047d96 Garde: presets Soirs de semaine/Fin de semaine (combinables) + rotation par semaine (défaut) + aperçu
- 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>
2026-06-04 21:47:04 -04:00
louispaulb
ca82cfed9f Garde: la rotation avance par jour de garde (la séquence continue sur les jours affichés)
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>
2026-06-04 21:17:25 -04:00
louispaulb
b00217e53e Garde: séquence de rotation éditable — doublons permis + remplacer un tech par position
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>
2026-06-04 21:01:42 -04:00
louispaulb
ae60e60f9a Garde: dept libre + liste techs complète + réordonner la rotation + éditer + 2 sem. consécutives
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>
2026-06-04 20:53:26 -04:00
louispaulb
fedaf53a3b Planification: drapeau « longue durée » sur absence (maternité/invalidité = à remplacer)
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>
2026-06-04 20:31:39 -04:00
louispaulb
d6c4e05488 Planification: rotation de garde par département (récurrence + rotation)
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>
2026-06-04 20:27:24 -04:00
louispaulb
15276c89ff Planification: modèle par défaut (★) appliqué en 1 clic
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>
2026-06-04 20:23:40 -04:00
louispaulb
1459ae6506 Planification: application de modèle consciente des absences (permanent vs vacances)
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>
2026-06-04 20:16:05 -04:00
louispaulb
5e15ed5b8b Planification: menu de case court (2 raccourcis + slider en haut) — Appliquer toujours atteignable
- 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>
2026-06-04 19:55:32 -04:00
louispaulb
ac957b251b Planification: Suppr/Backspace pour vider les cases sélectionnées (ou la case active)
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>
2026-06-04 19:46:04 -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
2260a302b3 Planification: contour 1px foncé autour de la bande de disponibilité (vs fond du timeline)
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>
2026-06-04 19:36:07 -04:00
louispaulb
5376cbb14e Planification: fix slider d'ajustement (menu) — sélection de texte + resize continu
- 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>
2026-06-04 19:29:57 -04:00
louispaulb
23a2e527cd Planification: barre de temps pâle + barre de statut opaque vert→orange (occupation)
- Barre de TEMPS (dispo) = pâle (bleu très pâle matin → violet pâle soir) : repère discret du quand.
- Barre de STATUT (occupé) = OPAQUE vert (peu) → orange (plein) → rouge (surbooké), positionnée
  aux vraies heures des jobs → ressort nettement sur le fond pâle ; les trous pâles = offrables.
- Légende: dispo (matin→soir) pâle + occupation (vert→orange) + garde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:22:05 -04:00
louispaulb
0b8ebf3b8f Planification: cellule = barre de dispo seule sur une échelle de temps (règle horaire en-tête)
- 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>
2026-06-04 19:18:38 -04:00
louispaulb
1aefd0fdec Planification: barre de dispo dégradée par l'heure (bleu matin → violet soir), fini les lettres
- 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>
2026-06-04 19:12:27 -04:00
louispaulb
b05ee54f92 Planification: ne montrer que les presets nommés + coller multi-cases fiable + fix Cmd+C/V
- 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>
2026-06-04 19:04:37 -04:00
louispaulb
ed8ef3bbc6 Planification: fix menu (régression cellHours) + copier/coller + slider d'ajustement dans le menu
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>
2026-06-04 17:24:07 -04:00
louispaulb
0cf14ec978 Planification: copier-coller de cases + créneaux custom (slider q-range) + auto-nommage
- 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>
2026-06-04 16:02:15 -04:00
louispaulb
ea4d91400d Planification: cellule sans icônes — juste intervalle + timeline
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>
2026-06-04 15:55:02 -04:00
louispaulb
054deb7b97 Roster: la garde ne compte PAS comme heures travaillées (mise en dispo)
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>
2026-06-04 15:52:01 -04:00
louispaulb
63c19b8b92 Planification: axe timeline adaptatif + intervalle texte + modèles triés par usage
#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>
2026-06-04 15:48:47 -04:00
louispaulb
ebb440b5d8 Roster: quart de Garde (on_call) = réserve d'urgence, jamais offert au booking
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>
2026-06-04 15:37:34 -04:00
louispaulb
f0d6e9fffe Planification: micro-timeline 24h, multi-shift, neutre/coloré, label h utilisées + icône période
Refonte selon retour: axe 24h (00→24). Chaque shift = bande NEUTRE positionnée (multi-shift OK:
Jour + Garde affichés en 2 bandes, gère le passage minuit). Jobs pris = traits COLORÉS (charge:
vert/orange/rouge). Label compact = icône période (☀/🌆/🌙) + heures utilisées/total (ex 4/8).
Intervalles exacts par shift dans l'infobulle. Tick à midi (50%).

Démo 8 juin: TECH-4738 = Jour 8-16 + Garde 18h-minuit (multi-shift), Matinal 7-15 / Décalé 9-17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:27:32 -04:00
louispaulb
2f868133d5 Planification: mini-timeline positionnée (fenêtre réelle du shift + blocs pris)
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>
2026-06-04 15:21:04 -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
535c2f13bd Ops cohérence: composants partagés TechSelect (autosuggest) + SkillSelect (chips)
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>
2026-06-04 11:57:58 -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
fe4825d93b Wizard: section Installation (palier financé) à l'étape Items
- 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>
2026-06-03 19:20:04 -04:00
louispaulb
c176c51fbb Wizard: 2 cases génériques Prix marketing / Prix original (barré) sur toutes les lignes + aperçu client WYSIWYG
- 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>
2026-06-03 18:31:54 -04:00
louispaulb
f2d1c00462 Site: texte Actualités révisé + template courriel gift natif (FR)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:42:52 -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
8b1cd7b6eb feat(ops): Ville column in overpriced-internet report (sort + filter by city)
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>
2026-06-01 22:44:34 -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
09d689e962 fix(website): rewrite garbled "Notre histoire — 2005" section
The origin story had a duplicated/merged first sentence, an inconsistent
name (Ghislain / Ghislain Guinois / "Ghilas" typo), and a present-tense
verb ("travaille") inside an otherwise past-tense narrative. Rewrote the
three paragraphs into clean, consistent French (passé composé), fixed the
name to "Ghislain Guinois", "18h" → "18 h".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:15:33 -04:00
louispaulb
3811662d66 feat(ops): assisted Cogeco spot-check on overpriced-internet report
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>
2026-06-01 21:24:36 -04:00
louispaulb
ec6182f4d5 feat(reports/legacy): data-freshness banner + recently-expired-discount column
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>
2026-06-01 19:31:41 -04:00
louispaulb
383967175a fix(reports/legacy): active clients only — exclude terminated + non-customer accounts
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>
2026-06-01 19:21:24 -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
6e103ff05b feat(campaigns/reminder): cascade clicks to parent + family banner
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>
2026-06-01 15:24:52 -04:00
louispaulb
8064606564 fix(campaigns/expiry-picker): show + save dates in America/Montreal TZ
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>
2026-06-01 15:06:57 -04:00
louispaulb
e63f55d864 feat(campaigns/expiry): date picker for explicit cutoff
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>
2026-06-01 14:45:14 -04:00
louispaulb
0248a195f8 fix(campaigns/reminder): softer tone + render expiry in tests
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>
2026-06-01 11:55:20 -04:00
louispaulb
e18523d377 feat(campaigns/detail): edit draft params + jump to template editor
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>
2026-06-01 11:51:45 -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
7e0f4d0799 feat(campaigns/wizard): inspectable dropped-row list with one-click recovery
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>
2026-05-22 11:55:48 -04:00
louispaulb
f7562b3f25 fix(campaigns/wizard): always show parse summary, even on 0 drops
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>
2026-05-22 11:49:30 -04:00
louispaulb
04a2db35c5 fix(campaigns/parse): keep no-name rows + surface skip breakdown
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>
2026-05-22 11:43:22 -04:00
louispaulb
27079bba11 feat(campaigns): one-click Giftbit admin lookup per recipient
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/4kpZMApLK4Bhttps://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>
2026-05-22 10:58:33 -04:00
louispaulb
dcc5679fa3 feat(campaigns/templates): visible wrapper-expiry date in the email
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>
2026-05-22 10:47:58 -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
6fe6e4df0d feat(campaigns): gift redirect wrapper — own expiry + reusable links
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>
2026-05-22 10:15:43 -04:00
louispaulb
8547082cf5 fix(campaigns/wizard): template dropdowns now show non-suffixed templates + refresh
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>
2026-05-22 09:51:29 -04:00
louispaulb
6c3380d984 feat(campaigns/wizard): per-language template override
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>
2026-05-22 09:46:58 -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
02b1b55b7b feat(campaigns): distinguish gift-CTA click from generic email click
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>
2026-05-22 09:22:12 -04:00
louispaulb
eafff12856 feat(campaigns/editor): "Variables" button — visible merge-tag reference
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>
2026-05-22 09:14:56 -04:00
louispaulb
c3a062956f fix(campaigns/list): "Envois" column counted only status=sent
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>
2026-05-22 09:11:15 -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
c695f05822 fix(ops/campaigns): v-pre on translation dialog hint to avoid Vue parser crash on {{ '{{...}}' }} 2026-05-22 07:23:35 -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
9ad5b46b4c feat(campaigns): create new templates from UI + enable Unlayer template library
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>
2026-05-22 06:39:26 -04:00
louispaulb
cda44c51cf fix(ops/campaigns): drop loadBlank() call + force explicit editor dimensions
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>
2026-05-22 06:18:27 -04:00
louispaulb
3cc0377e3d feat(ops/campaigns): group merge tags by category + add toolbar hint
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>
2026-05-22 06:16:16 -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
110cea3995 feat(ops/campaigns): Phase 2 — switch editor page to easy-email iframe
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>
2026-05-22 06:01:28 -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
e6b48d8791 feat(campaigns): auto-clean first/last names (QC accents + compound split)
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>
2026-05-21 21:33:10 -04:00
louispaulb
ea780e61ab fix(ops/campaigns): clarify Step 2 actions + add inline preview + jump-to-editor
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>
2026-05-21 21:01:40 -04:00
louispaulb
3477b8f3bb feat(campaigns): apply real TARGO brand + auto-route FR/EN by Customer.language
Brand audit against the official guide (Feb 2026 v1.0) caught several
inconsistencies in the email template:

- Wrong primary green: was #019547, should be #00C853 (Targo Green from
  brand palette). Globally replaced.
- Wrong gradient: was #019547→#06a04d, should be 135deg #00C853→#005026
  (the official Gradient Targo from the brand). Now using Outlook-safe
  background-image + bgcolor fallback for solid green on Outlook desktop.
- Wrong contact info: facturation@targointernet.com / 514 242-1500 →
  support@targo.ca / 514 448-0773 / 1 855 888-2746 (per §11 of guide).
- Wrong website: targointernet.com + gigafibre.ca → www.targo.ca.
- Missing slogan + green dot: footer now ends with the trademark
  tagline "Services de confiance, tout-en-un, près de chez vous." with
  the obligatory green period (always FR — it's the trademark, not a
  marketing line, so stays untranslated in EN template too).
- Missing brand fonts: added Space Grotesk (display) + Plus Jakarta
  Sans (body) via Google Fonts. Wrapped in MSO conditional comments so
  Outlook desktop skips the request and falls back to Helvetica via
  the explicit font-family stack on every element.
- Wrong body bg / text colors: now #F5FAF7 (Muted) / #1B2E24
  (Foreground) per brand semantic palette.
- Wrong info-pill bg: was #f3f4f3 → #F5FAF7 (Muted).
- Added official dark footer band #1C1E26 (Targo Dark) with white
  inverted wordmark, slogan, address, copyright.

Multilang routing (FR/EN):

- lib/campaigns.js matchCustomer now fetches Customer.language
  (14k FR / 1k EN distribution confirmed on prod). Default 'fr' for
  unmatched contacts.
- New templateForLanguage(lang) helper picks gift-email-<lang>.html,
  falls back to FR. Resolves 'fr-CA' → 'fr' etc.
- sendCampaignAsync pre-loads templates per recipient with an in-memory
  cache to avoid re-reading from disk on every send.
- gift-email-en.html created — English translation of the full FR
  template, keeping the slogan in French (it's the trademark tagline).
- year variable now injected (replaces hardcoded © year).

UI (CampaignNewPage):

- New "Langue" column in the Step 2 recipient table. Shows a clickable
  chip (FR primary green / EN blue-grey) that toggles language inline,
  so a campaign manager can override the ERPNext-resolved language
  per recipient.
- Step 3 recap now shows "Répartition par langue: 145 × FR, 12 × EN"
  before confirming the send.

Spell-check:

- TemplateEditorPage HTML mode now has spellcheck="true" + dynamic
  lang attribute on the textarea, picked from the template name suffix
  (gift-email-fr → fr, gift-email-en → en). Browser's native dictionary
  flags typos in real time. AI-grade rewrites deferred to the future
  /campaigns/ai/rewrite endpoint discussed previously.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:50:56 -04:00
louispaulb
672188bab9 feat(ops/campaigns): explicit contact↔shortlink pairing review before approve
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>
2026-05-21 20:31:44 -04:00
louispaulb
44ce8a616a fix(ops/campaigns): correct row counts in Step 1 — Link Order CSV had no header
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>
2026-05-21 20:27:22 -04:00
louispaulb
0a5c67be13 feat(campaigns): support Giftbit Link Order CSV + add blank-canvas editor mode
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>
2026-05-21 20:14:29 -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
f507367ac4 fix(ops/client): cancelled subs no longer inflate monthly total + Lieu link in-app
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.
2026-05-08 11:21:18 -04:00
louispaulb
4af25071be fix(ops/dispatch): /desk/<DocType>/ broken URL → /app/<slug>/ + add /address/validate hub
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.)
2026-05-08 11:01:32 -04:00
louispaulb
478d24859e fix(ops/dispatch): surface customer + service-location links from a job + fix bad coords
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.
2026-05-08 10:29:59 -04:00
louispaulb
bb77c04943 feat(hub+ops): user invite flow sends temp password via Mailjet + dev .env.example
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.
2026-05-05 19:50:06 -04:00
louispaulb
969fe42f3f feat(ops/auth): invite-user UI in Settings — creates Authentik + ERPNext + recovery email
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.
2026-05-05 15:29:18 -04:00
louispaulb
7a607817c0 refactor(ops/dispatch): single-color Lucide icons + tech-first resource filter
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.
2026-05-05 14:31:00 -04:00
louispaulb
9844e5ae54 fix(ops/dispatch): top bar polish — visible ⋯ menu, collapsed AI, fly-to tech, views dropdown
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).
2026-05-05 14:17:33 -04:00
louispaulb
2d38c79011 refactor(ops/dispatch): consolidate top toolbar with overflow ⋯ menu
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.
2026-05-05 14:07:04 -04:00
louispaulb
510d619b5e feat(ops/dispatch): right-click tech pin + click-on-map home picker + center map on HQ
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.
2026-05-05 14:02:26 -04:00
louispaulb
ac52406e30 feat(ops/dispatch): editable tech home base + new default at Gigafibre HQ
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.
2026-05-05 13:53:14 -04:00
louispaulb
5a2de35405 fix(ops/dispatch): tech pin drifts away from lat/lng on map zoom
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.
2026-05-05 13:40:29 -04:00
louispaulb
7aca04b7fa feat(ops): Service Contract detail view + sub-delete redirects there
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.
2026-04-23 14:46:34 -04:00
louispaulb
c6a53c2590 feat(ops/client): contract-aware sub delete with termination preview
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.
2026-04-23 13:47:53 -04:00
louispaulb
db14ccf5cd feat(ops/client): edit/delete/reorder subscriptions + rebate nesting
- 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.
2026-04-23 11:21:41 -04:00
louispaulb
ecc8827c59 fix(ops/client): consolidate on Service Subscription + catalog browse
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>
2026-04-23 11:07:54 -04:00