Commit Graph

484 Commits

Author SHA1 Message Date
louispaulb
4ae2907172 solveur : le RANG (compétence principale) domine la polyvalence — répare la mauvaise répartition
Bug (Louis) : Stephane (réparation rang 1, installation secondaire) recevait 3 installations, William
(installation rang 1/principale) une seule. Cause : versatility_weight=6 > rank_weight=2 → un tech moins
polyvalent mais NON spécialiste rafflait les jobs hors de sa spécialité. Fix : rank_weight=20 (domine),
versatility_weight=3 (léger départage entre techs de même rang → réserve les experts). Testé : William obtient
les installations. eff/AM-PM/distance inchangés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:10:37 -04:00
louispaulb
8ee4c834f8 planif: retirer les jobs faits/annulés datés dans le FUTUR (artefacts legacy) de l'occupation
occupancyByTechDay incluait Completed/Cancelled (trace du tech). Un ticket F fermé mais à due_date
FUTURE → le pont crée un Dispatch Job 'Completed' daté au futur (ex. LEG-250851, Completed, 2026-07-08)
→ il apparaissait comme travail à venir (surtout visible sur mobile, où le desktop l'estompe).
Fix : on écarte les jobs faits/annulés dont scheduled_date > aujourd'hui (Est) ; la trace passée/du jour reste.
Vérifié : 250851 absent de l'occupation après fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:04:15 -04:00
louispaulb
8b0241673d solveur VRP : efficacité par compétence + réserve des experts (polyvalence pénalisée)
route_solver.py :
- dimension Temps PAR VÉHICULE (AddDimensionWithVehicleTransits) : service × efficacité(tech,skill)
  → un tech rapide (facteur bas) tient plus de jobs.
- coût d'arc : + efficacité (rapide récompensé) + pénalité de POLYVALENCE (nb de compétences) → les cas
  simples vont d'abord aux techs les moins polyvalents, réservant les experts. eff_bias=8, versatility_weight=6.
roster.js : passe skill_eff + efficiency par véhicule au solveur.
Testé en conteneur prod : tech rapide+mono-compétence rafle les jobs, polyvalent réservé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:51:18 -04:00
louispaulb
88a5e84f90 planif Suggérer : UNE seule méthode (VRP) + retrait des réglages de coûts
Retire le sélecteur de stratégies (5 modes) et les champs Coûts (Spécialité/Heures sup/km-h/Calcul s)
mal compris. Méthode unique = solveur VRP, forcée. Description claire des critères pris en compte.
(Backend : brancher l'efficacité par skill + réserve-experts dans le solveur = étape suivante.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:47:17 -04:00
louispaulb
61a2f1000f planif: efficacité GLOBALE en % intuitif (haut = plus rapide), cohérent avec le per-skill
Le champ « Cadence » global était un FACTEUR brut (step 0.05, bas=rapide) → taper 1000 = facteur 1000
= catastrophiquement lent. Passé en % via effPctOf/factorFromPct (100/x) : 1000 % = facteur 0.1 = 10× plus
rapide, 90 % = plus lent. Renommé « Efficacité % » + tooltip. Le per-skill était déjà en % (inchangé).
NB : l'efficacité n'influe que sur les stratégies heuristiques (Intelligent/Meilleurs/Équilibré/Juste-ce-qu'il-faut),
PAS sur « Optimiser » (VRP OR-Tools) qui optimise routes+compétences+capacité sans facteur d'efficacité.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:19:29 -04:00
louispaulb
3a0b65942c planif Suggérer : ordre AM→PM des arrêts + picker « déplacer vers » filtré
- nnOrder respecte la fenêtre horaire : bloc AM (8-12) → sans-fenêtre → PM (12-16), plus-proche-voisin
  DANS chaque bloc. Avant : NN pur → une job PM pouvait finir 1re, une « premier en AM » 2e (bug signalé).
  Utilise le toggle AM/PM explicite OU detectAmpm (texte : « **premier en AM** », « PM Fin de journée »).
- Picker « déplacer vers un autre tech » : n'affiche que les techs SÉLECTIONNÉS + compétents par défaut,
  + « Afficher tous les techniciens… » pour lever le filtre (repli sur tous si aucun sélectionné-compétent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:14:07 -04:00
louispaulb
746a8de6df planif: fix figement carte des tournées suggérées (boucle de rendu) + tracés routiers réels
Cause : :routes="suggestRoutes.map(...)" inline créait un NOUVEAU tableau à chaque rendu → watch deep
de RouteMap → draw → emit('metrics') → onRouteMetrics mutait realRoutes → re-rendu → nouveau tableau →
BOUCLE INFINIE (onglet figé ; le remplacement asynchrone OSRM ne finissait jamais → lignes à vol d'oiseau).
Fix : computed CACHÉ suggestMapRoutes (réf stable) + onRouteMetrics idempotent. La géométrie routière OSRM
(vérifiée côté serveur : 200, ~405 pts) s'affiche maintenant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:57:39 -04:00
louispaulb
9142d6dd26 tickets: clic sur le nom du client (panneau) → route vers sa fiche /clients/:id
Le @navigate du panneau ticket routait tout vers loadModalTicket ; désormais dt==='Customer' → fiche.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 09:58:48 -04:00
louispaulb
8586b9310a planif: Suggérer ne fige plus — timeout solveur + dialogue toujours fermable
- roster API : AbortController (timeout 45s, 90s pour /optimize-plan) → un solveur/hub lent ou
  injoignable lève au lieu de laisser l'await pendre à l'infini (suggestDlg.building coincé à true).
  Au-delà → repli heuristique local, dialogue utilisable.
- q-dialog Suggérer : @hide remet building=false (jamais coincé-ouvert au ré-open).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 09:56:12 -04:00
louispaulb
77dd426343 tickets (table): colonne « Département » (Type renommé) + pastille technicien assigné
Ajoute assigned_staff à la requête + colonne Assigné (avatar initiales, couleur par tech) ;
issue_type renommé Type→Département. Distingue les tickets quasi identiques (ex. 3 Cancellation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 09:39:42 -04:00
louispaulb
27263d7766 tickets: distinguer par département + technicien assigné ; réponse courriel = Received
- TicketCard : badge Département (issue_type) + technicien (assigned_staff) → distingue les
  tickets quasi identiques (ex. 3 « Cancellation » = Support/Facturation/Rétention).
- migrate_tickets.py : importe assigned_staff (ticket.assign_to → nom du staff) — le rattrapage
  ne le posait pas. Backfill des ~7,5k tickets assignés en F mais sans technicien côté ERPNext.
- logToLinkedTicket : direction Received si l'expéditeur = adresse destinataire du ticket
  (une réponse depuis un domaine interne @targo n'est plus mal classée « Sent »).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 08:50:01 -04:00
louispaulb
417ef08f58 ticket: aller-retour courriel (envoi + réponse revient au fil)
Hub (conversation.js) : getOrCreateConvForIssue (lie une Issue à une conv email) + emailTicket
(envoie dans un fil Gmail, sujet [Ticket #ISS-…] + lien direct, journalise Sent Communication).
Route POST /conversations/ticket-email (gaté Authentik). La réponse du client re-thread par le tag
sujet (TICKET_REF_RX) OU le threadId → logToLinkedTicket (déjà en place) → Communication Received
sur l'Issue → visible au panneau. notifyCustomer mémorise désormais conv.threadId au 1er envoi.

UI (IssueDetail) : bouton « Envoyer par courriel » + dialogue (À/Cc/message), « Répondre » scindé
en « Note interne » (Communication) vs courriel. Lien direct = URL du ticket.

Testé : envoi réel à louis@targo.ca sur ISS-0000250013 (channel:email, Sent Communication OK).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 07:42:39 -04:00
louispaulb
cb16167f72 ticket panel: décodage entités HTML + fil unifié + lien client cherchable
- richHtml() (sanitize.js) : décode entités osTicket (&eacute;, &#039;, double-échappées &amp;#039;)
  puis DOMPurify → plus de « codes html » dans les détails.
- IssueDetail : fusionne Échanges (Communication) + Fil (Comment) en UN fil chronologique
  en bulles (notes internes en ambre) ; aère la mise en page.
- Bandeau Client cherchable par nom, association/dissociation 1 clic (comme une conversation)
  — le ticket portait déjà .customer mais aucun champ pour le voir/lier. Résout le cas voicemail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 07:09:25 -04:00
louispaulb
bd7a077f46 portal(vue): fix stray space in prices ($39 .95 -> $39.95)
JSX collapses the newline between {price} and <sup>.cents</sup>; a Vue template
renders it as a visible space. Made the dollar amount and the <sup> cents
adjacent in Services/Telephone/Television price cards. Verified: $39.95/$25.00/$10.00.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 06:48:18 -04:00
louispaulb
8a51925da8 devices: import du manquant + fix liens SS.device orphelins + catch-up quotidien
- import_devices_and_enrich.py : creds via env (secret hors repo) + SKIP_ENRICH (devices seuls).
  Rattrapage : 1160 devices importés (tous les devices F courants sont maintenant en Service Equipment).
- FIX bug d'import : rename_all_doctypes.py avait renommé Service Equipment (EQ-{md5}→EQP-{seq})
  sans cascader vers Service Subscription.device → 6980 liens orphelins ("Could not find Device: EQ-…",
  bloquait les saves pleine-doc). 5241 remappés via legacy_device_id+md5 (UPDATE PG direct).
  Résiduel 1739 = device supprimé côté F (1584 Actif / 155 Annulé) → décision NULL vs relink en attente.
- Récurrence : tickets-daily.sh importe désormais AUSSI les devices (SKIP_ENRICH=1), quotidien 04:30.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:09:16 -04:00
louispaulb
d8052bb17c portal(vue): footer uses RouterLink (was raw <a href>, dropped /next base)
Footer internal links were absolute anchors copied verbatim from React (correct
at root, but under /next they jumped out to the React site). Switched the 7
route links to RouterLink so the router base is applied; hash (#apropos/#contact)
and external (store.targo.ca) links left as-is. Verified: footer now links
/next/internet, /next/support, /next/accessibilite, etc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:10:08 -04:00
louispaulb
fbb7de935f portal(vue): document required Supabase env via .env.example
Real .env stays untracked (matches apps/website). Fixes admin white-screen
(supabaseUrl is required) by making the build-time requirement explicit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:01:15 -04:00
louispaulb
0b9933c485 targo-sync: retirer le mdp F du repo + cron quotidien tickets
⚠️ SÉCURITÉ : le défaut en dur du mdp F ("facturation") avait fuité dans sync_services /
sync_invoices (commit 004c3f8). Défaut passé à "" (secret via env uniquement). Le mdp est
DÉSORMAIS chargé depuis /opt/targo-sync/secrets.env (hôte, chmod 600, gitignored).
NB : le mdp reste dans l'HISTORIQUE git (004c3f8) → rotation recommandée (décision Louis).

Cron quotidien tickets (récurrence #26) :
- tickets-daily.sh (30 4 * * *) → migrate_tickets.py (idempotent), n'importe que les nouveaux.
- Séparé de run.sh (horaire) : scan 252k trop lourd pour l'horaire.
- .gitignore : secrets.env / *.env / *.log / .lock jamais versionnés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:55:35 -04:00
louispaulb
0c5f33460a scripts(targo-sync): incremental invoice/service sync + daily tickets
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:52:14 -04:00
louispaulb
e950bd3ac6 hub: reverse geocoding infra-first (fibre field-pins -> RQA fallback); address resolution + geotagged field photos
- /address/reverse: reverseNearestFibre() (fibre field-pins, source of truth) then RQA
- searchCustomers: civic address resolves to the resident (not fuzzy homonyms)
- field-app + /field/photo: geotagged photos confirm the correct job

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:52:14 -04:00
louispaulb
042c5545f7 ops: MapLibre/OSM basemap (drop Mapbox) + satellite toggle, unified Créer flow, wizard pricing, infra-first reverse client
- Maps: MapLibre GL + self-hosted Protomaps tiles + ESRI satellite toggle
- FAB 'Créer' chooser (incl. Soumission wizard) via useCreateSignal
- Quote wizard: new default tiers (80/500/1500 @ 39.95/49.95/49.95), competitor
  price-match, mini-map pin w/ green check, carry qualified address
- api/address.reverse() + reverseGeocodeOSM tries RQA-backed hub, Nominatim fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:52:14 -04:00
louispaulb
3c7f04870c portal: Vue 3 + Tailwind + shadcn-vue rewrite of www.gigafibre.ca (preview at /next)
Pixel-identical port of the React marketing site: Tailwind config, HSL theme
tokens, fonts (Plus Jakarta Sans / Space Grotesk) and custom utilities copied
verbatim; shadcn-vue (reka-ui) components; all 33 routes ported. Served at
www.gigafibre.ca/next (noindex) behind nginx, ahead of replacing the React site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:52:14 -04:00
louispaulb
224db4527e migrate_tickets: env creds (secret hors repo) + mode PREVIEW + host défaut 10.100.80.100
Rattrapage 2026-07-06 : 9632 Issues créés (9657 manquants, 25 err données), tickets
Δ 9643→11. Reste : rendre récurrent (cron quotidien ou watermark) pour ne pas rouvrir le trou.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:08:53 -04:00
louispaulb
5783ec30b9 sync-legacy: scoreboard abos = F brut status=1 (écart positif = fantômes retirés d'ERPNext) + note clarifiée
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:23:21 -04:00
louispaulb
29589597b1 sync-legacy: raffiner l'annulation des fantômes (pas de cascade aveugle)
La 1re version cascadait compte résilié ⇒ TOUS services Annulé, ce qui contredit
la politique 2026-06-13 (statut SERVICE = vérité ; employés gardent internet gratuit
sur comptes terminés) et annulait ~842 services légitimes (dont 185k$/mo facturés
encore par F ou gratuits).

Raffinement (sync_services_incremental.py, marqueur RAFFINÉ 2026-07-06) :
- annul_ghost(compte, prix) : n'annule QUE le fantôme AVÉRÉ = compte résilié +
  service PAYANT (prix>0) + F silencieux depuis >24 mois (MAX(invoice.date_orig)).
  Préserve les gratuits (employés), tout ce que F facture encore, et le jamais-facturé
  (ambigu → revue manuelle via /rapports/resilies-actifs).
- Phases B (création) + D (rafraîchissement) passent par annul_ghost.
- Résultat mesuré : 3013 fantômes avérés annulés, 842 réactivés/préservés,
  fantômes avérés restants = 0.

Scoreboard : métrique renommée « actifs sur compte résilié — à revoir » (hub note +
libellé UI) puisque le résidu n'est plus un bug mais une liste de revue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:20:24 -04:00
louispaulb
004c3f8dee sync-legacy: scoreboard de réconciliation + fix des abonnements fantômes à la source
Scoreboard (console de réconciliation F ↔ ERPNext) :
- hub: legacy-sync.js scoreboard() + GET /legacy-sync/scoreboard — comptes bruts
  par module (clients/lieux/abos/appareils/tickets/factures/paiements) + métrique
  ghost_active_subscriptions. Devices = tabService Equipment (pas tabDevice).
  Count F « abos actifs » exclut les comptes résiliés (comparaison pomme-à-pomme).
- ops: carte « Réconciliation par module » sur LegacySyncPage.vue.

Fix fantômes à la SOURCE (scripts/targo-sync/, jusqu'ici hors repo) :
- La vraie sync des services = scripts Python hôte (/opt/targo-sync/, cron horaire),
  PAS le hub. svc_status() ignorait le statut du COMPTE → un compte résilié F
  (status 3/4/5) dont les services restent status=1 ressortait « Actif » (F ne
  cascade pas). Résultat : ~3855 abonnements fantômes recréés à chaque heure.
- sync_services_incremental.py: svc_status() rendu conscient du compte (force
  'Annulé' si compte résilié) sur Phase B (création) + Phase D (rafraîchissement).
  Rollout: dry-run {Actif→Annulé: 3855} → APPLY=1 → ghost=0 stable.
- Versionne aussi run.sh + sync_invoices_incremental.py + README (snapshot fidèle
  de la prod ; ces scripts écrivent en base chaque heure et n'étaient pas suivis).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:03:36 -04:00
louispaulb
1b6abc43f2 fix(events): garde-fou audience — exclut les comptes F résiliés (abos fantômes)
A (garde-fou immédiat, sans écriture) : resolveAudience exclut les clients dont le compte F
est résilié (fResiliatedAccountIds, caché 30 min) — évite d'inviter d'ex-clients tant que la
donnée n'est pas nettoyée (cf feedback_ghost_active_subscriptions). L'aperçu affiche « N
ex-client(s) résilié(s) exclus ».

Vérifié prod : « clients actifs » 7943→5943 (2663 exclus) ; « abonnement actif ≥40$ »
3102→2213 (1125 ex-clients exclus). 33/33 tests. Déployé hub.

Ne corrige PAS la donnée sous-jacente (abos 'Actif' fantômes + MRR gonflé) ni la logique de
sync — voir tâche #25 (dry-run prêt : 3873 abos / 2663 clients / ~150K$ MRR fantôme).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:09:52 -04:00
louispaulb
1ab899388c feat(events): total mensuel activé seulement si « abonnement actif » + liste des comptes sans courriel
- Le champ « Total mensuel minimum » est désactivé tant que « Abonnement actif » n'est pas
  coché (le montant vient des abonnements actifs) ; il se remet à 0 si on décoche.
- L'aperçu d'audience liste maintenant les comptes SANS courriel (rattachables) avec un lien
  vers chaque fiche client (/clients/:id) → permet de valider/ajouter le courriel manquant.
  resolveAudience renvoie `no_email:[{customer_id,name}]` (plafonné 300) ; l'endpoint le transmet.

Vérifié prod : filtre abonnement actif + ≥40$/mois → 3102 destinataires · 284 sans courriel
(liste des 284 renvoyée avec noms + fiches). Champ montant désactivé par défaut, activé au toggle.
33/33 tests unitaires. Déployé hub + SPA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:38:19 -04:00
louispaulb
5d780187b0 feat(events): filtres audience précis — type (résidentiel/commercial) + total mensuel min
Ajout au sélecteur d'audience (mode masse) :
- Type de client : Résidentiel (customer_type=Individual) / Commercial (Company) / Tous.
- Total mensuel minimum ($/mois) : somme des Service Subscription ACTIFS (monthly_price)
  par client ≥ seuil. resolveAudience agrège les abonnements actifs quand active_sub OU
  min_monthly>0 ; le total est renvoyé par destinataire (monthly).

Vérifié sur données prod réelles : Résidentiel 7099, Commercial 928, total mensuel ≥50$ =
2739 clients (montants agrégés réels). Champs bien peuplés (contrairement à territoire, retiré).
32/32 tests unitaires. Déployé hub + SPA. Le blast réel reste gaté.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:25:05 -04:00
louispaulb
1f74194419 feat(events): sélecteur d'audience (liste clients filtrée OU CSV) + aperçu + choix de modèle
Dialogue « Envoyer une invitation » → bascule Test / Envoi de masse. Mode masse :
- Choix du MODÈLE de courriel (défaut « Invitation Fête 20 ans (TARGO) » + modèles de
  /campaigns/templates) — rend le module réutilisable.
- Audience par FILTRES liste clients (clients actifs / abonnement actif) OU IMPORT CSV
  (email seul obligatoire ; firstname/lastname/language/customer_id/phone optionnels, alias FR/EN).
- Bouton « Aperçu de l'audience » → décompte live + échantillon (ex. 7943 clients actifs avec
  courriel, 759 sans courriel écartés). Choix canal Mailjet (suivi)/Gmail.

Hub : resolveAudience({mode,filters,csv}) (recherche aveugle clients via erp.list + Service
Subscription pour « abonnement actif » ; dédup + courriel requis) + POST /events/:id/audience.
Filtre territoire retiré (champ ERPNext non peuplé → 0 résultat, éviter la confusion).

⚠️ L'ENVOI DE MASSE réel (création campagne + blast + suivi) reste GATÉ/non branché — bannière
« dernière étape à activer ». Test-à-soi inchangé. 29/29 tests unitaires. Déployé hub + SPA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:39:22 -04:00
louispaulb
632b466310 feat(events): palette TARGO uniquement + téléphone visible + IP visiteur + CSV complet
- Couleurs restreintes à la charte TARGO (noir/vert/blanc/gris + bleu en haut) sur le
  courriel ET la page RSVP : bandeau supérieur bleu #1a86c7, accents/CTA verts, texte
  noir/gris, fond blanc, programme en pastilles vert/bleu/gris. Retrait orange/jaune/rose/rouge.
- Admin : le téléphone saisi s'affiche sous le courriel (n'apparaissait pas avant).
- Hub : capture de l'adresse IP du visiteur à l'inscription (x-forwarded-for → socket).
- CSV enrichi = TOUTES les infos : client, n° client, ID compte, nom au dossier, téléphone,
  personnes, courriel, allergies, confiance, signaux, langue, IP, créé le, répondu le.

Déployé hub + SPA 2026-07-06. Vérifié prod : palette conforme, téléphone + IP stockés
(819 555-9999 / 96.125.192.22), test envoyé OK. 21/21 tests unitaires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 12:41:42 -04:00
louispaulb
c8cb6bfa5d feat(events): courriel d'invitation plus festif + titre « Targo fête ses 20 ans »
- Titre corrigé partout (courriel, page RSVP, admin) : « Fête Targo 20 ans » →
  « Targo fête ses 20 ans » (FR) / « Targo turns 20! » (EN), tagline étoffée.
- Courriel refait façon flyer : bandeau confetti + ballons 🎈 + pastille « 20 ANS »,
  programme en cercles colorés (vert/bleu/violet/rose/orange), encart « places limitées »,
  ligne « Merci de votre confiance au fil des années 💚 », pied targo.ca.

Déployé hub 2026-07-06 (page RSVP live = « Targo fête ses 20 ans » ; test envoyé OK).
La page RSVP et le nom en admin viennent du hub — pas de rebuild SPA requis (le repli
cosmétique dans EventsPage suivra au prochain déploiement SPA). 21/21 tests unitaires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 12:24:06 -04:00
louispaulb
50501aca5f feat(events): rattachement multi-signal + validation croisée aveugle + envoi test
Suite aux demandes : bouton d'envoi test (courriel par défaut = utilisateur), auto-
rattachement par courriel/nom/téléphone, et confirmation « êtes-vous un client ? » par
recoupement — SANS autosuggest (aucune fuite de PII).

Hub lib/events.js :
- matchAndValidate({token,number,email,phone,name}) : recherche AVEUGLE de candidats par
  courriel/téléphone/numéro (en parallèle), le nom corrobore. « confirmed » = ≥2 signaux
  concordent sur le MÊME compte ; « probable » = 1 ; « none » = aucun. Jamais bloquant, ne
  renvoie jamais de donnée client. Empêche un courriel deviné de mal rattacher.
- Formulaire RSVP : ajoute Nom (requis) + Téléphone (optionnel), n° client devient optionnel.
  Message de remerciement adaptatif (« ✓ on vous a reconnu » si confirmed).
- POST /events/:id/send {test:true, test_emails[]} : envoi TEST via Mailjet/Gmail (réutilise
  email.sendEmail/gmail.sendMessage + inviteEmail + rsvpLink). Envoi de masse reste gaté (400).

Ops EventsPage : badge de confiance (Confirmé/Probable/À vérifier + signaux en infobulle),
dialogue d'envoi test (canal + courriel défaut = usePermissions().userEmail), CSV enrichi
(téléphone + confiance). Filtres par niveau de confiance.

Vérifié : 21/21 tests unitaires hub (cross-validation + envoi test mailjet/gmail + masse gatée) ;
page Ops rend sans erreur, dialogue s'ouvre avec courriel pré-rempli.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:51 -04:00
louispaulb
e4a8990711 feat(events): inscription (RSVP) événement + page admin Ops — Fête Targo 20 ans
Nouvelle fonctionnalité « Événements » (aucun courriel envoyé — moteur d'envoi prêt mais gaté).

Hub (services/targo-hub) :
- lib/events.js : config événement semée (fete-20-ans, bilingue FR/EN) ; page
  PUBLIQUE festive GET /rsvp/:id[?t=<jwt>&lang] (n° client · combien · courriel ·
  allergies ; ?t pré-remplit + rattache via magic-link) ; POST /rsvp/:id/submit
  (résout le client au mieux via portal-auth.lookupCustomer, valide, stocke clé-par-
  client → re-soumission = mise à jour, pas de doublon) ; endpoints STAFF GET /events,
  GET /events/:id/rsvps (+ décompte total de personnes), DELETE ; helpers lien perso
  (generateCustomerToken) + courriel d'invitation bilingue. Réutilise les patrons rating.js.
- server.js : dispatch /rsvp + /events → lib/events ; /rsvp/ ajouté à PUBLIC (page +
  soumission anonymes) ; /events ajouté à ALWAYS_ENFORCE (vue staff = PII → token requis).

Ops (apps/ops) :
- pages/EventsPage.vue (/evenements) : bandeau événement, KPIs (inscriptions / personnes
  attendues = total food truck / à vérifier), lien public copiable (affiche/QR/réseaux),
  carte invitation courriel (envoi gaté), table filtrable (DynamicFilter) + export CSV +
  suppression. Réutilise PageHeader/StatCard/DataTable/DynamicFilter/useCsvExport.
- api/events.js (client hub), route /evenements, entrée menu « Événements » (PartyPopper).

Vérifié : 30/30 tests unitaires hub (offline) ; page Ops rend proprement à 375px+desktop,
0 erreur console, dégradation gracieuse tant que le hub n'est pas déployé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 09:41:24 -04:00
louispaulb
7da4b1289c feat(ops+hub): notify techs of their tournée (SMS/email + token link + flags)
À la publication (vue Tournées), bouton « Notifier » → aperçu par tech
PUIS envoi. Chaque tech reçoit :
- UN lien token personnel (techFieldLink = app terrain, ses billets du
  jour à répondre),
- la liste de ses interventions avec ADRESSE + flags cruciaux (AM/PM ·
  URGENT · Appeler avant) détectés du titre/notes/priorité.

Hub (lib/roster.js) : composeDispatchNotifs(dates, techIds) + route
POST /roster/notify-dispatch (preview=compose sans envoi | envoi SMS via
twilio + Email via lib/email.js, best-effort par tech, retourne compte +
erreurs). jobFlags() détecte AM/PM/urgent/appeler-avant.
Client : api roster.notifyDispatch ; dialogue APERÇU (toggles SMS/Courriel
+ liste par tech + corps du message, alerte si pas de tel/courriel) →
« Envoyer ». Aperçu d'abord = pas d'envoi accidentel.

Vérifié : hub node --check OK ; client rend le bouton + dialogue + toggles,
0 erreur. ⚠️ Le PREVIEW/envoi nécessite le déploiement HUB (nouvelle
route) — GATÉ + sortant. Faire un test 1 tech avant usage réel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 09:24:21 -04:00
louispaulb
add3c08cee feat(ops): Suggérer — re-optimize existing (dispatched) jobs as a simulation
Suggérer peut servir de SIMULATION sur des jobs DÉJÀ dispatchés : nouveau
toggle « Ré-optimiser aussi les jobs déjà assignés » dans la config. Quand
activé, le solveur VRP reçoit AUSSI les noms des jobs assignés de la
fenêtre (assignedNamesForWindow, depuis occByTechDay) en plus du pool →
repropose une répartition de TOUT (existant + nouveaux jobs ajoutés).
Force le solveur (l'heuristique locale n'a que le pool en mémoire).
Aperçu seulement — rien n'est écrit avant « Appliquer » (qui réassigne).

Vérifié : toggle rendu dans la config Suggérer, 0 nouvelle erreur. Le
résultat de ré-optimisation (solveur roster-solver) est à confirmer en
prod (comme les autres runs solveur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:47:31 -04:00
louispaulb
92c320d08f fix(ops): job detail — show all fields (raw pool job), full title, assign tech
Cause du « on ne voit qu'une partie du titre, aucun autre détail » :
openJobDetail lisait la forme BLOC (b.customer/skill/detail/legacy_id)
mais reçoit un job BRUT du pool quand ouvert depuis une goutte/carrousel
(customer_name/required_skill/legacy_detail/legacy_ticket_id) → tous les
champs vides sauf le titre. Corrigé : mappe les DEUX formes (client,
compétence, adresse, description, n° ticket, date) + charge le fil du
billet via le bon id → détails + commentaires s'affichent.

- Titre : plus tronqué (ellipsis → retour à la ligne, titre complet).
- Assignation DEPUIS le volet : sélecteur « Assigner à… / Réassigner… »
  (jdAssignTech) ; si d'autres tâches à la MÊME adresse/client/jour →
  propose de les assigner au même tech (réutilise assignNames +
  siblingJobs, comme la grille).

Vérifié live (goutte carrousel, ticket #253649) : titre complet non
tronqué, méta = compétence tv + adresse 44 Carre Morrison + dept + date,
section commentaires + sélecteur d'assignation présents ; 0 erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:20:05 -04:00
louispaulb
98184d0a61 feat(ops): job detail — map collapsed, duration adjust; popup width caps
Retours terrain sur les modales de détail job (carte Tournées) :
- Volet détail 1 job : la CARTE (jd-map + tracé GPS) n'est pas requise pour
  lire le ticket → repliée par défaut derrière « Voir la carte / tracé
  GPS » (init à la demande, pas au chargement). Détails simples d'abord +
  champ « Durée estimée » ajustable (comme « Suggérer ») qui persiste
  duration_h (+ override de session). Titre déjà complet.
- Popups « liste des jobs à cette adresse » débordaient hors écran sans
  scroll : maxWidth 260 + overflow-wrap + max-height 44vh scroll (carte
  jour du panneau À assigner ET popup groupée RouteMap).

Vérifié live : volet 1 job → carte masquée par défaut, bouton déplie +
init canvas OK, champ durée = 2.25h ajustable ; 0 nouvelle erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:06:02 -04:00
louispaulb
3979f79283 feat(ops): auto-detect AM/PM from ticket title + AM/PM in RDV proposal
1. Détection auto AM/PM : les tickets legacy « Note AM » / « Note PM »
   (importés tels quels) sont détectés (detectAmpm : titre + note, tokens
   AM/PM / avant-midi / après-midi, null si ambigu) et la contrainte
   horaire est PRÉ-APPLIQUÉE dans « Suggérer » (suggestDlg.jobTime seedé à
   l'ouverture, marqueur  auto_awesome sur la puce, modifiable). Le
   solveur respecte alors la fenêtre 8-12 / 12-16.
2. Fenêtre RDV « 3 dispos du client » : chaque créneau proposé a un
   sélecteur AM/PM (avant-midi/après-midi) qui règle l'heure représentative
   (AM=08:00, PM=13:00), heure exacte toujours ajustable. Aligné sur AM/PM
   du dispatch.

Vérifié live : Suggérer affiche auto_awesome + puce AM pré-cochée (jobs
« Note AM ») ; RDV → clic PM règle 13:00, AM règle 08:00 ; 0 nouvelle
erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:54:46 -04:00
louispaulb
aa52a2c98c feat(ops): map unassigned jobs grouped by address + overlap fan
#3 Regroupement par adresse : routeUnassignedPins groupe les jobs non
assignés d'une même adresse en UNE pastille portant les icônes des types
de jobs (distinctes, max 3) + le TEMPS TOTAL + un compteur ; couleur =
priorité la plus haute. Clic (tap mobile) sur une adresse à >1 job →
popup listant chaque job (sujet + durée), chaque ligne ouvre le détail →
plus besoin de cliquer chaque ticket, info visible d'un coup d'œil.

#4 Chevauchement : les gouttes non assignées rejoignent recluster()/fan()
(déjà éprouvé sur les arrêts assignés) → les pastilles proches s'éclatent
en éventail pour rester distinguables/atteignables. (Le regroupement par
adresse réduit déjà le fouillis ; le fan gère les adresses proches ≠.)

Vérifié par injection du markup (impossible de rendre la carte en dev,
jeu sans coordonnées) : pastille groupée = horizontale 92×22, 2 icônes +
compteur + « 45min » visibles ; popup = lignes cliquables. Compile OK,
0 nouvelle erreur. Rendu carte à confirmer en prod (jobs géocodés).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:23:42 -04:00
louispaulb
c05a6272b7 feat(ops): pickups = ~5min jobs + generic visit icon (handyman, not bolt)
Retours terrain :
1. « Ramassage d'équipement » = simple collecte chez le client → job court.
   Détection (isPickup : titre/type ~ /ramm?ass|récupération/) → durée
   plafonnée à 5 min : jobDur(pickup)=5/60 (maths d'occupation/Suggérer) +
   capPickups() sur les 3 chargements du pool (est_min affiché=5). Évite de
   surallouer le temps même si l'est_min du ticket dit plus long.
2. Icône de visite GÉNÉRIQUE (type non reconnu) : `bolt` (éclair, peu
   parlant) → `handyman` (technicien + outils). Fallback de skillIcon →
   s'applique partout (carte markerIcon + listes skillSym).

Vérifié live (données réelles) : ticket « Rammassage Équipements #253602 »
(St-Anicet) affiche ≈ 5min ; carrousel : plus de bolt, handyman présent
pour le job générique ; 0 nouvelle erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:09:22 -04:00
louispaulb
cfc721e598 feat(ops): installation jobs use 'router' icon (fiber box) everywhere
Sur suggestion de l'user (visuel maison+fibre) : l'icône « installation »
passe de construction/tools-ladder à `router` (le routeur/ONT posé au
domicile) — plus parlant pour une installation fibre. Appliqué PARTOUT via
les 2 fonctions d'icône : markerIcon (carte) + skillSym (carrousel,
kanban, blocs jour/semaine). Ligature material `router` → rend aussi bien
dans le <span material-icons> des marqueurs carte que dans les q-icon.
(Le glyphe custom maison+fibre est un SVG → impossible sur les marqueurs
carte en DOM brut ; router = meilleur équivalent material intégré.)

Vérifié live : carrousel affiche router pour les 8 jobs d'install (à côté
de build/live_tv/bolt), 0 nouvelle erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:01:01 -04:00
louispaulb
01ba6a70ef feat(ops): unassigned map jobs show job-type symbol (like assigned pills)
Les jobs non assignés étaient de simples gouttes oranges SANS symbole,
alors que les assignés ont une pastille horizontale avec icône du type de
job (📞/🔧/) + n°. Uniformisé en RÉUTILISANT le rendu éprouvé des arrêts
assignés (≠ tentative q-icon/SVG précédente qui rendait vertical/vide) :

- RouteMap.renderPins : pastille `.rm-pill` HORIZONTALE + icône via
  <span class="material-icons"> (ligature material fiable), couleur =
  priorité (orange = non assigné), SANS numéro d'ordre. Fini la goutte.
- routeUnassignedPins fournit `icon: markerIcon(required_skill||service_type)`
  (markerIcon = ligatures material, celles qui s'affichent hors q-icon).

Vérifié : le markup exact injecté dans la page rend une pastille
HORIZONTALE 30×22 avec glyphe material 14px visible (police « Material
Icons ») — plus le bug vertical/sans-icône. Rendu carte identique à celui
des pastilles assignées (mêmes classes/mécanisme). À confirmer à l'œil en
prod (dev sans jobs géocodés → 0 marqueur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:54:24 -04:00
louispaulb
c0695f7ea9 feat(ops): saveable tech groups in Suggérer + solo tech route on chip click
1. Groupes de techs enregistrés (Suggérer) : sélection manuelle de techs
   sauvée comme groupe nommé réutilisable (ex. Installateurs, Épisseurs) —
   utile quand la compétence déduite du job est imprécise. Persisté par
   utilisateur (useUserPrefs 'techGroups', suit l'usager entre appareils).
   Chips « Groupes : » (clic = appliquer, ✕ = supprimer) + bouton
   « Enregistrer » (nomme la sélection courante) + chip « Tout
   désélectionner ». Complète le sélecteur par compétence existant.
2. Vue Tournées — clic sur un chip technicien = ISOLER sa tournée sur la
   carte (masquer les autres) pour voir une route précise ; re-clic sur le
   tech déjà seul = tout réafficher ; chip « Tout afficher » si masqués.
   (Avant : le clic masquait/affichait un tech à la fois — inversé.)

Vérifié live : groupes save→apply→delete + « Tout désélectionner »
(13→0) OK ; 0 nouvelle erreur. (Les chips de tournée n'apparaissent
qu'avec des jobs géocodés — comportement solo vérifié par la logique,
à confirmer à l'œil en prod.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:17:03 -04:00
louispaulb
9b866ec3b9 Revert "feat(ops): unassigned map jobs reuse assigned-stop pill + overlap fan"
This reverts commit 80e09c87ba.
2026-07-05 08:05:03 -04:00
louispaulb
1b095ae0e8 Revert "fix(ops): unify job icons — custom skillSym SVGs on map markers too"
This reverts commit e149b252b3.
2026-07-05 08:05:03 -04:00
louispaulb
e149b252b3 fix(ops): unify job icons — custom skillSym SVGs on map markers too
Les jobs ASSIGNÉS (marqueurs carte, HTML brut) utilisaient markerIcon =
ligatures material génériques (install→construction, monteur→camion
générique), alors que les jobs NON assignés (carrousel, blocs, gouttes)
utilisent skillSym = icônes SVG custom (échelle+outils, camion-nacelle,
casque). Résultat : même compétence, icône différente selon jour passé
(assigné) vs à venir (non assigné). Unifié sur les SVG custom :

- RouteMap : les marqueurs (arrêts assignés ET gouttes non assignées)
  rendent l'icône via un VRAI <q-icon> (h+render) → gère les SVG custom
  skillSym comme le reste de l'app ; repli material-icons si échec ;
  démontage propre dans clearMarkers/clearPins (pas de fuite).
- PlanificationPage : les arrêts (Tournées + revue Suggérer) passent
  skillSym(skill) au lieu de markerIcon ; markerIcon retiré (mort).

⚠️ Rendu carte NON vérifiable en préview (jeu dev sans coordonnées → 0
marqueur). Compile OK, carrousel (25 chips) inchangé, 0 nouvelle erreur.
À confirmer à l'œil en prod (jobs géocodés). Reste : unifier la FORME des
puces (chip/pill/carte) selon contexte — passe design dédiée.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 07:25:56 -04:00
louispaulb
4c8242eccb fix(ops): job overdue = due date, not created; day-list from carousel
Retours terrain :
1. « SLA dépassé » / en-retard se basait sur la CRÉATION du ticket
   (jobSlaBadge → creation), ce qui marque en retard des jobs planifiés
   plus tard (le client peut demander une visite quelques jours après
   l'incident). Désormais l'échéance = la DATE DE VISITE PRÉVUE
   (scheduled_date) : badge « En retard » seulement si scheduled_date <
   aujourd'hui ; futur/aujourd'hui = à l'heure. Repli SLA-création
   uniquement pour les jobs SANS date planifiée (backlog). (Le dispatch
   utilisait déjà scheduled_date pour le périmètre — inchangé.)
2. Le carrousel « N à répartir » (Tournées) est plus long à scanner que la
   liste par jour du legacy. La pastille « N à répartir » est maintenant
   cliquable → ouvre la liste VERTICALE des jobs de ce jour (panneau
   « Jobs à assigner » via openDayInPanel, filtré sur le jour) avec
   recherche + chips type/compétence + tri par ville. Meilleure lecture,
   surtout sur mobile.

Vérifié live (Tournées, 06/07) : clic « à répartir » ouvre le panneau
filtré (25 jobs du jour) ; 0 nouvelle erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 07:13:38 -04:00
louispaulb
bae7303d66 fix(ops): Suggérer never sweeps overdue jobs unless opted in
Le correctif précédent ne couvrait que l'entrée Tournées. Les jobs en
retard (scheduled_date < fenêtre) ou sans date étaient encore balayés sur
le jour choisi via d'autres chemins (bouton SUGGÉRER hors Tournées,
solveur  Optimiser). Corrigé À LA SOURCE + au placement :

- suggestJobs() (source unique — utilisée aussi par le solveur qui reçoit
  job_names) : exclut désormais les jobs hors fenêtre (retard/sans date/
  autre jour) SAUF si l'utilisateur coche «  en retard » / « Sans date »
  ou fait une sélection lasso. Sélectionner un seul jour ne dispatche donc
  QUE les jobs dus ce jour-là.
- buildSuggestion() : la branche fourre-tout `else cand = win` ne place
  plus les retards/sans-date automatiquement — même garde (lasso ou chips
  explicites) en défense.

Vérifié live (Tournées, 06/07) : plan de 25 jobs, tous sur « 07-06 »
(aucune date antérieure), 0 nouvelle erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 00:16:19 -04:00
louispaulb
8b3c97de9d fix(ops): Suggérer no overdue-sweep in Tournées + mobile review scroll
Deux retours terrain :
1. Jobs « en retard » (date < jour sélectionné) apparaissaient dans la
   répartition auto. Cause : en vue Tournées, suggestWindow suivait les
   chips de date du POOL, pas le jour choisi dans la bande → buildSuggestion
   balayait les jobs antérieurs sur le jour (branche `else cand = win`).
   Fix : en vue Tournées, `suggestWindow = [routesDay]` (la bande de dates
   fait autorité) → seuls les jobs DUS ce jour-là sont répartis, jamais les
   retards. (Le balayage des retards reste dispo hors Tournées via les chips.)
2. Revue « Répartition suggérée » trop dense sur mobile, lignes rognées
   sans défilement. Fix : dialogue MAXIMISÉ <sm + chaque rangée job
   (.suggest-entry) défile horizontalement (nowrap + overflow-x, enfants
   non rétrécis, sujet borné) → toute la ligne (badges/AM-PM/actions)
   accessible au swipe.

Vérifié live 375px : dialogue plein écran ; 25/25 rangées défilent
(scrollW ~698 > clientW 341) ; plan bâti sur le jour choisi ; 0 nouvelle
erreur console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:42:25 -04:00