Commit Graph

357 Commits

Author SHA1 Message Date
louispaulb
4bed6bb9fd planif : extraction #4/2 — dialogue Synchroniser les techniciens → components/planif/TechSyncDialog.vue
2e extraction de la décomposition (couplage nul : ne lit AUCUN état partagé).
- components/planif/TechSyncDialog.vue (NOUVEAU) : reactive techSync + techSyncSelCount +
  applyTechSync déplacés ; rapport self-fetch à l'ouverture (watch modelValue) ; v-model
  pour l'ouverture ; émet `applied` après création → le parent appelle loadBase
- PlanificationPage : dialogue + reactive + 2 fonctions retirés ; menu Outils → `showTechSync = true` ;
  `<TechSyncDialog v-model="showTechSync" @applied="loadBase" />`
- Vérifié en aperçu : ouvre via le menu, rapport chargé (staff 53 · fiches 56 · tech 41), 0 erreur

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:02:10 -04:00
louispaulb
659cfae12c planif : extraction #4/1 — dialogue Congés & disponibilités → components/planif/LeaveDialog.vue
Début de la décomposition de PlanificationPage (6725 l.). 1re extraction = le panneau
le plus AUTO-CONTENU (survey de couplage) : congés/disponibilités.

- components/planif/LeaveDialog.vue (NOUVEAU, 78 l.) : état privé (leaveRows/leaveFilter/
  newLeave) + fonctions (loadLeave/approveLeave/createLeave) déplacés tels quels ; v-model
  pour l'ouverture ; techs + techOptions en props LECTURE SEULE ; charge à l'ouverture (watch) ;
  CSS .demand-tbl recopié (le scoped ne traverse pas vers l'enfant)
- PlanificationPage : dialogue + état + 4 fonctions retirés (−39 l.) ; menu Outils → `showLeave = true`
- AUCUNE mutation d'état partagé (pas de loadWeek) → ne peut pas désynchroniser la grille
- Vérifié en aperçu : ouvre via le menu, table + formulaire rendus, chargement OK, CSS 11px appliqué

Prochaines extractions sûres (par couplage croissant) : techSync, types de shift, puis
absence/demande (loadWeek), enfin les couplés (team/tags/garde/publish).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:45:22 -04:00
louispaulb
675e1b0bdd ops : types de job = source unique (config/job-types)
Suite de la consolidation des énumérations partagées (après priorités).
- config/job-types.js (NOUVEAU) : JOB_TYPE_OPTIONS + JOB_TYPES (Installation/Réparation/
  Maintenance/Retrait/Dépannage/Autre) = SOURCE UNIQUE
- élimine 3 copies : wizard-constants (ré-exporte désormais), TaskNode, UnifiedCreateModal,
  flow-editor/kind-catalogs (JOB_TYPES)
- LAISSÉS volontairement (vocabulaires DISTINCTS, pas des doublons) : statuts de job
  (cycle de vie ≠ actions du pool ≠ ticket ≠ campagne ≠ appareil) ; issueTypeOptions ;
  jobTypes de l'app terrain (valeurs anglaises Repair/Delivery/Other — à réconcilier
  séparément, risque de données)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:27:32 -04:00
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
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
louispaulb
80e09c87ba feat(ops): unassigned map jobs reuse assigned-stop pill + overlap fan
Sur la carte Tournées, les jobs NON assignés (props.pins) étaient de
simples gouttes oranges sans icône ni gestion du chevauchement, alors que
les arrêts ASSIGNÉS sont des pastilles round-rect (icône compétence + n°)
qui s'éclatent en éventail au survol quand elles se chevauchent
(recluster/fan). Uniformisé :
- renderPins() rend la MÊME pastille .rm-pill (icône de compétence via
  pin.icon = skillSym), variante « non assigné » (.rm-pill-un : bordure
  pointillée + halo orange), avec hover onEnter/onLeave.
- recluster() opère désormais sur stopMk.concat(pinMk) → arrêts et jobs
  non assignés participent au même éclatement au chevauchement.
- routeUnassignedPins fournit `icon` (skillSym du required_skill/service).

⚠️ Visuel (pastille + éventail) NON vérifiable en préview locale : le jeu
de données dev n'a pas de coordonnées sur les jobs → 0 pin rendu. Code =
miroir fidèle du rendu d'arrêt éprouvé ; map s'initialise, 0 nouvelle
erreur console. À vérifier à l'œil en prod (adresses réelles géocodées).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:24:19 -04:00
louispaulb
cef6507f5f feat(ops): filter chips scope Suggérer + skill-matched techs (Tournées)
Suite au carrousel : les chips de filtre de la bande « à répartir »
pilotent maintenant AUSSI « Suggérer » (« ce que je filtre = ce que je
répartis »).

- Chips PRIORITÉ (normalisée high/medium/low, via POOL_PRIOS) ajoutés à
  côté des secteurs ; filtrent la liste + la carte (routeDayUnassignedView
  = jour + compétence + secteur + priorité). Affichés seulement si >1
  priorité présente (comme les secteurs).
- suggestJobs() : en vue Tournées, le périmètre = routeDayUnassignedView
  EXACTEMENT (corrige l'incohérence : avant, les chips secteur étaient
  ignorés par Suggérer alors que l'infobulle disait « du secteur »).
- openSuggest() : pré-sélection des techs par COMPÉTENCE — parmi les
  disponibles (quart ce jour), ceux dont t.skills couvre un required_skill
  des jobs du périmètre ; repli sur tous les dispos si aucun match.
  Toujours modifiable (le dialogue liste les 56 techs, cochables).

Vérifié live (06/07) : secteur Huntingdon → vue 4 jobs → Suggérer affiche
« 4 job » (avant : ignorait le secteur) + 13/56 techs pré-cochés
(compétents+dispos, reste modifiable). 0 nouvelle erreur console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:11:02 -04:00
louispaulb
b7b1647ece feat(ops): unassigned-jobs strip → horizontal carousel (Tournées)
La bande « N à répartir » sous le sélecteur de jour (vue Tournées)
retournait à la ligne sur 2 rangées (max-height 70px, scroll vertical) et
PLAFONNAIT à 16 jobs (« +N de plus » cachait le reste). Passée en
carrousel horizontal 1 rangée (flex nowrap + overflow-x auto, scroll-snap,
barre fine) qui montre TOUS les jobs du jour — compact au-dessus de la
carte, tactile/tablette, aligné avec la bande de dates au-dessus.

Vérifié live (Tournées, 06/07 = 25 à répartir) : 25 chips rendus (fini le
plafond 16), 1 rangée nowrap, scrollWidth 5870 > 1214 = défile bien.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:03:51 -04:00
louispaulb
eb4d098bf0 feat(ops): redesign client ticket list (TicketCard, mobile-first)
La section Tickets de la fiche utilisait un q-table dont le mode carte
mobile empilait des valeurs non étiquetées (« 0 », n°, sujet, avatars,
date…) = archaïque et peu lisible (cf. capture agent terrain). Nouveau
composant components/customer/TicketCard.vue : sujet en évidence + badges
statut/priorité colorés (édition inline conservée via InlineField) + n°
(legacy ou ISS) + date + avatars ouvreur/assigné empilés ; rangée
responsive (flex-wrap) lisible sur mobile ET desktop. Remplace le
DataTable dans la section Tickets ; imports morts retirés (ticketCols,
ticketStatusClass, priorityClass, staffColor, staffInitials).

Vérifié live (C-LPB4) : 5 cartes, sujet + Open/Medium + #ISS-… + date,
aucun élément > viewport à 375px, rendu propre à 1280px, 0 erreur.

Note : la réutilisation d'icônes de compétences dans les tickets est
reportée — les Issues (surtout legacy) n'ont pas de signal compétence
fiable (0 Dispatch Job correspondant) ; à faire quand un lien
ticket→compétence existe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:53:50 -04:00
louispaulb
c193bcbf1f fix(ops): device detail modal responsive on mobile
Le panneau de détail (DetailModal) était un panneau droit fixe 600px/90vw
et la carte réseau d'EquipmentDetail (.topo-row = 4 nœuds fixes 90px + 3
liens ≈528px) débordait/rognait sur mobile (nœud « Internet » coupé, IP/
onglets tronqués). DetailModal : sur <sm → dialogue maximisé pleine
largeur (position bottom) au lieu du panneau droit ; desktop inchangé
(600px droite). EquipmentDetail @<600px : nœuds topo flex (largeur auto),
liens 18px, dv-grid 2 col, dv-row2 en colonne, diag-grid 1 col.

Vérifié live (C-LPB4, EQP-0000011366) : 375px → carte pleine largeur, 0
débordement horizontal (docScrollW=375), 4 nœuds topo visibles
(Internet→OLT→Modem→17 app.), 0 élément > viewport, 0 erreur ; 1280px →
panneau droit 600px inchangé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:41:15 -04:00
louispaulb
d4a5644e37 refactor(ops): extract PaymentSection from ClientDetailPage
T7 découpage géants — sort la section « Paiements » unifiée (méthode de
paiement carte/PPA/liens Stripe + transactions Payment Entry + charges
Stripe réelles + ententes) dans components/customer/PaymentSection.vue.
Reste un module de la grille réordonnable : open (v-model:open),
moduleStyle (ordre CSS) et moduleVisible fournis par la fiche ; données +
états de chargement + actions (usePaymentActions + recordPayment/
confirmRefund/loadAllPayments/openModal) passés en props ; colonnes et
formatMoney importés dans l'enfant. ClientDetailPage -78 l. ; imports
morts retirés (paymentMethodCols/paymentCols/arrangementCols).

Vérifié en local sur hub LIVE (C-LPB4) : en-tête « Paiements (3) » + PPA
+ boutons ; v-model expand OK ; Méthode de paiement = carte réelle
MASTERCARD ••7030 (cus_UJNksulWgk5VVX, PPA Oui) ; 3 charges Stripe
« Remboursé ». 0 erreur console.

ClientDetailPage: 2074 → 1646 lignes sur les 3 extractions T7
(DeviceStrip + LocationCard + PaymentSection).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:37:55 -04:00
louispaulb
925ffc9c3f refactor(ops): extract LocationCard from ClientDetailPage
T7 découpage géants — sort le « lieu de service » (en-tête adresse +
DeviceStrip + abonnements mensuels/annuels drag-drop + tickets) dans
components/customer/LocationCard.vue. Les sélecteurs/actions stateful
(locSubs*/toggle*/onSub*/section*) restent liés aux instances de
composables du parent et sont passés en props-fonctions homonymes → le
template les résout inchangé, comportement identique (mêmes closures,
même invalidation de cache useSubscriptionGroups/useSubscriptionActions).
Helpers purs (formatMoney/isRebate/…) et usePermissions importés dans
l'enfant. Styles loc-*/sub-* copiés (scoped). ClientDetailPage -242 l. ;
imports morts retirés (DeviceStrip/locStatusClass/isRebate/subMainLabel/
sectionTotal/annualPrice/locInlineFields).

Vérifié en local sur hub LIVE (C-LPB4) : rendu pixel-identique (carte
691 rue des hirondelles, DeviceStrip live acs-online, ABONNEMENTS 4 =
INTERNET 3 / RABAIS 1, total 0,00 $/m), toggle de section fonctionne via
la prop (INTERNET 0→3 rangées, chevron expand_more), 0 erreur console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:31:33 -04:00
louispaulb
dc28437d5f refactor(ops): extract DeviceStrip from ClientDetailPage
T7 découpage géants — sort le bandeau d'appareils (chips ONT/routeur +
menu Ajouter) dans components/customer/DeviceStrip.vue. Consomme le
composable singleton useDeviceStatus (couche données ACS/OLT partagée) ;
les helpers de présentation propres aux appareils (signalColor,
formatTimeAgo, doReboot, doRefreshParams) déménagent DANS le composant
(utilisés nulle part ailleurs). Le parent émet open-device + add-*.
ClientDetailPage -108 lignes ; imports/destructures device retirés.

Vérifié en local sur hub LIVE (C-LPB4) : chip vert + point acs-online
réel, tooltip ACS complet (SN TPLGC4160688, OLT oltRem03 1/3/0, En ligne
il y a 4m, Fibre Up, 17 clients WiFi, FW/WAN IP/SSID), 0 warning console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:19:37 -04:00
louispaulb
b9df6d720a fix(ops/mobile): bandes d'occupation ≤7 jours remplissent la largeur (fini le micro-défilement)
Les cellules de jour étaient à largeur fixe (52px) → une bande de 7 jours (~400px)
débordait de ~57px sur mobile et défilait légèrement (6,5 jours visibles).

Override mobile `.os-day { flex:1 0 40px; width:auto }` : jusqu'à 7 jours, les
cellules s'étirent pour remplir la largeur (tableau de bord « cette/prochaine
semaine » = 7 tiennent pile). Au-delà (tournées = bande 14 j) elles gardent leur
plancher 40px et la bande défile comme avant.

Vérifié 375px : tableau de bord 7 j = ne défile plus (jour 41px) ; Planif 14 j =
défile toujours (jour 40px). Portée mobile uniquement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:58:43 -04:00
louispaulb
5a35f64a9d fix(ops/mobile): Network GPON tient sur mobile (onglets défilants + arbre enroulé + boutons tactiles)
NetworkPage débordait de ~118px à 375px. Deux causes corrigées :

- Barre d'onglets (4 onglets = 435px) non bornée dans une rangée flex → ne
  déclenchait pas le défilement natif de Quasar. Ajout global `.q-tabs { max-width:100% }`
  en mobile (app.scss) : Quasar réactive son scroll (aucun effet là où déjà borné).
- Arbre GPON : en-têtes OLT/slot/port en flex non-enroulé (nom·IP·adresse·nb
  clients·SNMP·slots·éditer) → débordement. `flex-wrap:wrap` + neutralisation du
  `margin-left:auto` des compteurs sur mobile.
- Boutons d'action de l'arbre (éditer OLT, ajouter port, éditer lieu) étaient
  révélés au survol (opacity:0) → invisibles au tactile. Toujours visibles < 600px.

Vérifié à 375px : docScrollW 493→375, en-têtes enroulées, crayon éditer visible.
Portée strictement mobile (@media max-width:599px) — desktop inchangé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:56:11 -04:00
louispaulb
68063a9f69 refactor(ui): T7 — extraire CustomerSatisfaction de ClientDetailPage (faible couplage)
Premier découpage de la fiche (2102 l) : bloc « Satisfaction » (étoiles/commentaire + « Demander une évaluation »)
→ components/customer/CustomerSatisfaction.vue (props latest/count, émet 'draft'). Vérifié sur la fiche (rend « Aucune
évaluation » à l'identique, 0 erreur). Extraction DÉLIBÉRÉMENT petite + vérifiable ; les gros blocs couplés
(LocationCard/PaymentSection/DeviceStrip — 10+ méthodes parentes : getDevice/combinedStatus/isOnline… + données ACS live
à vérifier) restent à faire en passe dédiée. Build vert, leak 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:22:52 -04:00
louispaulb
936152d452 feat(ui): T5 — DynamicFilter sur Factures fournisseurs + File courriels
- SupplierInvoices : toggle statut → <DynamicFilter> (chips À traiter/Créées/Ignorées + recherche fournisseur/n°/expéditeur, client-side) ; presets 'filter.supplier-invoices'.
- EmailQueue : select statut → chips (Not Sent/Sending/Sent/Error, → rechargement serveur via onFilterChange) + recherche destinataire/référence (client, displayRows) ; presets 'filter.email-queue'.
Build vert, leak 0. DynamicFilter désormais sur 6 pages (Clients/Tickets/Évaluations/RDV/Factures fourn./File courriels).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:02:11 -04:00
louispaulb
f6f4303e2e feat(ui): T5 — DynamicFilter sur RDV (worklist : recherche + chips Type)
Worklist de rendez-vous : recherche (job/client/lieu) + chips service_type (dynamiques) + presets ('filter.rdv') ;
`filteredJobs` applique le filtre APRÈS la portée `view` (à planifier/à recontacter/tous), qui reste un toggle séparé.
Client-side, réactif. Build vert, leak 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:36:32 -04:00
louispaulb
7def94199c feat(ui): T5 — DynamicFilter sur Évaluations (mode client-side)
Recherche client/commentaire + chips Note (5★/3–4★/≤2★) + presets ('filter.evaluations'). Prouve le mode CLIENT-SIDE :
le computed `sorted` filtre (matchNote + recherche) puis trie ; réagit à filterState sans rechargement.
Build vert, leak 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:29:54 -04:00
louispaulb
4d940eefc4 feat(ui): T5 — DynamicFilter sur Tickets (recherche + chips statut/type/priorité + presets)
Remplace search + 3 q-select par <DynamicFilter> ('filter.tickets') ; chipGroups computed (types dynamiques) ;
statut défaut = not_closed préservé ; mappe l'état → refs existantes (search/statusFilter/typeFilter/priorityFilter) → resetAndLoad débouncé.
La PORTÉE « Mes tickets / Mes départements / Tous » reste un toggle séparé (logique de scope/pagination distincte). Compte redondant retiré.
Vérifié préview : 3 groupes, « Non fermés » actif par défaut, chip Urgent 542→3, toggle portée conservé. Build vert, leak 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:26:57 -04:00
louispaulb
3d52ae32c4 feat(ui): T5 audit — composant DynamicFilter réutilisable + intégré sur Clients
- DynamicFilter.vue : recherche + groupes de chips (tag/statut/…) + PRESETS sauvegardés par utilisateur
  (useUserPrefs, clé presetKey) ; contrôleur d'état ({search,chips}) émis → le parent applique (client OU serveur).
- ClientsPage : remplace search+toggle par <DynamicFilter> ; AJOUTE chips Statut/Groupe/Territoire (avant : statut seul) → pilotent la requête serveur ; presets 'filter.clients'.
- fix(erp): countDocs comptait FAUX avec or_filters (get_count ignore or_filters → 0 pendant une recherche) → compte via get_list quand or_filters présent. Vérifié : « bourdon » 0→33.

Vérifié préview : chips (Actifs 9008), recherche (bourdon 33 = lignes), menu presets. Build vert, leak 0. Réutilisable : Tickets/Network/Évaluations/Boîte ensuite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:13:37 -04:00
louispaulb
8f11d41a42 feat(ui): T3 audit — anti-débordement mobile app-wide (bornes app.scss @<600px)
Champs à largeur fixe inline (style="width:NNNpx") bornés à max-width:100% (jamais étirés de force) ;
q-menu .q-list borné ; boutons icône denses ≥32px (cible tactile) ; utilitaires opt-in .ops-row-wrap / .ops-clamp.
(Dialogues + menus déjà couverts par audit #1.) Vérifié à 375px : fiche/Tickets/Rapports/Sous-traitants ≈0 débordement
horizontal, aucune casse visuelle. NOTE : Network reste large (structurel — carte cytoscape/arbre GPON → traité par-page en T7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:50:42 -04:00
louispaulb
7ab71ce6a8 refactor(ui): T1 (suite) — unifier les formatters variants vers les canoniques (formats cohérents)
Sur autorisation user (changement d'affichage OK si ça améliore l'UI) :
- date+heure → formatDateTimeShort : Settings, GiftsInventory, EmailQueue
- date → formatDate : Subcontractors
- initials → staffInitials (1er+dernier) : Historique
- fmtDur → useHelpers.fmtDur («30m»/«1h30») : RendezVous
LAISSÉS (UX volontaire/niche) : ConversationPanel formatDate (relatif auj/hier), PublishScheduleModal (d/m compact),
TechTasks fmtDate (weekday terrain), Planif fmtH (axe timeline), SupplierInvoices fmtMoney (multi-devise).
Build vert, leak 0 ; 12 copies locales éliminées au total (T1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:42:17 -04:00
louispaulb
1017cab649 refactor(ui): T1 audit — dédup formatters → useFormatters (0 changement d'affichage)
3 formateurs canoniques ajoutés (initials [2 premiers mots, '?'], fmtClock [«8:30»→«8h30»], formatDateLong)
+ remplacement des copies locales IDENTIQUES (vérifiées à l'impl) :
- initials : PlanificationPage + RouteMap
- fmtTime → fmtClock : JobCard + TechTasksPage
- formatDate → formatDateLong : ReportRevenueExplorer + ReportInternetCher
Les VRAIES variantes laissées telles quelles (staffInitials 1er+dernier ; fmtDate string ; fmtDur RendezVous ;
fmtMoney multi-devise ; formatDate Settings/ConversationPanel/Gifts date+heure). Build vert, leak 0.
Première tranche de l'audit workflow 26 pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:31:18 -04:00
louispaulb
112ed0ea2b fix(ui): fiche client — en-tête ne déborde plus en mobile ; assistant = sélecteur recherchable
- CustomerHeader : sous-ligne (nom/legacy/type/langue) était `no-wrap` → débordait sa colonne et
  chevauchait le bloc statut/liens en mobile. Fix : wrap + colonne `min-width:0` + le bloc statut/ERPNext/Users
  passe pleine largeur en dessous (<sm) via `col-12 col-sm-auto` ; titre en ellipsis.
- Ajouter un assistant : q-select brut → <TechSelect> (recherche typeahead) ; jdTeamOptions = nom · compétences
  (searchable par nom OU compétence), CAPABLES d'abord (possèdent la compétence du job), value = id ; jdAddAssistant résout le tech par id.

Vérifié préview mobile (375px) : sous-ligne wrap, 0 débordement (scrollW==clientW) ; build vert, leak 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:31:44 -04:00
louispaulb
80c8005f0c feat(planif): symbole de compétence du job sur les chips « à répartir » (Tournées)
Remplace l'icône terrain/distant par le SYMBOLE de compétence (skillSym : live_tv/build/cable/cell_tower/camion-nacelle…, couleur = getTagColor) — cohérent avec les cartes kanban + blocs de tournée. Vérifié préview : chips = live_tv (TV), build (réparation)…

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:16:12 -04:00
louispaulb
c8afa97004 feat(planif): Tournées — chips secteur + jobs non assignés sur la carte (gouttes), filtre liste+carte
Batch D — regrouper/situer les non-assignés du jour :
- RouteMap : nouvelle prop `pins` (+ renderPins/clearPins/watch, gouttes oranges couleur=priorité, clic → @pin-click) — miroir de `live`, inclus dans le fit bounds
- Planif Tournées : chips SECTEUR (par ville, comptés, triés) qui filtrent SIMULTANÉMENT la liste « à répartir » ET les gouttes sur la carte ; routeUnassignedPins (coords robustes latitude/lat), clic goutte → détail ; « Suggérer » agit sur le secteur si filtré

Vérifié préview : jour 06/07 → 10 secteurs + 21 gouttes ; clic « STE CLOTILDE » → 4 chips + 4 gouttes (« 4 à répartir / 21 »).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:06:34 -04:00
louispaulb
21724e65f8 feat(planif): Tournées — filtre compétence sur la carte + jour ciblé + jobs non assignés en évidence
Batch B — le filtre de compétence pilote la carte : dayLivePositions (GPS techs) filtré par visibleTechs
(les tournées l'étaient déjà via visibleTechs) → seuls les techs capables + leurs jobs sur la carte.

Batch C — mise en évidence des non-assignés du jour :
- Badge rouge « dus non assignés » par jour sur la bande (OccupancyStrip.os-unassigned), depuis unassignedByDay
- routeDayUnassigned = jobs dus le jour sélectionné mais non assignés → bande « N à répartir » (chips cliquables → détail) + bouton « Suggérer » ; se met à jour au clic sur la strip

Vérifié préview : positions filtrées par compétence ; badges [1,21,16,15,3,8] sur la bande ; clic 06/07 → bande « 21 à répartir » (14 chips réels).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:58:58 -04:00
louispaulb
012f924683 feat(planif): simplification barre du haut — Suggérer (jour sélectionné), fix recherche X, retrait Équipe
- Bouton « Générer » (haut) → « Suggérer » (openSuggest) basé sur le jour de la vue ACTIVE (strip Tournées / Jour kanban / bande mobile). Le solveur de quarts est déplacé dans Outils (« Générer l'horaire (semaine) », doGenerate conservé).
- Fix recherche tech : le X (clearable) mettait search=null → `search.value.trim()` plantait le computed visibleTechs → le champ « ne se vidait pas ». Rendu null-safe.
- Retrait du sélecteur « Équipe » (groupOptions vide : aucun tech n'a de `group` ; le filtre par compétence suffit).

Vérifié préview (desktop 1400px) : X vide le champ sans planter, Suggérer ouvre le dispatch auto, « Équipe » absent, grille rendue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:47:42 -04:00
louispaulb
b7c24f58b2 feat(planif): sélecteur de jour « Tournées » = 2 semaines par défaut
routeStripDays passe de 7 → 14 jours (depuis le lundi affiché), données via /roster/capacity
(14 j, découplé de la fenêtre de charge 7 j du grid → barres réelles en semaine 2). Chargé à
l'entrée de la vue Tournées + au changement de semaine (watch [boardView, start]). Bande pleine
largeur + défilement horizontal ; jour courant marqué.

Vérifié préview : 14 cellules (29/06→12/07), today marqué, scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 08:55:29 -04:00
louispaulb
5491aa8abe feat(dashboard/planif): vue 2 semaines + deep-link jour + slot-finder « Planifier des quarts »
- Tableau de bord : 2 bandes d'occupation (« Cette semaine » / « Semaine prochaine », 14 j depuis lundi), jour courant marqué (liseré indigo dans OccupancyStrip). Clic jour → /planification?day=ISO
- Planif : deep-link `?day=` (cadre la semaine + sélectionne le jour grille/mobile/tournées) & `?skill=` (filtre compétence, match casse-insensible vs allSkills) via focusDay() lu au montage
- Slot-finder (SuggestSlotsDialog) : état « Aucun créneau » → bouton « Planifier des quarts pour « X » » → émet plan-shifts → Planif ouvre la timeline (vue grille) au jour cherché, filtrée sur la compétence, + toast « crée un quart pour ouvrir des créneaux »

Vérifié préview bout-en-bout : 2×7 jours + today, clic → ?day=2026-07-09 cadre 06→12/07, recherche vide → bouton → dialog fermé + grille + toast compétence. Build vert + leak-check 0. Aucun changement hub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 08:34:43 -04:00
louispaulb
9a507d35db feat(dashboard): bande d'occupation + KPI cliquables/tendance + garde-fou PPA ; barres de charge en-tête Planif
- <OccupancyStrip> réutilisable (extrait de .pm-strip) ; heatColor déplacé dans useHelpers = source unique
- Tableau de bord : bande « Charge à venir » (7 j glissants, réutilise /roster/capacity) ; clic jour → Planif ; légende + heures à répartir
- KPI cliquables (→ page liée) + sous-ligne tendance/contexte (urgents, +N tickets 7 j, non assignés)
- Garde-fou PPA : confirmation explicite avant prélèvement (cf. incident double-charge 2026-06)
- Planif : barres verticales d'occupation dans l'en-tête desktop (hdr-vbar) + sélecteur de jour « Tournées » = OccupancyStrip (remplace les chips « lun 29/06 »)

Vérifié en préview (strip, clics jour, dialog PPA annulé sans charge, nav KPI) + build vert + leak-check 0. Aucun changement hub (réutilise /roster/capacity).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 08:00:46 -04:00
louispaulb
cf9feb67f4 refactor(ops): retirer la page Dispatch dépréciée — tout sur Planification (−2179 lignes, −240 KB)
Batch 4 de l'audit. La page Dispatch n'était plus utilisée ; Planification la remplace.
- Supprimé DispatchPage.vue (2179 lignes → chunk 240 KB disparu du build).
- router : route /dispatch → REDIRECT vers /planification (anciens signets OK).
- nav.js : entrée « Dispatch » retirée. PlanificationPage : 2 liens « Tableau Dispatch » retirés.
- Dashboard (clic job du jour) + TaskNode (goToDispatch) → redirigent vers /planification.
- src/api/dispatch + src/config/dispatch INCHANGÉS (modules partagés app-wide).
Vérifié : build OK (chunk DispatchPage disparu), nav sans Dispatch (26 items), Planif rend,
/dispatch redirige.

NON appliqué (vérif avant édition = l'audit ne tenait pas) : consolidation initials()
— PAS équivalent (staffInitials = 1re+DERNIÈRE initiale + fallback '' ; local = 1re+2e mot +
fallback '?') → changerait les initiales affichées. Fixes de fuites : composants ont déjà
onUnmounted → « fuites » = cas v-show non confirmés. Laissés tels quels (churn/risque > gain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:03:57 -04:00
louispaulb
e5bd63b2d6 chore(ops): remove unused ResponsiveDialog.vue (0 imports)
Batch 4 (dead code) : composant orphelin (aucun import dans tout src/). Non bundlé
(tree-shaké) → suppression = nettoyage repo pur, aucun impact runtime/bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:43:44 -04:00
louispaulb
4a641ac802 perf(ops): lazy-load heavy libs & shell components — chunks route/shell bien plus légers
Batch 1 de l'audit d'optimisation. Les libs lourdes étaient statiquement importées
donc livrées même inutilisées → passées en import dynamique / defineAsyncComponent :
- MainLayout : ConversationPanel/PhoneModal/FlowEditor/NewTicket/Orchestrator/
  ServiceStatus/OutboxPanel → async (chunk shell 312 KB → 56 KB, chargé sur CHAQUE page)
- TaskGraphPage : TaskGantt (hy-vue-gantt) → async (2.6 MB → 20 KB ; le Gantt ne charge
  que dans la vue Gantt)
- NetworkPage : cytoscape → import() dans loadNetworkMap (484 KB → 52 KB)
- ReportAR/Revenu/Taxes : chart.js/auto → import() dans renderChart (async) (~150→~10 KB/page)

Total dist inchangé (~8 M) — le code est DÉFÉRÉ en chunks à la demande, pas supprimé.
Gain réel = ce que l'utilisateur télécharge pour VOIR une page. Build OK, leak-check propre,
shell + route rapport vérifiés (aucune erreur d'import).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:35:30 -04:00
louispaulb
1af8c62142 feat(planif): géofencing des jobs — timeline En route/Arrivé/Reparti (suivi façon colis)
Le hub compare la position GPS live de chaque tech (Traccar) aux jobs du jour →
détecte les transitions et les journalise par job. Le statut « live » du job est
DÉRIVÉ du journal (compute-on-read, aucune écriture ERPNext — pas de valeur « en
cours » côté doctype). Fiche job : timeline horizontale Assigné → En route → Arrivé
(HH:MM) → Reparti (HH:MM).

- lib/geofence.js : runScan() (rayon 150 m, sortie 230 m hystérésis, séjour 3 min),
  store data/geofence.json par job, machine à états ; startScan() scheduler 90 s
  (GEOFENCE_SCAN=off pour couper). Vérifié prod : 12 jobs × 14 positions, transitions OK.
- server.js : démarre le scan au boot.
- roster.js : GET /roster/job/:name/geofence (timeline) + /geofence-states + /geofence-scan (test).
- api/roster.js : jobGeofence(), geofenceStates().
- PlanificationPage : openJobDetail charge la timeline ; stepper .jd-geo (scoped).

Suite possible : écrire le vrai statut ERPNext à l'arrivée (nécessite d'ajouter une
valeur « En cours » au doctype Dispatch Job) — non fait pour éviter le risque schéma.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:34:55 -04:00
louispaulb
d966e1b749 feat(planif): positions GPS live des techs (Traccar) sur la carte des tournées
Onglet Tournées : marqueurs « live » de chaque tech (appareil Traccar associé),
rafraîchis ~25 s tant que l'onglet est ouvert. Marqueur = pastille ronde à initiales,
couleur = celle de la tournée du tech, halo pulsé si position fraîche (grisé sans halo
si > 10 min). Tooltip : nom · « il y a X min » · vitesse (km/h) ou « arrêté ».
Bouton « GPS live (N) » pour afficher/masquer. Respecte les chips techs masqués.

- hub roster.js : GET /roster/tech-positions → {positions:[{techId,techName,lat,lon,time,speed}]}
  (batch getPositions par appareil). Vérifié prod : 14 positions.
- api/roster.js : techPositions().
- RouteMap.vue : prop `live` + marqueurs .rm-live (halo pulsé, staleness), watch dédié.
- PlanificationPage : polling 25 s (démarré sur l'onglet Tournées, arrêté sinon +
  onUnmounted), couleur mappée depuis dayRoutes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:06:16 -04:00
louispaulb
c74462f97b feat(planif): dispatch auto = SEULEMENT les jobs du jour sélectionné (retards exclus par défaut)
Sélectionner le 7 juillet ne devait pas tirer les jobs en retard/sans date (souvent
faits mais pas fermés) dans la simulation. Par défaut (aucun chip de date coché),
suggestJobs ne garde que les jobs DATÉS sur le(s) jour(s) de la fenêtre — plus de
pollution par les retards/autres jours. Pour dispatcher les retards : cocher le chip
«  en retard » (comportement inchangé). Le job_names envoyé au solveur hub ne contient
alors que les jobs du jour → même résultat côté Optimiser.

Vérifié preview : jour 07-07 → 15 jobs, plan = 15 arrêts tous le 07-07, 0 en retard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:56:56 -04:00
louispaulb
3a836cfb21 fix(ops): HUB_URL = /ops/hub en prod (fin du menu vide — la VRAIE cause)
RACINE du « Accès OPS indisponible / menu vide » : le bundle appelait `/hub/...`
au lieu de `/ops/hub/...`. Cause : `.env.local` contient `VITE_HUB_URL=/hub`
(pour le proxy du serveur de dev) et Vite charge .env.local MÊME au build prod
(même fuite que VITE_DEV_USER) → HUB_URL="/hub" inliné. `/hub` (hors préfixe /ops)
est routé vers ERPNext → **HTTP 404** sur /hub/auth/permissions → can()=false →
menu/recherche/avis vides. Preuve navigateur : app `/hub/...`→404, direct
`/ops/hub/...`→200 superuser.

Fix : config/hub.js n'honore VITE_HUB_URL qu'en DEV ; en PROD HUB_URL =
BASE_URL + '/hub' = **/ops/hub** (chemin conçu, proxifié par nginx → hub, vérifié 200).
Bundle vérifié : plus de "/hub" standalone, tout est BASE_URL+"/hub".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:43:44 -04:00
louispaulb
7b54e87a59 fix(ops): overlay accès — message exact « n'a pas pu charger » + détail erreur + piste extension
L'overlay disait à tort « pas de permissions » alors que le compte y a droit et que
le fetch /auth/permissions échoue (blocage client). Message corrigé : « OPS n'a pas pu
charger les permissions » + affiche le détail technique (usePermissions.error) + suggère
fenêtre privée/autre navigateur (une extension bloque souvent /auth/permissions). Rend
chaque capture d'écran auto-diagnostique.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:31:41 -04:00
louispaulb
01bcd6476d fix(ops): la déconnexion ramène sur OPS après login (plus la page Authentik)
logout() ajoute `?next=<url OPS>` au flow d'invalidation Authentik : après
déconnexion, Authentik renvoie vers erp.gigafibre.ca/ops → qui (non authentifié)
redéclenche le login AVEC chemin de retour → après login on revient sur OPS,
au lieu d'atterrir sur /if/user/#/library. Sans régression si Authentik ignore
`next` (= comportement actuel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:25:41 -04:00
louispaulb
f5d8d38b1e fix(ops): retry permission load (superuser plus jamais bloqué par un échec transitoire au boot)
Symptôme (overlay fail-loud confirmé) : « OPS a reçu louis@targo.ca mais pas de
permissions », alors que le compte EST superuser (is_superuser=True, admin+sysadmin,
vérifié live) et que le hub renvoie 200 (6/6, ~0,14 s). La recherche (même base /hub,
appelée plus tard) marche → seul le 1er appel /hub au démarrage (checkSession) échoue
= course de boot / session pas encore « chaude ».

Fix : loadPermissions réessaie jusqu'à 4× (backoff 0,7→2,1 s, cache:no-store) avant de
conclure « pas d'accès ». Un échec transitoire ne verrouille plus l'app derrière un
menu vide / overlay. (Les droits sont dans Authentik : groupes admin/sysadmin + is_superuser,
inchangés — ce n'était ni le jeton ni les droits.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:18:53 -04:00
louispaulb
e6ac8b11ef fix(ops): fail-loud when OPS perms don't load + username fallback (supersede 96da97b session overlay)
Root cause of the recurring "blank menu": the hub keys OPS permissions to the
exact Authentik email (louis@targo.ca). If whoami passes a different email
(duplicate account e.g. louism@targointernet.com) or an empty email, the hub
404s → can()=false → menu/search/avis silently blank, mistaken for a token bug.
The ERP token and Authentik provider config were verified correct throughout.

- REVERT the misleading session-fix (96da97b): drop verifySessionAlive /
  sessionExpired / auto-reload churn and the "Session expirée" overlay; restore
  the original keep-alive ping. (Re-login couldn't fix a perms/identity issue,
  so that overlay only added noise.)
- FAIL-LOUD (App.vue): when permissions don't load in prod, show a clear overlay
  naming the exact account OPS received ("OPS a reçu <email>, pas de permissions")
  + Changer de compte / Recharger — instead of a silent blank shell.
- USERNAME FALLBACK: whoami username is captured (api/auth getAuthUsername),
  passed to loadPermissions, and the hub (/auth/permissions) retries by username
  when the email matches no provisioned OPS account. Verified on prod: username=Louis
  alone resolves; bad-email+username falls back; unknown → 404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:49:26 -04:00
louispaulb
96da97bfad fix(ops): session expirée → overlay clair + ré-auth, plus de menu blanc silencieux
Quand la session Authentik expire, nginx /auth/whoami renvoie 200 avec un
courriel VIDE (pas un 401) → ni _reauth ni le keep-alive ne le voyaient →
getLoggedUser retombait sur le propriétaire du jeton ERP, loadPermissions
était sauté, can() = false partout → menu/recherche/avis vides EN SILENCE
(l'app avait l'air cassée alors que le backend était sain).

- api/auth.js : verifySessionAlive() lit whoami (rafraîchit aussi la fenêtre
  Authentik) ; 200 + courriel vide confirmé par une 2e lecture (filtre les
  blips) = session morte → 1 rechargement auto (Traefik → login Authentik),
  sinon état sessionExpired (anti-boucle 30 s). getLoggedUser renvoie '' pour
  une session morte au lieu de masquer avec « Administrator ».
- stores/auth.js : checkSession relance verifySessionAlive si courriel vide.
- useSessionKeepAlive : le battement passe par verifySessionAlive (détecte
  enfin la mort par courriel-vide, pas seulement les 401).
- App.vue : overlay « Session expirée → Se reconnecter » au lieu d'une coquille
  vide. Inerte en dev local (localhost court-circuité).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:28:09 -04:00
louispaulb
8d80b44a79 feat(planif): « Suggérer » respecte aussi le filtre de TYPE du pool
Étend le filtre du dispatch auto au type affiché (« ce que je vois = ce que
je répartis ») : suggestJobs part désormais de assignJobsFiltered (type + date)
au lieu des seules dates. Défaut inchangé (aucun filtre = tout le pool part).
Levier concret : décocher « téléphonie » → non réparti aux techs terrain.

- suggestJobs : base = assignJobsFiltered (type ∩ date) hors On Hold ; lasso prioritaire.
- suggestFiltered + suggestScope : bouton « Suggérer (N) » et dialogue
  « N job(s) des jours + types cochés → jours » quels que soient les filtres actifs.
- Aucun changement hub : optimize-plan filtre déjà par job_names (déployé).

Vérifié preview : réparation seul → 18 ; réparation + 07-06 → 5 arrêts,
tous réparation, tous le 07-06 (heuristique locale).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:09:18 -04:00
louispaulb
6c5ed0060f feat(planif): « Suggérer » ne répartit que les jobs des jours cochés
Le dispatch auto utilisait tout le pool avec son propre sélecteur de jour,
en ignorant les chips de date du pool (et même la sélection lasso, sur le
chemin solveur hub). Désormais les chips pilotent le dispatch :

- FENÊTRE = les chips de date cochés ( en retard / Sans date → placés
  aujourd'hui) ; aucun chip → repli sur le sélecteur Aujourd'hui/Demain/date.
- JEU DE JOBS restreint aux jours cochés (ou à la sélection lasso) sur les
  3 chemins : heuristique locale, solveur local, ET solveur hub (nouveau
  param `job_names` dans optimize-plan, additif/rétrocompatible).
- UI : bouton « Suggérer (N) » + dialogue « N job(s) des jours cochés → jours ».
- Helper partagé jobMatchesDateChips (dé-dup avec le filtre d'affichage).

Vérifié en preview : chip 07-06 → 19 arrêts tous le 07-06 (local) ;
chips 07-06+07-07 → 34 arrêts, uniquement ces 2 jours (solveur hub déployé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:58:38 -04:00
louispaulb
c209098a44 fix(security): stop baking dev-user email into the prod bundle
VITE_DEV_USER (.env.local = louis@targo.ca) was inlined by Vite into
auth.js's localhost fallback, shipping personal PII in every prod build.
Gate that branch behind import.meta.env.DEV so esbuild dead-code-eliminates
it (and the inlined string) from production builds — permanent, no reliance
on a build flag. Also neutralize the two hardcoded louis@targo.ca in the
campaign TemplateEditor (merge-tag sample → exemple@gigafibre.ca; test-send
default → empty, send button already guards on non-empty). Leak-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:36:04 -04:00
louispaulb
69f00840c8 feat(planif): hybrid recurring shift model — weekly_schedule pattern + materialization
The weekly_schedule pattern per tech is now the SOURCE of shifts. New
materializeShifts() (hub roster.js) generates source='pattern' Shift
Assignment rows over an N-week horizon, auto-skipping QC holidays
(holidays-qc) and vacations (Tech Availability), and PRESERVING manual
overrides (source≠'pattern') — e.g. a one-off night week. Idempotent
(re-runnable / cron-ready).

- hub: fetchTechnicians returns parsed weekly_schedule; ensureShiftTemplate
  (find/create Shift Template by exact times); endpoints
  POST /roster/technician/:id/weekly-schedule and /roster/materialize-shifts
  (GET=dry-run, POST=apply, weeks/tech params).
- api/roster.js: setWeeklySchedule + materializeShifts.
- WeeklyScheduleEditor: mode toggle 🔁 Récurrent (base, materialized) ↔
  📌 Exception (concrete override weeks); adaptive labels/horizon.
- PlanificationPage onScheduleApply branches: recurring → setWeeklySchedule
  per tech + materializeShifts; exception → direct-write source='manuel'.

No ERPNext schema change (weekly_schedule + source already exist).
Dry-run against prod: 6 techs patterned → 48 shifts/2wk, 0 collisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:35:57 -04:00
louispaulb
c267f9a3dd feat(planif): functional dispatch — single-day suggest, one overdue chip, sick-tech redistribute, bulk shift gen
Making the auto-dispatch match the real workflow:
- SINGLE selected day: suggestWindow is now one day (suggestDay), not
  today+tomorrow. openSuggest defaults it to the board's focus day
  (mobileSelIso/kbSelIso/today); config has a day picker (Aujourd'hui /
  Demain / date). Overdue + undated jobs are pulled onto that day.
  Re-simulating today is fine (non-destructive; apply is explicit).
- ONE " en retard" chip: assignDates collapses all past dates into a
  single overdue chip (was one per date); pool filter handles __overdue__.
- SICK/absent tech: a "sick" button per review group deselects the tech
  and re-optimizes → the solver redistributes their jobs across the rest
  (verified: removing Anthony reassigned his jobs, Gilles picked up).
- BULK shift generation (A): WeeklyScheduleEditor gains multi-tech mode
  (tech chips, all on by default); "Générer les horaires (lot)" in the
  Outils + mobile menus applies a template to N techs × N weeks at once.

Verified: overdue collapses to "en retard 13"; day selector + single-day
plan (13 jobs); sick redistributes; bulk shows 56 techs / "Générer ×56".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:59:39 -04:00
louispaulb
b1ff46e8c1 feat(planif): weekly shift generator per tech (templates + N weeks) — reworked from Dispatch
The best schedule editor lived in the (to-be-deleted) Dispatch page.
Reworked it into a shared Quasar WeeklyScheduleEditor.vue:
- Reuses the shared useHelpers.SCHEDULE_PRESETS (5×8h / 4×10h / 3×12h)
  as one-click chips that fill a per-day form (toggle + start/end + hrs).
- "Générer pour N semaine(s)" → automation: repeats the pattern over N
  weeks from the displayed week.
- Emits the schedule; Planif writes Shift Assignments DIRECTLY (published)
  via ensureWindowTpl (per distinct window) + roster.createShift
  (idempotent), then refreshes the grid. No Publier round-trip.

Wired into the tech skill dialog ("Générer l'horaire…", + a "5×8h
rapide" quick path). This is what makes the créneau/skill-shift feature
usable (regular shifts now exist). Dispatch's schedule modal can be
retired — its useful bits (SCHEDULE_PRESETS) were already shared.

Verified: editor opens per tech with presets + 7 day rows + weeks input;
matches the target UX in Planification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:37:46 -04:00
louispaulb
8b9d5376a7 feat(booking): slot search respects skill + real shifts + weekend-for-repairs
suggestSlots was skill-blind and assumed a default 8-17 shift for every
tech. Now:
- SKILL FILTER: only techs whose skills (or _user_tags) include the
  requested skill are considered. Dialog passes the picked chip's skill.
- REAL SHIFTS: slot windows come from published Shift Assignment ×
  Shift Template (garde/on_call excluded). A tech with no regular shift
  that day yields no slot — so it reflects reality (needs shifts created).
- WEEKENDS reserved for repairs: Sat/Sun skipped unless the skill is a
  repair/dépannage. Removed SLOT_DEFAULT_SHIFT.
- Empty-state message hints to publish a shift / weekend rule.

Verified: with only garde shifts in the horizon, correctly returns 0
(regular_count=0). Skill/weekend/garde-exclusion logic confirmed via
data diagnostic. Produces slots once a regular day-shift is published for
a qualified tech.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:21:01 -04:00