The reminder previously always targeted everyone not yet registered as one
fixed group. Staff can now pick which engagement segments to include:
- reminderCandidates()/reminderSend() (events.js) accept an optional
`segments` filter (subset of sent/opened/clicked, matching the original
invitation's per-recipient status). `breakdown` always reports the FULL
unfiltered counts so the UI can show each checkbox's true count
regardless of what's checked; `candidates`/`count` reflect the filter —
what actually gets sent.
- New routes accept it: GET reminder-preview?segments=a,b and POST
reminder-send {segments:[...]}.
- EventsPage.vue Rappel tab: the flat breakdown chips became checkboxes
(all checked by default = prior behavior), with a live target count and
a client-side-filtered recipient preview.
- CampaignDetailPage.vue: new "Relancer les non-inscrits" button for any
event-type send — same checkbox/count/confirm flow in a compact dialog,
without leaving the campaign page. Targets that campaign directly (no
source picker needed, unlike the Events-page flow which can target any
past send for the event).
Verified live: segment-filtered preview against the real production
campaign (2,295 total non-registered -> 708 when limited to opened+clicked,
correctly excluding the 1,587 who never opened).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Our per-recipient status relies on (a) the SMTP 250 at send time and (b)
webhooks Mailjet pushes afterward — a missed/delayed webhook could leave a
recipient stuck on a stale status. This queries Mailjet's own Message API
(GET /v3/REST/message?CustomID=...) directly, using the CustomID we already
stamp on every send, and compares Mailjet's authoritative Status against
what we have locally.
- campaigns.js: mailjetLookupByCustomId() (reuses the SMTP API key/secret
for REST auth) + reconcileCampaignWithMailjet(id, {limit}), which
prioritizes failed/bounced recipients first, then samples the rest.
compareStatus() distinguishes a real problem from normal webhook lag:
Mailjet being "ahead" of us (e.g. already 'opened' while we still show
'sent') is fine; Mailjet having NO record of a message we think we sent,
or reporting success where we recorded 'failed', are flagged.
- New route: GET /campaigns/:id/mailjet-reconcile?limit=N.
- CampaignDetailPage.vue: "Vérifier avec Mailjet" button + results dialog.
Verified against the real production campaign right after deploy: sampled
20 live recipients, 0 discrepancies, confirming both the feature and our
existing bookkeeping are accurate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A hub restart kills sendEventCampaignAsync's in-memory loop mid-flight,
leaving a campaign stuck at status='sending' with recipients still
'pending' — this happened live today and required a manual server-side
fix. Building a proper resume path instead of relying on that:
- resumeCampaignSend(campaignId): re-reads channel/from/event_id from the
campaign's own params (nothing to re-enter), refuses if nothing is
pending or a send is already active, and re-invokes the same worker,
which skips already-'sent' rows — safe to call on a healthy campaign too.
- Anti-double-send guard (activeEventSends / tryReserveSend): reservation
now happens synchronously at the scheduling point (massSend/reminderSend/
resumeCampaignSend), not inside the deferred setImmediate callback —
closes a race where two near-simultaneous calls could both slip through
before either worker actually started.
- New route: POST /events/campaign-resume {campaign_id}.
- CampaignDetailPage.vue: "Reprendre l'envoi" button, shown only for
event-type campaigns stuck at status='sending' with pending recipients.
Verified live: used the new endpoint to resume the real in-progress "On
fête nos 20 ans" campaign after this exact deploy interrupted it — resumed
cleanly with zero duplicate sends.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New reminder engine (events.js): reminderCandidates(eventId, sourceCampaignId)
targets recipients from a past invitation send who haven't submitted an RSVP
yet — covering both "never clicked" and "clicked but didn't register" — while
excluding pending/failed/excluded rows. Returns a status breakdown (not
opened / opened not clicked / clicked not registered) for preview before
sending. reminderSend() creates+starts a new tracked campaign (reminder_of
linking back to the source), reusing the same send worker with an
isReminder flag.
- isReminder threads through inviteSubject (prefixes "Petit rappel — "/
"Reminder — ") and inviteEmailFestive (adds a 🔔 badge above the title);
exposed on invite-preview and test-send too so the look can be checked
before a real reminder blast.
- New routes: GET /events/:id/campaigns (past sends for the picker),
GET /events/:id/reminder-preview (count + breakdown + sample, no side
effects), POST /events/:id/reminder-send.
- campaigns.js: listCampaigns() summary gains event_id/type/reminder_of
(same read, no extra cost) and is now exported, so events.js can list an
event's past campaigns without loading each one in full.
- EventsPage.vue: new "Rappel" tab in the send dialog — pick a source send,
see the live count/breakdown/recipient list, confirm-to-send. Test tab
gains a toggle to preview the reminder look via a real test-send.
- CampaignDetailPage.vue: "Ouvert le"/"Cliqué le" columns on the recipient
table (shared by gift and event campaigns).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le nom de la file/règle était un q-select autosuggest dont les options (groupNames) sont vides →
il paraissait vide et il fallait Entrée pour valider une saisie. Remplacé par un q-input texte
libre (clearable) : on tape et on édite le nom directement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remplace les 2 listes déroulantes de modèles (figées, non éditables) par un éditeur d'intervalle
compact début → fin, un pour la semaine (soir/nuit) + un optionnel fin de semaine. Le modèle on_call
est créé/retrouvé au save (ensureGardeTpl). Quart qui chevauche minuit : si fin ≤ début → badge
« 🌙 +1j » automatique, durée = wrap +24h (ex. 19:00 → 07:00 = 12h). La bande de garde de nuit
s'affiche du soir jusqu'à minuit sur la case (cellBands clampé). editGardeRule ré-hydrate les
intervalles depuis les modèles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Quand on ouvre le menu contextuel d'édition de quart sur une cellule, la cellule reste maintenant
encadrée (contour bleu + fond léger) tant que le menu est ouvert → on voit clairement quelle cellule
on édite. Classe .cell-editing pilotée par menu.show + menu.tech/menu.day.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Un X sur une action de SUPPRESSION prête à confusion avec le X de fermeture d'un menu/fenêtre.
Toutes les commandes qui SUPPRIMENT/retirent un élément passent de icon="close" à icon="delete"
(corbeille) : WeeklyScheduleEditor (retirer quart), ConversationPanel (supprimer note),
PlanifCellMenu (retirer quart), FunctionCatalogDialog (supprimer rôle), TechScanPage (retirer
code-barres), EventsPage (retirer d'une liste/pool/programme/note), PlanificationPage (étape de
garde, deleteDayAsg ×2). Les 2 actions « retour au pool » (removeFromPush/removeFromDay) passent à
icon="inbox" (renvoi au pool — PAS une destruction). Les clears de filtre/champ libellés « Effacer »
restent inchangés (ce ne sont pas des suppressions d'élément).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Les inputs heure de la rangée de quart (.pcm-time) n'avaient pas de largeur → ils prenaient toute
la place et poussaient « total h » + ✕ hors du menu (scroll horizontal). Largeur fixe 58px + centré,
box-sizing border-box. La rangée début → fin + total + ✕ tient maintenant dans le menu 300px, comme
l'éditeur de préréglage. Override redondant .pcm-preset-editor .pcm-time retiré.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Un jour VIDE (ou une semaine future non planifiée) ne voyait aucun quart → le préréglage retombait
sur 8–16 générique. Désormais on APPREND la fenêtre la plus fréquente de chaque tech depuis ses vrais
quarts à chaque loadWeek et on la PERSISTE (localStorage roster-usual-win-v1). Sur un jour sans quart,
le préréglage propose donc le « quart normal le plus probable » du tech (ex. Houssam → 8–18), même
sur une semaine future. Priorité : perso enregistré > usuel appris > patron hebdo > 8–16.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Le chip « Jour »/« Soir » et le champ ✎ prennent d'abord le quart RÉEL affiché ce jour
(ex. Houssam 8→18 s'affiche « Jour 8–18 »), sinon le préréglage parent (usuel/perso), sinon défaut.
- Éditeur de préréglage : label sur sa propre ligne + inputs bornés → début → fin ET les boutons ✓/✕
tiennent maintenant dans le menu 300px (avant : seule l'heure de début était visible).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le préréglage « Jour » n'est plus le générique 8–16 : par défaut = la fenêtre la plus probable
du tech (la plus fréquente de son patron hebdo weekly_schedule, sinon de ses quarts réguliers
déjà assignés), ex. Houssam → 8–18. Toujours surchargeable via ✎ (perso enregistré prioritaire).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Vraie cause : les calques de cellule-jour (.ovl-warn triangle, .tl-fn-in rôle, .office-blk
« sans déplacement » = z-index 3 ; .drop-badge = 6) passaient PAR-DESSUS la colonne gelée
(.tech-col z-index 2) au défilement → ils recouvraient le nom du tech. Colonne gelée montée
à z-index 8 (en-tête 9) → elle recouvre désormais tout le contenu de cellule qui glisse dessous.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`.ovl-warn` (triangle surcharge) et `.offshift-warn` vivent hors de `.tl` (enfants directs
de `.cell`, sans overflow) → ils débordaient. `.cell { overflow:hidden }` les confine comme
les blocs de quart. `.drop-badge` repositionné à top:1px (au lieu de -8px) pour survivre au clip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Régression du lot précédent : `.tech-col { width:360px; overflow:hidden }` coupait l'œil
quand le contenu dépassait 360px (ex. groupe « Vente porte-à-porte », flex-shrink:0 sans max-width).
- retire la largeur fixe + overflow:hidden sur .tech-col (la colonne s'ajuste, l'œil reste visible)
- .grp tronque (max-width 90px + ellipsis, rétrécissable) → ne pousse plus l'œil
- .hide-eye épinglé (flex-shrink:0) au bout de la rangée
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug Gigafibre.ca — corrigés/ajoutés :
- P1 réassignation « Non assignés » (le menu placeholder n'exige plus l'union des compétences → assignable à la main, ⚠ par job)
- P2 tech en congé/vacances exclu du pool de dispatch (garde suggestUnavailable + pastille congé)
- P3 cadence MASQUÉE par défaut (tous 100 %), révélée seulement si nettement plus lent/rapide
- P5 l'auto-save (draft) ne supprime plus un quart PUBLIÉ (cause du « publié puis à republier »)
- P6 récap jobs simplifié (type·ville·rue·AM/PM·#) dans la modale Techniciens disponibles
- P8 plages perso Jour/Soir PAR TECH (presets personnalisables, ex. 7:30–17:30)
- A1 re-clic sur un tech (q-menu no-parent-event) · A4 type de job au survol · A5 « Vider » visible
- A3 modem/ONU/remplacement → réparation (skill-resolver = source unique ; copie legacy périmée supprimée)
- A6 clip colonne Ressource + label de rôle (barre de défilement horizontale proportionnelle)
- A7 techs par compétence (survol) · A8 « Donner à » trié par compétences en commun
Note : ces fichiers portaient déjà des changements déployés-non-commités ; ce commit les RÉCONCILIE
avec le lot ci-dessus (co-édition — cf. reference_coedit_uncommitted_audit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unify the wireless (airOS) device view onto the shared statCards grid — signal
(colored + meter), signal côté borne, CCQ, débit lien TX/RX, capacité airMAX,
SSID, fréquence, uptime, gestion, point d'accès (link) — same glanceable cards as
fiber, replacing the old chip row.
"See what's happening" — the three things F shows, now in OPS:
- actual traffic: live eth0 sparkline (already present)
- DHCP leases = the devices behind the CPE: ops_airos.php mode=leases SSHes the CPE
(port 2194) -> cat /tmp/dhcpd.leases -> {mac, ip, host, vendor(OUI), remaining};
hub airosLeases + GET /collab/airos-leases; UI renders an on-demand "Appareils du
client (DHCP)" table with refresh.
- wifi/wired clients = those leases.
Verified: leases for Bryson CPE -> Cisco-Linksys 172.31.1.19 (OUI vendor resolved),
through the hub too. Build clean, leak 0; deployed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Redesign the device hero into a glanceable card grid — one card per key metric,
plain-language hints, color = health. Driven by a merged statCards model (live OLT
dostuff > GenieACS), so it works for both ACS-covered and tech-3 (no-ACS) ONUs;
each card renders only when its data exists.
Cards: Signal fibre (colored value + healthy-range meter + word: Bon/Faible/…),
Uptime, IP publique, Appareils (Wi-Fi/filaire breakdown, click → device list),
Réseau maillé (satellites), Gestion (GUI link), Téléphonie/VoIP, Télé/IPTV,
Distance, Profil de ligne, Firmware, Dern. coupure. Replaces the old dense tiles
in both the ACS topo card and the no-ACS fiber hero.
Build clean, leak 0; deployed. Design previewed with the Imagine widget first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fiber twin of the wireless-MAC issue: when an ONT is replaced, F's `fibre` table
holds the current serial + OLT coords, but OPS keeps the old serial → dostuff
queries a serial no longer on the OLT → shows "offline" for an online customer
(C-LPB4: OPS had TPLGC4160688/ontid 0; F fibre id 13890 = TPLG55684520/ontid 2).
- hub: fibreById() reads F `fibre` by the SE's legacy_fibre_id (hub reaches
gestionclient); reconcileFibreOnu() aligns serial_number/gpon_serial/olt_ip/
slot/port/ontid; fibreLive({equipment}) reconciles THEN dostuffs with the current
serial. GET /collab/fibre-live, POST /collab/fibre-sync, and syncFibreOnus()
(park-wide, batched) folded into the daily 03h tick alongside wireless.
- ops: the fiber hero + "Do Stuff en direct" now call /collab/fibre-live (auto
reconcile on load), reflect the corrected serial locally, and show "ONU
synchronisée depuis F (remplacement)".
Verified: fibreLive(EQP-0000011366) auto-corrected TPLGC4160688 → TPLG55684520
(logged), and dostuff on the current serial = online (Rx -14.39 / Tx -13.78, bon,
uptime 1h17). Build clean, leak 0; deployed index.d652a898.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fibre ONUs on unpolled OLTs (tech-3 XGS-PON, e.g. olt3/172.17.192.6) have no
GenieACS `device`, so the modern topo card AND the "Do Stuff en direct" block —
both gated on `device` — rendered nothing, leaving a stuck "Chargement ACS…", a
stale "Unauthorized" modem-diag, and static titles. But dostuff gives full live
status for these ONUs.
- New modern fiber hero (v-if="isFibre && !device"): topology strip (Internet→OLT→
ONT) + online/offline pill + colored Rx/Tx signal + distance/uptime/mgmt(link)/
WAN/VoIP/IPTV/firmware tiles + copy-for-ticket + refresh — driven by the live OLT
read (dostuff), auto-loaded on mount (2.5s grace for ACS to fill `device` first,
so ACS-covered ONUs keep their native card; no double fetch).
- Suppressed the misleading stuck "Chargement diagnostic ACS…" for fibre (hero owns
the state) and confirmed the heavy Playwright modem-diag stays v-if="false".
Verified: build clean, leak 0; dostuff on the shown ONU (TPLGC4160688 @ 172.17.192.6)
returns live state (Hors ligne, uptime 2d12h, mgmt/WAN/VoIP/IPTV) → hero populates.
Deployed index.42eea809.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fast page-load status (no slow status.cgi): ops_airos.php gains a `mode=status`
path that returns RADIUS-only state (online, since, current MAC, AP IP) in ~ms.
Hub airosStatus() + GET /collab/airos-status; EquipmentDetail loads it on mount for
wireless → "● En ligne depuis 9h · IP …" badge (with MAC auto-sync on this call too).
Daily park-wide reconcile (03h ET): ops_airos.php `bulk=1` returns every wireless
service's current radacct MAC in one call (2 queries + merge); hub syncWirelessMacs()
indexes Service Equipment by mgmt IP and updates only changed MACs. Scheduled via a
/30-min tick that fires once in the 03h ET window (WIRELESS_MAC_SYNC=off to disable),
started lazily on first request (not at require → no timer in CLI/tests). Manual
trigger: POST /collab/airos-mac-sync.
Fixed erp.update success detection (returns {ok:false}, never throws) in autoSyncMac
+ syncWirelessMacs — counts are now accurate. Verified: reconcile bulk 351 / checked
333 / updated / errs=18 = pre-existing broken service_location links (flagged
separately, non-blocking). Deployed hub+F+SPA (index.5444d59b); leak 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MAC updates now flow into OPS automatically. On any signal read (wireless panel
OR the assistant's check_signal), the hub's airosSignal resolves the Service
Equipment record and, if the RADIUS-authoritative MAC (radacct.callingstationid,
auto-updated on WPA2 re-auth) differs from the stored mac_address, it updates
Service Equipment server-side — idempotent, fill/correct, no click.
UI shows a green "MAC synchronisée automatiquement depuis RADIUS (était …)" note
and reflects it in the Identity field; the manual button remains only as a fallback
if the auto-update can't run. Manual serial/MAC edit (InlineField) still available.
Verified: corrupted EQP-0000100001 mac to 00:00:00:00:00:00 → one airosSignal read
self-healed it to RADIUS 24:5A:4C:30:CE:AA (logged). SPA deployed; leak-check 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RADIUS MAC (user's ask — auto-follows equipment swaps): ops_airos.php resolves the
service's radius_user (device→service) and reads the latest radacct session →
callingstationid = current CPE MAC (auto-updated on WPA2-RADIUS re-auth) and
nasipaddress = the AP IP (reliable — replaces the flaky radio-MAC guess). Verified
on Bryson: RADIUS shows the current MAC and a *different* MAC 2 days prior (proving
the swap was auto-tracked). EquipmentDetail flags a RADIUS≠stored MAC mismatch with
a 1-click "Mettre à jour" (updateDoc) — manual serial/MAC edit (InlineField) stays.
The RADIUS DB isn't reachable from the hub (no GRANT) → done via the F bridge.
Live client-ethernet throughput (F parity): ops_airos returns eth0 rx/tx byte
counters + ts; EquipmentDetail's wireless panel gets a "Débit en direct" toggle that
polls every ~6s (self-rescheduling, no overlap) and draws ▼download/▲upload
sparklines (eth0 tx=download, rx=upload). Verified live: ↓8.0/↑1.9 kbps on Bryson.
Also: AP link now uses the reliable nasipaddress; signal tiles keep interval coloring.
Deployed (hub + F bridge + SPA index.27375132); leak-check 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backfill (scripts/backfill-wireless-equip.js): mirror F wireless CPEs
(airos_ac/airosm/cambium) into OPS Service Equipment, resolving customer +
service_location via device→service→delivery_id→Service Location(legacy_delivery_id),
dedup by MAC-or-IP, serial=MAC when F sn is null, undo manifest. Park was already
~99% synced — created the 2 genuinely-missing (incl. James A. Bryson) + flagged
devices without service/MAC. Verified: Bryson EQP-0000100001 → live LiteBeam 5AC
signal -74/-72, airMAX 93/67.
Signal interval coloring (F parity): wifiSigColor (airOS: >=-60 green … <-78 red)
and fibreRxColor (Rx -8..-25 green, -25..-28 orange, else red) color the live
signal tiles (wireless + fibre). Assistant analysis (check_signal advice) kept.
Device + AP links in service details: CPE management GUI link (Interface →
https://ip) in the wireless panel; AP link when its mgmt IP resolves (ops_airos.php
resolves the AP by radio MAC in F's device table, best-effort → apIp/ap_ip), else
shows the AP MAC. Hub normalizeAiros passes apIp through.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Last F Do Stuff gap was wireless CPEs (signal, signal-AP, CCQ, TX/RX rate,
airMAX capacity). Empirically confirmed the hub cannot reach the CPE mgmt
network (all probes timed out) — so, exactly like the fiber TP-Link path,
this goes through n8n.
- hub: ticket-collab.airosSignal({serial|ip}) + GET /collab/airos-signal.
Resolves mgmt IP/login from Service Equipment, calls n8n webhook
get_signal_airos (mirror of F device_ajax/airos_ac_ajax.php: airOS auth +
status.cgi parse), normalizes signal/signalAp/ccq/tx-rx rate/airMAX
capacity/model/version/uptime. Degrades gracefully: 404 -> registered:false.
- ops: EquipmentDetail shows a "Do Stuff en direct (sans-fil)" panel for
wireless equipment (isWireless heuristic), rendering the same tiles + feeding
"Copier pour le ticket". Shows an "en attente" note until the webhook exists.
Verified: hub live returns {ok:false,registered:false} for a real CPE (SN-772);
SPA build clean, leak-check 0 secrets, deployed (index.c4ccc86f.js). The one
remaining hop is creating the n8n get_signal_airos workflow (spec in docs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Standardize the device-ops entry so users coming from F aren't lost:
- New shared DoStuffButton.vue (canonical "Do Stuff" label + icon): resolves
the device by serial>service_location>customer and opens the ONE device view
(EquipmentDetail). Reused in SubscriptionDetail (replaces bespoke button).
- DeviceStrip (fiche): tooltip hint + right-click "Do Stuff — ouvrir l'appareil"
(works even for devices without ACS data, e.g. wireless), so the term is
discoverable on the customer card too.
Bring the canonical device view to F's function set (device_view.php):
- On-demand "Do Stuff en direct (F)" fetch (/collab/dostuff) surfaces the fields
the passive poller lacks: VoIP, IPTV, distance, WAN IP, line profile, firmware,
mgmt GUI — reserved tech-3 XGS-PON, ~30 s, never auto (OLT load).
- "Copier pour le ticket" (F's copy button): plaintext device summary to clipboard.
Verified: SPA build clean, leak-check 0 secrets, deployed to /opt/ops-app
(index.969e330c.js live, 303508 B served, nginx.conf preserved).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Niveau 2 de Request 1 : à partir des traces GPS, détecte QUELS véhicules ont
séjourné sur un job, puis SUGGÈRE (jamais auto) :
- ASSISTANT : le véhicule du tech assigné était là ET un autre a séjourné → propose
d'ajouter le tech de cet autre véhicule comme assistant.
- MAUVAISE AFFECTATION : le véhicule du tech assigné n'a PAS été vu mais un autre a
fait la visite → le tech a pris un autre camion (mapping non mis à jour).
Garde-fous : séjour (pas passage), fenêtre du jour, garde MÊME-ADRESSE (un véhicule
présent pour SON propre job co-localisé n'est pas un assistant). Cache traces/jour + 1 h/job.
geofence.js : onsiteReview(job) + dismissOnsite ; roster.js : GET /roster/job/:name/onsite
+ POST .../onsite-dismiss ; api/roster : jobOnsite/onsiteDismiss ; GeofenceTimeline : bloc
suggestions avec [Ajouter comme assistant] (addAssistant) / [Assigner ce véhicule au tech]
(setTechTraccarDevice, si device non mappé) / [Ignorer] (dismiss) — confirmées, non destructives.
Mauvaise-affectation vers le véhicule d'UN AUTRE tech = ambigu → pas d'auto-bouton (flag + Ignorer).
Vérifié live : 45 devices ; LEG-255021 → « 37 - Anthony » = tech assigné, 0 suggestion (OK) ;
scan du jour : 31 jobs, 11 avec véhicule sur place, 3 mauvaises affectations détectées.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Demande user : une façon SIMPLE d'accéder au Do Stuff. Ajout d'un bouton « Do Stuff —
appareil » sur le détail du service (SubscriptionDetail), comme le bouton Do Stuff de F.
Résout l'appareil du service (Service Equipment par lieu de service, sinon par client ;
préfère un appareil actif avec n° de série) et navigate('Service Equipment', name) →
la fiche ouvre EquipmentDetail = la vue « Do Stuff » (signal Rx/Tx, débit WAN en direct,
actions device_action). Marche pour fibre ET sans-fil (l'appareil résolu s'ouvre ; les
stats airMAX sans-fil s'y afficheront quand leur lecteur sera branché).
Build spa OK, md5 match. Rendu live = fiche sous SSO (prod).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Demande user : garder l'historique du véhicule au moment du hit géofence — rester exact
même si le tech change de véhicule un autre jour. Les événements (en_route/on_site/departed)
enregistrent désormais deviceId+deviceName ; rec.device = véhicule du passage. runScan
(live) fige le device du fix courant ; reconcileFromTrack (rejeu trace) fige le device
rejoué ce jour-là. timeline() renvoie device ; GeofenceTimeline affiche « Véhicule : … ».
Vérifié : hub OK, timeline() expose device (null sur les 351 events antérieurs = normal ;
capturé pour les prochains passages). Build spa OK, md5 match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Demande user : afficher les véhicules Traccar avec les initiales du TECHNICIEN assigné,
et ne retomber sur les initiales du device Traccar QUE si aucun tech n'est assigné au véhicule.
- hub /roster/tech-positions : part désormais de TOUS les devices Traccar (pas seulement ceux
liés à un tech). Pour chaque device localisé : techName si un tech y est assigné (via
traccar_device_id), sinon deviceName ; renvoie techName + deviceName + name résolu (tech>device).
Exclut l'île nulle (0,0 = jamais localisé). Live : 23 positions (14 tech, 9 device-only).
- RouteMap : libellé marqueur = initials(techName || deviceName || name) (tech prioritaire).
- PlanificationPage dayLivePositions : les véhicules SANS tech s'affichent aussi (gris),
filtrés par visibilité seulement s'ils ont un tech.
PlanificationPage.vue = co-édité → DÉPLOYÉ (build) mais NON commité (hunk à isoler).
Vérifié : endpoint live OK ; build spa OK, md5 match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le module carte du job (JobMapModule, réutilisé volet Tournées + détail ticket) avait
un repère + tracé GPS Traccar mais AUCUN itinéraire in-app (seulement un lien Google
externe). Ajout d'un toggle « Itinéraire routier » : trace le trajet routier RÉEL via
l'OSRM auto-hébergé (roster.osrmRoute → geometry) sur la carte MapLibre + affiche km/min,
et cadre la vue sur le trajet. Départ = position live du tech assigné (traccarLive),
sinon géoloc du navigateur ; destination = coords du SERVICE (GPS de l'adresse de service,
désormais fiables). Lien « Navigation » Google conservé pour la voix sur téléphone.
Réutilise l'infra existante (OSRM /roster/osrm-route, MapLibre/OSM auto-hébergé, ZÉRO
Mapbox). Vérifié : build spa OK, 0 erreur HMR. Rendu live (fiche/ticket) = SSO côté prod.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remplace le 2e champ « rechercher un client » du chat par une liste d'options
que l'ASSISTANT propose DANS le fil (comme un menu de désambiguïsation). Plus
naturel, plus simple : on écrit une seule chose, et si le client est ambigu
l'assistant liste les candidats probables — un clic les envoie comme tour suivant.
Backend (staff-agent.js + agent-tools.json) :
- lecteur find_customer(query) → CANDIDATS via /collab/customer-search (réutilise
l'endpoint de l'ancien typeahead). 1 candidat → l'assistant prend l'id ; >1 →
il demande « lequel ? » sans réénumérer (l'UI affiche la liste).
- execToolPlan capte les candidats (find_customer + check_service ambigu) dans
ctx.options ; plan() renvoie { options } ; prompt : règle « identifier le client ».
Frontend (OrchestratorDialog.vue + MainLayout.vue) :
- retire le q-select entitySearch (+ la prop et le binding MainLayout) ; composer
= un seul champ. Rend d.options en q-list cliquable ; pickOption envoie
« label (id) » comme tour suivant ; options effacées à l'envoi/au reset.
Vérifié live (dev→hub prod) : « redémarre le modem du client Tremblay » → 1 champ,
8 options cliquables + « lequel ? » ; clic « Tremblay (Filtr-Aqua) Sylvain » →
tour « … (C-SYLVT…) » envoyé, options effacées, l'assistant continue ; 0 erreur console.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- OrchestratorDialog restructuré en vraie fenêtre de chat : carte à hauteur
fixe (76vh), en-tête + conversation qui défile + composer épinglé en bas.
On écrit SOUS la discussion (comme tout chat), l'input se vide à l'envoi,
auto-défilement vers le bas à chaque nouveau message. Confirmation du plan
déplacée en ligne dans le fil (plus de pied de page). État initial « Comment
puis-je aider ? » + exemples. Recherche client/adresse intégrée au composer.
- MonAujourdhui : « Allouer un technicien » → « Assistant » (icône auto_awesome),
ouvre le chat vierge ; visible aussi pour les rôles Boîte de réception.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Avant : l'assistant posait une question (« Souhaitez-vous que je crée… ? ») mais le dialogue était mono-coup — impossible
de répondre (pas de fil, chaque envoi repartait de zéro). Maintenant c'est une conversation :
- Backend staff-agent.plan(text, email, history) : accepte les tours précédents (user/assistant, 10 derniers) → contexte ;
POST /staff-agent {text, history}.
- OrchestratorDialog : fil de bulles (user/assistant), le champ se vide après envoi pour RÉPONDRE, historique renvoyé à
chaque tour, bouton « Envoyer » en mode conversation. Le plan (actions à confirmer) suit le dernier tour.
Vérifié : (backend) « vérifier signal C-LPB4 » → diagnostic (réparation, 12 techs/431h) ; « oui, crée » AVEC history →
propose create_job(C-LPB4, Réparation). (UI) 4 bulles [user,assistant,user,assistant], champ vidé, action « Créer un job »
affichée, 0 erreur console. Déployé.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
L'action ouvrait déjà le copilote Gemini (staff-agent) mais sans autocomplétion → il fallait taper le client exactement.
Ajout d'un typeahead d'ENTITÉ dans le chat (OrchestratorDialog, prop entity-search) : on tape → suggestions live
(nom · adresse · téléphone · courriel via /collab/customer-search) → le choix INJECTE l'entité RÉSOLUE « client <id>
(<nom> — <adresse>) » dans la demande, que Gemini extrait pour agir (proposer_rdv_client / assign_tech). Activé sur le
copilote STAFF (MainLayout entity-search) ; graine « Allouer un technicien : » (MonAujourdhui). Séparateur propre après « : ».
Vérifié en dev : « tremblay » → 8 suggestions → choix → prompt « Allouer un technicien : client C-SYLVT… (Tremblay…) » ;
endpoint /collab/customer-search OK (12 matches) ; 0 erreur console. Build OK, leak-clean, déployé.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Réconciliation de l'audit : isole et commite UNIQUEMENT mon travail déployé-non-commité dans 7 fichiers co-édités,
sans toucher au working tree (donc sans capturer le travail en cours des autres sessions, qui reste non commité) :
- server.js : SSE event 'ready' + retry:3000 ; routes /olt/onu/plan|run + /olt/wifi-clients.
- conversation.js : endpoint /conversations/my-inbox-counts (accueil : en attente / suivis / en retard).
- useConversations.js : armSSE (reconnexion+resync+repli poll 25s) + cleanup.
- PlanificationPage.vue : chips géofence (Lane 1c) + sélecteur « Générer l'horaire » (Move 3) + isLate (arrivée en retard).
- SettingsPage.vue : section « Thème & couleurs » (ThemeEditor).
- IssueDetail.vue + ConversationPanel.vue : props source-issue/customer/service-location/phone (« Proposer au client »).
EXCLUS (restent non commités, propriété d'autres sessions) : /service-location, /conversations/msg-visibility, retrait
champ Mapbox, fix conv-message 'belongs', hiérarchie parent_incident, JobMediaModule, etc. Vérifié : 0 marqueur étranger
dans le staged. Tout est déjà LIVE ; ceci n'aligne que le dépôt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Composant autonome créé lors de la consolidation Thème→Paramètres (redirect routeur /theme→/settings déjà commité en
23f0f72). Fichier 100% mien, sans mélange → seul élément sûr à commiter de l'audit ; le point de montage (import dans
SettingsPage.vue) reste non commité car ce fichier est co-édité (voir rapport d'audit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
L'accueil ne chargeait qu'au montage. Ajout : refetch toutes les 60 s + immédiat au retour sur l'onglet
(visibilitychange), avec nettoyage à onUnmounted. dash() conserve la valeur précédente pendant le refetch → pas de
clignotement vers « … ». Les compteurs « en attente de réponse / en retard / interventions » restent à jour sans
rechargement. Build OK, leak-clean, déployé.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enrichit la carte « Interventions aujourd'hui » (au-delà du simple découpage par statut) : croise l'heure de début
prévue avec l'état géofence live → pastille ROUGE « N en retard » quand le début est passé de > 20 min mais le tech
n'est PAS montré sur place (ni parti). Signal proactif de retard/no-show pour le répartiteur. Réutilise /roster/geofence-states.
Propre (composant seul, aucun changement hub). Vérifié en dev : rendu 3 cartes, aucune erreur console (0 job aujourd'hui
en dev → pastille masquée, correct). Build OK, leak-clean, déployé.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Le décompte « exact » a révélé que la métrique elle-même était faible : _assign ERPNext quasi vide ici (1726 « open »
tous non assignés). Corrigé pour refléter la RÉALITÉ, via /conversations/my-inbox-counts :
- Boîte : « en attente de réponse » = conversations actives dont le DERNIER message vient du client (212) + contexte
« N actives » — vraiment actionnable, vs « 540 total » ou « 1726 tickets ouverts ».
- Tickets sur moi : basé sur le SUIVI (le vrai « Mes tickets » d'OPS), pas _assign ; + « en retard » (ouvert > 48 h) en rouge.
Endpoint hub my-inbox-counts (conversation.js, déployé — co-édité, non commité ici). Vérifié en dev : 212 en attente / 540
actives / 0 suivis, 0 erreur console. Build OK, leak-clean, déployé.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Step 3/3. Avant : '/' = « Tableau de bord » réservé (requires view_dashboard_kpi) → un agent de service n'avait pas
d'accueil pertinent. Maintenant '/' = « Accueil » visible par tous ; l'accueil PERSONNEL (MonAujourdhui) s'affiche pour
chacun, et les sections GESTION sont gated dans la page :
- KPI (stats) + Administration + Activité récente → can('view_dashboard_kpi')
- Charge 2 semaines (occupation) → can('view_all_jobs')
Résultat : un CSR voit un accueil épuré (sa boîte / ses tickets) ; un gestionnaire garde le tableau complet.
Embarque aussi la consolidation déjà déployée que ces fichiers portaient : Équipe + Thème → Paramètres (nav retirée +
routes redirigées vers /settings).
Vérifié en dev : nav « Accueil » (plus « Tableau de bord »), sections gestion rendues pour superuser, MonAujourdhui présent,
0 erreur console. Build OK, leak-clean, déployé.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Step 1/3 des améliorations. AvailabilityByReason (dialogue partagé, monté dans le ticket ET la conversation) n'offrait
que « Trouver un créneau » (assigner soi-même). Ajout du 2e chemin — laisser le CLIENT choisir — là où le diagnostic se fait :
- Bouton « Proposer au client » → api proposeAppointment → hub /roster/book/propose : résout OU **crée** un Dispatch Job
ouvert (via createDispatchJob) puis envoie le lien /book par texto. Toast avec « Copier le lien » si pas de SMS.
- roster.js : /roster/book/propose crée le job ouvert si aucun (client ou sujet requis) + pose required_skill.
- Props de contexte passées par les 2 panneaux (source_issue/customer/service_location/phone) — édition additive des
panneaux co-édités = NON commitée ici (déployée via le bundle).
Vérifié : endpoint 404 attendu sans contexte ; « Proposer au client » compilé dans le bundle ; build OK, leak-clean, déployé.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applique « ne pas réutiliser une UX/métrique faible telle quelle » :
- Boîte de réception : remplace le compteur de session 'unread' (démarrait à 0, trompeur) par un VRAI décompte via
/conversations/inbox-tickets — « à trier » (non assignés) + total ouvert. Tickets sur moi = _assign contient mon courriel.
- Interventions aujourd'hui : ajoute l'ÉTAT TERRAIN LIVE (en route / sur place via /roster/geofence-states, pastilles
qui pulsent) en plus du découpage par statut.
- Barre d'ACTIONS réelles : « Allouer un technicien » → ouvre le copilote NL PRÉ-REMPLI (event ops:assistant → MainLayout),
« Nouvelle vente », « Rechercher ⌘K ». Le pont ops:assistant est ajouté dans MainLayout (propre).
- États chargement (« … ») / vide, focus clavier, prefers-reduced-motion.
Vérifié en dev : rendu, 199 à trier (donnée réelle), event allocate + palette OK, 0 erreur console. Build OK, leak-clean, déployé.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Concrétise « chacun voit ses outils, pas les 24 » : bandeau personnel dont CHAQUE carte est gated par capacité —
- Ma boîte (view_clients) : notifications non lues → /communications
- Tickets sur moi (view_all_tickets|view_own_tickets) : Issues ouvertes filtrées sur _assign = mon courriel → /tickets
- Interventions aujourd'hui (view_all_jobs) : Dispatch Jobs du jour groupés par statut → /planification
Salutation + date FR localisées (America/Toronto). Données via endpoints EXISTANTS (erp listDocs + cloche), filtrage
« à moi » côté client, dégradé gracieux (« — ») si indispo. Additif (monté au-dessus de OutageAlertsPanel), styles --ops-*.
Vérifié en dev : rendu + 3 cartes + salutation nommée + 0 erreur console. Build OK, leak-clean, déployé.
Reste (différé) : atterrissage par rôle au niveau ROUTAGE (les non-managers arrivent ailleurs que sur '/') + sources
« à moi » dédiées (non-lus par canal, mes jobs par tech) au lieu du filtrage client.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Même classe de bug que 187115c, sur deux flux SSE qui manquaient la reconnexion :
- TechTasksPage (app terrain) : AUCUN onerror → au restart du hub (502→CLOSED) le technicien perdait TOUT le
temps réel (nouvelles tâches, changements de statut) jusqu'au rechargement manuel. Ajout reconnexion 3 s + resync
loadTasks() à la RE-connexion (le hub ne rejoue pas les événements manqués).
- OutageAlertsPanel : au restart, tombait en repli poll 30 s et n'en sortait JAMAIS (ni retour temps réel, ni
resync immédiat). Ajout reconnexion 3 s + à la RE-connexion : arrêt du poll + fetchActive() pour rattraper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cause (diagnostic) : docker restart du hub à chaque déploiement détruit tous les clients SSE en mémoire. Les flux courriels/
cloche utilisaient un EventSource brut SANS onerror/reconnexion → le 502 pendant le restart les met CLOSED définitivement
(plus de reconnexion native) → morts jusqu'au rechargement manuel. Le dispatch se reconnectait mais ne resynchronisait jamais.
Fix (client) :
- useSSE : nouvelle option onReconnect() appelée à chaque RE-connexion (pas au 1er open) → resync après coupure.
- useNotifications (cloche) : reconnexion auto si le flux meurt (CLOSED → relance 3 s).
Côté co-édité déployé via bundle (non commité ici) : useConversations (inbox) = reconnexion + resync fetchList + repli poll 25 s
sur les 3 connexions SSE ; PlanificationPage = onReconnect → reloadOccupancy+reloadPool ; server.js = event 'ready' + retry:3000.
Hub 'ready' vérifié live. Reste (durabilité, différé) : persister _watchSnap pour rejouer les deltas Legacy manqués pendant la coupure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
- 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>