Commit Graph

512 Commits

Author SHA1 Message Date
louispaulb
da9c918fa9 feat(ux): gate ONU device actions + delete behind manage_settings, own block
Lane 3 (durcissement) : OnuActionsPanel (suspendre/rétablir/redémarrer/vitesse/remplacer/retirer — écritures réseau live,
suspendre coupe le client) était monté SANS permission dans EquipmentDetail. Désormais gaté can('manage_settings') + sorti
du bloc olt_name (garde propre serial+olt_ip → visible même sans olt_name). Bouton « Supprimer cet équipement » aussi gaté.
(Panel déjà monté depuis un travail antérieur ; ici = gating + placement.)

Lane 1c (déployé via bundle, PlanificationPage co-édité non commité) : puces géofence live En route/Arrivé/Reparti sur les
cartes Jour (kanban) + lignes éditeur de journée, via l'endpoint /roster/geofence-states existant mais jamais appelé
(geofenceStates + refreshGeofenceStates hooké dans reloadOccupancy). Lane 1a : le picker lead exclut déjà le tech assigné
+ garde 409 « déjà assigné dans F » → re-pick déjà empêché (aucun code risqué ajouté).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 10:30:45 -04:00
louispaulb
c1a240023b feat(field-app): refonte accessibilité terrain (gros texte/contraste/cibles + flux guidé)
Priorité accessibilité (usage terrain, gants, luminosité variable — utilisable par une personne âgée / focus TDAH) :
- Type scale +, contraste renforcé (sous-textes #94a3b8→#aebccf), cibles tactiles ≥54-58px (action principale, boutons d'action, cartes).
- Liste : emphase du PROCHAIN arrêt (ruban vert + bordure/halo), compteur « X de Y terminé(s) », arrêts terminés atténués.
- Détail : NAV + action principale au-dessus de la ligne de flottaison — « 🧭 Démarrer l'itinéraire » (bouton bleu proéminent) puis « 📍 Je suis arrivé » (grand vert) + phrase d'aide selon l'état ; carte descendue en référence ; Street View en lien discret ; boutons Commenter/Photo/Appareil agrandis (icône+libellé empilés).
- focus-visible + micro-feedback tactile (reduced-motion respecté).
Vérifié live (Playwright mobile, token tech réel) : liste + détail rendus, carte OSM/MapLibre (0 Mapbox).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 10:23:19 -04:00
louispaulb
5dd38eb79b feat(gps): vue « appareil GPS → technicien » (assignation inverse) + non-associés
Avant : on associait un GPS à un tech UNIQUEMENT depuis le popover du tech (vue Semaine). Ajout du sens inverse demandé :
partir de l'APPAREIL. Hub roster.js :
- GET /roster/traccar-devices : roster enrichi (appareil + tech(s) assigné(s) + unassigned + duplicate + dernier signal),
  croise Traccar getDevices × Dispatch Technician.traccar_device_id. Vérifié live (45 appareils, ex. #4 → Benjamin Djanpou).
- POST /roster/traccar-device-assign {device_id,tech_id} : assigne un appareil→tech en LIBÉRANT d'abord tout autre tech qui
  le détient (le champ vit sur le tech → sinon 2 techs pointeraient le même device ; le doublon est aussi signalé dans la vue).
SPA : GpsDevicesDialog.vue (q-table statut/appareil/dernier signal/technicien via TechSelect + filtre « non associés »),
ouvert par un bouton « Appareils GPS » dans la barre Tournées de Planification. api/roster : listTraccarDevicesRoster + assignTraccarDevice.
PlanificationPage (bouton + mount) = co-édité → déployé, non commité.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:42:32 -04:00
louispaulb
48faeba72b fix(dispatch): coords job EXACTES (fibre par service) + hardening distances auto-dispatch
Précision coords (le levier amont unique : alimente géofence + matrice OSRM + solveur, tous lus à chaud) :
- fibreByDelivery(deliveryId) : AUTORITÉ #0 = le DROP d'installation exact via ticket.delivery_id → service → fibre.*_service_id
  (placemark d'infra si lié, sinon coord fibre). Câblé dans buildJob (avant resolveDevCoords) ET geolocateJobs (refreshFibre,
  seuil de réécriture 30 m car drop exact). Le tick horaire l'applique tout seul désormais.
- BACKFILL exécuté : 15 jobs actifs corrigés (14 fibre_service_placemarks) — dont LEG-247012 (7,5 km !) et LEG-254147 (14 km) qui
  étaient grossièrement mal géocodés → snappés au drop exact. Idempotent (re-run 0).

Hardening auto-dispatch (audit) :
- osrmMatrix : un nœud SANS coords n'est plus à 0 min de tout (« adjacent gratuit » → insertion n'importe où) mais à 180 min
  (« loin/inconnu ») → le solveur ne chaîne plus gratuitement les jobs sans coords au milieu des vrais arrêts.

Restent des jobs sans source coord (ex. lots camping Lac des Pins sans ligne fibre) = data gap F, pas résolvable ici.
Suivi recommandé (non fait, flag) : valider bornes QC sur les chemins de LECTURE (pas que l'ingestion) ; retirer les défauts
tech silencieux (Montréal/Ste-Clotilde) ; sortir les jobs sans coords du solveur vers le pool « à situer » ; invalider le cache
matrice SPA quand les coords changent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:36:08 -04:00
louispaulb
16da087e18 fix(geofence): arrivées manquées — passe TRACE rétrospective + dwell persisté + rayon 250m
Diagnostic (Nathan Morrisseau 2026-07-18) : 3 jobs où le tech était à 8-13m pendant 37-166 min sont restés « En route »,
jamais « Arrivé ». Cause : le scan live n'accroche « on_site » que si le hub tourne 3 min EN CONTINU avec le tech dans le
rayon, et le compteur de séjour (enterMs) était en mémoire, persisté SEULEMENT à un changement d'état → tout redémarrage du
hub le remettait à zéro. 18/07 = 0 arrivée pour TOUTE la flotte (seul jour), alors que « en route » (1 tick) s'accrochait.

Fixes (les 3 demandés) :
- FIX 1 dwell persisté : runScan marque le store 'dirty' quand enterMs change → survit aux redémarrages.
- FIX 2 passe TRACE rétrospective : reconcileFromTrack(date) rejoue l'historique GPS Traccar complet du jour (traccar.getHistory
  ajouté) et DÉRIVE arrivée/départ → insensible aux redémarrages/ticks manqués/bursts. Planifiée /15 min sur le jour courant
  (scan live conservé pour la réactivité UI) + route GET /roster/geofence-reconcile?date=&dry=1 (backfill/diag).
- FIX 3 rayon 150→250m (+ sortie 230→350m), aligné sur l'arrivée-auto de l'app terrain → couvre le géocodage rural imprécis.

Vérifié live : backfill 18/07 → LEG-254929/255009/255042 = Arrivé+Reparti (source track), idempotent (re-run 0/0), today OK.
Restent en_route : LEG-254770 (187m, séjour <3min) + LEG-255029 (579m, coords à corriger) = imprécision géocodage, pas géofence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:17:31 -04:00
louispaulb
9cb9739d3f feat(maps): zéro Mapbox (app terrain → MapLibre/OSM) + meilleur trajet trafic
- field-app.html (app tech) : carte Mapbox GL → MapLibre GL + tuiles raster OSM (aucun jeton). C'était le DERNIER Mapbox vivant.
- NOUVEAU « 🚀 Meilleur trajet du jour (trafic en direct) » : ordre optimal des arrêts via NOTRE OSRM /table + plus-proche-voisin (le plugin /trip n'est pas compilé), puis handoff à Google Maps multi-arrêts → trafic EN DIRECT gratuit (OSM seul n'a pas de trafic). Endpoint hub GET /field/route?t=&from=lat,lon.
- config/erpnext.js MAPBOX_TOKEN vidé + JobEditModal (module mort) : vignette statique api.mapbox.com → lien OSM. Bundle SPA désormais SANS jeton Mapbox ni api.mapbox.com (vérifié).
- serveFieldApp : retrait de l'injection du jeton Mapbox.

Vérifié live : field-app rendu = MapLibre (0 api.mapbox.com) ; OSRM /table Ok ; ordre A→C→B correct depuis origine ouest ; /field/route 200. Reste (co-édité, déployé non-commité) : tooltips PlanificationPage « Mapbox »→« OSRM/OSM » + retrait carte réglages Mapbox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:01:00 -04:00
louispaulb
218bef8c5a refactor(olt-ops): dédup plomberie n8n + effets pilotés par le spec ACTIONS
- extrait n8nRequest(verb,url,body,timeout) partagé par dostuff() et wifiClients() (fin de la double implémentation https-GET-with-body)
- effets déplacés dans ACTIONS[action].effect → supprime l'échelle de if dans plan()
Comportement préservé (vérifié live : plan tech-3 suspend identique, gardes run inchangées). Déployé prod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 07:38:48 -04:00
louispaulb
23dde4ecf0 feat(ops): native Payment Method/Arrangement + VoIP Line sections + G3 Raisecom SNMP writes
Native detail sections (fin de la grille+desk pour les derniers doctypes cliquables de la fiche client) :
- PaymentMethodDetail / PaymentArrangementDetail / VoipLineDetail — LECTURE SEULE (aucune charge/paiement déclenché ; mot de passe SIP jamais affiché). Enregistrés dans SECTION_MAP.

Phase 2 G3 (parité écriture fibre tech-2 Raisecom, ~10 500 ONUs) :
- olt-ops.js : écritures SNMP natives (net-snmp) suspend/rétablir (VLAN 40↔666) / redémarrer / vitesse (line-profile), OIDs EXTRAITS DU DRIVER F raisecom_rcmg.php (vérifiés à la source) + SAVE 8886.1.2.1.1.0 après chaque set. Encodages olt_id (slot*1e7+port*1e5+ontid) et onu_id ((slot+32)*8388608+port*65536+ontid) — round-trip vérifié. Repli coords ONU depuis le poller SNMP live (olt-snmp.getOnuBySerial) car les coords Raisecom ne sont pas dans Service Equipment. Gaté : env OLT_RW_COMMUNITY requis (absent → run refuse proprement, 0 set), confirm+idempotencyKey obligatoires. Vérifié live : plan tech-2 refuse sans coords/RW ; run refuse ; 0 écriture réseau tirée.
- OnuActionsPanel : ajout « Redémarrer ».

Déployé prod (hub olt-ops + SPA). DetailModal SECTION_MAP (5 entrées ajoutées) = déployé mais co-édité → non commité.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:39:59 -04:00
louispaulb
3d4700decf feat(ops): native Dispatch Job/Communication detail + ONU write parity (Phase 1-2)
Phase 1 (détail natif partout, fini le desk ERPNext) :
- DispatchJobDetail.vue : vue NATIVE du Dispatch Job (assemble les modules partagés : carte/tracé, géofence, photos terrain, durée, compétences, équipe+assign 409-aware, fil+réponse) → SECTION_MAP['Dispatch Job']. Répond à la plainte « le job tombe sur la grille générique + bouton desk ».
- CommunicationDetail.vue : vue NATIVE d'une Communication (courriel/appel) → SECTION_MAP['Communication'] ; HTML assaini.
- 3 pages orphelines (Réseau/GPON, Téléphonie SIP, Flux agent IA) surfacées en nav gatée view_settings ; OCR rattaché à Factures fournisseurs (bouton Numériser) ; stubs « Bientôt » retirés de Rapports.

Phase 2 (parité écriture fibre — remplace le « Do Stuff » de F) :
- lib/olt-ops.js : plan(lecture seule : résout OLT/slot/port + payload n8n exact + effets + avertissements) / run(fire le verbe n8n dostuff PUIS miroir Service Equipment). Idempotent (clé, fenêtre 10 min), confirm obligatoire, tech-3 seulement (tech-2 Raisecom → SNMP natif = G3 à venir). + wifi-clients (G1).
- OnuActionsPanel.vue : suspend/rétablir/forfait/remplacer/retirer/activer en plan→confirm→run, monté dans EquipmentDetail.

Backend déployé+vérifié live (plan/run/guards/auth : plan 200 résout EQP réel, run sans confirm→refus, sans auth→401). SPA déployée /opt/ops-app. AUCUNE écriture réseau réelle tirée (run = à déclencher par un humain via le bouton).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:04:19 -04:00
louispaulb
cd25fa2504 fix(dispatch): double-assign F + médias terrain dans le ticket + deep links natifs
- capacityByDay compte les jobs assignés SANS heure (untimed_used_h au prorata AM/PM/Soir)
- /roster/assign-job : garde 409 si le ticket F est déjà à un autre vrai tech (force pour écraser) ; SPA = dialog confirm
- mirrorAssign : start_time depuis F due_time (am/pm) + fix due_date (cur.→r.) + backfill fill-only dans watchLegacy (97% des tickets F = 'day' → le spread capacité est le fix porteur)
- pushAssignments : garde anti-clobber (conflits F skippés, force=1) + compteur conflicts
- ticket-collab : courriels de notification → deep link OPS /#/tickets?open= (fini le desk) ; TicketsPage lit ?open= ; Dashboard ouvre le ticket précis
- DetailModal + SupplierInvoices : lien desk gaté can('manage_settings')
- NOUVEAU JobMediaModule (photos tech géo-vérifiées + journal terrain) monté dans IssueDetail onsite + volet Planif ; hub /roster/job-media + /field/photo-file signé ; JobThread poll silencieux 45s
- legacy-sync emailList : strip caractères invisibles (soft hyphen U+00AD…) — fixait un 417 InvalidEmailAddress en boucle (compte 15988)

Déployé prod 2026-07-18 (hub restart + SPA /opt/ops-app), sondes live OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:02:37 -04:00
louispaulb
ef7b51399d refactor(planif): « Bloquer du temps » = UN job/ticket qui occupe la timeline, priorité = curseur dur/souple
Suite retour Louis : plus de mode soft/hard (toggle du commit précédent retiré). Un
seul concept — un job/ticket interne qui OCCUPE la timeline (formation · réunion ·
projet · entretien) → dé-priorise le tech au dispatch. La PRIORITÉ est le curseur :
Moyenne/Basse = les urgences/réparations peuvent quand même prendre le tech ; Urgent
(high) = bloc dur, non traversé.

Front : dialogue unique « Bloquer du temps » (Intitulé · journée complète · date ·
début · durée · Priorité [défaut Moyenne] · ticket optionnel), info adaptée à la
priorité. Un seul submit `doBlockTime` → /roster/filler-job. Retiré : bascule de mode,
branche réserve, `roster.reserveTech`. Les 2 points d'entrée (bouton volet réglages +
menu de cellule) → même dialogue, relabellés « Bloquer du temps… ».

Hub (dispatch.js suggestSlots) : la traversée urgence passe du flag `job_type ===
'Réservation'` à la PRIORITÉ — `isSoftBlock` = bloc interne (Interne/Réservation) de
priorité ≤ moyenne → traversé en mode urgent ; high résiste ; les vrais jobs client ne
sont JAMAIS traversés (pas de double-booking). Ajout du champ `priority` au fetch.
(/roster/reserve laissé en place, inutilisé — retrait ultérieur.)

Vérifié : test unitaire pierce (medium/low traversé · high résiste · job client jamais)
+ aperçu (dialogue unique, priorité Moyenne, info souple, pas de toggle). Build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:38:17 -04:00
louispaulb
ba6967adc6 refactor(planif): fusion « Réserver du temps » + « Bloquer / job générique » en UN dialogue
Deux dialogues faisaient la même chose (rendre un tech indisponible pour un bloc)
avec des champs ~80% identiques, des points d'entrée différents (bouton volet
réglages vs menu de cellule) et aucune explication de leur différence → lus comme
un doublon. Seul vrai écart : bloc DUR (hors dispatch, ticket optionnel) vs réserve
SOUPLE (dé-priorise, urgences passent).

→ UN dialogue « Réserver / bloquer du temps » avec bascule de MODE (segmented
control : « Hors dispatch (bloc) » / « Urgences OK (souple) »). Champs communs
(tech · date · début · durée · journée complète · motif/intitulé) ; priorité/type/
ticket seulement en mode bloc. Soumission `doBlockOrReserve` → /roster/reserve OU
/roster/filler-job selon le mode (endpoints inchangés). Les 2 points d'entrée
(bouton « Réserver / bloquer… » du volet réglages [souple par défaut] · item de menu
de cellule [bloc par défaut, pré-rempli]) ouvrent le MÊME dialogue.

Supprimé : resvDlg + doReserve (le dialogue « Réserver » séparé). Aperçu vérifié :
bascule de mode montre/masque priorité/type/ticket + change libellé/info/bouton.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:52:12 -04:00
louispaulb
9c1b578d5d feat(dispatch): provisioning identites enrichi Employee+Authentik (multi-courriels)
extEmailIndex(): index nom->emails depuis ERPNext Employee (company/prefered/
personal/user_id) + Authentik /core/users. provisionIdentities l utilise pour:
- CREATE des identites d ex-staff sans courriel legacy (si une source externe en a un)
- ENRICH: tous les courriels decouverts s accumulent en alias_emails (union,
  jamais overwrite) -> multi-courriels par personne, resolvable par chaque alias
- skip des comptes systeme (ticket dispatch, Gestion Inventaire, Tech Targo)
Applique en prod: +1 lien, +5 identites enrichies d alias (perso+corpo). Idempotent
(re-run 0 ecriture). Les 41 restants = ex-employes absents de toute source ->
mapping manuel via l onglet Identites (liste unresolved du rapport).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:50:23 -04:00
louispaulb
e8fd69c6f8 feat(planif): pause indéfinie STAGÉE → visible tout de suite, commise au Publier (retire quarts futurs + relève jobs)
Une mise en pause indéfinie ne s'applique plus immédiatement : elle est mise en
attente (pendingPause, localStorage — comme les congés), prend effet VISUELLEMENT
(nom barré + puce « pause à publier » sur la rangée, compteur « non publié »), et
n'est COMMISE qu'au Publier — au cas où on se trompe.

- TechScheduleDialog : le toggle « Pause indéfinie » émet `stage-pause` (plus
  d'écriture roster directe) ; l'état reflète serveur OU en attente (prop pendingPaused).
- Parent onStagePause : stage/dé-stage ; réactivation d'un tech DÉJÀ en pause reste
  immédiate (Disponible + re-matérialisation du patron).
- Résumé de Publier (groupé par tech) : ligne « Pause indéfinie · motif · N quart(s)
  futur(s) retiré(s) » + chaque quart futur listé en retrait ; en-tête « · N pause(s) ».
- doPublishConfirmed : exclut les quarts futurs (aujourd'hui→) des techs en pause de la
  charge → publish-week les SUPPRIME ; applique le statut En pause ; puis relève des jobs
  via l'impact IROPS existant (checkAbsenceImpact). Quarts passés conservés ; patron
  récurrent intact (réactivation re-matérialise).

Vérifié en aperçu (client-only, RIEN écrit en prod) : Philippe Bourdon → puce +
nom barré ; résumé « Pause indéfinie · Arrêt maladie · 14 quart(s) futur(s) retiré(s) »
+ 14 retraits, à côté du quart de Nathan Morrisseau ; annulé + nettoyé. Build OK.
⚠️ Chemin de COMMIT au Publier (suppression quarts + pause + relève jobs) non exercé en
prod (écriture réelle) — réutilise publishWeek/pauseTechnician/checkAbsenceImpact éprouvés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:33:08 -04:00
louispaulb
e98b21ba0a feat(dispatch): provisioning identites depuis legacy staff (convergence ledger)
provisionIdentities({dryRun,scope}) : pour chaque staff du ledger, garantit une
identite canonique portant legacy_staff_id (+ tech_id si Dispatch Technician, donc
les admins restent non-tech). LINK (identite existante par courriel/nom -> remplit
legacy_staff_id) ou CREATE (depuis staff si courriel). Idempotent. Route
GET|POST /dispatch/legacy-sync/tech-history/provision-identities?scope=ledger|active.
Applique en prod: +33 identites liees a leur legacy_staff_id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:23:23 -04:00
louispaulb
60da7a01da feat(dispatch): tech-history resolue via identite canonique (dynamique)
Le rapport techHistory groupe desormais par IDENTITE (lib/identity resolveIdentity:
staff_id -> nom), calculee A LA LECTURE -> corriger une identite (onglet Settings
Identites) reclasse le ledger AUSSITOT (pas de valeur figee).
- is_tech = l identite a un tech_id : le staff admin (ex. Megane Liaud, sans tech_id)
  n est PLUS compte comme technicien.
- Sortie: par assignee {label,email,is_tech,kind,total,from_pool} + liste unresolved
  (noms sans identite, ex. prenom seul) a mapper dynamiquement.
- Detail ?tech= accepte cle identite | courriel | tech_id | staff_id | nom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:14:30 -04:00
louispaulb
947595f086 feat(planif): résumé de Publier complet + groupé par technicien
« Toute modification doit être résumée avant d'être publiée (au cas où on se
trompe). » Le résumé était incomplet et groupé par date :
- retraits de quarts INVISIBLES (pubSummary.removed codé en dur à 0) alors que
  le hub SUPPRIME au Publier tout quart absent de la charge (publish-week) —
  une suppression accidentelle passait donc inaperçue ;
- groupé par date, difficile de voir ce qui change POUR UN TECH.

pubSummary regroupe désormais PAR TECHNICIEN (avatar + nom) et couvre tout ce
que Publier va appliquer : quarts à publier (ajouts/modifs), quarts RETIRÉS
(diff contre l'instantané serveur du chargement, serverSet → capte les
suppressions), et congés/absences en attente. En-tête : « N quart(s) · R
retiré(s) · A congé(s) · L levé(s) ». Un tech sans changement n'apparaît pas.
(La garde a son propre bouton « Publier la garde » — hors de ce flux.)

Vérifié : repro logique (modif = −ancien/+nouveau, retrait, congés, tech
inchangé omis) + aperçu live (Josée-Anne : Congé 14/07 + Maladie 15/07 ;
Nathan Morrisseau : quart 8h–16h) — rien écrit en prod (annulé). Build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:06:05 -04:00
louispaulb
9d568986fc fix(planif): afficher les congés en attente dans le résumé de Publier
Le résumé de « Publier l'horaire » (pubSummary) était construit UNIQUEMENT à
partir des quarts non publiés (unpublished) et ignorait pendingAbs. Un congé
mis en attente (ex. Josée-Anne, congé indéterminé) allumait bien le badge
« non publié » ET était bien envoyé au Publier (flushPendingAbsences), mais
n'apparaissait PAS dans le dialogue de confirmation — on n'y voyait que le
changement de quart (Nathan Morrisseau). Le répartiteur ne pouvait donc pas
réviser le congé avant de confirmer.

pubSummary intègre désormais pendingAbs, groupé par date, avec compteurs
absAdded/absRemoved ; l'en-tête affiche « N quart(s) · M congé(s) » et chaque
jour liste les congés (event_busy orange = ajout · event_available teal =
retrait) à côté des quarts.

Vérifié (aperçu, client-only, rien écrit) : congé indéfini Josée-Anne 13-17/07
→ résumé « 1 quart(s) · 5 congé(s) », lignes Congé par jour à côté du quart de
Nathan Morrisseau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:29:31 -04:00
louispaulb
6761c9e05a feat(dispatch): ledger historique d assignation par tech (ops_tech_ticket_history)
Reconstruit la timeline complete d assignation des tickets depuis le CHANGE LOG
osTicket (ticket_msg: 'assign... change de X a Y') vers une table plate dans la
PG ERPNext (a cote de tabDispatch Job/tabIssue), incluant les tickets fermes.

- techHistoryBackfill(): scan watermarke borne de ticket_msg (I/O previsible),
  parse + mapping nom->staff->TECH-<id>, upsert idempotent. Backfill initial + tick
  incremental (un seul chemin) -> capte les nouveaux changements (les notres ET ceux de F).
- techHistory({tech}): rapport par tech (detail) ou recap tous techs.
- Routes: GET /dispatch/legacy-sync/tech-history[?tech=], GET|POST .../tech-history/backfill.
- Tick live planifie dans startSync (LEGACY_TECH_HISTORY, defaut on; LEGACY_TECH_HISTORY_MIN=15).
- Detecte 'de Tech Targo a <tech>' = sortie du pool vers un tech (from_pool).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:58:03 -04:00
louispaulb
e43f04d3e5 feat(dispatch): write-back unifie Publier vers F + tickets perimes natifs
Hub (legacy-dispatch-sync.js):
- publishPreview() + GET /dispatch/legacy-sync/publish-preview: apercu unifie
  OPS->F (assignation + fermeture + horaire) + avertissements tech non mappe
  = la liste des changements avant du bouton. Lecture seule.
- publishJob() + GET|POST /dispatch/legacy-sync/publish-job: apply par job
  (GET=plan dry-run, POST=applique) via ops_reassign.php.
- staleTickets() + GET /dispatch/legacy-sync/stale: tickets ouverts sans
  activite >=N j (natif, remplace le courriel stale de F); scope buckets/maxAge en SQL.
- staleNudge() + GET|POST /stale-nudge + digest planifie opt-in
  (LEGACY_STALE_NUDGE), anti-spam persiste, nudge via alertWebhook.

Bridge F (ops_reassign.php):
- action schedule: write-back due_date/due_time (horaire OPS->F).

Verifie en prod (lecture seule + dry-run; aucune ecriture F reelle tiree).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:49:28 -04:00
louispaulb
956e96b8bc chore(hub): sync repo with deployed hub state (uncommitted prod work)
Le hub deploye (/opt/targo-hub) avait derive du depot (deploiements scp
directs non commites). Rapproche la branche de la realite deployee AVANT
d empiler le nouveau travail:
- legacy-dispatch-sync.js: liens natifs source_issue/depends_on/parent_incident
  + backfills (deployes, jamais commites)
- ops_reassign.php: action post (note/reponse au fil, deployee)

Aucun secret (creds via ops_secret.php). Fichiers = copie exacte du deploye.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:49:17 -04:00
louispaulb
e0e1da1c01 fix(dispatch): lever le cap 50 sur les techniciens (occupation + charge)
techOccupancy et getTechsWithLoad récupéraient les Dispatch Technician avec
limit_page_length=50 alors que l'effectif est de 56. Les techs au-delà du 50e
(ordre ERPNext) étaient donc ABSENTS de l'occupation → dans le calendrier mois
de leur horaire (TechScheduleDialog), c.hasShift est piloté UNIQUEMENT par
l'occupation (shiftMap), donc leurs quarts créés en Semaine/Jour n'y
apparaissaient jamais (tout gris) — ex. Philippe Bourdon & co., alors que Simon
Clot-Gagnon (dans les 50) s'affichait bien. Même cap = mêmes techs invisibles
au dispatch/ranking (getTechsWithLoad). Cap porté à 500 (comme les requêtes
jobs/disponibilités voisines).

Confirmé live : roster/technicians = 56 (Philippe présent) vs dispatch/occupancy
= 45 sans Philippe. Les Shift Assignment existaient (fetchés limit 2000) mais ne
nourrissent que le menu de cellule, pas la barre d'occupation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 07:19:15 -04:00
louispaulb
c7c9f81364 fix(planif): Suggérer = jobs DUS le jour sélectionné · jour partagé entre vues (session) · quarts brouillon reflétés au calendrier mois
1) Suggérer (retour terrain, ex. ticket 254403 en retard auto-dispatché) :
   - fenêtre = EXACTEMENT le jour sélectionné ; les chips de date du pool
     n'élargissent plus jamais le dispatch (elles filtrent l'affichage)
   - suggestJobs filtré à la SOURCE (couvre solveur VRP + heuristique) :
     scheduled_date === jour sélectionné ; retards/sans-date exclus —
     seule exception : sélection manuelle (lasso)
   - libellés du dialogue mis à jour (« N job(s) DUS ce jour · retards exclus »)
2) Jour sélectionné PARTAGÉ (selDay) persisté en sessionStorage (planif_sel_day) :
   écrit par bande Tournées / Jour kanban / sélecteur de date / bande mobile /
   dialogue Suggérer ; repris au changement de vue (Jour ne retombe plus sur
   « aujourd'hui », Tournées suit, Mois ancre son mois dessus)
3) Quarts créés en Semaine/Jour → calendrier mois par tech + vue Mois :
   flush du brouillon (debounce 800 ms) avant l'ouverture du dialogue horaire,
   flush au passage en vue Mois, et refresh auto des calendriers ouverts quand
   une auto-sauvegarde se termine (techSchedRefresh / monthRefresh ;
   MonthOverview.refreshKey enfin câblé du parent)

Vérifié (aperçu 9001, lecture seule) : pool 44 jobs dont 8 en retard → dialogue
n'offre que les 2 DUS le jour choisi ; 22/07 suivi Semaine→Jour→Tournées ;
Mois affiche « Août 2026 » pour selDay=2026-08-05 ; build SPA OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:17:12 -04:00
louispaulb
48edede793 refactor(planif): désencombrer l'en-tête selon UX_REFACTORING_PROMPT (PL1/PL2/PL4/PL5/PL6)
- PL5 : sous-menu « Modèles de semaine » (menu-dans-menu) → dialogue dédié
  components/planif/WeekTemplatesDialog.vue (props données / emits save·apply·
  set-default·delete ; état + mutations restent dans la page, EmptyState réutilisé)
- PL2 : menu Outils 13 items + sous-menu → 11 items à plat ; retrait des 2 entrées
  redondantes avec la barre (« À assigner », vue Mois)
- PL1/PL4 : barre allégée — bouton ★ modèle par défaut déplacé dans le ⋮ (groupé)
  + ⌘K (planif-templates, planif-apply-default) ; HelpHint « Suggérer » retiré
  (l'explication vit déjà dans le bandeau du dialogue)
- PL6 : champ orphelin « Max h/sem » supprimé (ref jamais consommée = code mort)
- docs : statuts PL1/PL2/PL4/PL5/PL6 mis à jour dans UX_REFACTORING_PROMPT.md

Vérifié : build SPA OK · aperçu 9001 — Outils/⋮/dialogue exercés, chaîne emit
save → prompt parent OK, grille Semaine + Tournées intactes, 0 nouvelle erreur
console (les « Anchor: target null » préexistent au baseline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 20:56:34 -04:00
louispaulb
8e7838d1fb docs: prompt réutilisable de refactor UX (UX_REFACTORING_PROMPT.md)
Réécriture vérifiée du brouillon initial — 43 corrections issues d'un review
multi-agents contre le code réel @ 718698f :
- 16 règles UX calibrées au repo (chrome-only, CTA desktop top-right,
  Function-props, fr-CA, raccourcis clavier, états async, LTE, 44px, tokens)
- tableau des primitives canoniques (OverflowMenu, DisclosureSection, HelpHint…)
- audit anti-patterns avec colonnes Status+Anchor (repérage par nom d'état,
  pas par ligne) ; lignes déjà résolues marquées (T2, TechTasks, PL4/PL7)
- plan de décomposition PlanificationPage réaliste (décomposition #4 reconnue,
  vagues 1-2, règle script-mapping, pas de composable fourre-tout)
- Definition of Done (lint/build/preview + invariants DnD/SSE/MapLibre/Publier)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:27:51 -04:00
louispaulb
718698f0d0 feat(ops): gate ticket activation on pending subs + show consolidated proration preview
The "Activer + facture brouillon" button now appears ONLY when the ticket's
customer+location has En attente subscriptions (read-only /billing/activation-
preview) — defining which completed tickets trigger activation. The confirm
shows all services that would activate and the consolidated prorated total
(internet+tv+phone in one draft invoice). Preview writes nothing; commit gated
by PRORATION_WRITE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:24:27 -04:00
louispaulb
e1d3212bf7 feat(ops): ticket Statut control (Complété/Annulé) + sale activation → draft invoice
Add a "Statut" button on the ticket (distinct from the snooze "Reporter"):
Ouvert / En attente / Complété (Resolved) / Annulé (Closed). Additive — does not
touch the shared TicketStatusControl.

For a ticket with a linked open install job, an explicit "Activer + facture
brouillon" button (also offered when set to Complété) reuses the EXISTING hub
flow: POST /dispatch/job-status Completed → chain unblock → activateSubscription
ForJob → one consolidated prorated DRAFT invoice, gated by PRORATION_WRITE /
BILLING_APPROVAL_GATE (never auto-charges; billing manager edits/approves). No
new billing logic, no hub change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:04:08 -04:00
louispaulb
28a5789fe0 fix(ops): tickets list returned 0 — Issue query used fields ERPNext v16 rejects
An in-flight TicketsPage change added assigned_staff_email / opened_by_staff_email
to ISSUE_FIELDS, but ERPNext v16 refuses those in a `fields` query. The frontend
queries ERPNext directly (no bad-field stripping like the hub), so the whole Issue
list 400'd → 0 tickets for everyone. Removed the two rejected fields; UserAvatar
falls back to the assigned_staff name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:39:07 -04:00
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
d0f899cf6d feat(ops): staff console — surface dup accounts + per-user schedule deep-link
- Duplicate Authentik accounts now surfaced automatically: a "⚠ Doublons (N)"
  filter chip + the warning badge itself is clickable to clean up (the 7 dup
  people are status=ok, so they were hidden under the default orphan filter).
- "Horaire" button on rows with a tech reuses the SAME Planification schedule
  module (TechScheduleDialog: recurring template + congés + pause) via a
  /planification?sched=<tech_id> deep-link — no inferior rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:44:56 -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
cfcd7c04cc feat(ops/planif): add JobPool + useJobPool + RouteMap route/explode
Completes the jobs-to-assign pool unification whose PlanificationPage wiring
landed in 9c49054 (which referenced these files before they were committed):
- JobPool.vue: one shared component (floating/docked/mobile) replacing the 3
  divergent pool implementations
- useJobPool.js: shared filter/sort/group/badge composable
- RouteMap.vue: live tech icons join the explode/fan; single-tech real Traccar
  day-route layer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:30:36 -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
9145624949 feat(ops): unify tech skill/cadence editor + merge team tools
Extract the per-tech skill editor (chips ordered by priority + per-skill Score
★ + Cadence %) into shared components/planif/SkillCadenceTable.vue, used by BOTH
the click-a-tech popover AND the team tool — one template everywhere.

Rebuild "Équipe — cadence & coût" into "Équipe — compétences, cadence & coût":
each tech is an expandable row showing SkillCadenceTable (per-skill cadence) +
global cadence (default 100%) + cost. Operates on live tech objects so edits
persist via the existing handlers. Folds "Gérer les compétences" (tag catalog:
rename/recolor/delete) in as a collapsible section, and collapses the two menu
entries into one "Équipe — compétences · cadence · coût".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:57:01 -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
1552df3984 Campaign detail: fix customer link pointing to ERPNext instead of Ops
The recipient row's customer link was href="/#/clients/<id>" — with the app on
hash routing served under /ops/, a leading slash before # resolves against the
site root, dropping /ops/ and landing on ERPNext desk. Drop the leading slash
so it stays scoped under the current /ops/ path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:57:54 -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
cab1a9c94a Events: wire mass-send button + confirmation dialog into the UI
Frontend half of the campaign-integrated mass send: an "Envoyer à N
destinataire(s)" button in the send dialog's mass tab, gated behind a
confirmation dialog that shows the exact recipient count and channel before
firing. On success, links to the created campaign in /campaigns for tracking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:41:34 -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
3489576212 Planif: congés publish-required, menu de cellule partagé, vue Mois
- Absences (congé/pause) passent par le modèle local -> Publier (journal,
  undo, dirty badge) au lieu d'écrire directement au serveur. Applique à la
  grille ET au calendrier par tech (TechScheduleDialog).
- Fix capacité "48h" : l'estimation ignorait l'absence et le patron hebdo.
- Menu de cellule (Jour/Soir/Garde/Absent/heures) extrait en composant
  partagé PlanifCellMenu, réutilisé par la grille et le calendrier par tech.
- Raccourci "Jour" = 8-16 (pas 8-17) ; saisie rapide accepte "816".
- Nouvelle vue Mois (MonthOverview) : qui est en quart/absent par jour +
  couverture par compétence (heures requises vs heures en quart). Remplace
  l'ancien module "Demande - effectif requis par créneau".
2026-07-09 11:32:54 -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
893ac8cd4a Déclutter: primitives réutilisables + palette ⌘K + Planif épurée
Ajoute 3 primitives partagées pour standardiser le désencombrement
(philosophie 3 paliers : coup d'œil → survol → détail) :
- OverflowMenu.vue : « ⋮ » standardisé (une action primaire visible,
  le secondaire dans le menu).
- DisclosureSection.vue : section repliée par défaut (résumé + corps
  révélé), état persisté par utilisateur (useUserPrefs).
- CommandPalette.vue + useCommandPalette.js : palette globale ⌘K
  (actions rapides + pages + actions contextuelles de page + recherche
  clients/équipe), registre d'actions par page = escape hatch.

Câblage MainLayout : palette montée + raccourci Cmd/Ctrl+K + indice ⌘K
dans la recherche desktop.

Adoptions :
- RapportsPage : PageHeader + stubs « Opérations » repliés + rapports
  enregistrés dans la palette.
- DashboardPage : bloc admin replié (DisclosureSection) + PPA rétrogradé
  dans un OverflowMenu (anti clic accidentel) + actions dans la palette.
- PlanificationPage : le panneau « Jobs à assigner » n'ouvre plus au
  chargement ; alerte « N à assigner » par jour (clic → panneau filtré) ;
  undo/redo/refresh regroupés dans un OverflowMenu ; actions dans la palette.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 06:53:24 -04:00