Commit Graph

234 Commits

Author SHA1 Message Date
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