Commit Graph

184 Commits

Author SHA1 Message Date
louispaulb
e8dc86e058 feat(ops): one-click identity linking (Authentik ↔ Employee ↔ Tech), merge-safe
POST /auth/staff/sync-identities upserts a unified identity per internal
Authentik user, resolving Employee + Dispatch Technician and deriving email
aliases from the company email. Resolve-first: merges into an existing identity's
key and never overwrites a manual label/tech/alias (fixes Louis-Paul, whose
login louis@targo.ca ≠ employee email louispaul@); employee lookup is
alias-aware so louispaul@ finds HR-EMP-4. "Lier les identités" button in the
Staff console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:27:30 -04:00
louispaulb
6826f11587 fix(ops): dedupe removes inactive duplicate accounts, not just never-logged-in
Frédérique's duplicate (frederique@targointernet.com) had an old last_login but
was already deactivated, so the "never-logged-in only" rule skipped it and
nothing happened. Now a non-keeper duplicate is deleted when it's inactive OR
never logged in; only a genuinely second active+used account is skipped for
manual resolution. Keeper = active first, then most-recent login, then oldest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:16:10 -04:00
louispaulb
11c53c956a feat(ops): staff console — one-click dedupe of duplicate Authentik accounts
POST /auth/staff/dedupe {email}: keeps the used account (has last_login, else
oldest) and deletes ONLY never-logged-in duplicate Authentik records for that
email — safe (no sessions/history, ERPNext untouched since it's one User per
email). Console shows "Nettoyer le doublon" on flagged rows with a confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:33:22 -04:00
louispaulb
148cb9a320 fix(ops): staff recon — alias-aware employee, dedupe Authentik accounts, link-not-duplicate
- resolveEmployeeForEmail + /auth/staff now resolve the Employee through the
  unified identity's aliases: louis@targo.ca finds HR-EMP-4 (under
  louispaul@targointernet.com). Fixes "no employee linked" for Louis-Paul.
- /auth/staff collapses multiple Authentik accounts with the same email into one
  row (username `joseph` + `joseph@...` → one), flags dup_accounts for cleanup.
- provision LINKS an existing Employee (match user_id → company_email → name)
  instead of creating a duplicate — fixes the Aurélie HR-EMP-107 doublon risk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:20:09 -04:00
louispaulb
b43f092da3 feat(ops): staff reconciliation console (Authentik ↔ System User/Employee/Tech)
Settings → Staff tab: one row per internal Authentik user with provisioning
state (active, groups, System User / Employee / Dispatch Technician / identity),
flagging orphan (active but no System User → not provisioned, e.g. Karim
Takougang), departed (inactive/identity-departed), ok.

Hub /auth/staff (GET reconciliation) + /auth/staff/provision (groups + System
User + Employee + Tech + identity in one click) + /auth/staff/active
(deactivate/reactivate, non-destructive) + /auth/staff/impact + guarded DELETE
(refuses with 409 if tickets/jobs reference the person → offers deactivate).
Writes admin-gated. identity.js gains programmatic upsert/setActive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:01:33 -04:00
louispaulb
dec8321823 feat(ops): unified staff identity (label + email aliases + tech link + departed flag)
One person = one identity { label, primary_email (SSO login), alias_emails[],
tech_id, active, kind }. Every email + tech ID resolves to it.

Hub: lib/identity.js (NEW, SOURCE UNIQUE) — resolver + secured endpoints
(/identity/map|resolve read; upsert/alias/merge/delete admin-only). server.js
mounts /identity. auth.getDisplayNameByEmail consults the identity label first
(any alias → full name everywhere). Seed: Louis-Paul Bourdon, Louis Morneau.

SPA: composables/useIdentity + api/identity. IssueDetail dedupes assignment by
identity (m'ajouter + search = same person → no double chip), shows full names,
and hides departed people (active:false) from the picker. IdentityManager.vue
(Settings → Identités tab): edit label/aliases/tech link, merge duplicates,
block/reactivate (departed = non-destructive: hidden from pickers, history kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:22:48 -04:00
louispaulb
14530787bb consolidate uncommitted multi-session work (NL staff-agent, user avatars/profil, HelpHint, ticket detail modules, skill icons, hub agent/payments/sync)
Lot de travaux accumulés non commités (plusieurs sessions), vérifiés : hub `node --check`
tout OK + build SPA propre.
- ops : AssignmentField, CommandPalette, IssueDetail/DetailModal + detail-sections/modules,
  UserAvatar/useAvatar, UserProfileDialog/EmployeeEditDialog, HelpHint, useSkillIcons,
  pages (Dashboard/Equipe/Clients/Tickets/Reports/Settings/Evaluations/LegacySync…),
  usePermissions/useUserGroups/useDetailModal, MainLayout, TicketStatusControl
- hub : staff-agent.js (couche commandes NL), agent.js/agent-tools.json/voice-agent.js,
  avatars.js, conversation.js, payments.js, sync-orchestrator.js, auth.js, campaigns.js,
  legacy-dispatch-sync.js, server.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:43:50 -04:00
louispaulb
9c49054b8d feat(ops): quick filler job from cell menu (blocks tech from dispatch)
Add "Bloquer / job générique…" to the timeline cell menu (opens on empty-cell
click or right-click). Opens a small dialog prefilled with the tech + day:
editable title, full-day toggle (= that cell's shift length) or custom
start/duration, standard priority (default Moyenne), type, and a "generate a
linked ticket" toggle.

New hub POST /roster/filler-job creates a standard-priority assigned Dispatch
Job (+ optional ERPNext Issue via source_issue, best-effort so a ticket failure
never blocks the job). Since timed jobs now count toward occupancy, this removes
the tech from regular dispatch — unlike /reserve which stays soft for urgencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:58:18 -04:00
louispaulb
04fb52426f fix(ops): slot finder respects untimed load (humanly possible)
suggestSlots built gaps only from timed jobs, so a tech loaded with untimed
(no start_time) legacy jobs still looked free and got offered slots. Now the
day is skipped when timed + untimed committed hours + the requested duration
exceed the shift. Also matches assigned_tech by technician_id OR docname.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:43:21 -04:00
louispaulb
7df6ad30e3 fix(ops): accurate occupancy denominator + editable skills + support filter
Three fixes from live feedback:
1. techOccupancy counted only jobs WITH a start_time → legacy osTicket jobs
   (assigned, no appointment hour) showed as free (e.g. Houssam "40h libre"
   with 6 jobs). Now untimed assigned jobs count toward busy (rendered as
   hatched "unscheduled" blocks) and jobs match by technician_id OR docname.
   techOccupancy also accepts CSV skills (require ALL) for multi-skill needs.
2. Support now maps to a 'support' skill (was []), so it lists only
   support-skilled agents — techs with no skills set are excluded. Added
   'support' to SKILL_VOCAB + DEPARTMENT_SKILLS.
3. AvailabilityByReason skills list is editable: auto-added skills (e.g.
   'sans-fil' from wireless service address) can be removed per-chip when
   not relevant. Bands + summary now reflect ALL selected skills.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:28:42 -04:00
louispaulb
2a9f201826 Events: personal rsvp_url per-language, address field, logo spacing
- rsvpLink() now appends &lang=<en|fr> when generating a recipient's personal
  link, so the English invitation's link opens the English RSVP form instead
  of defaulting to French. Threaded through sendTest and the mass-send worker.
- New optional per-language "address" field (editor input, SEED default for
  fete-20-ans, Unlayer template var), rendered under the date with a pin icon
  in both the festive email and the RSVP page.
- Logo: +20px bottom margin in both the email template and the RSVP page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:26:45 -04:00
louispaulb
929ef73d93 Events: real mass send (campaign-integrated) + white email header bar
- Mass send: "Envoyer à N destinataire(s)" button behind a confirmation dialog
  showing the exact recipient count. Creates a real campaign record (reuses
  campaigns.newCampaignId/loadCampaign/saveCampaign — 3 new exports, no worker
  changes) so Mailjet open/click tracking, per-recipient status, report.csv, and
  the /campaigns list all work unchanged. Events owns the actual send loop
  (per-recipient personal rsvp_url, per-language attachments, festive/Unlayer
  template) since the generic campaign worker doesn't support those.
- Festive email template: top accent bar changed from TARGO blue to white.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:39:50 -04:00
louispaulb
0853947c43 Events: remove RSVP customer-number field; fuzzy multi-field name audience filter
- RSVP form: drop the "Votre numéro client (optionnel)" field (and its submit
  wiring). Magic-link tokens still resolve the customer; matching falls back to
  name/email/phone.
- Audience name filter: was a case-sensitive ERPNext `like` on customer_name only,
  so "lpb" matched nothing (the account is C-LPB4 / "Louis-Paul Bourdon", matched via
  account ID / mandataire). Now resolved via nameLikeCustomerIds — accent/case-
  insensitive across customer_name + account name + mandataire + contact_name_legacy
  (PG pool, REST fallback), intersected with the other filters. Confirms "Tous"
  includes résiliés (include_resiliated) works end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:26:59 -04:00
louispaulb
d26722f048 Events: editable (Unlayer) email template, "Tous" includes résiliés, realtime picker
- Email template: an event can use an editable campaign-style template (Unlayer) for
  its invitation instead of the built-in festive builder. Config field email_template
  ('' = festive default/fallback). inviteEmail() renders the named template via
  campaigns.renderNamedTemplate with {{firstname}}/{{rsvp_url}} (+ event vars), else
  festive. campaigns.js: add 'event-' template prefix + export renderNamedTemplate.
  New GET /events/:id/invite-preview (festive | configured | override) for live preview.
  Editor gains a template selector, "design in editor" link, and an inbox preview dialog.
- Audience "Tous (incl. résiliés)": filters.include_resiliated skips the résilié guard
  for win-back; "Clients actifs" still excludes them.
- Manual picker: realtime fuzzy search into a results list (add per row), separate pool,
  then add-batch — replaces the combobox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:05:31 -04:00
louispaulb
3aa59adec3 Events: creation, capacity cap, language-tagged attachments, additive audience list
Ops event page overhaul (hub lib/events.js + email.js, ops EventsPage.vue + api):

- Create/edit/delete events (persisted config shadowing the seeded SEED_CONTENT);
  labels factored into shared LABELS, content editable bilingual (EN falls back to FR).
- Capacity cap by headcount: public RSVP form closes with a "booking is full" message
  when reached (server-enforced); existing registrants can still update.
- Email attachments (image/PDF, <=4MB, <=6), tagged per language (fr/en/both) so each
  customer gets files in their account language; optional Gemini auto-detect on images.
  email.sendEmail gains a generic attachments[] param.
- Additive main audience list (persisted per event): build one deduped list by appending
  batches from filters, manual picks, or CSV; per-source breakdown, remove/clear.
- Filters: name-contains (customer_name like) and city (Service Location.city, resolved
  accent/case-insensitively via PG with REST fallback), intersected with other criteria.
- Manual picker + fuzzy search reuse app-wide /collab/customer-search (pg_trgm), so
  "repa" finds "Réparation …".
- nginx /hub/ proxy: client_max_body_size 8m for base64 attachment uploads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:10:43 -04:00
louispaulb
f4a6d07a8b feat(ops): reason → skill → skill-aware availability module
Bridge reason → required skill → availability so the occupancy denominator
reflects the skill a job needs ("70 h installation" vs "150 h all techs").

Hub:
- lib/skill-resolver.js (NEW, SOURCE UNIQUE): shared techHasSkill(s) predicate,
  deptToSkill (moved here, re-exported from roster.js — no cycle),
  DEPARTMENT_SKILLS bridge, resolveSkills({text,department,jobType,useAI})
  (chip → rule → Gemini fallback, reusing classifyEmail pattern).
- roster.js: capacityByDay(start,days,skills) filters denominator to qualified
  techs (+ guards used/due). New POST /roster/resolve-skills, /roster/capacity?skill=.
  Backward-compatible (no skill = prior behavior).
- dispatch.js: techOccupancy/suggestSlots reuse the shared predicate.

SPA:
- config/departments.js: skills[]+dur per department + skillsForDepartment();
  added Réparation reason.
- components/shared/OccupancyBands.vue (NEW): vertical-strip grid extracted from
  SuggestSlotsDialog (now reuses it).
- components/shared/AvailabilityByReason.vue (NEW): reason chips + dictate→AI →
  resolved skill → skill-filtered bands + adjusted-denominator summary.
  Mounted in ClientDetailPage, IssueDetail, ConversationPanel.
- api/roster.js: getCapacity(start,days,skill) + resolveSkills().
- Planif + Dashboard capacity bands pass skill to /roster/capacity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:33:30 -04:00
louispaulb
6bf323b18e fix(ops): champ d'assignation — échelle d'implication + bascules « moi » + fix lecture équipe
Refonte du modèle d'interaction (retour user) + correction du bug d'affichage des assistants.

Bug corrigé (racine = hub) : getJobTeam lisait le doctype-enfant « Dispatch Job Assistant »
via erp.list → PermissionError pour le compte de service → renvoyait [] en silence. Résultat :
chips assistants jamais affichées, tech non exclu de l'autosuggest, et POST add/remove partait
de [] → écrasait les assistants existants. Fix : lire `assistants` VIA LE DOC PARENT (child table
embarquée, sans vérif de perm sur l'enfant). Vérifié : getJobTeam(LEG-253958) renvoie l'assistant.

Nouveau modèle (AssignmentField) :
- UN champ « Ajouter un participant » (techs + compétences en tête, puis tous les utilisateurs).
  1er ajouté = assigné/lead ; suivants = assistant par défaut.
- Échelle d'implication par personne (menu sur la chip) : Suiveur ‹ Assistant ‹ Sur place (équipe).
  · Suiveur (#) = reçoit les MàJ, hors équipe (/conversations/follow).
  · Assistant (CC) = sur l'équipe, SANS bloc horaire (Dispatch Job Assistant pinned=0).
  · Sur place = assistant AVEC bloc réservé dans son horaire (pinned=1) — masqué sur ticket (can-onsite=false).
- « Assist » / « Suivre » deviennent des BASCULES sur l'utilisateur connecté (moi), plus des liens d'expansion.
- Suivi keyé par courriel si connu, sinon tech_id (repli).

Câblage : PlanificationPage jdSetAssistant({value,label,onsite}) → addAssistant pinned=onsite?1:0
(le hub `add` remplace la ligne du même tech → sert aussi à changer de niveau). IssueDetail : can-onsite=false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:40:36 -04:00
louispaulb
2f81cff892 feat(ops): champ d'assignation MULTIFONCTION (À / CC / #) partagé
Fusionne l'assignation en UN autosuggest + chips à 3 rôles, réutilisé sur le
volet tâche (PlanificationPage) ET le détail ticket (IssueDetail).

- Nouveau composant partagé AssignmentField.vue : chips distinctes À (assigné /
  lead) · CC (assistant / renfort) · # (follower). Autosuggest par défaut =
  techniciens ; 1er choisi = assigné, 2e+ = assistant. Mini-liens « Assist » et
  « #follow » étendent la liste à TOUS les utilisateurs (assistant ou follower
  depuis n'importe qui). Le suivi (follower) est géré dans le composant via
  /conversations/follow (identique sur toutes les surfaces).
- TechSelect : props optionnelles searchFn (recherche async « tous les
  utilisateurs ») + noOptionLabel — amélioration du composant partagé, pas de fork.
- PlanificationPage : le champ To/Cc devient AssignmentField (assigné→jdAssignTech,
  assistant→addAssistant, désassigner→jdUnassign) ; adaptateurs jdAssignee /
  jdAssistants / jdFieldAssist.
- IssueDetail : remplace le multiselect _assign par AssignmentField (1er _assign =
  À, suivants = CC ; frappe assign_to) ; pool par défaut = utilisateurs assignables.
- Hub (conversation.js) : POST /conversations/follow accepte un courriel cible
  (abonner n'importe quel utilisateur) ; nouveau GET /conversations/followers
  ?doctype&name → liste des abonnés (pastilles #).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:49:57 -04:00
louispaulb
844eff9a49 fix: capacité réelle (hors congé/pause/archivé), archive via store hub, calendrier horaire tech
- capacité/occupation par jour : retranche les (tech,jour) en congé approuvé /
  pause / archivé du dénominateur (dashboard cap_h + bande Planif visStat) —
  fini le « 24/150 » gonflé par des techs indisponibles
- archive technicien : store hub durable (data/archived_techs.json) au lieu
  d'écrire status=Archivé (ERPNext v16 rejette la valeur hors liste → 500) ;
  exclusion partout (roster/solveur/créneaux/occupation) + restauration
- TechScheduleDialog calendrier : jours SANS quart grisés (comme week-end),
  jours AVEC quart en blanc + mince barre d'occupation → vraie dispo du tech
- techOccupancy : horizon jusqu'à 42 j (calendrier mois)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:46:21 -04:00
louispaulb
13039be301 feat: suivi généralisé, occupation ressources, horaire tech, revert journal
Follow / subscribe (au-delà des tickets)
- hub: store généralisé /conversations/follow {doctype,name} (follows.json,
  migre ticket_follows.json sous Issue) ; ticket-follow = alias Issue
- InterveneDialog: tuile « Suivre » câblée (toggle + état) ; TicketsPage sync

« Trouver un créneau »
- hub: /dispatch/occupancy (techOccupancy) — occupation par tech filtrée skill
- SuggestSlotsDialog: panneau occupation (bandes verticales) + props de
  pré-remplissage (adresse/coords/skill)
- ClientDetailPage: entrée directe « Trouver un créneau » (appel/réparation)
- fix moteur créneaux: suggestSlots + techOccupancy respectent désormais les
  congés (Tech Availability approuvé), « En pause » et « Archivé »

Horaire par technicien (components/planif/TechScheduleDialog)
- écran unique: pause indéfinie + motif · horaire récurrent (réutilise
  WeeklyScheduleEditor) · calendrier du mois (cases carrées, glisser-
  sélectionner + pinceau congé/maladie/indispo/effacer) · archivage réversible
- hub: /roster/technician/:id/archive (status Archivé) + exclusion au fetch
  (_fetchTechniciansRaw) + /roster/technicians?archived=1 (restauration)
- icône « event » de la rangée tech remplace le bouton pause

Journal des changements (icône history)
- chaque changement de quart révertible individuellement (bouton « annuler »),
  à la façon de la liste « Publier »

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:40:22 -04:00
louispaulb
4870cec09b hub+solveur : compétences multi par job + réservation + matérialisation quarts + app terrain natif
- roster.js : store /roster/job-skills (LISTE = source unique des compétences requises),
  enrichissement required_skills[] + required_skill (1re, rétro-compat), solveur techCovers
  (le tech doit les avoir TOUTES) ; /roster/reserve (bloc placeholder priorité moyenne, soft) ;
  publishWeek modes draft/submit/approve/publish ; matérialiseur de quarts récurrents
  (source:pattern) ; décodage des entités HTML aux points de service
- route_solver.py : skill_ok list-aware (toutes les compétences requises)
- dispatch.js : suggestSlots ignoreReserved (une réservation ne bloque pas la recherche de créneau)
- conversation.js : agentName sur le fil (commentaire au nom du tech, pas « Targo Ops »)
- legacy-dispatch-sync.js : postTicketLegacy(actorEmail, actorName) — commentaire attribué
- field-app.html + server.js : commentaire + fil de ticket NATIFS (restent sur OPS)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:46:42 -04:00
louispaulb
eb8376c9c3 app terrain : commentaire NATIF (reste sur OPS) au lieu du renvoi vers store.targo.ca
- field-app : « 💬 Répondre » (lien legacy store.targo.ca) → « 💬 Commenter » = boîte native (textarea + case
  « envoyer au client (public) ») qui POST /field/comment. Retrait du lien reply legacy.
- Hub /field/comment (token tech) : ajoute au FIL du ticket. Par défaut = NOTE INTERNE (jamais au client) ;
  case public cochée → réponse publique (courriel client) via postTicketLegacy(public). Job natif → Comment ERPNext.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 14:16:24 -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
4ae2907172 solveur : le RANG (compétence principale) domine la polyvalence — répare la mauvaise répartition
Bug (Louis) : Stephane (réparation rang 1, installation secondaire) recevait 3 installations, William
(installation rang 1/principale) une seule. Cause : versatility_weight=6 > rank_weight=2 → un tech moins
polyvalent mais NON spécialiste rafflait les jobs hors de sa spécialité. Fix : rank_weight=20 (domine),
versatility_weight=3 (léger départage entre techs de même rang → réserve les experts). Testé : William obtient
les installations. eff/AM-PM/distance inchangés.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:51:18 -04:00
louispaulb
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
e950bd3ac6 hub: reverse geocoding infra-first (fibre field-pins -> RQA fallback); address resolution + geotagged field photos
- /address/reverse: reverseNearestFibre() (fibre field-pins, source of truth) then RQA
- searchCustomers: civic address resolves to the resident (not fuzzy homonyms)
- field-app + /field/photo: geotagged photos confirm the correct job

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 09:24:21 -04:00
louispaulb
d385367095 feat(hub): lib/vacation-import — lecteur .xlsx (ZIP/zlib, sans dép) + parseur vacances + matcher techs
Cœur de l'import du calendrier de vacances (cf. memory project_vacation_import) :
- unzip() : lecteur ZIP minimal via zlib.inflateRawSync (aucune dépendance npm, comme gmail.js évite googleapis)
- parse xlsx : sharedStrings + styles(fills→couleur) + cellules ; couleur cyan/jaune/magenta = Choix1/Choix2/Approuvé (vert=instruction, ignoré)
- parseCell() : texte de cellule → jours ISO (« 6 et 7 », « 6 au 10 », « 13,14…24 », « 21 juin au 1er juillet », listes .-) ; résolution du quantième par proximité (gère bornes de mois + listes multi-semaines)
- extractPeople() : personnes → jours par statut, plages contiguës
- matchAll() : appariement noms→techs en 2 passes (certains d'abord, puis lever ambiguïtés sur techs restants ; indice = initiale de nom)
- buildPreview() : buffer + techs → payload dry-run

Vérifié EN LOCAL (node, runtime hub) contre le vrai fichier « Vacances 2026.xlsx » : 30 semaines, 24 personnes, 24/24 appariées ; ambiguïtés Louis/Simon + cellules texte-sans-couleur correctement signalées « À classer ». Endpoints + UI OPS = étape suivante.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:26:57 -04:00
louispaulb
0e5c890b74 perf(hub): cache liste techs (30s) + garde anti-chevauchement sync + jeton ingest mémoïsé
Batch 2 de l'audit (perf backend), items sûrs :
- roster.js : fetchTechnicians caché 30 s (appelé par endpoint + generate + materialize +
  tech-positions polling 25 s pour des données quasi statiques). invalidateTechCache() sur
  les 6 écritures Dispatch Technician (coût/effic/skills/pause/traccar/weekly) → pas de stale
  après édition. Vérifié : call1 71 ms → call2 5 ms (caché).
- legacy-dispatch-sync.js : garde _inFlight sur le tick d'import ET la veille → un passage
  long ne se relance plus par-dessus (évitait doublons Dispatch Job + contention ERPNext).
- conversation.js : jeton d'ingestion webhook mémoïsé (mailIngestToken) au lieu d'un
  fs.readFileSync SYNCHRONE à chaque courriel/canal entrant (bloquait la boucle d'événements).

NON fait (par prudence) : paralléliser les erp.list séquentiels de generate() (séquencement
DÉLIBÉRÉ anti-ECONNRESET Frappe, cf. feedback_hub_crash_502) ; batch N+1 Customer du triage
(refactor risqué d'une chaîne de fallback stateful — reporté) ; jitter des pollers (502 boot
déjà mitigé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:42:18 -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
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
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
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
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
louispaulb
a7a6baf8b5 feat(geoloc): OSM geocoder + postal-code centroid fallback (never wrong-city)
The ingest geocode fallback called Mapbox with only a broad territory
bbox guard — so a wrong-city guess (e.g. "Rue Elsie" → Valleyfield, both
in-territory) was accepted. Rebuilt the chain to be bounded:

  infra (fibre/placemarks) → RQA exact → OSM (validated) → postal-code
  centroid → phone-match / town-centre (existing last resorts)

- geocodeNominatim(): free OpenStreetMap geocoder (User-Agent per OSM
  policy, cached, in-territory) — replaces Mapbox in the ingest path.
- OSM result is ACCEPTED only if within 10 km of the postal-code centroid
  (geocodeCentroid, RQA average for the full postal code); otherwise
  rejected → the centroid is used. So a J0S1H0 address can never land in
  Valleyfield again.
- postal_centroid is the guaranteed bounded fallback.

Note on connectors: the fibre table is a PERMANENT direct MySQL link
(cfg.LEGACY_DB); placemarks come via the permanent PHP bridge
ops_placemarks.php — both already wired into resolveDevCoords.

Verified: J0S1H0 centroid = 45.0857,-74.1795 (Huntingdon, 3471 addrs);
OSM reachable and returns in-town coords for known streets.

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