Commit Graph

304 Commits

Author SHA1 Message Date
louispaulb
987ae98bfc planif : éditeurs de compétences unifiés sur TagEditor (fin de SkillSelect)
Régression corrigée : les 4 sélecteurs de compétences de Planification utilisaient
SkillSelect (chips plates, CSV, sans couleurs ni catalogue) au lieu du composant
riche TagEditor déjà employé pour les compétences d'un technicien.

- jobDetail + feuille du pool (« Compétences requises ») : SkillSelect → TagEditor
  (chips COLORÉES, autosuggest depuis tagCatalog, création à la volée + palette 12 couleurs)
- table d'équipe + table de demande de personnel : idem → TagEditor (compact)
- SkillSelect.vue SUPPRIMÉ (0 référence) ; import retiré
- normSkillList() : normalisation unique (TagEditor émet un tableau de libellés/{tag} ;
  rétro-compat CSV) → réutilisée par jobDetail/pool/équipe/demande
- demande : d.skills passe de CSV à tableau (migration au chargement + CSV rétabli
  à la génération des besoins)

RÉUTILISER les composants partagés (TagEditor pour toute saisie de compétences/tags),
ne pas reconstruire d'équivalent inférieur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:06:15 -04:00
louispaulb
7ca3ceef66 ops : priorités 3 niveaux (source unique) + compétences multi + création unifiée + planif
- config/dispatch-priority.js (NOUVEAU) : DISPATCH_PRIORITIES = SOURCE UNIQUE des 3 niveaux
  (Urgent/Moyenne/Basse, couleurs 🔴🟠) — élimine 5 copies locales (UnifiedCreateModal,
  TaskNode, CreateOfferModal, TechJobDetailPage, PlanificationPage POOL_PRIOS) et corrige
  la dérive « Moyenne » bleue de CreateOfferModal
- PlanificationPage/TechJobDetailPage : éditeur de compétences SkillSelect → /roster/job-skills ;
  FIX : SkillSelect émet une CSV (pas un tableau) → les handlers n'effaçaient plus les compétences ;
  panneau job (statut/reporter/réserver un tech) ; sommaire avant publication + auto-save + journal + annuler
- UnifiedCreateModal/ProjectWizard/QuoteWizard/TaskNode : formulaire de création unifié,
  divulgation progressive, dédup client (courriel/téléphone) + message d'erreur convivial ;
  suppression de NewTicketDialog (fusionné)
- IssueDetail : fil de ticket (nom d'expéditeur) ; app terrain lecture/commentaire
- useConversationDisplay/useHelpers/wizard-constants : priorités alignées sur 3 niveaux

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:46:57 -04:00
louispaulb
0ba789a5c0 planif Notifier : toggles SMS/courriel PAR TECH (SMS préféré) + édition inline du contact
- Chaque tech a ses propres toggles SMS/Courriel (cochés selon l'info dispo, SMS préféré) → on peut exclure
  quelqu'un (ex. envoi test). Envoi scindé : 1 appel SMS (techs cochés+tél) + 1 appel courriel (cochés+email).
  Bouton « Envoyer (N) » = nb réel de destinataires.
- Édition INLINE dans le dialogue : tech sans téléphone/courriel → champ + « Enregistrer » sans quitter
  (roster.setTechContact → POST /roster/technician/contact → update Dispatch Technician + invalide le cache).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 14:04:11 -04:00
louispaulb
d6db1a5fa4 planif: contrôle Statut/Reporter aussi sur le panneau job (3 vues couvertes)
jobDetail porte désormais .status ; TicketStatusControl (Dispatch Job) dans l'en-tête du volet job
(Reporter = replanifie scheduled_date +N · Fermer=Completed · Rouvrir=open). Le contrôle est maintenant
présent sur les 3 vues (page ticket · fiche client · Planification). Reste (séquencé) : convergence layout/sections #27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:57:41 -04:00
louispaulb
fcfd63c2e9 ticket: contrôle Statut/Reporter partagé (v1 sur le panneau ticket)
Nouveau TicketStatusControl (doctype-conscient Issue/Dispatch Job) : Reporter +1j/+7j/date · En attente
d'un autre ticket #X · Fermer/Rouvrir. Reporter = On Hold + note datée (job = replanifie scheduled_date).
Posé sur IssueDetail (page ticket + fiche client) en remplacement des boutons Fermer/Rouvrir épars.
Prochaine étape (séquencée) : câbler sur le panneau job (Planification) + convergence des 3 vues (#27).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:55:29 -04:00
louispaulb
8a655f9933 planif: assistant renfort — nom robuste (jamais « undefined »)
jdAddAssistant : dérive le nom du tech chargé OU du libellé de l'option choisie OU l'id (jamais undefined),
coerce tech_id en String → la réservation (bloc hachuré) se crée correctement. Puce : repli techNameById/id/—.
(DB vérifiée : 0 ligne assistant corrompue ; le « undefined » était transitoire à l'ajout.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:36:43 -04:00
louispaulb
969d5af9f9 planif: horaires (lot) — recherche/skill/tout-aucun ; grille 2 sem. par défaut (sélecteur retiré)
- WeeklyScheduleEditor (lot) : recherche par nom + chips « par compétence » (sélectionne les techs qui l'ont)
  + boutons Tout / Aucun. Passe t.skills depuis openSchedGenBulk. Avant : liste de chips sans filtre.
- Grille : 2 semaines (days=14) par défaut, sélecteur « Semaine/2 sem. » retiré (évitait les changements d'étendue accidentels).
- (Rappel : onScheduleApply recharge déjà la grille → les quarts générés en lot apparaissent après application.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:56:17 -04:00
louispaulb
712f113e29 planif barre d'outils : sélecteur de vue à gauche, Publier unifié, picker single-job filtré
- Vues (Semaine/Jour/Tournées) déplacées À GAUCHE, mises en évidence (view-switch) ; retrait du titre
  « Planification » redondant (déjà au-dessus) → moins de déplacement d'éléments.
- « Publier » unifié en split-button : action principale + sous-options (☑ SMS · Publier au legacy).
- Picker « déplacer UNE job vers un autre tech » : filtré aux techs sélectionnés+compétents de la simulation
  (+ « Afficher tous »). Avant : le repli montrait tout le monde (techsForJob n'expose pas .skills → covers échouait).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:41:37 -04:00
louispaulb
9f2fb0eaf9 planif: liens Street View + Google Maps/satellite dans le volet détail job (géoloc)
Sous l'adresse, quand le job a des coordonnées : lien « Street View » (vue au sol, évaluer la difficulté)
+ « Carte / satellite » (Google Maps). Réutilise le format pano existant (openStreetView).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:21:15 -04:00
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
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
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
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
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